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