Coverage for python/lsst/pipe/tasks/quickFrameMeasurement.py : 17%

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 pipe_tasks.
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/>.
22import numpy as np
23import lsst.afw.detection as afwDetect
24import lsst.afw.table as afwTable
25import lsst.meas.base as measBase
26import lsst.daf.base as dafBase
27import lsst.pipe.base as pipeBase
28import lsst.pex.config as pexConfig
29from lsst.meas.base import MeasurementError
30from lsst.meas.algorithms.installGaussianPsf import InstallGaussianPsfTask
33class QuickFrameMeasurementTaskConfig(pexConfig.Config):
34 """Config class for the QuickFrameMeasurementTask.
35 """
36 installPsf = pexConfig.ConfigurableField(
37 target=InstallGaussianPsfTask,
38 doc="Task for installing an initial PSF",
39 )
40 maxNonRoundness = pexConfig.Field(
41 dtype=float,
42 doc="Ratio of xx to yy (or vice versa) above which to cut, in order to exclude spectra",
43 default=5.,
44 )
45 maxExtendedness = pexConfig.Field(
46 dtype=float,
47 doc="Max absolute value of xx and yy above which to cut, in order to exclude large/things",
48 default=100,
49 )
50 doExtendednessCut = pexConfig.Field(
51 dtype=bool,
52 doc="Apply the extendeness cut, as definted by maxExtendedness",
53 default=False,
54 )
55 centroidPixelPercentile = pexConfig.Field(
56 dtype=float,
57 doc="The image's percentile value which the centroid must be greater than to pass the final peak"
58 " check. Ignored if doCheckCentroidPixelValue is False",
59 default=90,
60 )
61 doCheckCentroidPixelValue = pexConfig.Field(
62 dtype=bool,
63 doc="Check that the centroid found is actually in the centroidPixelPercentile percentile of the"
64 " image? Set to False for donut images.",
65 default=True,
66 )
67 initialPsfWidth = pexConfig.Field(
68 dtype=float,
69 doc="Guess at the initial PSF FWHM in pixels.",
70 default=10,
71 )
72 nSigmaDetection = pexConfig.Field(
73 dtype=float,
74 doc="Number of sigma for the detection limit.",
75 default=20,
76 )
77 nPixMinDetection = pexConfig.Field(
78 dtype=int,
79 doc="Minimum number of pixels in a detected source.",
80 default=10,
81 )
83 def setDefaults(self):
84 super().setDefaults()
85 self.installPsf.fwhm = self.initialPsfWidth
88class QuickFrameMeasurementTask(pipeBase.Task):
89 """WARNING: An experimental new task with changable API! Do not rely on yet!
91 This task finds the centroid of the brightest source in a given CCD-image
92 and returns its centroid and a rough estimate of the seeing/PSF.
94 It is designed for speed, such that it can be used in observing scripts
95 to provide pointing offsets, allowing subsequent pointings to place
96 a source at an exact pixel position.
98 The approach taken here is deliberately sub-optimal in the detection and
99 measurement sense, with all optimisation being done for speed and robustness
100 of the result.
102 A small set of unit tests exist for this task, which run automatically
103 if afwdata is setup. These, however, are stricky unit tests, and will not
104 catch algorithmic regressions. TODO: DM-29038 exists to merge a regression
105 real test which runs against 1,000 LATISS images, but is therefore slow
106 and requires access to the data.
108 Parameters
109 ----------
110 config : lsst.pipe.tasks.quickFrameMeasurement.QuickFrameMeasurementTaskConfig
111 Configuration class for the QuickFrameMeasurementTask.
113 display : lsst.afw.display.Display, optional
114 The display to use for showing the images, detections and centroids.
116 Returns
117 -------
118 result : lsst.pipe.base.Struct()
119 Return strucure containing whether the task was successful, the main
120 source's centroid, its the aperture fluxes, the ixx and iyy of the
121 source, and the median ixx, iyy of the detections in the exposure.
122 See run() method for further details.
124 Raises
125 ------
126 This task should *never* raise, as the run() method is enclosed in an
127 except Exception block, so that it will never fail during observing.
128 Failure modes should be limited to returning a return Struct() with the same
129 structure as the success case, with all value set to np.nan but with
130 result.success=False.
131 """
132 ConfigClass = QuickFrameMeasurementTaskConfig
133 _DefaultName = 'quickFrameMeasurementTask'
135 def __init__(self, config, *, display=None, **kwargs):
136 super().__init__(config=config, **kwargs)
137 self.makeSubtask("installPsf")
139 self.display = None
140 if display:
141 self.display = display
143 self.centroidName = "base_SdssCentroid"
144 self.shapeName = "base_SdssShape"
145 self.schema = afwTable.SourceTable.makeMinimalSchema()
146 self.schema.getAliasMap().set("slot_Centroid", self.centroidName)
147 self.schema.getAliasMap().set("slot_Shape", self.shapeName)
148 self.control = measBase.SdssCentroidControl()
149 self.centroider = measBase.SdssCentroidAlgorithm(self.control, self.centroidName, self.schema)
150 self.sdssShape = measBase.SdssShapeControl()
151 self.shaper = measBase.SdssShapeAlgorithm(self.sdssShape, self.shapeName, self.schema)
152 self.apFluxControl = measBase.ApertureFluxControl()
153 md = dafBase.PropertySet()
154 self.apFluxer = measBase.CircularApertureFluxAlgorithm(self.apFluxControl, "aperFlux",
155 self.schema, md)
157 self.table = afwTable.SourceTable.make(self.schema) # make sure to call this last!
159 @staticmethod
160 def detectObjectsInExp(exp, nSigma, nPixMin, grow=0):
161 """Run a very basic but fast threshold-based object detection on an exposure
162 Return the footPrintSet for the objects in a postISR exposure.
164 Parameters
165 ----------
166 exp : lsst.afw.image.Exposure
167 Image in which to detect objects.
169 nSigma : float
170 nSigma above image's stddev at which to set the detection threshold.
172 nPixMin : int
173 Minimum number of pixels for detection.
175 grow : int
176 Grow the detected footprint by this many pixels.
178 Returns
179 -------
180 footPrintSet : lsst.afw.detection.FootprintSet
181 FootprintSet containing the detections.
182 """
183 threshold = afwDetect.Threshold(nSigma, afwDetect.Threshold.STDEV)
184 footPrintSet = afwDetect.FootprintSet(exp.getMaskedImage(), threshold, "DETECTED", nPixMin)
185 if grow > 0:
186 isotropic = True
187 footPrintSet = afwDetect.FootprintSet(footPrintSet, grow, isotropic)
188 return footPrintSet
190 @staticmethod
191 def checkResult(exp, centroid, srcNum, percentile):
192 """Perform a final check that centroid location is actually bright.
194 Parameters
195 ----------
196 exp : lsst.afw.image.Exposure
197 centroid : `tuple` of `float`
198 Location of the centroid in pixel coordinates
200 scrNum : int
201 Number of the source in the source catalog. Only used if the check
202 is failed, for debug purposes.
204 percentile : float
205 Image's percentile above which the pixel containing the centroid
206 must be in order to pass the check.
208 Raises
209 ------
210 ValueError
211 Raised if the centroid's pixel is not above the percentile threshold
212 """
213 threshold = np.percentile(exp.image.array, percentile)
214 pixelValue = exp.image[centroid]
215 if pixelValue < threshold:
216 msg = (f"Final centroid pixel value check failed: srcNum {srcNum} at {centroid}"
217 f" has central pixel = {pixelValue:3f} <"
218 f" {percentile} percentile of image = {threshold:3f}")
219 raise ValueError(msg)
220 return
222 @staticmethod
223 def _calcMedianXxYy(objData):
224 """Return the median ixx and iyy for object in the image.
225 """
226 medianXx = np.nanmedian([element['xx'] for element in objData.values()])
227 medianYy = np.nanmedian([element['xx'] for element in objData.values()])
228 return medianXx, medianYy
230 def _calcBrightestObjSrcNum(self, objData):
231 """Find the brightest source which passes the cuts among the sources.
233 Parameters
234 ----------
235 objData : `dict` of `dict`
236 Dictionary, keyed by source number, containing the measurements.
238 Returns
239 -------
240 srcNum : int
241 The source number of the brightest source which passes the cuts.
242 """
243 max70, max70srcNum = -1, -1
244 max25, max25srcNum = -1, -1
246 for srcNum in sorted(objData.keys()): # srcNum not contiguous so don't use a list comp
247 # skip flag used rather than continue statements so we have all the
248 # metrics computed for debug purposes as this task is whack-a-mole
249 skip = False
250 xx = objData[srcNum]['xx']
251 yy = objData[srcNum]['yy']
253 xx = max(xx, 1e-9) # need to protect against division by zero
254 yy = max(yy, 1e-9) # because we don't `continue` on zero moments
256 if self.config.doExtendednessCut:
257 if xx > self.config.maxExtendedness or yy > self.config.maxExtendedness:
258 skip = True
260 nonRoundness = xx/yy
261 nonRoundness = max(nonRoundness, 1/nonRoundness)
262 if nonRoundness > self.config.maxNonRoundness:
263 skip = True
265 if self.log.isDebugEnabled():
266 text = f"src {srcNum}: {objData[srcNum]['xCentroid']:.0f}, {objData[srcNum]['yCentroid']:.0f}"
267 text += f" - xx={xx:.1f}, yy={yy:.1f}, nonRound={nonRoundness:.1f}"
268 text += f" - ap70={objData[srcNum]['apFlux70']:,.0f}"
269 text += f" - ap25={objData[srcNum]['apFlux25']:,.0f}"
270 text += f" - skip={skip}"
271 self.log.debug(text)
273 if skip:
274 continue
276 ap70 = objData[srcNum]['apFlux70']
277 ap25 = objData[srcNum]['apFlux25']
278 if ap70 > max70:
279 max70 = ap70
280 max70srcNum = srcNum
281 if ap25 > max25:
282 max25 = ap25
283 max25srcNum = srcNum
284 if max70srcNum != max25srcNum:
285 self.log.warn("WARNING! Max apFlux70 for different object than with max apFlux25")
287 if max70srcNum >= 0: # starts as -1, return None if nothing is acceptable
288 return max70srcNum
289 return None
291 def _measureFp(self, fp, exp):
292 """Run the measurements on a footprint.
294 Parameters
295 ----------
296 fp : lsst.afw.detection.Footprint
297 The footprint to measure.
299 exp : lsst.afw.image.Exposure
300 The footprint's parent exposure.
302 Returns
303 -------
304 src : lsst.afw.table.SourceRecord
305 The source record containing the measurements.
306 """
307 src = self.table.makeRecord()
308 src.setFootprint(fp)
309 self.centroider.measure(src, exp)
310 self.shaper.measure(src, exp)
311 self.apFluxer.measure(src, exp)
312 return src
314 def _getDataFromSrcRecord(self, src):
315 """Extract the shapes and centroids from a source record.
317 Parameters
318 ----------
319 src : lsst.afw.table.SourceRecord
320 The source record from which to extract the measurements.
322 Returns
323 -------
324 srcData : lsst.pipe.base.Struct
325 The struct containing the extracted measurements.
326 """
327 pScale = self.plateScale
328 xx = np.sqrt(src['base_SdssShape_xx'])*2.355*pScale # 2.355 for FWHM, pScale for platescale from exp
329 yy = np.sqrt(src['base_SdssShape_yy'])*2.355*pScale
330 xCentroid = src['base_SdssCentroid_x']
331 yCentroid = src['base_SdssCentroid_y']
332 # apFluxes available: 70, 50, 35, 25, 17, 12 9, 6, 4.5, 3
333 apFlux70 = src['aperFlux_70_0_instFlux']
334 apFlux25 = src['aperFlux_25_0_instFlux']
335 return pipeBase.Struct(xx=xx,
336 yy=yy,
337 xCentroid=xCentroid,
338 yCentroid=yCentroid,
339 apFlux70=apFlux70,
340 apFlux25=apFlux25)
342 @staticmethod
343 def _getDataFromFootprintOnly(fp, exp):
344 """Get the shape, centroid and flux from a footprint.
346 Parameters
347 ----------
348 fp : lsst.afw.detection.Footprint
349 The footprint to measure.
350 exp : lsst.afw.image.Exposure
351 The footprint's parent exposure.
353 Returns
354 -------
355 srcData : lsst.pipe.base.Struct
356 The struct containing the extracted measurements.
357 """
358 xx = fp.getShape().getIxx()
359 yy = fp.getShape().getIyy()
360 xCentroid, yCentroid = fp.getCentroid()
361 apFlux70 = np.sum(exp[fp.getBBox()].image.array)
362 apFlux25 = np.sum(exp[fp.getBBox()].image.array)
363 return pipeBase.Struct(xx=xx,
364 yy=yy,
365 xCentroid=xCentroid,
366 yCentroid=yCentroid,
367 apFlux70=apFlux70,
368 apFlux25=apFlux25)
370 @staticmethod
371 def _measurementResultToDict(measurementResult):
372 """Convenience function to repackage measurement results to a dict.
374 Parameters
375 ----------
376 measurementResult : lsst.afw.table.SourceRecord
377 The source record to convert to a dict.
379 Returns
380 -------
381 objData : `dict`
382 The dict containing the extracted data.
383 """
384 objData = {}
385 objData['xx'] = measurementResult.xx
386 objData['yy'] = measurementResult.yy
387 objData['xCentroid'] = measurementResult.xCentroid
388 objData['yCentroid'] = measurementResult.yCentroid
389 objData['apFlux70'] = measurementResult.apFlux70
390 objData['apFlux25'] = measurementResult.apFlux25
391 return objData
393 @staticmethod
394 def _makeEmptyReturnStruct():
395 """Make the default/template return struct, with defaults to False/nan.
397 Returns
398 -------
399 objData : lsst.pipe.base.Struct
400 The default template return structure.
401 """
402 result = pipeBase.Struct()
403 result.success = False
404 result.brightestObjCentroid = (np.nan, np.nan)
405 result.brightestObj_xXyY = (np.nan, np.nan)
406 result.brightestObjApFlux70 = np.nan
407 result.brightestObjApFlux25 = np.nan
408 result.medianXxYy = (np.nan, np.nan)
409 return result
411 def run(self, exp, doDisplay=False):
412 """Calculate position, flux and shape of the brightest star in an image.
414 Given an an assembled (and at least minimally ISRed exposure),
415 quickly and robustly calculate the centroid of the
416 brightest star in the image.
418 Parameters
419 ----------
420 exp : lsst.afw.image.Exposure
421 The exposure in which to find and measure the brightest star.
423 doDisplay : bool
424 Display the image and found sources. A diplay object must have
425 been passed to the task constructor.
427 Returns
428 -------
429 result : lsst.pipe.base.Struct
430 Struct containing:
431 Whether the task ran successfully and found the object (bool)
432 The object's centroid (float, float)
433 The object's ixx, iyy (float, float)
434 The object's 70 pixel aperture flux (float)
435 The object's 25 pixel aperture flux (float)
436 The images's median ixx, iyy (float, float)
437 If unsuccessful, the success field is False and all other results
438 are np.nan of the expected shape.
440 Notes
441 -----
442 Because of this task's involvement in observing scripts, the run method
443 should *never* raise. Failure modes are noted by returning a Struct with
444 the same structure as the success case, with all value set to np.nan and
445 result.success=False.
446 """
447 try:
448 result = self._run(exp=exp, doDisplay=doDisplay)
449 return result
450 except Exception as e:
451 self.log.warn(f"Failed to find main source centroid {e}")
452 result = self._makeEmptyReturnStruct()
453 return result
455 def _run(self, exp, doDisplay=False):
456 """The actual run method, called by run()
458 Behaviour is documented in detail in the main run().
459 """
460 self.plateScale = exp.getWcs().getPixelScale().asArcseconds()
461 median = np.nanmedian(exp.image.array)
462 exp.image -= median # is put back later
463 self.installPsf.run(exp)
464 sources = self.detectObjectsInExp(exp, nSigma=self.config.nSigmaDetection,
465 nPixMin=self.config.nPixMinDetection)
467 if doDisplay:
468 if self.display is None:
469 raise RuntimeError("Display failed as no display provided during init()")
470 self.display.mtv(exp)
472 fpSet = sources.getFootprints()
473 self.log.info(f"Found {len(fpSet)} sources in exposure")
475 objData = {}
476 nMeasured = 0
478 for srcNum, fp in enumerate(fpSet):
479 try:
480 src = self._measureFp(fp, exp)
481 result = self._getDataFromSrcRecord(src)
482 except MeasurementError:
483 try:
484 # gets shape and centroid from footprint
485 result = self._getDataFromFootprintOnly(fp, exp)
486 except MeasurementError as e:
487 self.log.info(f"Skipped measuring source {srcNum}: {e}")
488 continue
489 objData[srcNum] = self._measurementResultToDict(result)
490 nMeasured += 1
492 self.log.info(f"Measured {nMeasured} of {len(fpSet)} sources in exposure")
494 medianXxYy = self._calcMedianXxYy(objData)
496 brightestObjSrcNum = self._calcBrightestObjSrcNum(objData)
497 if brightestObjSrcNum is None:
498 raise RuntimeError("No sources in image passed cuts")
500 x = objData[brightestObjSrcNum]['xCentroid']
501 y = objData[brightestObjSrcNum]['yCentroid']
502 brightestObjCentroid = (x, y)
503 xx = objData[brightestObjSrcNum]['xx']
504 yy = objData[brightestObjSrcNum]['yy']
505 brightestObjApFlux70 = objData[brightestObjSrcNum]['apFlux70']
506 brightestObjApFlux25 = objData[brightestObjSrcNum]['apFlux25']
508 exp.image += median # put background back in
509 if self.config.doCheckCentroidPixelValue:
510 self.checkResult(exp, brightestObjCentroid, brightestObjSrcNum,
511 self.config.centroidPixelPercentile)
513 result = self._makeEmptyReturnStruct()
514 result.success = True
515 result.brightestObjCentroid = brightestObjCentroid
516 result.brightestObj_xXyY = (xx, yy)
517 result.brightestObjApFlux70 = brightestObjApFlux70
518 result.brightestObjApFlux25 = brightestObjApFlux25
519 result.medianXxYy = medianXxYy
520 return result