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