1 from __future__
import absolute_import, division, print_function
3 __all__ = [
"FitSipDistortionTask",
"FitSipDistortionConfig"]
5 from builtins
import range
11 import lsst.afw.display
13 from .scaledPolynomialTransformFitter
import ScaledPolynomialTransformFitter, OutlierRejectionControl
14 from .sipTransform
import SipForwardTransform, SipReverseTransform, makeWcs
15 from .makeMatchStatistics
import makeMatchStatisticsInRadians
17 from .setMatchDistance
import setMatchDistance
21 order = lsst.pex.config.RangeField(
22 doc=
"Order of SIP polynomial",
27 numRejIter = lsst.pex.config.RangeField(
28 doc=
"Number of rejection iterations",
33 rejSigma = lsst.pex.config.RangeField(
34 doc=
"Number of standard deviations for clipping level",
39 nClipMin = lsst.pex.config.Field(
40 doc=
"Minimum number of matches to reject when sigma-clipping",
44 nClipMax = lsst.pex.config.Field(
45 doc=
"Maximum number of matches to reject when sigma-clipping",
49 maxScatterArcsec = lsst.pex.config.RangeField(
50 doc=
"Maximum median scatter of a WCS fit beyond which the fit fails (arcsec); " +
51 "be generous, as this is only intended to catch catastrophic failures",
56 refUncertainty = lsst.pex.config.Field(
57 doc=
"RMS uncertainty in reference catalog positions, in pixels. Will be added " +
58 "in quadrature with measured uncertainties in the fit.",
62 nGridX = lsst.pex.config.Field(
63 doc=
"Number of X grid points used to invert the SIP reverse transform.",
67 nGridY = lsst.pex.config.Field(
68 doc=
"Number of Y grid points used to invert the SIP reverse transform.",
72 gridBorder = lsst.pex.config.Field(
73 doc=
"When setting the gird region, how much to extend the image " +
74 "bounding box (in pixels) before transforming it to intermediate " +
75 "world coordinates using the initial WCS.",
82 """Fit a TAN-SIP WCS given a list of reference object/source matches
84 FitSipDistortionTask is a drop-in replacement for
85 :py:class:`lsst.meas.astrom.FitTanSipWcsTask`. It is built on fundamentally
86 stronger fitting algorithms, but has received significantly less testing.
88 Like :py:class:`lsst.meas.astrom.FitTanSipWcsTask`, this task is most
89 easily used as the wcsFitter component of
90 :py:class:`lsst.meas.astrom.AstrometryTask`; it can be enabled in a config
95 from lsst.meas.astrom import FitSipDistortionTask
96 config.(...).astometry.wcsFitter.retarget(FitSipDistortionTask)
101 The algorithm used by FitSipDistortionTask involves three steps:
103 - We set the CRVAL and CRPIX reference points to the mean positions of
104 the matches, while holding the CD matrix fixed to the value passed in
105 to the run() method. This work is done by the makeInitialWcs method.
107 - We fit the SIP "reverse transform" (the AP and BP polynomials that map
108 "intermediate world coordinates" to pixels). This happens iteratively;
109 while fitting for the polynomial coefficients given a set of matches is
110 a linear operation that can be done without iteration, outlier
111 rejection using sigma-clipping and estimation of the intrinsic scatter
112 are not. By fitting the reverse transform first, we can do outlier
113 rejection in pixel coordinates, where we can better handle the source
114 measurement uncertainties that contribute to the overall scatter. This
116 :cpp:class:`lsst::meas::astrom::ScaledPolynomialTransform`, which is
117 somewhat more general than the SIP reverse transform in that it allows
118 an affine transform both before and after the polynomial. This is
119 somewhat more numerically stable than the SIP form, which applies only
120 a linear transform (with no offset) before the polynomial and only a
121 shift afterwards. We only convert to SIP form once the fitting is
122 complete. This conversion is exact (though it may be subject to
123 significant round-off error) as long as we do not attempt to null the
124 low-order SIP polynomial terms (we do not).
126 - Once the SIP reverse transform has been fit, we use it to populate a
127 grid of points that we use as the data points for fitting its inverse,
128 the SIP forward transform. Because our "data" here is artificial,
129 there is no need for outlier rejection or uncertainty handling. We
130 again fit a general scaled polynomial, and only convert to SIP form
131 when the fit is complete.
137 Enabling DEBUG-level logging on this task will report the number of
138 outliers rejected and the current estimate of intrinsic scatter at each
141 FitSipDistortionTask also supports the following lsstDebug variables to
142 control diagnostic displays:
143 - FitSipDistortionTask.display: if True, enable display diagnostics.
144 - FitSipDistortionTask.frame: frame to which the display will be sent
145 - FitSipDistortionTask.pause: whether to pause (by dropping into pdb)
146 between iterations (default is True). If
147 False, multiple frames will be used,
148 starting at the given number.
150 The diagnostic display displays the image (or an empty image if
151 exposure=None) overlaid with the positions of sources and reference
152 objects will be shown for every iteration in the reverse transform fit.
153 The legend for the overlay is:
156 Reference sources transformed without SIP distortion terms; this
157 uses a TAN WCS whose CRPIX, CRVAL and CD matrix are the same
158 as those in the TAN-SIP WCS being fit. These are not expected to
159 line up with sources unless distortion is small.
162 Same as Red X, but for matches that were rejected as outliers.
165 Reference sources using the current best-fit TAN-SIP WCS. These
166 are connected to the corresponding non-distorted WCS position by
167 a red line, and should be a much better fit to source positions
171 Same as Red O, but for matches that were rejected as outliers.
174 Source positions and their error ellipses, including the current
175 estimate of the intrinsic scatter.
178 Same as Green Ellipse, but for matches that were rejected as outliers.
183 See :py:class:`lsst.pipe.base.Task`; FitSipDistortionTask does not add any
184 additional constructor parameters.
188 ConfigClass = FitSipDistortionConfig
189 _DefaultName =
"fitWcs"
192 lsst.pipe.base.Task.__init__(self, **kwds)
194 self.outlierRejectionCtrl.nClipMin = self.config.nClipMin
195 self.outlierRejectionCtrl.nClipMax = self.config.nClipMax
196 self.outlierRejectionCtrl.nSigma = self.config.rejSigma
198 @lsst.pipe.base.timeMethod
199 def fitWcs(self, matches, initWcs, bbox=None, refCat=None, sourceCat=None, exposure=None):
200 """Fit a TAN-SIP WCS from a list of reference object/source matches
205 matches : list of :cpp:class:`lsst::afw::table::ReferenceMatch`
206 A sequence of reference object/source matches.
207 The following fields are read:
208 - match.first (reference object) coord
209 - match.second (source) centroid
210 The following fields are written:
211 - match.first (reference object) centroid,
212 - match.second (source) centroid
213 - match.distance (on sky separation, in radians)
214 initWcs : :cpp:class:`lsst::afw::image::Wcs`
215 An initial WCS whose CD matrix is used as the final CD matrix.
216 bbox : :cpp:class:`lsst::afw::geom::Box2I`
217 The region over which the WCS will be valid (PARENT pixel coordinates);
218 if None or an empty box then computed from matches
219 refCat : :cpp:class:`lsst::afw::table::SimpleCatalog`
220 Reference object catalog, or None.
221 If provided then all centroids are updated with the new WCS,
222 otherwise only the centroids for ref objects in matches are updated.
223 Required fields are "centroid_x", "centroid_y", "coord_ra", and "coord_dec".
224 sourceCat : :cpp:class:`lsst::afw::table::SourceCatalog`
225 Source catalog, or None.
226 If provided then coords are updated with the new WCS;
227 otherwise only the coords for sources in matches are updated.
228 Required input fields are "slot_Centroid_x", "slot_Centroid_y",
229 "slot_Centroid_xSigma", "slot_Centroid_ySigma", and optionally
230 "slot_Centroid_x_y_Cov". The "coord_ra" and "coord_dec" fields
231 will be updated but are not used as input.
232 exposure : :cpp:class:`lsst::afw::image::Exposure`
233 An Exposure or other displayable image on which matches can be
234 overplotted. Ignored (and may be None) if display-based debugging
235 is not enabled via lsstDebug.
240 An lsst.pipe.base.Struct with the following fields:
242 wcs : :cpp:class:`lsst::afw::image::TanWcs`
244 scatterOnSky : :cpp:class:`lsst::afw::geom::Angle`
245 The median on-sky separation between reference objects and
246 sources in "matches", as an lsst.afw.geom.Angle
249 display = lsstDebug.Info(__name__).display
250 displayFrame = lsstDebug.Info(__name__).frame
251 displayPause = lsstDebug.Info(__name__).pause
254 bbox = lsst.afw.geom.Box2D()
255 for match
in matches:
256 bbox.include(match.second.getCentroid())
257 bbox = lsst.afw.geom.Box2I(bbox)
260 cdMatrix = lsst.afw.geom.LinearTransform(wcs.getCDMatrix())
268 revFitter = ScaledPolynomialTransformFitter.fromMatches(self.config.order, matches, wcs,
269 self.config.refUncertainty)
271 for nIter
in range(self.config.numRejIter):
272 revFitter.updateModel()
273 intrinsicScatter = revFitter.updateIntrinsicScatter()
276 "Iteration {0}: intrinsic scatter is {1:4.3f} pixels, "
277 "rejected {2} outliers at {3:3.2f} sigma.".format(
278 nIter+1, intrinsicScatter, nRejected, clippedSigma
282 displayFrame = self.
display(revFitter, exposure=exposure, bbox=bbox,
283 frame=displayFrame, displayPause=displayPause)
285 revScaledPoly = revFitter.getTransform()
289 sipReverse = SipReverseTransform.convert(revScaledPoly, wcs.getPixelOrigin(), cdMatrix)
296 gridBBoxPix = lsst.afw.geom.Box2D(bbox)
297 gridBBoxPix.grow(self.config.gridBorder)
302 gridBBoxIwc = lsst.afw.geom.Box2D()
303 for point
in gridBBoxPix.getCorners():
304 point -= lsst.afw.geom.Extent2D(wcs.getPixelOrigin())
305 gridBBoxIwc.include(cdMatrix(point))
306 fwdFitter = ScaledPolynomialTransformFitter.fromGrid(self.config.order, gridBBoxIwc,
307 self.config.nGridX, self.config.nGridY,
311 fwdScaledPoly = fwdFitter.getTransform()
312 sipForward = SipForwardTransform.convert(fwdScaledPoly, wcs.getPixelOrigin(), cdMatrix)
316 wcs =
makeWcs(sipForward, sipReverse, wcs.getSkyOrigin())
318 if refCat
is not None:
319 self.log.debug(
"Updating centroids in refCat")
320 lsst.afw.table.updateRefCentroids(wcs, refList=refCat)
322 self.log.warn(
"Updating reference object centroids in match list; refCat is None")
323 lsst.afw.table.updateRefCentroids(wcs, refList=[match.first
for match
in matches])
325 if sourceCat
is not None:
326 self.log.debug(
"Updating coords in sourceCat")
327 lsst.afw.table.updateSourceCoords(wcs, sourceList=sourceCat)
329 self.log.warn(
"Updating source coords in match list; sourceCat is None")
330 lsst.afw.table.updateSourceCoords(wcs, sourceList=[match.second
for match
in matches])
332 self.log.debug(
"Updating distance in match list")
336 scatterOnSky = stats.getValue()*lsst.afw.geom.radians
338 if scatterOnSky.asArcseconds() > self.config.maxScatterArcsec:
339 raise lsst.pipe.base.TaskError(
340 "Fit failed: median scatter on sky = %0.3f arcsec > %0.3f config.maxScatterArcsec" %
341 (scatterOnSky.asArcseconds(), self.config.maxScatterArcsec))
343 return lsst.pipe.base.Struct(
345 scatterOnSky=scatterOnSky,
348 def display(self, revFitter, exposure=None, bbox=None, frame=0, pause=True):
349 """Display positions and outlier status overlaid on an image.
351 This method is called by fitWcs when display debugging is enabled. It
352 always drops into pdb before returning to allow interactive inspection,
353 and hence it should never be called in non-interactive contexts.
358 revFitter : :cpp:class:`lsst::meas::astrom::ScaledPolynomialTransformFitter`
359 Fitter object initialized with `fromMatches` for fitting a "reverse"
360 distortion: the mapping from intermediate world coordinates to
362 exposure : :cpp:class:`lsst::afw::image::Exposure`
363 An Exposure or other displayable image on which matches can be
365 bbox : :cpp:class:`lsst::afw::geom::Box2I`
366 Bounding box of the region on which matches should be plotted.
368 data = revFitter.getData()
369 disp = lsst.afw.display.getDisplay(frame=frame)
370 if exposure
is not None:
372 elif bbox
is not None:
373 disp.mtv(exposure=lsst.afw.image.ExposureF(bbox))
375 raise TypeError(
"At least one of 'exposure' and 'bbox' must be provided.")
376 data = revFitter.getData()
377 srcKey = lsst.afw.table.Point2DKey(data.schema[
"src"])
378 srcErrKey = lsst.afw.table.CovarianceMatrix2fKey(data.schema[
"src"], [
"x",
"y"])
379 refKey = lsst.afw.table.Point2DKey(data.schema[
"initial"])
380 modelKey = lsst.afw.table.Point2DKey(data.schema[
"model"])
381 rejectedKey = data.schema.find(
"rejected").key
382 with disp.Buffering():
384 colors = ((lsst.afw.display.RED, lsst.afw.display.GREEN)
385 if not record.get(rejectedKey)
else
386 (lsst.afw.display.MAGENTA, lsst.afw.display.CYAN))
387 rx, ry = record.get(refKey)
388 disp.dot(
"x", rx, ry, size=10, ctype=colors[0])
389 mx, my = record.get(modelKey)
390 disp.dot(
"o", mx, my, size=10, ctype=colors[0])
391 disp.line([(rx, ry), (mx, my)], ctype=colors[0])
392 sx, sy = record.get(srcKey)
393 sErr = record.get(srcErrKey)
394 sEllipse = lsst.afw.geom.ellipses.Quadrupole(sErr[0, 0], sErr[1, 1], sErr[0, 1])
395 disp.dot(sEllipse, sx, sy, ctype=colors[1])
396 if pause
or pause
is None:
397 print(
"Dropping into debugger to allow inspection of display. Type 'continue' when done.")
405 """Generate a guess Wcs from the astrometric matches
407 We create a Wcs anchored at the center of the matches, with the scale
408 of the input Wcs. This is necessary because the Wcs may have a very
409 approximation position (as is common with telescoped-generated Wcs).
410 We're using the best of each: positions from the matches, and scale
415 matches : list of :cpp:class:`lsst::afw::table::ReferenceMatch`
416 A sequence of reference object/source matches.
417 The following fields are read:
418 - match.first (reference object) coord
419 - match.second (source) centroid
420 wcs : :cpp:class:`lsst::afw::image::Wcs`
421 An initial WCS whose CD matrix is used as the CD matrix of the
427 A new :cpp:class:`lsst::afw::image::Wcs`.
429 crpix = lsst.afw.geom.Extent2D(0, 0)
430 crval = lsst.afw.geom.Extent3D(0, 0, 0)
432 crpix += lsst.afw.geom.Extent2D(mm.second.getCentroid())
433 crval += lsst.afw.geom.Extent3D(mm.first.getCoord().toIcrs().getVector())
434 crpix /= len(matches)
435 crval /= len(matches)
436 cd = wcs.getCDMatrix()
437 newWcs = lsst.afw.image.makeWcs(lsst.afw.coord.IcrsCoord(lsst.afw.geom.Point3D(crval)),
438 lsst.afw.geom.Point2D(crpix), cd[0, 0], cd[0, 1], cd[1, 0], cd[1, 1])
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.
Control object for outlier rejection in ScaledPolynomialTransformFitter.