lsst.meas.astrom  22.0.1-8-g903eb1c+d5b52a074f
fitSipDistortion.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__ = ["FitSipDistortionTask", "FitSipDistortionConfig"]
23 
24 
25 import lsst.sphgeom
26 import lsst.pipe.base
27 import lsst.geom
28 import lsst.afw.image
29 import lsst.afw.geom
30 import lsst.afw.display
31 from lsst.utils.timer import timeMethod
32 
33 from .scaledPolynomialTransformFitter import ScaledPolynomialTransformFitter, OutlierRejectionControl
34 from .sipTransform import SipForwardTransform, SipReverseTransform, makeWcs
35 from .makeMatchStatistics import makeMatchStatisticsInRadians
36 
37 from .setMatchDistance import setMatchDistance
38 
39 
40 class FitSipDistortionConfig(lsst.pex.config.Config):
41  """Config for FitSipDistortionTask"""
42  order = lsst.pex.config.RangeField(
43  doc="Order of SIP polynomial",
44  dtype=int,
45  default=4,
46  min=0,
47  )
48  numRejIter = lsst.pex.config.RangeField(
49  doc="Number of rejection iterations",
50  dtype=int,
51  default=3,
52  min=0,
53  )
54  rejSigma = lsst.pex.config.RangeField(
55  doc="Number of standard deviations for clipping level",
56  dtype=float,
57  default=3.0,
58  min=0.0,
59  )
60  nClipMin = lsst.pex.config.Field(
61  doc="Minimum number of matches to reject when sigma-clipping",
62  dtype=int,
63  default=0
64  )
65  nClipMax = lsst.pex.config.Field(
66  doc="Maximum number of matches to reject when sigma-clipping",
67  dtype=int,
68  default=1
69  )
70  maxScatterArcsec = lsst.pex.config.RangeField(
71  doc="Maximum median scatter of a WCS fit beyond which the fit fails (arcsec); "
72  "be generous, as this is only intended to catch catastrophic failures",
73  dtype=float,
74  default=10,
75  min=0,
76  )
77  refUncertainty = lsst.pex.config.Field(
78  doc="RMS uncertainty in reference catalog positions, in pixels. Will be added "
79  "in quadrature with measured uncertainties in the fit.",
80  dtype=float,
81  default=0.25,
82  )
83  nGridX = lsst.pex.config.Field(
84  doc="Number of X grid points used to invert the SIP reverse transform.",
85  dtype=int,
86  default=100,
87  )
88  nGridY = lsst.pex.config.Field(
89  doc="Number of Y grid points used to invert the SIP reverse transform.",
90  dtype=int,
91  default=100,
92  )
93  gridBorder = lsst.pex.config.Field(
94  doc="When setting the gird region, how much to extend the image "
95  "bounding box (in pixels) before transforming it to intermediate "
96  "world coordinates using the initial WCS.",
97  dtype=float,
98  default=50.0,
99  )
100 
101 
102 class FitSipDistortionTask(lsst.pipe.base.Task):
103  """Fit a TAN-SIP WCS given a list of reference object/source matches.
104  """
105  ConfigClass = FitSipDistortionConfig
106  _DefaultName = "fitWcs"
107 
108  def __init__(self, **kwargs):
109  lsst.pipe.base.Task.__init__(self, **kwargs)
110  self.outlierRejectionCtrloutlierRejectionCtrl = OutlierRejectionControl()
111  self.outlierRejectionCtrloutlierRejectionCtrl.nClipMin = self.config.nClipMin
112  self.outlierRejectionCtrloutlierRejectionCtrl.nClipMax = self.config.nClipMax
113  self.outlierRejectionCtrloutlierRejectionCtrl.nSigma = self.config.rejSigma
114 
115  @timeMethod
116  def fitWcs(self, matches, initWcs, bbox=None, refCat=None, sourceCat=None, exposure=None):
117  """Fit a TAN-SIP WCS from a list of reference object/source matches.
118 
119  Parameters
120  ----------
121  matches : `list` of `lsst.afw.table.ReferenceMatch`
122  A sequence of reference object/source matches.
123  The following fields are read:
124  - match.first (reference object) coord
125  - match.second (source) centroid
126 
127  The following fields are written:
128  - match.first (reference object) centroid
129  - match.second (source) centroid
130  - match.distance (on sky separation, in radians)
131 
132  initWcs : `lsst.afw.geom.SkyWcs`
133  An initial WCS whose CD matrix is used as the final CD matrix.
134  bbox : `lsst.geom.Box2I`
135  The region over which the WCS will be valid (PARENT pixel coordinates);
136  if `None` or an empty box then computed from matches
137  refCat : `lsst.afw.table.SimpleCatalog`
138  Reference object catalog, or `None`.
139  If provided then all centroids are updated with the new WCS,
140  otherwise only the centroids for ref objects in matches are updated.
141  Required fields are "centroid_x", "centroid_y", "coord_ra", and "coord_dec".
142  sourceCat : `lsst.afw.table.SourceCatalog`
143  Source catalog, or `None`.
144  If provided then coords are updated with the new WCS;
145  otherwise only the coords for sources in matches are updated.
146  Required input fields are "slot_Centroid_x", "slot_Centroid_y",
147  "slot_Centroid_xErr", "slot_Centroid_yErr", and optionally
148  "slot_Centroid_x_y_Cov". The "coord_ra" and "coord_dec" fields
149  will be updated but are not used as input.
150  exposure : `lsst.afw.image.Exposure`
151  An Exposure or other displayable image on which matches can be
152  overplotted. Ignored (and may be `None`) if display-based debugging
153  is not enabled via lsstDebug.
154 
155  Returns
156  -------
157  An lsst.pipe.base.Struct with the following fields:
158  - wcs : `lsst.afw.geom.SkyWcs`
159  The best-fit WCS.
160  - scatterOnSky : `lsst.geom.Angle`
161  The median on-sky separation between reference objects and
162  sources in "matches", as an `lsst.geom.Angle`
163  """
164  import lsstDebug
165  display = lsstDebug.Info(__name__).display
166  displayFrame = lsstDebug.Info(__name__).frame
167  displayPause = lsstDebug.Info(__name__).pause
168 
169  if bbox is None:
170  bbox = lsst.geom.Box2D()
171  for match in matches:
172  bbox.include(match.second.getCentroid())
173  bbox = lsst.geom.Box2I(bbox)
174 
175  wcs = self.makeInitialWcsmakeInitialWcs(matches, initWcs)
176  cdMatrix = lsst.geom.LinearTransform(wcs.getCdMatrix())
177 
178  # Fit the "reverse" mapping from intermediate world coordinates to
179  # pixels, rejecting outliers. Fitting in this direction first makes it
180  # easier to handle the case where we have uncertainty on source
181  # positions but not reference positions. That's the case we have
182  # right now for purely bookeeeping reasons, and it may be the case we
183  # have in the future when we us Gaia as the reference catalog.
184  revFitter = ScaledPolynomialTransformFitter.fromMatches(self.config.order, matches, wcs,
185  self.config.refUncertainty)
186  revFitter.fit()
187  for nIter in range(self.config.numRejIter):
188  revFitter.updateModel()
189  intrinsicScatter = revFitter.updateIntrinsicScatter()
190  clippedSigma, nRejected = revFitter.rejectOutliers(self.outlierRejectionCtrloutlierRejectionCtrl)
191  self.log.debug(
192  "Iteration {0}: intrinsic scatter is {1:4.3f} pixels, "
193  "rejected {2} outliers at {3:3.2f} sigma.".format(
194  nIter+1, intrinsicScatter, nRejected, clippedSigma
195  )
196  )
197  if display:
198  displayFrame = self.displaydisplay(revFitter, exposure=exposure, bbox=bbox,
199  frame=displayFrame, displayPause=displayPause)
200  revFitter.fit()
201  revScaledPoly = revFitter.getTransform()
202  # Convert the generic ScaledPolynomialTransform result to SIP form
203  # with given CRPIX and CD (this is an exact conversion, up to
204  # floating-point round-off error)
205  sipReverse = SipReverseTransform.convert(revScaledPoly, wcs.getPixelOrigin(), cdMatrix)
206 
207  # Fit the forward mapping to a grid of points created from the reverse
208  # transform. Because that grid needs to be defined in intermediate
209  # world coordinates, and we don't have a good way to get from pixels to
210  # intermediate world coordinates yet (that's what we're fitting), we'll
211  # first grow the box to make it conservatively large...
212  gridBBoxPix = lsst.geom.Box2D(bbox)
213  gridBBoxPix.grow(self.config.gridBorder)
214  # ...and then we'll transform using just the CRPIX offset and CD matrix
215  # linear transform, which is the TAN-only (no SIP distortion, and
216  # hence approximate) mapping from pixels to intermediate world
217  # coordinates.
218  gridBBoxIwc = lsst.geom.Box2D()
219  for point in gridBBoxPix.getCorners():
220  point -= lsst.geom.Extent2D(wcs.getPixelOrigin())
221  gridBBoxIwc.include(cdMatrix(point))
222  fwdFitter = ScaledPolynomialTransformFitter.fromGrid(self.config.order, gridBBoxIwc,
223  self.config.nGridX, self.config.nGridY,
224  revScaledPoly)
225  fwdFitter.fit()
226  # Convert to SIP forward form.
227  fwdScaledPoly = fwdFitter.getTransform()
228  sipForward = SipForwardTransform.convert(fwdScaledPoly, wcs.getPixelOrigin(), cdMatrix)
229 
230  # Make a new WCS from the SIP transform objects and the CRVAL in the
231  # initial WCS.
232  wcs = makeWcs(sipForward, sipReverse, wcs.getSkyOrigin())
233 
234  if refCat is not None:
235  self.log.debug("Updating centroids in refCat")
236  lsst.afw.table.updateRefCentroids(wcs, refList=refCat)
237  else:
238  self.log.warn("Updating reference object centroids in match list; refCat is None")
239  lsst.afw.table.updateRefCentroids(wcs, refList=[match.first for match in matches])
240 
241  if sourceCat is not None:
242  self.log.debug("Updating coords in sourceCat")
243  lsst.afw.table.updateSourceCoords(wcs, sourceList=sourceCat)
244  else:
245  self.log.warn("Updating source coords in match list; sourceCat is None")
246  lsst.afw.table.updateSourceCoords(wcs, sourceList=[match.second for match in matches])
247 
248  self.log.debug("Updating distance in match list")
249  setMatchDistance(matches)
250 
251  stats = makeMatchStatisticsInRadians(wcs, matches, lsst.afw.math.MEDIAN)
252  scatterOnSky = stats.getValue()*lsst.geom.radians
253 
254  if scatterOnSky.asArcseconds() > self.config.maxScatterArcsec:
255  raise lsst.pipe.base.TaskError(
256  "Fit failed: median scatter on sky = %0.3f arcsec > %0.3f config.maxScatterArcsec" %
257  (scatterOnSky.asArcseconds(), self.config.maxScatterArcsec))
258 
259  return lsst.pipe.base.Struct(
260  wcs=wcs,
261  scatterOnSky=scatterOnSky,
262  )
263 
264  def display(self, revFitter, exposure=None, bbox=None, frame=0, pause=True):
265  """Display positions and outlier status overlaid on an image.
266 
267  This method is called by fitWcs when display debugging is enabled. It
268  always drops into pdb before returning to allow interactive inspection,
269  and hence it should never be called in non-interactive contexts.
270 
271  Parameters
272  ----------
273  revFitter : :cpp:class:`lsst::meas::astrom::ScaledPolynomialTransformFitter`
274  Fitter object initialized with `fromMatches` for fitting a "reverse"
275  distortion: the mapping from intermediate world coordinates to
276  pixels.
277  exposure : :cpp:class:`lsst::afw::image::Exposure`
278  An Exposure or other displayable image on which matches can be
279  overplotted.
280  bbox : :cpp:class:`lsst::afw::geom::Box2I`
281  Bounding box of the region on which matches should be plotted.
282  """
283  data = revFitter.getData()
284  disp = lsst.afw.display.getDisplay(frame=frame)
285  if exposure is not None:
286  disp.mtv(exposure)
287  elif bbox is not None:
288  disp.mtv(exposure=lsst.afw.image.ExposureF(bbox))
289  else:
290  raise TypeError("At least one of 'exposure' and 'bbox' must be provided.")
291  data = revFitter.getData()
292  srcKey = lsst.afw.table.Point2DKey(data.schema["src"])
293  srcErrKey = lsst.afw.table.CovarianceMatrix2fKey(data.schema["src"], ["x", "y"])
294  refKey = lsst.afw.table.Point2DKey(data.schema["initial"])
295  modelKey = lsst.afw.table.Point2DKey(data.schema["model"])
296  rejectedKey = data.schema.find("rejected").key
297  with disp.Buffering():
298  for record in data:
299  colors = ((lsst.afw.display.RED, lsst.afw.display.GREEN)
300  if not record.get(rejectedKey) else
301  (lsst.afw.display.MAGENTA, lsst.afw.display.CYAN))
302  rx, ry = record.get(refKey)
303  disp.dot("x", rx, ry, size=10, ctype=colors[0])
304  mx, my = record.get(modelKey)
305  disp.dot("o", mx, my, size=10, ctype=colors[0])
306  disp.line([(rx, ry), (mx, my)], ctype=colors[0])
307  sx, sy = record.get(srcKey)
308  sErr = record.get(srcErrKey)
309  sEllipse = lsst.afw.geom.Quadrupole(sErr[0, 0], sErr[1, 1], sErr[0, 1])
310  disp.dot(sEllipse, sx, sy, ctype=colors[1])
311  if pause or pause is None: # default is to pause
312  print("Dropping into debugger to allow inspection of display. Type 'continue' when done.")
313  import pdb
314  pdb.set_trace()
315  return frame
316  else:
317  return frame + 1 # increment and return the frame for the next iteration.
318 
319  def makeInitialWcs(self, matches, wcs):
320  """Generate a guess Wcs from the astrometric matches
321 
322  We create a Wcs anchored at the center of the matches, with the scale
323  of the input Wcs. This is necessary because the Wcs may have a very
324  approximation position (as is common with telescoped-generated Wcs).
325  We're using the best of each: positions from the matches, and scale
326  from the input Wcs.
327 
328  Parameters
329  ----------
330  matches : list of :cpp:class:`lsst::afw::table::ReferenceMatch`
331  A sequence of reference object/source matches.
332  The following fields are read:
333 
334  - match.first (reference object) coord
335  - match.second (source) centroid
336 
337  wcs : :cpp:class:`lsst::afw::geom::SkyWcs`
338  An initial WCS whose CD matrix is used as the CD matrix of the
339  result.
340 
341  Returns
342  -------
343  newWcs : `lsst.afw.geom.SkyWcs`
344  A new WCS guess.
345  """
346  crpix = lsst.geom.Extent2D(0, 0)
347  crval = lsst.sphgeom.Vector3d(0, 0, 0)
348  for mm in matches:
349  crpix += lsst.geom.Extent2D(mm.second.getCentroid())
350  crval += mm.first.getCoord().getVector()
351  crpix /= len(matches)
352  crval /= len(matches)
353  cd = wcs.getCdMatrix()
354  newWcs = lsst.afw.geom.makeSkyWcs(crpix=lsst.geom.Point2D(crpix),
355  crval=lsst.geom.SpherePoint(crval),
356  cdMatrix=cd)
357  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< SkyWcs > makeSkyWcs(daf::base::PropertySet &metadata, bool strip=false)
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.
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.