def test_reproject_celestial_2d_gal2equ(wcsapi, roundtrip_coords): """ Test reprojection of a 2D celestial image, which includes a coordinate system conversion. """ with fits.open(get_pkg_data_filename('data/galactic_2d.fits', package='reproject.tests')) as pf: hdu_in = pf[0] header_out = hdu_in.header.copy() header_out['CTYPE1'] = 'RA---TAN' header_out['CTYPE2'] = 'DEC--TAN' header_out['CRVAL1'] = 266.39311 header_out['CRVAL2'] = -28.939779 if wcsapi: # Enforce a pure wcsapi API wcs_in, data_in = as_high_level_wcs(WCS(hdu_in.header)), hdu_in.data wcs_out = as_high_level_wcs(WCS(header_out)) shape_out = header_out['NAXIS2'], header_out['NAXIS1'] array_out, footprint_out = reproject_interp( (data_in, wcs_in), wcs_out, shape_out=shape_out, roundtrip_coords=roundtrip_coords) else: array_out, footprint_out = reproject_interp( hdu_in, header_out, roundtrip_coords=roundtrip_coords) return array_footprint_to_hdulist(array_out, footprint_out, header_out)
def test_celestial_mismatch_3d(roundtrip_coords): """ Make sure an error is raised if the input image has celestial WCS information and the output does not (and vice-versa). This example will use the _reproject_full route. """ with fits.open( get_pkg_data_filename('data/equatorial_3d.fits', package='reproject.tests')) as pf: hdu_in = pf[0] header_out = hdu_in.header.copy() header_out['CTYPE1'] = 'APPLES' header_out['CTYPE2'] = 'ORANGES' header_out['CTYPE3'] = 'BANANAS' data = hdu_in.data wcs1 = WCS(hdu_in.header) wcs2 = WCS(header_out) with pytest.raises(ValueError, match="Input WCS has celestial components but output WCS " "does not"): array_out, footprint_out = reproject_interp( (data, wcs1), wcs2, shape_out=(1, 2, 3), roundtrip_coords=roundtrip_coords) with pytest.raises(ValueError, match="Output WCS has celestial components but input WCS " "does not"): array_out, footprint_out = reproject_interp( (data, wcs2), wcs1, shape_out=(1, 2, 3), roundtrip_coords=roundtrip_coords)
def test_small_cutout(wcsapi, roundtrip_coords): """ Test reprojection of a cutout from a larger image (makes sure that the pre-reprojection cropping works) """ with fits.open(get_pkg_data_filename('data/galactic_2d.fits', package='reproject.tests')) as pf: hdu_in = pf[0] header_out = hdu_in.header.copy() header_out['NAXIS1'] = 10 header_out['NAXIS2'] = 9 header_out['CTYPE1'] = 'RA---TAN' header_out['CTYPE2'] = 'DEC--TAN' header_out['CRVAL1'] = 266.39311 header_out['CRVAL2'] = -28.939779 header_out['CRPIX1'] = 5.1 header_out['CRPIX2'] = 4.7 if wcsapi: # Enforce a pure wcsapi API wcs_in, data_in = as_high_level_wcs(WCS(hdu_in.header)), hdu_in.data wcs_out = as_high_level_wcs(WCS(header_out)) shape_out = header_out['NAXIS2'], header_out['NAXIS1'] array_out, footprint_out = reproject_interp( (data_in, wcs_in), wcs_out, shape_out=shape_out, roundtrip_coords=roundtrip_coords) else: array_out, footprint_out = reproject_interp( hdu_in, header_out, roundtrip_coords=roundtrip_coords) return array_footprint_to_hdulist(array_out, footprint_out, header_out)
def test_identity_with_offset(roundtrip_coords): # Reproject an array and WCS to itself but with a margin, which should # end up empty. This is a regression test for a bug that caused some # values to extend beyond the original footprint. wcs = WCS(naxis=2) wcs.wcs.ctype = 'RA---TAN', 'DEC--TAN' wcs.wcs.crpix = 322, 151 wcs.wcs.crval = 43, 23 wcs.wcs.cdelt = -0.1, 0.1 wcs.wcs.equinox = 2000. array_in = np.random.random((233, 123)) wcs_out = wcs.deepcopy() wcs_out.wcs.crpix += 1 shape_out = (array_in.shape[0] + 2, array_in.shape[1] + 2) array_out, footprint = reproject_interp( (array_in, wcs), wcs_out, shape_out=shape_out, roundtrip_coords=roundtrip_coords) expected = np.pad(array_in, 1, 'constant', constant_values=np.nan) assert_allclose(expected, array_out, atol=1e-10)
def test_mwpan_car_to_mol(roundtrip_coords): """ Test reprojection of the Mellinger Milky Way Panorama from CAR to MOL, which was returning all NaNs due to a regression that was introduced in reproject 0.3 (https://github.com/astrofrog/reproject/pull/124). """ hdu_in = fits.Header.fromtextfile( get_pkg_data_filename('data/mwpan2_RGB_3600.hdr', package='reproject.tests')) with pytest.warns(FITSFixedWarning): wcs_in = WCS(hdu_in, naxis=2) data_in = np.ones((hdu_in['NAXIS2'], hdu_in['NAXIS1']), dtype=float) header_out = fits.Header() header_out['NAXIS'] = 2 header_out['NAXIS1'] = 360 header_out['NAXIS2'] = 180 header_out['CRPIX1'] = 180 header_out['CRPIX2'] = 90 header_out['CRVAL1'] = 0 header_out['CRVAL2'] = 0 header_out['CDELT1'] = -2 * np.sqrt(2) / np.pi header_out['CDELT2'] = 2 * np.sqrt(2) / np.pi header_out['CTYPE1'] = 'GLON-MOL' header_out['CTYPE2'] = 'GLAT-MOL' header_out['RADESYS'] = 'ICRS' array_out, footprint_out = reproject_interp( (data_in, wcs_in), header_out, roundtrip_coords=roundtrip_coords) assert np.isfinite(array_out).any()
def test_reproject_with_output_array(roundtrip_coords): """ Test both full_reproject and slicewise reprojection. We use a case where the non-celestial slices are the same and therefore where both algorithms can work. """ header_in = fits.Header.fromtextfile( get_pkg_data_filename('data/cube.hdr', package='reproject.tests')) array_in = np.ones((3, 200, 180)) shape_out = (3, 160, 170) out_full = np.empty(shape_out) wcs_in = WCS(header_in) wcs_out = wcs_in.deepcopy() wcs_out.wcs.ctype = ['GLON-SIN', 'GLAT-SIN', wcs_in.wcs.ctype[2]] wcs_out.wcs.crval = [158.0501, -21.530282, wcs_in.wcs.crval[2]] wcs_out.wcs.crpix = [50., 50., wcs_in.wcs.crpix[2] + 0.4] # TODO when someone learns how to do it: make sure the memory isn't duplicated... returned_array = reproject_interp((array_in, wcs_in), wcs_out, output_array=out_full, return_footprint=False, roundtrip_coords=roundtrip_coords) assert out_full is returned_array
def test_reproject_celestial_3d_equ2gal(wcsapi, axis_order, roundtrip_coords): """ Test reprojection of a 3D cube with celestial components, which includes a coordinate system conversion (the original header is in equatorial coordinates). We test using both the 'fast' method which assumes celestial slices are independent, and the 'full' method. We also scramble the input dimensions of the data and header to make sure that the reprojection can deal with this. """ # Read in the input cube with fits.open( get_pkg_data_filename('data/equatorial_3d.fits', package='reproject.tests')) as pf: hdu_in = pf[0] # Define the output header - this should be the same for all versions of # this test to make sure we can use a single reference file. header_out = hdu_in.header.copy() header_out['NAXIS1'] = 10 header_out['NAXIS2'] = 9 header_out['CTYPE1'] = 'GLON-SIN' header_out['CTYPE2'] = 'GLAT-SIN' header_out['CRVAL1'] = 163.16724 header_out['CRVAL2'] = -15.777405 header_out['CRPIX1'] = 6 header_out['CRPIX2'] = 5 # We now scramble the input axes if axis_order != (0, 1, 2): wcs_in = WCS(hdu_in.header) wcs_in = wcs_in.sub((3 - np.array(axis_order)[::-1]).tolist()) hdu_in.header = wcs_in.to_header() hdu_in.data = np.transpose(hdu_in.data, axis_order) if wcsapi: # Enforce a pure wcsapi API wcs_in, data_in = as_high_level_wcs(WCS(hdu_in.header)), hdu_in.data wcs_out = as_high_level_wcs(WCS(header_out)) shape_out = header_out['NAXIS3'], header_out['NAXIS2'], header_out['NAXIS1'] array_out, footprint_out = reproject_interp( (data_in, wcs_in), wcs_out, shape_out=shape_out, roundtrip_coords=roundtrip_coords) else: array_out, footprint_out = reproject_interp( hdu_in, header_out, roundtrip_coords=roundtrip_coords) return array_footprint_to_hdulist(array_out, footprint_out, header_out)
def test_reproject_roundtrip(file_format): # Test the reprojection with solar data, which ensures that the masking of # pixels based on round-tripping works correctly. Using asdf is not just # about testing a different format but making sure that GWCS works. # The observer handling changed in 2.1. pytest.importorskip('sunpy', minversion='2.1.0') from sunpy.map import Map from sunpy.coordinates.ephemeris import get_body_heliographic_stonyhurst if file_format == 'fits': map_aia = Map(get_pkg_data_filename('data/aia_171_level1.fits', package='reproject.tests')) data = map_aia.data wcs = map_aia.wcs date = map_aia.date target_wcs = wcs.deepcopy() elif file_format == 'asdf': pytest.importorskip('astropy', minversion='4.0') pytest.importorskip('gwcs', minversion='0.12') asdf = pytest.importorskip('asdf') aia = asdf.open( get_pkg_data_filename('data/aia_171_level1.asdf', package='reproject.tests')) data = aia['data'][...] wcs = aia['wcs'] date = wcs.output_frame.reference_frame.obstime target_wcs = Map( get_pkg_data_filename('data/aia_171_level1.fits', package='reproject.tests')).wcs.deepcopy() else: raise ValueError('file_format should be fits or asdf') # Reproject to an observer on Venus target_wcs.wcs.cdelt = ([24, 24]*u.arcsec).to(u.deg) target_wcs.wcs.crpix = [64, 64] venus = get_body_heliographic_stonyhurst('venus', date) target_wcs.wcs.aux.hgln_obs = venus.lon.to_value(u.deg) target_wcs.wcs.aux.hglt_obs = venus.lat.to_value(u.deg) target_wcs.wcs.aux.dsun_obs = venus.radius.to_value(u.m) output, footprint = reproject_interp((data, wcs), target_wcs, (128, 128)) header_out = target_wcs.to_header() # ASTROPY_LT_40: astropy v4.0 introduced new default header keywords, # once we support only astropy 4.0 and later we can update the reference # data files and remove this section. for key in ('CRLN_OBS', 'CRLT_OBS', 'DSUN_OBS', 'HGLN_OBS', 'HGLT_OBS', 'MJDREFF', 'MJDREFI', 'MJDREF', 'MJD-OBS', 'RSUN_REF'): header_out.pop(key, None) header_out['DATE-OBS'] = header_out['DATE-OBS'].replace('T', ' ') return array_footprint_to_hdulist(output, footprint, header_out)
def test_naxis_mismatch(roundtrip_coords): """ Make sure an error is raised if the input and output WCS have a different number of dimensions. """ data = np.ones((3, 2, 2)) wcs_in = WCS(naxis=3) wcs_out = WCS(naxis=2) with pytest.raises(ValueError, match="Number of dimensions between input and output WCS " "should match"): array_out, footprint_out = reproject_interp( (data, wcs_in), wcs_out, shape_out=(1, 2), roundtrip_coords=roundtrip_coords)
def test_spectral_mismatch_3d(roundtrip_coords): """ Make sure an error is raised if there are mismatches between the presence or type of spectral axis. """ with fits.open( get_pkg_data_filename('data/equatorial_3d.fits', package='reproject.tests')) as pf: hdu_in = pf[0] header_out = hdu_in.header.copy() header_out['CTYPE3'] = 'FREQ' header_out['CUNIT3'] = 'Hz' data = hdu_in.data wcs1 = WCS(hdu_in.header) wcs2 = WCS(header_out) with pytest.raises(ValueError, match=r"The input \(VOPT\) and output \(FREQ\) spectral " r"coordinate types are not equivalent\."): array_out, footprint_out = reproject_interp( (data, wcs1), wcs2, shape_out=(1, 2, 3), roundtrip_coords=roundtrip_coords) header_out['CTYPE3'] = 'BANANAS' wcs2 = WCS(header_out) with pytest.raises(ValueError, match="Input WCS has a spectral component but output WCS " "does not"): array_out, footprint_out = reproject_interp( (data, wcs1), wcs2, shape_out=(1, 2, 3), roundtrip_coords=roundtrip_coords) with pytest.raises(ValueError, match="Output WCS has a spectral component but input WCS " "does not"): array_out, footprint_out = reproject_interp( (data, wcs2), wcs1, shape_out=(1, 2, 3), roundtrip_coords=roundtrip_coords)
def test_inequal_wcs_dims(roundtrip_coords): inp_cube = np.arange(3, dtype='float').repeat(4 * 5).reshape(3, 4, 5) header_in = fits.Header.fromtextfile( get_pkg_data_filename('data/cube.hdr', package='reproject.tests')) header_out = header_in.copy() header_out['CTYPE3'] = 'VRAD' header_out['CUNIT3'] = 'm/s' header_in['CTYPE3'] = 'STOKES' header_in['CUNIT3'] = '' wcs_out = WCS(header_out) with pytest.raises(ValueError, match="Output WCS has a spectral component but input WCS " "does not"): out_cube, out_cube_valid = reproject_interp( (inp_cube, header_in), wcs_out, shape_out=(2, 4, 5), roundtrip_coords=roundtrip_coords)
def test_4d_fails(roundtrip_coords): header_in = fits.Header.fromtextfile( get_pkg_data_filename('data/cube.hdr', package='reproject.tests')) header_in['NAXIS'] = 4 header_out = header_in.copy() w_in = WCS(header_in) w_out = WCS(header_out) array_in = np.zeros((2, 3, 4, 5)) with pytest.raises(ValueError, match="Length of shape_out should match number of dimensions " "in wcs_out"): x_out, y_out, z_out = reproject_interp( (array_in, w_in), w_out, shape_out=[2, 4, 5, 6], roundtrip_coords=roundtrip_coords)
def test_different_wcs_types(roundtrip_coords): inp_cube = np.arange(3, dtype='float').repeat(4 * 5).reshape(3, 4, 5) header_in = fits.Header.fromtextfile( get_pkg_data_filename('data/cube.hdr', package='reproject.tests')) header_out = header_in.copy() header_out['CTYPE3'] = 'VRAD' header_out['CUNIT3'] = 'm/s' header_in['CTYPE3'] = 'VELO' header_in['CUNIT3'] = 'm/s' wcs_out = WCS(header_out) with pytest.raises(ValueError, match=r"The input \(VELO\) and output \(VRAD\) spectral " r"coordinate types are not equivalent\."): out_cube, out_cube_valid = reproject_interp( (inp_cube, header_in), wcs_out, shape_out=(2, 4, 5), roundtrip_coords=roundtrip_coords)
def test_slice_reprojection(roundtrip_coords): """ Test case where only the slices change and the celestial projection doesn't """ inp_cube = np.arange(3, dtype='float').repeat(4 * 5).reshape(3, 4, 5) header_in = fits.Header.fromtextfile( get_pkg_data_filename('data/cube.hdr', package='reproject.tests')) header_in['NAXIS1'] = 5 header_in['NAXIS2'] = 4 header_in['NAXIS3'] = 3 header_out = header_in.copy() header_out['NAXIS3'] = 2 header_out['CRPIX3'] -= 0.5 wcs_in = WCS(header_in) wcs_out = WCS(header_out) out_cube, out_cube_valid = reproject_interp( (inp_cube, wcs_in), wcs_out, shape_out=(2, 4, 5), roundtrip_coords=roundtrip_coords) # we expect to be projecting from # inp_cube = np.arange(3, dtype='float').repeat(4*5).reshape(3,4,5) # to # inp_cube_interp = (inp_cube[:-1]+inp_cube[1:])/2. # which is confirmed by # map_coordinates(inp_cube.astype('float'), new_coords, order=1, cval=np.nan, mode='constant') # np.testing.assert_allclose(inp_cube_interp, map_coordinates(inp_cube.astype('float'), # new_coords, order=1, cval=np.nan, mode='constant')) assert out_cube.shape == (2, 4, 5) assert out_cube_valid.sum() == 40. # We only check that the *valid* pixels are equal # but it's still nice to check that the "valid" array works as a mask np.testing.assert_allclose(out_cube[out_cube_valid.astype('bool')], ((inp_cube[:-1] + inp_cube[1:]) / 2.)[out_cube_valid.astype('bool')]) # Actually, I fixed it, so now we can test all np.testing.assert_allclose(out_cube, ((inp_cube[:-1] + inp_cube[1:]) / 2.))
def test_small_cutout_outside(roundtrip_coords): """ Test reprojection of a cutout from a larger image - in this case the cutout is completely outside the region of the input image so we should take a shortcut that returns arrays of NaNs. """ with fits.open(get_pkg_data_filename('data/galactic_2d.fits', package='reproject.tests')) as pf: hdu_in = pf[0] header_out = hdu_in.header.copy() header_out['NAXIS1'] = 10 header_out['NAXIS2'] = 9 header_out['CTYPE1'] = 'RA---TAN' header_out['CTYPE2'] = 'DEC--TAN' header_out['CRVAL1'] = 216.39311 header_out['CRVAL2'] = -21.939779 header_out['CRPIX1'] = 5.1 header_out['CRPIX2'] = 4.7 array_out, footprint_out = reproject_interp( hdu_in, header_out, roundtrip_coords=roundtrip_coords) assert np.all(np.isnan(array_out)) assert np.all(footprint_out == 0)
def test_reproject_3d_celestial_correctness_ra2gal(roundtrip_coords): inp_cube = np.arange(3, dtype='float').repeat(7 * 8).reshape(3, 7, 8) header_in = fits.Header.fromtextfile( get_pkg_data_filename('data/cube.hdr', package='reproject.tests')) header_in['NAXIS1'] = 8 header_in['NAXIS2'] = 7 header_in['NAXIS3'] = 3 header_out = header_in.copy() header_out['CTYPE1'] = 'GLON-TAN' header_out['CTYPE2'] = 'GLAT-TAN' header_out['CRVAL1'] = 158.5644791 header_out['CRVAL2'] = -21.59589875 # make the cube a cutout approximately in the center of the other one, but smaller header_out['NAXIS1'] = 4 header_out['CRPIX1'] = 2 header_out['NAXIS2'] = 3 header_out['CRPIX2'] = 1.5 header_out['NAXIS3'] = 2 header_out['CRPIX3'] -= 0.5 wcs_in = WCS(header_in) wcs_out = WCS(header_out) out_cube, out_cube_valid = reproject_interp( (inp_cube, wcs_in), wcs_out, shape_out=(2, 3, 4), roundtrip_coords=roundtrip_coords) assert out_cube.shape == (2, 3, 4) assert out_cube_valid.sum() == out_cube.size # only compare the spectral axis np.testing.assert_allclose(out_cube[:, 0, 0], ((inp_cube[:-1] + inp_cube[1:]) / 2.)[:, 0, 0])
def make_cutouts(data, catalog, wcs=None, origin=0, verbose=True): """Make cutouts of catalog targets from a 2D image array. Expects input image WCS to be in the TAN projection. Parameters ---------- data : 2D `~numpy.ndarray` or `~astropy.nddata.NDData` The 2D cutout array. catalog : `~astropy.table.table.Table` Catalog table defining the sources to cut out. Must contain unit information as the cutout tool does not assume default units. wcs : `~astropy.wcs.wcs.WCS` WCS if the input image is `~numpy.ndarray`. origin : int Whether SkyCoord.from_pixel should use 0 or 1-based pixel coordinates. verbose : bool Print extra info. Default is `True`. Returns ------- cutouts : list A list of NDData. If cutout failed for a target, `None` will be added as a place holder. Output WCS will in be in Tan projection. Notes ----- The input Catalog must have the following columns, which MUST have units information where applicable: * ``'id'`` - ID string; no unit necessary. * ``'coords'`` - SkyCoord (Overrides ra, dec, x and y columns). * ``'ra'`` or ``'x'``- RA (angular units e.g., deg, H:M:S, arcsec etc..) or pixel x position (only in `~astropy.units.pix`). * ``'dec'`` or ``'y'`` - Dec (angular units e.g., deg, D:M:S, arcsec etc..) or pixel y position (only in `~astropy.units.pix`). * ``'cutout_width'`` - Cutout width (e.g., in arcsec, pix). * ``'cutout_height'`` - Cutout height (e.g., in arcsec, pix). Optional columns: * ``'cutout_pa'`` - Cutout angle (e.g., in deg, arcsec). This is only use if user chooses to rotate the cutouts. Positive value will result in a clockwise rotation. If saved to fits, cutouts are organized as follows: <output_dir>/ <id>.fits Each cutout image is a simple single-extension FITS with updated WCS. Its header has the following special keywords: * ``OBJ_RA`` - RA of the cutout object in degrees. * ``OBJ_DEC`` - DEC of the cutout object in degrees. * ``OBJ_ROT`` - Rotation of cutout object in degrees. """ # Do not rotate if column is missing. if 'cutout_pa' in catalog.colnames: if catalog['cutout_pa'].unit is None: raise u.UnitsError("Units not specified for cutout_pa.") apply_rotation = True else: apply_rotation = False # Optional dependencies... if apply_rotation: try: from reproject.interpolation.high_level import reproject_interp except ImportError as e: raise ImportError("Optional requirement not met: " + e.msg) # Search for wcs: if isinstance(data, NDData): if wcs is not None: raise Exception("Ambiguous: WCS defined in NDData and parameters.") wcs = data.wcs elif not isinstance(data, np.ndarray): raise TypeError("Input image should be a 2D `~numpy.ndarray` " "or `~astropy.nddata.NDData") elif wcs is None: raise Exception("WCS information was not provided.") if wcs.wcs.ctype[0] != 'RA---TAN' or wcs.wcs.ctype[1] != 'DEC--TAN': raise Exception("Expected WCS to be in the TAN projection.") # Calculate the pixel scale of input image: pixel_scales = proj_plane_pixel_scales(wcs) pixel_scale_width = pixel_scales[0] * u.Unit(wcs.wcs.cunit[0]) / u.pix pixel_scale_height = pixel_scales[1] * u.Unit(wcs.wcs.cunit[1]) / u.pix # Check if `SkyCoord`s are available: if 'coords' in catalog.colnames: coords = catalog['coords'] if not isinstance(coords, SkyCoord): raise TypeError('The coords column is not a SkyCoord') elif 'ra' in catalog.colnames and 'dec' in catalog.colnames: if 'x' in catalog.colnames and 'y' in catalog.colnames: raise Exception( "Ambiguous catalog: Both (ra, dec) and pixel positions provided." ) if catalog['ra'].unit is None or catalog['dec'].unit is None: raise u.UnitsError( "Units not specified for ra and/or dec columns.") coords = SkyCoord(catalog['ra'], catalog['dec'], unit=(catalog['ra'].unit, catalog['dec'].unit)) elif 'x' in catalog.colnames and 'y' in catalog.colnames: coords = SkyCoord.from_pixel(catalog['x'].astype(float), catalog['y'].astype(float), wcs, origin=origin) else: try: coords = SkyCoord.guess_from_table(catalog) except Exception as e: raise e coords = coords.transform_to(wcs_to_celestial_frame(wcs)) # Figure out cutout size: if 'cutout_width' in catalog.colnames: if catalog['cutout_width'].unit is None: raise u.UnitsError("Units not specified for cutout_width.") if catalog['cutout_width'].unit == u.pix: width = catalog['cutout_width'].astype(float) # pix else: width = (catalog['cutout_width'] / pixel_scale_width).decompose().value # pix else: raise Exception("cutout_width column not found in catalog.") if 'cutout_height' in catalog.colnames: if catalog['cutout_height'].unit is None: raise u.UnitsError("Units not specified for cutout_height.") if catalog['cutout_height'].unit == u.pix: height = catalog['cutout_height'].astype(float) # pix else: height = (catalog['cutout_height'] / pixel_scale_height).decompose().value # pix else: raise Exception("cutout_height column not found in catalog.") cutcls = partial(Cutout2D, data.data, wcs=wcs, mode='partial') cutouts = [] for position, x_pix, y_pix, row in zip(coords, width, height, catalog): if apply_rotation: pix_rot = row['cutout_pa'].to(u.degree).value # Construct new rotated WCS: cutout_wcs = WCS(naxis=2) cutout_wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] cutout_wcs.wcs.crval = [position.ra.deg, position.dec.deg] cutout_wcs.wcs.crpix = [(x_pix + 1) * 0.5, (y_pix + 1) * 0.5] try: cutout_wcs.wcs.cd = wcs.wcs.cd cutout_wcs.rotateCD(-pix_rot) except AttributeError: cutout_wcs.wcs.cdelt = wcs.wcs.cdelt cutout_wcs.wcs.crota = [0, -pix_rot] cutout_hdr = cutout_wcs.to_header() # Rotate the image using reproject try: cutout_arr = reproject_interp( (data, wcs), cutout_hdr, shape_out=(math.floor(y_pix + math.copysign(0.5, y_pix)), math.floor(x_pix + math.copysign(0.5, x_pix))), order=1) except Exception: if verbose: log.info('reproject failed: ' 'Skipping {0}'.format(row['id'])) cutouts.append(None) continue cutout_arr = cutout_arr[0] # Ignore footprint cutout_hdr['OBJ_ROT'] = (pix_rot, 'Cutout rotation in degrees') else: # Make cutout or handle exceptions by adding None to output list try: cutout = cutcls(position, size=(y_pix, x_pix)) except NoConvergence: if verbose: log.info('WCS solution did not converge: ' 'Skipping {0}'.format(row['id'])) cutouts.append(None) continue except NoOverlapError: if verbose: log.info('Cutout is not on image: ' 'Skipping {0}'.format(row['id'])) cutouts.append(None) continue else: cutout_hdr = cutout.wcs.to_header() cutout_arr = cutout.data # If cutout result is empty, skip that target if np.array_equiv(cutout_arr, 0): if verbose: log.info('No data in cutout: Skipping {0}'.format(row['id'])) cutouts.append(None) continue # Finish constructing header. cutout_hdr['OBJ_RA'] = (position.ra.deg, 'Cutout object RA in deg') cutout_hdr['OBJ_DEC'] = (position.dec.deg, 'Cutout object DEC in deg') cutouts.append( NDData(data=cutout_arr, wcs=WCS(cutout_hdr), meta=cutout_hdr)) return cutouts
def make_cutouts(data, catalog, wcs=None, origin=0, verbose=True): """Make cutouts of catalog targets from a 2D image array. Expects input image WCS to be in the TAN projection. Parameters ---------- data : 2D `~numpy.ndarray` or `~astropy.nddata.NDData` The 2D cutout array. catalog : `~astropy.table.table.Table` Catalog table defining the sources to cut out. Must contain unit information as the cutout tool does not assume default units. wcs : `~astropy.wcs.wcs.WCS` WCS if the input image is `~numpy.ndarray`. origin : int Whether SkyCoord.from_pixel should use 0 or 1-based pixel coordinates. verbose : bool Print extra info. Default is `True`. Returns ------- cutouts : list A list of NDData. If cutout failed for a target, `None` will be added as a place holder. Output WCS will in be in Tan projection. Notes ----- The input Catalog must have the following columns, which MUST have units information where applicable: * ``'id'`` - ID string; no unit necessary. * ``'coords'`` - SkyCoord (Overrides ra, dec, x and y columns). * ``'ra'`` or ``'x'``- RA (angular units e.g., deg, H:M:S, arcsec etc..) or pixel x position (only in `~astropy.units.pix`). * ``'dec'`` or ``'y'`` - Dec (angular units e.g., deg, D:M:S, arcsec etc..) or pixel y position (only in `~astropy.units.pix`). * ``'cutout_width'`` - Cutout width (e.g., in arcsec, pix). * ``'cutout_height'`` - Cutout height (e.g., in arcsec, pix). Optional columns: * ``'cutout_pa'`` - Cutout angle (e.g., in deg, arcsec). This is only use if user chooses to rotate the cutouts. Positive value will result in a clockwise rotation. If saved to fits, cutouts are organized as follows: <output_dir>/ <id>.fits Each cutout image is a simple single-extension FITS with updated WCS. Its header has the following special keywords: * ``OBJ_RA`` - RA of the cutout object in degrees. * ``OBJ_DEC`` - DEC of the cutout object in degrees. * ``OBJ_ROT`` - Rotation of cutout object in degrees. """ # Do not rotate if column is missing. if 'cutout_pa' in catalog.colnames: if catalog['cutout_pa'].unit is None: raise u.UnitsError("Units not specified for cutout_pa.") apply_rotation = True else: apply_rotation = False # Optional dependencies... if apply_rotation: try: from reproject.interpolation.high_level import reproject_interp except ImportError as e: raise ImportError("Optional requirement not met: " + e.msg) # Search for wcs: if isinstance(data, NDData): if wcs is not None: raise Exception("Ambiguous: WCS defined in NDData and parameters.") wcs = data.wcs elif not isinstance(data, np.ndarray): raise TypeError("Input image should be a 2D `~numpy.ndarray` " "or `~astropy.nddata.NDData") elif wcs is None: raise Exception("WCS information was not provided.") if wcs.wcs.ctype[0] != 'RA---TAN' or wcs.wcs.ctype[1] != 'DEC--TAN': raise Exception("Expected WCS to be in the TAN projection.") # Calculate the pixel scale of input image: pixel_scales = proj_plane_pixel_scales(wcs) pixel_scale_width = pixel_scales[0] * u.Unit(wcs.wcs.cunit[0]) / u.pix pixel_scale_height = pixel_scales[1] * u.Unit(wcs.wcs.cunit[1]) / u.pix # Check if `SkyCoord`s are available: if 'coords' in catalog.colnames: coords = catalog['coords'] if not isinstance(coords, SkyCoord): raise TypeError('The coords column is not a SkyCoord') elif 'ra' in catalog.colnames and 'dec' in catalog.colnames: if 'x' in catalog.colnames and 'y' in catalog.colnames: raise Exception("Ambiguous catalog: Both (ra, dec) and pixel positions provided.") if catalog['ra'].unit is None or catalog['dec'].unit is None: raise u.UnitsError("Units not specified for ra and/or dec columns.") coords = SkyCoord(catalog['ra'], catalog['dec'], unit=(catalog['ra'].unit, catalog['dec'].unit)) elif 'x' in catalog.colnames and 'y' in catalog.colnames: coords = SkyCoord.from_pixel(catalog['x'].astype(float), catalog['y'].astype(float), wcs, origin=origin) else: try: coords = SkyCoord.guess_from_table(catalog) except Exception as e: raise e coords = coords.transform_to(wcs_to_celestial_frame(wcs)) # Figure out cutout size: if 'cutout_width' in catalog.colnames: if catalog['cutout_width'].unit is None: raise u.UnitsError("Units not specified for cutout_width.") if catalog['cutout_width'].unit == u.pix: width = catalog['cutout_width'].astype(float) # pix else: width = (catalog['cutout_width'] / pixel_scale_width).decompose().value # pix else: raise Exception("cutout_width column not found in catalog.") if 'cutout_height' in catalog.colnames: if catalog['cutout_height'].unit is None: raise u.UnitsError("Units not specified for cutout_height.") if catalog['cutout_height'].unit == u.pix: height = catalog['cutout_height'].astype(float) # pix else: height = (catalog['cutout_height'] / pixel_scale_height).decompose().value # pix else: raise Exception("cutout_height column not found in catalog.") cutcls = partial(Cutout2D, data.data, wcs=wcs, mode='partial') cutouts = [] for position, x_pix, y_pix, row in zip(coords, width, height, catalog): if apply_rotation: pix_rot = row['cutout_pa'].to(u.degree).value # Construct new rotated WCS: cutout_wcs = WCS(naxis=2) cutout_wcs.wcs.ctype = ['RA---TAN', 'DEC--TAN'] cutout_wcs.wcs.crval = [position.ra.deg, position.dec.deg] cutout_wcs.wcs.crpix = [(x_pix + 1) * 0.5, (y_pix + 1) * 0.5] try: cutout_wcs.wcs.cd = wcs.wcs.cd cutout_wcs.rotateCD(-pix_rot) except AttributeError: cutout_wcs.wcs.cdelt = wcs.wcs.cdelt cutout_wcs.wcs.crota = [0, -pix_rot] cutout_hdr = cutout_wcs.to_header() # Rotate the image using reproject try: cutout_arr = reproject_interp( (data, wcs), cutout_hdr, shape_out=(math.floor(y_pix + math.copysign(0.5, y_pix)), math.floor(x_pix + math.copysign(0.5, x_pix))), order=1) except Exception: if verbose: log.info('reproject failed: ' 'Skipping {0}'.format(row['id'])) cutouts.append(None) continue cutout_arr = cutout_arr[0] # Ignore footprint cutout_hdr['OBJ_ROT'] = (pix_rot, 'Cutout rotation in degrees') else: # Make cutout or handle exceptions by adding None to output list try: cutout = cutcls(position, size=(y_pix, x_pix)) except NoConvergence: if verbose: log.info('WCS solution did not converge: ' 'Skipping {0}'.format(row['id'])) cutouts.append(None) continue except NoOverlapError: if verbose: log.info('Cutout is not on image: ' 'Skipping {0}'.format(row['id'])) cutouts.append(None) continue else: cutout_hdr = cutout.wcs.to_header() cutout_arr = cutout.data # If cutout result is empty, skip that target if np.array_equiv(cutout_arr, 0): if verbose: log.info('No data in cutout: Skipping {0}'.format(row['id'])) cutouts.append(None) continue # Finish constructing header. cutout_hdr['OBJ_RA'] = (position.ra.deg, 'Cutout object RA in deg') cutout_hdr['OBJ_DEC'] = (position.dec.deg, 'Cutout object DEC in deg') cutouts.append(NDData(data=cutout_arr, wcs=WCS(cutout_hdr), meta=cutout_hdr)) return cutouts