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