diff --git a/README.rst b/README.rst index 52db55c3c..f23376a35 100644 --- a/README.rst +++ b/README.rst @@ -9,12 +9,13 @@ images using various techniques via a uniform interface. By coordinate system to another (for example changing the pixel resolution, orientation, coordinate system). Currently, we have implemented reprojection of celestial images by interpolation (like -`SWARP `__), as well as by -finding the exact overlap between pixels on the celestial sphere (like -`Montage `__). It can also -reproject to/from HEALPIX projections by relying on the -`astropy-healpix `__ -package. +`SWARP `__), by the adaptive and +anti-aliased algorithm of `DeForest (2004) +`_, and by finding the +exact overlap between pixels on the celestial sphere (like `Montage +`__). It can also reproject to/from +HEALPIX projections by relying on the `astropy-healpix +`__ package. For more information, including on how to install the package, see https://reproject.readthedocs.io diff --git a/docs/celestial.rst b/docs/celestial.rst index 3df7794d3..907b25b49 100644 --- a/docs/celestial.rst +++ b/docs/celestial.rst @@ -28,7 +28,8 @@ reproject such data: This is more accurate than interpolation, especially when the input and output resolutions differ, or when there are strong distortions, for example for large areas of the sky or when reprojecting images that include the - solar limb. + solar limb. This algorithm also applies anti-aliasing, and ultimately + produces much more accurate photometry than plain interpolation. * Computing the **exact overlap** of pixels on the sky by treating them as **four-sided spherical polygons** on the sky and computing spherical polygon @@ -42,8 +43,9 @@ reproject such data: from coordinates on the sky to coordinates on the surface of a spherical body). -Currently, this package implements interpolation, adaptive resampling, and -spherical polygon intersection. +Currently, this package implements :ref:`interpolation`, +:ref:`adaptive resampling`, and +:ref:`spherical polygon intersection`. .. note:: The reprojection/resampling is always done assuming that the image is in **surface brightness units**. For example, if you have an image @@ -57,6 +59,11 @@ spherical polygon intersection. described below. In future, we will provide a convenience function to return the area of all the pixels to make it easier. + However, the :ref:`adaptive resampling` algorithm provides + an option to conserve flux by appropriately rescaling each output + pixel. With this option, an image in flux units need not be coverted + to surface brightness. + .. _common: Common options @@ -170,26 +177,57 @@ include: * ``'biquadratic'``: second order interpolation * ``'bicubic'``: third order interpolation +.. _adaptive: + Adaptive resampling =================== The :func:`~reproject.reproject_adaptive` function can be used to carry out -reprojection using the `DeForest (2004) +anti-aliased reprojection using the `DeForest (2004) `_ algorithm:: >>> from reproject import reproject_adaptive -In addition to the arguments described in :ref:`common`, the order of the -interpolation to use when sampling values in the input map can be controlled by -setting the ``order=`` argument to either an integer or a string giving the -order of the interpolation. Supported strings include: - -* ``'nearest-neighbor'``: zeroth order interpolation -* ``'bilinear'``: first order interpolation - -Additionally, one can control the calculation of the Jacobian used in this -algorithm with the ``center_jacobian`` flag. The Jacobian matrix represents how -the corresponding input-image coordinate varies as you move between output +This algorithm provides high-quality photometry through anti-aliased +reprojection, which avoids the problems of plain interpolation when the input +and output images have different resolutions, and it offers a flux-conserving +mode. + +Options +------- + +In addition to the arguments described in :ref:`common`, one can use the +options described below. + +A rescaling of output pixel values to conserve flux can be enabled with the +``conserve_flux`` flag. (Flux conservation is stronger with a Gaussian +kernel---see below.) + +The kernel used for interpolation and averaging can be controlled with a set of +options. The ``kernel`` argument can be set to 'hann' or 'gaussian' to set the +function being used. The Hann window is the default, and the Gaussian window +improves anti-aliasing and photometric accuracy (or flux conservation, when the +flux-conserving mode is enabled) at the cost of blurring the output image by a +few pixels. The ``kernel_width`` argument sets the width of the Gaussian +kernel, in pixels, and is ignored for the Hann window. This width is measured +between the Gaussian's :math:`\pm 1 \sigma` points. The default value is 1.3 +for the Gaussian, chosen to minimize blurring without compromising accuracy. +Lower values may introduce photometric errors or leave input pixels +under-sampled, while larger values may improve anti-aliasing behavior but will +increase blurring of the output image. Since the Gaussian function has infinite +extent, it must be truncated. This is done by sampling within a region of +finite size. The width in pixels of the sampling region is determined by the +coordinate transform and scaled by the ``sample_region_width`` option, and this +scaling represents a trade-off between accuracy and computation speed. The +default value of 4 represents a reasonable choice, with errors in extreme cases +typically limited to less than one percent, while a value of 5 typically reduces +extreme errors to a fraction of a percent. (The ``sample_region_width`` option +has no effect for the Hann window, as that window does not have infinite +extent.) + +One can control the calculation of the Jacobian used in this +algorithm with the ``center_jacobian`` flag. The Jacobian matrix represents +how the corresponding input-image coordinate varies as you move between output pixels (or d(input image coordinate) / d(output image coordinate)), and serves as a local linearization of the coordinate transformation. When this flag is ``True``, the Jacobian is calculated at pixel grid points by calculating the @@ -205,16 +243,27 @@ points. This is more efficient, and the loss of accuracy is extremely small for transformations that vary smoothly between pixels. The default (``False``) is to use the faster option. -Broadly speaking, the algorithm works by approximating the -footprint of each output pixel by an elliptical shape in the input image -which is stretched and rotated by the transformation (as described by the -Jacobian mentioned above), then finding the weighted average of samples inside -that ellipse, where the weight is 1 at the center of the ellipse, and 0 at the -side, and the shape of the weight function is given by an analytical -distribution (currently we use a Hann function). +Algorithm Description +--------------------- + +Broadly speaking, the algorithm works by approximating the footprint of each +output pixel by an elliptical shape in the input image, which is then stretched +and rotated by the transformation (as described by the Jacobian mentioned +above), then finding the weighted average of samples inside that ellipse, where +the shape of the weighting function is given by an analytical distribution. +Hann and Gaussian functions are supported in this implementation, and this +choice of functions produces an anti-aliased reprojection. In cases where an +image is being reduced in resolution, a region of the input image is averaged +to produce each output pixel, while in cases where an image is being magnified, +the averaging becomes a non-linear interpolation between nearby input pixels. +When a reprojection enlarges some regions in the input image and shrinks other +regions, this algorithm smoothly transitions between interpolation and spatial +averaging as appropriate for each individual output pixel (and likewise, the +amount of spatial averaging is adjusted as the scaling factor varies). This +produces high-quality resampling with excellent photometric accuracy. To illustrate the benefits of this method, we consider a simple case -where the reprojection includes a large change in resoluton. We choose +where the reprojection includes a large change in resolution. We choose to use an artificial data example to better illustrate the differences: .. plot:: @@ -245,28 +294,43 @@ to use an artificial data example to better illustrate the differences: # Reproject using interpolation and adaptive resampling result_interp, _ = reproject_interp((input_array, input_wcs), output_wcs, shape_out=(60, 60)) - result_deforest, _ = reproject_adaptive((input_array, input_wcs), - output_wcs, shape_out=(60, 60)) - - plt.subplot(1, 3, 1) + result_hann, _ = reproject_adaptive((input_array, input_wcs), + output_wcs, shape_out=(60, 60), + kernel='hann') + result_gaussian, _ = reproject_adaptive((input_array, input_wcs), + output_wcs, shape_out=(60, 60), + kernel='gaussian') + + plt.figure(figsize=(10, 5)) + plt.subplot(1, 4, 1) plt.imshow(input_array, origin='lower', vmin=0, vmax=1, interpolation='hanning') plt.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False) plt.title('Input array') - plt.subplot(1, 3, 2) + plt.subplot(1, 4, 2) plt.imshow(result_interp, origin='lower', vmin=0, vmax=1) plt.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False) plt.title('reproject_interp') - plt.subplot(1, 3, 3) - plt.imshow(result_deforest, origin='lower', vmin=0, vmax=0.5) + plt.subplot(1, 4, 3) + plt.imshow(result_hann, origin='lower', vmin=0, vmax=0.5) + plt.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False) + plt.title('reproject_adaptive\nHann kernel') + plt.subplot(1, 4, 4) + plt.imshow(result_gaussian, origin='lower', vmin=0, vmax=0.5) plt.tick_params(left=False, bottom=False, labelleft=False, labelbottom=False) - plt.title('reproject_adaptive') - -In the case of interpolation, the output accuracy is poor because for each output -pixel we interpolate a single position in the input array, which means that either -that position falls inside a region where the flux is zero or one, and this is -very sensitive to aliasing effects. For the adaptive resampling, each output pixel -is formed from the weighted average of several pixels in the input, and all input -pixels should contribute to the output, with no gaps. + plt.title('reproject_adaptive\nGaussian kernel') + +In the case of interpolation, the output accuracy is poor because, for each +output pixel, we interpolate a single position in the input array which will +fall inside a region where the flux is zero or one, and this is very sensitive +to aliasing effects. For the adaptive resampling, each output pixel is formed +from the weighted average of several pixels in the input, and all input pixels +should contribute to the output, with no gaps. It can also be seen how the +results differ between the Gaussian and Hann kernels. While the Gaussian kernel +blurs the output image slightly, it provides much strong anti-aliasing (as the +rotated grid lines appear much smoother and consistent in brightness from pixel +to pixel). + +.. _exact: Spherical Polygon Intersection ============================== diff --git a/reproject/adaptive/core.py b/reproject/adaptive/core.py index 40b11433e..93a27c6ad 100644 --- a/reproject/adaptive/core.py +++ b/reproject/adaptive/core.py @@ -31,9 +31,11 @@ def __call__(self, pixel_out): return pixel_in -def _reproject_adaptive_2d(array, wcs_in, wcs_out, shape_out, order=1, +def _reproject_adaptive_2d(array, wcs_in, wcs_out, shape_out, return_footprint=True, center_jacobian=False, - roundtrip_coords=True): + roundtrip_coords=True, conserve_flux=False, + kernel='Hann', kernel_width=1.3, + sample_region_width=4): """ Reproject celestial slices from an n-d array from one WCS to another using the DeForest (2004) algorithm [1]_, and assuming all other dimensions @@ -49,8 +51,6 @@ def _reproject_adaptive_2d(array, wcs_in, wcs_out, shape_out, order=1, The output WCS shape_out : tuple The shape of the output array - order : int, optional - The order of the interpolation. return_footprint : bool Whether to return the footprint in addition to the output array. center_jacobian : bool @@ -58,6 +58,15 @@ def _reproject_adaptive_2d(array, wcs_in, wcs_out, shape_out, order=1, roundtrip_coords : bool Whether to veryfiy that coordinate transformations are defined in both directions. + conserve_flux : bool + Whether to rescale output pixel values so flux is conserved + kernel : str + The averaging kernel to use. + kernel_width : double + The width of the kernel in pixels. Applies only to the Gaussian kernel. + sample_region_width : double + The width in pixels of the sample region, used only for the Gaussian + kernel which otherwise has infinite extent. Returns ------- @@ -96,7 +105,9 @@ def _reproject_adaptive_2d(array, wcs_in, wcs_out, shape_out, order=1, transformer = CoordinateTransformer(wcs_in, wcs_out, roundtrip_coords) map_coordinates(array_in, array_out, transformer, out_of_range_nan=True, - order=order, center_jacobian=center_jacobian) + center_jacobian=center_jacobian, conserve_flux=conserve_flux, + kernel=kernel, kernel_width=kernel_width, + sample_region_width=sample_region_width) if return_footprint: return array_out, (~np.isnan(array_out)).astype(float) diff --git a/reproject/adaptive/deforest.pyx b/reproject/adaptive/deforest.pyx index 6a5e92045..a5de27682 100644 --- a/reproject/adaptive/deforest.pyx +++ b/reproject/adaptive/deforest.pyx @@ -3,7 +3,8 @@ # Cython implementation of the image resampling method described in "On # resampling of Solar Images", C.E. DeForest, Solar Physics 2004 -# Copyright (c) 2014, Ruben De Visscher All rights reserved. +# Original version copyright (c) 2014, Ruben De Visscher. All rights reserved. +# v2 updates copyright (c) 2022, Sam Van Kooten. All rights reserved. # # Redistribution and use in source and binary forms, with or without # modification, are permitted provided that the following conditions are met: @@ -112,15 +113,15 @@ cdef double hanning_filter(double x, double y) nogil: y = fabs(y) if x >= 1 or y >= 1: return 0 - return (cos(x * pi)+1.0) * (cos(y * pi)+1.0) / 2.0 + return (cos(x * pi)+1.0) * (cos(y * pi)+1.0) @cython.boundscheck(False) @cython.wraparound(False) @cython.nonecheck(False) @cython.cdivision(True) -cdef double gaussian_filter(double x, double y) nogil: - return exp(-(x*x+y*y) * 1.386294) +cdef double gaussian_filter(double x, double y, double width) nogil: + return exp(-(x*x+y*y) / (width*width) * 2) @cython.boundscheck(False) @@ -152,41 +153,21 @@ cdef double clip(double x, double vmin, double vmax, int cyclic, int out_of_rang @cython.wraparound(False) @cython.nonecheck(False) @cython.cdivision(True) -cdef double bilinear_interpolation(double[:,:] source, double x, double y, int x_cyclic, int y_cyclic, int out_of_range_nan) nogil: - - x = clip(x, -0.5, source.shape[1] - 0.5, x_cyclic, out_of_range_nan) - y = clip(y, -0.5, source.shape[0] - 0.5, y_cyclic, out_of_range_nan) +cdef double sample_array(double[:,:] source, double x, double y, int x_cyclic, + int y_cyclic, int out_of_range_nan) nogil: + x = clip(x, 0, source.shape[1] - 1, x_cyclic, out_of_range_nan) + y = clip(y, 0, source.shape[0] - 1, y_cyclic, out_of_range_nan) if isnan(x) or isnan(y): return nan - cdef int xmin = floor(x) - cdef int ymin = floor(y) - cdef int xmax = xmin + 1 - cdef int ymax = ymin + 1 - - cdef double fQ11 = source[max(0, ymin), max(0, xmin)] - cdef double fQ21 = source[max(0, ymin), min(source.shape[1] - 1, xmax)] - cdef double fQ12 = source[min(source.shape[0] - 1, ymax), max(0, xmin)] - cdef double fQ22 = source[min(source.shape[0] - 1, ymax), min(source.shape[1] - 1, xmax)] + return source[ y, x] - return ((fQ11 * (xmax - x) * (ymax - y) - + fQ21 * (x - xmin) * (ymax - y) - + fQ12 * (xmax - x) * (y - ymin) - + fQ22 * (x - xmin) * (y - ymin)) - * ((xmax - xmin) * (ymax - ymin))) - -@cython.boundscheck(False) -@cython.wraparound(False) -@cython.nonecheck(False) -@cython.cdivision(True) -cdef double nearest_neighbour_interpolation(double[:,:] source, double x, double y, int x_cyclic, int y_cyclic, int out_of_range_nan) nogil: - y = clip(round(y), 0, source.shape[0]-1, y_cyclic, out_of_range_nan) - x = clip(round(x), 0, source.shape[1]-1, x_cyclic, out_of_range_nan) - if isnan(y) or isnan(x): - return nan - return source[y, x] +KERNELS = {} +KERNELS['hann'] = 0 +KERNELS['hanning'] = KERNELS['hann'] +KERNELS['gaussian'] = 1 @cython.boundscheck(False) @@ -196,7 +177,14 @@ cdef double nearest_neighbour_interpolation(double[:,:] source, double x, double def map_coordinates(double[:,:] source, double[:,:] target, Ci, int max_samples_width=-1, int conserve_flux=False, int progress=False, int singularities_nan=False, int x_cyclic=False, int y_cyclic=False, int out_of_range_nan=False, - int order=0, bint center_jacobian=False): + bint center_jacobian=False, str kernel='Hann', double kernel_width=1.3, + double sample_region_width=4): + cdef int kernel_flag + try: + kernel_flag = KERNELS[kernel.lower()] + except KeyError: + raise ValueError("'kernel' must be 'Hann' or 'Gaussian'") + cdef np.ndarray[np.float64_t, ndim=3] pixel_target cdef int delta if center_jacobian: @@ -210,7 +198,7 @@ def map_coordinates(double[:,:] source, double[:,:] target, Ci, int max_samples_ # be representing (-1,-1) in the output image. delta = -1 - cdef int yi, xi, yoff, xoff + cdef int yi, xi, y, x for yi in range(pixel_target.shape[0]): for xi in range(pixel_target.shape[1]): pixel_target[yi,xi,0] = xi + delta @@ -258,17 +246,17 @@ def map_coordinates(double[:,:] source, double[:,:] target, Ci, int max_samples_ Jy = np.empty((target.shape[0] + 1, target.shape[1], 2)) for yi in range(target.shape[0]): for xi in range(target.shape[1]): - Jx[yi, xi, 0] = pixel_source[yi+1, xi, 0] - pixel_source[yi+1, xi+1, 0] - Jx[yi, xi, 1] = pixel_source[yi+1, xi, 1] - pixel_source[yi+1, xi+1, 1] - Jy[yi, xi, 0] = pixel_source[yi, xi+1, 0] - pixel_source[yi+1, xi+1, 0] - Jy[yi, xi, 1] = pixel_source[yi, xi+1, 1] - pixel_source[yi+1, xi+1, 1] + Jx[yi, xi, 0] = -pixel_source[yi+1, xi, 0] + pixel_source[yi+1, xi+1, 0] + Jx[yi, xi, 1] = -pixel_source[yi+1, xi, 1] + pixel_source[yi+1, xi+1, 1] + Jy[yi, xi, 0] = -pixel_source[yi, xi+1, 0] + pixel_source[yi+1, xi+1, 0] + Jy[yi, xi, 1] = -pixel_source[yi, xi+1, 1] + pixel_source[yi+1, xi+1, 1] xi = target.shape[1] - Jx[yi, xi, 0] = pixel_source[yi+1, xi, 0] - pixel_source[yi+1, xi+1, 0] - Jx[yi, xi, 1] = pixel_source[yi+1, xi, 1] - pixel_source[yi+1, xi+1, 1] + Jx[yi, xi, 0] = -pixel_source[yi+1, xi, 0] + pixel_source[yi+1, xi+1, 0] + Jx[yi, xi, 1] = -pixel_source[yi+1, xi, 1] + pixel_source[yi+1, xi+1, 1] yi = target.shape[0] for xi in range(target.shape[1]): - Jy[yi, xi, 0] = pixel_source[yi, xi+1, 0] - pixel_source[yi+1, xi+1, 0] - Jy[yi, xi, 1] = pixel_source[yi, xi+1, 1] - pixel_source[yi+1, xi+1, 1] + Jy[yi, xi, 0] = -pixel_source[yi, xi+1, 0] + pixel_source[yi+1, xi+1, 0] + Jy[yi, xi, 1] = -pixel_source[yi, xi+1, 1] + pixel_source[yi+1, xi+1, 1] # Now trim the padding we added earlier. Since `delta` was used above, # the value at (0,0) will now truly represent (0,0) and so on. After @@ -281,8 +269,6 @@ def map_coordinates(double[:,:] source, double[:,:] target, Ci, int max_samples_ cdef double[:,:] J = np.zeros((2, 2)) cdef double[:,:] U = np.zeros((2, 2)) cdef double[:] s = np.zeros((2,)) - cdef double[:] s_padded = np.zeros((2,)) - cdef double[:] si = np.zeros((2,)) cdef double[:,:] V = np.zeros((2, 2)) cdef int samples_width cdef double[:] transformed = np.zeros((2,)) @@ -290,7 +276,7 @@ def map_coordinates(double[:,:] source, double[:,:] target, Ci, int max_samples_ cdef double[:] current_offset = np.zeros((2,)) cdef double weight_sum = 0.0 cdef double weight - cdef double interpolated + cdef double value cdef double[:] P1 = np.empty((2,)) cdef double[:] P2 = np.empty((2,)) cdef double[:] P3 = np.empty((2,)) @@ -304,10 +290,10 @@ def map_coordinates(double[:,:] source, double[:,:] target, Ci, int max_samples_ if center_jacobian: # Compute the Jacobian for the transformation applied to # this pixel, as finite differences. - Ji[0,0] = offset_source_x[yi, xi, 0] - offset_source_x[yi, xi+1, 0] - Ji[1,0] = offset_source_x[yi, xi, 1] - offset_source_x[yi, xi+1, 1] - Ji[0,1] = offset_source_y[yi, xi, 0] - offset_source_y[yi+1, xi, 0] - Ji[1,1] = offset_source_y[yi, xi, 1] - offset_source_y[yi+1, xi, 1] + Ji[0,0] = -offset_source_x[yi, xi, 0] + offset_source_x[yi, xi+1, 0] + Ji[1,0] = -offset_source_x[yi, xi, 1] + offset_source_x[yi, xi+1, 1] + Ji[0,1] = -offset_source_y[yi, xi, 0] + offset_source_y[yi+1, xi, 0] + Ji[1,1] = -offset_source_y[yi, xi, 1] + offset_source_y[yi+1, xi, 1] else: # Compute the Jacobian for the transformation applied to # this pixel, as a mean of the Jacobian a half-pixel @@ -322,88 +308,105 @@ def map_coordinates(double[:,:] source, double[:,:] target, Ci, int max_samples_ # Find and pad the singular values of the Jacobian. svd2x2_decompose(Ji, U, s, V) - s_padded[0] = max(1.0, s[0]) - s_padded[1] = max(1.0, s[1]) - si[0] = 1.0/s[0] - si[1] = 1.0/s[1] - svd2x2_compose(V, si, U, J) - svd2x2_compose(U, s_padded, V, Ji_padded) - - # We'll need to sample some number of input images to set this - # output pixel. Later on, we'll compute weights to assign to - # each input pixel with a Hanning window, and that window will - # assign weights of zero outside some range. Right now, we'll - # determine a search region within the input image---a bounding - # box around those pixels that will be assigned non-zero - # weights. - # - # We do that by identifying the locations in the input image of - # the corners of a square region centered around the output - # pixel (using the local linearization of the transformation). - # Those transformed coordinates will set our bounding box. + s[0] = max(1.0, s[0]) + s[1] = max(1.0, s[1]) + svd2x2_compose(U, s, V, Ji_padded) + # Build J, the inverse of Ji, by using 1/s and swapping the + # order of U and V. + s[0] = 1.0/s[0] + s[1] = 1.0/s[1] + svd2x2_compose(V, s, U, J) + + # We'll need to sample some number of input image pixels to set + # this output pixel. Later on, we'll compute weights to assign + # to each input pixel, and they will be at or near zero outside + # some range. Right now, we'll determine a search region within + # the input image---a bounding box around those pixels that + # will be assigned non-zero weights. # - # The output-plane region we're transforming is twice the width - # of a pixel---it runs to the centers of the neighboring - # pixels, rather than the edges of those pixels. When we use - # the Hann window as our filter function, having that window - # stretch to the neighboring pixel centers ensures that, at - # every point, the sum of the overlapping Hann windows is 1, - # and therefore that every input-image pixel is fully - # distributed into some combination of output pixels (in the - # limit of a Jacobian that is constant across all output - # pixels). - - # Transform the corners of the output-plane region to the input - # plane. - P1[0] = - 1 * Ji_padded[0, 0] + 1 * Ji_padded[0, 1] - P1[1] = - 1 * Ji_padded[1, 0] + 1 * Ji_padded[1, 1] - P2[0] = + 1 * Ji_padded[0, 0] + 1 * Ji_padded[0, 1] - P2[1] = + 1 * Ji_padded[1, 0] + 1 * Ji_padded[1, 1] - P3[0] = - 1 * Ji_padded[0, 0] - 1 * Ji_padded[0, 1] - P3[1] = - 1 * Ji_padded[1, 0] - 1 * Ji_padded[1, 1] - P4[0] = + 1 * Ji_padded[0, 0] - 1 * Ji_padded[0, 1] - P4[1] = + 1 * Ji_padded[1, 0] - 1 * Ji_padded[1, 1] - - # Find a bounding box around the transformed coordinates. - # (Check all four points at each step, since sometimes negative - # Jacobian values will mirror the transformed pixel.) - top = max(P1[1], P2[1], P3[1], P4[1]) - bottom = min(P1[1], P2[1], P3[1], P4[1]) - right = max(P1[0], P2[0], P3[0], P4[0]) - left = min(P1[0], P2[0], P3[0], P4[0]) + # We do that by defining a square region in the output plane + # centered on the output pixel, and transforming its corners to + # the input plane (using the local linearization of the + # transformation). Those transformed coordinates will set our + # bounding box. + if kernel_flag == 0: + # The Hann window is zero outside +/-1, so + # that's how far we need to go. + # + # The Hann window width is twice the width of a pixel---it + # runs to the centers of the neighboring pixels, rather + # than the edges of those pixels. This ensures that, at + # every point, the sum of the overlapping Hann windows is + # 1, and therefore that every input-image pixel is fully + # distributed into some combination of output pixels (in + # the limit of a Jacobian that is constant across all + # output pixels). + P1[0] = - 1 * Ji_padded[0, 0] + 1 * Ji_padded[0, 1] + P1[1] = - 1 * Ji_padded[1, 0] + 1 * Ji_padded[1, 1] + P2[0] = + 1 * Ji_padded[0, 0] + 1 * Ji_padded[0, 1] + P2[1] = + 1 * Ji_padded[1, 0] + 1 * Ji_padded[1, 1] + P3[0] = - 1 * Ji_padded[0, 0] - 1 * Ji_padded[0, 1] + P3[1] = - 1 * Ji_padded[1, 0] - 1 * Ji_padded[1, 1] + P4[0] = + 1 * Ji_padded[0, 0] - 1 * Ji_padded[0, 1] + P4[1] = + 1 * Ji_padded[1, 0] - 1 * Ji_padded[1, 1] + + # Find a bounding box around the transformed coordinates. + # (Check all four points at each step, in case a negative + # Jacobian value is mirroring the transformed pixel.) + top = max(P1[1], P2[1], P3[1], P4[1]) + bottom = min(P1[1], P2[1], P3[1], P4[1]) + right = max(P1[0], P2[0], P3[0], P4[0]) + left = min(P1[0], P2[0], P3[0], P4[0]) + elif kernel_flag == 1: + # The Gaussian window is non-zero everywhere, but it's + # close to zero almost everywhere. Sampling the whole input + # image isn't tractable, so we truncate and sample only + # within a certain region. + # n.b. `s` currently contains the reciprocal of the + # singular values + top = sample_region_width / (2 * min(s[0], s[1])) + bottom = -top + right = top + left = -right + else: + with gil: + raise ValueError("Invalid kernel type") if max_samples_width > 0 and max(right-left, top-bottom) > max_samples_width: if singularities_nan: target[yi,xi] = nan else: - if order == 0: - target[yi,xi] = nearest_neighbour_interpolation(source, pixel_source[yi,xi,0], pixel_source[yi,xi,1], x_cyclic, y_cyclic, out_of_range_nan) - else: - target[yi,xi] = bilinear_interpolation(source, pixel_source[yi,xi,0], pixel_source[yi,xi,1], x_cyclic, y_cyclic, out_of_range_nan) + target[yi,xi] = sample_array(source, + pixel_source[yi,xi,0], pixel_source[yi,xi,1], + x_cyclic, y_cyclic, out_of_range_nan) continue - # Clamp to the largest offsets that remain within the source - # image. (Going out-of-bounds in the image plane would be - # handled correctly in the interpolation routines, but skipping - # those pixels altogether is faster.) + top += pixel_source[yi,xi,1] + bottom += pixel_source[yi,xi,1] + right += pixel_source[yi,xi,0] + left += pixel_source[yi,xi,0] + + # Clamp sampling region to stay within the source image. (Going + # outside the image plane is handled otherwise, but it's faster + # to just omit those points completely.) if not x_cyclic: - right = min(source.shape[1] - 0.5 - pixel_source[yi,xi,0], right) - left = max(-0.5 - pixel_source[yi,xi,0], left) + right = min(source.shape[1] - 1, right) + left = max(0, left) if not y_cyclic: - top = min(source.shape[0] - 0.5 - pixel_source[yi,xi,1], top) - bottom = max(-0.5 - pixel_source[yi,xi,1], bottom) + top = min(source.shape[0] - 1, top) + bottom = max(0, bottom) target[yi,xi] = 0.0 weight_sum = 0.0 # Iterate through that bounding box in the input image. - for yoff in range(ceil(bottom), floor(top)+1): - current_offset[1] = yoff - current_pixel_source[1] = pixel_source[yi,xi,1] + yoff + for y in range(ceil(bottom), floor(top)+1): + current_pixel_source[1] = y + current_offset[1] = current_pixel_source[1] - pixel_source[yi,xi,1] has_sampled_this_row = False - for xoff in range(ceil(left), floor(right)+1): - current_offset[0] = xoff - current_pixel_source[0] = pixel_source[yi,xi,0] + xoff + for x in range(ceil(left), floor(right)+1): + current_pixel_source[0] = x + current_offset[0] = current_pixel_source[0] - pixel_source[yi,xi,0] # Find the fractional position of the input location # within the transformed ellipse. transformed[0] = J[0,0] * current_offset[0] + J[0,1] * current_offset[1] @@ -411,7 +414,17 @@ def map_coordinates(double[:,:] source, double[:,:] target, Ci, int max_samples_ # Compute an averaging weight to be assigned to this # input location. - weight = hanning_filter(transformed[0], transformed[1]) + if kernel_flag == 0: + weight = hanning_filter( + transformed[0], transformed[1]) + elif kernel_flag == 1: + weight = gaussian_filter( + transformed[0], + transformed[1], + kernel_width) + else: + with gil: + raise ValueError("Invalid kernel type") if weight == 0: # As we move along each row in the image, we'll # first be seeing input-plane pixels that don't map @@ -428,17 +441,14 @@ def map_coordinates(double[:,:] source, double[:,:] target, Ci, int max_samples_ if has_sampled_this_row: break continue + has_sampled_this_row = True - # Produce an input-image value to sample. Our output - # pixel doesn't necessarily map to an integer - # coordinate in the input image, and so our input - # samples must be interpolated. - if order == 0: - interpolated = nearest_neighbour_interpolation(source, current_pixel_source[0], current_pixel_source[1], x_cyclic, y_cyclic, out_of_range_nan) - else: - interpolated = bilinear_interpolation(source, current_pixel_source[0], current_pixel_source[1], x_cyclic, y_cyclic, out_of_range_nan) - if not isnan(interpolated): - target[yi,xi] += weight * interpolated + value = sample_array(source, current_pixel_source[0], + current_pixel_source[1], x_cyclic, y_cyclic, + out_of_range_nan) + + if not isnan(value): + target[yi,xi] += weight * value weight_sum += weight target[yi,xi] /= weight_sum if conserve_flux: diff --git a/reproject/adaptive/high_level.py b/reproject/adaptive/high_level.py index b9da841f2..4c7ccb67b 100644 --- a/reproject/adaptive/high_level.py +++ b/reproject/adaptive/high_level.py @@ -1,21 +1,24 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst +import astropy.utils from ..utils import parse_input_data, parse_output_projection from .core import _reproject_adaptive_2d __all__ = ['reproject_adaptive'] -ORDER = {} -ORDER['nearest-neighbor'] = 0 -ORDER['bilinear'] = 1 - +@astropy.utils.deprecated_renamed_argument('order', None, since=0.9) def reproject_adaptive(input_data, output_projection, shape_out=None, hdu_in=0, - order='bilinear', return_footprint=True, - center_jacobian=False, roundtrip_coords=True): + order=None, + return_footprint=True, center_jacobian=False, + roundtrip_coords=True, conserve_flux=False, + kernel='Hann', kernel_width=1.3, sample_region_width=4): """ - Reproject celestial slices from an 2d array from one WCS to another using - the DeForest (2004) adaptive resampling algorithm. + Reproject a 2D array from one WCS to another using the DeForest (2004) + adaptive, anti-aliased resampling algorithm, with optional flux + conservation. This algorithm smoothly transitions between filtered + interpolation and spatial averaging, depending on the scaling applied by + the transformation at each output location. Parameters ---------- @@ -42,15 +45,9 @@ def reproject_adaptive(input_data, output_projection, shape_out=None, hdu_in=0, hdu_in : int or str, optional If ``input_data`` is a FITS file or an `~astropy.io.fits.HDUList` instance, specifies the HDU to use. - order : int or str, optional - The order of the interpolation. This can be any of the - following strings: - - * 'nearest-neighbor' - * 'bilinear' - - or an integer. A value of ``0`` indicates nearest neighbor - interpolation. + order : str + Deprecated, and no longer has any effect. Will be removed in a future + release. return_footprint : bool Whether to return the footprint in addition to the output array. center_jacobian : bool @@ -72,6 +69,31 @@ def reproject_adaptive(input_data, output_projection, shape_out=None, hdu_in=0, roundtrip_coords : bool Whether to verify that coordinate transformations are defined in both directions. + conserve_flux : bool + Whether to rescale output pixel values so flux is conserved. + kernel : str + The averaging kernel to use. Allowed values are 'Hann' and 'Gaussian'. + Case-insensitive. The Gaussian kernel produces better photometric + accuracy and stronger anti-aliasing at the cost of some blurring (on + the scale of a few pixels). + kernel_width : double + The width of the kernel in pixels, measuring to +/- 1 sigma for the + Gaussian window. Does not apply to the Hann window. Reducing this width + may introduce photometric errors or leave input pixels under-sampled, + while increasing it may improve the degree of anti-aliasing but will + increase blurring of the output image. If this width is changed from + the default, a proportional change should be made to the value of + sample_region_width to maintain an equivalent degree of photometric + accuracy. + sample_region_width : double + The width in pixels of the output-image region which, when transformed + to the input plane, defines the region to be sampled for each output + pixel. Used only for the Gaussian kernel, which otherwise has infinite + extent. This value sets a trade-off between accuracy and computation + time, with better accuracy at higher values. The default value of 4, + with the default kernel width, should limit the most extreme errors to + less than one percent. Higher values will offer even more photometric + accuracy. Returns ------- @@ -88,10 +110,10 @@ def reproject_adaptive(input_data, output_projection, shape_out=None, hdu_in=0, array_in, wcs_in = parse_input_data(input_data, hdu_in=hdu_in) wcs_out, shape_out = parse_output_projection(output_projection, shape_out=shape_out) - if isinstance(order, str): - order = ORDER[order] - return _reproject_adaptive_2d(array_in, wcs_in, wcs_out, shape_out, - order=order, return_footprint=return_footprint, + return_footprint=return_footprint, center_jacobian=center_jacobian, - roundtrip_coords=roundtrip_coords) + roundtrip_coords=roundtrip_coords, + conserve_flux=conserve_flux, + kernel=kernel, kernel_width=kernel_width, + sample_region_width=sample_region_width) diff --git a/reproject/adaptive/tests/reference/test_reproject_adaptive_2d.fits b/reproject/adaptive/tests/reference/test_reproject_adaptive_2d.fits index afa3869d2..604af3e90 100644 Binary files a/reproject/adaptive/tests/reference/test_reproject_adaptive_2d.fits and b/reproject/adaptive/tests/reference/test_reproject_adaptive_2d.fits differ diff --git a/reproject/adaptive/tests/reference/test_reproject_adaptive_2d_rotated.fits b/reproject/adaptive/tests/reference/test_reproject_adaptive_2d_rotated.fits index 9cb9e8471..863ee07a9 100644 Binary files a/reproject/adaptive/tests/reference/test_reproject_adaptive_2d_rotated.fits and b/reproject/adaptive/tests/reference/test_reproject_adaptive_2d_rotated.fits differ diff --git a/reproject/adaptive/tests/reference/test_reproject_adaptive_roundtrip.fits b/reproject/adaptive/tests/reference/test_reproject_adaptive_roundtrip.fits index 1caf42bf7..2115ba981 100644 Binary files a/reproject/adaptive/tests/reference/test_reproject_adaptive_roundtrip.fits and b/reproject/adaptive/tests/reference/test_reproject_adaptive_roundtrip.fits differ diff --git a/reproject/adaptive/tests/reference/test_reproject_adaptive_uncentered_jacobian.fits b/reproject/adaptive/tests/reference/test_reproject_adaptive_uncentered_jacobian.fits index 1a449b99f..3b5274646 100644 Binary files a/reproject/adaptive/tests/reference/test_reproject_adaptive_uncentered_jacobian.fits and b/reproject/adaptive/tests/reference/test_reproject_adaptive_uncentered_jacobian.fits differ diff --git a/reproject/adaptive/tests/test_core.py b/reproject/adaptive/tests/test_core.py index c44e0216e..f40184ac6 100644 --- a/reproject/adaptive/tests/test_core.py +++ b/reproject/adaptive/tests/test_core.py @@ -1,6 +1,7 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst import os +from itertools import product import numpy as np import pytest @@ -103,7 +104,10 @@ def test_reproject_adaptive_2d_rotated(center_jacobian, roundtrip_coords): @pytest.mark.parametrize('roundtrip_coords', (False, True)) -def test_reproject_adaptive_high_aliasing_potential(roundtrip_coords): +@pytest.mark.parametrize('center_jacobian', (False, True)) +@pytest.mark.parametrize('kernel', ('hann', 'gaussian')) +def test_reproject_adaptive_high_aliasing_potential_rotation( + roundtrip_coords, center_jacobian, kernel): # Generate sample data with vertical stripes alternating with every column data_in = np.arange(40*40).reshape((40, 40)) data_in = (data_in) % 2 @@ -124,13 +128,15 @@ def test_reproject_adaptive_high_aliasing_potential(roundtrip_coords): array_out = reproject_adaptive((data_in, wcs_in), wcs_out, shape_out=(11, 6), return_footprint=False, - roundtrip_coords=roundtrip_coords) + roundtrip_coords=roundtrip_coords, + center_jacobian=center_jacobian, + kernel=kernel) # The CDELT1 value in wcs_out produces a down-sampling by a factor of two # along the output x axis. With the input image containing vertical lines # with values of zero or one, we should have uniform values of 0.5 # throughout our output array. - np.testing.assert_allclose(array_out, 0.5) + np.testing.assert_allclose(array_out, 0.5, rtol=0.001) # Within the transforms, the order of operations is: # input pixel coordinates -> input rotation -> input scaling @@ -145,21 +151,27 @@ def test_reproject_adaptive_high_aliasing_potential(roundtrip_coords): array_out = reproject_adaptive((data_in, wcs_in), wcs_out, shape_out=(11, 6), return_footprint=False, - roundtrip_coords=roundtrip_coords) - np.testing.assert_allclose(array_out, 0.5) + roundtrip_coords=roundtrip_coords, + center_jacobian=center_jacobian, + kernel=kernel) + np.testing.assert_allclose(array_out, 0.5, rtol=0.001) # But if we add a 90-degree rotation to the input coordinates, then when # our stretched output pixels are projected onto the input data, they will # be stretched along the stripes, rather than perpendicular to them, and so # we'll still see the alternating stripes in our output data---whether or # not wcs_out contains a rotation. + # For these last two cases, we only use a Hann kernel, since the blurring + # inherent with the Gaussian kernel makes the comparison more difficult. angle = 90 * np.pi / 180 wcs_in.wcs.pc = [[np.cos(angle), -np.sin(angle)], [np.sin(angle), np.cos(angle)]] array_out = reproject_adaptive((data_in, wcs_in), wcs_out, shape_out=(11, 6), return_footprint=False, - roundtrip_coords=roundtrip_coords) + roundtrip_coords=roundtrip_coords, + center_jacobian=center_jacobian, + kernel='hann') # Generate the expected pattern of alternating stripes data_ref = np.arange(array_out.shape[1]) % 2 @@ -171,12 +183,129 @@ def test_reproject_adaptive_high_aliasing_potential(roundtrip_coords): array_out = reproject_adaptive((data_in, wcs_in), wcs_out, shape_out=(11, 6), return_footprint=False, - roundtrip_coords=roundtrip_coords) + roundtrip_coords=roundtrip_coords, + center_jacobian=center_jacobian, + kernel='hann') data_ref = np.arange(array_out.shape[0]) % 2 data_ref = np.vstack([data_ref] * array_out.shape[1]).T np.testing.assert_allclose(array_out, data_ref) +@pytest.mark.parametrize('roundtrip_coords', (False, True)) +@pytest.mark.parametrize('center_jacobian', (False, True)) +def test_reproject_adaptive_high_aliasing_potential_shearing( + roundtrip_coords, center_jacobian): + # Generate sample data with vertical stripes alternating with every column + data_in = np.arange(40*40).reshape((40, 40)) + data_in = (data_in) % 2 + + # Set up the input image coordinates, defining pixel coordinates as world + # coordinates (with an offset) + wcs_in = WCS(naxis=2) + wcs_in.wcs.crpix = 21, 21 + wcs_in.wcs.crval = 0, 0 + wcs_in.wcs.cdelt = 1, 1 + + for shear_x in (-1, 0, 1): + for shear_y in (-1, 0, 1): + if shear_x == shear_y == 0: + continue + + # Set up the output image coordinates, with shearing in both x + # and y + wcs_out = WCS(naxis=2) + wcs_out.wcs.crpix = 3, 5 + wcs_out.wcs.crval = 0, 0 + wcs_out.wcs.cdelt = 1, 1 + wcs_out.wcs.pc = ( + np.array([[1, shear_x], [0, 1]]) + @ np.array([[1, 0], [shear_y, 1]])) + + # n.b. The Gaussian kernel is much better-behaved in this + # particular scenario, so using it allows a much smaller tolerance + # in the comparison. Likewise, the kernel width is boosted to 1.5 + # to achieve stronger anti-aliasing for this test. + array_out = reproject_adaptive((data_in, wcs_in), + wcs_out, shape_out=(11, 6), + return_footprint=False, + roundtrip_coords=False, + center_jacobian=center_jacobian, + kernel='gaussian', + kernel_width=1.5) + + # We should get values close to 0.5 (an average of the 1s and 0s + # in the input image). This is as opposed to values near 0 or 1, + # which would indicate incorrect averaging of sampled points. + np.testing.assert_allclose(array_out, 0.5, atol=0.02, rtol=0) + + +def test_reproject_adaptive_flux_conservation(): + # This is more than just testing the `conserve_flux` flag---the expectation + # that flux should be conserved gives a very easy way to quantify how + # correct an image is. We use that to run through a grid of affine + # transformations, checking that the output is correct for each one. + + # Generate input data of a single point---this is the most unforgiving + # setup, as there's no change for multiple pixels to average out if their + # reprojected flux goes slightly above or below being conserved. + data_in = np.zeros((20, 20)) + data_in[12, 13] = 1 + + # Set up the input image coordinates, defining pixel coordinates as world + # coordinates (with an offset) + wcs_in = WCS(naxis=2) + wcs_in.wcs.crpix = 11, 11 + wcs_in.wcs.crval = 0, 0 + wcs_in.wcs.cdelt = 1, 1 + + wcs_out = WCS(naxis=2) + wcs_out.wcs.crpix = 16, 16 + wcs_out.wcs.crval = 0, 0 + + # Define our grid of affine transformations. Even with only a few values + # for each parameter, this is by far the longest-running test in this file, + # so it's tough to justify a finer grid. + rotations = [0, 45, 80, 90] + scales_x = np.logspace(-.2, .2, 3) + scales_y = np.logspace(-.3, .3, 3) + translations_x = np.linspace(0, 8, 3) + translations_y = np.linspace(0, .42, 3) + shears_x = np.linspace(-.7, .7, 3) + shears_y = np.linspace(-.2, .2, 3) + + for rot, scale_x, scale_y, trans_x, trans_y, shear_x, shear_y in product( + rotations, scales_x, scales_y, translations_x, translations_y, + shears_x, shears_y): + wcs_out.wcs.cdelt = scale_x, scale_y + angle = rot * np.pi/180 + rot_matrix = np.array([[np.cos(angle), -np.sin(angle)], + [np.sin(angle), np.cos(angle)]]) + shear_x_matrix = np.array([[1, shear_x], [0, 1]]) + shear_y_matrix = np.array([[1, 0], [shear_y, 1]]) + wcs_out.wcs.pc = rot_matrix @ shear_x_matrix @ shear_y_matrix + + # The Gaussian kernel does a better job at flux conservation, so + # choosing it here allows a tighter tolerance. Increasing the sample + # region width also allows a tighter tolerance---less room for bugs to + # hide! + array_out = reproject_adaptive((data_in, wcs_in), + wcs_out, shape_out=(30, 30), + return_footprint=False, + roundtrip_coords=False, + center_jacobian=False, + kernel='gaussian', + sample_region_width=5, + conserve_flux=True) + + # The degree of flux-conservation we end up seeing isn't necessarily + # something that we can constrain a priori, so here we test for + # conservation to within 0.4% because that's as good as it's getting + # right now. This test is more about ensuring future bugs don't + # accidentally worsen flux conservation than ensuring that some + # required threshold is met. + np.testing.assert_allclose(np.nansum(array_out), 1, rtol=0.004) + + def prepare_test_data(file_format): pytest.importorskip('sunpy', minversion='2.1.0') from sunpy.map import Map diff --git a/reproject/tests/test_high_level.py b/reproject/tests/test_high_level.py index 878874a3c..e0f7453e5 100644 --- a/reproject/tests/test_high_level.py +++ b/reproject/tests/test_high_level.py @@ -17,8 +17,8 @@ 'biquadratic', 'bicubic', 'flux-conserving', - 'adaptive-nearest-neighbor', - 'adaptive-bilinear') + 'adaptive-hann', + 'adaptive-gaussian') ALL_DTYPES = [] for endian in ('<', '>'): @@ -126,7 +126,7 @@ def test_surface_brightness(projection_type, dtype): data_out, footprint = reproject_exact((data_in, header_in), header_out) elif projection_type.startswith('adaptive'): data_out, footprint = reproject_adaptive((data_in, header_in), header_out, - order=projection_type.split('-', 1)[1]) + kernel=projection_type.split('-', 1)[1]) else: data_out, footprint = reproject_interp((data_in, header_in), header_out, order=projection_type) @@ -167,7 +167,7 @@ def test_identity_projection(projection_type): data_out, footprint = reproject_exact((data_in, header_in), header_in) elif projection_type.startswith('adaptive'): data_out, footprint = reproject_adaptive((data_in, header_in), header_in, - order=projection_type.split('-', 1)[1]) + kernel=projection_type.split('-', 1)[1]) else: data_out, footprint = reproject_interp((data_in, header_in), header_in, order=projection_type) @@ -176,4 +176,8 @@ def test_identity_projection(projection_type): # and the footprint values to be ~ones. expected_footprint = np.ones((header_in['NAXIS2'], header_in['NAXIS1'])) np.testing.assert_allclose(footprint, expected_footprint) - np.testing.assert_allclose(data_in, data_out, rtol=1e-6) + if projection_type != 'adaptive-gaussian': + np.testing.assert_allclose(data_in, data_out, rtol=1e-6) + else: + # The Gaussian kernel in the adaptive algorithm blurs the image + assert np.all(data_in != data_out)