Hide keyboard shortcuts

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/>. 

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 .scaledPolynomialTransformFitter import ScaledPolynomialTransformFitter, OutlierRejectionControl 

34from .sipTransform import SipForwardTransform, SipReverseTransform, makeWcs 

35from .makeMatchStatistics import makeMatchStatisticsInRadians 

36 

37from .setMatchDistance import setMatchDistance 

38 

39 

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

41 """Config for FitSipDistortionTask""" 

42 order = lsst.pex.config.RangeField( 

43 doc="Order of SIP polynomial", 

44 dtype=int, 

45 default=4, 

46 min=0, 

47 ) 

48 numRejIter = lsst.pex.config.RangeField( 

49 doc="Number of rejection iterations", 

50 dtype=int, 

51 default=3, 

52 min=0, 

53 ) 

54 rejSigma = lsst.pex.config.RangeField( 

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

56 dtype=float, 

57 default=3.0, 

58 min=0.0, 

59 ) 

60 nClipMin = lsst.pex.config.Field( 

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

62 dtype=int, 

63 default=0 

64 ) 

65 nClipMax = lsst.pex.config.Field( 

66 doc="Maximum number of matches to reject when sigma-clipping", 

67 dtype=int, 

68 default=1 

69 ) 

70 maxScatterArcsec = lsst.pex.config.RangeField( 

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

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

73 dtype=float, 

74 default=10, 

75 min=0, 

76 ) 

77 refUncertainty = lsst.pex.config.Field( 

78 doc="RMS uncertainty in reference catalog positions, in pixels. Will be added " 

79 "in quadrature with measured uncertainties in the fit.", 

80 dtype=float, 

81 default=0.25, 

82 ) 

83 nGridX = lsst.pex.config.Field( 

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

85 dtype=int, 

86 default=100, 

87 ) 

88 nGridY = lsst.pex.config.Field( 

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

90 dtype=int, 

91 default=100, 

92 ) 

93 gridBorder = lsst.pex.config.Field( 

94 doc="When setting the gird region, how much to extend the image " 

95 "bounding box (in pixels) before transforming it to intermediate " 

96 "world coordinates using the initial WCS.", 

97 dtype=float, 

98 default=50.0, 

99 ) 

100 

101 

102class FitSipDistortionTask(lsst.pipe.base.Task): 

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

104 """ 

105 ConfigClass = FitSipDistortionConfig 

106 _DefaultName = "fitWcs" 

107 

108 def __init__(self, **kwargs): 

109 lsst.pipe.base.Task.__init__(self, **kwargs) 

110 self.outlierRejectionCtrl = OutlierRejectionControl() 

111 self.outlierRejectionCtrl.nClipMin = self.config.nClipMin 

112 self.outlierRejectionCtrl.nClipMax = self.config.nClipMax 

113 self.outlierRejectionCtrl.nSigma = self.config.rejSigma 

114 

115 @timeMethod 

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

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

118 

119 Parameters 

120 ---------- 

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

122 A sequence of reference object/source matches. 

123 The following fields are read: 

124 - match.first (reference object) coord 

125 - match.second (source) centroid 

126 

127 The following fields are written: 

128 - match.first (reference object) centroid 

129 - match.second (source) centroid 

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

131 

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

133 An initial WCS whose CD matrix is used as the final CD matrix. 

134 bbox : `lsst.geom.Box2I` 

135 The region over which the WCS will be valid (PARENT pixel coordinates); 

136 if `None` or an empty box then computed from matches 

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

138 Reference object catalog, or `None`. 

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

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

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

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

143 Source catalog, or `None`. 

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

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

146 Required input fields are "slot_Centroid_x", "slot_Centroid_y", 

147 "slot_Centroid_xErr", "slot_Centroid_yErr", and optionally 

148 "slot_Centroid_x_y_Cov". The "coord_ra" and "coord_dec" fields 

149 will be updated but are not used as input. 

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

151 An Exposure or other displayable image on which matches can be 

152 overplotted. Ignored (and may be `None`) if display-based debugging 

153 is not enabled via lsstDebug. 

154 

155 Returns 

156 ------- 

157 An lsst.pipe.base.Struct with the following fields: 

158 - wcs : `lsst.afw.geom.SkyWcs` 

159 The best-fit WCS. 

160 - scatterOnSky : `lsst.geom.Angle` 

161 The median on-sky separation between reference objects and 

162 sources in "matches", as an `lsst.geom.Angle` 

163 """ 

164 import lsstDebug 

165 display = lsstDebug.Info(__name__).display 

166 displayFrame = lsstDebug.Info(__name__).frame 

167 displayPause = lsstDebug.Info(__name__).pause 

168 

169 if bbox is None: 

170 bbox = lsst.geom.Box2D() 

171 for match in matches: 

172 bbox.include(match.second.getCentroid()) 

173 bbox = lsst.geom.Box2I(bbox) 

174 

175 wcs = self.makeInitialWcs(matches, initWcs) 

176 cdMatrix = lsst.geom.LinearTransform(wcs.getCdMatrix()) 

177 

178 # Fit the "reverse" mapping from intermediate world coordinates to 

179 # pixels, rejecting outliers. Fitting in this direction first makes it 

180 # easier to handle the case where we have uncertainty on source 

181 # positions but not reference positions. That's the case we have 

182 # right now for purely bookeeeping reasons, and it may be the case we 

183 # have in the future when we us Gaia as the reference catalog. 

184 revFitter = ScaledPolynomialTransformFitter.fromMatches(self.config.order, matches, wcs, 

185 self.config.refUncertainty) 

186 revFitter.fit() 

187 for nIter in range(self.config.numRejIter): 

188 revFitter.updateModel() 

189 intrinsicScatter = revFitter.updateIntrinsicScatter() 

190 clippedSigma, nRejected = revFitter.rejectOutliers(self.outlierRejectionCtrl) 

191 self.log.debug( 

192 "Iteration {0}: intrinsic scatter is {1:4.3f} pixels, " 

193 "rejected {2} outliers at {3:3.2f} sigma.".format( 

194 nIter+1, intrinsicScatter, nRejected, clippedSigma 

195 ) 

196 ) 

197 if display: 

198 displayFrame = self.display(revFitter, exposure=exposure, bbox=bbox, 

199 frame=displayFrame, displayPause=displayPause) 

200 revFitter.fit() 

201 revScaledPoly = revFitter.getTransform() 

202 # Convert the generic ScaledPolynomialTransform result to SIP form 

203 # with given CRPIX and CD (this is an exact conversion, up to 

204 # floating-point round-off error) 

205 sipReverse = SipReverseTransform.convert(revScaledPoly, wcs.getPixelOrigin(), cdMatrix) 

206 

207 # Fit the forward mapping to a grid of points created from the reverse 

208 # transform. Because that grid needs to be defined in intermediate 

209 # world coordinates, and we don't have a good way to get from pixels to 

210 # intermediate world coordinates yet (that's what we're fitting), we'll 

211 # first grow the box to make it conservatively large... 

212 gridBBoxPix = lsst.geom.Box2D(bbox) 

213 gridBBoxPix.grow(self.config.gridBorder) 

214 # ...and then we'll transform using just the CRPIX offset and CD matrix 

215 # linear transform, which is the TAN-only (no SIP distortion, and 

216 # hence approximate) mapping from pixels to intermediate world 

217 # coordinates. 

218 gridBBoxIwc = lsst.geom.Box2D() 

219 for point in gridBBoxPix.getCorners(): 

220 point -= lsst.geom.Extent2D(wcs.getPixelOrigin()) 

221 gridBBoxIwc.include(cdMatrix(point)) 

222 fwdFitter = ScaledPolynomialTransformFitter.fromGrid(self.config.order, gridBBoxIwc, 

223 self.config.nGridX, self.config.nGridY, 

224 revScaledPoly) 

225 fwdFitter.fit() 

226 # Convert to SIP forward form. 

227 fwdScaledPoly = fwdFitter.getTransform() 

228 sipForward = SipForwardTransform.convert(fwdScaledPoly, wcs.getPixelOrigin(), cdMatrix) 

229 

230 # Make a new WCS from the SIP transform objects and the CRVAL in the 

231 # initial WCS. 

232 wcs = makeWcs(sipForward, sipReverse, wcs.getSkyOrigin()) 

233 

234 if refCat is not None: 

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

236 lsst.afw.table.updateRefCentroids(wcs, refList=refCat) 

237 else: 

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

239 lsst.afw.table.updateRefCentroids(wcs, refList=[match.first for match in matches]) 

240 

241 if sourceCat is not None: 

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

243 lsst.afw.table.updateSourceCoords(wcs, sourceList=sourceCat) 

244 else: 

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

246 lsst.afw.table.updateSourceCoords(wcs, sourceList=[match.second for match in matches]) 

247 

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

249 setMatchDistance(matches) 

250 

251 stats = makeMatchStatisticsInRadians(wcs, matches, lsst.afw.math.MEDIAN) 

252 scatterOnSky = stats.getValue()*lsst.geom.radians 

253 

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

255 raise lsst.pipe.base.TaskError( 

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

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

258 

259 return lsst.pipe.base.Struct( 

260 wcs=wcs, 

261 scatterOnSky=scatterOnSky, 

262 ) 

263 

264 def display(self, revFitter, exposure=None, bbox=None, frame=0, pause=True): 

265 """Display positions and outlier status overlaid on an image. 

266 

267 This method is called by fitWcs when display debugging is enabled. It 

268 always drops into pdb before returning to allow interactive inspection, 

269 and hence it should never be called in non-interactive contexts. 

270 

271 Parameters 

272 ---------- 

273 revFitter : :cpp:class:`lsst::meas::astrom::ScaledPolynomialTransformFitter` 

274 Fitter object initialized with `fromMatches` for fitting a "reverse" 

275 distortion: the mapping from intermediate world coordinates to 

276 pixels. 

277 exposure : :cpp:class:`lsst::afw::image::Exposure` 

278 An Exposure or other displayable image on which matches can be 

279 overplotted. 

280 bbox : :cpp:class:`lsst::afw::geom::Box2I` 

281 Bounding box of the region on which matches should be plotted. 

282 """ 

283 data = revFitter.getData() 

284 disp = lsst.afw.display.getDisplay(frame=frame) 

285 if exposure is not None: 

286 disp.mtv(exposure) 

287 elif bbox is not None: 

288 disp.mtv(exposure=lsst.afw.image.ExposureF(bbox)) 

289 else: 

290 raise TypeError("At least one of 'exposure' and 'bbox' must be provided.") 

291 data = revFitter.getData() 

292 srcKey = lsst.afw.table.Point2DKey(data.schema["src"]) 

293 srcErrKey = lsst.afw.table.CovarianceMatrix2fKey(data.schema["src"], ["x", "y"]) 

294 refKey = lsst.afw.table.Point2DKey(data.schema["initial"]) 

295 modelKey = lsst.afw.table.Point2DKey(data.schema["model"]) 

296 rejectedKey = data.schema.find("rejected").key 

297 with disp.Buffering(): 

298 for record in data: 

299 colors = ((lsst.afw.display.RED, lsst.afw.display.GREEN) 

300 if not record.get(rejectedKey) else 

301 (lsst.afw.display.MAGENTA, lsst.afw.display.CYAN)) 

302 rx, ry = record.get(refKey) 

303 disp.dot("x", rx, ry, size=10, ctype=colors[0]) 

304 mx, my = record.get(modelKey) 

305 disp.dot("o", mx, my, size=10, ctype=colors[0]) 

306 disp.line([(rx, ry), (mx, my)], ctype=colors[0]) 

307 sx, sy = record.get(srcKey) 

308 sErr = record.get(srcErrKey) 

309 sEllipse = lsst.afw.geom.Quadrupole(sErr[0, 0], sErr[1, 1], sErr[0, 1]) 

310 disp.dot(sEllipse, sx, sy, ctype=colors[1]) 

311 if pause or pause is None: # default is to pause 

312 print("Dropping into debugger to allow inspection of display. Type 'continue' when done.") 

313 import pdb 

314 pdb.set_trace() 

315 return frame 

316 else: 

317 return frame + 1 # increment and return the frame for the next iteration. 

318 

319 def makeInitialWcs(self, matches, wcs): 

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

321 

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

323 of the input Wcs. This is necessary because the Wcs may have a very 

324 approximation position (as is common with telescoped-generated Wcs). 

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

326 from the input Wcs. 

327 

328 Parameters 

329 ---------- 

330 matches : list of :cpp:class:`lsst::afw::table::ReferenceMatch` 

331 A sequence of reference object/source matches. 

332 The following fields are read: 

333 

334 - match.first (reference object) coord 

335 - match.second (source) centroid 

336 

337 wcs : :cpp:class:`lsst::afw::geom::SkyWcs` 

338 An initial WCS whose CD matrix is used as the CD matrix of the 

339 result. 

340 

341 Returns 

342 ------- 

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

344 A new WCS guess. 

345 """ 

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

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

348 for mm in matches: 

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

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

351 crpix /= len(matches) 

352 crval /= len(matches) 

353 cd = wcs.getCdMatrix() 

354 newWcs = lsst.afw.geom.makeSkyWcs(crpix=lsst.geom.Point2D(crpix), 

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

356 cdMatrix=cd) 

357 return newWcs