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

320 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2024-01-17 19:18 +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, factorNeg=None): 

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 factorNeg : `float` or `None` 

502 Multiplier for the configured threshold for negative detection polarity. 

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

504 for positive detection polarity). 

505 

506 Returns 

507 ------- 

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

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

510 

511 ``positive`` 

512 Positive detection footprints, if configured. 

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

514 ``negative`` 

515 Negative detection footprints, if configured. 

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

517 ``factor`` 

518 Multiplier for the configured threshold. 

519 (`float`) 

520 ``factorNeg`` 

521 Multiplier for the configured threshold for negative detection polarity. 

522 (`float`) 

523 """ 

524 if factorNeg is None: 

525 factorNeg = factor 

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

527 "detections: %f", factor) 

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

529 positiveThreshold=None, negativeThreshold=None) 

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

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

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

533 results.positive = afwDet.FootprintSet( 

534 middle, 

535 results.positiveThreshold, 

536 "DETECTED", 

537 self.config.minPixels 

538 ) 

539 results.positive.setRegion(bbox) 

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

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

542 results.negative = afwDet.FootprintSet( 

543 middle, 

544 results.negativeThreshold, 

545 "DETECTED_NEGATIVE", 

546 self.config.minPixels 

547 ) 

548 results.negative.setRegion(bbox) 

549 

550 return results 

551 

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

553 """Finalize the detected footprints. 

554 

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

556 mask planes, and logs the results. 

557 

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

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

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

561 detection results. 

562 

563 Parameters 

564 ---------- 

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

566 Mask image on which to flag detected pixels. 

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

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

569 ``negative`` entries; modified. 

570 sigma : `float` 

571 Gaussian sigma of PSF. 

572 factor : `float` 

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

574 used here for logging purposes. 

575 factorNeg : `float` or `None` 

576 Multiplier used for the negative detection polarity threshold. 

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

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

579 used here for logging purposes. 

580 """ 

581 factorNeg = factor if factorNeg is None else factorNeg 

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

583 fpSet = getattr(results, polarity) 

584 if fpSet is None: 

585 continue 

586 if self.config.nSigmaToGrow > 0: 

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

588 self.metadata["nGrow"] = nGrow 

589 if self.config.combinedGrow: 

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

591 else: 

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

593 afwGeom.Stencil.MANHATTAN) 

594 for fp in fpSet: 

595 fp.dilate(nGrow, stencil) 

596 fpSet.setMask(mask, maskName) 

597 if not self.config.returnOriginalFootprints: 

598 setattr(results, polarity, fpSet) 

599 

600 results.numPos = 0 

601 results.numPosPeaks = 0 

602 results.numNeg = 0 

603 results.numNegPeaks = 0 

604 positive = "" 

605 negative = "" 

606 

607 if results.positive is not None: 

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

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

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

611 if results.negative is not None: 

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

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

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

615 

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

617 positive, " and" if positive and negative else "", negative, 

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

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

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

621 

622 def reEstimateBackground(self, maskedImage, backgrounds): 

623 """Estimate the background after detection 

624 

625 Parameters 

626 ---------- 

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

628 Image on which to estimate the background. 

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

630 List of backgrounds; modified. 

631 

632 Returns 

633 ------- 

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

635 Empirical background model. 

636 """ 

637 bg = self.background.fitBackground(maskedImage) 

638 if self.config.adjustBackground: 

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

640 bg += self.config.adjustBackground 

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

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

643 self.background.config.undersampleStyle) 

644 

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

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

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

648 actrl.getOrderY(), actrl.getWeighting())) 

649 return bg 

650 

651 def clearUnwantedResults(self, mask, results): 

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

653 

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

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

656 

657 Parameters 

658 ---------- 

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

660 Mask image. 

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

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

663 modified. 

664 """ 

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

666 if self.config.reEstimateBackground: 

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

668 results.negative = None 

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

670 if self.config.reEstimateBackground: 

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

672 results.positive = None 

673 

674 @timeMethod 

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

676 """Detect footprints on an exposure. 

677 

678 Parameters 

679 ---------- 

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

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

682 set in-place. 

683 doSmooth : `bool`, optional 

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

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

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

687 plane. 

688 sigma : `float`, optional 

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

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

691 ``exposure``. 

692 clearMask : `bool`, optional 

693 Clear both DETECTED and DETECTED_NEGATIVE planes before running 

694 detection. 

695 expId : `dict`, optional 

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

697 RNG seed by subclasses. 

698 

699 Returns 

700 ------- 

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

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

703 

704 ``positive`` 

705 Positive polarity footprints. 

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

707 ``negative`` 

708 Negative polarity footprints. 

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

710 ``numPos`` 

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

712 negative. (`int`) 

713 ``numNeg`` 

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

715 positive. (`int`) 

716 ``background`` 

717 Re-estimated background. `None` if 

718 ``reEstimateBackground==False``. 

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

720 ``factor`` 

721 Multiplication factor applied to the configured detection 

722 threshold. (`float`) 

723 """ 

724 maskedImage = exposure.maskedImage 

725 

726 if clearMask: 

727 self.clearMask(maskedImage.getMask()) 

728 

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

730 with self.tempWideBackgroundContext(exposure): 

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

732 middle = convolveResults.middle 

733 sigma = convolveResults.sigma 

734 

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

736 results.background = afwMath.BackgroundList() 

737 if self.config.doTempLocalBackground: 

738 self.applyTempLocalBackground(exposure, middle, results) 

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

740 

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

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

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

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

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

746 negative=True) 

747 

748 if self.config.reEstimateBackground: 

749 self.reEstimateBackground(maskedImage, results.background) 

750 

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

752 

753 self.display(exposure, results, middle) 

754 

755 return results 

756 

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

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

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

760 

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

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

763 well-defined meaning in those cases. 

764 

765 Parameters 

766 ---------- 

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

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

769 local background-subtracted image. 

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

771 Footprints detected on the image. 

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

773 Threshold used to find footprints. 

774 negative : `bool`, optional 

775 Are we calculating for negative sources? 

776 """ 

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

778 return footprints 

779 polarity = -1 if negative else 1 

780 

781 # All incoming footprints have the same schema. 

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

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

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

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

786 

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

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

789 # significance field. 

790 newFootprints = afwDet.FootprintSet(footprints) 

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

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

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

794 new.getPeaks().clear() 

795 new.setPeakCatalog(newPeaks) 

796 

797 # Compute the significance values. 

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

799 for footprint in newFootprints.getFootprints(): 

800 footprint.updatePeakSignificance(exposure.variance, polarity) 

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

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

803 for footprint in newFootprints.getFootprints(): 

804 footprint.updatePeakSignificance(polarity*sigma) 

805 else: 

806 for footprint in newFootprints.getFootprints(): 

807 for peak in footprint.peaks: 

808 peak["significance"] = 0 

809 

810 return newFootprints 

811 

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

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

814 configuration and the statistics of the given image. 

815 

816 Parameters 

817 ---------- 

818 image : `afw.image.MaskedImage` 

819 Image to measure noise statistics from if needed. 

820 thresholdParity: `str` 

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

822 the Threshold will detect. 

823 factor : `float` 

824 Factor by which to multiply the configured detection threshold. 

825 This is useful for tweaking the detection threshold slightly. 

826 

827 Returns 

828 ------- 

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

830 Detection threshold. 

831 """ 

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

833 thresholdValue = self.config.thresholdValue 

834 thresholdType = self.config.thresholdType 

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

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

837 sctrl = afwMath.StatisticsControl() 

838 sctrl.setAndMask(bad) 

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

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

841 thresholdType = 'value' 

842 

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

844 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier) 

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

846 return threshold 

847 

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

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

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

851 

852 Parameters 

853 ---------- 

854 fpSet : `afw.detection.FootprintSet` 

855 Set of Footprints whose Peaks should be updated. 

856 image : `afw.image.MaskedImage` 

857 Image to detect new Footprints and Peak in. 

858 threshold : `afw.detection.Threshold` 

859 Threshold object for detection. 

860 

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

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

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

864 """ 

865 for footprint in fpSet.getFootprints(): 

866 oldPeaks = footprint.getPeaks() 

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

868 continue 

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

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

871 # Footprints. 

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

873 fpSetForPeaks = afwDet.FootprintSet( 

874 sub, 

875 threshold, 

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

877 self.config.minPixels 

878 ) 

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

880 for fpForPeaks in fpSetForPeaks.getFootprints(): 

881 for peak in fpForPeaks.getPeaks(): 

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

883 newPeaks.append(peak) 

884 if len(newPeaks) > 0: 

885 del oldPeaks[:] 

886 oldPeaks.extend(newPeaks) 

887 else: 

888 del oldPeaks[1:] 

889 

890 @staticmethod 

891 def setEdgeBits(maskedImage, goodBBox, edgeBitmask): 

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

893 

894 Parameters 

895 ---------- 

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

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

898 goodBBox : `lsst.geom.Box2I` 

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

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

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

902 outside ``goodBBox``. 

903 """ 

904 msk = maskedImage.getMask() 

905 

906 mx0, my0 = maskedImage.getXY0() 

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

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

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

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

911 [0, 0, 

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

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

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

915 ): 

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

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

918 edgeMask |= edgeBitmask 

919 

920 @contextmanager 

921 def tempWideBackgroundContext(self, exposure): 

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

923 

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

925 detection of large footprints that may overwhelm the deblender. 

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

927 

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

929 the context manager. 

930 

931 Parameters 

932 ---------- 

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

934 Exposure on which to remove large-scale background. 

935 

936 Returns 

937 ------- 

938 context : context manager 

939 Context manager that will ensure the temporary wide background 

940 is restored. 

941 """ 

942 doTempWideBackground = self.config.doTempWideBackground 

943 if doTempWideBackground: 

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

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

946 self.tempWideBackground.run(exposure).background 

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

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

949 image = exposure.maskedImage.image 

950 mask = exposure.maskedImage.mask 

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

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

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

954 try: 

955 yield 

956 finally: 

957 if doTempWideBackground: 

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

959 

960 

961def addExposures(exposureList): 

962 """Add a set of exposures together. 

963 

964 Parameters 

965 ---------- 

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

967 Sequence of exposures to add. 

968 

969 Returns 

970 ------- 

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

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

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

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

975 """ 

976 exposure0 = exposureList[0] 

977 image0 = exposure0.getMaskedImage() 

978 

979 addedImage = image0.Factory(image0, True) 

980 addedImage.setXY0(image0.getXY0()) 

981 

982 for exposure in exposureList[1:]: 

983 image = exposure.getMaskedImage() 

984 addedImage += image 

985 

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

987 return addedExposure