Coverage for python/lsst/meas/astrom/fitAffineWcs.py: 23%

80 statements  

« prev     ^ index     » next       coverage.py v7.3.0, created at 2023-08-31 10:21 +0000

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__ = ["FitAffineWcsTask", "FitAffineWcsConfig", "TransformedSkyWcsMaker"] 

23 

24 

25import astshim 

26import numpy as np 

27from scipy.linalg import lstsq 

28 

29from lsst.afw.geom import makeSkyWcs, SkyWcs 

30import lsst.afw.math 

31from lsst.geom import Point2D, radians 

32import lsst.pex.config as pexConfig 

33import lsst.pipe.base as pipeBase 

34from lsst.utils.timer import timeMethod 

35 

36from ._measAstromLib import makeMatchStatisticsInRadians 

37from .setMatchDistance import setMatchDistance 

38 

39 

40# Keeping this around for now in case any of the fit parameters need to be 

41# configurable. Likely the maximum allowed shift magnitude (parameter 2 in the 

42# fit.) 

43class FitAffineWcsConfig(pexConfig.Config): 

44 """Config for FitTanSipWcsTask.""" 

45 pass 

46 

47 

48class FitAffineWcsTask(pipeBase.Task): 

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

50 

51 This WCS fitter should be used on top of a cameraGeom distortion model as 

52 the model assumes that only a shift the WCS center position and a small 

53 affine transform are required. 

54 """ 

55 ConfigClass = FitAffineWcsConfig 

56 _DefaultName = "fitAffineWcs" 

57 

58 @timeMethod 

59 def fitWcs(self, 

60 matches, 

61 initWcs, 

62 bbox=None, 

63 refCat=None, 

64 sourceCat=None, 

65 exposure=None): 

66 """Fit a simple Affine transform with a shift to the matches and update 

67 the WCS. 

68 

69 This method assumes that the distortion model of the telescope is 

70 applied correctly and is accurate with only a slight rotation, 

71 rotation, and "squish" required to fit to the reference locations. 

72 

73 Parameters 

74 ---------- 

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

76 The following fields are read: 

77 

78 - match.first (reference object) coord 

79 - match.second (source) centroid 

80 

81 The following fields are written: 

82 

83 - match.first (reference object) centroid, 

84 - match.second (source) centroid 

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

86 

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

88 initial WCS 

89 bbox : `lsst.geom.Box2I` 

90 Ignored; present for consistency with FitSipDistortionTask. 

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

92 reference object catalog, or None. 

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

94 otherwise only the centroids for ref objects in matches are 

95 updated. Required fields are "centroid_x", "centroid_y", 

96 "coord_ra", and "coord_dec". 

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

98 source catalog, or None. 

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

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

101 Required fields are "slot_Centroid_x", "slot_Centroid_y", and 

102 "coord_ra", and "coord_dec". 

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

104 Ignored; present for consistency with FitSipDistortionTask. 

105 

106 Returns 

107 ------- 

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

109 with the following fields: 

110 

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

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

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

114 """ 

115 # Create a data-structure that decomposes the input Wcs frames and 

116 # appends the new transform. 

117 wcsMaker = TransformedSkyWcsMaker(initWcs) 

118 

119 # Grab the initial transformations going back from sky coordinates 

120 # and forward from pixel coordinates. 

121 back = wcsMaker.frameDict.getMapping(wcsMaker.frameMax, wcsMaker.frameMax-1) 

122 forward = wcsMaker.lastMapBeforeSky 

123 

124 # Create containers for the data going into the Affine fit. This will 

125 # be done by approximating the solution to Ax=b where x will be the 

126 # affine parameters and a linear shift. The approximate solution is 

127 # calculated using a least squares minimization of the Ax=b equation. 

128 # 

129 # This is looking to find the affine transform of the following form: 

130 # [x', y'] = [[a, b], [c, d]] [x, y] + [s, t] 

131 # 

132 # where a,b,c,d are the parameters of the affine transform, and s,t 

133 # are linear shift parameters. 

134 # 

135 # To solve for these unknown parameters the unknown matrix x in the 

136 # equation Ax=b will be of the form: 

137 # [a, b, c, d, s, t]. 

138 # 

139 # This implies that each constraining point will correspond to two rows 

140 # in the A matrix in the following form: 

141 # [x_i, y_i, 0, 0, 1, 0] 

142 # [0, 0, x_i, y_i, 0, 1]. 

143 # 

144 # The corresponding output points in the b vector will have the form: 

145 # [x'_i, y'_i, x'_(i+i), y'_(i+1)....] 

146 A = np.zeros((len(matches)*2, 6), dtype=float) 

147 b = np.empty(len(matches)*2, dtype=float) 

148 

149 # Constant terms related to the shift in x and and y parameters. 

150 A[::2, 4] = 1 

151 A[1::2, 5] = 1 

152 

153 # loop over each of the matches and populate the matrices. 

154 for i, match in enumerate(matches): 

155 refCoord = match.first.getCoord() 

156 b[i*2:i*2+2] = back.applyForward(refCoord) 

157 

158 srcCentroid = match.second.getCentroid() 

159 val = forward.applyForward(srcCentroid) 

160 A[i*2, :2] = val 

161 A[i*2+1, 2:4] = val 

162 

163 # solve for the affine and shift parameters 

164 # The lapack_driver parameter is set to the quickest routine tested for 

165 # this application at the time of writing. 

166 fit = lstsq(A, b, lapack_driver='gelsy')[0] 

167 

168 self.log.debug("Linear shift in x: %.3f, y: %.3f, " 

169 "Affine matrix: [[%.6f, %.6f], [%.6f, %.6f]]...", 

170 fit[4], fit[5], 

171 fit[0], fit[1], fit[2], fit[3]) 

172 

173 # create the final wcs 

174 wcs = wcsMaker.makeWcs(fit[4:], fit[:4].reshape((2, 2))) 

175 

176 # Copied from other fit*WcsTasks. 

177 if refCat is not None: 

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

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

180 else: 

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

182 lsst.afw.table.updateRefCentroids( 

183 wcs, 

184 refList=[match.first for match in matches]) 

185 

186 if sourceCat is not None: 

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

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

189 else: 

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

191 lsst.afw.table.updateSourceCoords( 

192 wcs, 

193 sourceList=[match.second for match in matches]) 

194 setMatchDistance(matches) 

195 

196 stats = makeMatchStatisticsInRadians(wcs, 

197 matches, 

198 lsst.afw.math.MEDIAN) 

199 scatterOnSky = stats.getValue() * radians 

200 

201 self.log.debug("In fitter scatter %.4f", scatterOnSky.asArcseconds()) 

202 

203 return lsst.pipe.base.Struct( 

204 wcs=wcs, 

205 scatterOnSky=scatterOnSky, 

206 ) 

207 

208 

209class TransformedSkyWcsMaker(): 

210 """Convenience class for appending a shifting an input SkyWcs on sky and 

211 appending an affine transform. 

212 

213 The class assumes that all frames are sequential and are mapped one to the 

214 next. 

215 

216 Parameters 

217 ---------- 

218 input_sky_wcs : `lsst.afw.geom.SkyWcs` 

219 WCS to decompose and append affine matrix and shift in on sky 

220 location to. 

221 """ 

222 

223 def __init__(self, inputSkyWcs): 

224 self.frameDict = inputSkyWcs.getFrameDict() 

225 

226 # Grab the order of the frames by index. 

227 # TODO: DM-20825 

228 # Change the frame the transform is appended to to be explicitly 

229 # the FIELD_ANGLE->IWC transform. Requires related tickets to be 

230 # completed. 

231 domains = self.frameDict.getAllDomains() 

232 self.frameIdxs = np.sort([self.frameDict.getIndex(domain) 

233 for domain in domains]) 

234 self.frameMin = np.min(self.frameIdxs) 

235 self.frameMax = np.max(self.frameIdxs) 

236 

237 # Find frame just before the final mapping to sky and store those 

238 # indices and mappings for later. 

239 self.mapFrom = self.frameMax - 2 

240 if self.mapFrom < self.frameMin: 

241 self.mapFrom = self.frameMin 

242 self.mapTo = self.frameMax - 1 

243 if self.mapTo <= self.mapFrom: 

244 self.mapTo = self.frameMax 

245 self.lastMapBeforeSky = self.frameDict.getMapping( 

246 self.mapFrom, self.mapTo) 

247 

248 # Get the original WCS sky location. 

249 

250 self.origin = inputSkyWcs.getSkyOrigin() 

251 

252 def makeWcs(self, linearShift, affMatrix): 

253 """Apply a shift and affine transform to the WCS internal to this 

254 class. 

255 

256 A new SkyWcs with these transforms applied is returns. 

257 

258 Parameters 

259 ---------- 

260 linearShift : `numpy.ndarray`, (2,) 

261 A linear shift to apply at the same time as applying the affine 

262 matrix transform. 

263 aff_matrix : 'numpy.ndarray', (3, 3) 

264 Affine matrix to apply to the mapping/transform to add to the 

265 WCS. 

266 

267 Returns 

268 ------- 

269 outputWcs : `lsst.afw.geom.SkyWcs` 

270 Wcs with a final shift and affine transform applied. 

271 """ 

272 # Create a WCS that only maps from IWC to Sky with the shifted 

273 # Sky origin position. This is simply the final undistorted tangent 

274 # plane to sky. The PIXELS to SKY map will be become our IWC to SKY 

275 # map and gives us our final shift position. 

276 iwcsToSkyWcs = makeSkyWcs( 

277 Point2D(0., 0.), 

278 self.origin, 

279 np.array([[1., 0.], [0., 1.]])) 

280 iwcToSkyMap = iwcsToSkyWcs.getFrameDict().getMapping("PIXELS", "SKY") 

281 

282 # Append a simple affine Matrix transform to the current to the 

283 # second to last frame mapping. e.g. the one just before IWC to SKY. 

284 newMapping = self.lastMapBeforeSky.then(astshim.MatrixMap(affMatrix)) 

285 newMapping = newMapping.then(astshim.ShiftMap(linearShift)) 

286 

287 # Create a new frame dict starting from the input_sky_wcs's first 

288 # frame. Append the correct mapping created above and our new on 

289 # sky location. 

290 outputFrameDict = astshim.FrameDict( 

291 self.frameDict.getFrame(self.frameMin)) 

292 for frameIdx in self.frameIdxs: 

293 if frameIdx == self.mapFrom: 

294 outputFrameDict.addFrame( 

295 self.mapFrom, 

296 newMapping, 

297 self.frameDict.getFrame(self.mapTo)) 

298 elif frameIdx >= self.mapTo: 

299 continue 

300 else: 

301 outputFrameDict.addFrame( 

302 frameIdx, 

303 self.frameDict.getMapping(frameIdx, frameIdx + 1), 

304 self.frameDict.getFrame(frameIdx + 1)) 

305 # Append the final sky frame to the frame dict. 

306 outputFrameDict.addFrame( 

307 self.frameMax - 1, 

308 iwcToSkyMap, 

309 iwcsToSkyWcs.getFrameDict().getFrame("SKY")) 

310 

311 return SkyWcs(outputFrameDict)