Coverage for python/lsst/meas/algorithms/detection.py: 14%

316 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-02-10 02:18 -0800

1# 

2# LSST Data Management System 

3# 

4# Copyright 2008-2017 AURA/LSST. 

5# 

6# This product includes software developed by the 

7# LSST Project (http://www.lsst.org/). 

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 LSST License Statement and 

20# the GNU General Public License along with this program. If not, 

21# see <https://www.lsstcorp.org/LegalNotices/>. 

22# 

23 

24__all__ = ("SourceDetectionConfig", "SourceDetectionTask", "addExposures") 

25 

26from contextlib import contextmanager 

27 

28import numpy as np 

29 

30import lsst.geom 

31import lsst.afw.display as afwDisplay 

32import lsst.afw.detection as afwDet 

33import lsst.afw.geom as afwGeom 

34import lsst.afw.image as afwImage 

35import lsst.afw.math as afwMath 

36import lsst.afw.table as afwTable 

37import lsst.pex.config as pexConfig 

38import lsst.pipe.base as pipeBase 

39from lsst.utils.timer import timeMethod 

40from .subtractBackground import SubtractBackgroundTask 

41 

42 

43class SourceDetectionConfig(pexConfig.Config): 

44 """Configuration parameters for the SourceDetectionTask 

45 """ 

46 minPixels = pexConfig.RangeField( 

47 doc="detected sources with fewer than the specified number of pixels will be ignored", 

48 dtype=int, optional=False, default=1, min=0, 

49 ) 

50 isotropicGrow = pexConfig.Field( 

51 doc="Pixels should be grown as isotropically as possible (slower)", 

52 dtype=bool, optional=False, default=False, 

53 ) 

54 combinedGrow = pexConfig.Field( 

55 doc="Grow all footprints at the same time? This allows disconnected footprints to merge.", 

56 dtype=bool, default=True, 

57 ) 

58 nSigmaToGrow = pexConfig.Field( 

59 doc="Grow detections by nSigmaToGrow * [PSF RMS width]; if 0 then do not grow", 

60 dtype=float, default=2.4, # 2.4 pixels/sigma is roughly one pixel/FWHM 

61 ) 

62 returnOriginalFootprints = pexConfig.Field( 

63 doc="Grow detections to set the image mask bits, but return the original (not-grown) footprints", 

64 dtype=bool, optional=False, default=False, 

65 ) 

66 thresholdValue = pexConfig.RangeField( 

67 doc="Threshold for footprints; exact meaning and units depend on thresholdType.", 

68 dtype=float, optional=False, default=5.0, min=0.0, 

69 ) 

70 includeThresholdMultiplier = pexConfig.RangeField( 

71 doc="Include threshold relative to thresholdValue", 

72 dtype=float, default=1.0, min=0.0, 

73 ) 

74 thresholdType = pexConfig.ChoiceField( 

75 doc="specifies the desired flavor of Threshold", 

76 dtype=str, optional=False, default="stdev", 

77 allowed={ 

78 "variance": "threshold applied to image variance", 

79 "stdev": "threshold applied to image std deviation", 

80 "value": "threshold applied to image value", 

81 "pixel_stdev": "threshold applied to per-pixel std deviation", 

82 }, 

83 ) 

84 thresholdPolarity = pexConfig.ChoiceField( 

85 doc="specifies whether to detect positive, or negative sources, or both", 

86 dtype=str, optional=False, default="positive", 

87 allowed={ 

88 "positive": "detect only positive sources", 

89 "negative": "detect only negative sources", 

90 "both": "detect both positive and negative sources", 

91 }, 

92 ) 

93 adjustBackground = pexConfig.Field( 

94 dtype=float, 

95 doc="Fiddle factor to add to the background; debugging only", 

96 default=0.0, 

97 ) 

98 reEstimateBackground = pexConfig.Field( 

99 dtype=bool, 

100 doc="Estimate the background again after final source detection?", 

101 default=True, optional=False, 

102 ) 

103 background = pexConfig.ConfigurableField( 

104 doc="Background re-estimation; ignored if reEstimateBackground false", 

105 target=SubtractBackgroundTask, 

106 ) 

107 tempLocalBackground = pexConfig.ConfigurableField( 

108 doc=("A local (small-scale), temporary background estimation step run between " 

109 "detecting above-threshold regions and detecting the peaks within " 

110 "them; used to avoid detecting spuerious peaks in the wings."), 

111 target=SubtractBackgroundTask, 

112 ) 

113 doTempLocalBackground = pexConfig.Field( 

114 dtype=bool, 

115 doc="Enable temporary local background subtraction? (see tempLocalBackground)", 

116 default=True, 

117 ) 

118 tempWideBackground = pexConfig.ConfigurableField( 

119 doc=("A wide (large-scale) background estimation and removal before footprint and peak detection. " 

120 "It is added back into the image after detection. The purpose is to suppress very large " 

121 "footprints (e.g., from large artifacts) that the deblender may choke on."), 

122 target=SubtractBackgroundTask, 

123 ) 

124 doTempWideBackground = pexConfig.Field( 

125 dtype=bool, 

126 doc="Do temporary wide (large-scale) background subtraction before footprint detection?", 

127 default=False, 

128 ) 

129 nPeaksMaxSimple = pexConfig.Field( 

130 dtype=int, 

131 doc=("The maximum number of peaks in a Footprint before trying to " 

132 "replace its peaks using the temporary local background"), 

133 default=1, 

134 ) 

135 nSigmaForKernel = pexConfig.Field( 

136 dtype=float, 

137 doc=("Multiple of PSF RMS size to use for convolution kernel bounding box size; " 

138 "note that this is not a half-size. The size will be rounded up to the nearest odd integer"), 

139 default=7.0, 

140 ) 

141 statsMask = pexConfig.ListField( 

142 dtype=str, 

143 doc="Mask planes to ignore when calculating statistics of image (for thresholdType=stdev)", 

144 default=['BAD', 'SAT', 'EDGE', 'NO_DATA'], 

145 ) 

146 

147 def setDefaults(self): 

148 self.tempLocalBackground.binSize = 64 

149 self.tempLocalBackground.algorithm = "AKIMA_SPLINE" 

150 self.tempLocalBackground.useApprox = False 

151 # Background subtraction to remove a large-scale background (e.g., scattered light); restored later. 

152 # Want to keep it from exceeding the deblender size limit of 1 Mpix, so half that is reasonable. 

153 self.tempWideBackground.binSize = 512 

154 self.tempWideBackground.algorithm = "AKIMA_SPLINE" 

155 self.tempWideBackground.useApprox = False 

156 # Ensure we can remove even bright scattered light that is DETECTED 

157 for maskPlane in ("DETECTED", "DETECTED_NEGATIVE"): 

158 if maskPlane in self.tempWideBackground.ignoredPixelMask: 

159 self.tempWideBackground.ignoredPixelMask.remove(maskPlane) 

160 

161 

162class SourceDetectionTask(pipeBase.Task): 

163 """Detect peaks and footprints of sources in an image. 

164 

165 This task convolves the image with a Gaussian approximation to the PSF, 

166 matched to the sigma of the input exposure, because this is separable and 

167 fast. The PSF would have to be very non-Gaussian or non-circular for this 

168 approximation to have a significant impact on the signal-to-noise of the 

169 detected sources. 

170 

171 Parameters 

172 ---------- 

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

174 Schema object used to create the output `lsst.afw.table.SourceCatalog` 

175 **kwds 

176 Keyword arguments passed to `lsst.pipe.base.Task.__init__` 

177 

178 If schema is not None and configured for 'both' detections, 

179 a 'flags.negative' field will be added to label detections made with a 

180 negative threshold. 

181 

182 Notes 

183 ----- 

184 This task can add fields to the schema, so any code calling this task must 

185 ensure that these columns are indeed present in the input match list. 

186 """ 

187 ConfigClass = SourceDetectionConfig 

188 _DefaultName = "sourceDetection" 

189 

190 def __init__(self, schema=None, **kwds): 

191 pipeBase.Task.__init__(self, **kwds) 

192 if schema is not None and self.config.thresholdPolarity == "both": 

193 self.negativeFlagKey = schema.addField( 

194 "flags_negative", type="Flag", 

195 doc="set if source was detected as significantly negative" 

196 ) 

197 else: 

198 if self.config.thresholdPolarity == "both": 

199 self.log.warning("Detection polarity set to 'both', but no flag will be " 

200 "set to distinguish between positive and negative detections") 

201 self.negativeFlagKey = None 

202 if self.config.reEstimateBackground: 

203 self.makeSubtask("background") 

204 if self.config.doTempLocalBackground: 

205 self.makeSubtask("tempLocalBackground") 

206 if self.config.doTempWideBackground: 

207 self.makeSubtask("tempWideBackground") 

208 

209 @timeMethod 

210 def run(self, table, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None): 

211 r"""Detect sources and return catalog(s) of detections. 

212 

213 Parameters 

214 ---------- 

215 table : `lsst.afw.table.SourceTable` 

216 Table object that will be used to create the SourceCatalog. 

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

218 Exposure to process; DETECTED mask plane will be set in-place. 

219 doSmooth : `bool` 

220 If True, smooth the image before detection using a Gaussian of width 

221 ``sigma``, or the measured PSF width. Set to False when running on 

222 e.g. a pre-convolved image, or a mask plane. 

223 sigma : `float` 

224 Sigma of PSF (pixels); used for smoothing and to grow detections; 

225 if None then measure the sigma of the PSF of the exposure 

226 clearMask : `bool` 

227 Clear DETECTED{,_NEGATIVE} planes before running detection. 

228 expId : `int` 

229 Exposure identifier; unused by this implementation, but used for 

230 RNG seed by subclasses. 

231 

232 Returns 

233 ------- 

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

235 The `~lsst.pipe.base.Struct` contains: 

236 

237 ``sources`` 

238 Detected sources on the exposure. 

239 (`lsst.afw.table.SourceCatalog`) 

240 ``positive`` 

241 Positive polarity footprints. 

242 (`lsst.afw.detection.FootprintSet` or `None`) 

243 ``negative`` 

244 Negative polarity footprints. 

245 (`lsst.afw.detection.FootprintSet` or `None`) 

246 ``numPos`` 

247 Number of footprints in positive or 0 if detection polarity was 

248 negative. (`int`) 

249 ``numNeg`` 

250 Number of footprints in negative or 0 if detection polarity was 

251 positive. (`int`) 

252 ``background`` 

253 Re-estimated background. `None` if 

254 ``reEstimateBackground==False``. 

255 (`lsst.afw.math.BackgroundList`) 

256 ``factor`` 

257 Multiplication factor applied to the configured detection 

258 threshold. (`float`) 

259 

260 Raises 

261 ------ 

262 ValueError 

263 Raised if flags.negative is needed, but isn't in table's schema. 

264 lsst.pipe.base.TaskError 

265 Raised if sigma=None, doSmooth=True and the exposure has no PSF. 

266 

267 Notes 

268 ----- 

269 If you want to avoid dealing with Sources and Tables, you can use 

270 `detectFootprints()` to just get the 

271 `~lsst.afw.detection.FootprintSet`\s. 

272 """ 

273 if self.negativeFlagKey is not None and self.negativeFlagKey not in table.getSchema(): 

274 raise ValueError("Table has incorrect Schema") 

275 results = self.detectFootprints(exposure=exposure, doSmooth=doSmooth, sigma=sigma, 

276 clearMask=clearMask, expId=expId) 

277 sources = afwTable.SourceCatalog(table) 

278 sources.reserve(results.numPos + results.numNeg) 

279 if results.negative: 

280 results.negative.makeSources(sources) 

281 if self.negativeFlagKey: 

282 for record in sources: 

283 record.set(self.negativeFlagKey, True) 

284 if results.positive: 

285 results.positive.makeSources(sources) 

286 results.fpSets = results.copy() # Backward compatibility 

287 results.sources = sources 

288 return results 

289 

290 def display(self, exposure, results, convolvedImage=None): 

291 """Display detections if so configured 

292 

293 Displays the ``exposure`` in frame 0, overlays the detection peaks. 

294 

295 Requires that ``lsstDebug`` has been set up correctly, so that 

296 ``lsstDebug.Info("lsst.meas.algorithms.detection")`` evaluates `True`. 

297 

298 If the ``convolvedImage`` is non-`None` and 

299 ``lsstDebug.Info("lsst.meas.algorithms.detection") > 1``, the 

300 ``convolvedImage`` will be displayed in frame 1. 

301 

302 Parameters 

303 ---------- 

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

305 Exposure to display, on which will be plotted the detections. 

306 results : `lsst.pipe.base.Struct` 

307 Results of the 'detectFootprints' method, containing positive and 

308 negative footprints (which contain the peak positions that we will 

309 plot). This is a `Struct` with ``positive`` and ``negative`` 

310 elements that are of type `lsst.afw.detection.FootprintSet`. 

311 convolvedImage : `lsst.afw.image.Image`, optional 

312 Convolved image used for thresholding. 

313 """ 

314 try: 

315 import lsstDebug 

316 display = lsstDebug.Info(__name__).display 

317 except ImportError: 

318 try: 

319 display 

320 except NameError: 

321 display = False 

322 if not display: 

323 return 

324 

325 afwDisplay.setDefaultMaskTransparency(75) 

326 

327 disp0 = afwDisplay.Display(frame=0) 

328 disp0.mtv(exposure, title="detection") 

329 

330 def plotPeaks(fps, ctype): 

331 if fps is None: 

332 return 

333 with disp0.Buffering(): 

334 for fp in fps.getFootprints(): 

335 for pp in fp.getPeaks(): 

336 disp0.dot("+", pp.getFx(), pp.getFy(), ctype=ctype) 

337 plotPeaks(results.positive, "yellow") 

338 plotPeaks(results.negative, "red") 

339 

340 if convolvedImage and display > 1: 

341 disp1 = afwDisplay.Display(frame=1) 

342 disp1.mtv(convolvedImage, title="PSF smoothed") 

343 

344 disp2 = afwDisplay.Display(frame=2) 

345 disp2.mtv(afwImage.ImageF(np.sqrt(exposure.variance.array)), title="stddev") 

346 

347 def applyTempLocalBackground(self, exposure, middle, results): 

348 """Apply a temporary local background subtraction 

349 

350 This temporary local background serves to suppress noise fluctuations 

351 in the wings of bright objects. 

352 

353 Peaks in the footprints will be updated. 

354 

355 Parameters 

356 ---------- 

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

358 Exposure for which to fit local background. 

359 middle : `lsst.afw.image.MaskedImage` 

360 Convolved image on which detection will be performed 

361 (typically smaller than ``exposure`` because the 

362 half-kernel has been removed around the edges). 

363 results : `lsst.pipe.base.Struct` 

364 Results of the 'detectFootprints' method, containing positive and 

365 negative footprints (which contain the peak positions that we will 

366 plot). This is a `Struct` with ``positive`` and ``negative`` 

367 elements that are of type `lsst.afw.detection.FootprintSet`. 

368 """ 

369 # Subtract the local background from the smoothed image. Since we 

370 # never use the smoothed again we don't need to worry about adding 

371 # it back in. 

372 bg = self.tempLocalBackground.fitBackground(exposure.getMaskedImage()) 

373 bgImage = bg.getImageF(self.tempLocalBackground.config.algorithm, 

374 self.tempLocalBackground.config.undersampleStyle) 

375 middle -= bgImage.Factory(bgImage, middle.getBBox()) 

376 if self.config.thresholdPolarity != "negative": 

377 results.positiveThreshold = self.makeThreshold(middle, "positive") 

378 self.updatePeaks(results.positive, middle, results.positiveThreshold) 

379 if self.config.thresholdPolarity != "positive": 

380 results.negativeThreshold = self.makeThreshold(middle, "negative") 

381 self.updatePeaks(results.negative, middle, results.negativeThreshold) 

382 

383 def clearMask(self, mask): 

384 """Clear the DETECTED and DETECTED_NEGATIVE mask planes. 

385 

386 Removes any previous detection mask in preparation for a new 

387 detection pass. 

388 

389 Parameters 

390 ---------- 

391 mask : `lsst.afw.image.Mask` 

392 Mask to be cleared. 

393 """ 

394 mask &= ~(mask.getPlaneBitMask("DETECTED") | mask.getPlaneBitMask("DETECTED_NEGATIVE")) 

395 

396 def calculateKernelSize(self, sigma): 

397 """Calculate the size of the smoothing kernel. 

398 

399 Uses the ``nSigmaForKernel`` configuration parameter. Note 

400 that that is the full width of the kernel bounding box 

401 (so a value of 7 means 3.5 sigma on either side of center). 

402 The value will be rounded up to the nearest odd integer. 

403 

404 Parameters 

405 ---------- 

406 sigma : `float` 

407 Gaussian sigma of smoothing kernel. 

408 

409 Returns 

410 ------- 

411 size : `int` 

412 Size of the smoothing kernel. 

413 """ 

414 return (int(sigma * self.config.nSigmaForKernel + 0.5)//2)*2 + 1 # make sure it is odd 

415 

416 def getPsf(self, exposure, sigma=None): 

417 """Create a single Gaussian PSF for an exposure. 

418 

419 If ``sigma`` is provided, we make a `~lsst.afw.detection.GaussianPsf` 

420 with that, otherwise use the sigma from the psf of the ``exposure`` to 

421 make the `~lsst.afw.detection.GaussianPsf`. 

422 

423 Parameters 

424 ---------- 

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

426 Exposure from which to retrieve the PSF. 

427 sigma : `float`, optional 

428 Gaussian sigma to use if provided. 

429 

430 Returns 

431 ------- 

432 psf : `lsst.afw.detection.GaussianPsf` 

433 PSF to use for detection. 

434 

435 Raises 

436 ------ 

437 RuntimeError 

438 Raised if ``sigma`` is not provided and ``exposure`` does not 

439 contain a ``Psf`` object. 

440 """ 

441 if sigma is None: 

442 psf = exposure.getPsf() 

443 if psf is None: 

444 raise RuntimeError("Unable to determine PSF to use for detection: no sigma provided") 

445 sigma = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius() 

446 size = self.calculateKernelSize(sigma) 

447 psf = afwDet.GaussianPsf(size, size, sigma) 

448 return psf 

449 

450 def convolveImage(self, maskedImage, psf, doSmooth=True): 

451 """Convolve the image with the PSF. 

452 

453 We convolve the image with a Gaussian approximation to the PSF, 

454 because this is separable and therefore fast. It's technically a 

455 correlation rather than a convolution, but since we use a symmetric 

456 Gaussian there's no difference. 

457 

458 The convolution can be disabled with ``doSmooth=False``. If we do 

459 convolve, we mask the edges as ``EDGE`` and return the convolved image 

460 with the edges removed. This is because we can't convolve the edges 

461 because the kernel would extend off the image. 

462 

463 Parameters 

464 ---------- 

465 maskedImage : `lsst.afw.image.MaskedImage` 

466 Image to convolve. 

467 psf : `lsst.afw.detection.Psf` 

468 PSF to convolve with (actually with a Gaussian approximation 

469 to it). 

470 doSmooth : `bool` 

471 Actually do the convolution? Set to False when running on 

472 e.g. a pre-convolved image, or a mask plane. 

473 

474 Returns 

475 ------- 

476 results : `lsst.pipe.base.Struct` 

477 The `~lsst.pipe.base.Struct` contains: 

478 

479 ``middle`` 

480 Convolved image, without the edges. (`lsst.afw.image.MaskedImage`) 

481 ``sigma`` 

482 Gaussian sigma used for the convolution. (`float`) 

483 """ 

484 self.metadata["doSmooth"] = doSmooth 

485 sigma = psf.computeShape(psf.getAveragePosition()).getDeterminantRadius() 

486 self.metadata["sigma"] = sigma 

487 

488 if not doSmooth: 

489 middle = maskedImage.Factory(maskedImage, deep=True) 

490 return pipeBase.Struct(middle=middle, sigma=sigma) 

491 

492 # Smooth using a Gaussian (which is separable, hence fast) of width sigma 

493 # Make a SingleGaussian (separable) kernel with the 'sigma' 

494 kWidth = self.calculateKernelSize(sigma) 

495 self.metadata["smoothingKernelWidth"] = kWidth 

496 gaussFunc = afwMath.GaussianFunction1D(sigma) 

497 gaussKernel = afwMath.SeparableKernel(kWidth, kWidth, gaussFunc, gaussFunc) 

498 

499 convolvedImage = maskedImage.Factory(maskedImage.getBBox()) 

500 

501 afwMath.convolve(convolvedImage, maskedImage, gaussKernel, afwMath.ConvolutionControl()) 

502 

503 # Only search psf-smoothed part of frame 

504 goodBBox = gaussKernel.shrinkBBox(convolvedImage.getBBox()) 

505 middle = convolvedImage.Factory(convolvedImage, goodBBox, afwImage.PARENT, False) 

506 

507 # Mark the parts of the image outside goodBBox as EDGE 

508 self.setEdgeBits(maskedImage, goodBBox, maskedImage.getMask().getPlaneBitMask("EDGE")) 

509 

510 return pipeBase.Struct(middle=middle, sigma=sigma) 

511 

512 def applyThreshold(self, middle, bbox, factor=1.0): 

513 r"""Apply thresholds to the convolved image 

514 

515 Identifies `~lsst.afw.detection.Footprint`\s, both positive and negative. 

516 The threshold can be modified by the provided multiplication 

517 ``factor``. 

518 

519 Parameters 

520 ---------- 

521 middle : `lsst.afw.image.MaskedImage` 

522 Convolved image to threshold. 

523 bbox : `lsst.geom.Box2I` 

524 Bounding box of unconvolved image. 

525 factor : `float` 

526 Multiplier for the configured threshold. 

527 

528 Returns 

529 ------- 

530 results : `lsst.pipe.base.Struct` 

531 The `~lsst.pipe.base.Struct` contains: 

532 

533 ``positive`` 

534 Positive detection footprints, if configured. 

535 (`lsst.afw.detection.FootprintSet` or `None`) 

536 ``negative`` 

537 Negative detection footprints, if configured. 

538 (`lsst.afw.detection.FootprintSet` or `None`) 

539 ``factor`` 

540 Multiplier for the configured threshold. 

541 (`float`) 

542 """ 

543 results = pipeBase.Struct(positive=None, negative=None, factor=factor, 

544 positiveThreshold=None, negativeThreshold=None) 

545 # Detect the Footprints (peaks may be replaced if doTempLocalBackground) 

546 if self.config.reEstimateBackground or self.config.thresholdPolarity != "negative": 

547 results.positiveThreshold = self.makeThreshold(middle, "positive", factor=factor) 

548 results.positive = afwDet.FootprintSet( 

549 middle, 

550 results.positiveThreshold, 

551 "DETECTED", 

552 self.config.minPixels 

553 ) 

554 results.positive.setRegion(bbox) 

555 if self.config.reEstimateBackground or self.config.thresholdPolarity != "positive": 

556 results.negativeThreshold = self.makeThreshold(middle, "negative", factor=factor) 

557 results.negative = afwDet.FootprintSet( 

558 middle, 

559 results.negativeThreshold, 

560 "DETECTED_NEGATIVE", 

561 self.config.minPixels 

562 ) 

563 results.negative.setRegion(bbox) 

564 

565 return results 

566 

567 def finalizeFootprints(self, mask, results, sigma, factor=1.0): 

568 """Finalize the detected footprints. 

569 

570 Grow the footprints, set the ``DETECTED`` and ``DETECTED_NEGATIVE`` 

571 mask planes, and log the results. 

572 

573 ``numPos`` (number of positive footprints), ``numPosPeaks`` (number 

574 of positive peaks), ``numNeg`` (number of negative footprints), 

575 ``numNegPeaks`` (number of negative peaks) entries are added to the 

576 ``results`` struct. 

577 

578 Parameters 

579 ---------- 

580 mask : `lsst.afw.image.Mask` 

581 Mask image on which to flag detected pixels. 

582 results : `lsst.pipe.base.Struct` 

583 Struct of detection results, including ``positive`` and 

584 ``negative`` entries; modified. 

585 sigma : `float` 

586 Gaussian sigma of PSF. 

587 factor : `float` 

588 Multiplier for the configured threshold. 

589 """ 

590 for polarity, maskName in (("positive", "DETECTED"), ("negative", "DETECTED_NEGATIVE")): 

591 fpSet = getattr(results, polarity) 

592 if fpSet is None: 

593 continue 

594 if self.config.nSigmaToGrow > 0: 

595 nGrow = int((self.config.nSigmaToGrow * sigma) + 0.5) 

596 self.metadata["nGrow"] = nGrow 

597 if self.config.combinedGrow: 

598 fpSet = afwDet.FootprintSet(fpSet, nGrow, self.config.isotropicGrow) 

599 else: 

600 stencil = (afwGeom.Stencil.CIRCLE if self.config.isotropicGrow else 

601 afwGeom.Stencil.MANHATTAN) 

602 for fp in fpSet: 

603 fp.dilate(nGrow, stencil) 

604 fpSet.setMask(mask, maskName) 

605 if not self.config.returnOriginalFootprints: 

606 setattr(results, polarity, fpSet) 

607 

608 results.numPos = 0 

609 results.numPosPeaks = 0 

610 results.numNeg = 0 

611 results.numNegPeaks = 0 

612 positive = "" 

613 negative = "" 

614 

615 if results.positive is not None: 

616 results.numPos = len(results.positive.getFootprints()) 

617 results.numPosPeaks = sum(len(fp.getPeaks()) for fp in results.positive.getFootprints()) 

618 positive = " %d positive peaks in %d footprints" % (results.numPosPeaks, results.numPos) 

619 if results.negative is not None: 

620 results.numNeg = len(results.negative.getFootprints()) 

621 results.numNegPeaks = sum(len(fp.getPeaks()) for fp in results.negative.getFootprints()) 

622 negative = " %d negative peaks in %d footprints" % (results.numNegPeaks, results.numNeg) 

623 

624 self.log.info("Detected%s%s%s to %g %s", 

625 positive, " and" if positive and negative else "", negative, 

626 self.config.thresholdValue*self.config.includeThresholdMultiplier*factor, 

627 "DN" if self.config.thresholdType == "value" else "sigma") 

628 

629 def reEstimateBackground(self, maskedImage, backgrounds): 

630 """Estimate the background after detection 

631 

632 Parameters 

633 ---------- 

634 maskedImage : `lsst.afw.image.MaskedImage` 

635 Image on which to estimate the background. 

636 backgrounds : `lsst.afw.math.BackgroundList` 

637 List of backgrounds; modified. 

638 

639 Returns 

640 ------- 

641 bg : `lsst.afw.math.backgroundMI` 

642 Empirical background model. 

643 """ 

644 bg = self.background.fitBackground(maskedImage) 

645 if self.config.adjustBackground: 

646 self.log.warning("Fiddling the background by %g", self.config.adjustBackground) 

647 bg += self.config.adjustBackground 

648 self.log.info("Resubtracting the background after object detection") 

649 maskedImage -= bg.getImageF(self.background.config.algorithm, 

650 self.background.config.undersampleStyle) 

651 

652 actrl = bg.getBackgroundControl().getApproximateControl() 

653 backgrounds.append((bg, getattr(afwMath.Interpolate, self.background.config.algorithm), 

654 bg.getAsUsedUndersampleStyle(), actrl.getStyle(), actrl.getOrderX(), 

655 actrl.getOrderY(), actrl.getWeighting())) 

656 return bg 

657 

658 def clearUnwantedResults(self, mask, results): 

659 """Clear unwanted results from the Struct of results 

660 

661 If we specifically want only positive or only negative detections, 

662 drop the ones we don't want, and its associated mask plane. 

663 

664 Parameters 

665 ---------- 

666 mask : `lsst.afw.image.Mask` 

667 Mask image. 

668 results : `lsst.pipe.base.Struct` 

669 Detection results, with ``positive`` and ``negative`` elements; 

670 modified. 

671 """ 

672 if self.config.thresholdPolarity == "positive": 

673 if self.config.reEstimateBackground: 

674 mask &= ~mask.getPlaneBitMask("DETECTED_NEGATIVE") 

675 results.negative = None 

676 elif self.config.thresholdPolarity == "negative": 

677 if self.config.reEstimateBackground: 

678 mask &= ~mask.getPlaneBitMask("DETECTED") 

679 results.positive = None 

680 

681 @timeMethod 

682 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None): 

683 """Detect footprints on an exposure. 

684 

685 Parameters 

686 ---------- 

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

688 Exposure to process; DETECTED{,_NEGATIVE} mask plane will be 

689 set in-place. 

690 doSmooth : `bool`, optional 

691 If True, smooth the image before detection using a Gaussian 

692 of width ``sigma``, or the measured PSF width of ``exposure``. 

693 Set to False when running on e.g. a pre-convolved image, or a mask 

694 plane. 

695 sigma : `float`, optional 

696 Gaussian Sigma of PSF (pixels); used for smoothing and to grow 

697 detections; if `None` then measure the sigma of the PSF of the 

698 ``exposure``. 

699 clearMask : `bool`, optional 

700 Clear both DETECTED and DETECTED_NEGATIVE planes before running 

701 detection. 

702 expId : `dict`, optional 

703 Exposure identifier; unused by this implementation, but used for 

704 RNG seed by subclasses. 

705 

706 Returns 

707 ------- 

708 results : `lsst.pipe.base.Struct` 

709 A `~lsst.pipe.base.Struct` containing: 

710 

711 ``positive`` 

712 Positive polarity footprints. 

713 (`lsst.afw.detection.FootprintSet` or `None`) 

714 ``negative`` 

715 Negative polarity footprints. 

716 (`lsst.afw.detection.FootprintSet` or `None`) 

717 ``numPos`` 

718 Number of footprints in positive or 0 if detection polarity was 

719 negative. (`int`) 

720 ``numNeg`` 

721 Number of footprints in negative or 0 if detection polarity was 

722 positive. (`int`) 

723 ``background`` 

724 Re-estimated background. `None` if 

725 ``reEstimateBackground==False``. 

726 (`lsst.afw.math.BackgroundList`) 

727 ``factor`` 

728 Multiplication factor applied to the configured detection 

729 threshold. (`float`) 

730 """ 

731 maskedImage = exposure.maskedImage 

732 

733 if clearMask: 

734 self.clearMask(maskedImage.getMask()) 

735 

736 psf = self.getPsf(exposure, sigma=sigma) 

737 with self.tempWideBackgroundContext(exposure): 

738 convolveResults = self.convolveImage(maskedImage, psf, doSmooth=doSmooth) 

739 middle = convolveResults.middle 

740 sigma = convolveResults.sigma 

741 

742 results = self.applyThreshold(middle, maskedImage.getBBox()) 

743 results.background = afwMath.BackgroundList() 

744 if self.config.doTempLocalBackground: 

745 self.applyTempLocalBackground(exposure, middle, results) 

746 self.finalizeFootprints(maskedImage.mask, results, sigma) 

747 

748 # Compute the significance of peaks after the peaks have been 

749 # finalized and after local background correction/updatePeaks, so 

750 # that the significance represents the "final" detection S/N. 

751 results.positive = self.setPeakSignificance(middle, results.positive, results.positiveThreshold) 

752 results.negative = self.setPeakSignificance(middle, results.negative, results.negativeThreshold, 

753 negative=True) 

754 

755 if self.config.reEstimateBackground: 

756 self.reEstimateBackground(maskedImage, results.background) 

757 

758 self.clearUnwantedResults(maskedImage.getMask(), results) 

759 

760 self.display(exposure, results, middle) 

761 

762 return results 

763 

764 def setPeakSignificance(self, exposure, footprints, threshold, negative=False): 

765 """Set the significance of each detected peak to the pixel value divided 

766 by the appropriate standard-deviation for ``config.thresholdType``. 

767 

768 Only sets significance for "stdev" and "pixel_stdev" thresholdTypes; 

769 we leave it undefined for "value" and "variance" as it does not have a 

770 well-defined meaning in those cases. 

771 

772 Parameters 

773 ---------- 

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

775 Exposure that footprints were detected on, likely the convolved, 

776 local background-subtracted image. 

777 footprints : `lsst.afw.detection.FootprintSet` 

778 Footprints detected on the image. 

779 threshold : `lsst.afw.detection.Threshold` 

780 Threshold used to find footprints. 

781 negative : `bool`, optional 

782 Are we calculating for negative sources? 

783 """ 

784 if footprints is None or footprints.getFootprints() == []: 

785 return footprints 

786 polarity = -1 if negative else 1 

787 

788 # All incoming footprints have the same schema. 

789 mapper = afwTable.SchemaMapper(footprints.getFootprints()[0].peaks.schema) 

790 mapper.addMinimalSchema(footprints.getFootprints()[0].peaks.schema) 

791 mapper.addOutputField("significance", type=float, 

792 doc="Ratio of peak value to configured standard deviation.") 

793 

794 # Copy the old peaks to the new ones with a significance field. 

795 # Do this independent of the threshold type, so we always have a 

796 # significance field. 

797 newFootprints = afwDet.FootprintSet(footprints) 

798 for old, new in zip(footprints.getFootprints(), newFootprints.getFootprints()): 

799 newPeaks = afwDet.PeakCatalog(mapper.getOutputSchema()) 

800 newPeaks.extend(old.peaks, mapper=mapper) 

801 new.getPeaks().clear() 

802 new.setPeakCatalog(newPeaks) 

803 

804 # Compute the significance values. 

805 if self.config.thresholdType == "pixel_stdev": 

806 for footprint in newFootprints.getFootprints(): 

807 footprint.updatePeakSignificance(exposure.variance, polarity) 

808 elif self.config.thresholdType == "stdev": 

809 sigma = threshold.getValue() / self.config.thresholdValue 

810 for footprint in newFootprints.getFootprints(): 

811 footprint.updatePeakSignificance(polarity*sigma) 

812 else: 

813 for footprint in newFootprints.getFootprints(): 

814 for peak in footprint.peaks: 

815 peak["significance"] = 0 

816 

817 return newFootprints 

818 

819 def makeThreshold(self, image, thresholdParity, factor=1.0): 

820 """Make an afw.detection.Threshold object corresponding to the task's 

821 configuration and the statistics of the given image. 

822 

823 Parameters 

824 ---------- 

825 image : `afw.image.MaskedImage` 

826 Image to measure noise statistics from if needed. 

827 thresholdParity: `str` 

828 One of "positive" or "negative", to set the kind of fluctuations 

829 the Threshold will detect. 

830 factor : `float` 

831 Factor by which to multiply the configured detection threshold. 

832 This is useful for tweaking the detection threshold slightly. 

833 

834 Returns 

835 ------- 

836 threshold : `lsst.afw.detection.Threshold` 

837 Detection threshold. 

838 """ 

839 parity = False if thresholdParity == "negative" else True 

840 thresholdValue = self.config.thresholdValue 

841 thresholdType = self.config.thresholdType 

842 if self.config.thresholdType == 'stdev': 

843 bad = image.getMask().getPlaneBitMask(self.config.statsMask) 

844 sctrl = afwMath.StatisticsControl() 

845 sctrl.setAndMask(bad) 

846 stats = afwMath.makeStatistics(image, afwMath.STDEVCLIP, sctrl) 

847 thresholdValue *= stats.getValue(afwMath.STDEVCLIP) 

848 thresholdType = 'value' 

849 

850 threshold = afwDet.createThreshold(thresholdValue*factor, thresholdType, parity) 

851 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier) 

852 self.log.debug("Detection threshold: %s", threshold) 

853 return threshold 

854 

855 def updatePeaks(self, fpSet, image, threshold): 

856 """Update the Peaks in a FootprintSet by detecting new Footprints and 

857 Peaks in an image and using the new Peaks instead of the old ones. 

858 

859 Parameters 

860 ---------- 

861 fpSet : `afw.detection.FootprintSet` 

862 Set of Footprints whose Peaks should be updated. 

863 image : `afw.image.MaskedImage` 

864 Image to detect new Footprints and Peak in. 

865 threshold : `afw.detection.Threshold` 

866 Threshold object for detection. 

867 

868 Input Footprints with fewer Peaks than self.config.nPeaksMaxSimple 

869 are not modified, and if no new Peaks are detected in an input 

870 Footprint, the brightest original Peak in that Footprint is kept. 

871 """ 

872 for footprint in fpSet.getFootprints(): 

873 oldPeaks = footprint.getPeaks() 

874 if len(oldPeaks) <= self.config.nPeaksMaxSimple: 

875 continue 

876 # We detect a new FootprintSet within each non-simple Footprint's 

877 # bbox to avoid a big O(N^2) comparison between the two sets of 

878 # Footprints. 

879 sub = image.Factory(image, footprint.getBBox()) 

880 fpSetForPeaks = afwDet.FootprintSet( 

881 sub, 

882 threshold, 

883 "", # don't set a mask plane 

884 self.config.minPixels 

885 ) 

886 newPeaks = afwDet.PeakCatalog(oldPeaks.getTable()) 

887 for fpForPeaks in fpSetForPeaks.getFootprints(): 

888 for peak in fpForPeaks.getPeaks(): 

889 if footprint.contains(peak.getI()): 

890 newPeaks.append(peak) 

891 if len(newPeaks) > 0: 

892 del oldPeaks[:] 

893 oldPeaks.extend(newPeaks) 

894 else: 

895 del oldPeaks[1:] 

896 

897 @staticmethod 

898 def setEdgeBits(maskedImage, goodBBox, edgeBitmask): 

899 """Set the edgeBitmask bits for all of maskedImage outside goodBBox 

900 

901 Parameters 

902 ---------- 

903 maskedImage : `lsst.afw.image.MaskedImage` 

904 Image on which to set edge bits in the mask. 

905 goodBBox : `lsst.geom.Box2I` 

906 Bounding box of good pixels, in ``LOCAL`` coordinates. 

907 edgeBitmask : `lsst.afw.image.MaskPixel` 

908 Bit mask to OR with the existing mask bits in the region 

909 outside ``goodBBox``. 

910 """ 

911 msk = maskedImage.getMask() 

912 

913 mx0, my0 = maskedImage.getXY0() 

914 for x0, y0, w, h in ([0, 0, 

915 msk.getWidth(), goodBBox.getBeginY() - my0], 

916 [0, goodBBox.getEndY() - my0, msk.getWidth(), 

917 maskedImage.getHeight() - (goodBBox.getEndY() - my0)], 

918 [0, 0, 

919 goodBBox.getBeginX() - mx0, msk.getHeight()], 

920 [goodBBox.getEndX() - mx0, 0, 

921 maskedImage.getWidth() - (goodBBox.getEndX() - mx0), msk.getHeight()], 

922 ): 

923 edgeMask = msk.Factory(msk, lsst.geom.BoxI(lsst.geom.PointI(x0, y0), 

924 lsst.geom.ExtentI(w, h)), afwImage.LOCAL) 

925 edgeMask |= edgeBitmask 

926 

927 @contextmanager 

928 def tempWideBackgroundContext(self, exposure): 

929 """Context manager for removing wide (large-scale) background 

930 

931 Removing a wide (large-scale) background helps to suppress the 

932 detection of large footprints that may overwhelm the deblender. 

933 It does, however, set a limit on the maximum scale of objects. 

934 

935 The background that we remove will be restored upon exit from 

936 the context manager. 

937 

938 Parameters 

939 ---------- 

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

941 Exposure on which to remove large-scale background. 

942 

943 Returns 

944 ------- 

945 context : context manager 

946 Context manager that will ensure the temporary wide background 

947 is restored. 

948 """ 

949 doTempWideBackground = self.config.doTempWideBackground 

950 if doTempWideBackground: 

951 self.log.info("Applying temporary wide background subtraction") 

952 original = exposure.maskedImage.image.array[:].copy() 

953 self.tempWideBackground.run(exposure).background 

954 # Remove NO_DATA regions (e.g., edge of the field-of-view); these can cause detections after 

955 # subtraction because of extrapolation of the background model into areas with no constraints. 

956 image = exposure.maskedImage.image 

957 mask = exposure.maskedImage.mask 

958 noData = mask.array & mask.getPlaneBitMask("NO_DATA") > 0 

959 isGood = mask.array & mask.getPlaneBitMask(self.config.statsMask) == 0 

960 image.array[noData] = np.median(image.array[~noData & isGood]) 

961 try: 

962 yield 

963 finally: 

964 if doTempWideBackground: 

965 exposure.maskedImage.image.array[:] = original 

966 

967 

968def addExposures(exposureList): 

969 """Add a set of exposures together. 

970 

971 Parameters 

972 ---------- 

973 exposureList : `list` of `lsst.afw.image.Exposure` 

974 Sequence of exposures to add. 

975 

976 Returns 

977 ------- 

978 addedExposure : `lsst.afw.image.Exposure` 

979 An exposure of the same size as each exposure in ``exposureList``, 

980 with the metadata from ``exposureList[0]`` and a masked image equal 

981 to the sum of all the exposure's masked images. 

982 """ 

983 exposure0 = exposureList[0] 

984 image0 = exposure0.getMaskedImage() 

985 

986 addedImage = image0.Factory(image0, True) 

987 addedImage.setXY0(image0.getXY0()) 

988 

989 for exposure in exposureList[1:]: 

990 image = exposure.getMaskedImage() 

991 addedImage += image 

992 

993 addedExposure = exposure0.Factory(addedImage, exposure0.getWcs()) 

994 return addedExposure