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

Shortcuts 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

116 statements  

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__ = ["FitTanSipWcsTask", "FitTanSipWcsConfig"] 

23 

24 

25import numpy as np 

26 

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 

36 

37 

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 ) 

72 

73 

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" 

79 

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 

83 

84 Parameters 

85 ---------- 

86 matches : `list` of `lsst.afw.table.ReferenceMatch` 

87 The following fields are read: 

88 

89 - match.first (reference object) coord 

90 - match.second (source) centroid 

91 

92 The following fields are written: 

93 

94 - match.first (reference object) centroid, 

95 - match.second (source) centroid 

96 - match.distance (on sky separation, in radians) 

97 

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. 

115 

116 Returns 

117 ------- 

118 result : `lsst.pipe.base.Struct` 

119 with the following fields: 

120 

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() 

127 

128 import lsstDebug 

129 debug = lsstDebug.Info(__name__) 

130 

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) 

154 

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]) 

161 

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]) 

168 

169 self.log.debug("Updating distance in match list") 

170 setMatchDistance(matches) 

171 

172 scatterOnSky = sipObject.getScatterOnSky() 

173 

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)) 

178 

179 return pipeBase.Struct( 

180 wcs=wcs, 

181 scatterOnSky=scatterOnSky, 

182 ) 

183 

184 def initialWcs(self, matches, wcs): 

185 """Generate a guess Wcs from the astrometric matches 

186 

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. 

192 

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. 

199 

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 

216 

217 def _fitWcs(self, matches, wcs): 

218 """Fit a Wcs based on the matches and a guess Wcs. 

219 

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. 

226 

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 

236 

237 def rejectMatches(self, matches, wcs, rejected): 

238 """Flag deviant matches 

239 

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. 

243 

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. 

252 

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()) 

263 

264 def plotFit(self, matches, wcs, rejected): 

265 """Plot the fit 

266 

267 We create four plots, for all combinations of (dx, dy) against 

268 (x, y). Good points are black, while rejected points are red. 

269 

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 

284 

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]) 

290 

291 dx = x1 - x2 

292 dy = y1 - y2 

293 

294 good = np.logical_not(rejected) 

295 

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") 

302 

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") 

308 

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") 

314 

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") 

320 

321 plt.show()