Coverage for python/lsst/meas/astrom/fitTanSipWcs.py : 16%

Hot-keys on this page
r m x p toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
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/>.
22__all__ = ["FitTanSipWcsTask", "FitTanSipWcsConfig"]
25import numpy as np
27import lsst.geom
28import lsst.sphgeom
29import lsst.afw.geom as afwGeom
30import lsst.afw.table as afwTable
31import lsst.pex.config as pexConfig
32import lsst.pipe.base as pipeBase
33from lsst.utils.timer import timeMethod
34from .setMatchDistance import setMatchDistance
35from .sip import makeCreateWcsWithSip
38class FitTanSipWcsConfig(pexConfig.Config):
39 """Config for FitTanSipWcsTask."""
40 order = pexConfig.RangeField(
41 doc="order of SIP polynomial",
42 dtype=int,
43 default=2,
44 min=0,
45 )
46 numIter = pexConfig.RangeField(
47 doc="number of iterations of fitter (which fits X and Y separately, and so benefits from "
48 "a few iterations",
49 dtype=int,
50 default=3,
51 min=1,
52 )
53 numRejIter = pexConfig.RangeField(
54 doc="number of rejection iterations",
55 dtype=int,
56 default=1,
57 min=0,
58 )
59 rejSigma = pexConfig.RangeField(
60 doc="Number of standard deviations for clipping level",
61 dtype=float,
62 default=3.0,
63 min=0.0,
64 )
65 maxScatterArcsec = pexConfig.RangeField(
66 doc="maximum median scatter of a WCS fit beyond which the fit fails (arcsec); "
67 "be generous, as this is only intended to catch catastrophic failures",
68 dtype=float,
69 default=10,
70 min=0,
71 )
74class FitTanSipWcsTask(pipeBase.Task):
75 """Fit a TAN-SIP WCS given a list of reference object/source matches.
76 """
77 ConfigClass = FitTanSipWcsConfig
78 _DefaultName = "fitWcs"
80 @timeMethod
81 def fitWcs(self, matches, initWcs, bbox=None, refCat=None, sourceCat=None, exposure=None):
82 """Fit a TAN-SIP WCS from a list of reference object/source matches
84 Parameters
85 ----------
86 matches : `list` of `lsst.afw.table.ReferenceMatch`
87 The following fields are read:
89 - match.first (reference object) coord
90 - match.second (source) centroid
92 The following fields are written:
94 - match.first (reference object) centroid,
95 - match.second (source) centroid
96 - match.distance (on sky separation, in radians)
98 initWcs : `lsst.afw.geom.SkyWcs`
99 initial WCS
100 bbox : `lsst.geom.Box2I`
101 the region over which the WCS will be valid (an lsst:afw::geom::Box2I);
102 if None or an empty box then computed from matches
103 refCat : `lsst.afw.table.SimpleCatalog`
104 reference object catalog, or None.
105 If provided then all centroids are updated with the new WCS,
106 otherwise only the centroids for ref objects in matches are updated.
107 Required fields are "centroid_x", "centroid_y", "coord_ra", and "coord_dec".
108 sourceCat : `lsst.afw.table.SourceCatalog`
109 source catalog, or None.
110 If provided then coords are updated with the new WCS;
111 otherwise only the coords for sources in matches are updated.
112 Required fields are "slot_Centroid_x", "slot_Centroid_y", and "coord_ra", and "coord_dec".
113 exposure : `lsst.afw.image.Exposure`
114 Ignored; present for consistency with FitSipDistortionTask.
116 Returns
117 -------
118 result : `lsst.pipe.base.Struct`
119 with the following fields:
121 - ``wcs`` : the fit WCS (`lsst.afw.geom.SkyWcs`)
122 - ``scatterOnSky`` : median on-sky separation between reference
123 objects and sources in "matches" (`lsst.afw.geom.Angle`)
124 """
125 if bbox is None:
126 bbox = lsst.geom.Box2I()
128 import lsstDebug
129 debug = lsstDebug.Info(__name__)
131 wcs = self.initialWcs(matches, initWcs)
132 rejected = np.zeros(len(matches), dtype=bool)
133 for rej in range(self.config.numRejIter):
134 sipObject = self._fitWcs([mm for i, mm in enumerate(matches) if not rejected[i]], wcs)
135 wcs = sipObject.getNewWcs()
136 rejected = self.rejectMatches(matches, wcs, rejected)
137 if rejected.sum() == len(rejected):
138 raise RuntimeError("All matches rejected in iteration %d" % (rej + 1,))
139 self.log.debug(
140 "Iteration {0} of astrometry fitting: rejected {1} outliers, "
141 "out of {2} total matches.".format(
142 rej, rejected.sum(), len(rejected)
143 )
144 )
145 if debug.plot:
146 print("Plotting fit after rejection iteration %d/%d" % (rej + 1, self.config.numRejIter))
147 self.plotFit(matches, wcs, rejected)
148 # Final fit after rejection
149 sipObject = self._fitWcs([mm for i, mm in enumerate(matches) if not rejected[i]], wcs)
150 wcs = sipObject.getNewWcs()
151 if debug.plot:
152 print("Plotting final fit")
153 self.plotFit(matches, wcs, rejected)
155 if refCat is not None:
156 self.log.debug("Updating centroids in refCat")
157 afwTable.updateRefCentroids(wcs, refList=refCat)
158 else:
159 self.log.warn("Updating reference object centroids in match list; refCat is None")
160 afwTable.updateRefCentroids(wcs, refList=[match.first for match in matches])
162 if sourceCat is not None:
163 self.log.debug("Updating coords in sourceCat")
164 afwTable.updateSourceCoords(wcs, sourceList=sourceCat)
165 else:
166 self.log.warn("Updating source coords in match list; sourceCat is None")
167 afwTable.updateSourceCoords(wcs, sourceList=[match.second for match in matches])
169 self.log.debug("Updating distance in match list")
170 setMatchDistance(matches)
172 scatterOnSky = sipObject.getScatterOnSky()
174 if scatterOnSky.asArcseconds() > self.config.maxScatterArcsec:
175 raise pipeBase.TaskError(
176 "Fit failed: median scatter on sky = %0.3f arcsec > %0.3f config.maxScatterArcsec" %
177 (scatterOnSky.asArcseconds(), self.config.maxScatterArcsec))
179 return pipeBase.Struct(
180 wcs=wcs,
181 scatterOnSky=scatterOnSky,
182 )
184 def initialWcs(self, matches, wcs):
185 """Generate a guess Wcs from the astrometric matches
187 We create a Wcs anchored at the center of the matches, with the scale
188 of the input Wcs. This is necessary because matching returns only
189 matches with no estimated Wcs, and the input Wcs is a wild guess.
190 We're using the best of each: positions from the matches, and scale
191 from the input Wcs.
193 Parameters
194 ----------
195 matches : `list` of `lsst.afw.table.ReferenceMatch`
196 List of sources matched to references.
197 wcs : `lsst.afw.geom.SkyWcs`
198 Current WCS.
200 Returns
201 -------
202 newWcs : `lsst.afw.geom.SkyWcs`
203 Initial WCS guess from estimated crpix and crval.
204 """
205 crpix = lsst.geom.Extent2D(0, 0)
206 crval = lsst.sphgeom.Vector3d(0, 0, 0)
207 for mm in matches:
208 crpix += lsst.geom.Extent2D(mm.second.getCentroid())
209 crval += mm.first.getCoord().getVector()
210 crpix /= len(matches)
211 crval /= len(matches)
212 newWcs = afwGeom.makeSkyWcs(crpix=lsst.geom.Point2D(crpix),
213 crval=lsst.geom.SpherePoint(crval),
214 cdMatrix=wcs.getCdMatrix())
215 return newWcs
217 def _fitWcs(self, matches, wcs):
218 """Fit a Wcs based on the matches and a guess Wcs.
220 Parameters
221 ----------
222 matches : `list` of `lsst.afw.table.ReferenceMatch`
223 List of sources matched to references.
224 wcs : `lsst.afw.geom.SkyWcs`
225 Current WCS.
227 Returns
228 -------
229 sipObject : `lsst.meas.astrom.sip.CreateWcsWithSip`
230 Fitted SIP object.
231 """
232 for i in range(self.config.numIter):
233 sipObject = makeCreateWcsWithSip(matches, wcs, self.config.order)
234 wcs = sipObject.getNewWcs()
235 return sipObject
237 def rejectMatches(self, matches, wcs, rejected):
238 """Flag deviant matches
240 We return a boolean numpy array indicating whether the corresponding
241 match should be rejected. The previous list of rejections is used
242 so we can calculate uncontaminated statistics.
244 Parameters
245 ----------
246 matches : `list` of `lsst.afw.table.ReferenceMatch`
247 List of sources matched to references.
248 wcs : `lsst.afw.geom.SkyWcs`
249 Fitted WCS.
250 rejected : array-like of `bool`
251 Array of matches rejected from the fit. Unused.
253 Returns
254 -------
255 rejectedMatches : `ndarray` of type `bool`
256 Matched objects found to be outside of tolerance.
257 """
258 fit = [wcs.skyToPixel(m.first.getCoord()) for m in matches]
259 dx = np.array([ff.getX() - mm.second.getCentroid().getX() for ff, mm in zip(fit, matches)])
260 dy = np.array([ff.getY() - mm.second.getCentroid().getY() for ff, mm in zip(fit, matches)])
261 good = np.logical_not(rejected)
262 return (dx > self.config.rejSigma*dx[good].std()) | (dy > self.config.rejSigma*dy[good].std())
264 def plotFit(self, matches, wcs, rejected):
265 """Plot the fit
267 We create four plots, for all combinations of (dx, dy) against
268 (x, y). Good points are black, while rejected points are red.
270 Parameters
271 ----------
272 matches : `list` of `lsst.afw.table.ReferenceMatch`
273 List of sources matched to references.
274 wcs : `lsst.afw.geom.SkyWcs`
275 Fitted WCS.
276 rejected : array-like of `bool`
277 Array of matches rejected from the fit.
278 """
279 try:
280 import matplotlib.pyplot as plt
281 except ImportError as e:
282 self.log.warn("Unable to import matplotlib: %s", e)
283 return
285 fit = [wcs.skyToPixel(m.first.getCoord()) for m in matches]
286 x1 = np.array([ff.getX() for ff in fit])
287 y1 = np.array([ff.getY() for ff in fit])
288 x2 = np.array([m.second.getCentroid().getX() for m in matches])
289 y2 = np.array([m.second.getCentroid().getY() for m in matches])
291 dx = x1 - x2
292 dy = y1 - y2
294 good = np.logical_not(rejected)
296 figure = plt.figure()
297 axes = figure.add_subplot(2, 2, 1)
298 axes.plot(x2[good], dx[good], 'ko')
299 axes.plot(x2[rejected], dx[rejected], 'ro')
300 axes.set_xlabel("x")
301 axes.set_ylabel("dx")
303 axes = figure.add_subplot(2, 2, 2)
304 axes.plot(x2[good], dy[good], 'ko')
305 axes.plot(x2[rejected], dy[rejected], 'ro')
306 axes.set_xlabel("x")
307 axes.set_ylabel("dy")
309 axes = figure.add_subplot(2, 2, 3)
310 axes.plot(y2[good], dx[good], 'ko')
311 axes.plot(y2[rejected], dx[rejected], 'ro')
312 axes.set_xlabel("y")
313 axes.set_ylabel("dx")
315 axes = figure.add_subplot(2, 2, 4)
316 axes.plot(y2[good], dy[good], 'ko')
317 axes.plot(y2[rejected], dy[rejected], 'ro')
318 axes.set_xlabel("y")
319 axes.set_ylabel("dy")
321 plt.show()