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

286 statements  

« prev     ^ index     » next       coverage.py v6.4.1, created at 2022-07-11 07:09 +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 .subtractBackground import SubtractBackgroundTask 

40 

41 

42class SourceDetectionConfig(pexConfig.Config): 

43 """Configuration parameters for the SourceDetectionTask 

44 """ 

45 minPixels = pexConfig.RangeField( 

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

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

48 ) 

49 isotropicGrow = pexConfig.Field( 

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

51 dtype=bool, optional=False, default=False, 

52 ) 

53 combinedGrow = pexConfig.Field( 

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

55 dtype=bool, default=True, 

56 ) 

57 nSigmaToGrow = pexConfig.Field( 

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

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

60 ) 

61 returnOriginalFootprints = pexConfig.Field( 

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

63 dtype=bool, optional=False, default=False, 

64 ) 

65 thresholdValue = pexConfig.RangeField( 

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

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

68 ) 

69 includeThresholdMultiplier = pexConfig.RangeField( 

70 doc="Include threshold relative to thresholdValue", 

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

72 ) 

73 thresholdType = pexConfig.ChoiceField( 

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

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

76 allowed={ 

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

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

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

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

81 }, 

82 ) 

83 thresholdPolarity = pexConfig.ChoiceField( 

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

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

86 allowed={ 

87 "positive": "detect only positive sources", 

88 "negative": "detect only negative sources", 

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

90 }, 

91 ) 

92 adjustBackground = pexConfig.Field( 

93 dtype=float, 

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

95 default=0.0, 

96 ) 

97 reEstimateBackground = pexConfig.Field( 

98 dtype=bool, 

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

100 default=True, optional=False, 

101 ) 

102 background = pexConfig.ConfigurableField( 

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

104 target=SubtractBackgroundTask, 

105 ) 

106 tempLocalBackground = pexConfig.ConfigurableField( 

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

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

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

110 target=SubtractBackgroundTask, 

111 ) 

112 doTempLocalBackground = pexConfig.Field( 

113 dtype=bool, 

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

115 default=True, 

116 ) 

117 tempWideBackground = pexConfig.ConfigurableField( 

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

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

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

121 target=SubtractBackgroundTask, 

122 ) 

123 doTempWideBackground = pexConfig.Field( 

124 dtype=bool, 

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

126 default=False, 

127 ) 

128 nPeaksMaxSimple = pexConfig.Field( 

129 dtype=int, 

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

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

132 default=1, 

133 ) 

134 nSigmaForKernel = pexConfig.Field( 

135 dtype=float, 

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

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

138 default=7.0, 

139 ) 

140 statsMask = pexConfig.ListField( 

141 dtype=str, 

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

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

144 ) 

145 

146 def setDefaults(self): 

147 self.tempLocalBackground.binSize = 64 

148 self.tempLocalBackground.algorithm = "AKIMA_SPLINE" 

149 self.tempLocalBackground.useApprox = False 

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

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

152 self.tempWideBackground.binSize = 512 

153 self.tempWideBackground.algorithm = "AKIMA_SPLINE" 

154 self.tempWideBackground.useApprox = False 

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

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

157 if maskPlane in self.tempWideBackground.ignoredPixelMask: 

158 self.tempWideBackground.ignoredPixelMask.remove(maskPlane) 

159 

160 

161class SourceDetectionTask(pipeBase.Task): 

162 """Create the detection task. Most arguments are simply passed onto pipe.base.Task. 

163 

164 Parameters 

165 ---------- 

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

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

168 **kwds 

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

170 

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

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

173 negative threshold. 

174 

175 Notes 

176 ----- 

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

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

179 """ 

180 

181 ConfigClass = SourceDetectionConfig 

182 _DefaultName = "sourceDetection" 

183 

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

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

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

187 self.negativeFlagKey = schema.addField( 

188 "flags_negative", type="Flag", 

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

190 ) 

191 else: 

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

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

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

195 self.negativeFlagKey = None 

196 if self.config.reEstimateBackground: 

197 self.makeSubtask("background") 

198 if self.config.doTempLocalBackground: 

199 self.makeSubtask("tempLocalBackground") 

200 if self.config.doTempWideBackground: 

201 self.makeSubtask("tempWideBackground") 

202 

203 @pipeBase.timeMethod 

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

205 """Run source detection and create a SourceCatalog of detections. 

206 

207 Parameters 

208 ---------- 

209 table : `lsst.afw.table.SourceTable` 

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

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

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

213 doSmooth : `bool` 

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

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

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

217 sigma : `float` 

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

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

220 clearMask : `bool` 

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

222 expId : `int` 

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

224 RNG seed by subclasses. 

225 

226 Returns 

227 ------- 

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

229 ``sources`` 

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

231 ``fpSets`` 

232 The result resturned by `detectFootprints` 

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

234 

235 Raises 

236 ------ 

237 ValueError 

238 If flags.negative is needed, but isn't in table's schema. 

239 lsst.pipe.base.TaskError 

240 If sigma=None, doSmooth=True and the exposure has no PSF. 

241 

242 Notes 

243 ----- 

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

245 detectFootprints() to just get the `lsst.afw.detection.FootprintSet`s. 

246 """ 

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

248 raise ValueError("Table has incorrect Schema") 

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

250 clearMask=clearMask, expId=expId) 

251 sources = afwTable.SourceCatalog(table) 

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

253 if results.negative: 

254 results.negative.makeSources(sources) 

255 if self.negativeFlagKey: 

256 for record in sources: 

257 record.set(self.negativeFlagKey, True) 

258 if results.positive: 

259 results.positive.makeSources(sources) 

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

261 results.sources = sources 

262 return results 

263 

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

265 """Display detections if so configured 

266 

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

268 

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

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

271 

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

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

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

275 

276 Parameters 

277 ---------- 

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

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

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

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

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

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

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

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

286 Convolved image used for thresholding. 

287 """ 

288 try: 

289 import lsstDebug 

290 display = lsstDebug.Info(__name__).display 

291 except ImportError: 

292 try: 

293 display 

294 except NameError: 

295 display = False 

296 if not display: 

297 return 

298 

299 afwDisplay.setDefaultMaskTransparency(75) 

300 

301 disp0 = afwDisplay.Display(frame=0) 

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

303 

304 def plotPeaks(fps, ctype): 

305 if fps is None: 

306 return 

307 with disp0.Buffering(): 

308 for fp in fps.getFootprints(): 

309 for pp in fp.getPeaks(): 

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

311 plotPeaks(results.positive, "yellow") 

312 plotPeaks(results.negative, "red") 

313 

314 if convolvedImage and display > 1: 

315 disp1 = afwDisplay.Display(frame=1) 

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

317 

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

319 """Apply a temporary local background subtraction 

320 

321 This temporary local background serves to suppress noise fluctuations 

322 in the wings of bright objects. 

323 

324 Peaks in the footprints will be updated. 

325 

326 Parameters 

327 ---------- 

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

329 Exposure for which to fit local background. 

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

331 Convolved image on which detection will be performed 

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

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

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

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

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

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

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

339 """ 

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

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

342 # it back in. 

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

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

345 self.tempLocalBackground.config.undersampleStyle) 

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

347 thresholdPos = self.makeThreshold(middle, "positive") 

348 thresholdNeg = self.makeThreshold(middle, "negative") 

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

350 self.updatePeaks(results.positive, middle, thresholdPos) 

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

352 self.updatePeaks(results.negative, middle, thresholdNeg) 

353 

354 def clearMask(self, mask): 

355 """Clear the DETECTED and DETECTED_NEGATIVE mask planes 

356 

357 Removes any previous detection mask in preparation for a new 

358 detection pass. 

359 

360 Parameters 

361 ---------- 

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

363 Mask to be cleared. 

364 """ 

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

366 

367 def calculateKernelSize(self, sigma): 

368 """Calculate size of smoothing kernel 

369 

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

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

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

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

374 

375 Parameters 

376 ---------- 

377 sigma : `float` 

378 Gaussian sigma of smoothing kernel. 

379 

380 Returns 

381 ------- 

382 size : `int` 

383 Size of the smoothing kernel. 

384 """ 

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

386 

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

388 """Retrieve the PSF for an exposure 

389 

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

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

392 

393 Parameters 

394 ---------- 

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

396 Exposure from which to retrieve the PSF. 

397 sigma : `float`, optional 

398 Gaussian sigma to use if provided. 

399 

400 Returns 

401 ------- 

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

403 PSF to use for detection. 

404 """ 

405 if sigma is None: 

406 psf = exposure.getPsf() 

407 if psf is None: 

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

409 sigma = psf.computeShape().getDeterminantRadius() 

410 size = self.calculateKernelSize(sigma) 

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

412 return psf 

413 

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

415 """Convolve the image with the PSF 

416 

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

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

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

420 Gaussian there's no difference. 

421 

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

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

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

425 because the kernel would extend off the image. 

426 

427 Parameters 

428 ---------- 

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

430 Image to convolve. 

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

432 PSF to convolve with (actually with a Gaussian approximation 

433 to it). 

434 doSmooth : `bool` 

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

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

437 

438 Return Struct contents 

439 ---------------------- 

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

441 Convolved image, without the edges. 

442 sigma : `float` 

443 Gaussian sigma used for the convolution. 

444 """ 

445 self.metadata.set("doSmooth", doSmooth) 

446 sigma = psf.computeShape().getDeterminantRadius() 

447 self.metadata.set("sigma", sigma) 

448 

449 if not doSmooth: 

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

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

452 

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

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

455 kWidth = self.calculateKernelSize(sigma) 

456 self.metadata.set("smoothingKernelWidth", kWidth) 

457 gaussFunc = afwMath.GaussianFunction1D(sigma) 

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

459 

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

461 

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

463 # 

464 # Only search psf-smoothed part of frame 

465 # 

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

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

468 # 

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

470 # 

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

472 

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

474 

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

476 """Apply thresholds to the convolved image 

477 

478 Identifies ``Footprint``s, both positive and negative. 

479 

480 The threshold can be modified by the provided multiplication 

481 ``factor``. 

482 

483 Parameters 

484 ---------- 

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

486 Convolved image to threshold. 

487 bbox : `lsst.geom.Box2I` 

488 Bounding box of unconvolved image. 

489 factor : `float` 

490 Multiplier for the configured threshold. 

491 

492 Return Struct contents 

493 ---------------------- 

494 positive : `lsst.afw.detection.FootprintSet` or `None` 

495 Positive detection footprints, if configured. 

496 negative : `lsst.afw.detection.FootprintSet` or `None` 

497 Negative detection footprints, if configured. 

498 factor : `float` 

499 Multiplier for the configured threshold. 

500 """ 

501 results = pipeBase.Struct(positive=None, negative=None, factor=factor) 

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

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

504 threshold = self.makeThreshold(middle, "positive", factor=factor) 

505 results.positive = afwDet.FootprintSet( 

506 middle, 

507 threshold, 

508 "DETECTED", 

509 self.config.minPixels 

510 ) 

511 results.positive.setRegion(bbox) 

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

513 threshold = self.makeThreshold(middle, "negative", factor=factor) 

514 results.negative = afwDet.FootprintSet( 

515 middle, 

516 threshold, 

517 "DETECTED_NEGATIVE", 

518 self.config.minPixels 

519 ) 

520 results.negative.setRegion(bbox) 

521 

522 return results 

523 

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

525 """Finalize the detected footprints 

526 

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

528 mask planes, and logs the results. 

529 

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

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

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

533 detection results. 

534 

535 Parameters 

536 ---------- 

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

538 Mask image on which to flag detected pixels. 

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

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

541 ``negative`` entries; modified. 

542 sigma : `float` 

543 Gaussian sigma of PSF. 

544 factor : `float` 

545 Multiplier for the configured threshold. 

546 """ 

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

548 fpSet = getattr(results, polarity) 

549 if fpSet is None: 

550 continue 

551 if self.config.nSigmaToGrow > 0: 

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

553 self.metadata.set("nGrow", nGrow) 

554 if self.config.combinedGrow: 

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

556 else: 

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

558 afwGeom.Stencil.MANHATTAN) 

559 for fp in fpSet: 

560 fp.dilate(nGrow, stencil) 

561 fpSet.setMask(mask, maskName) 

562 if not self.config.returnOriginalFootprints: 

563 setattr(results, polarity, fpSet) 

564 

565 results.numPos = 0 

566 results.numPosPeaks = 0 

567 results.numNeg = 0 

568 results.numNegPeaks = 0 

569 positive = "" 

570 negative = "" 

571 

572 if results.positive is not None: 

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

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

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

576 if results.negative is not None: 

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

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

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

580 

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

582 positive, " and" if positive and negative else "", negative, 

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

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

585 

586 def reEstimateBackground(self, maskedImage, backgrounds): 

587 """Estimate the background after detection 

588 

589 Parameters 

590 ---------- 

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

592 Image on which to estimate the background. 

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

594 List of backgrounds; modified. 

595 

596 Returns 

597 ------- 

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

599 Empirical background model. 

600 """ 

601 bg = self.background.fitBackground(maskedImage) 

602 if self.config.adjustBackground: 

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

604 bg += self.config.adjustBackground 

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

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

607 self.background.config.undersampleStyle) 

608 

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

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

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

612 actrl.getOrderY(), actrl.getWeighting())) 

613 return bg 

614 

615 def clearUnwantedResults(self, mask, results): 

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

617 

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

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

620 

621 Parameters 

622 ---------- 

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

624 Mask image. 

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

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

627 modified. 

628 """ 

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

630 if self.config.reEstimateBackground: 

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

632 results.negative = None 

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

634 if self.config.reEstimateBackground: 

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

636 results.positive = None 

637 

638 @pipeBase.timeMethod 

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

640 """Detect footprints on an exposure. 

641 

642 Parameters 

643 ---------- 

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

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

646 set in-place. 

647 doSmooth : `bool`, optional 

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

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

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

651 plane. 

652 sigma : `float`, optional 

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

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

655 ``exposure``. 

656 clearMask : `bool`, optional 

657 Clear both DETECTED and DETECTED_NEGATIVE planes before running 

658 detection. 

659 expId : `dict`, optional 

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

661 RNG seed by subclasses. 

662 

663 Return Struct contents 

664 ---------------------- 

665 positive : `lsst.afw.detection.FootprintSet` 

666 Positive polarity footprints (may be `None`) 

667 negative : `lsst.afw.detection.FootprintSet` 

668 Negative polarity footprints (may be `None`) 

669 numPos : `int` 

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

671 negative. 

672 numNeg : `int` 

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

674 positive. 

675 background : `lsst.afw.math.BackgroundList` 

676 Re-estimated background. `None` if 

677 ``reEstimateBackground==False``. 

678 factor : `float` 

679 Multiplication factor applied to the configured detection 

680 threshold. 

681 """ 

682 maskedImage = exposure.maskedImage 

683 

684 if clearMask: 

685 self.clearMask(maskedImage.getMask()) 

686 

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

688 with self.tempWideBackgroundContext(exposure): 

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

690 middle = convolveResults.middle 

691 sigma = convolveResults.sigma 

692 

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

694 results.background = afwMath.BackgroundList() 

695 if self.config.doTempLocalBackground: 

696 self.applyTempLocalBackground(exposure, middle, results) 

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

698 

699 if self.config.reEstimateBackground: 

700 self.reEstimateBackground(maskedImage, results.background) 

701 

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

703 self.display(exposure, results, middle) 

704 

705 return results 

706 

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

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

709 configuration and the statistics of the given image. 

710 

711 Parameters 

712 ---------- 

713 image : `afw.image.MaskedImage` 

714 Image to measure noise statistics from if needed. 

715 thresholdParity: `str` 

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

717 the Threshold will detect. 

718 factor : `float` 

719 Factor by which to multiply the configured detection threshold. 

720 This is useful for tweaking the detection threshold slightly. 

721 

722 Returns 

723 ------- 

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

725 Detection threshold. 

726 """ 

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

728 thresholdValue = self.config.thresholdValue 

729 thresholdType = self.config.thresholdType 

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

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

732 sctrl = afwMath.StatisticsControl() 

733 sctrl.setAndMask(bad) 

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

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

736 thresholdType = 'value' 

737 

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

739 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier) 

740 return threshold 

741 

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

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

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

745 

746 Parameters 

747 ---------- 

748 fpSet : `afw.detection.FootprintSet` 

749 Set of Footprints whose Peaks should be updated. 

750 image : `afw.image.MaskedImage` 

751 Image to detect new Footprints and Peak in. 

752 threshold : `afw.detection.Threshold` 

753 Threshold object for detection. 

754 

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

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

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

758 """ 

759 for footprint in fpSet.getFootprints(): 

760 oldPeaks = footprint.getPeaks() 

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

762 continue 

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

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

765 # Footprints. 

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

767 fpSetForPeaks = afwDet.FootprintSet( 

768 sub, 

769 threshold, 

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

771 self.config.minPixels 

772 ) 

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

774 for fpForPeaks in fpSetForPeaks.getFootprints(): 

775 for peak in fpForPeaks.getPeaks(): 

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

777 newPeaks.append(peak) 

778 if len(newPeaks) > 0: 

779 del oldPeaks[:] 

780 oldPeaks.extend(newPeaks) 

781 else: 

782 del oldPeaks[1:] 

783 

784 @staticmethod 

785 def setEdgeBits(maskedImage, goodBBox, edgeBitmask): 

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

787 

788 Parameters 

789 ---------- 

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

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

792 goodBBox : `lsst.geom.Box2I` 

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

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

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

796 outside ``goodBBox``. 

797 """ 

798 msk = maskedImage.getMask() 

799 

800 mx0, my0 = maskedImage.getXY0() 

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

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

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

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

805 [0, 0, 

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

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

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

809 ): 

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

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

812 edgeMask |= edgeBitmask 

813 

814 @contextmanager 

815 def tempWideBackgroundContext(self, exposure): 

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

817 

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

819 detection of large footprints that may overwhelm the deblender. 

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

821 

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

823 the context manager. 

824 

825 Parameters 

826 ---------- 

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

828 Exposure on which to remove large-scale background. 

829 

830 Returns 

831 ------- 

832 context : context manager 

833 Context manager that will ensure the temporary wide background 

834 is restored. 

835 """ 

836 doTempWideBackground = self.config.doTempWideBackground 

837 if doTempWideBackground: 

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

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

840 self.tempWideBackground.run(exposure).background 

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

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

843 image = exposure.maskedImage.image 

844 mask = exposure.maskedImage.mask 

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

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

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

848 try: 

849 yield 

850 finally: 

851 if doTempWideBackground: 

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

853 

854 

855def addExposures(exposureList): 

856 """Add a set of exposures together. 

857 

858 Parameters 

859 ---------- 

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

861 Sequence of exposures to add. 

862 

863 Returns 

864 ------- 

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

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

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

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

869 """ 

870 exposure0 = exposureList[0] 

871 image0 = exposure0.getMaskedImage() 

872 

873 addedImage = image0.Factory(image0, True) 

874 addedImage.setXY0(image0.getXY0()) 

875 

876 for exposure in exposureList[1:]: 

877 image = exposure.getMaskedImage() 

878 addedImage += image 

879 

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

881 return addedExposure