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

117 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-08 03:24 -0700

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 . import exceptions 

35from .setMatchDistance import setMatchDistance 

36from .sip import makeCreateWcsWithSip 

37 

38 

39class FitTanSipWcsConfig(pexConfig.Config): 

40 """Config for FitTanSipWcsTask.""" 

41 order = pexConfig.RangeField( 

42 doc="order of SIP polynomial", 

43 dtype=int, 

44 default=2, 

45 min=0, 

46 ) 

47 numIter = pexConfig.RangeField( 

48 doc="number of iterations of fitter (which fits X and Y separately, and so benefits from " 

49 "a few iterations", 

50 dtype=int, 

51 default=3, 

52 min=1, 

53 ) 

54 numRejIter = pexConfig.RangeField( 

55 doc="number of rejection iterations", 

56 dtype=int, 

57 default=1, 

58 min=0, 

59 ) 

60 rejSigma = pexConfig.RangeField( 

61 doc="Number of standard deviations for clipping level", 

62 dtype=float, 

63 default=3.0, 

64 min=0.0, 

65 ) 

66 maxScatterArcsec = pexConfig.RangeField( 

67 doc="maximum median scatter of a WCS fit beyond which the fit fails (arcsec); " 

68 "be generous, as this is only intended to catch catastrophic failures", 

69 dtype=float, 

70 default=10, 

71 min=0, 

72 ) 

73 

74 

75class FitTanSipWcsTask(pipeBase.Task): 

76 """Fit a TAN-SIP WCS given a list of reference object/source matches. 

77 """ 

78 ConfigClass = FitTanSipWcsConfig 

79 _DefaultName = "fitTanSipWcs" 

80 

81 @timeMethod 

82 def fitWcs(self, matches, initWcs, bbox=None, refCat=None, sourceCat=None, exposure=None): 

83 """Fit a TAN-SIP WCS from a list of reference object/source matches 

84 

85 Parameters 

86 ---------- 

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

88 The following fields are read: 

89 

90 - match.first (reference object) coord 

91 - match.second (source) centroid 

92 

93 The following fields are written: 

94 

95 - match.first (reference object) centroid, 

96 - match.second (source) centroid 

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

98 

99 initWcs : `lsst.afw.geom.SkyWcs` 

100 initial WCS 

101 bbox : `lsst.geom.Box2I` 

102 the region over which the WCS will be valid (an lsst:afw::geom::Box2I); 

103 if None or an empty box then computed from matches 

104 refCat : `lsst.afw.table.SimpleCatalog` 

105 reference object catalog, or None. 

106 If provided then all centroids are updated with the new WCS, 

107 otherwise only the centroids for ref objects in matches are updated. 

108 Required fields are "centroid_x", "centroid_y", "coord_ra", and "coord_dec". 

109 sourceCat : `lsst.afw.table.SourceCatalog` 

110 source catalog, or None. 

111 If provided then coords are updated with the new WCS; 

112 otherwise only the coords for sources in matches are updated. 

113 Required fields are "slot_Centroid_x", "slot_Centroid_y", and "coord_ra", and "coord_dec". 

114 exposure : `lsst.afw.image.Exposure` 

115 Ignored; present for consistency with FitSipDistortionTask. 

116 

117 Returns 

118 ------- 

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

120 with the following fields: 

121 

122 - ``wcs`` : the fit WCS (`lsst.afw.geom.SkyWcs`) 

123 - ``scatterOnSky`` : median on-sky separation between reference 

124 objects and sources in "matches" (`lsst.afw.geom.Angle`) 

125 """ 

126 if bbox is None: 

127 bbox = lsst.geom.Box2I() 

128 

129 import lsstDebug 

130 debug = lsstDebug.Info(__name__) 

131 

132 wcs = self.initialWcs(matches, initWcs) 

133 rejected = np.zeros(len(matches), dtype=bool) 

134 for rej in range(self.config.numRejIter): 

135 sipObject = self._fitWcs([mm for i, mm in enumerate(matches) if not rejected[i]], wcs) 

136 wcs = sipObject.getNewWcs() 

137 rejected = self.rejectMatches(matches, wcs, rejected) 

138 if rejected.sum() == len(rejected): 

139 raise exceptions.AstrometryFitFailure(f"All matches rejected in fitter iteration {rej+1}") 

140 self.log.debug( 

141 "Iteration %d of astrometry fitting: rejected %d outliers, out of %d total matches.", 

142 rej, rejected.sum(), len(rejected) 

143 ) 

144 if debug.plot: 

145 print("Plotting fit after rejection iteration %d/%d" % (rej + 1, self.config.numRejIter)) 

146 self.plotFit(matches, wcs, rejected) 

147 # Final fit after rejection 

148 sipObject = self._fitWcs([mm for i, mm in enumerate(matches) if not rejected[i]], wcs) 

149 wcs = sipObject.getNewWcs() 

150 if debug.plot: 

151 print("Plotting final fit") 

152 self.plotFit(matches, wcs, rejected) 

153 

154 if refCat is not None: 

155 self.log.debug("Updating centroids in refCat") 

156 afwTable.updateRefCentroids(wcs, refList=refCat) 

157 else: 

158 self.log.warning("Updating reference object centroids in match list; refCat is None") 

159 afwTable.updateRefCentroids(wcs, refList=[match.first for match in matches]) 

160 

161 if sourceCat is not None: 

162 self.log.debug("Updating coords in sourceCat") 

163 afwTable.updateSourceCoords(wcs, sourceList=sourceCat) 

164 else: 

165 self.log.warning("Updating source coords in match list; sourceCat is None") 

166 afwTable.updateSourceCoords(wcs, sourceList=[match.second for match in matches]) 

167 

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

169 setMatchDistance(matches) 

170 

171 scatterOnSky = sipObject.getScatterOnSky() 

172 

173 if scatterOnSky.asArcseconds() > self.config.maxScatterArcsec: 

174 raise exceptions.AstrometryFitFailure( 

175 "Fit failed: median scatter on sky = %0.3f arcsec > %0.3f config.maxScatterArcsec" % 

176 (scatterOnSky.asArcseconds(), self.config.maxScatterArcsec)) 

177 

178 return pipeBase.Struct( 

179 wcs=wcs, 

180 scatterOnSky=scatterOnSky, 

181 ) 

182 

183 def initialWcs(self, matches, wcs): 

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

185 

186 We create a Wcs anchored at the center of the matches, with the scale 

187 of the input Wcs. This is necessary because matching returns only 

188 matches with no estimated Wcs, and the input Wcs is a wild guess. 

189 We're using the best of each: positions from the matches, and scale 

190 from the input Wcs. 

191 

192 Parameters 

193 ---------- 

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

195 List of sources matched to references. 

196 wcs : `lsst.afw.geom.SkyWcs` 

197 Current WCS. 

198 

199 Returns 

200 ------- 

201 newWcs : `lsst.afw.geom.SkyWcs` 

202 Initial WCS guess from estimated crpix and crval. 

203 """ 

204 crpix = lsst.geom.Extent2D(0, 0) 

205 crval = lsst.sphgeom.Vector3d(0, 0, 0) 

206 for mm in matches: 

207 crpix += lsst.geom.Extent2D(mm.second.getCentroid()) 

208 crval += mm.first.getCoord().getVector() 

209 crpix /= len(matches) 

210 crval /= len(matches) 

211 newWcs = afwGeom.makeSkyWcs(crpix=lsst.geom.Point2D(crpix), 

212 crval=lsst.geom.SpherePoint(crval), 

213 cdMatrix=wcs.getCdMatrix()) 

214 return newWcs 

215 

216 def _fitWcs(self, matches, wcs): 

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

218 

219 Parameters 

220 ---------- 

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

222 List of sources matched to references. 

223 wcs : `lsst.afw.geom.SkyWcs` 

224 Current WCS. 

225 

226 Returns 

227 ------- 

228 sipObject : `lsst.meas.astrom.sip.CreateWcsWithSip` 

229 Fitted SIP object. 

230 """ 

231 for i in range(self.config.numIter): 

232 sipObject = makeCreateWcsWithSip(matches, wcs, self.config.order) 

233 wcs = sipObject.getNewWcs() 

234 return sipObject 

235 

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

237 """Flag deviant matches 

238 

239 We return a boolean numpy array indicating whether the corresponding 

240 match should be rejected. The previous list of rejections is used 

241 so we can calculate uncontaminated statistics. 

242 

243 Parameters 

244 ---------- 

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

246 List of sources matched to references. 

247 wcs : `lsst.afw.geom.SkyWcs` 

248 Fitted WCS. 

249 rejected : array-like of `bool` 

250 Array of matches rejected from the fit. Unused. 

251 

252 Returns 

253 ------- 

254 rejectedMatches : `ndarray` of type `bool` 

255 Matched objects found to be outside of tolerance. 

256 """ 

257 fit = [wcs.skyToPixel(m.first.getCoord()) for m in matches] 

258 dx = np.array([ff.getX() - mm.second.getCentroid().getX() for ff, mm in zip(fit, matches)]) 

259 dy = np.array([ff.getY() - mm.second.getCentroid().getY() for ff, mm in zip(fit, matches)]) 

260 good = np.logical_not(rejected) 

261 return (dx > self.config.rejSigma*dx[good].std()) | (dy > self.config.rejSigma*dy[good].std()) 

262 

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

264 """Plot the fit 

265 

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

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

268 

269 Parameters 

270 ---------- 

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

272 List of sources matched to references. 

273 wcs : `lsst.afw.geom.SkyWcs` 

274 Fitted WCS. 

275 rejected : array-like of `bool` 

276 Array of matches rejected from the fit. 

277 """ 

278 try: 

279 import matplotlib.pyplot as plt 

280 except ImportError as e: 

281 self.log.warning("Unable to import matplotlib: %s", e) 

282 return 

283 

284 fit = [wcs.skyToPixel(m.first.getCoord()) for m in matches] 

285 x1 = np.array([ff.getX() for ff in fit]) 

286 y1 = np.array([ff.getY() for ff in fit]) 

287 x2 = np.array([m.second.getCentroid().getX() for m in matches]) 

288 y2 = np.array([m.second.getCentroid().getY() for m in matches]) 

289 

290 dx = x1 - x2 

291 dy = y1 - y2 

292 

293 good = np.logical_not(rejected) 

294 

295 figure = plt.figure() 

296 axes = figure.add_subplot(2, 2, 1) 

297 axes.plot(x2[good], dx[good], 'ko') 

298 axes.plot(x2[rejected], dx[rejected], 'ro') 

299 axes.set_xlabel("x") 

300 axes.set_ylabel("dx") 

301 

302 axes = figure.add_subplot(2, 2, 2) 

303 axes.plot(x2[good], dy[good], 'ko') 

304 axes.plot(x2[rejected], dy[rejected], 'ro') 

305 axes.set_xlabel("x") 

306 axes.set_ylabel("dy") 

307 

308 axes = figure.add_subplot(2, 2, 3) 

309 axes.plot(y2[good], dx[good], 'ko') 

310 axes.plot(y2[rejected], dx[rejected], 'ro') 

311 axes.set_xlabel("y") 

312 axes.set_ylabel("dx") 

313 

314 axes = figure.add_subplot(2, 2, 4) 

315 axes.plot(y2[good], dy[good], 'ko') 

316 axes.plot(y2[rejected], dy[rejected], 'ro') 

317 axes.set_xlabel("y") 

318 axes.set_ylabel("dy") 

319 

320 plt.show()