lsst.meas.astrom  22.0.1-8-g903eb1c+d63d89abdd
fitTanSipWcs.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__ = ["FitTanSipWcsTask", "FitTanSipWcsConfig"]
23 
24 
25 import numpy as np
26 
27 import lsst.geom
28 import lsst.sphgeom
29 import lsst.afw.geom as afwGeom
30 import lsst.afw.table as afwTable
31 import lsst.pex.config as pexConfig
32 import lsst.pipe.base as pipeBase
33 from lsst.utils.timer import timeMethod
34 from .setMatchDistance import setMatchDistance
35 from .sip import makeCreateWcsWithSip
36 
37 
38 class FitTanSipWcsConfig(pexConfig.Config):
39  """Config for FitTanSipWcsTask."""
40  order = pexConfig.RangeField(
41  doc="order of SIP polynomial",
42  dtype=int,
43  default=2,
44  min=0,
45  )
46  numIter = pexConfig.RangeField(
47  doc="number of iterations of fitter (which fits X and Y separately, and so benefits from "
48  "a few iterations",
49  dtype=int,
50  default=3,
51  min=1,
52  )
53  numRejIter = pexConfig.RangeField(
54  doc="number of rejection iterations",
55  dtype=int,
56  default=1,
57  min=0,
58  )
59  rejSigma = pexConfig.RangeField(
60  doc="Number of standard deviations for clipping level",
61  dtype=float,
62  default=3.0,
63  min=0.0,
64  )
65  maxScatterArcsec = pexConfig.RangeField(
66  doc="maximum median scatter of a WCS fit beyond which the fit fails (arcsec); "
67  "be generous, as this is only intended to catch catastrophic failures",
68  dtype=float,
69  default=10,
70  min=0,
71  )
72 
73 
74 class FitTanSipWcsTask(pipeBase.Task):
75  """Fit a TAN-SIP WCS given a list of reference object/source matches.
76  """
77  ConfigClass = FitTanSipWcsConfig
78  _DefaultName = "fitWcs"
79 
80  @timeMethod
81  def fitWcs(self, matches, initWcs, bbox=None, refCat=None, sourceCat=None, exposure=None):
82  """Fit a TAN-SIP WCS from a list of reference object/source matches
83 
84  Parameters
85  ----------
86  matches : `list` of `lsst.afw.table.ReferenceMatch`
87  The following fields are read:
88 
89  - match.first (reference object) coord
90  - match.second (source) centroid
91 
92  The following fields are written:
93 
94  - match.first (reference object) centroid,
95  - match.second (source) centroid
96  - match.distance (on sky separation, in radians)
97 
98  initWcs : `lsst.afw.geom.SkyWcs`
99  initial WCS
100  bbox : `lsst.geom.Box2I`
101  the region over which the WCS will be valid (an lsst:afw::geom::Box2I);
102  if None or an empty box then computed from matches
103  refCat : `lsst.afw.table.SimpleCatalog`
104  reference object catalog, or None.
105  If provided then all centroids are updated with the new WCS,
106  otherwise only the centroids for ref objects in matches are updated.
107  Required fields are "centroid_x", "centroid_y", "coord_ra", and "coord_dec".
108  sourceCat : `lsst.afw.table.SourceCatalog`
109  source catalog, or None.
110  If provided then coords are updated with the new WCS;
111  otherwise only the coords for sources in matches are updated.
112  Required fields are "slot_Centroid_x", "slot_Centroid_y", and "coord_ra", and "coord_dec".
113  exposure : `lsst.afw.image.Exposure`
114  Ignored; present for consistency with FitSipDistortionTask.
115 
116  Returns
117  -------
118  result : `lsst.pipe.base.Struct`
119  with the following fields:
120 
121  - ``wcs`` : the fit WCS (`lsst.afw.geom.SkyWcs`)
122  - ``scatterOnSky`` : median on-sky separation between reference
123  objects and sources in "matches" (`lsst.afw.geom.Angle`)
124  """
125  if bbox is None:
126  bbox = lsst.geom.Box2I()
127 
128  import lsstDebug
129  debug = lsstDebug.Info(__name__)
130 
131  wcs = self.initialWcsinitialWcs(matches, initWcs)
132  rejected = np.zeros(len(matches), dtype=bool)
133  for rej in range(self.config.numRejIter):
134  sipObject = self._fitWcs_fitWcs([mm for i, mm in enumerate(matches) if not rejected[i]], wcs)
135  wcs = sipObject.getNewWcs()
136  rejected = self.rejectMatchesrejectMatches(matches, wcs, rejected)
137  if rejected.sum() == len(rejected):
138  raise RuntimeError("All matches rejected in iteration %d" % (rej + 1,))
139  self.log.debug(
140  "Iteration {0} of astrometry fitting: rejected {1} outliers, "
141  "out of {2} total matches.".format(
142  rej, rejected.sum(), len(rejected)
143  )
144  )
145  if debug.plot:
146  print("Plotting fit after rejection iteration %d/%d" % (rej + 1, self.config.numRejIter))
147  self.plotFitplotFit(matches, wcs, rejected)
148  # Final fit after rejection
149  sipObject = self._fitWcs_fitWcs([mm for i, mm in enumerate(matches) if not rejected[i]], wcs)
150  wcs = sipObject.getNewWcs()
151  if debug.plot:
152  print("Plotting final fit")
153  self.plotFitplotFit(matches, wcs, rejected)
154 
155  if refCat is not None:
156  self.log.debug("Updating centroids in refCat")
157  afwTable.updateRefCentroids(wcs, refList=refCat)
158  else:
159  self.log.warn("Updating reference object centroids in match list; refCat is None")
160  afwTable.updateRefCentroids(wcs, refList=[match.first for match in matches])
161 
162  if sourceCat is not None:
163  self.log.debug("Updating coords in sourceCat")
164  afwTable.updateSourceCoords(wcs, sourceList=sourceCat)
165  else:
166  self.log.warn("Updating source coords in match list; sourceCat is None")
167  afwTable.updateSourceCoords(wcs, sourceList=[match.second for match in matches])
168 
169  self.log.debug("Updating distance in match list")
170  setMatchDistance(matches)
171 
172  scatterOnSky = sipObject.getScatterOnSky()
173 
174  if scatterOnSky.asArcseconds() > self.config.maxScatterArcsec:
175  raise pipeBase.TaskError(
176  "Fit failed: median scatter on sky = %0.3f arcsec > %0.3f config.maxScatterArcsec" %
177  (scatterOnSky.asArcseconds(), self.config.maxScatterArcsec))
178 
179  return pipeBase.Struct(
180  wcs=wcs,
181  scatterOnSky=scatterOnSky,
182  )
183 
184  def initialWcs(self, matches, wcs):
185  """Generate a guess Wcs from the astrometric matches
186 
187  We create a Wcs anchored at the center of the matches, with the scale
188  of the input Wcs. This is necessary because matching returns only
189  matches with no estimated Wcs, and the input Wcs is a wild guess.
190  We're using the best of each: positions from the matches, and scale
191  from the input Wcs.
192 
193  Parameters
194  ----------
195  matches : `list` of `lsst.afw.table.ReferenceMatch`
196  List of sources matched to references.
197  wcs : `lsst.afw.geom.SkyWcs`
198  Current WCS.
199 
200  Returns
201  -------
202  newWcs : `lsst.afw.geom.SkyWcs`
203  Initial WCS guess from estimated crpix and crval.
204  """
205  crpix = lsst.geom.Extent2D(0, 0)
206  crval = lsst.sphgeom.Vector3d(0, 0, 0)
207  for mm in matches:
208  crpix += lsst.geom.Extent2D(mm.second.getCentroid())
209  crval += mm.first.getCoord().getVector()
210  crpix /= len(matches)
211  crval /= len(matches)
212  newWcs = afwGeom.makeSkyWcs(crpix=lsst.geom.Point2D(crpix),
213  crval=lsst.geom.SpherePoint(crval),
214  cdMatrix=wcs.getCdMatrix())
215  return newWcs
216 
217  def _fitWcs(self, matches, wcs):
218  """Fit a Wcs based on the matches and a guess Wcs.
219 
220  Parameters
221  ----------
222  matches : `list` of `lsst.afw.table.ReferenceMatch`
223  List of sources matched to references.
224  wcs : `lsst.afw.geom.SkyWcs`
225  Current WCS.
226 
227  Returns
228  -------
229  sipObject : `lsst.meas.astrom.sip.CreateWcsWithSip`
230  Fitted SIP object.
231  """
232  for i in range(self.config.numIter):
233  sipObject = makeCreateWcsWithSip(matches, wcs, self.config.order)
234  wcs = sipObject.getNewWcs()
235  return sipObject
236 
237  def rejectMatches(self, matches, wcs, rejected):
238  """Flag deviant matches
239 
240  We return a boolean numpy array indicating whether the corresponding
241  match should be rejected. The previous list of rejections is used
242  so we can calculate uncontaminated statistics.
243 
244  Parameters
245  ----------
246  matches : `list` of `lsst.afw.table.ReferenceMatch`
247  List of sources matched to references.
248  wcs : `lsst.afw.geom.SkyWcs`
249  Fitted WCS.
250  rejected : array-like of `bool`
251  Array of matches rejected from the fit. Unused.
252 
253  Returns
254  -------
255  rejectedMatches : `ndarray` of type `bool`
256  Matched objects found to be outside of tolerance.
257  """
258  fit = [wcs.skyToPixel(m.first.getCoord()) for m in matches]
259  dx = np.array([ff.getX() - mm.second.getCentroid().getX() for ff, mm in zip(fit, matches)])
260  dy = np.array([ff.getY() - mm.second.getCentroid().getY() for ff, mm in zip(fit, matches)])
261  good = np.logical_not(rejected)
262  return (dx > self.config.rejSigma*dx[good].std()) | (dy > self.config.rejSigma*dy[good].std())
263 
264  def plotFit(self, matches, wcs, rejected):
265  """Plot the fit
266 
267  We create four plots, for all combinations of (dx, dy) against
268  (x, y). Good points are black, while rejected points are red.
269 
270  Parameters
271  ----------
272  matches : `list` of `lsst.afw.table.ReferenceMatch`
273  List of sources matched to references.
274  wcs : `lsst.afw.geom.SkyWcs`
275  Fitted WCS.
276  rejected : array-like of `bool`
277  Array of matches rejected from the fit.
278  """
279  try:
280  import matplotlib.pyplot as plt
281  except ImportError as e:
282  self.log.warn("Unable to import matplotlib: %s", e)
283  return
284 
285  fit = [wcs.skyToPixel(m.first.getCoord()) for m in matches]
286  x1 = np.array([ff.getX() for ff in fit])
287  y1 = np.array([ff.getY() for ff in fit])
288  x2 = np.array([m.second.getCentroid().getX() for m in matches])
289  y2 = np.array([m.second.getCentroid().getY() for m in matches])
290 
291  dx = x1 - x2
292  dy = y1 - y2
293 
294  good = np.logical_not(rejected)
295 
296  figure = plt.figure()
297  axes = figure.add_subplot(2, 2, 1)
298  axes.plot(x2[good], dx[good], 'ko')
299  axes.plot(x2[rejected], dx[rejected], 'ro')
300  axes.set_xlabel("x")
301  axes.set_ylabel("dx")
302 
303  axes = figure.add_subplot(2, 2, 2)
304  axes.plot(x2[good], dy[good], 'ko')
305  axes.plot(x2[rejected], dy[rejected], 'ro')
306  axes.set_xlabel("x")
307  axes.set_ylabel("dy")
308 
309  axes = figure.add_subplot(2, 2, 3)
310  axes.plot(y2[good], dx[good], 'ko')
311  axes.plot(y2[rejected], dx[rejected], 'ro')
312  axes.set_xlabel("y")
313  axes.set_ylabel("dx")
314 
315  axes = figure.add_subplot(2, 2, 4)
316  axes.plot(y2[good], dy[good], 'ko')
317  axes.plot(y2[rejected], dy[rejected], 'ro')
318  axes.set_xlabel("y")
319  axes.set_ylabel("dy")
320 
321  plt.show()
def fitWcs(self, matches, initWcs, bbox=None, refCat=None, sourceCat=None, exposure=None)
Definition: fitTanSipWcs.py:81
def plotFit(self, matches, wcs, rejected)
def rejectMatches(self, matches, wcs, rejected)
void updateRefCentroids(geom::SkyWcs const &wcs, ReferenceCollection &refList)
void updateSourceCoords(geom::SkyWcs const &wcs, SourceCollection &sourceList)
CreateWcsWithSip< MatchT > makeCreateWcsWithSip(std::vector< MatchT > const &matches, afw::geom::SkyWcs const &linearWcs, int const order, geom::Box2I const &bbox=geom::Box2I(), int const ngrid=0)
Factory function for CreateWcsWithSip.
STL namespace.