Coverage for python/lsst/meas/extensions/trailedSources/NaivePlugin.py: 20%
101 statements
« prev ^ index » next coverage.py v7.1.0, created at 2023-02-05 18:15 -0800
« prev ^ index » next coverage.py v7.1.0, created at 2023-02-05 18:15 -0800
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#
24import numpy as np
25import scipy.optimize as sciOpt
26from scipy.special import erf
28import lsst.log
29from lsst.meas.base.pluginRegistry import register
30from lsst.meas.base import SingleFramePlugin, SingleFramePluginConfig
31from lsst.meas.base import FlagHandler, FlagDefinitionList, SafeCentroidExtractor
32from lsst.meas.base import MeasurementError
34__all__ = ("SingleFrameNaiveTrailConfig", "SingleFrameNaiveTrailPlugin")
37class SingleFrameNaiveTrailConfig(SingleFramePluginConfig):
38 """Config class for SingleFrameNaiveTrailPlugin.
39 """
40 pass
43@register("ext_trailedSources_Naive")
44class SingleFrameNaiveTrailPlugin(SingleFramePlugin):
45 """Naive trailed source measurement plugin
47 Measures the length, angle from +x-axis, and end points of an extended
48 source using the second moments.
50 Parameters
51 ----------
52 config: `SingleFrameNaiveTrailConfig`
53 Plugin configuration.
54 name: `str`
55 Plugin name.
56 schema: `lsst.afw.table.Schema`
57 Schema for the output catalog.
58 metadata: `lsst.daf.base.PropertySet`
59 Metadata to be attached to output catalog.
61 Notes
62 -----
63 This measurement plugin aims to utilize the already measured adaptive
64 second moments to naively estimate the length and angle, and thus
65 end-points, of a fast-moving, trailed source. The length is solved for via
66 finding the root of the difference between the numerical (stack computed)
67 and the analytic adaptive second moments. The angle, theta, from the x-axis
68 is also computed via adaptive moments: theta = arctan(2*Ixy/(Ixx - Iyy))/2.
69 The end points of the trail are then given by (xc +/- (L/2)*cos(theta),
70 yc +/- (L/2)*sin(theta)), with xc and yc being the centroid coordinates.
72 See also
73 --------
74 lsst.meas.base.SingleFramePlugin
75 """
77 ConfigClass = SingleFrameNaiveTrailConfig
79 @classmethod
80 def getExecutionOrder(cls):
81 # Needs centroids, shape, and flux measurements.
82 # VeresPlugin is run after, which requires image data.
83 return cls.APCORR_ORDER + 0.1
85 def __init__(self, config, name, schema, metadata):
86 super().__init__(config, name, schema, metadata)
88 # Measurement Keys
89 self.keyX0 = schema.addField(name + "_x0", type="D", doc="Trail head X coordinate.", units="pixel")
90 self.keyY0 = schema.addField(name + "_y0", type="D", doc="Trail head Y coordinate.", units="pixel")
91 self.keyX1 = schema.addField(name + "_x1", type="D", doc="Trail tail X coordinate.", units="pixel")
92 self.keyY1 = schema.addField(name + "_y1", type="D", doc="Trail tail Y coordinate.", units="pixel")
93 self.keyFlux = schema.addField(name + "_flux", type="D", doc="Trailed source flux.", units="count")
94 self.keyL = schema.addField(name + "_length", type="D", doc="Trail length.", units="pixel")
95 self.keyAngle = schema.addField(name + "_angle", type="D", doc="Angle measured from +x-axis.")
97 # Measurement Error Keys
98 self.keyX0Err = schema.addField(name + "_x0Err", type="D",
99 doc="Trail head X coordinate error.", units="pixel")
100 self.keyY0Err = schema.addField(name + "_y0Err", type="D",
101 doc="Trail head Y coordinate error.", units="pixel")
102 self.keyX1Err = schema.addField(name + "_x1Err", type="D",
103 doc="Trail tail X coordinate error.", units="pixel")
104 self.keyY1Err = schema.addField(name + "_y1Err", type="D",
105 doc="Trail tail Y coordinate error.", units="pixel")
107 flagDefs = FlagDefinitionList()
108 flagDefs.addFailureFlag("No trailed-source measured")
109 self.NO_FLUX = flagDefs.add("flag_noFlux", "No suitable prior flux measurement")
110 self.NO_CONVERGE = flagDefs.add("flag_noConverge", "The root finder did not converge")
111 self.flagHandler = FlagHandler.addFields(schema, name, flagDefs)
113 self.centriodExtractor = SafeCentroidExtractor(schema, name)
115 def measure(self, measRecord, exposure):
116 """Run the Naive trailed source measurement algorithm.
118 Parameters
119 ----------
120 measRecord : `lsst.afw.table.SourceRecord`
121 Record describing the object being measured.
122 exposure : `lsst.afw.image.Exposure`
123 Pixel data to be measured.
125 See also
126 --------
127 lsst.meas.base.SingleFramePlugin.measure
128 """
129 xc, yc = self.centriodExtractor(measRecord, self.flagHandler)
130 Ixx, Iyy, Ixy = measRecord.getShape().getParameterVector()
131 xmy = Ixx - Iyy
132 xpy = Ixx + Iyy
133 xmy2 = xmy*xmy
134 xy2 = Ixy*Ixy
135 a2 = 0.5 * (xpy + np.sqrt(xmy2 + 4.0*xy2))
136 sigma = exposure.getPsf().getSigma()
138 length, results = self.findLength(a2, sigma*sigma)
139 if not results.converged:
140 lsst.log.info(results.flag)
141 raise MeasurementError(self.NO_CONVERGE.doc, self.NO_CONVERGE.number)
143 theta = 0.5 * np.arctan2(2.0 * Ixy, xmy)
144 a = length/2.0
145 dydt = a*np.cos(theta)
146 dxdt = a*np.sin(theta)
147 x0 = xc - dydt
148 y0 = yc - dxdt
149 x1 = xc + dydt
150 y1 = yc + dxdt
152 # For now, use the shape flux.
153 flux = measRecord.get("base_SdssShape_instFlux")
155 # Fall back to aperture flux
156 if not np.isfinite(flux):
157 if np.isfinite(measRecord.getApInstFlux()):
158 flux = measRecord.getApInstFlux()
159 else:
160 raise MeasurementError(self.NO_FLUX.doc, self.NO_FLUX.number)
162 # Propagate errors from second moments
163 xcErr2, ycErr2 = np.diag(measRecord.getCentroidErr())
164 IxxErr2, IyyErr2, IxyErr2 = np.diag(measRecord.getShapeErr())
165 desc = np.sqrt(xmy2 + 4.0*xy2) # Descriminant^1/2 of EV equation
166 denom = 2*np.sqrt(2.0*(Ixx + np.sqrt(4.0*xy2 + xmy2 + Iyy))) # Denominator for dadIxx and dadIyy
167 dadIxx = (1.0 + (xmy/desc)) / denom
168 dadIyy = (1.0 - (xmy/desc)) / denom
169 dadIxy = (4.0*Ixy) / (desc * denom)
170 aErr2 = IxxErr2*dadIxx*dadIxx + IyyErr2*dadIyy*dadIyy + IxyErr2*dadIxy*dadIxy
171 thetaErr2 = ((IxxErr2 + IyyErr2)*xy2 + xmy2*IxyErr2) / (desc*desc*desc*desc)
173 dxda = np.cos(theta)
174 dyda = np.sin(theta)
175 xErr2 = aErr2*dxda*dxda + thetaErr2*dxdt*dxdt
176 yErr2 = aErr2*dyda*dyda + thetaErr2*dydt*dydt
177 x0Err = np.sqrt(xErr2 + xcErr2) # Same for x1
178 y0Err = np.sqrt(yErr2 + ycErr2) # Same for y1
180 # Set flags
181 measRecord.set(self.keyX0, x0)
182 measRecord.set(self.keyY0, y0)
183 measRecord.set(self.keyX1, x1)
184 measRecord.set(self.keyY1, y1)
185 measRecord.set(self.keyFlux, flux)
186 measRecord.set(self.keyL, length)
187 measRecord.set(self.keyAngle, theta)
188 measRecord.set(self.keyX0Err, x0Err)
189 measRecord.set(self.keyY0Err, y0Err)
190 measRecord.set(self.keyX1Err, x0Err)
191 measRecord.set(self.keyY1Err, y0Err)
193 def fail(self, measRecord, error=None):
194 """Record failure
196 See also
197 --------
198 lsst.meas.base.SingleFramePlugin.fail
199 """
200 if error is None:
201 self.flagHandler.handleFailure(measRecord)
202 else:
203 self.flagHandler.handleFailure(measRecord, error.cpp)
205 def _computeSecondMomentDiff(self, z, c):
206 """Compute difference of the numerical and analytic second moments.
208 Parameters
209 ----------
210 z : `float`
211 Proportional to the length of the trail. (see notes)
212 c : `float`
213 Constant (see notes)
215 Returns
216 -------
217 diff : `float`
218 Difference in numerical and analytic second moments.
220 Notes
221 -----
222 This is a simplified expression for the difference between the stack
223 computed adaptive second-moment and the analytic solution. The variable
224 z is proportional to the length such that L = 2*z*sqrt(2*(Ixx+Iyy)),
225 and c is a constant (c = 4*Ixx/((Ixx+Iyy)*sqrt(pi))). Both have been
226 defined to avoid unnecessary floating-point operations in the root
227 finder.
228 """
230 diff = erf(z) - c*z*np.exp(-z*z)
231 return diff
233 def findLength(self, Ixx, Iyy):
234 """Find the length of a trail, given adaptive second-moments.
236 Uses a root finder to compute the length of a trail corresponding to
237 the adaptive second-moments computed by previous measurements
238 (ie. SdssShape).
240 Parameters
241 ----------
242 Ixx : `float`
243 Adaptive second-moment along x-axis.
244 Iyy : `float`
245 Adaptive second-moment along y-axis.
247 Returns
248 -------
249 length : `float`
250 Length of the trail.
251 results : `scipy.optimize.RootResults`
252 Contains messages about convergence from the root finder.
253 """
255 xpy = Ixx + Iyy
256 c = 4.0*Ixx/(xpy*np.sqrt(np.pi))
258 # Given a 'c' in (c_min, c_max], the root is contained in (0,1].
259 # c_min is given by the case: Ixx == Iyy, ie. a point source.
260 # c_max is given by the limit Ixx >> Iyy.
261 # Emperically, 0.001 is a suitable lower bound, assuming Ixx > Iyy.
262 z, results = sciOpt.brentq(lambda z: self._computeSecondMomentDiff(z, c),
263 0.001, 1.0, full_output=True)
265 length = 2.0*z*np.sqrt(2.0*xpy)
266 return length, results