Coverage for python/lsst/meas/astrom/fitSipDistortion.py: 20%

127 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-11 04:08 -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__ = ["FitSipDistortionTask", "FitSipDistortionConfig"] 

23 

24 

25import lsst.sphgeom 

26import lsst.pipe.base 

27import lsst.geom 

28import lsst.afw.image 

29import lsst.afw.geom 

30import lsst.afw.display 

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 

42class FitSipDistortionConfig(lsst.pex.config.Config): 

43 """Config for FitSipDistortionTask""" 

44 order = lsst.pex.config.RangeField( 

45 doc="Order of SIP polynomial", 

46 dtype=int, 

47 default=4, 

48 min=0, 

49 ) 

50 numRejIter = lsst.pex.config.RangeField( 

51 doc="Number of rejection iterations", 

52 dtype=int, 

53 default=3, 

54 min=0, 

55 ) 

56 rejSigma = lsst.pex.config.RangeField( 

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

58 dtype=float, 

59 default=3.0, 

60 min=0.0, 

61 ) 

62 nClipMin = lsst.pex.config.Field( 

63 doc="Minimum number of matches to reject when sigma-clipping", 

64 dtype=int, 

65 default=0 

66 ) 

67 nClipMax = lsst.pex.config.Field( 

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 ) 

85 nGridX = lsst.pex.config.Field( 

86 doc="Number of X grid points used to invert the SIP reverse transform.", 

87 dtype=int, 

88 default=100, 

89 ) 

90 nGridY = lsst.pex.config.Field( 

91 doc="Number of Y grid points used to invert the SIP reverse transform.", 

92 dtype=int, 

93 default=100, 

94 ) 

95 gridBorder = lsst.pex.config.Field( 

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: 

256 raise exceptions.AstrometryFitFailure( 

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