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

319 statements  

« prev     ^ index     » next       coverage.py v7.2.5, created at 2023-05-03 03:09 -0700

1# 

2# LSST Data Management System 

3# 

4# Copyright 2008-2017 AURA/LSST. 

5# 

6# This product includes software developed by the 

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

8# 

9# This program is free software: you can redistribute it and/or modify 

10# it under the terms of the GNU General Public License as published by 

11# the Free Software Foundation, either version 3 of the License, or 

12# (at your option) any later version. 

13# 

14# This program is distributed in the hope that it will be useful, 

15# but WITHOUT ANY WARRANTY; without even the implied warranty of 

16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the LSST License Statement and 

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

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

22# 

23 

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

25 

26from contextlib import contextmanager 

27 

28import numpy as np 

29 

30import lsst.geom 

31import lsst.afw.display as afwDisplay 

32import lsst.afw.detection as afwDet 

33import lsst.afw.geom as afwGeom 

34import lsst.afw.image as afwImage 

35import lsst.afw.math as afwMath 

36import lsst.afw.table as afwTable 

37import lsst.pex.config as pexConfig 

38import lsst.pipe.base as pipeBase 

39from lsst.utils.timer import timeMethod 

40from .subtractBackground import SubtractBackgroundTask 

41 

42 

43class SourceDetectionConfig(pexConfig.Config): 

44 """Configuration parameters for the SourceDetectionTask 

45 """ 

46 minPixels = pexConfig.RangeField( 

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

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

49 ) 

50 isotropicGrow = pexConfig.Field( 

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

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

53 ) 

54 combinedGrow = pexConfig.Field( 

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

56 dtype=bool, default=True, 

57 ) 

58 nSigmaToGrow = pexConfig.Field( 

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

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

61 ) 

62 returnOriginalFootprints = pexConfig.Field( 

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

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

65 ) 

66 thresholdValue = pexConfig.RangeField( 

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

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

69 ) 

70 includeThresholdMultiplier = pexConfig.RangeField( 

71 doc="Include threshold relative to thresholdValue", 

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

73 ) 

74 thresholdType = pexConfig.ChoiceField( 

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

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

77 allowed={ 

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

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

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

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

82 }, 

83 ) 

84 thresholdPolarity = pexConfig.ChoiceField( 

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

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

87 allowed={ 

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

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

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

91 }, 

92 ) 

93 adjustBackground = pexConfig.Field( 

94 dtype=float, 

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

96 default=0.0, 

97 ) 

98 reEstimateBackground = pexConfig.Field( 

99 dtype=bool, 

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

101 default=True, optional=False, 

102 ) 

103 background = pexConfig.ConfigurableField( 

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

105 target=SubtractBackgroundTask, 

106 ) 

107 tempLocalBackground = pexConfig.ConfigurableField( 

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

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

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

111 target=SubtractBackgroundTask, 

112 ) 

113 doTempLocalBackground = pexConfig.Field( 

114 dtype=bool, 

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

116 default=True, 

117 ) 

118 tempWideBackground = pexConfig.ConfigurableField( 

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

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

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

122 target=SubtractBackgroundTask, 

123 ) 

124 doTempWideBackground = pexConfig.Field( 

125 dtype=bool, 

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

127 default=False, 

128 ) 

129 nPeaksMaxSimple = pexConfig.Field( 

130 dtype=int, 

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

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

133 default=1, 

134 ) 

135 nSigmaForKernel = pexConfig.Field( 

136 dtype=float, 

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

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

139 default=7.0, 

140 ) 

141 statsMask = pexConfig.ListField( 

142 dtype=str, 

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

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

145 ) 

146 

147 def setDefaults(self): 

148 self.tempLocalBackground.binSize = 64 

149 self.tempLocalBackground.algorithm = "AKIMA_SPLINE" 

150 self.tempLocalBackground.useApprox = False 

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

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

153 self.tempWideBackground.binSize = 512 

154 self.tempWideBackground.algorithm = "AKIMA_SPLINE" 

155 self.tempWideBackground.useApprox = False 

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

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

158 if maskPlane in self.tempWideBackground.ignoredPixelMask: 

159 self.tempWideBackground.ignoredPixelMask.remove(maskPlane) 

160 

161 

162class SourceDetectionTask(pipeBase.Task): 

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

164 

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

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

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

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

169 detected sources. 

170 

171 Parameters 

172 ---------- 

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

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

175 **kwds 

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

177 

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

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

180 negative threshold. 

181 

182 Notes 

183 ----- 

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

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

186 """ 

187 ConfigClass = SourceDetectionConfig 

188 _DefaultName = "sourceDetection" 

189 

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

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

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

193 self.negativeFlagKey = schema.addField( 

194 "flags_negative", type="Flag", 

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

196 ) 

197 else: 

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

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

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

201 self.negativeFlagKey = None 

202 if self.config.reEstimateBackground: 

203 self.makeSubtask("background") 

204 if self.config.doTempLocalBackground: 

205 self.makeSubtask("tempLocalBackground") 

206 if self.config.doTempWideBackground: 

207 self.makeSubtask("tempWideBackground") 

208 

209 @timeMethod 

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

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

212 

213 Parameters 

214 ---------- 

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

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

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

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

219 doSmooth : `bool` 

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

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

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

223 sigma : `float` 

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

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

226 clearMask : `bool` 

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

228 expId : `int` 

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

230 RNG seed by subclasses. 

231 

232 Returns 

233 ------- 

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

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

236 

237 ``sources`` 

238 Detected sources on the exposure. 

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

240 ``positive`` 

241 Positive polarity footprints. 

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

243 ``negative`` 

244 Negative polarity footprints. 

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

246 ``numPos`` 

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

248 negative. (`int`) 

249 ``numNeg`` 

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

251 positive. (`int`) 

252 ``background`` 

253 Re-estimated background. `None` if 

254 ``reEstimateBackground==False``. 

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

256 ``factor`` 

257 Multiplication factor applied to the configured detection 

258 threshold. (`float`) 

259 

260 Raises 

261 ------ 

262 ValueError 

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

264 lsst.pipe.base.TaskError 

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

266 

267 Notes 

268 ----- 

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

270 `detectFootprints()` to just get the 

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

272 """ 

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

274 raise ValueError("Table has incorrect Schema") 

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

276 clearMask=clearMask, expId=expId) 

277 sources = afwTable.SourceCatalog(table) 

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

279 if results.negative: 

280 results.negative.makeSources(sources) 

281 if self.negativeFlagKey: 

282 for record in sources: 

283 record.set(self.negativeFlagKey, True) 

284 if results.positive: 

285 results.positive.makeSources(sources) 

286 results.sources = sources 

287 return results 

288 

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

290 """Display detections if so configured 

291 

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

293 

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

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

296 

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

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

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

300 

301 Parameters 

302 ---------- 

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

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

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

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

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

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

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

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

311 Convolved image used for thresholding. 

312 """ 

313 try: 

314 import lsstDebug 

315 display = lsstDebug.Info(__name__).display 

316 except ImportError: 

317 try: 

318 display 

319 except NameError: 

320 display = False 

321 if not display: 

322 return 

323 

324 afwDisplay.setDefaultMaskTransparency(75) 

325 

326 disp0 = afwDisplay.Display(frame=0) 

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

328 

329 def plotPeaks(fps, ctype): 

330 if fps is None: 

331 return 

332 with disp0.Buffering(): 

333 for fp in fps.getFootprints(): 

334 for pp in fp.getPeaks(): 

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

336 plotPeaks(results.positive, "yellow") 

337 plotPeaks(results.negative, "red") 

338 

339 if convolvedImage and display > 1: 

340 disp1 = afwDisplay.Display(frame=1) 

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

342 

343 disp2 = afwDisplay.Display(frame=2) 

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

345 

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

347 """Apply a temporary local background subtraction 

348 

349 This temporary local background serves to suppress noise fluctuations 

350 in the wings of bright objects. 

351 

352 Peaks in the footprints will be updated. 

353 

354 Parameters 

355 ---------- 

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

357 Exposure for which to fit local background. 

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

359 Convolved image on which detection will be performed 

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

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

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

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

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

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

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

367 """ 

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

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

370 # it back in. 

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

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

373 self.tempLocalBackground.config.undersampleStyle) 

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

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

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

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

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

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

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

381 

382 def clearMask(self, mask): 

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

384 

385 Removes any previous detection mask in preparation for a new 

386 detection pass. 

387 

388 Parameters 

389 ---------- 

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

391 Mask to be cleared. 

392 """ 

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

394 

395 def calculateKernelSize(self, sigma): 

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

397 

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

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

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

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

402 

403 Parameters 

404 ---------- 

405 sigma : `float` 

406 Gaussian sigma of smoothing kernel. 

407 

408 Returns 

409 ------- 

410 size : `int` 

411 Size of the smoothing kernel. 

412 """ 

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

414 

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

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

417 

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

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

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

421 

422 Parameters 

423 ---------- 

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

425 Exposure from which to retrieve the PSF. 

426 sigma : `float`, optional 

427 Gaussian sigma to use if provided. 

428 

429 Returns 

430 ------- 

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

432 PSF to use for detection. 

433 

434 Raises 

435 ------ 

436 RuntimeError 

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

438 contain a ``Psf`` object. 

439 """ 

440 if sigma is None: 

441 psf = exposure.getPsf() 

442 if psf is None: 

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

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

445 size = self.calculateKernelSize(sigma) 

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

447 return psf 

448 

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

450 """Convolve the image with the PSF. 

451 

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

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

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

455 Gaussian there's no difference. 

456 

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

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

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

460 because the kernel would extend off the image. 

461 

462 Parameters 

463 ---------- 

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

465 Image to convolve. 

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

467 PSF to convolve with (actually with a Gaussian approximation 

468 to it). 

469 doSmooth : `bool` 

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

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

472 

473 Returns 

474 ------- 

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

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

477 

478 ``middle`` 

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

480 ``sigma`` 

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

482 """ 

483 self.metadata["doSmooth"] = doSmooth 

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

485 self.metadata["sigma"] = sigma 

486 

487 if not doSmooth: 

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

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

490 

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

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

493 kWidth = self.calculateKernelSize(sigma) 

494 self.metadata["smoothingKernelWidth"] = kWidth 

495 gaussFunc = afwMath.GaussianFunction1D(sigma) 

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

497 

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

499 

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

501 

502 # Only search psf-smoothed part of frame 

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

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

505 

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

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

508 

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

510 

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

512 r"""Apply thresholds to the convolved image 

513 

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

515 The threshold can be modified by the provided multiplication 

516 ``factor``. 

517 

518 Parameters 

519 ---------- 

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

521 Convolved image to threshold. 

522 bbox : `lsst.geom.Box2I` 

523 Bounding box of unconvolved image. 

524 factor : `float` 

525 Multiplier for the configured threshold. 

526 factorNeg : `float` or `None` 

527 Multiplier for the configured threshold for negative detection polarity. 

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

529 for positive detection polarity). 

530 

531 Returns 

532 ------- 

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

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

535 

536 ``positive`` 

537 Positive detection footprints, if configured. 

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

539 ``negative`` 

540 Negative detection footprints, if configured. 

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

542 ``factor`` 

543 Multiplier for the configured threshold. 

544 (`float`) 

545 ``factorNeg`` 

546 Multiplier for the configured threshold for negative detection polarity. 

547 (`float`) 

548 """ 

549 if factorNeg is None: 

550 factorNeg = factor 

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

552 "detections: %f", factor) 

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

554 positiveThreshold=None, negativeThreshold=None) 

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

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

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

558 results.positive = afwDet.FootprintSet( 

559 middle, 

560 results.positiveThreshold, 

561 "DETECTED", 

562 self.config.minPixels 

563 ) 

564 results.positive.setRegion(bbox) 

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

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

567 results.negative = afwDet.FootprintSet( 

568 middle, 

569 results.negativeThreshold, 

570 "DETECTED_NEGATIVE", 

571 self.config.minPixels 

572 ) 

573 results.negative.setRegion(bbox) 

574 

575 return results 

576 

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

578 """Finalize the detected footprints. 

579 

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

581 mask planes, and log the results. 

582 

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

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

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

586 ``results`` struct. 

587 

588 Parameters 

589 ---------- 

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

591 Mask image on which to flag detected pixels. 

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

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

594 ``negative`` entries; modified. 

595 sigma : `float` 

596 Gaussian sigma of PSF. 

597 factor : `float` 

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

599 used here for logging purposes. 

600 factorNeg : `float` or `None` 

601 Multiplier used for the negative detection polarity threshold. 

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

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

604 used here for logging purposes. 

605 """ 

606 factorNeg = factor if factorNeg is None else factorNeg 

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

608 fpSet = getattr(results, polarity) 

609 if fpSet is None: 

610 continue 

611 if self.config.nSigmaToGrow > 0: 

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

613 self.metadata["nGrow"] = nGrow 

614 if self.config.combinedGrow: 

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

616 else: 

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

618 afwGeom.Stencil.MANHATTAN) 

619 for fp in fpSet: 

620 fp.dilate(nGrow, stencil) 

621 fpSet.setMask(mask, maskName) 

622 if not self.config.returnOriginalFootprints: 

623 setattr(results, polarity, fpSet) 

624 

625 results.numPos = 0 

626 results.numPosPeaks = 0 

627 results.numNeg = 0 

628 results.numNegPeaks = 0 

629 positive = "" 

630 negative = "" 

631 

632 if results.positive is not None: 

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

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

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

636 if results.negative is not None: 

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

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

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

640 

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

642 positive, " and" if positive and negative else "", negative, 

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

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

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

646 

647 def reEstimateBackground(self, maskedImage, backgrounds): 

648 """Estimate the background after detection 

649 

650 Parameters 

651 ---------- 

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

653 Image on which to estimate the background. 

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

655 List of backgrounds; modified. 

656 

657 Returns 

658 ------- 

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

660 Empirical background model. 

661 """ 

662 bg = self.background.fitBackground(maskedImage) 

663 if self.config.adjustBackground: 

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

665 bg += self.config.adjustBackground 

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

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

668 self.background.config.undersampleStyle) 

669 

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

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

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

673 actrl.getOrderY(), actrl.getWeighting())) 

674 return bg 

675 

676 def clearUnwantedResults(self, mask, results): 

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

678 

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

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

681 

682 Parameters 

683 ---------- 

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

685 Mask image. 

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

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

688 modified. 

689 """ 

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

691 if self.config.reEstimateBackground: 

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

693 results.negative = None 

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

695 if self.config.reEstimateBackground: 

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

697 results.positive = None 

698 

699 @timeMethod 

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

701 """Detect footprints on an exposure. 

702 

703 Parameters 

704 ---------- 

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

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

707 set in-place. 

708 doSmooth : `bool`, optional 

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

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

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

712 plane. 

713 sigma : `float`, optional 

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

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

716 ``exposure``. 

717 clearMask : `bool`, optional 

718 Clear both DETECTED and DETECTED_NEGATIVE planes before running 

719 detection. 

720 expId : `dict`, optional 

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

722 RNG seed by subclasses. 

723 

724 Returns 

725 ------- 

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

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

728 

729 ``positive`` 

730 Positive polarity footprints. 

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

732 ``negative`` 

733 Negative polarity footprints. 

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

735 ``numPos`` 

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

737 negative. (`int`) 

738 ``numNeg`` 

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

740 positive. (`int`) 

741 ``background`` 

742 Re-estimated background. `None` if 

743 ``reEstimateBackground==False``. 

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

745 ``factor`` 

746 Multiplication factor applied to the configured detection 

747 threshold. (`float`) 

748 """ 

749 maskedImage = exposure.maskedImage 

750 

751 if clearMask: 

752 self.clearMask(maskedImage.getMask()) 

753 

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

755 with self.tempWideBackgroundContext(exposure): 

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

757 middle = convolveResults.middle 

758 sigma = convolveResults.sigma 

759 

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

761 results.background = afwMath.BackgroundList() 

762 if self.config.doTempLocalBackground: 

763 self.applyTempLocalBackground(exposure, middle, results) 

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

765 

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

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

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

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

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

771 negative=True) 

772 

773 if self.config.reEstimateBackground: 

774 self.reEstimateBackground(maskedImage, results.background) 

775 

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

777 

778 self.display(exposure, results, middle) 

779 

780 return results 

781 

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

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

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

785 

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

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

788 well-defined meaning in those cases. 

789 

790 Parameters 

791 ---------- 

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

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

794 local background-subtracted image. 

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

796 Footprints detected on the image. 

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

798 Threshold used to find footprints. 

799 negative : `bool`, optional 

800 Are we calculating for negative sources? 

801 """ 

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

803 return footprints 

804 polarity = -1 if negative else 1 

805 

806 # All incoming footprints have the same schema. 

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

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

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

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

811 

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

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

814 # significance field. 

815 newFootprints = afwDet.FootprintSet(footprints) 

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

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

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

819 new.getPeaks().clear() 

820 new.setPeakCatalog(newPeaks) 

821 

822 # Compute the significance values. 

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

824 for footprint in newFootprints.getFootprints(): 

825 footprint.updatePeakSignificance(exposure.variance, polarity) 

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

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

828 for footprint in newFootprints.getFootprints(): 

829 footprint.updatePeakSignificance(polarity*sigma) 

830 else: 

831 for footprint in newFootprints.getFootprints(): 

832 for peak in footprint.peaks: 

833 peak["significance"] = 0 

834 

835 return newFootprints 

836 

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

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

839 configuration and the statistics of the given image. 

840 

841 Parameters 

842 ---------- 

843 image : `afw.image.MaskedImage` 

844 Image to measure noise statistics from if needed. 

845 thresholdParity: `str` 

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

847 the Threshold will detect. 

848 factor : `float` 

849 Factor by which to multiply the configured detection threshold. 

850 This is useful for tweaking the detection threshold slightly. 

851 

852 Returns 

853 ------- 

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

855 Detection threshold. 

856 """ 

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

858 thresholdValue = self.config.thresholdValue 

859 thresholdType = self.config.thresholdType 

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

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

862 sctrl = afwMath.StatisticsControl() 

863 sctrl.setAndMask(bad) 

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

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

866 thresholdType = 'value' 

867 

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

869 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier) 

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

871 return threshold 

872 

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

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

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

876 

877 Parameters 

878 ---------- 

879 fpSet : `afw.detection.FootprintSet` 

880 Set of Footprints whose Peaks should be updated. 

881 image : `afw.image.MaskedImage` 

882 Image to detect new Footprints and Peak in. 

883 threshold : `afw.detection.Threshold` 

884 Threshold object for detection. 

885 

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

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

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

889 """ 

890 for footprint in fpSet.getFootprints(): 

891 oldPeaks = footprint.getPeaks() 

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

893 continue 

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

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

896 # Footprints. 

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

898 fpSetForPeaks = afwDet.FootprintSet( 

899 sub, 

900 threshold, 

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

902 self.config.minPixels 

903 ) 

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

905 for fpForPeaks in fpSetForPeaks.getFootprints(): 

906 for peak in fpForPeaks.getPeaks(): 

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

908 newPeaks.append(peak) 

909 if len(newPeaks) > 0: 

910 del oldPeaks[:] 

911 oldPeaks.extend(newPeaks) 

912 else: 

913 del oldPeaks[1:] 

914 

915 @staticmethod 

916 def setEdgeBits(maskedImage, goodBBox, edgeBitmask): 

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

918 

919 Parameters 

920 ---------- 

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

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

923 goodBBox : `lsst.geom.Box2I` 

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

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

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

927 outside ``goodBBox``. 

928 """ 

929 msk = maskedImage.getMask() 

930 

931 mx0, my0 = maskedImage.getXY0() 

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

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

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

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

936 [0, 0, 

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

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

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

940 ): 

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

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

943 edgeMask |= edgeBitmask 

944 

945 @contextmanager 

946 def tempWideBackgroundContext(self, exposure): 

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

948 

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

950 detection of large footprints that may overwhelm the deblender. 

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

952 

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

954 the context manager. 

955 

956 Parameters 

957 ---------- 

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

959 Exposure on which to remove large-scale background. 

960 

961 Returns 

962 ------- 

963 context : context manager 

964 Context manager that will ensure the temporary wide background 

965 is restored. 

966 """ 

967 doTempWideBackground = self.config.doTempWideBackground 

968 if doTempWideBackground: 

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

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

971 self.tempWideBackground.run(exposure).background 

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

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

974 image = exposure.maskedImage.image 

975 mask = exposure.maskedImage.mask 

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

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

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

979 try: 

980 yield 

981 finally: 

982 if doTempWideBackground: 

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

984 

985 

986def addExposures(exposureList): 

987 """Add a set of exposures together. 

988 

989 Parameters 

990 ---------- 

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

992 Sequence of exposures to add. 

993 

994 Returns 

995 ------- 

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

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

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

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

1000 """ 

1001 exposure0 = exposureList[0] 

1002 image0 = exposure0.getMaskedImage() 

1003 

1004 addedImage = image0.Factory(image0, True) 

1005 addedImage.setXY0(image0.getXY0()) 

1006 

1007 for exposure in exposureList[1:]: 

1008 image = exposure.getMaskedImage() 

1009 addedImage += image 

1010 

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

1012 return addedExposure