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

325 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-17 11:19 +0000

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="Grow pixels as isotropically as possible? If False, use a Manhattan metric instead.", 

52 dtype=bool, default=True, 

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 detecting 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="Multiplier on thresholdValue for whether a source is included in the output catalog." 

72 " For example, thresholdValue=5, includeThresholdMultiplier=10, thresholdType='pixel_stdev' " 

73 "results in a catalog of sources at >50 sigma with the detection mask and footprints " 

74 "including pixels >5 sigma.", 

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

76 ) 

77 thresholdType = pexConfig.ChoiceField( 

78 doc="Specifies the meaning of thresholdValue.", 

79 dtype=str, optional=False, default="pixel_stdev", 

80 allowed={ 

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

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

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

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

85 }, 

86 ) 

87 thresholdPolarity = pexConfig.ChoiceField( 

88 doc="Specifies whether to detect positive, or negative sources, or both.", 

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

90 allowed={ 

91 "positive": "detect only positive sources", 

92 "negative": "detect only negative sources", 

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

94 }, 

95 ) 

96 adjustBackground = pexConfig.Field( 

97 dtype=float, 

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

99 default=0.0, 

100 ) 

101 reEstimateBackground = pexConfig.Field( 

102 dtype=bool, 

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

104 default=True, optional=False, 

105 ) 

106 background = pexConfig.ConfigurableField( 

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

108 target=SubtractBackgroundTask, 

109 ) 

110 tempLocalBackground = pexConfig.ConfigurableField( 

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

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

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

114 target=SubtractBackgroundTask, 

115 ) 

116 doTempLocalBackground = pexConfig.Field( 

117 dtype=bool, 

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

119 default=True, 

120 ) 

121 tempWideBackground = pexConfig.ConfigurableField( 

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

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

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

125 target=SubtractBackgroundTask, 

126 ) 

127 doTempWideBackground = pexConfig.Field( 

128 dtype=bool, 

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

130 default=False, 

131 ) 

132 nPeaksMaxSimple = pexConfig.Field( 

133 dtype=int, 

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

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

136 default=1, 

137 ) 

138 nSigmaForKernel = pexConfig.Field( 

139 dtype=float, 

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

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

142 default=7.0, 

143 ) 

144 statsMask = pexConfig.ListField( 

145 dtype=str, 

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

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

148 ) 

149 excludeMaskPlanes = lsst.pex.config.ListField( 

150 dtype=str, 

151 default=[], 

152 doc="Mask planes to exclude when detecting sources." 

153 ) 

154 

155 def setDefaults(self): 

156 self.tempLocalBackground.binSize = 64 

157 self.tempLocalBackground.algorithm = "AKIMA_SPLINE" 

158 self.tempLocalBackground.useApprox = False 

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

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

161 self.tempWideBackground.binSize = 512 

162 self.tempWideBackground.algorithm = "AKIMA_SPLINE" 

163 self.tempWideBackground.useApprox = False 

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

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

166 if maskPlane in self.tempWideBackground.ignoredPixelMask: 

167 self.tempWideBackground.ignoredPixelMask.remove(maskPlane) 

168 

169 

170class SourceDetectionTask(pipeBase.Task): 

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

172 

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

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

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

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

177 detected sources. 

178 

179 Parameters 

180 ---------- 

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

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

183 **kwds 

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

185 

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

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

188 negative threshold. 

189 

190 Notes 

191 ----- 

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

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

194 """ 

195 ConfigClass = SourceDetectionConfig 

196 _DefaultName = "sourceDetection" 

197 

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

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

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

201 self.negativeFlagKey = schema.addField( 

202 "flags_negative", type="Flag", 

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

204 ) 

205 else: 

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

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

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

209 self.negativeFlagKey = None 

210 if self.config.reEstimateBackground: 

211 self.makeSubtask("background") 

212 if self.config.doTempLocalBackground: 

213 self.makeSubtask("tempLocalBackground") 

214 if self.config.doTempWideBackground: 

215 self.makeSubtask("tempWideBackground") 

216 

217 @timeMethod 

218 def run(self, table, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None, 

219 background=None): 

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

221 

222 Parameters 

223 ---------- 

224 table : `lsst.afw.table.SourceTable` 

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

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

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

228 doSmooth : `bool`, optional 

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

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

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

232 sigma : `float`, optional 

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

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

235 clearMask : `bool`, optional 

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

237 expId : `int`, optional 

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

239 RNG seed by subclasses. 

240 background : `lsst.afw.math.BackgroundList`, optional 

241 Background that was already subtracted from the exposure; will be 

242 modified in-place if ``reEstimateBackground=True``. 

243 

244 Returns 

245 ------- 

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

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

248 

249 ``sources`` 

250 Detected sources on the exposure. 

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

252 ``positive`` 

253 Positive polarity footprints. 

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

255 ``negative`` 

256 Negative polarity footprints. 

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

258 ``numPos`` 

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

260 negative. (`int`) 

261 ``numNeg`` 

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

263 positive. (`int`) 

264 ``background`` 

265 Re-estimated background. `None` if 

266 ``reEstimateBackground==False``. 

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

268 ``factor`` 

269 Multiplication factor applied to the configured detection 

270 threshold. (`float`) 

271 

272 Raises 

273 ------ 

274 ValueError 

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

276 lsst.pipe.base.TaskError 

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

278 

279 Notes 

280 ----- 

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

282 `detectFootprints()` to just get the 

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

284 """ 

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

286 raise ValueError("Table has incorrect Schema") 

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

288 clearMask=clearMask, expId=expId, background=background) 

289 sources = afwTable.SourceCatalog(table) 

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

291 if results.negative: 

292 results.negative.makeSources(sources) 

293 if self.negativeFlagKey: 

294 for record in sources: 

295 record.set(self.negativeFlagKey, True) 

296 if results.positive: 

297 results.positive.makeSources(sources) 

298 results.sources = sources 

299 return results 

300 

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

302 """Display detections if so configured 

303 

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

305 

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

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

308 

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

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

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

312 

313 Parameters 

314 ---------- 

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

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

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

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

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

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

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

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

323 Convolved image used for thresholding. 

324 """ 

325 try: 

326 import lsstDebug 

327 display = lsstDebug.Info(__name__).display 

328 except ImportError: 

329 try: 

330 display 

331 except NameError: 

332 display = False 

333 if not display: 

334 return 

335 

336 afwDisplay.setDefaultMaskTransparency(75) 

337 

338 disp0 = afwDisplay.Display(frame=0) 

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

340 

341 def plotPeaks(fps, ctype): 

342 if fps is None: 

343 return 

344 with disp0.Buffering(): 

345 for fp in fps.getFootprints(): 

346 for pp in fp.getPeaks(): 

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

348 plotPeaks(results.positive, "yellow") 

349 plotPeaks(results.negative, "red") 

350 

351 if convolvedImage and display > 1: 

352 disp1 = afwDisplay.Display(frame=1) 

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

354 

355 disp2 = afwDisplay.Display(frame=2) 

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

357 

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

359 """Apply a temporary local background subtraction 

360 

361 This temporary local background serves to suppress noise fluctuations 

362 in the wings of bright objects. 

363 

364 Peaks in the footprints will be updated. 

365 

366 Parameters 

367 ---------- 

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

369 Exposure for which to fit local background. 

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

371 Convolved image on which detection will be performed 

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

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

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

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

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

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

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

379 """ 

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

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

382 # it back in. 

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

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

385 self.tempLocalBackground.config.undersampleStyle) 

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

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

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

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

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

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

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

393 

394 def clearMask(self, mask): 

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

396 

397 Removes any previous detection mask in preparation for a new 

398 detection pass. 

399 

400 Parameters 

401 ---------- 

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

403 Mask to be cleared. 

404 """ 

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

406 

407 def calculateKernelSize(self, sigma): 

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

409 

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

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

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

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

414 

415 Parameters 

416 ---------- 

417 sigma : `float` 

418 Gaussian sigma of smoothing kernel. 

419 

420 Returns 

421 ------- 

422 size : `int` 

423 Size of the smoothing kernel. 

424 """ 

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

426 

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

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

429 

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

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

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

433 

434 Parameters 

435 ---------- 

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

437 Exposure from which to retrieve the PSF. 

438 sigma : `float`, optional 

439 Gaussian sigma to use if provided. 

440 

441 Returns 

442 ------- 

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

444 PSF to use for detection. 

445 

446 Raises 

447 ------ 

448 RuntimeError 

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

450 contain a ``Psf`` object. 

451 """ 

452 if sigma is None: 

453 psf = exposure.getPsf() 

454 if psf is None: 

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

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

457 size = self.calculateKernelSize(sigma) 

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

459 return psf 

460 

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

462 """Convolve the image with the PSF. 

463 

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

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

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

467 Gaussian there's no difference. 

468 

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

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

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

472 because the kernel would extend off the image. 

473 

474 Parameters 

475 ---------- 

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

477 Image to convolve. 

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

479 PSF to convolve with (actually with a Gaussian approximation 

480 to it). 

481 doSmooth : `bool` 

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

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

484 

485 Returns 

486 ------- 

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

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

489 

490 ``middle`` 

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

492 ``sigma`` 

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

494 """ 

495 self.metadata["doSmooth"] = doSmooth 

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

497 self.metadata["sigma"] = sigma 

498 

499 if not doSmooth: 

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

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

502 

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

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

505 kWidth = self.calculateKernelSize(sigma) 

506 self.metadata["smoothingKernelWidth"] = kWidth 

507 gaussFunc = afwMath.GaussianFunction1D(sigma) 

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

509 

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

511 

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

513 

514 # Only search psf-smoothed part of frame 

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

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

517 

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

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

520 

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

522 

523 def applyThreshold(self, middle, bbox, factor=1.0, factorNeg=None): 

524 r"""Apply thresholds to the convolved image 

525 

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

527 The threshold can be modified by the provided multiplication 

528 ``factor``. 

529 

530 Parameters 

531 ---------- 

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

533 Convolved image to threshold. 

534 bbox : `lsst.geom.Box2I` 

535 Bounding box of unconvolved image. 

536 factor : `float` 

537 Multiplier for the configured threshold. 

538 factorNeg : `float` or `None` 

539 Multiplier for the configured threshold for negative detection polarity. 

540 If `None`, will be set equal to ``factor`` (i.e. equal to the factor used 

541 for positive detection polarity). 

542 

543 Returns 

544 ------- 

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

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

547 

548 ``positive`` 

549 Positive detection footprints, if configured. 

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

551 ``negative`` 

552 Negative detection footprints, if configured. 

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

554 ``factor`` 

555 Multiplier for the configured threshold. 

556 (`float`) 

557 ``factorNeg`` 

558 Multiplier for the configured threshold for negative detection polarity. 

559 (`float`) 

560 """ 

561 if factorNeg is None: 

562 factorNeg = factor 

563 self.log.info("Setting factor for negative detections equal to that for positive " 

564 "detections: %f", factor) 

565 results = pipeBase.Struct(positive=None, negative=None, factor=factor, factorNeg=factorNeg, 

566 positiveThreshold=None, negativeThreshold=None) 

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

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

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

570 results.positive = afwDet.FootprintSet( 

571 middle, 

572 results.positiveThreshold, 

573 "DETECTED", 

574 self.config.minPixels 

575 ) 

576 results.positive.setRegion(bbox) 

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

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

579 results.negative = afwDet.FootprintSet( 

580 middle, 

581 results.negativeThreshold, 

582 "DETECTED_NEGATIVE", 

583 self.config.minPixels 

584 ) 

585 results.negative.setRegion(bbox) 

586 

587 return results 

588 

589 def finalizeFootprints(self, mask, results, sigma, factor=1.0, factorNeg=None): 

590 """Finalize the detected footprints. 

591 

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

593 mask planes, and log the results. 

594 

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

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

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

598 ``results`` struct. 

599 

600 Parameters 

601 ---------- 

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

603 Mask image on which to flag detected pixels. 

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

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

606 ``negative`` entries; modified. 

607 sigma : `float` 

608 Gaussian sigma of PSF. 

609 factor : `float` 

610 Multiplier for the configured threshold. Note that this is only 

611 used here for logging purposes. 

612 factorNeg : `float` or `None` 

613 Multiplier used for the negative detection polarity threshold. 

614 If `None`, a factor equal to ``factor`` (i.e. equal to the one used 

615 for positive detection polarity) is assumed. Note that this is only 

616 used here for logging purposes. 

617 """ 

618 factorNeg = factor if factorNeg is None else factorNeg 

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

620 fpSet = getattr(results, polarity) 

621 if fpSet is None: 

622 continue 

623 if self.config.nSigmaToGrow > 0: 

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

625 self.metadata["nGrow"] = nGrow 

626 if self.config.combinedGrow: 

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

628 else: 

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

630 afwGeom.Stencil.MANHATTAN) 

631 for fp in fpSet: 

632 fp.dilate(nGrow, stencil) 

633 fpSet.setMask(mask, maskName) 

634 if not self.config.returnOriginalFootprints: 

635 setattr(results, polarity, fpSet) 

636 

637 results.numPos = 0 

638 results.numPosPeaks = 0 

639 results.numNeg = 0 

640 results.numNegPeaks = 0 

641 positive = "" 

642 negative = "" 

643 

644 if results.positive is not None: 

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

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

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

648 if results.negative is not None: 

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

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

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

652 

653 self.log.info("Detected%s%s%s to %g +ve and %g -ve %s", 

654 positive, " and" if positive and negative else "", negative, 

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

656 self.config.thresholdValue*self.config.includeThresholdMultiplier*factorNeg, 

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

658 

659 def reEstimateBackground(self, maskedImage, backgrounds): 

660 """Estimate the background after detection 

661 

662 Parameters 

663 ---------- 

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

665 Image on which to estimate the background. 

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

667 List of backgrounds; modified. 

668 

669 Returns 

670 ------- 

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

672 Empirical background model. 

673 """ 

674 bg = self.background.fitBackground(maskedImage) 

675 if self.config.adjustBackground: 

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

677 bg += self.config.adjustBackground 

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

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

680 self.background.config.undersampleStyle) 

681 

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

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

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

685 actrl.getOrderY(), actrl.getWeighting())) 

686 return bg 

687 

688 def clearUnwantedResults(self, mask, results): 

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

690 

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

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

693 

694 Parameters 

695 ---------- 

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

697 Mask image. 

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

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

700 modified. 

701 """ 

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

703 if self.config.reEstimateBackground: 

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

705 results.negative = None 

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

707 if self.config.reEstimateBackground: 

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

709 results.positive = None 

710 

711 @timeMethod 

712 def detectFootprints(self, exposure, doSmooth=True, sigma=None, clearMask=True, expId=None, 

713 background=None): 

714 """Detect footprints on an exposure. 

715 

716 Parameters 

717 ---------- 

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

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

720 set in-place. 

721 doSmooth : `bool`, optional 

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

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

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

725 plane. 

726 sigma : `float`, optional 

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

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

729 ``exposure``. 

730 clearMask : `bool`, optional 

731 Clear both DETECTED and DETECTED_NEGATIVE planes before running 

732 detection. 

733 expId : `dict`, optional 

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

735 RNG seed by subclasses. 

736 background : `lsst.afw.math.BackgroundList`, optional 

737 Background that was already subtracted from the exposure; will be 

738 modified in-place if ``reEstimateBackground=True``. 

739 

740 Returns 

741 ------- 

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

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

744 

745 ``positive`` 

746 Positive polarity footprints. 

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

748 ``negative`` 

749 Negative polarity footprints. 

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

751 ``numPos`` 

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

753 negative. (`int`) 

754 ``numNeg`` 

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

756 positive. (`int`) 

757 ``background`` 

758 Re-estimated background. `None` or the input ``background`` 

759 if ``reEstimateBackground==False``. 

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

761 ``factor`` 

762 Multiplication factor applied to the configured detection 

763 threshold. (`float`) 

764 """ 

765 maskedImage = exposure.maskedImage 

766 

767 if clearMask: 

768 self.clearMask(maskedImage.getMask()) 

769 

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

771 with self.tempWideBackgroundContext(exposure): 

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

773 middle = convolveResults.middle 

774 sigma = convolveResults.sigma 

775 self.removeBadPixels(middle) 

776 

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

778 results.background = background if background is not None else afwMath.BackgroundList() 

779 

780 if self.config.doTempLocalBackground: 

781 self.applyTempLocalBackground(exposure, middle, results) 

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

783 

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

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

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

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

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

789 negative=True) 

790 

791 if self.config.reEstimateBackground: 

792 self.reEstimateBackground(maskedImage, results.background) 

793 

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

795 

796 self.display(exposure, results, middle) 

797 

798 return results 

799 

800 def removeBadPixels(self, middle): 

801 """Set the significance of flagged pixels to zero. 

802 

803 Parameters 

804 ---------- 

805 middle : `lsst.afw.image.ExposureF` 

806 Score or maximum likelihood difference image. 

807 The image plane will be modified in place. 

808 """ 

809 badPixelMask = lsst.afw.image.Mask.getPlaneBitMask(self.config.excludeMaskPlanes) 

810 badPixels = middle.mask.array & badPixelMask > 0 

811 middle.image.array[badPixels] = 0 

812 

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

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

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

816 

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

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

819 well-defined meaning in those cases. 

820 

821 Parameters 

822 ---------- 

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

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

825 local background-subtracted image. 

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

827 Footprints detected on the image. 

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

829 Threshold used to find footprints. 

830 negative : `bool`, optional 

831 Are we calculating for negative sources? 

832 """ 

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

834 return footprints 

835 polarity = -1 if negative else 1 

836 

837 # All incoming footprints have the same schema. 

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

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

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

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

842 

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

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

845 # significance field. 

846 newFootprints = afwDet.FootprintSet(footprints) 

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

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

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

850 new.getPeaks().clear() 

851 new.setPeakCatalog(newPeaks) 

852 

853 # Compute the significance values. 

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

855 for footprint in newFootprints.getFootprints(): 

856 footprint.updatePeakSignificance(exposure.variance, polarity) 

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

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

859 for footprint in newFootprints.getFootprints(): 

860 footprint.updatePeakSignificance(polarity*sigma) 

861 else: 

862 for footprint in newFootprints.getFootprints(): 

863 for peak in footprint.peaks: 

864 peak["significance"] = 0 

865 

866 return newFootprints 

867 

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

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

870 configuration and the statistics of the given image. 

871 

872 Parameters 

873 ---------- 

874 image : `afw.image.MaskedImage` 

875 Image to measure noise statistics from if needed. 

876 thresholdParity: `str` 

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

878 the Threshold will detect. 

879 factor : `float` 

880 Factor by which to multiply the configured detection threshold. 

881 This is useful for tweaking the detection threshold slightly. 

882 

883 Returns 

884 ------- 

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

886 Detection threshold. 

887 """ 

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

889 thresholdValue = self.config.thresholdValue 

890 thresholdType = self.config.thresholdType 

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

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

893 sctrl = afwMath.StatisticsControl() 

894 sctrl.setAndMask(bad) 

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

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

897 thresholdType = 'value' 

898 

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

900 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier) 

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

902 return threshold 

903 

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

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

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

907 

908 Parameters 

909 ---------- 

910 fpSet : `afw.detection.FootprintSet` 

911 Set of Footprints whose Peaks should be updated. 

912 image : `afw.image.MaskedImage` 

913 Image to detect new Footprints and Peak in. 

914 threshold : `afw.detection.Threshold` 

915 Threshold object for detection. 

916 

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

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

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

920 """ 

921 for footprint in fpSet.getFootprints(): 

922 oldPeaks = footprint.getPeaks() 

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

924 continue 

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

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

927 # Footprints. 

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

929 fpSetForPeaks = afwDet.FootprintSet( 

930 sub, 

931 threshold, 

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

933 self.config.minPixels 

934 ) 

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

936 for fpForPeaks in fpSetForPeaks.getFootprints(): 

937 for peak in fpForPeaks.getPeaks(): 

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

939 newPeaks.append(peak) 

940 if len(newPeaks) > 0: 

941 del oldPeaks[:] 

942 oldPeaks.extend(newPeaks) 

943 else: 

944 del oldPeaks[1:] 

945 

946 @staticmethod 

947 def setEdgeBits(maskedImage, goodBBox, edgeBitmask): 

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

949 

950 Parameters 

951 ---------- 

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

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

954 goodBBox : `lsst.geom.Box2I` 

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

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

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

958 outside ``goodBBox``. 

959 """ 

960 msk = maskedImage.getMask() 

961 

962 mx0, my0 = maskedImage.getXY0() 

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

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

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

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

967 [0, 0, 

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

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

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

971 ): 

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

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

974 edgeMask |= edgeBitmask 

975 

976 @contextmanager 

977 def tempWideBackgroundContext(self, exposure): 

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

979 

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

981 detection of large footprints that may overwhelm the deblender. 

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

983 

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

985 the context manager. 

986 

987 Parameters 

988 ---------- 

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

990 Exposure on which to remove large-scale background. 

991 

992 Returns 

993 ------- 

994 context : context manager 

995 Context manager that will ensure the temporary wide background 

996 is restored. 

997 """ 

998 doTempWideBackground = self.config.doTempWideBackground 

999 if doTempWideBackground: 

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

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

1002 self.tempWideBackground.run(exposure).background 

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

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

1005 image = exposure.maskedImage.image 

1006 mask = exposure.maskedImage.mask 

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

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

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

1010 try: 

1011 yield 

1012 finally: 

1013 if doTempWideBackground: 

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

1015 

1016 

1017def addExposures(exposureList): 

1018 """Add a set of exposures together. 

1019 

1020 Parameters 

1021 ---------- 

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

1023 Sequence of exposures to add. 

1024 

1025 Returns 

1026 ------- 

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

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

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

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

1031 """ 

1032 exposure0 = exposureList[0] 

1033 image0 = exposure0.getMaskedImage() 

1034 

1035 addedImage = image0.Factory(image0, True) 

1036 addedImage.setXY0(image0.getXY0()) 

1037 

1038 for exposure in exposureList[1:]: 

1039 image = exposure.getMaskedImage() 

1040 addedImage += image 

1041 

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

1043 return addedExposure