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

325 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-06 10:39 +0000

1# 

2# LSST Data Management System 

3# 

4# Copyright 2008-2017 AURA/LSST. 

5# 

6# This product includes software developed by the 

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

8# 

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

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

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

22# 

23 

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

25 

26from contextlib import contextmanager 

27 

28import numpy as np 

29 

30import lsst.geom 

31import lsst.afw.display as afwDisplay 

32import lsst.afw.detection as afwDet 

33import lsst.afw.geom as afwGeom 

34import lsst.afw.image as afwImage 

35import lsst.afw.math as afwMath 

36import lsst.afw.table as afwTable 

37import lsst.pex.config as pexConfig 

38import lsst.pipe.base as pipeBase 

39from lsst.utils.timer import timeMethod 

40from .subtractBackground import SubtractBackgroundTask 

41 

42 

43class SourceDetectionConfig(pexConfig.Config): 

44 """Configuration parameters for the SourceDetectionTask 

45 """ 

46 minPixels = pexConfig.RangeField( 

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

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

49 ) 

50 isotropicGrow = pexConfig.Field( 

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

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

53 ) 

54 combinedGrow = pexConfig.Field( 

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

56 dtype=bool, default=True, 

57 ) 

58 nSigmaToGrow = pexConfig.Field( 

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

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

61 ) 

62 returnOriginalFootprints = pexConfig.Field( 

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

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

65 ) 

66 thresholdValue = pexConfig.RangeField( 

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

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

69 ) 

70 includeThresholdMultiplier = pexConfig.RangeField( 

71 doc="Include threshold relative to thresholdValue", 

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

73 ) 

74 thresholdType = pexConfig.ChoiceField( 

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

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

77 allowed={ 

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

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

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

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

82 }, 

83 ) 

84 thresholdPolarity = pexConfig.ChoiceField( 

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

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

87 allowed={ 

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

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

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

91 }, 

92 ) 

93 adjustBackground = pexConfig.Field( 

94 dtype=float, 

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

96 default=0.0, 

97 ) 

98 reEstimateBackground = pexConfig.Field( 

99 dtype=bool, 

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

101 default=True, optional=False, 

102 ) 

103 background = pexConfig.ConfigurableField( 

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

105 target=SubtractBackgroundTask, 

106 ) 

107 tempLocalBackground = pexConfig.ConfigurableField( 

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

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

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

111 target=SubtractBackgroundTask, 

112 ) 

113 doTempLocalBackground = pexConfig.Field( 

114 dtype=bool, 

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

116 default=True, 

117 ) 

118 tempWideBackground = pexConfig.ConfigurableField( 

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

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

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

122 target=SubtractBackgroundTask, 

123 ) 

124 doTempWideBackground = pexConfig.Field( 

125 dtype=bool, 

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

127 default=False, 

128 ) 

129 nPeaksMaxSimple = pexConfig.Field( 

130 dtype=int, 

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

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

133 default=1, 

134 ) 

135 nSigmaForKernel = pexConfig.Field( 

136 dtype=float, 

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

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

139 default=7.0, 

140 ) 

141 statsMask = pexConfig.ListField( 

142 dtype=str, 

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

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

145 ) 

146 excludeMaskPlanes = lsst.pex.config.ListField( 

147 dtype=str, 

148 default=[], 

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

150 ) 

151 

152 def setDefaults(self): 

153 self.tempLocalBackground.binSize = 64 

154 self.tempLocalBackground.algorithm = "AKIMA_SPLINE" 

155 self.tempLocalBackground.useApprox = False 

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

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

158 self.tempWideBackground.binSize = 512 

159 self.tempWideBackground.algorithm = "AKIMA_SPLINE" 

160 self.tempWideBackground.useApprox = False 

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

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

163 if maskPlane in self.tempWideBackground.ignoredPixelMask: 

164 self.tempWideBackground.ignoredPixelMask.remove(maskPlane) 

165 

166 

167class SourceDetectionTask(pipeBase.Task): 

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

169 

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

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

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

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

174 detected sources. 

175 

176 Parameters 

177 ---------- 

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

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

180 **kwds 

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

182 

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

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

185 negative threshold. 

186 

187 Notes 

188 ----- 

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

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

191 """ 

192 ConfigClass = SourceDetectionConfig 

193 _DefaultName = "sourceDetection" 

194 

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

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

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

198 self.negativeFlagKey = schema.addField( 

199 "flags_negative", type="Flag", 

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

201 ) 

202 else: 

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

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

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

206 self.negativeFlagKey = None 

207 if self.config.reEstimateBackground: 

208 self.makeSubtask("background") 

209 if self.config.doTempLocalBackground: 

210 self.makeSubtask("tempLocalBackground") 

211 if self.config.doTempWideBackground: 

212 self.makeSubtask("tempWideBackground") 

213 

214 @timeMethod 

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

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

217 

218 Parameters 

219 ---------- 

220 table : `lsst.afw.table.SourceTable` 

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

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

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

224 doSmooth : `bool` 

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

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

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

228 sigma : `float` 

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

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

231 clearMask : `bool` 

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

233 expId : `int` 

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

235 RNG seed by subclasses. 

236 

237 Returns 

238 ------- 

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

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

241 

242 ``sources`` 

243 Detected sources on the exposure. 

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

245 ``positive`` 

246 Positive polarity footprints. 

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

248 ``negative`` 

249 Negative polarity footprints. 

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

251 ``numPos`` 

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

253 negative. (`int`) 

254 ``numNeg`` 

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

256 positive. (`int`) 

257 ``background`` 

258 Re-estimated background. `None` if 

259 ``reEstimateBackground==False``. 

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

261 ``factor`` 

262 Multiplication factor applied to the configured detection 

263 threshold. (`float`) 

264 

265 Raises 

266 ------ 

267 ValueError 

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

269 lsst.pipe.base.TaskError 

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

271 

272 Notes 

273 ----- 

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

275 `detectFootprints()` to just get the 

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

277 """ 

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

279 raise ValueError("Table has incorrect Schema") 

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

281 clearMask=clearMask, expId=expId) 

282 sources = afwTable.SourceCatalog(table) 

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

284 if results.negative: 

285 results.negative.makeSources(sources) 

286 if self.negativeFlagKey: 

287 for record in sources: 

288 record.set(self.negativeFlagKey, True) 

289 if results.positive: 

290 results.positive.makeSources(sources) 

291 results.sources = sources 

292 return results 

293 

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

295 """Display detections if so configured 

296 

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

298 

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

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

301 

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

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

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

305 

306 Parameters 

307 ---------- 

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

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

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

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

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

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

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

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

316 Convolved image used for thresholding. 

317 """ 

318 try: 

319 import lsstDebug 

320 display = lsstDebug.Info(__name__).display 

321 except ImportError: 

322 try: 

323 display 

324 except NameError: 

325 display = False 

326 if not display: 

327 return 

328 

329 afwDisplay.setDefaultMaskTransparency(75) 

330 

331 disp0 = afwDisplay.Display(frame=0) 

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

333 

334 def plotPeaks(fps, ctype): 

335 if fps is None: 

336 return 

337 with disp0.Buffering(): 

338 for fp in fps.getFootprints(): 

339 for pp in fp.getPeaks(): 

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

341 plotPeaks(results.positive, "yellow") 

342 plotPeaks(results.negative, "red") 

343 

344 if convolvedImage and display > 1: 

345 disp1 = afwDisplay.Display(frame=1) 

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

347 

348 disp2 = afwDisplay.Display(frame=2) 

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

350 

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

352 """Apply a temporary local background subtraction 

353 

354 This temporary local background serves to suppress noise fluctuations 

355 in the wings of bright objects. 

356 

357 Peaks in the footprints will be updated. 

358 

359 Parameters 

360 ---------- 

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

362 Exposure for which to fit local background. 

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

364 Convolved image on which detection will be performed 

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

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

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

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

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

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

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

372 """ 

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

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

375 # it back in. 

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

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

378 self.tempLocalBackground.config.undersampleStyle) 

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

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

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

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

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

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

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

386 

387 def clearMask(self, mask): 

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

389 

390 Removes any previous detection mask in preparation for a new 

391 detection pass. 

392 

393 Parameters 

394 ---------- 

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

396 Mask to be cleared. 

397 """ 

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

399 

400 def calculateKernelSize(self, sigma): 

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

402 

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

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

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

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

407 

408 Parameters 

409 ---------- 

410 sigma : `float` 

411 Gaussian sigma of smoothing kernel. 

412 

413 Returns 

414 ------- 

415 size : `int` 

416 Size of the smoothing kernel. 

417 """ 

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

419 

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

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

422 

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

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

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

426 

427 Parameters 

428 ---------- 

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

430 Exposure from which to retrieve the PSF. 

431 sigma : `float`, optional 

432 Gaussian sigma to use if provided. 

433 

434 Returns 

435 ------- 

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

437 PSF to use for detection. 

438 

439 Raises 

440 ------ 

441 RuntimeError 

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

443 contain a ``Psf`` object. 

444 """ 

445 if sigma is None: 

446 psf = exposure.getPsf() 

447 if psf is None: 

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

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

450 size = self.calculateKernelSize(sigma) 

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

452 return psf 

453 

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

455 """Convolve the image with the PSF. 

456 

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

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

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

460 Gaussian there's no difference. 

461 

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

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

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

465 because the kernel would extend off the image. 

466 

467 Parameters 

468 ---------- 

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

470 Image to convolve. 

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

472 PSF to convolve with (actually with a Gaussian approximation 

473 to it). 

474 doSmooth : `bool` 

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

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

477 

478 Returns 

479 ------- 

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

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

482 

483 ``middle`` 

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

485 ``sigma`` 

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

487 """ 

488 self.metadata["doSmooth"] = doSmooth 

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

490 self.metadata["sigma"] = sigma 

491 

492 if not doSmooth: 

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

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

495 

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

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

498 kWidth = self.calculateKernelSize(sigma) 

499 self.metadata["smoothingKernelWidth"] = kWidth 

500 gaussFunc = afwMath.GaussianFunction1D(sigma) 

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

502 

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

504 

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

506 

507 # Only search psf-smoothed part of frame 

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

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

510 

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

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

513 

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

515 

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

517 r"""Apply thresholds to the convolved image 

518 

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

520 The threshold can be modified by the provided multiplication 

521 ``factor``. 

522 

523 Parameters 

524 ---------- 

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

526 Convolved image to threshold. 

527 bbox : `lsst.geom.Box2I` 

528 Bounding box of unconvolved image. 

529 factor : `float` 

530 Multiplier for the configured threshold. 

531 factorNeg : `float` or `None` 

532 Multiplier for the configured threshold for negative detection polarity. 

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

534 for positive detection polarity). 

535 

536 Returns 

537 ------- 

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

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

540 

541 ``positive`` 

542 Positive detection footprints, if configured. 

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

544 ``negative`` 

545 Negative detection footprints, if configured. 

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

547 ``factor`` 

548 Multiplier for the configured threshold. 

549 (`float`) 

550 ``factorNeg`` 

551 Multiplier for the configured threshold for negative detection polarity. 

552 (`float`) 

553 """ 

554 if factorNeg is None: 

555 factorNeg = factor 

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

557 "detections: %f", factor) 

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

559 positiveThreshold=None, negativeThreshold=None) 

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

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

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

563 results.positive = afwDet.FootprintSet( 

564 middle, 

565 results.positiveThreshold, 

566 "DETECTED", 

567 self.config.minPixels 

568 ) 

569 results.positive.setRegion(bbox) 

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

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

572 results.negative = afwDet.FootprintSet( 

573 middle, 

574 results.negativeThreshold, 

575 "DETECTED_NEGATIVE", 

576 self.config.minPixels 

577 ) 

578 results.negative.setRegion(bbox) 

579 

580 return results 

581 

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

583 """Finalize the detected footprints. 

584 

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

586 mask planes, and log the results. 

587 

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

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

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

591 ``results`` struct. 

592 

593 Parameters 

594 ---------- 

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

596 Mask image on which to flag detected pixels. 

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

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

599 ``negative`` entries; modified. 

600 sigma : `float` 

601 Gaussian sigma of PSF. 

602 factor : `float` 

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

604 used here for logging purposes. 

605 factorNeg : `float` or `None` 

606 Multiplier used for the negative detection polarity threshold. 

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

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

609 used here for logging purposes. 

610 """ 

611 factorNeg = factor if factorNeg is None else factorNeg 

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

613 fpSet = getattr(results, polarity) 

614 if fpSet is None: 

615 continue 

616 if self.config.nSigmaToGrow > 0: 

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

618 self.metadata["nGrow"] = nGrow 

619 if self.config.combinedGrow: 

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

621 else: 

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

623 afwGeom.Stencil.MANHATTAN) 

624 for fp in fpSet: 

625 fp.dilate(nGrow, stencil) 

626 fpSet.setMask(mask, maskName) 

627 if not self.config.returnOriginalFootprints: 

628 setattr(results, polarity, fpSet) 

629 

630 results.numPos = 0 

631 results.numPosPeaks = 0 

632 results.numNeg = 0 

633 results.numNegPeaks = 0 

634 positive = "" 

635 negative = "" 

636 

637 if results.positive is not None: 

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

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

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

641 if results.negative is not None: 

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

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

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

645 

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

647 positive, " and" if positive and negative else "", negative, 

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

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

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

651 

652 def reEstimateBackground(self, maskedImage, backgrounds): 

653 """Estimate the background after detection 

654 

655 Parameters 

656 ---------- 

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

658 Image on which to estimate the background. 

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

660 List of backgrounds; modified. 

661 

662 Returns 

663 ------- 

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

665 Empirical background model. 

666 """ 

667 bg = self.background.fitBackground(maskedImage) 

668 if self.config.adjustBackground: 

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

670 bg += self.config.adjustBackground 

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

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

673 self.background.config.undersampleStyle) 

674 

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

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

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

678 actrl.getOrderY(), actrl.getWeighting())) 

679 return bg 

680 

681 def clearUnwantedResults(self, mask, results): 

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

683 

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

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

686 

687 Parameters 

688 ---------- 

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

690 Mask image. 

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

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

693 modified. 

694 """ 

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

696 if self.config.reEstimateBackground: 

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

698 results.negative = None 

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

700 if self.config.reEstimateBackground: 

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

702 results.positive = None 

703 

704 @timeMethod 

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

706 """Detect footprints on an exposure. 

707 

708 Parameters 

709 ---------- 

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

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

712 set in-place. 

713 doSmooth : `bool`, optional 

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

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

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

717 plane. 

718 sigma : `float`, optional 

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

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

721 ``exposure``. 

722 clearMask : `bool`, optional 

723 Clear both DETECTED and DETECTED_NEGATIVE planes before running 

724 detection. 

725 expId : `dict`, optional 

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

727 RNG seed by subclasses. 

728 

729 Returns 

730 ------- 

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

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

733 

734 ``positive`` 

735 Positive polarity footprints. 

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

737 ``negative`` 

738 Negative polarity footprints. 

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

740 ``numPos`` 

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

742 negative. (`int`) 

743 ``numNeg`` 

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

745 positive. (`int`) 

746 ``background`` 

747 Re-estimated background. `None` if 

748 ``reEstimateBackground==False``. 

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

750 ``factor`` 

751 Multiplication factor applied to the configured detection 

752 threshold. (`float`) 

753 """ 

754 maskedImage = exposure.maskedImage 

755 

756 if clearMask: 

757 self.clearMask(maskedImage.getMask()) 

758 

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

760 with self.tempWideBackgroundContext(exposure): 

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

762 middle = convolveResults.middle 

763 sigma = convolveResults.sigma 

764 self.removeBadPixels(middle) 

765 

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

767 results.background = afwMath.BackgroundList() 

768 if self.config.doTempLocalBackground: 

769 self.applyTempLocalBackground(exposure, middle, results) 

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

771 

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

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

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

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

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

777 negative=True) 

778 

779 if self.config.reEstimateBackground: 

780 self.reEstimateBackground(maskedImage, results.background) 

781 

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

783 

784 self.display(exposure, results, middle) 

785 

786 return results 

787 

788 def removeBadPixels(self, middle): 

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

790 

791 Parameters 

792 ---------- 

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

794 Score or maximum likelihood difference image. 

795 The image plane will be modified in place. 

796 """ 

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

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

799 middle.image.array[badPixels] = 0 

800 

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

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

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

804 

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

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

807 well-defined meaning in those cases. 

808 

809 Parameters 

810 ---------- 

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

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

813 local background-subtracted image. 

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

815 Footprints detected on the image. 

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

817 Threshold used to find footprints. 

818 negative : `bool`, optional 

819 Are we calculating for negative sources? 

820 """ 

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

822 return footprints 

823 polarity = -1 if negative else 1 

824 

825 # All incoming footprints have the same schema. 

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

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

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

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

830 

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

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

833 # significance field. 

834 newFootprints = afwDet.FootprintSet(footprints) 

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

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

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

838 new.getPeaks().clear() 

839 new.setPeakCatalog(newPeaks) 

840 

841 # Compute the significance values. 

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

843 for footprint in newFootprints.getFootprints(): 

844 footprint.updatePeakSignificance(exposure.variance, polarity) 

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

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

847 for footprint in newFootprints.getFootprints(): 

848 footprint.updatePeakSignificance(polarity*sigma) 

849 else: 

850 for footprint in newFootprints.getFootprints(): 

851 for peak in footprint.peaks: 

852 peak["significance"] = 0 

853 

854 return newFootprints 

855 

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

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

858 configuration and the statistics of the given image. 

859 

860 Parameters 

861 ---------- 

862 image : `afw.image.MaskedImage` 

863 Image to measure noise statistics from if needed. 

864 thresholdParity: `str` 

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

866 the Threshold will detect. 

867 factor : `float` 

868 Factor by which to multiply the configured detection threshold. 

869 This is useful for tweaking the detection threshold slightly. 

870 

871 Returns 

872 ------- 

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

874 Detection threshold. 

875 """ 

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

877 thresholdValue = self.config.thresholdValue 

878 thresholdType = self.config.thresholdType 

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

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

881 sctrl = afwMath.StatisticsControl() 

882 sctrl.setAndMask(bad) 

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

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

885 thresholdType = 'value' 

886 

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

888 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier) 

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

890 return threshold 

891 

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

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

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

895 

896 Parameters 

897 ---------- 

898 fpSet : `afw.detection.FootprintSet` 

899 Set of Footprints whose Peaks should be updated. 

900 image : `afw.image.MaskedImage` 

901 Image to detect new Footprints and Peak in. 

902 threshold : `afw.detection.Threshold` 

903 Threshold object for detection. 

904 

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

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

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

908 """ 

909 for footprint in fpSet.getFootprints(): 

910 oldPeaks = footprint.getPeaks() 

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

912 continue 

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

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

915 # Footprints. 

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

917 fpSetForPeaks = afwDet.FootprintSet( 

918 sub, 

919 threshold, 

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

921 self.config.minPixels 

922 ) 

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

924 for fpForPeaks in fpSetForPeaks.getFootprints(): 

925 for peak in fpForPeaks.getPeaks(): 

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

927 newPeaks.append(peak) 

928 if len(newPeaks) > 0: 

929 del oldPeaks[:] 

930 oldPeaks.extend(newPeaks) 

931 else: 

932 del oldPeaks[1:] 

933 

934 @staticmethod 

935 def setEdgeBits(maskedImage, goodBBox, edgeBitmask): 

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

937 

938 Parameters 

939 ---------- 

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

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

942 goodBBox : `lsst.geom.Box2I` 

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

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

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

946 outside ``goodBBox``. 

947 """ 

948 msk = maskedImage.getMask() 

949 

950 mx0, my0 = maskedImage.getXY0() 

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

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

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

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

955 [0, 0, 

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

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

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

959 ): 

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

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

962 edgeMask |= edgeBitmask 

963 

964 @contextmanager 

965 def tempWideBackgroundContext(self, exposure): 

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

967 

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

969 detection of large footprints that may overwhelm the deblender. 

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

971 

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

973 the context manager. 

974 

975 Parameters 

976 ---------- 

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

978 Exposure on which to remove large-scale background. 

979 

980 Returns 

981 ------- 

982 context : context manager 

983 Context manager that will ensure the temporary wide background 

984 is restored. 

985 """ 

986 doTempWideBackground = self.config.doTempWideBackground 

987 if doTempWideBackground: 

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

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

990 self.tempWideBackground.run(exposure).background 

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

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

993 image = exposure.maskedImage.image 

994 mask = exposure.maskedImage.mask 

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

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

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

998 try: 

999 yield 

1000 finally: 

1001 if doTempWideBackground: 

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

1003 

1004 

1005def addExposures(exposureList): 

1006 """Add a set of exposures together. 

1007 

1008 Parameters 

1009 ---------- 

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

1011 Sequence of exposures to add. 

1012 

1013 Returns 

1014 ------- 

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

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

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

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

1019 """ 

1020 exposure0 = exposureList[0] 

1021 image0 = exposure0.getMaskedImage() 

1022 

1023 addedImage = image0.Factory(image0, True) 

1024 addedImage.setXY0(image0.getXY0()) 

1025 

1026 for exposure in exposureList[1:]: 

1027 image = exposure.getMaskedImage() 

1028 addedImage += image 

1029 

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

1031 return addedExposure