# Licensed under a 3-clause BSD style license - see LICENSE.rstimportcopyfromfunctoolsimportlru_cacheimportnumpyasnpimportastropy.unitsasufromastropy.coordinatesimport(ITRS,BaseBodycentricRepresentation,BaseCoordinateFrame,BaseGeodeticRepresentation,CartesianRepresentation,SphericalRepresentation,)fromastropy.utilsimportunbroadcastfrom.wcsimportWCS,WCSSUB_LATITUDE,WCSSUB_LONGITUDE__doctest_skip__=["wcs_to_celestial_frame","celestial_frame_to_wcs"]__all__=["obsgeo_to_frame","add_stokes_axis_to_wcs","celestial_frame_to_wcs","wcs_to_celestial_frame","proj_plane_pixel_scales","proj_plane_pixel_area","is_proj_plane_distorted","non_celestial_pixel_scales","skycoord_to_pixel","pixel_to_skycoord","custom_wcs_to_frame_mappings","custom_frame_to_wcs_mappings","pixel_to_pixel","local_partial_pixel_derivatives","fit_wcs_from_points",]SOLAR_SYSTEM_OBJ_DICT={"EA":"Earth","SE":"Moon","ME":"Mercury","VE":"Venus","MA":"Mars","JU":"Jupiter","SA":"Saturn","UR":"Uranus","NE":"Neptune",}@lru_cache(maxsize=100)defsolar_system_body_frame(object_name,representation_type):returntype(f"{object_name}Frame",(BaseCoordinateFrame,),dict(name=object_name,representation_type=representation_type),)@lru_cache(maxsize=100)defsolar_system_body_representation_type(object_name,baserepresentation,equatorial_radius,flattening):returntype(f"{object_name}{baserepresentation.__name__[4:]}",(baserepresentation,),dict(_equatorial_radius=equatorial_radius,_flattening=flattening),)
[docs]defadd_stokes_axis_to_wcs(wcs,add_before_ind):""" Add a new Stokes axis that is uncorrelated with any other axes. Parameters ---------- wcs : `~astropy.wcs.WCS` The WCS to add to add_before_ind : int Index of the WCS to insert the new Stokes axis in front of. To add at the end, do add_before_ind = wcs.wcs.naxis The beginning is at position 0. Returns ------- `~astropy.wcs.WCS` A new `~astropy.wcs.WCS` instance with an additional axis """inds=[i+1foriinrange(wcs.wcs.naxis)]inds.insert(add_before_ind,0)newwcs=wcs.sub(inds)newwcs.wcs.ctype[add_before_ind]="STOKES"newwcs.wcs.cname[add_before_ind]="STOKES"returnnewwcs
def_wcs_to_celestial_frame_builtin(wcs):# Import astropy.coordinates here to avoid circular importsfromastropy.coordinatesimport(FK4,FK5,ICRS,ITRS,FK4NoETerms,Galactic,SphericalRepresentation,)# Import astropy.time here otherwise setup.py fails before extensions are compiledfromastropy.timeimportTimeifwcs.wcs.lng==-1orwcs.wcs.lat==-1:returnNoneradesys=wcs.wcs.radesysifnp.isnan(wcs.wcs.equinox):equinox=Noneelse:equinox=wcs.wcs.equinoxxcoord=wcs.wcs.ctype[wcs.wcs.lng][:4]ycoord=wcs.wcs.ctype[wcs.wcs.lat][:4]# Apply logic from FITS standard to determine the default radesysifradesys==""andxcoord=="RA--"andycoord=="DEC-":ifequinoxisNone:radesys="ICRS"elifequinox<1984.0:radesys="FK4"else:radesys="FK5"ifradesys=="FK4":ifequinoxisnotNone:equinox=Time(equinox,format="byear")frame=FK4(equinox=equinox)elifradesys=="FK4-NO-E":ifequinoxisnotNone:equinox=Time(equinox,format="byear")frame=FK4NoETerms(equinox=equinox)elifradesys=="FK5":ifequinoxisnotNone:equinox=Time(equinox,format="jyear")frame=FK5(equinox=equinox)elifradesys=="ICRS":frame=ICRS()else:ifxcoord=="GLON"andycoord=="GLAT":frame=Galactic()elifxcoord=="TLON"andycoord=="TLAT":# The default representation for ITRS is cartesian, but for WCS# purposes, we need the spherical representation.frame=ITRS(representation_type=SphericalRepresentation,obstime=wcs.wcs.dateobsorNone,)elifxcoord[2:4]in("LN","LT")andxcoord[:2]inSOLAR_SYSTEM_OBJ_DICT.keys():# Coordinates on a planetary body, as defined in# https://agupubs.onlinelibrary.wiley.com/doi/10.1029/2018EA000388object_name=SOLAR_SYSTEM_OBJ_DICT.get(xcoord[:2])a_radius=wcs.wcs.aux.a_radiusb_radius=wcs.wcs.aux.b_radiusc_radius=wcs.wcs.aux.c_radiusif"bodycentric"inwcs.wcs.name.lower():baserepresentation=BaseBodycentricRepresentationrepresentation_type_name="BodycentricRepresentation"else:baserepresentation=BaseGeodeticRepresentationrepresentation_type_name="GeodeticRepresentation"ifa_radius==b_radius:equatorial_radius=a_radius*u.mflattening=(a_radius-c_radius)/a_radiuselse:raiseNotImplementedError("triaxial systems are not supported at this time.")# create a new representation classrepresentation_type=solar_system_body_representation_type(SOLAR_SYSTEM_OBJ_DICT.get(xcoord[:2]),baserepresentation,equatorial_radius,flattening,)# create a new frame classframe=solar_system_body_frame(SOLAR_SYSTEM_OBJ_DICT.get(xcoord[:2]),representation_type)else:frame=Nonereturnframedef_celestial_frame_to_wcs_builtin(frame,projection="TAN"):# Import astropy.coordinates here to avoid circular importsfromastropy.coordinatesimport(FK4,FK5,ICRS,ITRS,BaseRADecFrame,FK4NoETerms,Galactic,)# Create a 2-dimensional WCSwcs=WCS(naxis=2)ifisinstance(frame,BaseRADecFrame):xcoord="RA--"ycoord="DEC-"ifisinstance(frame,ICRS):wcs.wcs.radesys="ICRS"elifisinstance(frame,FK4NoETerms):wcs.wcs.radesys="FK4-NO-E"wcs.wcs.equinox=frame.equinox.byearelifisinstance(frame,FK4):wcs.wcs.radesys="FK4"wcs.wcs.equinox=frame.equinox.byearelifisinstance(frame,FK5):wcs.wcs.radesys="FK5"wcs.wcs.equinox=frame.equinox.jyearelse:returnNoneelifisinstance(frame,Galactic):xcoord="GLON"ycoord="GLAT"elifisinstance(frame,ITRS):xcoord="TLON"ycoord="TLAT"wcs.wcs.radesys="ITRS"wcs.wcs.dateobs=frame.obstime.utc.isot# TODO: once we have a BaseBodyFrame, replace this with an isinstance checkelifhasattr(frame,"name")andframe.nameinSOLAR_SYSTEM_OBJ_DICT.values():xcoord=frame.name[:2].upper().replace("MO","SE")+"LN"ycoord=frame.name[:2].upper().replace("MO","SE")+"LT"ifissubclass(frame.representation_type,BaseGeodeticRepresentation):wcs.wcs.name="Planetographic Body-Fixed"elifissubclass(frame.representation_type,BaseBodycentricRepresentation):wcs.wcs.name="Bodycentric Body-Fixed"else:raiseValueError("Planetary coordinates in WCS require a geodetic or bodycentric ""representation, not {frame.representation_type}.")wcs.wcs.aux.a_radius=frame.representation_type._equatorial_radius.valuewcs.wcs.aux.b_radius=frame.representation_type._equatorial_radius.valuewcs.wcs.aux.c_radius=wcs.wcs.aux.a_radius*(1.0-frame.representation_type._flattening.to(u.dimensionless_unscaled).value)else:returnNonewcs.wcs.ctype=[xcoord+"-"+projection,ycoord+"-"+projection]returnwcsWCS_FRAME_MAPPINGS=[[_wcs_to_celestial_frame_builtin]]FRAME_WCS_MAPPINGS=[[_celestial_frame_to_wcs_builtin]]
[docs]defwcs_to_celestial_frame(wcs):""" For a given WCS, return the coordinate frame that matches the celestial component of the WCS. Parameters ---------- wcs : :class:`~astropy.wcs.WCS` instance The WCS to find the frame for Returns ------- frame : :class:`~astropy.coordinates.BaseCoordinateFrame` subclass instance An instance of a :class:`~astropy.coordinates.BaseCoordinateFrame` subclass instance that best matches the specified WCS. Notes ----- To extend this function to frames not defined in astropy.coordinates, you can write your own function which should take a :class:`~astropy.wcs.WCS` instance and should return either an instance of a frame, or `None` if no matching frame was found. You can register this function temporarily with:: >>> from astropy.wcs.utils import wcs_to_celestial_frame, custom_wcs_to_frame_mappings >>> with custom_wcs_to_frame_mappings(my_function): ... wcs_to_celestial_frame(...) """formapping_setinWCS_FRAME_MAPPINGS:forfuncinmapping_set:frame=func(wcs)ifframeisnotNone:returnframeraiseValueError("Could not determine celestial frame corresponding to the specified WCS object")
[docs]defcelestial_frame_to_wcs(frame,projection="TAN"):""" For a given coordinate frame, return the corresponding WCS object. Note that the returned WCS object has only the elements corresponding to coordinate frames set (e.g. ctype, equinox, radesys). Parameters ---------- frame : :class:`~astropy.coordinates.BaseCoordinateFrame` subclass instance An instance of a :class:`~astropy.coordinates.BaseCoordinateFrame` subclass instance for which to find the WCS projection : str Projection code to use in ctype, if applicable Returns ------- wcs : :class:`~astropy.wcs.WCS` instance The corresponding WCS object Examples -------- :: >>> from astropy.wcs.utils import celestial_frame_to_wcs >>> from astropy.coordinates import FK5 >>> frame = FK5(equinox='J2010') >>> wcs = celestial_frame_to_wcs(frame) >>> wcs.to_header() WCSAXES = 2 / Number of coordinate axes CRPIX1 = 0.0 / Pixel coordinate of reference point CRPIX2 = 0.0 / Pixel coordinate of reference point CDELT1 = 1.0 / [deg] Coordinate increment at reference point CDELT2 = 1.0 / [deg] Coordinate increment at reference point CUNIT1 = 'deg' / Units of coordinate increment and value CUNIT2 = 'deg' / Units of coordinate increment and value CTYPE1 = 'RA---TAN' / Right ascension, gnomonic projection CTYPE2 = 'DEC--TAN' / Declination, gnomonic projection CRVAL1 = 0.0 / [deg] Coordinate value at reference point CRVAL2 = 0.0 / [deg] Coordinate value at reference point LONPOLE = 180.0 / [deg] Native longitude of celestial pole LATPOLE = 0.0 / [deg] Native latitude of celestial pole RADESYS = 'FK5' / Equatorial coordinate system EQUINOX = 2010.0 / [yr] Equinox of equatorial coordinates Notes ----- To extend this function to frames not defined in astropy.coordinates, you can write your own function which should take a :class:`~astropy.coordinates.BaseCoordinateFrame` subclass instance and a projection (given as a string) and should return either a WCS instance, or `None` if the WCS could not be determined. You can register this function temporarily with:: >>> from astropy.wcs.utils import celestial_frame_to_wcs, custom_frame_to_wcs_mappings >>> with custom_frame_to_wcs_mappings(my_function): ... celestial_frame_to_wcs(...) """formapping_setinFRAME_WCS_MAPPINGS:forfuncinmapping_set:wcs=func(frame,projection=projection)ifwcsisnotNone:returnwcsraiseValueError("Could not determine WCS corresponding to the specified coordinate frame.")
[docs]defproj_plane_pixel_scales(wcs):""" For a WCS returns pixel scales along each axis of the image pixel at the ``CRPIX`` location once it is projected onto the "plane of intermediate world coordinates" as defined in `Greisen & Calabretta 2002, A&A, 395, 1061 <https://ui.adsabs.harvard.edu/abs/2002A%26A...395.1061G>`_. .. note:: This function is concerned **only** about the transformation "image plane"->"projection plane" and **not** about the transformation "celestial sphere"->"projection plane"->"image plane". Therefore, this function ignores distortions arising due to non-linear nature of most projections. .. note:: In order to compute the scales corresponding to celestial axes only, make sure that the input `~astropy.wcs.WCS` object contains celestial axes only, e.g., by passing in the `~astropy.wcs.WCS.celestial` WCS object. Parameters ---------- wcs : `~astropy.wcs.WCS` A world coordinate system object. Returns ------- scale : ndarray A vector (`~numpy.ndarray`) of projection plane increments corresponding to each pixel side (axis). The units of the returned results are the same as the units of `~astropy.wcs.Wcsprm.cdelt`, `~astropy.wcs.Wcsprm.crval`, and `~astropy.wcs.Wcsprm.cd` for the celestial WCS and can be obtained by inquiring the value of `~astropy.wcs.Wcsprm.cunit` property of the input `~astropy.wcs.WCS` WCS object. See Also -------- astropy.wcs.utils.proj_plane_pixel_area """returnnp.sqrt((wcs.pixel_scale_matrix**2).sum(axis=0,dtype=float))
[docs]defproj_plane_pixel_area(wcs):""" For a **celestial** WCS (see `astropy.wcs.WCS.celestial`) returns pixel area of the image pixel at the ``CRPIX`` location once it is projected onto the "plane of intermediate world coordinates" as defined in `Greisen & Calabretta 2002, A&A, 395, 1061 <https://ui.adsabs.harvard.edu/abs/2002A%26A...395.1061G>`_. .. note:: This function is concerned **only** about the transformation "image plane"->"projection plane" and **not** about the transformation "celestial sphere"->"projection plane"->"image plane". Therefore, this function ignores distortions arising due to non-linear nature of most projections. .. note:: In order to compute the area of pixels corresponding to celestial axes only, this function uses the `~astropy.wcs.WCS.celestial` WCS object of the input ``wcs``. This is different from the `~astropy.wcs.utils.proj_plane_pixel_scales` function that computes the scales for the axes of the input WCS itself. Parameters ---------- wcs : `~astropy.wcs.WCS` A world coordinate system object. Returns ------- area : float Area (in the projection plane) of the pixel at ``CRPIX`` location. The units of the returned result are the same as the units of the `~astropy.wcs.Wcsprm.cdelt`, `~astropy.wcs.Wcsprm.crval`, and `~astropy.wcs.Wcsprm.cd` for the celestial WCS and can be obtained by inquiring the value of `~astropy.wcs.Wcsprm.cunit` property of the `~astropy.wcs.WCS.celestial` WCS object. Raises ------ ValueError Pixel area is defined only for 2D pixels. Most likely the `~astropy.wcs.Wcsprm.cd` matrix of the `~astropy.wcs.WCS.celestial` WCS is not a square matrix of second order. Notes ----- Depending on the application, square root of the pixel area can be used to represent a single pixel scale of an equivalent square pixel whose area is equal to the area of a generally non-square pixel. See Also -------- astropy.wcs.utils.proj_plane_pixel_scales """psm=wcs.celestial.pixel_scale_matrixifpsm.shape!=(2,2):raiseValueError("Pixel area is defined only for 2D pixels.")returnnp.abs(np.linalg.det(psm))
[docs]defis_proj_plane_distorted(wcs,maxerr=1.0e-5):r""" For a WCS returns `False` if square image (detector) pixels stay square when projected onto the "plane of intermediate world coordinates" as defined in `Greisen & Calabretta 2002, A&A, 395, 1061 <https://ui.adsabs.harvard.edu/abs/2002A%26A...395.1061G>`_. It will return `True` if transformation from image (detector) coordinates to the focal plane coordinates is non-orthogonal or if WCS contains non-linear (e.g., SIP) distortions. .. note:: Since this function is concerned **only** about the transformation "image plane"->"focal plane" and **not** about the transformation "celestial sphere"->"focal plane"->"image plane", this function ignores distortions arising due to non-linear nature of most projections. Let's denote by *C* either the original or the reconstructed (from ``PC`` and ``CDELT``) CD matrix. `is_proj_plane_distorted` verifies that the transformation from image (detector) coordinates to the focal plane coordinates is orthogonal using the following check: .. math:: \left \| \frac{C \cdot C^{\mathrm{T}}} {| det(C)|} - I \right \|_{\mathrm{max}} < \epsilon . Parameters ---------- wcs : `~astropy.wcs.WCS` World coordinate system object maxerr : float, optional Accuracy to which the CD matrix, **normalized** such that :math:`|det(CD)|=1`, should be close to being an orthogonal matrix as described in the above equation (see :math:`\epsilon`). Returns ------- distorted : bool Returns `True` if focal (projection) plane is distorted and `False` otherwise. """cwcs=wcs.celestialreturnnot_is_cd_orthogonal(cwcs.pixel_scale_matrix,maxerr)or_has_distortion(cwcs)# fmt: skip
def_is_cd_orthogonal(cd,maxerr):shape=cd.shapeifnot(len(shape)==2andshape[0]==shape[1]):raiseValueError("CD (or PC) matrix must be a 2D square matrix.")pixarea=np.abs(np.linalg.det(cd))ifpixarea==0.0:raiseValueError("CD (or PC) matrix is singular.")# NOTE: Technically, below we should use np.dot(cd, np.conjugate(cd.T))# However, I am not aware of complex CD/PC matrices...I=np.dot(cd,cd.T)/pixareacd_unitary_err=np.amax(np.abs(I-np.eye(shape[0])))returncd_unitary_err<maxerr
[docs]defnon_celestial_pixel_scales(inwcs):""" Calculate the pixel scale along each axis of a non-celestial WCS, for example one with mixed spectral and spatial axes. Parameters ---------- inwcs : `~astropy.wcs.WCS` The world coordinate system object. Returns ------- scale : `numpy.ndarray` The pixel scale along each axis. """ifinwcs.is_celestial:raiseValueError("WCS is celestial, use celestial_pixel_scales instead")pccd=inwcs.pixel_scale_matrixifnp.allclose(np.extract(1-np.eye(*pccd.shape),pccd),0):returnnp.abs(np.diagonal(pccd))*u.degelse:raiseValueError("WCS is rotated, cannot determine consistent pixel scales")
def_has_distortion(wcs):""" `True` if contains any SIP or image distortion components. """returnany(getattr(wcs,dist_attr)isnotNonefordist_attrin["cpdis1","cpdis2","det2im1","det2im2","sip"])# TODO: in future, we should think about how the following two functions can be# integrated better into the WCS class.
[docs]defskycoord_to_pixel(coords,wcs,origin=0,mode="all"):""" Convert a set of SkyCoord coordinates into pixels. Parameters ---------- coords : `~astropy.coordinates.SkyCoord` The coordinates to convert. wcs : `~astropy.wcs.WCS` The WCS transformation to use. origin : int Whether to return 0 or 1-based pixel coordinates. mode : 'all' or 'wcs' Whether to do the transformation including distortions (``'all'``) or only including only the core WCS transformation (``'wcs'``). Returns ------- xp, yp : `numpy.ndarray` The pixel coordinates See Also -------- astropy.coordinates.SkyCoord.from_pixel """if_has_distortion(wcs)andwcs.naxis!=2:raiseValueError("Can only handle WCS with distortions for 2-dimensional WCS")# Keep only the celestial part of the axes, also re-orders lon/latwcs=wcs.sub([WCSSUB_LONGITUDE,WCSSUB_LATITUDE])ifwcs.naxis!=2:raiseValueError("WCS should contain celestial component")# Check which frame the WCS usesframe=wcs_to_celestial_frame(wcs)# Check what unit the WCS needsxw_unit=u.Unit(wcs.wcs.cunit[0])yw_unit=u.Unit(wcs.wcs.cunit[1])# Convert positions to framecoords=coords.transform_to(frame)# Extract longitude and latitude. We first try and use lon/lat directly,# but if the representation is not spherical or unit spherical this will# fail. We should then force the use of the unit spherical# representation. We don't do that directly to make sure that we preserve# custom lon/lat representations if available.try:lon=coords.data.lon.to(xw_unit)lat=coords.data.lat.to(yw_unit)exceptAttributeError:lon=coords.spherical.lon.to(xw_unit)lat=coords.spherical.lat.to(yw_unit)# Convert to pixel coordinatesifmode=="all":xp,yp=wcs.all_world2pix(lon.value,lat.value,origin)elifmode=="wcs":xp,yp=wcs.wcs_world2pix(lon.value,lat.value,origin)else:raiseValueError("mode should be either 'all' or 'wcs'")returnxp,yp
[docs]defpixel_to_skycoord(xp,yp,wcs,origin=0,mode="all",cls=None):""" Convert a set of pixel coordinates into a `~astropy.coordinates.SkyCoord` coordinate. Parameters ---------- xp, yp : float or ndarray The coordinates to convert. wcs : `~astropy.wcs.WCS` The WCS transformation to use. origin : int Whether to return 0 or 1-based pixel coordinates. mode : 'all' or 'wcs' Whether to do the transformation including distortions (``'all'``) or only including only the core WCS transformation (``'wcs'``). cls : class or None The class of object to create. Should be a `~astropy.coordinates.SkyCoord` subclass. If None, defaults to `~astropy.coordinates.SkyCoord`. Returns ------- coords : `~astropy.coordinates.SkyCoord` subclass The celestial coordinates. Whatever ``cls`` type is. See Also -------- astropy.coordinates.SkyCoord.from_pixel """# Import astropy.coordinates here to avoid circular importsfromastropy.coordinatesimportSkyCoord,UnitSphericalRepresentation# we have to do this instead of actually setting the default to SkyCoord# because importing SkyCoord at the module-level leads to circular# dependencies.ifclsisNone:cls=SkyCoordif_has_distortion(wcs)andwcs.naxis!=2:raiseValueError("Can only handle WCS with distortions for 2-dimensional WCS")# Keep only the celestial part of the axes, also re-orders lon/latwcs=wcs.sub([WCSSUB_LONGITUDE,WCSSUB_LATITUDE])ifwcs.naxis!=2:raiseValueError("WCS should contain celestial component")# Check which frame the WCS usesframe=wcs_to_celestial_frame(wcs)# Check what unit the WCS giveslon_unit=u.Unit(wcs.wcs.cunit[0])lat_unit=u.Unit(wcs.wcs.cunit[1])# Convert pixel coordinates to celestial coordinatesifmode=="all":lon,lat=wcs.all_pix2world(xp,yp,origin)elifmode=="wcs":lon,lat=wcs.wcs_pix2world(xp,yp,origin)else:raiseValueError("mode should be either 'all' or 'wcs'")# Add units to longitude/latitudelon=lon*lon_unitlat=lat*lat_unit# Create a SkyCoord-like objectdata=UnitSphericalRepresentation(lon=lon,lat=lat)coords=cls(frame.realize_frame(data))returncoords
def_unique_with_order_preserved(items):""" Return a list of unique items in the list provided, preserving the order in which they are found. """new_items=[]foriteminitems:ifitemnotinnew_items:new_items.append(item)returnnew_itemsdef_pixel_to_world_correlation_matrix(wcs):""" Return a correlation matrix between the pixel coordinates and the high level world coordinates, along with the list of high level world coordinate classes. The shape of the matrix is ``(n_world, n_pix)``, where ``n_world`` is the number of high level world coordinates. """# We basically want to collapse the world dimensions together that are# combined into the same high-level objects.# Get the following in advance as getting these properties can be expensiveall_components=wcs.low_level_wcs.world_axis_object_componentsall_classes=wcs.low_level_wcs.world_axis_object_classesaxis_correlation_matrix=wcs.low_level_wcs.axis_correlation_matrixcomponents=_unique_with_order_preserved([c[0]forcinall_components])matrix=np.zeros((len(components),wcs.pixel_n_dim),dtype=bool)foriworldinrange(wcs.world_n_dim):iworld_unique=components.index(all_components[iworld][0])matrix[iworld_unique]|=axis_correlation_matrix[iworld]classes=[all_classes[component][0]forcomponentincomponents]returnmatrix,classesdef_pixel_to_pixel_correlation_matrix(wcs_in,wcs_out):""" Correlation matrix between the input and output pixel coordinates for a pixel -> world -> pixel transformation specified by two WCS instances. The first WCS specified is the one used for the pixel -> world transformation and the second WCS specified is the one used for the world -> pixel transformation. The shape of the matrix is ``(n_pixel_out, n_pixel_in)``. """matrix1,classes1=_pixel_to_world_correlation_matrix(wcs_in)matrix2,classes2=_pixel_to_world_correlation_matrix(wcs_out)iflen(classes1)!=len(classes2):raiseValueError("The two WCS return a different number of world coordinates")# Check if classes match uniquelyunique_match=Truemapping=[]forclass1inclasses1:matches=classes2.count(class1)ifmatches==0:raiseValueError("The world coordinate types of the two WCS do not match")elifmatches>1:unique_match=Falsebreakelse:mapping.append(classes2.index(class1))ifunique_match:# Classes are unique, so we need to re-order matrix2 along the world# axis using the mapping we found above.matrix2=matrix2[mapping]elifclasses1!=classes2:raiseValueError("World coordinate order doesn't match and automatic matching is ambiguous")matrix=np.matmul(matrix2.T,matrix1)returnmatrixdef_split_matrix(matrix):""" Given an axis correlation matrix from a WCS object, return information about the individual WCS that can be split out. The output is a list of tuples, where each tuple contains a list of pixel dimensions and a list of world dimensions that can be extracted to form a new WCS. For example, in the case of a spectral cube with the first two world coordinates being the celestial coordinates and the third coordinate being an uncorrelated spectral axis, the matrix would look like:: array([[ True, True, False], [ True, True, False], [False, False, True]]) and this function will return ``[([0, 1], [0, 1]), ([2], [2])]``. """pixel_used=[]split_info=[]foripixinrange(matrix.shape[1]):ifipixinpixel_used:continuepixel_include=np.zeros(matrix.shape[1],dtype=bool)pixel_include[ipix]=Truen_pix_prev,n_pix=0,1whilen_pix>n_pix_prev:world_include=matrix[:,pixel_include].any(axis=1)pixel_include=matrix[world_include,:].any(axis=0)n_pix_prev,n_pix=n_pix,np.sum(pixel_include)pixel_indices=list(np.nonzero(pixel_include)[0])world_indices=list(np.nonzero(world_include)[0])pixel_used.extend(pixel_indices)split_info.append((pixel_indices,world_indices))returnsplit_info
[docs]defpixel_to_pixel(wcs_in,wcs_out,*inputs):""" Transform pixel coordinates in a dataset with a WCS to pixel coordinates in another dataset with a different WCS. This function is designed to efficiently deal with input pixel arrays that are broadcasted views of smaller arrays, and is compatible with any APE14-compliant WCS. Parameters ---------- wcs_in : `~astropy.wcs.wcsapi.BaseHighLevelWCS` A WCS object for the original dataset which complies with the high-level shared APE 14 WCS API. wcs_out : `~astropy.wcs.wcsapi.BaseHighLevelWCS` A WCS object for the target dataset which complies with the high-level shared APE 14 WCS API. *inputs : Scalars or arrays giving the pixel coordinates to transform. """# Shortcut for scalarsifnp.isscalar(inputs[0]):world_outputs=wcs_in.pixel_to_world(*inputs)ifnotisinstance(world_outputs,(tuple,list)):world_outputs=(world_outputs,)returnwcs_out.world_to_pixel(*world_outputs)# Remember original shapeoriginal_shape=inputs[0].shapematrix=_pixel_to_pixel_correlation_matrix(wcs_in,wcs_out)split_info=_split_matrix(matrix)outputs=[None]*wcs_out.pixel_n_dimforpixel_in_indices,pixel_out_indicesinsplit_info:pixel_inputs=[]foripixinrange(wcs_in.pixel_n_dim):ifipixinpixel_in_indices:pixel_inputs.append(unbroadcast(inputs[ipix]))else:pixel_inputs.append(inputs[ipix].flat[0])pixel_inputs=np.broadcast_arrays(*pixel_inputs)world_outputs=wcs_in.pixel_to_world(*pixel_inputs)ifnotisinstance(world_outputs,(tuple,list)):world_outputs=(world_outputs,)pixel_outputs=wcs_out.world_to_pixel(*world_outputs)ifwcs_out.pixel_n_dim==1:pixel_outputs=(pixel_outputs,)foripixinrange(wcs_out.pixel_n_dim):ifipixinpixel_out_indices:outputs[ipix]=np.broadcast_to(pixel_outputs[ipix],original_shape)returnoutputs[0]ifwcs_out.pixel_n_dim==1elseoutputs
[docs]deflocal_partial_pixel_derivatives(wcs,*pixel,normalize_by_world=False):""" Return a matrix of shape ``(world_n_dim, pixel_n_dim)`` where each entry ``[i, j]`` is the partial derivative d(world_i)/d(pixel_j) at the requested pixel position. Parameters ---------- wcs : `~astropy.wcs.WCS` The WCS transformation to evaluate the derivatives for. *pixel : float The scalar pixel coordinates at which to evaluate the derivatives. normalize_by_world : bool If `True`, the matrix is normalized so that for each world entry the derivatives add up to 1. """# Find the world coordinates at the requested pixelpixel_ref=np.array(pixel)world_ref=np.array(wcs.pixel_to_world_values(*pixel_ref))# Set up the derivative matrixderivatives=np.zeros((wcs.world_n_dim,wcs.pixel_n_dim))foriinrange(wcs.pixel_n_dim):pixel_off=pixel_ref.copy()pixel_off[i]+=1world_off=np.array(wcs.pixel_to_world_values(*pixel_off))derivatives[:,i]=world_off-world_refifnormalize_by_world:derivatives/=derivatives.sum(axis=0)[:,np.newaxis]returnderivatives
def_linear_wcs_fit(params,lon,lat,x,y,w_obj):""" Objective function for fitting linear terms. Parameters ---------- params : array 6 element array. First 4 elements are PC matrix, last 2 are CRPIX. lon, lat: array Sky coordinates. x, y: array Pixel coordinates w_obj: `~astropy.wcs.WCS` WCS object """cd=params[0:4]crpix=params[4:6]w_obj.wcs.cd=((cd[0],cd[1]),(cd[2],cd[3]))w_obj.wcs.crpix=crpixlon2,lat2=w_obj.wcs_pix2world(x,y,0)lat_resids=lat-lat2lon_resids=lon-lon2# In case the longitude has wrapped aroundlon_resids=np.mod(lon_resids-180.0,360.0)-180.0resids=np.concatenate((lon_resids*np.cos(np.radians(lat)),lat_resids))returnresidsdef_sip_fit(params,lon,lat,u,v,w_obj,order,coeff_names):"""Objective function for fitting SIP. Parameters ---------- params : array Fittable parameters. First 4 elements are PC matrix, last 2 are CRPIX. lon, lat: array Sky coordinates. u, v: array Pixel coordinates w_obj: `~astropy.wcs.WCS` WCS object """fromastropy.modeling.modelsimportSIP# here to avoid circular import# unpack paramscrpix=params[0:2]cdx=params[2:6].reshape((2,2))a_params=params[6:6+len(coeff_names)]b_params=params[6+len(coeff_names):]# assign to wcs, used for transformations in this functionw_obj.wcs.cd=cdxw_obj.wcs.crpix=crpixa_coeff,b_coeff={},{}foriinrange(len(coeff_names)):a_coeff["A_"+coeff_names[i]]=a_params[i]b_coeff["B_"+coeff_names[i]]=b_params[i]sip=SIP(crpix=crpix,a_order=order,b_order=order,a_coeff=a_coeff,b_coeff=b_coeff)fuv,guv=sip(u,v)xo,yo=np.dot(cdx,np.array([u+fuv-crpix[0],v+guv-crpix[1]]))# use all pix2world in case `projection` contains distortion tablex,y=w_obj.all_world2pix(lon,lat,0)x,y=np.dot(w_obj.wcs.cd,(x-w_obj.wcs.crpix[0],y-w_obj.wcs.crpix[1]))resids=np.concatenate((x-xo,y-yo))returnresids
[docs]deffit_wcs_from_points(xy,world_coords,proj_point="center",projection="TAN",sip_degree=None):""" Given two matching sets of coordinates on detector and sky, compute the WCS. Fits a WCS object to matched set of input detector and sky coordinates. Optionally, a SIP can be fit to account for geometric distortion. Returns an `~astropy.wcs.WCS` object with the best fit parameters for mapping between input pixel and sky coordinates. The projection type (default 'TAN') can passed in as a string, one of the valid three-letter projection codes - or as a WCS object with projection keywords already set. Note that if an input WCS has any non-polynomial distortion, this will be applied and reflected in the fit terms and coefficients. Passing in a WCS object in this way essentially allows it to be refit based on the matched input coordinates and projection point, but take care when using this option as non-projection related keywords in the input might cause unexpected behavior. Notes ----- - The fiducial point for the spherical projection can be set to 'center' to use the mean position of input sky coordinates, or as an `~astropy.coordinates.SkyCoord` object. - Units in all output WCS objects will always be in degrees. - If the coordinate frame differs between `~astropy.coordinates.SkyCoord` objects passed in for ``world_coords`` and ``proj_point``, the frame for ``world_coords`` will override as the frame for the output WCS. - If a WCS object is passed in to ``projection`` the CD/PC matrix will be used as an initial guess for the fit. If this is known to be significantly off and may throw off the fit, set to the identity matrix (for example, by doing wcs.wcs.pc = [(1., 0.,), (0., 1.)]) Parameters ---------- xy : (`numpy.ndarray`, `numpy.ndarray`) tuple x & y pixel coordinates. world_coords : `~astropy.coordinates.SkyCoord` Skycoord object with world coordinates. proj_point : 'center' or ~astropy.coordinates.SkyCoord` Defaults to 'center', in which the geometric center of input world coordinates will be used as the projection point. To specify an exact point for the projection, a Skycoord object with a coordinate pair can be passed in. For consistency, the units and frame of these coordinates will be transformed to match ``world_coords`` if they don't. projection : str or `~astropy.wcs.WCS` Three letter projection code, of any of standard projections defined in the FITS WCS standard. Optionally, a WCS object with projection keywords set may be passed in. sip_degree : None or int If set to a non-zero integer value, will fit SIP of degree ``sip_degree`` to model geometric distortion. Defaults to None, meaning no distortion corrections will be fit. Returns ------- wcs : `~astropy.wcs.WCS` The best-fit WCS to the points given. """fromscipy.optimizeimportleast_squaresimportastropy.unitsasufromastropy.coordinatesimportSkyCoord# here to avoid circular importfrom.wcsimportSipxp,yp=xytry:lon,lat=world_coords.data.lon.deg,world_coords.data.lat.degexceptAttributeError:unit_sph=world_coords.unit_sphericallon,lat=unit_sph.lon.deg,unit_sph.lat.deg# verify inputif(type(proj_point)!=type(world_coords))and(proj_point!="center"):raiseValueError("proj_point must be set to 'center', or an""`~astropy.coordinates.SkyCoord` object with ""a pair of points.")use_center_as_proj_point=str(proj_point)=="center"ifnotuse_center_as_proj_point:assertproj_point.size==1proj_codes=["AZP","SZP","TAN","STG","SIN","ARC","ZEA","AIR","CYP","CEA","CAR","MER","SFL","PAR","MOL","AIT","COP","COE","COD","COO","BON","PCO","TSC","CSC","QSC","HPX","XPH",]iftype(projection)==str:ifprojectionnotinproj_codes:raiseValueError("Must specify valid projection code from list of supported types: ",", ".join(proj_codes),)# empty wcs to fill in with fit valueswcs=celestial_frame_to_wcs(frame=world_coords.frame,projection=projection)else:# if projection is not string, should be wcs object. use as template.wcs=copy.deepcopy(projection)wcs.cdelt=(1.0,1.0)# make sure cdelt is 1wcs.sip=None# Change PC to CD, since cdelt will be set to 1ifwcs.wcs.has_pc():wcs.wcs.cd=wcs.wcs.pcwcs.wcs.__delattr__("pc")if(type(sip_degree)!=type(None))and(type(sip_degree)!=int):raiseValueError("sip_degree must be None, or integer.")# compute bounding box for sources in image coordinates:xpmin,xpmax,ypmin,ypmax=xp.min(),xp.max(),yp.min(),yp.max()# set pixel_shape to span of input pointswcs.pixel_shape=(1ifxpmax<=0.0elseint(np.ceil(xpmax)),1ifypmax<=0.0elseint(np.ceil(ypmax)),)# determine CRVAL from inputclose=lambdal,p:p[np.argmin(np.abs(l))]ifuse_center_as_proj_point:# use center of input pointssc1=SkyCoord(lon.min()*u.deg,lat.max()*u.deg)sc2=SkyCoord(lon.max()*u.deg,lat.min()*u.deg)pa=sc1.position_angle(sc2)sep=sc1.separation(sc2)midpoint_sc=sc1.directional_offset_by(pa,sep/2)wcs.wcs.crval=(midpoint_sc.data.lon.deg,midpoint_sc.data.lat.deg)wcs.wcs.crpix=((xpmax+xpmin)/2.0,(ypmax+ypmin)/2.0)else:# convert units, initial guess for crpixproj_point.transform_to(world_coords)wcs.wcs.crval=(proj_point.data.lon.deg,proj_point.data.lat.deg)wcs.wcs.crpix=(close(lon-wcs.wcs.crval[0],xp+1),close(lon-wcs.wcs.crval[1],yp+1),)# fit linear terms, assign to wcs# use (1, 0, 0, 1) as initial guess, in case input wcs was passed in# and cd terms are way off.# Use bounds to require that the fit center pixel is on the input imageifxpmin==xpmax:xpmin,xpmax=xpmin-0.5,xpmax+0.5ifypmin==ypmax:ypmin,ypmax=ypmin-0.5,ypmax+0.5p0=np.concatenate([wcs.wcs.cd.flatten(),wcs.wcs.crpix.flatten()])fit=least_squares(_linear_wcs_fit,p0,args=(lon,lat,xp,yp,wcs),bounds=[[-np.inf,-np.inf,-np.inf,-np.inf,xpmin+1,ypmin+1],[np.inf,np.inf,np.inf,np.inf,xpmax+1,ypmax+1],],)wcs.wcs.crpix=np.array(fit.x[4:6])wcs.wcs.cd=np.array(fit.x[0:4].reshape((2,2)))# fit SIP, if specified. Only fit forward coefficientsifsip_degree:degree=sip_degreeif"-SIP"notinwcs.wcs.ctype[0]:wcs.wcs.ctype=[x+"-SIP"forxinwcs.wcs.ctype]coef_names=[f"{i}_{j}"foriinrange(degree+1)forjinrange(degree+1)if(i+j)<(degree+1)and(i+j)>1]p0=np.concatenate((np.array(wcs.wcs.crpix),wcs.wcs.cd.flatten(),np.zeros(2*len(coef_names)),))fit=least_squares(_sip_fit,p0,args=(lon,lat,xp,yp,wcs,degree,coef_names),bounds=[[xpmin+1,ypmin+1]+[-np.inf]*(4+2*len(coef_names)),[xpmax+1,ypmax+1]+[np.inf]*(4+2*len(coef_names)),],)coef_fit=(list(fit.x[6:6+len(coef_names)]),list(fit.x[6+len(coef_names):]),)# put fit values in wcswcs.wcs.cd=fit.x[2:6].reshape((2,2))wcs.wcs.crpix=fit.x[0:2]a_vals=np.zeros((degree+1,degree+1))b_vals=np.zeros((degree+1,degree+1))forcoef_nameincoef_names:a_vals[int(coef_name[0])][int(coef_name[2])]=coef_fit[0].pop(0)b_vals[int(coef_name[0])][int(coef_name[2])]=coef_fit[1].pop(0)wcs.sip=Sip(a_vals,b_vals,np.zeros((degree+1,degree+1)),np.zeros((degree+1,degree+1)),wcs.wcs.crpix,)returnwcs
[docs]defobsgeo_to_frame(obsgeo,obstime):""" Convert a WCS obsgeo property into an ITRS coordinate frame. Parameters ---------- obsgeo : array-like A shape ``(6, )`` array representing ``OBSGEO-[XYZ], OBSGEO-[BLH]`` as returned by ``WCS.wcs.obsgeo``. obstime : time-like The time associated with the coordinate, will be passed to `~astropy.coordinates.ITRS` as the obstime keyword. Returns ------- ~astropy.coordinates.ITRS An `~astropy.coordinates.ITRS` coordinate frame representing the coordinates. Notes ----- The obsgeo array as accessed on a `.WCS` object is a length 6 numpy array where the first three elements are the coordinate in a cartesian representation and the second 3 are the coordinate in a spherical representation. This function priorities reading the cartesian coordinates, and will only read the spherical coordinates if the cartesian coordinates are either all zero or any of the cartesian coordinates are non-finite. In the case where both the spherical and cartesian coordinates have some non-finite values the spherical coordinates will be returned with the non-finite values included. """if(obsgeoisNoneorlen(obsgeo)!=6ornp.all(np.array(obsgeo)==0)ornp.all(~np.isfinite(obsgeo))):raiseValueError(f"Can not parse the 'obsgeo' location ({obsgeo}). ""obsgeo should be a length 6 non-zero, finite numpy array")# If the cartesian coords are zero or have NaNs in them use the spherical onesifnp.all(obsgeo[:3]==0)ornp.any(~np.isfinite(obsgeo[:3])):data=SphericalRepresentation(*(obsgeo[3:]*(u.deg,u.deg,u.m)))# Otherwise we assume the cartesian ones are validelse:data=CartesianRepresentation(*obsgeo[:3]*u.m)returnITRS(data,obstime=obstime)