Coverage for python/lsst/meas/astrom/fitAffineWcs.py : 19%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# This file is part of meas_astrom.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
22__all__ = ["FitAffineWcsTask", "FitAffineWcsConfig", "TransformedSkyWcsMaker"]
25import astshim
26import numpy as np
27from scipy.optimize import least_squares
29from lsst.afw.geom import makeSkyWcs, SkyWcs
30import lsst.afw.math
31from lsst.geom import Point2D, degrees, arcseconds, radians
32import lsst.pex.config as pexConfig
33import lsst.pipe.base as pipeBase
34from lsst.utils.timer import timeMethod
36from .makeMatchStatistics import makeMatchStatisticsInRadians
37from .setMatchDistance import setMatchDistance
40def _chiFunc(x, refPoints, srcPixels, wcsMaker):
41 """Function to minimize to fit the shift and rotation in the WCS.
43 Parameters
44 ----------
45 x : `numpy.ndarray`
46 Current fit values to test. Float values in array are:
48 - ``bearingTo``: Direction to move the wcs coord in.
49 - ``separation``: Distance along sphere to move wcs coord in.
50 - ``affine0,0``: [0, 0] value of the 2x2 affine transform matrix.
51 - ``affine0,1``: [0, 1] value of the 2x2 affine transform matrix.
52 - ``affine1,0``: [1, 0] value of the 2x2 affine transform matrix.
53 - ``affine1,1``: [1, 1] value of the 2x2 affine transform matrix.
54 refPoints : `list` of `lsst.afw.geom.SpherePoint`
55 Reference object on Sky locations.
56 srcPixels : `list` of `lsst.geom.Point2D`
57 Source object positions on the pixels.
58 wcsMaker : `TransformedSkyWcsMaker`
59 Container class for producing the updated Wcs.
61 Returns
62 -------
63 outputSeparations : `list` of `float`
64 Separation between predicted source location and reference location in
65 radians.
66 """
67 wcs = wcsMaker.makeWcs(x[:2], x[2:].reshape((2, 2)))
69 outputSeparations = []
70 # Fit both sky to pixel and pixel to sky to avoid any non-invertible
71 # affine matrices.
72 for ref, src in zip(refPoints, srcPixels):
73 skySep = ref.getTangentPlaneOffset(wcs.pixelToSky(src))
74 outputSeparations.append(skySep[0].asArcseconds())
75 outputSeparations.append(skySep[1].asArcseconds())
76 xySep = src - wcs.skyToPixel(ref)
77 # Convert the pixel separations to units, arcseconds to match units
78 # of sky separation.
79 outputSeparations.append(
80 xySep[0] * wcs.getPixelScale(src).asArcseconds())
81 outputSeparations.append(
82 xySep[1] * wcs.getPixelScale(src).asArcseconds())
84 return outputSeparations
87# Keeping this around for now in case any of the fit parameters need to be
88# configurable. Likely the maximum allowed shift magnitude (parameter 2 in the
89# fit.)
90class FitAffineWcsConfig(pexConfig.Config):
91 """Config for FitTanSipWcsTask."""
92 pass
95class FitAffineWcsTask(pipeBase.Task):
96 """Fit a TAN-SIP WCS given a list of reference object/source matches.
98 This WCS fitter should be used on top of a cameraGeom distortion model as
99 the model assumes that only a shift the WCS center position and a small
100 affine transform are required.
101 """
102 ConfigClass = FitAffineWcsConfig
103 _DefaultName = "fitAffineWcs"
105 @timeMethod
106 def fitWcs(self,
107 matches,
108 initWcs,
109 bbox=None,
110 refCat=None,
111 sourceCat=None,
112 exposure=None):
113 """Fit a simple Affine transform with a shift to the matches and update
114 the WCS.
116 This method assumes that the distortion model of the telescope is
117 applied correctly and is accurate with only a slight rotation,
118 rotation, and "squish" required to fit to the reference locations.
120 Parameters
121 ----------
122 matches : `list` of `lsst.afw.table.ReferenceMatch`
123 The following fields are read:
125 - match.first (reference object) coord
126 - match.second (source) centroid
128 The following fields are written:
130 - match.first (reference object) centroid,
131 - match.second (source) centroid
132 - match.distance (on sky separation, in radians)
134 initWcs : `lsst.afw.geom.SkyWcs`
135 initial WCS
136 bbox : `lsst.geom.Box2I`
137 Ignored; present for consistency with FitSipDistortionTask.
138 refCat : `lsst.afw.table.SimpleCatalog`
139 reference object catalog, or None.
140 If provided then all centroids are updated with the new WCS,
141 otherwise only the centroids for ref objects in matches are
142 updated. Required fields are "centroid_x", "centroid_y",
143 "coord_ra", and "coord_dec".
144 sourceCat : `lsst.afw.table.SourceCatalog`
145 source catalog, or None.
146 If provided then coords are updated with the new WCS;
147 otherwise only the coords for sources in matches are updated.
148 Required fields are "slot_Centroid_x", "slot_Centroid_y", and
149 "coord_ra", and "coord_dec".
150 exposure : `lsst.afw.image.Exposure`
151 Ignored; present for consistency with FitSipDistortionTask.
153 Returns
154 -------
155 result : `lsst.pipe.base.Struct`
156 with the following fields:
158 - ``wcs`` : the fit WCS (`lsst.afw.geom.SkyWcs`)
159 - ``scatterOnSky`` : median on-sky separation between reference
160 objects and sources in "matches" (`lsst.afw.geom.Angle`)
161 """
162 # Create a data-structure that decomposes the input Wcs frames and
163 # appends the new transform.
164 wcsMaker = TransformedSkyWcsMaker(initWcs)
166 refPoints = []
167 srcPixels = []
168 offsetLong = 0
169 offsetLat = 0
170 # Grab reference coordinates and source centroids. Compute the average
171 # direction and separation between the reference and the sources.
172 for match in matches:
173 refCoord = match.first.getCoord()
174 refPoints.append(refCoord)
175 srcCentroid = match.second.getCentroid()
176 srcPixels.append(srcCentroid)
177 srcCoord = initWcs.pixelToSky(srcCentroid)
178 deltaLong, deltaLat = srcCoord.getTangentPlaneOffset(refCoord)
179 offsetLong += deltaLong.asArcseconds()
180 offsetLat += deltaLat.asArcseconds()
181 offsetLong /= len(srcPixels)
182 offsetLat /= len(srcPixels)
183 offsetDist = np.sqrt(offsetLong ** 2 + offsetLat ** 2)
184 if offsetDist > 0.:
185 offsetDir = np.degrees(np.arccos(offsetLong / offsetDist))
186 else:
187 offsetDir = 0.
188 offsetDir *= np.sign(offsetLat)
189 self.log.debug("Initial shift guess: Direction: %.3f, Dist %.3f..." %
190 (offsetDir, offsetDist))
192 # Best performing fitter in scipy tried so far (vs. default settings in
193 # minimize). Exits early because of the xTol value which cannot be
194 # disabled in scipy1.2.1. Matrix starting values are non-zero as this
195 # results in better fit off-diagonal terms.
196 fit = least_squares(
197 _chiFunc,
198 x0=[offsetDir, offsetDist, 1., 1e-8, 1e-8, 1.],
199 args=(refPoints, srcPixels, wcsMaker),
200 method='dogbox',
201 bounds=[[-360, -np.inf, -np.inf, -np.inf, -np.inf, -np.inf],
202 [360, np.inf, np.inf, np.inf, np.inf, np.inf]],
203 ftol=2.3e-16,
204 gtol=2.31e-16,
205 xtol=2.3e-16)
206 self.log.debug("Best fit: Direction: %.3f, Dist: %.3f, "
207 "Affine matrix: [[%.6f, %.6f], [%.6f, %.6f]]..." %
208 (fit.x[0], fit.x[1],
209 fit.x[2], fit.x[3], fit.x[4], fit.x[5]))
211 wcs = wcsMaker.makeWcs(fit.x[:2], fit.x[2:].reshape((2, 2)))
213 # Copied from other fit*WcsTasks.
214 if refCat is not None:
215 self.log.debug("Updating centroids in refCat")
216 lsst.afw.table.updateRefCentroids(wcs, refList=refCat)
217 else:
218 self.log.warn("Updating reference object centroids in match list; "
219 "refCat is None")
220 lsst.afw.table.updateRefCentroids(
221 wcs,
222 refList=[match.first for match in matches])
224 if sourceCat is not None:
225 self.log.debug("Updating coords in sourceCat")
226 lsst.afw.table.updateSourceCoords(wcs, sourceList=sourceCat)
227 else:
228 self.log.warn("Updating source coords in match list; sourceCat is "
229 "None")
230 lsst.afw.table.updateSourceCoords(
231 wcs,
232 sourceList=[match.second for match in matches])
233 setMatchDistance(matches)
235 stats = makeMatchStatisticsInRadians(wcs,
236 matches,
237 lsst.afw.math.MEDIAN)
238 scatterOnSky = stats.getValue() * radians
240 self.log.debug("In fitter scatter %.4f" % scatterOnSky.asArcseconds())
242 return lsst.pipe.base.Struct(
243 wcs=wcs,
244 scatterOnSky=scatterOnSky,
245 )
248class TransformedSkyWcsMaker():
249 """Convenience class for appending a shifting an input SkyWcs on sky and
250 appending an affine transform.
252 The class assumes that all frames are sequential and are mapped one to the
253 next.
255 Parameters
256 ----------
257 input_sky_wcs : `lsst.afw.geom.SkyWcs`
258 WCS to decompose and append affine matrix and shift in on sky
259 location to.
260 """
262 def __init__(self, inputSkyWcs):
263 self.frameDict = inputSkyWcs.getFrameDict()
265 # Grab the order of the frames by index.
266 # TODO: DM-20825
267 # Change the frame the transform is appended to to be explicitly
268 # the FIELD_ANGLE->IWC transform. Requires related tickets to be
269 # completed.
270 domains = self.frameDict.getAllDomains()
271 self.frameIdxs = np.sort([self.frameDict.getIndex(domain)
272 for domain in domains])
273 self.frameMin = np.min(self.frameIdxs)
274 self.frameMax = np.max(self.frameIdxs)
276 # Find frame just before the final mapping to sky and store those
277 # indices and mappings for later.
278 self.mapFrom = self.frameMax - 2
279 if self.mapFrom < self.frameMin:
280 self.mapFrom = self.frameMin
281 self.mapTo = self.frameMax - 1
282 if self.mapTo <= self.mapFrom:
283 self.mapTo = self.frameMax
284 self.lastMapBeforeSky = self.frameDict.getMapping(
285 self.mapFrom, self.mapTo)
287 # Get the original WCS sky location.
289 self.origin = inputSkyWcs.getSkyOrigin()
291 def makeWcs(self, crvalOffset, affMatrix):
292 """Apply a shift and affine transform to the WCS internal to this
293 class.
295 A new SkyWcs with these transforms applied is returns.
297 Parameters
298 ----------
299 crval_shift : `numpy.ndarray`, (2,)
300 Shift in radians to apply to the Wcs origin/crvals.
301 aff_matrix : 'numpy.ndarray', (3, 3)
302 Affine matrix to apply to the mapping/transform to add to the
303 WCS.
305 Returns
306 -------
307 outputWcs : `lsst.afw.geom.SkyWcs`
308 Wcs with a final shift and affine transform applied.
309 """
310 # Create a WCS that only maps from IWC to Sky with the shifted
311 # Sky origin position. This is simply the final undistorted tangent
312 # plane to sky. The PIXELS to SKY map will be become our IWC to SKY
313 # map and gives us our final shift position.
314 iwcsToSkyWcs = makeSkyWcs(
315 Point2D(0., 0.),
316 self.origin.offset(crvalOffset[0] * degrees,
317 crvalOffset[1] * arcseconds),
318 np.array([[1., 0.], [0., 1.]]))
319 iwcToSkyMap = iwcsToSkyWcs.getFrameDict().getMapping("PIXELS", "SKY")
321 # Append a simple affine Matrix transform to the current to the
322 # second to last frame mapping. e.g. the one just before IWC to SKY.
323 newMapping = self.lastMapBeforeSky.then(astshim.MatrixMap(affMatrix))
325 # Create a new frame dict starting from the input_sky_wcs's first
326 # frame. Append the correct mapping created above and our new on
327 # sky location.
328 outputFrameDict = astshim.FrameDict(
329 self.frameDict.getFrame(self.frameMin))
330 for frameIdx in self.frameIdxs:
331 if frameIdx == self.mapFrom:
332 outputFrameDict.addFrame(
333 self.mapFrom,
334 newMapping,
335 self.frameDict.getFrame(self.mapTo))
336 elif frameIdx >= self.mapTo:
337 continue
338 else:
339 outputFrameDict.addFrame(
340 frameIdx,
341 self.frameDict.getMapping(frameIdx, frameIdx + 1),
342 self.frameDict.getFrame(frameIdx + 1))
343 # Append the final sky frame to the frame dict.
344 outputFrameDict.addFrame(
345 self.frameMax - 1,
346 iwcToSkyMap,
347 iwcsToSkyWcs.getFrameDict().getFrame("SKY"))
349 return SkyWcs(outputFrameDict)