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

Shortcuts on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

287 statements  

1# 

2# LSST Data Management System 

3# 

4# Copyright 2008-2017 AURA/LSST. 

5# 

6# This product includes software developed by the 

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

8# 

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

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

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

22# 

23 

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

25 

26from contextlib import contextmanager 

27 

28import numpy as np 

29 

30import lsst.geom 

31import lsst.afw.display as afwDisplay 

32import lsst.afw.detection as afwDet 

33import lsst.afw.geom as afwGeom 

34import lsst.afw.image as afwImage 

35import lsst.afw.math as afwMath 

36import lsst.afw.table as afwTable 

37import lsst.pex.config as pexConfig 

38import lsst.pipe.base as pipeBase 

39from lsst.utils.timer import timeMethod 

40from .subtractBackground import SubtractBackgroundTask 

41 

42 

43class SourceDetectionConfig(pexConfig.Config): 

44 """Configuration parameters for the SourceDetectionTask 

45 """ 

46 minPixels = pexConfig.RangeField( 

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

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

49 ) 

50 isotropicGrow = pexConfig.Field( 

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

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

53 ) 

54 combinedGrow = pexConfig.Field( 

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

56 dtype=bool, default=True, 

57 ) 

58 nSigmaToGrow = pexConfig.Field( 

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

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

61 ) 

62 returnOriginalFootprints = pexConfig.Field( 

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

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

65 ) 

66 thresholdValue = pexConfig.RangeField( 

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

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

69 ) 

70 includeThresholdMultiplier = pexConfig.RangeField( 

71 doc="Include threshold relative to thresholdValue", 

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

73 ) 

74 thresholdType = pexConfig.ChoiceField( 

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

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

77 allowed={ 

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

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

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

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

82 }, 

83 ) 

84 thresholdPolarity = pexConfig.ChoiceField( 

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

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

87 allowed={ 

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

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

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

91 }, 

92 ) 

93 adjustBackground = pexConfig.Field( 

94 dtype=float, 

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

96 default=0.0, 

97 ) 

98 reEstimateBackground = pexConfig.Field( 

99 dtype=bool, 

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

101 default=True, optional=False, 

102 ) 

103 background = pexConfig.ConfigurableField( 

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

105 target=SubtractBackgroundTask, 

106 ) 

107 tempLocalBackground = pexConfig.ConfigurableField( 

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

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

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

111 target=SubtractBackgroundTask, 

112 ) 

113 doTempLocalBackground = pexConfig.Field( 

114 dtype=bool, 

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

116 default=True, 

117 ) 

118 tempWideBackground = pexConfig.ConfigurableField( 

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

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

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

122 target=SubtractBackgroundTask, 

123 ) 

124 doTempWideBackground = pexConfig.Field( 

125 dtype=bool, 

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

127 default=False, 

128 ) 

129 nPeaksMaxSimple = pexConfig.Field( 

130 dtype=int, 

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

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

133 default=1, 

134 ) 

135 nSigmaForKernel = pexConfig.Field( 

136 dtype=float, 

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

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

139 default=7.0, 

140 ) 

141 statsMask = pexConfig.ListField( 

142 dtype=str, 

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

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

145 ) 

146 

147 def setDefaults(self): 

148 self.tempLocalBackground.binSize = 64 

149 self.tempLocalBackground.algorithm = "AKIMA_SPLINE" 

150 self.tempLocalBackground.useApprox = False 

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

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

153 self.tempWideBackground.binSize = 512 

154 self.tempWideBackground.algorithm = "AKIMA_SPLINE" 

155 self.tempWideBackground.useApprox = False 

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

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

158 if maskPlane in self.tempWideBackground.ignoredPixelMask: 

159 self.tempWideBackground.ignoredPixelMask.remove(maskPlane) 

160 

161 

162class SourceDetectionTask(pipeBase.Task): 

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

164 

165 Parameters 

166 ---------- 

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

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

169 **kwds 

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

171 

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

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

174 negative threshold. 

175 

176 Notes 

177 ----- 

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

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

180 """ 

181 

182 ConfigClass = SourceDetectionConfig 

183 _DefaultName = "sourceDetection" 

184 

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

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

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

188 self.negativeFlagKey = schema.addField( 

189 "flags_negative", type="Flag", 

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

191 ) 

192 else: 

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

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

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

196 self.negativeFlagKey = None 

197 if self.config.reEstimateBackground: 

198 self.makeSubtask("background") 

199 if self.config.doTempLocalBackground: 

200 self.makeSubtask("tempLocalBackground") 

201 if self.config.doTempWideBackground: 

202 self.makeSubtask("tempWideBackground") 

203 

204 @timeMethod 

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

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

207 

208 Parameters 

209 ---------- 

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

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

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

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

214 doSmooth : `bool` 

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

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

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

218 sigma : `float` 

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

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

221 clearMask : `bool` 

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

223 expId : `int` 

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

225 RNG seed by subclasses. 

226 

227 Returns 

228 ------- 

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

230 ``sources`` 

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

232 ``fpSets`` 

233 The result resturned by `detectFootprints` 

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

235 

236 Raises 

237 ------ 

238 ValueError 

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

240 lsst.pipe.base.TaskError 

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

242 

243 Notes 

244 ----- 

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

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

247 """ 

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

249 raise ValueError("Table has incorrect Schema") 

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

251 clearMask=clearMask, expId=expId) 

252 sources = afwTable.SourceCatalog(table) 

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

254 if results.negative: 

255 results.negative.makeSources(sources) 

256 if self.negativeFlagKey: 

257 for record in sources: 

258 record.set(self.negativeFlagKey, True) 

259 if results.positive: 

260 results.positive.makeSources(sources) 

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

262 results.sources = sources 

263 return results 

264 

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

266 """Display detections if so configured 

267 

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

269 

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

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

272 

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

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

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

276 

277 Parameters 

278 ---------- 

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

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

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

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

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

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

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

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

287 Convolved image used for thresholding. 

288 """ 

289 try: 

290 import lsstDebug 

291 display = lsstDebug.Info(__name__).display 

292 except ImportError: 

293 try: 

294 display 

295 except NameError: 

296 display = False 

297 if not display: 

298 return 

299 

300 afwDisplay.setDefaultMaskTransparency(75) 

301 

302 disp0 = afwDisplay.Display(frame=0) 

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

304 

305 def plotPeaks(fps, ctype): 

306 if fps is None: 

307 return 

308 with disp0.Buffering(): 

309 for fp in fps.getFootprints(): 

310 for pp in fp.getPeaks(): 

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

312 plotPeaks(results.positive, "yellow") 

313 plotPeaks(results.negative, "red") 

314 

315 if convolvedImage and display > 1: 

316 disp1 = afwDisplay.Display(frame=1) 

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

318 

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

320 """Apply a temporary local background subtraction 

321 

322 This temporary local background serves to suppress noise fluctuations 

323 in the wings of bright objects. 

324 

325 Peaks in the footprints will be updated. 

326 

327 Parameters 

328 ---------- 

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

330 Exposure for which to fit local background. 

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

332 Convolved image on which detection will be performed 

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

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

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

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

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

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

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

340 """ 

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

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

343 # it back in. 

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

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

346 self.tempLocalBackground.config.undersampleStyle) 

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

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

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

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

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

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

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

354 

355 def clearMask(self, mask): 

356 """Clear the DETECTED and DETECTED_NEGATIVE mask planes 

357 

358 Removes any previous detection mask in preparation for a new 

359 detection pass. 

360 

361 Parameters 

362 ---------- 

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

364 Mask to be cleared. 

365 """ 

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

367 

368 def calculateKernelSize(self, sigma): 

369 """Calculate size of smoothing kernel 

370 

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

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

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

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

375 

376 Parameters 

377 ---------- 

378 sigma : `float` 

379 Gaussian sigma of smoothing kernel. 

380 

381 Returns 

382 ------- 

383 size : `int` 

384 Size of the smoothing kernel. 

385 """ 

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

387 

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

389 """Retrieve the PSF for an exposure 

390 

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

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

393 

394 Parameters 

395 ---------- 

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

397 Exposure from which to retrieve the PSF. 

398 sigma : `float`, optional 

399 Gaussian sigma to use if provided. 

400 

401 Returns 

402 ------- 

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

404 PSF to use for detection. 

405 """ 

406 if sigma is None: 

407 psf = exposure.getPsf() 

408 if psf is None: 

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

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

411 size = self.calculateKernelSize(sigma) 

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

413 return psf 

414 

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

416 """Convolve the image with the PSF 

417 

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

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

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

421 Gaussian there's no difference. 

422 

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

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

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

426 because the kernel would extend off the image. 

427 

428 Parameters 

429 ---------- 

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

431 Image to convolve. 

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

433 PSF to convolve with (actually with a Gaussian approximation 

434 to it). 

435 doSmooth : `bool` 

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

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

438 

439 Return Struct contents 

440 ---------------------- 

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

442 Convolved image, without the edges. 

443 sigma : `float` 

444 Gaussian sigma used for the convolution. 

445 """ 

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

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

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

449 

450 if not doSmooth: 

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

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

453 

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

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

456 kWidth = self.calculateKernelSize(sigma) 

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

458 gaussFunc = afwMath.GaussianFunction1D(sigma) 

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

460 

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

462 

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

464 # 

465 # Only search psf-smoothed part of frame 

466 # 

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

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

469 # 

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

471 # 

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

473 

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

475 

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

477 """Apply thresholds to the convolved image 

478 

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

480 

481 The threshold can be modified by the provided multiplication 

482 ``factor``. 

483 

484 Parameters 

485 ---------- 

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

487 Convolved image to threshold. 

488 bbox : `lsst.geom.Box2I` 

489 Bounding box of unconvolved image. 

490 factor : `float` 

491 Multiplier for the configured threshold. 

492 

493 Return Struct contents 

494 ---------------------- 

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

496 Positive detection footprints, if configured. 

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

498 Negative detection footprints, if configured. 

499 factor : `float` 

500 Multiplier for the configured threshold. 

501 """ 

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

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

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

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

506 results.positive = afwDet.FootprintSet( 

507 middle, 

508 threshold, 

509 "DETECTED", 

510 self.config.minPixels 

511 ) 

512 results.positive.setRegion(bbox) 

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

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

515 results.negative = afwDet.FootprintSet( 

516 middle, 

517 threshold, 

518 "DETECTED_NEGATIVE", 

519 self.config.minPixels 

520 ) 

521 results.negative.setRegion(bbox) 

522 

523 return results 

524 

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

526 """Finalize the detected footprints 

527 

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

529 mask planes, and logs the results. 

530 

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

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

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

534 detection results. 

535 

536 Parameters 

537 ---------- 

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

539 Mask image on which to flag detected pixels. 

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

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

542 ``negative`` entries; modified. 

543 sigma : `float` 

544 Gaussian sigma of PSF. 

545 factor : `float` 

546 Multiplier for the configured threshold. 

547 """ 

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

549 fpSet = getattr(results, polarity) 

550 if fpSet is None: 

551 continue 

552 if self.config.nSigmaToGrow > 0: 

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

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

555 if self.config.combinedGrow: 

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

557 else: 

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

559 afwGeom.Stencil.MANHATTAN) 

560 for fp in fpSet: 

561 fp.dilate(nGrow, stencil) 

562 fpSet.setMask(mask, maskName) 

563 if not self.config.returnOriginalFootprints: 

564 setattr(results, polarity, fpSet) 

565 

566 results.numPos = 0 

567 results.numPosPeaks = 0 

568 results.numNeg = 0 

569 results.numNegPeaks = 0 

570 positive = "" 

571 negative = "" 

572 

573 if results.positive is not None: 

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

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

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

577 if results.negative is not None: 

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

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

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

581 

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

583 positive, " and" if positive and negative else "", negative, 

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

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

586 

587 def reEstimateBackground(self, maskedImage, backgrounds): 

588 """Estimate the background after detection 

589 

590 Parameters 

591 ---------- 

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

593 Image on which to estimate the background. 

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

595 List of backgrounds; modified. 

596 

597 Returns 

598 ------- 

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

600 Empirical background model. 

601 """ 

602 bg = self.background.fitBackground(maskedImage) 

603 if self.config.adjustBackground: 

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

605 bg += self.config.adjustBackground 

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

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

608 self.background.config.undersampleStyle) 

609 

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

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

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

613 actrl.getOrderY(), actrl.getWeighting())) 

614 return bg 

615 

616 def clearUnwantedResults(self, mask, results): 

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

618 

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

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

621 

622 Parameters 

623 ---------- 

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

625 Mask image. 

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

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

628 modified. 

629 """ 

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

631 if self.config.reEstimateBackground: 

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

633 results.negative = None 

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

635 if self.config.reEstimateBackground: 

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

637 results.positive = None 

638 

639 @timeMethod 

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

641 """Detect footprints on an exposure. 

642 

643 Parameters 

644 ---------- 

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

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

647 set in-place. 

648 doSmooth : `bool`, optional 

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

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

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

652 plane. 

653 sigma : `float`, optional 

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

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

656 ``exposure``. 

657 clearMask : `bool`, optional 

658 Clear both DETECTED and DETECTED_NEGATIVE planes before running 

659 detection. 

660 expId : `dict`, optional 

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

662 RNG seed by subclasses. 

663 

664 Return Struct contents 

665 ---------------------- 

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

667 Positive polarity footprints (may be `None`) 

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

669 Negative polarity footprints (may be `None`) 

670 numPos : `int` 

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

672 negative. 

673 numNeg : `int` 

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

675 positive. 

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

677 Re-estimated background. `None` if 

678 ``reEstimateBackground==False``. 

679 factor : `float` 

680 Multiplication factor applied to the configured detection 

681 threshold. 

682 """ 

683 maskedImage = exposure.maskedImage 

684 

685 if clearMask: 

686 self.clearMask(maskedImage.getMask()) 

687 

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

689 with self.tempWideBackgroundContext(exposure): 

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

691 middle = convolveResults.middle 

692 sigma = convolveResults.sigma 

693 

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

695 results.background = afwMath.BackgroundList() 

696 if self.config.doTempLocalBackground: 

697 self.applyTempLocalBackground(exposure, middle, results) 

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

699 

700 if self.config.reEstimateBackground: 

701 self.reEstimateBackground(maskedImage, results.background) 

702 

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

704 self.display(exposure, results, middle) 

705 

706 return results 

707 

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

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

710 configuration and the statistics of the given image. 

711 

712 Parameters 

713 ---------- 

714 image : `afw.image.MaskedImage` 

715 Image to measure noise statistics from if needed. 

716 thresholdParity: `str` 

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

718 the Threshold will detect. 

719 factor : `float` 

720 Factor by which to multiply the configured detection threshold. 

721 This is useful for tweaking the detection threshold slightly. 

722 

723 Returns 

724 ------- 

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

726 Detection threshold. 

727 """ 

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

729 thresholdValue = self.config.thresholdValue 

730 thresholdType = self.config.thresholdType 

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

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

733 sctrl = afwMath.StatisticsControl() 

734 sctrl.setAndMask(bad) 

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

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

737 thresholdType = 'value' 

738 

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

740 threshold.setIncludeMultiplier(self.config.includeThresholdMultiplier) 

741 return threshold 

742 

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

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

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

746 

747 Parameters 

748 ---------- 

749 fpSet : `afw.detection.FootprintSet` 

750 Set of Footprints whose Peaks should be updated. 

751 image : `afw.image.MaskedImage` 

752 Image to detect new Footprints and Peak in. 

753 threshold : `afw.detection.Threshold` 

754 Threshold object for detection. 

755 

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

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

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

759 """ 

760 for footprint in fpSet.getFootprints(): 

761 oldPeaks = footprint.getPeaks() 

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

763 continue 

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

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

766 # Footprints. 

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

768 fpSetForPeaks = afwDet.FootprintSet( 

769 sub, 

770 threshold, 

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

772 self.config.minPixels 

773 ) 

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

775 for fpForPeaks in fpSetForPeaks.getFootprints(): 

776 for peak in fpForPeaks.getPeaks(): 

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

778 newPeaks.append(peak) 

779 if len(newPeaks) > 0: 

780 del oldPeaks[:] 

781 oldPeaks.extend(newPeaks) 

782 else: 

783 del oldPeaks[1:] 

784 

785 @staticmethod 

786 def setEdgeBits(maskedImage, goodBBox, edgeBitmask): 

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

788 

789 Parameters 

790 ---------- 

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

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

793 goodBBox : `lsst.geom.Box2I` 

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

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

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

797 outside ``goodBBox``. 

798 """ 

799 msk = maskedImage.getMask() 

800 

801 mx0, my0 = maskedImage.getXY0() 

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

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

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

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

806 [0, 0, 

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

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

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

810 ): 

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

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

813 edgeMask |= edgeBitmask 

814 

815 @contextmanager 

816 def tempWideBackgroundContext(self, exposure): 

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

818 

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

820 detection of large footprints that may overwhelm the deblender. 

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

822 

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

824 the context manager. 

825 

826 Parameters 

827 ---------- 

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

829 Exposure on which to remove large-scale background. 

830 

831 Returns 

832 ------- 

833 context : context manager 

834 Context manager that will ensure the temporary wide background 

835 is restored. 

836 """ 

837 doTempWideBackground = self.config.doTempWideBackground 

838 if doTempWideBackground: 

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

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

841 self.tempWideBackground.run(exposure).background 

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

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

844 image = exposure.maskedImage.image 

845 mask = exposure.maskedImage.mask 

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

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

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

849 try: 

850 yield 

851 finally: 

852 if doTempWideBackground: 

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

854 

855 

856def addExposures(exposureList): 

857 """Add a set of exposures together. 

858 

859 Parameters 

860 ---------- 

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

862 Sequence of exposures to add. 

863 

864 Returns 

865 ------- 

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

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

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

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

870 """ 

871 exposure0 = exposureList[0] 

872 image0 = exposure0.getMaskedImage() 

873 

874 addedImage = image0.Factory(image0, True) 

875 addedImage.setXY0(image0.getXY0()) 

876 

877 for exposure in exposureList[1:]: 

878 image = exposure.getMaskedImage() 

879 addedImage += image 

880 

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

882 return addedExposure