Coverage for python/lsst/meas/extensions/trailedSources/NaivePlugin.py: 19%

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

131 statements  

1# 

2# This file is part of meas_extensions_trailedSources. 

3# 

4# Developed for the LSST Data Management System. 

5# This product includes software developed by the LSST Project 

6# (http://www.lsst.org). 

7# See the COPYRIGHT file at the top-level directory of this distribution 

8# for details of code ownership. 

9# 

10# This program is free software: you can redistribute it and/or modify 

11# it under the terms of the GNU General Public License as published by 

12# the Free Software Foundation, either version 3 of the License, or 

13# (at your option) any later version. 

14# 

15# This program is distributed in the hope that it will be useful, 

16# but WITHOUT ANY WARRANTY; without even the implied warranty of 

17# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

18# GNU General Public License for more details. 

19# 

20# You should have received a copy of the GNU General Public License 

21# along with this program. If not, see <http://www.gnu.org/licenses/>. 

22# 

23 

24import numpy as np 

25import scipy.optimize as sciOpt 

26from scipy.special import erf 

27 

28import lsst.log 

29from lsst.geom import Point2D 

30from lsst.meas.base.pluginRegistry import register 

31from lsst.meas.base import SingleFramePlugin, SingleFramePluginConfig 

32from lsst.meas.base import FlagHandler, FlagDefinitionList, SafeCentroidExtractor 

33from lsst.meas.base import MeasurementError 

34 

35from ._trailedSources import VeresModel 

36from .utils import getMeasurementCutout 

37 

38__all__ = ("SingleFrameNaiveTrailConfig", "SingleFrameNaiveTrailPlugin") 

39 

40 

41class SingleFrameNaiveTrailConfig(SingleFramePluginConfig): 

42 """Config class for SingleFrameNaiveTrailPlugin. 

43 """ 

44 pass 

45 

46 

47@register("ext_trailedSources_Naive") 

48class SingleFrameNaiveTrailPlugin(SingleFramePlugin): 

49 """Naive trailed source measurement plugin 

50 

51 Measures the length, angle from +x-axis, and end points of an extended 

52 source using the second moments. 

53 

54 Parameters 

55 ---------- 

56 config: `SingleFrameNaiveTrailConfig` 

57 Plugin configuration. 

58 name: `str` 

59 Plugin name. 

60 schema: `lsst.afw.table.Schema` 

61 Schema for the output catalog. 

62 metadata: `lsst.daf.base.PropertySet` 

63 Metadata to be attached to output catalog. 

64 

65 Notes 

66 ----- 

67 This measurement plugin aims to utilize the already measured adaptive 

68 second moments to naively estimate the length and angle, and thus 

69 end-points, of a fast-moving, trailed source. The length is solved for via 

70 finding the root of the difference between the numerical (stack computed) 

71 and the analytic adaptive second moments. The angle, theta, from the x-axis 

72 is also computed via adaptive moments: theta = arctan(2*Ixy/(Ixx - Iyy))/2. 

73 The end points of the trail are then given by (xc +/- (length/2)*cos(theta) 

74 and yc +/- (length/2)*sin(theta)), with xc and yc being the centroid 

75 coordinates. 

76 

77 See also 

78 -------- 

79 lsst.meas.base.SingleFramePlugin 

80 """ 

81 

82 ConfigClass = SingleFrameNaiveTrailConfig 

83 

84 @classmethod 

85 def getExecutionOrder(cls): 

86 # Needs centroids, shape, and flux measurements. 

87 # VeresPlugin is run after, which requires image data. 

88 return cls.APCORR_ORDER + 0.1 

89 

90 def __init__(self, config, name, schema, metadata): 

91 super().__init__(config, name, schema, metadata) 

92 

93 # Measurement Keys 

94 self.keyRa = schema.addField(name + "_ra", type="D", doc="Trail centroid right ascension.") 

95 self.keyDec = schema.addField(name + "_dec", type="D", doc="Trail centroid declination.") 

96 self.keyX0 = schema.addField(name + "_x0", type="D", doc="Trail head X coordinate.", units="pixel") 

97 self.keyY0 = schema.addField(name + "_y0", type="D", doc="Trail head Y coordinate.", units="pixel") 

98 self.keyX1 = schema.addField(name + "_x1", type="D", doc="Trail tail X coordinate.", units="pixel") 

99 self.keyY1 = schema.addField(name + "_y1", type="D", doc="Trail tail Y coordinate.", units="pixel") 

100 self.keyFlux = schema.addField(name + "_flux", type="D", doc="Trailed source flux.", units="count") 

101 self.keyLength = schema.addField(name + "_length", type="D", doc="Trail length.", units="pixel") 

102 self.keyAngle = schema.addField(name + "_angle", type="D", doc="Angle measured from +x-axis.") 

103 

104 # Measurement Error Keys 

105 self.keyX0Err = schema.addField(name + "_x0Err", type="D", 

106 doc="Trail head X coordinate error.", units="pixel") 

107 self.keyY0Err = schema.addField(name + "_y0Err", type="D", 

108 doc="Trail head Y coordinate error.", units="pixel") 

109 self.keyX1Err = schema.addField(name + "_x1Err", type="D", 

110 doc="Trail tail X coordinate error.", units="pixel") 

111 self.keyY1Err = schema.addField(name + "_y1Err", type="D", 

112 doc="Trail tail Y coordinate error.", units="pixel") 

113 

114 flagDefs = FlagDefinitionList() 

115 flagDefs.addFailureFlag("No trailed-source measured") 

116 self.NO_FLUX = flagDefs.add("flag_noFlux", "No suitable prior flux measurement") 

117 self.NO_CONVERGE = flagDefs.add("flag_noConverge", "The root finder did not converge") 

118 self.NO_SIGMA = flagDefs.add("flag_noSigma", "No PSF width (sigma)") 

119 self.flagHandler = FlagHandler.addFields(schema, name, flagDefs) 

120 

121 self.centriodExtractor = SafeCentroidExtractor(schema, name) 

122 

123 def measure(self, measRecord, exposure): 

124 """Run the Naive trailed source measurement algorithm. 

125 

126 Parameters 

127 ---------- 

128 measRecord : `lsst.afw.table.SourceRecord` 

129 Record describing the object being measured. 

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

131 Pixel data to be measured. 

132 

133 See also 

134 -------- 

135 lsst.meas.base.SingleFramePlugin.measure 

136 """ 

137 

138 # Get the SdssShape centroid or fall back to slot 

139 xc = measRecord.get("base_SdssShape_x") 

140 yc = measRecord.get("base_SdssShape_y") 

141 if not np.isfinite(xc) or not np.isfinite(yc): 

142 xc, yc = self.centriodExtractor(measRecord, self.flagHandler) 

143 

144 ra, dec = self.computeRaDec(exposure, xc, yc) 

145 

146 Ixx, Iyy, Ixy = measRecord.getShape().getParameterVector() 

147 xmy = Ixx - Iyy 

148 xpy = Ixx + Iyy 

149 xmy2 = xmy*xmy 

150 xy2 = Ixy*Ixy 

151 a2 = 0.5 * (xpy + np.sqrt(xmy2 + 4.0*xy2)) 

152 

153 # Get the width of the PSF at the center of the trail 

154 center = Point2D(xc, yc) 

155 sigma = exposure.getPsf().computeShape(center).getTraceRadius() 

156 if not np.isfinite(sigma): 

157 raise MeasurementError(self.NO_SIGMA, self.NO_SIGMA.number) 

158 

159 # Check if moments are wieghted 

160 if measRecord.get("base_SdssShape_flag_unweighted"): 

161 lsst.log.debug("Unweighed") 

162 length = np.sqrt(6.0*(a2 - 2*sigma*sigma)) 

163 else: 

164 lsst.log.debug("Weighted") 

165 length, results = self.findLength(a2, sigma*sigma) 

166 if not results.converged: 

167 lsst.log.info(results.flag) 

168 raise MeasurementError(self.NO_CONVERGE.doc, self.NO_CONVERGE.number) 

169 

170 theta = 0.5 * np.arctan2(2.0 * Ixy, xmy) 

171 a = length/2.0 

172 dydt = a*np.cos(theta) 

173 dxdt = a*np.sin(theta) 

174 x0 = xc - dydt 

175 y0 = yc - dxdt 

176 x1 = xc + dydt 

177 y1 = yc + dxdt 

178 

179 # Get a cutout of the object from the exposure 

180 # cutout = getMeasurementCutout(exposure, xc, yc, L, sigma) 

181 cutout = getMeasurementCutout(measRecord, exposure) 

182 

183 # Compute flux assuming fixed parameters for VeresModel 

184 params = np.array([xc, yc, 1.0, length, theta]) # Flux = 1.0 

185 model = VeresModel(cutout) 

186 modelArray = model.computeModelImage(params).array.flatten() 

187 dataArray = cutout.image.array.flatten() 

188 flux = np.dot(dataArray, modelArray) / np.dot(modelArray, modelArray) 

189 

190 # Fall back to aperture flux 

191 if not np.isfinite(flux): 

192 if np.isfinite(measRecord.getApInstFlux()): 

193 flux = measRecord.getApInstFlux() 

194 else: 

195 raise MeasurementError(self.NO_FLUX.doc, self.NO_FLUX.number) 

196 

197 # Propagate errors from second moments 

198 xcErr2, ycErr2 = np.diag(measRecord.getCentroidErr()) 

199 IxxErr2, IyyErr2, IxyErr2 = np.diag(measRecord.getShapeErr()) 

200 desc = np.sqrt(xmy2 + 4.0*xy2) # Descriminant^1/2 of EV equation 

201 denom = 2*np.sqrt(2.0*(Ixx + np.sqrt(4.0*xy2 + xmy2 + Iyy))) # Denominator for dadIxx and dadIyy 

202 dadIxx = (1.0 + (xmy/desc)) / denom 

203 dadIyy = (1.0 - (xmy/desc)) / denom 

204 dadIxy = (4.0*Ixy) / (desc * denom) 

205 aErr2 = IxxErr2*dadIxx*dadIxx + IyyErr2*dadIyy*dadIyy + IxyErr2*dadIxy*dadIxy 

206 thetaErr2 = ((IxxErr2 + IyyErr2)*xy2 + xmy2*IxyErr2) / (desc*desc*desc*desc) 

207 

208 dxda = np.cos(theta) 

209 dyda = np.sin(theta) 

210 xErr2 = aErr2*dxda*dxda + thetaErr2*dxdt*dxdt 

211 yErr2 = aErr2*dyda*dyda + thetaErr2*dydt*dydt 

212 x0Err = np.sqrt(xErr2 + xcErr2) # Same for x1 

213 y0Err = np.sqrt(yErr2 + ycErr2) # Same for y1 

214 

215 # Set flags 

216 measRecord.set(self.keyRa, ra) 

217 measRecord.set(self.keyDec, dec) 

218 measRecord.set(self.keyX0, x0) 

219 measRecord.set(self.keyY0, y0) 

220 measRecord.set(self.keyX1, x1) 

221 measRecord.set(self.keyY1, y1) 

222 measRecord.set(self.keyFlux, flux) 

223 measRecord.set(self.keyLength, length) 

224 measRecord.set(self.keyAngle, theta) 

225 measRecord.set(self.keyX0Err, x0Err) 

226 measRecord.set(self.keyY0Err, y0Err) 

227 measRecord.set(self.keyX1Err, x0Err) 

228 measRecord.set(self.keyY1Err, y0Err) 

229 

230 def fail(self, measRecord, error=None): 

231 """Record failure 

232 

233 See also 

234 -------- 

235 lsst.meas.base.SingleFramePlugin.fail 

236 """ 

237 if error is None: 

238 self.flagHandler.handleFailure(measRecord) 

239 else: 

240 self.flagHandler.handleFailure(measRecord, error.cpp) 

241 

242 def _computeSecondMomentDiff(self, z, c): 

243 """Compute difference of the numerical and analytic second moments. 

244 

245 Parameters 

246 ---------- 

247 z : `float` 

248 Proportional to the length of the trail. (see notes) 

249 c : `float` 

250 Constant (see notes) 

251 

252 Returns 

253 ------- 

254 diff : `float` 

255 Difference in numerical and analytic second moments. 

256 

257 Notes 

258 ----- 

259 This is a simplified expression for the difference between the stack 

260 computed adaptive second-moment and the analytic solution. The variable 

261 z is proportional to the length such that length=2*z*sqrt(2*(Ixx+Iyy)), 

262 and c is a constant (c = 4*Ixx/((Ixx+Iyy)*sqrt(pi))). Both have been 

263 defined to avoid unnecessary floating-point operations in the root 

264 finder. 

265 """ 

266 

267 diff = erf(z) - c*z*np.exp(-z*z) 

268 return diff 

269 

270 def findLength(self, Ixx, Iyy): 

271 """Find the length of a trail, given adaptive second-moments. 

272 

273 Uses a root finder to compute the length of a trail corresponding to 

274 the adaptive second-moments computed by previous measurements 

275 (ie. SdssShape). 

276 

277 Parameters 

278 ---------- 

279 Ixx : `float` 

280 Adaptive second-moment along x-axis. 

281 Iyy : `float` 

282 Adaptive second-moment along y-axis. 

283 

284 Returns 

285 ------- 

286 length : `float` 

287 Length of the trail. 

288 results : `scipy.optimize.RootResults` 

289 Contains messages about convergence from the root finder. 

290 """ 

291 

292 xpy = Ixx + Iyy 

293 c = 4.0*Ixx/(xpy*np.sqrt(np.pi)) 

294 

295 # Given a 'c' in (c_min, c_max], the root is contained in (0,1]. 

296 # c_min is given by the case: Ixx == Iyy, ie. a point source. 

297 # c_max is given by the limit Ixx >> Iyy. 

298 # Emperically, 0.001 is a suitable lower bound, assuming Ixx > Iyy. 

299 z, results = sciOpt.brentq(lambda z: self._computeSecondMomentDiff(z, c), 

300 0.001, 1.0, full_output=True) 

301 

302 length = 2.0*z*np.sqrt(2.0*xpy) 

303 return length, results 

304 

305 def computeRaDec(self, exposure, x, y): 

306 """Convert pixel coordinates to RA and Dec. 

307 

308 Parameters 

309 ---------- 

310 exposure : `lsst.afw.image.ExposureF` 

311 Exposure object containing the WCS. 

312 x : `float` 

313 x coordinate of the trail centroid 

314 y : `float` 

315 y coodinate of the trail centroid 

316 

317 Returns 

318 ------- 

319 ra : `float` 

320 Right ascension. 

321 dec : `float` 

322 Declination. 

323 """ 

324 

325 wcs = exposure.getWcs() 

326 center = wcs.pixelToSky(Point2D(x, y)) 

327 ra = center.getRa().asDegrees() 

328 dec = center.getDec().asDegrees() 

329 return ra, dec