lsst.meas.astrom  14.0-7-g0d69b06+3
fitSipDistortion.py
Go to the documentation of this file.
1 from __future__ import absolute_import, division, print_function
2 
3 __all__ = ["FitSipDistortionTask", "FitSipDistortionConfig"]
4 
5 from builtins import range
6 
7 import lsst.pipe.base
8 import lsst.afw.image
9 import lsst.afw.geom
10 import lsst.afw.coord
11 import lsst.afw.display
12 
13 from .scaledPolynomialTransformFitter import ScaledPolynomialTransformFitter, OutlierRejectionControl
14 from .sipTransform import SipForwardTransform, SipReverseTransform, makeWcs
15 from .makeMatchStatistics import makeMatchStatisticsInRadians
16 
17 from .setMatchDistance import setMatchDistance
18 
19 
20 class FitSipDistortionConfig(lsst.pex.config.Config):
21  order = lsst.pex.config.RangeField(
22  doc="Order of SIP polynomial",
23  dtype=int,
24  default=4,
25  min=0,
26  )
27  numRejIter = lsst.pex.config.RangeField(
28  doc="Number of rejection iterations",
29  dtype=int,
30  default=3,
31  min=0,
32  )
33  rejSigma = lsst.pex.config.RangeField(
34  doc="Number of standard deviations for clipping level",
35  dtype=float,
36  default=3.0,
37  min=0.0,
38  )
39  nClipMin = lsst.pex.config.Field(
40  doc="Minimum number of matches to reject when sigma-clipping",
41  dtype=int,
42  default=0
43  )
44  nClipMax = lsst.pex.config.Field(
45  doc="Maximum number of matches to reject when sigma-clipping",
46  dtype=int,
47  default=1
48  )
49  maxScatterArcsec = lsst.pex.config.RangeField(
50  doc="Maximum median scatter of a WCS fit beyond which the fit fails (arcsec); " +
51  "be generous, as this is only intended to catch catastrophic failures",
52  dtype=float,
53  default=10,
54  min=0,
55  )
56  refUncertainty = lsst.pex.config.Field(
57  doc="RMS uncertainty in reference catalog positions, in pixels. Will be added " +
58  "in quadrature with measured uncertainties in the fit.",
59  dtype=float,
60  default=0.25,
61  )
62  nGridX = lsst.pex.config.Field(
63  doc="Number of X grid points used to invert the SIP reverse transform.",
64  dtype=int,
65  default=100,
66  )
67  nGridY = lsst.pex.config.Field(
68  doc="Number of Y grid points used to invert the SIP reverse transform.",
69  dtype=int,
70  default=100,
71  )
72  gridBorder = lsst.pex.config.Field(
73  doc="When setting the gird region, how much to extend the image " +
74  "bounding box (in pixels) before transforming it to intermediate " +
75  "world coordinates using the initial WCS.",
76  dtype=float,
77  default=50.0,
78  )
79 
80 
81 class FitSipDistortionTask(lsst.pipe.base.Task):
82  """Fit a TAN-SIP WCS given a list of reference object/source matches
83 
84  FitSipDistortionTask is a drop-in replacement for
85  :py:class:`lsst.meas.astrom.FitTanSipWcsTask`. It is built on fundamentally
86  stronger fitting algorithms, but has received significantly less testing.
87 
88  Like :py:class:`lsst.meas.astrom.FitTanSipWcsTask`, this task is most
89  easily used as the wcsFitter component of
90  :py:class:`lsst.meas.astrom.AstrometryTask`; it can be enabled in a config
91  file via e.g.
92 
93  .. code-block:: py
94 
95  from lsst.meas.astrom import FitSipDistortionTask
96  config.(...).astometry.wcsFitter.retarget(FitSipDistortionTask)
97 
98  Algorithm
99  ---------
100 
101  The algorithm used by FitSipDistortionTask involves three steps:
102 
103  - We set the CRVAL and CRPIX reference points to the mean positions of
104  the matches, while holding the CD matrix fixed to the value passed in
105  to the run() method. This work is done by the makeInitialWcs method.
106 
107  - We fit the SIP "reverse transform" (the AP and BP polynomials that map
108  "intermediate world coordinates" to pixels). This happens iteratively;
109  while fitting for the polynomial coefficients given a set of matches is
110  a linear operation that can be done without iteration, outlier
111  rejection using sigma-clipping and estimation of the intrinsic scatter
112  are not. By fitting the reverse transform first, we can do outlier
113  rejection in pixel coordinates, where we can better handle the source
114  measurement uncertainties that contribute to the overall scatter. This
115  fit results in a
116  :cpp:class:`lsst::meas::astrom::ScaledPolynomialTransform`, which is
117  somewhat more general than the SIP reverse transform in that it allows
118  an affine transform both before and after the polynomial. This is
119  somewhat more numerically stable than the SIP form, which applies only
120  a linear transform (with no offset) before the polynomial and only a
121  shift afterwards. We only convert to SIP form once the fitting is
122  complete. This conversion is exact (though it may be subject to
123  significant round-off error) as long as we do not attempt to null the
124  low-order SIP polynomial terms (we do not).
125 
126  - Once the SIP reverse transform has been fit, we use it to populate a
127  grid of points that we use as the data points for fitting its inverse,
128  the SIP forward transform. Because our "data" here is artificial,
129  there is no need for outlier rejection or uncertainty handling. We
130  again fit a general scaled polynomial, and only convert to SIP form
131  when the fit is complete.
132 
133 
134  Debugging
135  ---------
136 
137  Enabling DEBUG-level logging on this task will report the number of
138  outliers rejected and the current estimate of intrinsic scatter at each
139  iteration.
140 
141  FitSipDistortionTask also supports the following lsstDebug variables to
142  control diagnostic displays:
143  - FitSipDistortionTask.display: if True, enable display diagnostics.
144  - FitSipDistortionTask.frame: frame to which the display will be sent
145  - FitSipDistortionTask.pause: whether to pause (by dropping into pdb)
146  between iterations (default is True). If
147  False, multiple frames will be used,
148  starting at the given number.
149 
150  The diagnostic display displays the image (or an empty image if
151  exposure=None) overlaid with the positions of sources and reference
152  objects will be shown for every iteration in the reverse transform fit.
153  The legend for the overlay is:
154 
155  Red X
156  Reference sources transformed without SIP distortion terms; this
157  uses a TAN WCS whose CRPIX, CRVAL and CD matrix are the same
158  as those in the TAN-SIP WCS being fit. These are not expected to
159  line up with sources unless distortion is small.
160 
161  Magenta X
162  Same as Red X, but for matches that were rejected as outliers.
163 
164  Red O
165  Reference sources using the current best-fit TAN-SIP WCS. These
166  are connected to the corresponding non-distorted WCS position by
167  a red line, and should be a much better fit to source positions
168  than the Red Xs.
169 
170  Magenta O
171  Same as Red O, but for matches that were rejected as outliers.
172 
173  Green Ellipse
174  Source positions and their error ellipses, including the current
175  estimate of the intrinsic scatter.
176 
177  Cyan Ellipse
178  Same as Green Ellipse, but for matches that were rejected as outliers.
179 
180 
181  Parameters
182  ----------
183  See :py:class:`lsst.pipe.base.Task`; FitSipDistortionTask does not add any
184  additional constructor parameters.
185 
186  """
187 
188  ConfigClass = FitSipDistortionConfig
189  _DefaultName = "fitWcs"
190 
191  def __init__(self, **kwds):
192  lsst.pipe.base.Task.__init__(self, **kwds)
193  self.outlierRejectionCtrl = OutlierRejectionControl()
194  self.outlierRejectionCtrl.nClipMin = self.config.nClipMin
195  self.outlierRejectionCtrl.nClipMax = self.config.nClipMax
196  self.outlierRejectionCtrl.nSigma = self.config.rejSigma
197 
198  @lsst.pipe.base.timeMethod
199  def fitWcs(self, matches, initWcs, bbox=None, refCat=None, sourceCat=None, exposure=None):
200  """Fit a TAN-SIP WCS from a list of reference object/source matches
201 
202  Parameters
203  ----------
204 
205  matches : list of :cpp:class:`lsst::afw::table::ReferenceMatch`
206  A sequence of reference object/source matches.
207  The following fields are read:
208  - match.first (reference object) coord
209  - match.second (source) centroid
210  The following fields are written:
211  - match.first (reference object) centroid,
212  - match.second (source) centroid
213  - match.distance (on sky separation, in radians)
214  initWcs : :cpp:class:`lsst::afw::geom::SkyWcs`
215  An initial WCS whose CD matrix is used as the final CD matrix.
216  bbox : :cpp:class:`lsst::afw::geom::Box2I`
217  The region over which the WCS will be valid (PARENT pixel coordinates);
218  if None or an empty box then computed from matches
219  refCat : :cpp:class:`lsst::afw::table::SimpleCatalog`
220  Reference object catalog, or None.
221  If provided then all centroids are updated with the new WCS,
222  otherwise only the centroids for ref objects in matches are updated.
223  Required fields are "centroid_x", "centroid_y", "coord_ra", and "coord_dec".
224  sourceCat : :cpp:class:`lsst::afw::table::SourceCatalog`
225  Source catalog, or None.
226  If provided then coords are updated with the new WCS;
227  otherwise only the coords for sources in matches are updated.
228  Required input fields are "slot_Centroid_x", "slot_Centroid_y",
229  "slot_Centroid_xSigma", "slot_Centroid_ySigma", and optionally
230  "slot_Centroid_x_y_Cov". The "coord_ra" and "coord_dec" fields
231  will be updated but are not used as input.
232  exposure : :cpp:class:`lsst::afw::image::Exposure`
233  An Exposure or other displayable image on which matches can be
234  overplotted. Ignored (and may be None) if display-based debugging
235  is not enabled via lsstDebug.
236 
237  Returns
238  -------
239 
240  An lsst.pipe.base.Struct with the following fields:
241 
242  wcs : :cpp:class:`lsst::afw::geom::SkyWcs`
243  The best-fit WCS.
244  scatterOnSky : :cpp:class:`lsst::afw::geom::Angle`
245  The median on-sky separation between reference objects and
246  sources in "matches", as an lsst.afw.geom.Angle
247  """
248  import lsstDebug
249  display = lsstDebug.Info(__name__).display
250  displayFrame = lsstDebug.Info(__name__).frame
251  displayPause = lsstDebug.Info(__name__).pause
252 
253  if bbox is None:
254  bbox = lsst.afw.geom.Box2D()
255  for match in matches:
256  bbox.include(match.second.getCentroid())
257  bbox = lsst.afw.geom.Box2I(bbox)
258 
259  wcs = self.makeInitialWcs(matches, initWcs)
260  cdMatrix = lsst.afw.geom.LinearTransform(wcs.getCdMatrix())
261 
262  # Fit the "reverse" mapping from intermediate world coordinates to
263  # pixels, rejecting outliers. Fitting in this direction first makes it
264  # easier to handle the case where we have uncertainty on source
265  # positions but not reference positions. That's the case we have
266  # right now for purely bookeeeping reasons, and it may be the case we
267  # have in the future when we us Gaia as the reference catalog.
268  revFitter = ScaledPolynomialTransformFitter.fromMatches(self.config.order, matches, wcs,
269  self.config.refUncertainty)
270  revFitter.fit()
271  for nIter in range(self.config.numRejIter):
272  revFitter.updateModel()
273  intrinsicScatter = revFitter.updateIntrinsicScatter()
274  clippedSigma, nRejected = revFitter.rejectOutliers(self.outlierRejectionCtrl)
275  self.log.debug(
276  "Iteration {0}: intrinsic scatter is {1:4.3f} pixels, "
277  "rejected {2} outliers at {3:3.2f} sigma.".format(
278  nIter+1, intrinsicScatter, nRejected, clippedSigma
279  )
280  )
281  if display:
282  displayFrame = self.display(revFitter, exposure=exposure, bbox=bbox,
283  frame=displayFrame, displayPause=displayPause)
284  revFitter.fit()
285  revScaledPoly = revFitter.getTransform()
286  # Convert the generic ScaledPolynomialTransform result to SIP form
287  # with given CRPIX and CD (this is an exact conversion, up to
288  # floating-point round-off error)
289  sipReverse = SipReverseTransform.convert(revScaledPoly, wcs.getPixelOrigin(), cdMatrix)
290 
291  # Fit the forward mapping to a grid of points created from the reverse
292  # transform. Because that grid needs to be defined in intermediate
293  # world coordinates, and we don't have a good way to get from pixels to
294  # intermediate world coordinates yet (that's what we're fitting), we'll
295  # first grow the box to make it conservatively large...
296  gridBBoxPix = lsst.afw.geom.Box2D(bbox)
297  gridBBoxPix.grow(self.config.gridBorder)
298  # ...and then we'll transform using just the CRPIX offset and CD matrix
299  # linear transform, which is the TAN-only (no SIP distortion, and
300  # hence approximate) mapping from pixels to intermediate world
301  # coordinates.
302  gridBBoxIwc = lsst.afw.geom.Box2D()
303  for point in gridBBoxPix.getCorners():
304  point -= lsst.afw.geom.Extent2D(wcs.getPixelOrigin())
305  gridBBoxIwc.include(cdMatrix(point))
306  fwdFitter = ScaledPolynomialTransformFitter.fromGrid(self.config.order, gridBBoxIwc,
307  self.config.nGridX, self.config.nGridY,
308  revScaledPoly)
309  fwdFitter.fit()
310  # Convert to SIP forward form.
311  fwdScaledPoly = fwdFitter.getTransform()
312  sipForward = SipForwardTransform.convert(fwdScaledPoly, wcs.getPixelOrigin(), cdMatrix)
313 
314  # Make a new WCS from the SIP transform objects and the CRVAL in the
315  # initial WCS.
316  wcs = makeWcs(sipForward, sipReverse, wcs.getSkyOrigin())
317 
318  if refCat is not None:
319  self.log.debug("Updating centroids in refCat")
320  lsst.afw.table.updateRefCentroids(wcs, refList=refCat)
321  else:
322  self.log.warn("Updating reference object centroids in match list; refCat is None")
323  lsst.afw.table.updateRefCentroids(wcs, refList=[match.first for match in matches])
324 
325  if sourceCat is not None:
326  self.log.debug("Updating coords in sourceCat")
327  lsst.afw.table.updateSourceCoords(wcs, sourceList=sourceCat)
328  else:
329  self.log.warn("Updating source coords in match list; sourceCat is None")
330  lsst.afw.table.updateSourceCoords(wcs, sourceList=[match.second for match in matches])
331 
332  self.log.debug("Updating distance in match list")
333  setMatchDistance(matches)
334 
335  stats = makeMatchStatisticsInRadians(wcs, matches, lsst.afw.math.MEDIAN)
336  scatterOnSky = stats.getValue()*lsst.afw.geom.radians
337 
338  if scatterOnSky.asArcseconds() > self.config.maxScatterArcsec:
339  raise lsst.pipe.base.TaskError(
340  "Fit failed: median scatter on sky = %0.3f arcsec > %0.3f config.maxScatterArcsec" %
341  (scatterOnSky.asArcseconds(), self.config.maxScatterArcsec))
342 
343  return lsst.pipe.base.Struct(
344  wcs=wcs,
345  scatterOnSky=scatterOnSky,
346  )
347 
348  def display(self, revFitter, exposure=None, bbox=None, frame=0, pause=True):
349  """Display positions and outlier status overlaid on an image.
350 
351  This method is called by fitWcs when display debugging is enabled. It
352  always drops into pdb before returning to allow interactive inspection,
353  and hence it should never be called in non-interactive contexts.
354 
355  Parameters
356  ----------
357 
358  revFitter : :cpp:class:`lsst::meas::astrom::ScaledPolynomialTransformFitter`
359  Fitter object initialized with `fromMatches` for fitting a "reverse"
360  distortion: the mapping from intermediate world coordinates to
361  pixels.
362  exposure : :cpp:class:`lsst::afw::image::Exposure`
363  An Exposure or other displayable image on which matches can be
364  overplotted.
365  bbox : :cpp:class:`lsst::afw::geom::Box2I`
366  Bounding box of the region on which matches should be plotted.
367  """
368  data = revFitter.getData()
369  disp = lsst.afw.display.getDisplay(frame=frame)
370  if exposure is not None:
371  disp.mtv(exposure)
372  elif bbox is not None:
373  disp.mtv(exposure=lsst.afw.image.ExposureF(bbox))
374  else:
375  raise TypeError("At least one of 'exposure' and 'bbox' must be provided.")
376  data = revFitter.getData()
377  srcKey = lsst.afw.table.Point2DKey(data.schema["src"])
378  srcErrKey = lsst.afw.table.CovarianceMatrix2fKey(data.schema["src"], ["x", "y"])
379  refKey = lsst.afw.table.Point2DKey(data.schema["initial"])
380  modelKey = lsst.afw.table.Point2DKey(data.schema["model"])
381  rejectedKey = data.schema.find("rejected").key
382  with disp.Buffering():
383  for record in data:
384  colors = ((lsst.afw.display.RED, lsst.afw.display.GREEN)
385  if not record.get(rejectedKey) else
386  (lsst.afw.display.MAGENTA, lsst.afw.display.CYAN))
387  rx, ry = record.get(refKey)
388  disp.dot("x", rx, ry, size=10, ctype=colors[0])
389  mx, my = record.get(modelKey)
390  disp.dot("o", mx, my, size=10, ctype=colors[0])
391  disp.line([(rx, ry), (mx, my)], ctype=colors[0])
392  sx, sy = record.get(srcKey)
393  sErr = record.get(srcErrKey)
394  sEllipse = lsst.afw.geom.Quadrupole(sErr[0, 0], sErr[1, 1], sErr[0, 1])
395  disp.dot(sEllipse, sx, sy, ctype=colors[1])
396  if pause or pause is None: # default is to pause
397  print("Dropping into debugger to allow inspection of display. Type 'continue' when done.")
398  import pdb
399  pdb.set_trace()
400  return frame
401  else:
402  return frame + 1 # increment and return the frame for the next iteration.
403 
404  def makeInitialWcs(self, matches, wcs):
405  """Generate a guess Wcs from the astrometric matches
406 
407  We create a Wcs anchored at the center of the matches, with the scale
408  of the input Wcs. This is necessary because the Wcs may have a very
409  approximation position (as is common with telescoped-generated Wcs).
410  We're using the best of each: positions from the matches, and scale
411  from the input Wcs.
412 
413  Parameters
414  ----------
415  matches : list of :cpp:class:`lsst::afw::table::ReferenceMatch`
416  A sequence of reference object/source matches.
417  The following fields are read:
418  - match.first (reference object) coord
419  - match.second (source) centroid
420  wcs : :cpp:class:`lsst::afw::geom::SkyWcs`
421  An initial WCS whose CD matrix is used as the CD matrix of the
422  result.
423 
424  Returns
425  -------
426 
427  A new :cpp:class:`lsst::afw::geom::SkyWcs`.
428  """
429  crpix = lsst.afw.geom.Extent2D(0, 0)
430  crval = lsst.afw.geom.Extent3D(0, 0, 0)
431  for mm in matches:
432  crpix += lsst.afw.geom.Extent2D(mm.second.getCentroid())
433  crval += lsst.afw.geom.Extent3D(mm.first.getCoord().toIcrs().getVector())
434  crpix /= len(matches)
435  crval /= len(matches)
436  cd = wcs.getCdMatrix()
437  newWcs = lsst.afw.geom.makeSkyWcs(crpix=lsst.afw.geom.Point2D(crpix),
439  cdMatrix=cd)
440  return newWcs
std::shared_ptr< afw::geom::SkyWcs > makeWcs(SipForwardTransform const &sipForward, SipReverseTransform const &sipReverse, afw::coord::IcrsCoord const &skyOrigin)
Create a new TAN SIP Wcs from a pair of SIP transforms and the sky origin.
def fitWcs(self, matches, initWcs, bbox=None, refCat=None, sourceCat=None, exposure=None)
def display(self, revFitter, exposure=None, bbox=None, frame=0, pause=True)
std::shared_ptr< SkyWcs > makeSkyWcs(Point2D const &crpix, coord::IcrsCoord const &crval, Eigen::Matrix2d const &cdMatrix, std::string const &projection="TAN")
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.