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

316 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-10-14 09:15 +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 

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 """Create the detection task. Most arguments are simply passed onto pipe.base.Task. 

164 

165 Parameters 

166 ---------- 

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

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

169 **kwds 

170 Keyword arguments passed to `lsst.pipe.base.task.Task.__init__` 

171 

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

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

174 negative threshold. 

175 

176 Notes 

177 ----- 

178 This task can add fields to the schema, so any code calling this task must ensure that 

179 these columns are indeed present in the input match list. 

180 """ 

181 

182 ConfigClass = SourceDetectionConfig 

183 _DefaultName = "sourceDetection" 

184 

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

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

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

188 self.negativeFlagKey = schema.addField( 

189 "flags_negative", type="Flag", 

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

191 ) 

192 else: 

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

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

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

196 self.negativeFlagKey = None 

197 if self.config.reEstimateBackground: 

198 self.makeSubtask("background") 

199 if self.config.doTempLocalBackground: 

200 self.makeSubtask("tempLocalBackground") 

201 if self.config.doTempWideBackground: 

202 self.makeSubtask("tempWideBackground") 

203 

204 @timeMethod 

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

206 r"""Run source detection and create a SourceCatalog of detections. 

207 

208 Parameters 

209 ---------- 

210 table : `lsst.afw.table.SourceTable` 

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

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

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

214 doSmooth : `bool` 

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

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

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

218 sigma : `float` 

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

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

221 clearMask : `bool` 

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

223 expId : `int` 

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

225 RNG seed by subclasses. 

226 

227 Returns 

228 ------- 

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

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

231 

232 ``sources`` 

233 The detected sources (`lsst.afw.table.SourceCatalog`) 

234 ``fpSets`` 

235 The result returned by `detectFootprints()` 

236 (`lsst.pipe.base.Struct`). 

237 

238 Raises 

239 ------ 

240 ValueError 

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

242 lsst.pipe.base.TaskError 

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

244 

245 Notes 

246 ----- 

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

248 `detectFootprints()` to just get the 

249 `lsst.afw.detection.FootprintSet`\ s. 

250 """ 

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

252 raise ValueError("Table has incorrect Schema") 

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

254 clearMask=clearMask, expId=expId) 

255 sources = afwTable.SourceCatalog(table) 

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

257 if results.negative: 

258 results.negative.makeSources(sources) 

259 if self.negativeFlagKey: 

260 for record in sources: 

261 record.set(self.negativeFlagKey, True) 

262 if results.positive: 

263 results.positive.makeSources(sources) 

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

265 results.sources = sources 

266 return results 

267 

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

269 """Display detections if so configured 

270 

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

272 

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

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

275 

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

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

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

279 

280 Parameters 

281 ---------- 

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

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

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

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

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

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

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

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

290 Convolved image used for thresholding. 

291 """ 

292 try: 

293 import lsstDebug 

294 display = lsstDebug.Info(__name__).display 

295 except ImportError: 

296 try: 

297 display 

298 except NameError: 

299 display = False 

300 if not display: 

301 return 

302 

303 afwDisplay.setDefaultMaskTransparency(75) 

304 

305 disp0 = afwDisplay.Display(frame=0) 

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

307 

308 def plotPeaks(fps, ctype): 

309 if fps is None: 

310 return 

311 with disp0.Buffering(): 

312 for fp in fps.getFootprints(): 

313 for pp in fp.getPeaks(): 

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

315 plotPeaks(results.positive, "yellow") 

316 plotPeaks(results.negative, "red") 

317 

318 if convolvedImage and display > 1: 

319 disp1 = afwDisplay.Display(frame=1) 

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

321 

322 disp2 = afwDisplay.Display(frame=2) 

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

324 

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

326 """Apply a temporary local background subtraction 

327 

328 This temporary local background serves to suppress noise fluctuations 

329 in the wings of bright objects. 

330 

331 Peaks in the footprints will be updated. 

332 

333 Parameters 

334 ---------- 

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

336 Exposure for which to fit local background. 

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

338 Convolved image on which detection will be performed 

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

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

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

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

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

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

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

346 """ 

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

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

349 # it back in. 

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

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

352 self.tempLocalBackground.config.undersampleStyle) 

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

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

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

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

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

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

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

360 

361 def clearMask(self, mask): 

362 """Clear the DETECTED and DETECTED_NEGATIVE mask planes 

363 

364 Removes any previous detection mask in preparation for a new 

365 detection pass. 

366 

367 Parameters 

368 ---------- 

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

370 Mask to be cleared. 

371 """ 

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

373 

374 def calculateKernelSize(self, sigma): 

375 """Calculate size of smoothing kernel 

376 

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

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

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

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

381 

382 Parameters 

383 ---------- 

384 sigma : `float` 

385 Gaussian sigma of smoothing kernel. 

386 

387 Returns 

388 ------- 

389 size : `int` 

390 Size of the smoothing kernel. 

391 """ 

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

393 

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

395 """Retrieve the PSF for an exposure 

396 

397 If ``sigma`` is provided, we make a ``GaussianPsf`` with that, 

398 otherwise use the one from the ``exposure``. 

399 

400 Parameters 

401 ---------- 

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

403 Exposure from which to retrieve the PSF. 

404 sigma : `float`, optional 

405 Gaussian sigma to use if provided. 

406 

407 Returns 

408 ------- 

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

410 PSF to use for detection. 

411 """ 

412 if sigma is None: 

413 psf = exposure.getPsf() 

414 if psf is None: 

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

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

417 size = self.calculateKernelSize(sigma) 

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

419 return psf 

420 

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

422 """Convolve the image with the PSF 

423 

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

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

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

427 Gaussian there's no difference. 

428 

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

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

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

432 because the kernel would extend off the image. 

433 

434 Parameters 

435 ---------- 

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

437 Image to convolve. 

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

439 PSF to convolve with (actually with a Gaussian approximation 

440 to it). 

441 doSmooth : `bool` 

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

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

444 

445 Returns 

446 ------- 

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

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

449 

450 ``middle`` 

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

452 ``sigma`` 

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

454 """ 

455 self.metadata["doSmooth"] = doSmooth 

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

457 self.metadata["sigma"] = sigma 

458 

459 if not doSmooth: 

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

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

462 

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

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

465 kWidth = self.calculateKernelSize(sigma) 

466 self.metadata["smoothingKernelWidth"] = kWidth 

467 gaussFunc = afwMath.GaussianFunction1D(sigma) 

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

469 

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

471 

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

473 # 

474 # Only search psf-smoothed part of frame 

475 # 

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

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

478 # 

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

480 # 

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

482 

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

484 

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

486 r"""Apply thresholds to the convolved image 

487 

488 Identifies ``Footprint``\ s, both positive and negative. 

489 

490 The threshold can be modified by the provided multiplication 

491 ``factor``. 

492 

493 Parameters 

494 ---------- 

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

496 Convolved image to threshold. 

497 bbox : `lsst.geom.Box2I` 

498 Bounding box of unconvolved image. 

499 factor : `float` 

500 Multiplier for the configured threshold. 

501 

502 Returns 

503 ------- 

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

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

506 

507 ``positive`` 

508 Positive detection footprints, if configured. 

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

510 ``negative`` 

511 Negative detection footprints, if configured. 

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

513 ``factor`` 

514 Multiplier for the configured threshold. 

515 (`float`) 

516 """ 

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

518 positiveThreshold=None, negativeThreshold=None) 

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

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

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

522 results.positive = afwDet.FootprintSet( 

523 middle, 

524 results.positiveThreshold, 

525 "DETECTED", 

526 self.config.minPixels 

527 ) 

528 results.positive.setRegion(bbox) 

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

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

531 results.negative = afwDet.FootprintSet( 

532 middle, 

533 results.negativeThreshold, 

534 "DETECTED_NEGATIVE", 

535 self.config.minPixels 

536 ) 

537 results.negative.setRegion(bbox) 

538 

539 return results 

540 

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

542 """Finalize the detected footprints 

543 

544 Grows the footprints, sets the ``DETECTED`` and ``DETECTED_NEGATIVE`` 

545 mask planes, and logs the results. 

546 

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

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

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

550 detection results. 

551 

552 Parameters 

553 ---------- 

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

555 Mask image on which to flag detected pixels. 

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

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

558 ``negative`` entries; modified. 

559 sigma : `float` 

560 Gaussian sigma of PSF. 

561 factor : `float` 

562 Multiplier for the configured threshold. 

563 """ 

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

565 fpSet = getattr(results, polarity) 

566 if fpSet is None: 

567 continue 

568 if self.config.nSigmaToGrow > 0: 

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

570 self.metadata["nGrow"] = nGrow 

571 if self.config.combinedGrow: 

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

573 else: 

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

575 afwGeom.Stencil.MANHATTAN) 

576 for fp in fpSet: 

577 fp.dilate(nGrow, stencil) 

578 fpSet.setMask(mask, maskName) 

579 if not self.config.returnOriginalFootprints: 

580 setattr(results, polarity, fpSet) 

581 

582 results.numPos = 0 

583 results.numPosPeaks = 0 

584 results.numNeg = 0 

585 results.numNegPeaks = 0 

586 positive = "" 

587 negative = "" 

588 

589 if results.positive is not None: 

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

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

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

593 if results.negative is not None: 

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

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

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

597 

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

599 positive, " and" if positive and negative else "", negative, 

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

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

602 

603 def reEstimateBackground(self, maskedImage, backgrounds): 

604 """Estimate the background after detection 

605 

606 Parameters 

607 ---------- 

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

609 Image on which to estimate the background. 

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

611 List of backgrounds; modified. 

612 

613 Returns 

614 ------- 

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

616 Empirical background model. 

617 """ 

618 bg = self.background.fitBackground(maskedImage) 

619 if self.config.adjustBackground: 

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

621 bg += self.config.adjustBackground 

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

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

624 self.background.config.undersampleStyle) 

625 

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

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

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

629 actrl.getOrderY(), actrl.getWeighting())) 

630 return bg 

631 

632 def clearUnwantedResults(self, mask, results): 

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

634 

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

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

637 

638 Parameters 

639 ---------- 

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

641 Mask image. 

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

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

644 modified. 

645 """ 

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

647 if self.config.reEstimateBackground: 

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

649 results.negative = None 

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

651 if self.config.reEstimateBackground: 

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

653 results.positive = None 

654 

655 @timeMethod 

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

657 """Detect footprints on an exposure. 

658 

659 Parameters 

660 ---------- 

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

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

663 set in-place. 

664 doSmooth : `bool`, optional 

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

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

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

668 plane. 

669 sigma : `float`, optional 

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

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

672 ``exposure``. 

673 clearMask : `bool`, optional 

674 Clear both DETECTED and DETECTED_NEGATIVE planes before running 

675 detection. 

676 expId : `dict`, optional 

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

678 RNG seed by subclasses. 

679 

680 Returns 

681 ------- 

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

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

684 

685 ``positive`` 

686 Positive polarity footprints. 

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

688 ``negative`` 

689 Negative polarity footprints. 

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

691 ``numPos`` 

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

693 negative. (`int`) 

694 ``numNeg`` 

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

696 positive. (`int`) 

697 ``background`` 

698 Re-estimated background. `None` if 

699 ``reEstimateBackground==False``. 

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

701 ``factor`` 

702 Multiplication factor applied to the configured detection 

703 threshold. (`float`) 

704 """ 

705 maskedImage = exposure.maskedImage 

706 

707 if clearMask: 

708 self.clearMask(maskedImage.getMask()) 

709 

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

711 with self.tempWideBackgroundContext(exposure): 

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

713 middle = convolveResults.middle 

714 sigma = convolveResults.sigma 

715 

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

717 results.background = afwMath.BackgroundList() 

718 if self.config.doTempLocalBackground: 

719 self.applyTempLocalBackground(exposure, middle, results) 

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

721 

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

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

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

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

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

727 negative=True) 

728 

729 if self.config.reEstimateBackground: 

730 self.reEstimateBackground(maskedImage, results.background) 

731 

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

733 

734 self.display(exposure, results, middle) 

735 

736 return results 

737 

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

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

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

741 

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

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

744 well-defined meaning in those cases. 

745 

746 Parameters 

747 ---------- 

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

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

750 local background-subtracted image. 

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

752 Footprints detected on the image. 

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

754 Threshold used to find footprints. 

755 negative : `bool`, optional 

756 Are we calculating for negative sources? 

757 """ 

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

759 return footprints 

760 polarity = -1 if negative else 1 

761 

762 # All incoming footprints have the same schema. 

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

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

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

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

767 

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

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

770 # significance field. 

771 newFootprints = afwDet.FootprintSet(footprints) 

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

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

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

775 new.getPeaks().clear() 

776 new.setPeakCatalog(newPeaks) 

777 

778 # Compute the significance values. 

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

780 for footprint in newFootprints.getFootprints(): 

781 footprint.updatePeakSignificance(exposure.variance, polarity) 

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

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

784 for footprint in newFootprints.getFootprints(): 

785 footprint.updatePeakSignificance(polarity*sigma) 

786 else: 

787 for footprint in newFootprints.getFootprints(): 

788 for peak in footprint.peaks: 

789 peak["significance"] = 0 

790 

791 return newFootprints 

792 

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

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

795 configuration and the statistics of the given image. 

796 

797 Parameters 

798 ---------- 

799 image : `afw.image.MaskedImage` 

800 Image to measure noise statistics from if needed. 

801 thresholdParity: `str` 

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

803 the Threshold will detect. 

804 factor : `float` 

805 Factor by which to multiply the configured detection threshold. 

806 This is useful for tweaking the detection threshold slightly. 

807 

808 Returns 

809 ------- 

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

811 Detection threshold. 

812 """ 

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

814 thresholdValue = self.config.thresholdValue 

815 thresholdType = self.config.thresholdType 

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

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

818 sctrl = afwMath.StatisticsControl() 

819 sctrl.setAndMask(bad) 

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

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

822 thresholdType = 'value' 

823 

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

825 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier) 

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

827 return threshold 

828 

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

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

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

832 

833 Parameters 

834 ---------- 

835 fpSet : `afw.detection.FootprintSet` 

836 Set of Footprints whose Peaks should be updated. 

837 image : `afw.image.MaskedImage` 

838 Image to detect new Footprints and Peak in. 

839 threshold : `afw.detection.Threshold` 

840 Threshold object for detection. 

841 

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

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

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

845 """ 

846 for footprint in fpSet.getFootprints(): 

847 oldPeaks = footprint.getPeaks() 

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

849 continue 

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

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

852 # Footprints. 

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

854 fpSetForPeaks = afwDet.FootprintSet( 

855 sub, 

856 threshold, 

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

858 self.config.minPixels 

859 ) 

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

861 for fpForPeaks in fpSetForPeaks.getFootprints(): 

862 for peak in fpForPeaks.getPeaks(): 

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

864 newPeaks.append(peak) 

865 if len(newPeaks) > 0: 

866 del oldPeaks[:] 

867 oldPeaks.extend(newPeaks) 

868 else: 

869 del oldPeaks[1:] 

870 

871 @staticmethod 

872 def setEdgeBits(maskedImage, goodBBox, edgeBitmask): 

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

874 

875 Parameters 

876 ---------- 

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

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

879 goodBBox : `lsst.geom.Box2I` 

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

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

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

883 outside ``goodBBox``. 

884 """ 

885 msk = maskedImage.getMask() 

886 

887 mx0, my0 = maskedImage.getXY0() 

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

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

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

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

892 [0, 0, 

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

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

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

896 ): 

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

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

899 edgeMask |= edgeBitmask 

900 

901 @contextmanager 

902 def tempWideBackgroundContext(self, exposure): 

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

904 

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

906 detection of large footprints that may overwhelm the deblender. 

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

908 

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

910 the context manager. 

911 

912 Parameters 

913 ---------- 

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

915 Exposure on which to remove large-scale background. 

916 

917 Returns 

918 ------- 

919 context : context manager 

920 Context manager that will ensure the temporary wide background 

921 is restored. 

922 """ 

923 doTempWideBackground = self.config.doTempWideBackground 

924 if doTempWideBackground: 

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

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

927 self.tempWideBackground.run(exposure).background 

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

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

930 image = exposure.maskedImage.image 

931 mask = exposure.maskedImage.mask 

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

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

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

935 try: 

936 yield 

937 finally: 

938 if doTempWideBackground: 

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

940 

941 

942def addExposures(exposureList): 

943 """Add a set of exposures together. 

944 

945 Parameters 

946 ---------- 

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

948 Sequence of exposures to add. 

949 

950 Returns 

951 ------- 

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

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

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

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

956 """ 

957 exposure0 = exposureList[0] 

958 image0 = exposure0.getMaskedImage() 

959 

960 addedImage = image0.Factory(image0, True) 

961 addedImage.setXY0(image0.getXY0()) 

962 

963 for exposure in exposureList[1:]: 

964 image = exposure.getMaskedImage() 

965 addedImage += image 

966 

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

968 return addedExposure