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