lsst.meas.astrom  22.0.1-8-g903eb1c+f5361015ae
fitAffineWcs.py
Go to the documentation of this file.
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/>.
21 
22 __all__ = ["FitAffineWcsTask", "FitAffineWcsConfig", "TransformedSkyWcsMaker"]
23 
24 
25 import astshim
26 import numpy as np
27 from scipy.optimize import least_squares
28 
29 from lsst.afw.geom import makeSkyWcs, SkyWcs
30 import lsst.afw.math
31 from lsst.geom import Point2D, degrees, arcseconds, radians
32 import lsst.pex.config as pexConfig
33 import lsst.pipe.base as pipeBase
34 from lsst.utils.timer import timeMethod
35 
36 from .makeMatchStatistics import makeMatchStatisticsInRadians
37 from .setMatchDistance import setMatchDistance
38 
39 
40 def _chiFunc(x, refPoints, srcPixels, wcsMaker):
41  """Function to minimize to fit the shift and rotation in the WCS.
42 
43  Parameters
44  ----------
45  x : `numpy.ndarray`
46  Current fit values to test. Float values in array are:
47 
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.
60 
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)))
68 
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())
83 
84  return outputSeparations
85 
86 
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.)
90 class FitAffineWcsConfig(pexConfig.Config):
91  """Config for FitTanSipWcsTask."""
92  pass
93 
94 
95 class FitAffineWcsTask(pipeBase.Task):
96  """Fit a TAN-SIP WCS given a list of reference object/source matches.
97 
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"
104 
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.
115 
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.
119 
120  Parameters
121  ----------
122  matches : `list` of `lsst.afw.table.ReferenceMatch`
123  The following fields are read:
124 
125  - match.first (reference object) coord
126  - match.second (source) centroid
127 
128  The following fields are written:
129 
130  - match.first (reference object) centroid,
131  - match.second (source) centroid
132  - match.distance (on sky separation, in radians)
133 
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.
152 
153  Returns
154  -------
155  result : `lsst.pipe.base.Struct`
156  with the following fields:
157 
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)
165 
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))
191 
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]))
210 
211  wcs = wcsMaker.makeWcs(fit.x[:2], fit.x[2:].reshape((2, 2)))
212 
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")
221  wcs,
222  refList=[match.first for match in matches])
223 
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")
231  wcs,
232  sourceList=[match.second for match in matches])
233  setMatchDistance(matches)
234 
235  stats = makeMatchStatisticsInRadians(wcs,
236  matches,
237  lsst.afw.math.MEDIAN)
238  scatterOnSky = stats.getValue() * radians
239 
240  self.log.debug("In fitter scatter %.4f" % scatterOnSky.asArcseconds())
241 
242  return lsst.pipe.base.Struct(
243  wcs=wcs,
244  scatterOnSky=scatterOnSky,
245  )
246 
247 
249  """Convenience class for appending a shifting an input SkyWcs on sky and
250  appending an affine transform.
251 
252  The class assumes that all frames are sequential and are mapped one to the
253  next.
254 
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  """
261 
262  def __init__(self, inputSkyWcs):
263  self.frameDictframeDict = inputSkyWcs.getFrameDict()
264 
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.frameDictframeDict.getAllDomains()
271  self.frameIdxsframeIdxs = np.sort([self.frameDictframeDict.getIndex(domain)
272  for domain in domains])
273  self.frameMinframeMin = np.min(self.frameIdxsframeIdxs)
274  self.frameMaxframeMax = np.max(self.frameIdxsframeIdxs)
275 
276  # Find frame just before the final mapping to sky and store those
277  # indices and mappings for later.
278  self.mapFrommapFrom = self.frameMaxframeMax - 2
279  if self.mapFrommapFrom < self.frameMinframeMin:
280  self.mapFrommapFrom = self.frameMinframeMin
281  self.mapTomapTo = self.frameMaxframeMax - 1
282  if self.mapTomapTo <= self.mapFrommapFrom:
283  self.mapTomapTo = self.frameMaxframeMax
284  self.lastMapBeforeSkylastMapBeforeSky = self.frameDictframeDict.getMapping(
285  self.mapFrommapFrom, self.mapTomapTo)
286 
287  # Get the original WCS sky location.
288 
289  self.originorigin = inputSkyWcs.getSkyOrigin()
290 
291  def makeWcs(self, crvalOffset, affMatrix):
292  """Apply a shift and affine transform to the WCS internal to this
293  class.
294 
295  A new SkyWcs with these transforms applied is returns.
296 
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.
304 
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.originorigin.offset(crvalOffset[0] * degrees,
317  crvalOffset[1] * arcseconds),
318  np.array([[1., 0.], [0., 1.]]))
319  iwcToSkyMap = iwcsToSkyWcs.getFrameDict().getMapping("PIXELS", "SKY")
320 
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.lastMapBeforeSkylastMapBeforeSky.then(astshim.MatrixMap(affMatrix))
324 
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.frameDictframeDict.getFrame(self.frameMinframeMin))
330  for frameIdx in self.frameIdxsframeIdxs:
331  if frameIdx == self.mapFrommapFrom:
332  outputFrameDict.addFrame(
333  self.mapFrommapFrom,
334  newMapping,
335  self.frameDictframeDict.getFrame(self.mapTomapTo))
336  elif frameIdx >= self.mapTomapTo:
337  continue
338  else:
339  outputFrameDict.addFrame(
340  frameIdx,
341  self.frameDictframeDict.getMapping(frameIdx, frameIdx + 1),
342  self.frameDictframeDict.getFrame(frameIdx + 1))
343  # Append the final sky frame to the frame dict.
344  outputFrameDict.addFrame(
345  self.frameMaxframeMax - 1,
346  iwcToSkyMap,
347  iwcsToSkyWcs.getFrameDict().getFrame("SKY"))
348 
349  return SkyWcs(outputFrameDict)
def fitWcs(self, matches, initWcs, bbox=None, refCat=None, sourceCat=None, exposure=None)
void updateRefCentroids(geom::SkyWcs const &wcs, ReferenceCollection &refList)
void updateSourceCoords(geom::SkyWcs const &wcs, SourceCollection &sourceList)
afw::math::Statistics makeMatchStatisticsInRadians(afw::geom::SkyWcs const &wcs, std::vector< MatchT > const &matchList, int const flags, afw::math::StatisticsControl const &sctrl=afw::math::StatisticsControl())
Compute statistics of on-sky radial separation for a match list, in radians.