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

325 statements  

« prev     ^ index     » next       coverage.py v7.3.0, created at 2023-08-22 09:58 +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="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 excludeMaskPlanes = lsst.pex.config.ListField( 

147 dtype=str, 

148 default=[], 

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

150 ) 

151 

152 def setDefaults(self): 

153 self.tempLocalBackground.binSize = 64 

154 self.tempLocalBackground.algorithm = "AKIMA_SPLINE" 

155 self.tempLocalBackground.useApprox = False 

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

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

158 self.tempWideBackground.binSize = 512 

159 self.tempWideBackground.algorithm = "AKIMA_SPLINE" 

160 self.tempWideBackground.useApprox = False 

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

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

163 if maskPlane in self.tempWideBackground.ignoredPixelMask: 

164 self.tempWideBackground.ignoredPixelMask.remove(maskPlane) 

165 

166 

167class SourceDetectionTask(pipeBase.Task): 

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

169 

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

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

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

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

174 detected sources. 

175 

176 Parameters 

177 ---------- 

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

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

180 **kwds 

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

182 

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

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

185 negative threshold. 

186 

187 Notes 

188 ----- 

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

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

191 """ 

192 ConfigClass = SourceDetectionConfig 

193 _DefaultName = "sourceDetection" 

194 

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

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

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

198 self.negativeFlagKey = schema.addField( 

199 "flags_negative", type="Flag", 

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

201 ) 

202 else: 

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

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

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

206 self.negativeFlagKey = None 

207 if self.config.reEstimateBackground: 

208 self.makeSubtask("background") 

209 if self.config.doTempLocalBackground: 

210 self.makeSubtask("tempLocalBackground") 

211 if self.config.doTempWideBackground: 

212 self.makeSubtask("tempWideBackground") 

213 

214 @timeMethod 

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

216 background=None): 

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

218 

219 Parameters 

220 ---------- 

221 table : `lsst.afw.table.SourceTable` 

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

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

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

225 doSmooth : `bool`, optional 

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

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

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

229 sigma : `float`, optional 

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

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

232 clearMask : `bool`, optional 

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

234 expId : `int`, optional 

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

236 RNG seed by subclasses. 

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

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

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

240 

241 Returns 

242 ------- 

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

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

245 

246 ``sources`` 

247 Detected sources on the exposure. 

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

249 ``positive`` 

250 Positive polarity footprints. 

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

252 ``negative`` 

253 Negative polarity footprints. 

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

255 ``numPos`` 

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

257 negative. (`int`) 

258 ``numNeg`` 

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

260 positive. (`int`) 

261 ``background`` 

262 Re-estimated background. `None` if 

263 ``reEstimateBackground==False``. 

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

265 ``factor`` 

266 Multiplication factor applied to the configured detection 

267 threshold. (`float`) 

268 

269 Raises 

270 ------ 

271 ValueError 

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

273 lsst.pipe.base.TaskError 

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

275 

276 Notes 

277 ----- 

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

279 `detectFootprints()` to just get the 

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

281 """ 

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

283 raise ValueError("Table has incorrect Schema") 

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

285 clearMask=clearMask, expId=expId, background=background) 

286 sources = afwTable.SourceCatalog(table) 

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

288 if results.negative: 

289 results.negative.makeSources(sources) 

290 if self.negativeFlagKey: 

291 for record in sources: 

292 record.set(self.negativeFlagKey, True) 

293 if results.positive: 

294 results.positive.makeSources(sources) 

295 results.sources = sources 

296 return results 

297 

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

299 """Display detections if so configured 

300 

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

302 

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

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

305 

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

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

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

309 

310 Parameters 

311 ---------- 

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

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

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

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

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

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

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

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

320 Convolved image used for thresholding. 

321 """ 

322 try: 

323 import lsstDebug 

324 display = lsstDebug.Info(__name__).display 

325 except ImportError: 

326 try: 

327 display 

328 except NameError: 

329 display = False 

330 if not display: 

331 return 

332 

333 afwDisplay.setDefaultMaskTransparency(75) 

334 

335 disp0 = afwDisplay.Display(frame=0) 

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

337 

338 def plotPeaks(fps, ctype): 

339 if fps is None: 

340 return 

341 with disp0.Buffering(): 

342 for fp in fps.getFootprints(): 

343 for pp in fp.getPeaks(): 

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

345 plotPeaks(results.positive, "yellow") 

346 plotPeaks(results.negative, "red") 

347 

348 if convolvedImage and display > 1: 

349 disp1 = afwDisplay.Display(frame=1) 

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

351 

352 disp2 = afwDisplay.Display(frame=2) 

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

354 

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

356 """Apply a temporary local background subtraction 

357 

358 This temporary local background serves to suppress noise fluctuations 

359 in the wings of bright objects. 

360 

361 Peaks in the footprints will be updated. 

362 

363 Parameters 

364 ---------- 

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

366 Exposure for which to fit local background. 

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

368 Convolved image on which detection will be performed 

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

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

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

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

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

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

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

376 """ 

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

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

379 # it back in. 

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

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

382 self.tempLocalBackground.config.undersampleStyle) 

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

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

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

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

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

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

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

390 

391 def clearMask(self, mask): 

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

393 

394 Removes any previous detection mask in preparation for a new 

395 detection pass. 

396 

397 Parameters 

398 ---------- 

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

400 Mask to be cleared. 

401 """ 

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

403 

404 def calculateKernelSize(self, sigma): 

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

406 

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

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

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

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

411 

412 Parameters 

413 ---------- 

414 sigma : `float` 

415 Gaussian sigma of smoothing kernel. 

416 

417 Returns 

418 ------- 

419 size : `int` 

420 Size of the smoothing kernel. 

421 """ 

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

423 

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

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

426 

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

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

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

430 

431 Parameters 

432 ---------- 

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

434 Exposure from which to retrieve the PSF. 

435 sigma : `float`, optional 

436 Gaussian sigma to use if provided. 

437 

438 Returns 

439 ------- 

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

441 PSF to use for detection. 

442 

443 Raises 

444 ------ 

445 RuntimeError 

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

447 contain a ``Psf`` object. 

448 """ 

449 if sigma is None: 

450 psf = exposure.getPsf() 

451 if psf is None: 

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

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

454 size = self.calculateKernelSize(sigma) 

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

456 return psf 

457 

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

459 """Convolve the image with the PSF. 

460 

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

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

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

464 Gaussian there's no difference. 

465 

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

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

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

469 because the kernel would extend off the image. 

470 

471 Parameters 

472 ---------- 

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

474 Image to convolve. 

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

476 PSF to convolve with (actually with a Gaussian approximation 

477 to it). 

478 doSmooth : `bool` 

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

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

481 

482 Returns 

483 ------- 

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

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

486 

487 ``middle`` 

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

489 ``sigma`` 

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

491 """ 

492 self.metadata["doSmooth"] = doSmooth 

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

494 self.metadata["sigma"] = sigma 

495 

496 if not doSmooth: 

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

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

499 

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

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

502 kWidth = self.calculateKernelSize(sigma) 

503 self.metadata["smoothingKernelWidth"] = kWidth 

504 gaussFunc = afwMath.GaussianFunction1D(sigma) 

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

506 

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

508 

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

510 

511 # Only search psf-smoothed part of frame 

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

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

514 

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

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

517 

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

519 

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

521 r"""Apply thresholds to the convolved image 

522 

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

524 The threshold can be modified by the provided multiplication 

525 ``factor``. 

526 

527 Parameters 

528 ---------- 

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

530 Convolved image to threshold. 

531 bbox : `lsst.geom.Box2I` 

532 Bounding box of unconvolved image. 

533 factor : `float` 

534 Multiplier for the configured threshold. 

535 factorNeg : `float` or `None` 

536 Multiplier for the configured threshold for negative detection polarity. 

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

538 for positive detection polarity). 

539 

540 Returns 

541 ------- 

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

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

544 

545 ``positive`` 

546 Positive detection footprints, if configured. 

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

548 ``negative`` 

549 Negative detection footprints, if configured. 

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

551 ``factor`` 

552 Multiplier for the configured threshold. 

553 (`float`) 

554 ``factorNeg`` 

555 Multiplier for the configured threshold for negative detection polarity. 

556 (`float`) 

557 """ 

558 if factorNeg is None: 

559 factorNeg = factor 

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

561 "detections: %f", factor) 

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

563 positiveThreshold=None, negativeThreshold=None) 

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

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

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

567 results.positive = afwDet.FootprintSet( 

568 middle, 

569 results.positiveThreshold, 

570 "DETECTED", 

571 self.config.minPixels 

572 ) 

573 results.positive.setRegion(bbox) 

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

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

576 results.negative = afwDet.FootprintSet( 

577 middle, 

578 results.negativeThreshold, 

579 "DETECTED_NEGATIVE", 

580 self.config.minPixels 

581 ) 

582 results.negative.setRegion(bbox) 

583 

584 return results 

585 

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

587 """Finalize the detected footprints. 

588 

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

590 mask planes, and log the results. 

591 

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

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

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

595 ``results`` struct. 

596 

597 Parameters 

598 ---------- 

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

600 Mask image on which to flag detected pixels. 

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

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

603 ``negative`` entries; modified. 

604 sigma : `float` 

605 Gaussian sigma of PSF. 

606 factor : `float` 

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

608 used here for logging purposes. 

609 factorNeg : `float` or `None` 

610 Multiplier used for the negative detection polarity threshold. 

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

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

613 used here for logging purposes. 

614 """ 

615 factorNeg = factor if factorNeg is None else factorNeg 

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

617 fpSet = getattr(results, polarity) 

618 if fpSet is None: 

619 continue 

620 if self.config.nSigmaToGrow > 0: 

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

622 self.metadata["nGrow"] = nGrow 

623 if self.config.combinedGrow: 

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

625 else: 

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

627 afwGeom.Stencil.MANHATTAN) 

628 for fp in fpSet: 

629 fp.dilate(nGrow, stencil) 

630 fpSet.setMask(mask, maskName) 

631 if not self.config.returnOriginalFootprints: 

632 setattr(results, polarity, fpSet) 

633 

634 results.numPos = 0 

635 results.numPosPeaks = 0 

636 results.numNeg = 0 

637 results.numNegPeaks = 0 

638 positive = "" 

639 negative = "" 

640 

641 if results.positive is not None: 

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

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

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

645 if results.negative is not None: 

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

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

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

649 

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

651 positive, " and" if positive and negative else "", negative, 

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

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

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

655 

656 def reEstimateBackground(self, maskedImage, backgrounds): 

657 """Estimate the background after detection 

658 

659 Parameters 

660 ---------- 

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

662 Image on which to estimate the background. 

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

664 List of backgrounds; modified. 

665 

666 Returns 

667 ------- 

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

669 Empirical background model. 

670 """ 

671 bg = self.background.fitBackground(maskedImage) 

672 if self.config.adjustBackground: 

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

674 bg += self.config.adjustBackground 

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

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

677 self.background.config.undersampleStyle) 

678 

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

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

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

682 actrl.getOrderY(), actrl.getWeighting())) 

683 return bg 

684 

685 def clearUnwantedResults(self, mask, results): 

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

687 

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

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

690 

691 Parameters 

692 ---------- 

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

694 Mask image. 

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

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

697 modified. 

698 """ 

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

700 if self.config.reEstimateBackground: 

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

702 results.negative = None 

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

704 if self.config.reEstimateBackground: 

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

706 results.positive = None 

707 

708 @timeMethod 

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

710 background=None): 

711 """Detect footprints on an exposure. 

712 

713 Parameters 

714 ---------- 

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

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

717 set in-place. 

718 doSmooth : `bool`, optional 

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

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

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

722 plane. 

723 sigma : `float`, optional 

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

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

726 ``exposure``. 

727 clearMask : `bool`, optional 

728 Clear both DETECTED and DETECTED_NEGATIVE planes before running 

729 detection. 

730 expId : `dict`, optional 

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

732 RNG seed by subclasses. 

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

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

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

736 

737 Returns 

738 ------- 

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

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

741 

742 ``positive`` 

743 Positive polarity footprints. 

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

745 ``negative`` 

746 Negative polarity footprints. 

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

748 ``numPos`` 

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

750 negative. (`int`) 

751 ``numNeg`` 

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

753 positive. (`int`) 

754 ``background`` 

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

756 if ``reEstimateBackground==False``. 

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

758 ``factor`` 

759 Multiplication factor applied to the configured detection 

760 threshold. (`float`) 

761 """ 

762 maskedImage = exposure.maskedImage 

763 

764 if clearMask: 

765 self.clearMask(maskedImage.getMask()) 

766 

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

768 with self.tempWideBackgroundContext(exposure): 

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

770 middle = convolveResults.middle 

771 sigma = convolveResults.sigma 

772 self.removeBadPixels(middle) 

773 

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

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

776 

777 if self.config.doTempLocalBackground: 

778 self.applyTempLocalBackground(exposure, middle, results) 

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

780 

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

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

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

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

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

786 negative=True) 

787 

788 if self.config.reEstimateBackground: 

789 self.reEstimateBackground(maskedImage, results.background) 

790 

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

792 

793 self.display(exposure, results, middle) 

794 

795 return results 

796 

797 def removeBadPixels(self, middle): 

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

799 

800 Parameters 

801 ---------- 

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

803 Score or maximum likelihood difference image. 

804 The image plane will be modified in place. 

805 """ 

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

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

808 middle.image.array[badPixels] = 0 

809 

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

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

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

813 

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

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

816 well-defined meaning in those cases. 

817 

818 Parameters 

819 ---------- 

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

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

822 local background-subtracted image. 

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

824 Footprints detected on the image. 

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

826 Threshold used to find footprints. 

827 negative : `bool`, optional 

828 Are we calculating for negative sources? 

829 """ 

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

831 return footprints 

832 polarity = -1 if negative else 1 

833 

834 # All incoming footprints have the same schema. 

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

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

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

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

839 

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

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

842 # significance field. 

843 newFootprints = afwDet.FootprintSet(footprints) 

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

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

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

847 new.getPeaks().clear() 

848 new.setPeakCatalog(newPeaks) 

849 

850 # Compute the significance values. 

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

852 for footprint in newFootprints.getFootprints(): 

853 footprint.updatePeakSignificance(exposure.variance, polarity) 

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

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

856 for footprint in newFootprints.getFootprints(): 

857 footprint.updatePeakSignificance(polarity*sigma) 

858 else: 

859 for footprint in newFootprints.getFootprints(): 

860 for peak in footprint.peaks: 

861 peak["significance"] = 0 

862 

863 return newFootprints 

864 

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

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

867 configuration and the statistics of the given image. 

868 

869 Parameters 

870 ---------- 

871 image : `afw.image.MaskedImage` 

872 Image to measure noise statistics from if needed. 

873 thresholdParity: `str` 

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

875 the Threshold will detect. 

876 factor : `float` 

877 Factor by which to multiply the configured detection threshold. 

878 This is useful for tweaking the detection threshold slightly. 

879 

880 Returns 

881 ------- 

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

883 Detection threshold. 

884 """ 

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

886 thresholdValue = self.config.thresholdValue 

887 thresholdType = self.config.thresholdType 

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

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

890 sctrl = afwMath.StatisticsControl() 

891 sctrl.setAndMask(bad) 

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

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

894 thresholdType = 'value' 

895 

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

897 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier) 

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

899 return threshold 

900 

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

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

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

904 

905 Parameters 

906 ---------- 

907 fpSet : `afw.detection.FootprintSet` 

908 Set of Footprints whose Peaks should be updated. 

909 image : `afw.image.MaskedImage` 

910 Image to detect new Footprints and Peak in. 

911 threshold : `afw.detection.Threshold` 

912 Threshold object for detection. 

913 

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

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

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

917 """ 

918 for footprint in fpSet.getFootprints(): 

919 oldPeaks = footprint.getPeaks() 

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

921 continue 

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

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

924 # Footprints. 

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

926 fpSetForPeaks = afwDet.FootprintSet( 

927 sub, 

928 threshold, 

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

930 self.config.minPixels 

931 ) 

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

933 for fpForPeaks in fpSetForPeaks.getFootprints(): 

934 for peak in fpForPeaks.getPeaks(): 

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

936 newPeaks.append(peak) 

937 if len(newPeaks) > 0: 

938 del oldPeaks[:] 

939 oldPeaks.extend(newPeaks) 

940 else: 

941 del oldPeaks[1:] 

942 

943 @staticmethod 

944 def setEdgeBits(maskedImage, goodBBox, edgeBitmask): 

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

946 

947 Parameters 

948 ---------- 

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

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

951 goodBBox : `lsst.geom.Box2I` 

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

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

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

955 outside ``goodBBox``. 

956 """ 

957 msk = maskedImage.getMask() 

958 

959 mx0, my0 = maskedImage.getXY0() 

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

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

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

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

964 [0, 0, 

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

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

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

968 ): 

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

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

971 edgeMask |= edgeBitmask 

972 

973 @contextmanager 

974 def tempWideBackgroundContext(self, exposure): 

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

976 

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

978 detection of large footprints that may overwhelm the deblender. 

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

980 

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

982 the context manager. 

983 

984 Parameters 

985 ---------- 

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

987 Exposure on which to remove large-scale background. 

988 

989 Returns 

990 ------- 

991 context : context manager 

992 Context manager that will ensure the temporary wide background 

993 is restored. 

994 """ 

995 doTempWideBackground = self.config.doTempWideBackground 

996 if doTempWideBackground: 

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

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

999 self.tempWideBackground.run(exposure).background 

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

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

1002 image = exposure.maskedImage.image 

1003 mask = exposure.maskedImage.mask 

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

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

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

1007 try: 

1008 yield 

1009 finally: 

1010 if doTempWideBackground: 

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

1012 

1013 

1014def addExposures(exposureList): 

1015 """Add a set of exposures together. 

1016 

1017 Parameters 

1018 ---------- 

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

1020 Sequence of exposures to add. 

1021 

1022 Returns 

1023 ------- 

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

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

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

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

1028 """ 

1029 exposure0 = exposureList[0] 

1030 image0 = exposure0.getMaskedImage() 

1031 

1032 addedImage = image0.Factory(image0, True) 

1033 addedImage.setXY0(image0.getXY0()) 

1034 

1035 for exposure in exposureList[1:]: 

1036 image = exposure.getMaskedImage() 

1037 addedImage += image 

1038 

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

1040 return addedExposure