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