Hide keyboard shortcuts

Hot-keys 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

1# This file is part of pipe_tasks. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

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 GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22import numpy as np 

23import lsst.afw.detection as afwDetect 

24import lsst.afw.table as afwTable 

25import lsst.meas.base as measBase 

26import lsst.daf.base as dafBase 

27import lsst.pipe.base as pipeBase 

28import lsst.pex.config as pexConfig 

29from lsst.meas.base import MeasurementError 

30from lsst.meas.algorithms.installGaussianPsf import InstallGaussianPsfTask 

31 

32 

33class QuickFrameMeasurementTaskConfig(pexConfig.Config): 

34 """Config class for the QuickFrameMeasurementTask. 

35 """ 

36 installPsf = pexConfig.ConfigurableField( 

37 target=InstallGaussianPsfTask, 

38 doc="Task for installing an initial PSF", 

39 ) 

40 maxNonRoundness = pexConfig.Field( 

41 dtype=float, 

42 doc="Ratio of xx to yy (or vice versa) above which to cut, in order to exclude spectra", 

43 default=5., 

44 ) 

45 maxExtendedness = pexConfig.Field( 

46 dtype=float, 

47 doc="Max absolute value of xx and yy above which to cut, in order to exclude large/things", 

48 default=100, 

49 ) 

50 doExtendednessCut = pexConfig.Field( 

51 dtype=bool, 

52 doc="Apply the extendeness cut, as definted by maxExtendedness", 

53 default=False, 

54 ) 

55 centroidPixelPercentile = pexConfig.Field( 

56 dtype=float, 

57 doc="The image's percentile value which the centroid must be greater than to pass the final peak" 

58 " check. Ignored if doCheckCentroidPixelValue is False", 

59 default=90, 

60 ) 

61 doCheckCentroidPixelValue = pexConfig.Field( 

62 dtype=bool, 

63 doc="Check that the centroid found is actually in the centroidPixelPercentile percentile of the" 

64 " image? Set to False for donut images.", 

65 default=True, 

66 ) 

67 initialPsfWidth = pexConfig.Field( 

68 dtype=float, 

69 doc="Guess at the initial PSF FWHM in pixels.", 

70 default=10, 

71 ) 

72 nSigmaDetection = pexConfig.Field( 

73 dtype=float, 

74 doc="Number of sigma for the detection limit.", 

75 default=20, 

76 ) 

77 nPixMinDetection = pexConfig.Field( 

78 dtype=int, 

79 doc="Minimum number of pixels in a detected source.", 

80 default=10, 

81 ) 

82 

83 def setDefaults(self): 

84 super().setDefaults() 

85 self.installPsf.fwhm = self.initialPsfWidth 

86 

87 

88class QuickFrameMeasurementTask(pipeBase.Task): 

89 """WARNING: An experimental new task with changable API! Do not rely on yet! 

90 

91 This task finds the centroid of the brightest source in a given CCD-image 

92 and returns its centroid and a rough estimate of the seeing/PSF. 

93 

94 It is designed for speed, such that it can be used in observing scripts 

95 to provide pointing offsets, allowing subsequent pointings to place 

96 a source at an exact pixel position. 

97 

98 The approach taken here is deliberately sub-optimal in the detection and 

99 measurement sense, with all optimisation being done for speed and robustness 

100 of the result. 

101 

102 A small set of unit tests exist for this task, which run automatically 

103 if afwdata is setup. These, however, are stricky unit tests, and will not 

104 catch algorithmic regressions. TODO: DM-29038 exists to merge a regression 

105 real test which runs against 1,000 LATISS images, but is therefore slow 

106 and requires access to the data. 

107 

108 Parameters 

109 ---------- 

110 config : lsst.pipe.tasks.quickFrameMeasurement.QuickFrameMeasurementTaskConfig 

111 Configuration class for the QuickFrameMeasurementTask. 

112 

113 display : lsst.afw.display.Display, optional 

114 The display to use for showing the images, detections and centroids. 

115 

116 Returns 

117 ------- 

118 result : lsst.pipe.base.Struct() 

119 Return strucure containing whether the task was successful, the main 

120 source's centroid, its the aperture fluxes, the ixx and iyy of the 

121 source, and the median ixx, iyy of the detections in the exposure. 

122 See run() method for further details. 

123 

124 Raises 

125 ------ 

126 This task should *never* raise, as the run() method is enclosed in an 

127 except Exception block, so that it will never fail during observing. 

128 Failure modes should be limited to returning a return Struct() with the same 

129 structure as the success case, with all value set to np.nan but with 

130 result.success=False. 

131 """ 

132 ConfigClass = QuickFrameMeasurementTaskConfig 

133 _DefaultName = 'quickFrameMeasurementTask' 

134 

135 def __init__(self, config, *, display=None, **kwargs): 

136 super().__init__(config=config, **kwargs) 

137 self.makeSubtask("installPsf") 

138 

139 self.display = None 

140 if display: 

141 self.display = display 

142 

143 self.centroidName = "base_SdssCentroid" 

144 self.shapeName = "base_SdssShape" 

145 self.schema = afwTable.SourceTable.makeMinimalSchema() 

146 self.schema.getAliasMap().set("slot_Centroid", self.centroidName) 

147 self.schema.getAliasMap().set("slot_Shape", self.shapeName) 

148 self.control = measBase.SdssCentroidControl() 

149 self.centroider = measBase.SdssCentroidAlgorithm(self.control, self.centroidName, self.schema) 

150 self.sdssShape = measBase.SdssShapeControl() 

151 self.shaper = measBase.SdssShapeAlgorithm(self.sdssShape, self.shapeName, self.schema) 

152 self.apFluxControl = measBase.ApertureFluxControl() 

153 md = dafBase.PropertySet() 

154 self.apFluxer = measBase.CircularApertureFluxAlgorithm(self.apFluxControl, "aperFlux", 

155 self.schema, md) 

156 

157 self.table = afwTable.SourceTable.make(self.schema) # make sure to call this last! 

158 

159 @staticmethod 

160 def detectObjectsInExp(exp, nSigma, nPixMin, grow=0): 

161 """Run a very basic but fast threshold-based object detection on an exposure 

162 Return the footPrintSet for the objects in a postISR exposure. 

163 

164 Parameters 

165 ---------- 

166 exp : lsst.afw.image.Exposure 

167 Image in which to detect objects. 

168 

169 nSigma : float 

170 nSigma above image's stddev at which to set the detection threshold. 

171 

172 nPixMin : int 

173 Minimum number of pixels for detection. 

174 

175 grow : int 

176 Grow the detected footprint by this many pixels. 

177 

178 Returns 

179 ------- 

180 footPrintSet : lsst.afw.detection.FootprintSet 

181 FootprintSet containing the detections. 

182 """ 

183 threshold = afwDetect.Threshold(nSigma, afwDetect.Threshold.STDEV) 

184 footPrintSet = afwDetect.FootprintSet(exp.getMaskedImage(), threshold, "DETECTED", nPixMin) 

185 if grow > 0: 

186 isotropic = True 

187 footPrintSet = afwDetect.FootprintSet(footPrintSet, grow, isotropic) 

188 return footPrintSet 

189 

190 @staticmethod 

191 def checkResult(exp, centroid, srcNum, percentile): 

192 """Perform a final check that centroid location is actually bright. 

193 

194 Parameters 

195 ---------- 

196 exp : lsst.afw.image.Exposure 

197 centroid : `tuple` of `float` 

198 Location of the centroid in pixel coordinates 

199 

200 scrNum : int 

201 Number of the source in the source catalog. Only used if the check 

202 is failed, for debug purposes. 

203 

204 percentile : float 

205 Image's percentile above which the pixel containing the centroid 

206 must be in order to pass the check. 

207 

208 Raises 

209 ------ 

210 ValueError 

211 Raised if the centroid's pixel is not above the percentile threshold 

212 """ 

213 threshold = np.percentile(exp.image.array, percentile) 

214 pixelValue = exp.image[centroid] 

215 if pixelValue < threshold: 

216 msg = (f"Final centroid pixel value check failed: srcNum {srcNum} at {centroid}" 

217 f" has central pixel = {pixelValue:3f} <" 

218 f" {percentile} percentile of image = {threshold:3f}") 

219 raise ValueError(msg) 

220 return 

221 

222 @staticmethod 

223 def _calcMedianXxYy(objData): 

224 """Return the median ixx and iyy for object in the image. 

225 """ 

226 medianXx = np.nanmedian([element['xx'] for element in objData.values()]) 

227 medianYy = np.nanmedian([element['xx'] for element in objData.values()]) 

228 return medianXx, medianYy 

229 

230 def _calcBrightestObjSrcNum(self, objData): 

231 """Find the brightest source which passes the cuts among the sources. 

232 

233 Parameters 

234 ---------- 

235 objData : `dict` of `dict` 

236 Dictionary, keyed by source number, containing the measurements. 

237 

238 Returns 

239 ------- 

240 srcNum : int 

241 The source number of the brightest source which passes the cuts. 

242 """ 

243 max70, max70srcNum = -1, -1 

244 max25, max25srcNum = -1, -1 

245 

246 for srcNum in sorted(objData.keys()): # srcNum not contiguous so don't use a list comp 

247 # skip flag used rather than continue statements so we have all the 

248 # metrics computed for debug purposes as this task is whack-a-mole 

249 skip = False 

250 xx = objData[srcNum]['xx'] 

251 yy = objData[srcNum]['yy'] 

252 

253 xx = max(xx, 1e-9) # need to protect against division by zero 

254 yy = max(yy, 1e-9) # because we don't `continue` on zero moments 

255 

256 if self.config.doExtendednessCut: 

257 if xx > self.config.maxExtendedness or yy > self.config.maxExtendedness: 

258 skip = True 

259 

260 nonRoundness = xx/yy 

261 nonRoundness = max(nonRoundness, 1/nonRoundness) 

262 if nonRoundness > self.config.maxNonRoundness: 

263 skip = True 

264 

265 if self.log.isDebugEnabled(): 

266 text = f"src {srcNum}: {objData[srcNum]['xCentroid']:.0f}, {objData[srcNum]['yCentroid']:.0f}" 

267 text += f" - xx={xx:.1f}, yy={yy:.1f}, nonRound={nonRoundness:.1f}" 

268 text += f" - ap70={objData[srcNum]['apFlux70']:,.0f}" 

269 text += f" - ap25={objData[srcNum]['apFlux25']:,.0f}" 

270 text += f" - skip={skip}" 

271 self.log.debug(text) 

272 

273 if skip: 

274 continue 

275 

276 ap70 = objData[srcNum]['apFlux70'] 

277 ap25 = objData[srcNum]['apFlux25'] 

278 if ap70 > max70: 

279 max70 = ap70 

280 max70srcNum = srcNum 

281 if ap25 > max25: 

282 max25 = ap25 

283 max25srcNum = srcNum 

284 if max70srcNum != max25srcNum: 

285 self.log.warn("WARNING! Max apFlux70 for different object than with max apFlux25") 

286 

287 if max70srcNum >= 0: # starts as -1, return None if nothing is acceptable 

288 return max70srcNum 

289 return None 

290 

291 def _measureFp(self, fp, exp): 

292 """Run the measurements on a footprint. 

293 

294 Parameters 

295 ---------- 

296 fp : lsst.afw.detection.Footprint 

297 The footprint to measure. 

298 

299 exp : lsst.afw.image.Exposure 

300 The footprint's parent exposure. 

301 

302 Returns 

303 ------- 

304 src : lsst.afw.table.SourceRecord 

305 The source record containing the measurements. 

306 """ 

307 src = self.table.makeRecord() 

308 src.setFootprint(fp) 

309 self.centroider.measure(src, exp) 

310 self.shaper.measure(src, exp) 

311 self.apFluxer.measure(src, exp) 

312 return src 

313 

314 def _getDataFromSrcRecord(self, src): 

315 """Extract the shapes and centroids from a source record. 

316 

317 Parameters 

318 ---------- 

319 src : lsst.afw.table.SourceRecord 

320 The source record from which to extract the measurements. 

321 

322 Returns 

323 ------- 

324 srcData : lsst.pipe.base.Struct 

325 The struct containing the extracted measurements. 

326 """ 

327 pScale = self.plateScale 

328 xx = np.sqrt(src['base_SdssShape_xx'])*2.355*pScale # 2.355 for FWHM, pScale for platescale from exp 

329 yy = np.sqrt(src['base_SdssShape_yy'])*2.355*pScale 

330 xCentroid = src['base_SdssCentroid_x'] 

331 yCentroid = src['base_SdssCentroid_y'] 

332 # apFluxes available: 70, 50, 35, 25, 17, 12 9, 6, 4.5, 3 

333 apFlux70 = src['aperFlux_70_0_instFlux'] 

334 apFlux25 = src['aperFlux_25_0_instFlux'] 

335 return pipeBase.Struct(xx=xx, 

336 yy=yy, 

337 xCentroid=xCentroid, 

338 yCentroid=yCentroid, 

339 apFlux70=apFlux70, 

340 apFlux25=apFlux25) 

341 

342 @staticmethod 

343 def _getDataFromFootprintOnly(fp, exp): 

344 """Get the shape, centroid and flux from a footprint. 

345 

346 Parameters 

347 ---------- 

348 fp : lsst.afw.detection.Footprint 

349 The footprint to measure. 

350 exp : lsst.afw.image.Exposure 

351 The footprint's parent exposure. 

352 

353 Returns 

354 ------- 

355 srcData : lsst.pipe.base.Struct 

356 The struct containing the extracted measurements. 

357 """ 

358 xx = fp.getShape().getIxx() 

359 yy = fp.getShape().getIyy() 

360 xCentroid, yCentroid = fp.getCentroid() 

361 apFlux70 = np.sum(exp[fp.getBBox()].image.array) 

362 apFlux25 = np.sum(exp[fp.getBBox()].image.array) 

363 return pipeBase.Struct(xx=xx, 

364 yy=yy, 

365 xCentroid=xCentroid, 

366 yCentroid=yCentroid, 

367 apFlux70=apFlux70, 

368 apFlux25=apFlux25) 

369 

370 @staticmethod 

371 def _measurementResultToDict(measurementResult): 

372 """Convenience function to repackage measurement results to a dict. 

373 

374 Parameters 

375 ---------- 

376 measurementResult : lsst.afw.table.SourceRecord 

377 The source record to convert to a dict. 

378 

379 Returns 

380 ------- 

381 objData : `dict` 

382 The dict containing the extracted data. 

383 """ 

384 objData = {} 

385 objData['xx'] = measurementResult.xx 

386 objData['yy'] = measurementResult.yy 

387 objData['xCentroid'] = measurementResult.xCentroid 

388 objData['yCentroid'] = measurementResult.yCentroid 

389 objData['apFlux70'] = measurementResult.apFlux70 

390 objData['apFlux25'] = measurementResult.apFlux25 

391 return objData 

392 

393 @staticmethod 

394 def _makeEmptyReturnStruct(): 

395 """Make the default/template return struct, with defaults to False/nan. 

396 

397 Returns 

398 ------- 

399 objData : lsst.pipe.base.Struct 

400 The default template return structure. 

401 """ 

402 result = pipeBase.Struct() 

403 result.success = False 

404 result.brightestObjCentroid = (np.nan, np.nan) 

405 result.brightestObj_xXyY = (np.nan, np.nan) 

406 result.brightestObjApFlux70 = np.nan 

407 result.brightestObjApFlux25 = np.nan 

408 result.medianXxYy = (np.nan, np.nan) 

409 return result 

410 

411 def run(self, exp, doDisplay=False): 

412 """Calculate position, flux and shape of the brightest star in an image. 

413 

414 Given an an assembled (and at least minimally ISRed exposure), 

415 quickly and robustly calculate the centroid of the 

416 brightest star in the image. 

417 

418 Parameters 

419 ---------- 

420 exp : lsst.afw.image.Exposure 

421 The exposure in which to find and measure the brightest star. 

422 

423 doDisplay : bool 

424 Display the image and found sources. A diplay object must have 

425 been passed to the task constructor. 

426 

427 Returns 

428 ------- 

429 result : lsst.pipe.base.Struct 

430 Struct containing: 

431 Whether the task ran successfully and found the object (bool) 

432 The object's centroid (float, float) 

433 The object's ixx, iyy (float, float) 

434 The object's 70 pixel aperture flux (float) 

435 The object's 25 pixel aperture flux (float) 

436 The images's median ixx, iyy (float, float) 

437 If unsuccessful, the success field is False and all other results 

438 are np.nan of the expected shape. 

439 

440 Notes 

441 ----- 

442 Because of this task's involvement in observing scripts, the run method 

443 should *never* raise. Failure modes are noted by returning a Struct with 

444 the same structure as the success case, with all value set to np.nan and 

445 result.success=False. 

446 """ 

447 try: 

448 result = self._run(exp=exp, doDisplay=doDisplay) 

449 return result 

450 except Exception as e: 

451 self.log.warn(f"Failed to find main source centroid {e}") 

452 result = self._makeEmptyReturnStruct() 

453 return result 

454 

455 def _run(self, exp, doDisplay=False): 

456 """The actual run method, called by run() 

457 

458 Behaviour is documented in detail in the main run(). 

459 """ 

460 self.plateScale = exp.getWcs().getPixelScale().asArcseconds() 

461 median = np.nanmedian(exp.image.array) 

462 exp.image -= median # is put back later 

463 self.installPsf.run(exp) 

464 sources = self.detectObjectsInExp(exp, nSigma=self.config.nSigmaDetection, 

465 nPixMin=self.config.nPixMinDetection) 

466 

467 if doDisplay: 

468 if self.display is None: 

469 raise RuntimeError("Display failed as no display provided during init()") 

470 self.display.mtv(exp) 

471 

472 fpSet = sources.getFootprints() 

473 self.log.info(f"Found {len(fpSet)} sources in exposure") 

474 

475 objData = {} 

476 nMeasured = 0 

477 

478 for srcNum, fp in enumerate(fpSet): 

479 try: 

480 src = self._measureFp(fp, exp) 

481 result = self._getDataFromSrcRecord(src) 

482 except MeasurementError: 

483 try: 

484 # gets shape and centroid from footprint 

485 result = self._getDataFromFootprintOnly(fp, exp) 

486 except MeasurementError as e: 

487 self.log.info(f"Skipped measuring source {srcNum}: {e}") 

488 continue 

489 objData[srcNum] = self._measurementResultToDict(result) 

490 nMeasured += 1 

491 

492 self.log.info(f"Measured {nMeasured} of {len(fpSet)} sources in exposure") 

493 

494 medianXxYy = self._calcMedianXxYy(objData) 

495 

496 brightestObjSrcNum = self._calcBrightestObjSrcNum(objData) 

497 if brightestObjSrcNum is None: 

498 raise RuntimeError("No sources in image passed cuts") 

499 

500 x = objData[brightestObjSrcNum]['xCentroid'] 

501 y = objData[brightestObjSrcNum]['yCentroid'] 

502 brightestObjCentroid = (x, y) 

503 xx = objData[brightestObjSrcNum]['xx'] 

504 yy = objData[brightestObjSrcNum]['yy'] 

505 brightestObjApFlux70 = objData[brightestObjSrcNum]['apFlux70'] 

506 brightestObjApFlux25 = objData[brightestObjSrcNum]['apFlux25'] 

507 

508 exp.image += median # put background back in 

509 if self.config.doCheckCentroidPixelValue: 

510 self.checkResult(exp, brightestObjCentroid, brightestObjSrcNum, 

511 self.config.centroidPixelPercentile) 

512 

513 result = self._makeEmptyReturnStruct() 

514 result.success = True 

515 result.brightestObjCentroid = brightestObjCentroid 

516 result.brightestObj_xXyY = (xx, yy) 

517 result.brightestObjApFlux70 = brightestObjApFlux70 

518 result.brightestObjApFlux25 = brightestObjApFlux25 

519 result.medianXxYy = medianXxYy 

520 return result