Coverage for python / lsst / pipe / tasks / scaleZeroPoint.py: 40%

95 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-30 09:11 +0000

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 

22 

23from __future__ import annotations 

24 

25__all__ = ["ImageScaler", "SpatialImageScaler", "ScaleZeroPointTask"] 

26 

27import numpy 

28import lsst.geom as geom 

29import lsst.afw.image as afwImage 

30import lsst.pex.config as pexConfig 

31import lsst.pipe.base as pipeBase 

32from lsst.pipe.tasks.selectImages import BaseSelectImagesTask 

33from deprecated.sphinx import deprecated 

34 

35 

36class ImageScaler: 

37 """A class that scales an image. 

38 

39 This version uses a single scalar. Fancier versions may use a spatially varying scale. 

40 

41 Parameters 

42 ---------- 

43 scale : `float`, optional 

44 Scale correction to apply (see ``scaleMaskedImage``). 

45 """ 

46 

47 def __init__(self, scale=1.0): 

48 self._scale = scale 

49 

50 # TODO: Remove this property in DM-49402. 

51 @property 

52 @deprecated("This property will be removed after v30.", version="v30", category=FutureWarning) 

53 def scale(self) -> float: 

54 """Scale that it applies to a specified image.""" 

55 return self._scale 

56 

57 def scaleMaskedImage(self, maskedImage): 

58 """Scale the specified image or masked image in place. 

59 

60 Parameters 

61 ---------- 

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

63 Masked image to scale. 

64 """ 

65 maskedImage *= self._scale 

66 

67 

68class SpatialImageScaler(ImageScaler): 

69 """Multiplicative image scaler using interpolation over a grid of points. 

70 

71 Contains the x, y positions in tract coordinates and the scale factors. 

72 Interpolates only when scaleMaskedImage() or getInterpImage() is called. 

73 

74 Currently the only type of 'interpolation' implemented is CONSTANT which calculates the mean. 

75 

76 Parameters 

77 ---------- 

78 interpStyle : `Unknown` 

79 Interpolation style (`CONSTANT` is only option). 

80 xList : `list` of `int` 

81 List of X pixel positions. 

82 yList : `list` of `int` 

83 List of Y pixel positions. 

84 scaleList : `Unknown` 

85 List of multiplicative scale factors at (x,y). 

86 

87 Raises 

88 ------ 

89 RuntimeError 

90 Raised if the lists have different lengths. 

91 """ 

92 

93 def __init__(self, interpStyle, xList, yList, scaleList): 

94 if len(xList) != len(yList) or len(xList) != len(scaleList): 

95 raise RuntimeError( 

96 "len(xList)=%s len(yList)=%s, len(scaleList)=%s but all lists must have the same length" % 

97 (len(xList), len(yList), len(scaleList))) 

98 

99 # Eventually want this do be: self.interpStyle = getattr(afwMath.Interpolate2D, interpStyle) 

100 self._xList = xList 

101 self._yList = yList 

102 self._scaleList = scaleList 

103 

104 # TODO: Remove this property in DM-49402. 

105 @property 

106 @deprecated("This property will be removed after v30.", version="v30", category=FutureWarning) 

107 def scale(self) -> float: 

108 """Mean scale that it applies to a specified image.""" 

109 return numpy.mean(self._scaleList) 

110 

111 def scaleMaskedImage(self, maskedImage): 

112 """Apply scale correction to the specified masked image. 

113 

114 Parameters 

115 ---------- 

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

117 Masked image to scale; scale is applied in place. 

118 """ 

119 scale = self.getInterpImage(maskedImage.getBBox()) 

120 maskedImage *= scale 

121 

122 def getInterpImage(self, bbox): 

123 """Return an image containing the scale correction with same bounding box as supplied. 

124 

125 Parameters 

126 ---------- 

127 bbox : `lsst.geom.Box2I` 

128 Integer bounding box for image. 

129 

130 Raises 

131 ------ 

132 RuntimeError 

133 Raised if there are no fluxMag0s to interpolate. 

134 """ 

135 npoints = len(self._xList) 

136 

137 if npoints < 1: 

138 raise RuntimeError("Cannot create scaling image. Found no fluxMag0s to interpolate") 

139 

140 image = afwImage.ImageF(bbox, numpy.mean(self._scaleList)) 

141 

142 return image 

143 

144 

145class ScaleZeroPointConfig(pexConfig.Config): 

146 """Config for ScaleZeroPointTask. 

147 """ 

148 

149 zeroPoint = pexConfig.Field( 

150 dtype=float, 

151 doc="desired photometric zero point", 

152 default=27.0, 

153 ) 

154 

155 

156class SpatialScaleZeroPointConfig(ScaleZeroPointConfig): 

157 selectFluxMag0 = pexConfig.ConfigurableField( 

158 doc="Task to select data to compute spatially varying photometric zeropoint", 

159 target=BaseSelectImagesTask, 

160 ) 

161 

162 interpStyle = pexConfig.ChoiceField( 

163 dtype=str, 

164 doc="Algorithm to interpolate the flux scalings;" 

165 "Currently only one choice implemented", 

166 default="CONSTANT", 

167 allowed={ 

168 "CONSTANT": "Use a single constant value", 

169 } 

170 ) 

171 

172 

173class ScaleZeroPointTask(pipeBase.Task): 

174 """Compute scale factor to scale exposures to a desired photometric zero point. 

175 

176 This simple version assumes that the zero point is spatially invariant. 

177 """ 

178 

179 ConfigClass = ScaleZeroPointConfig 

180 _DefaultName = "scaleZeroPoint" 

181 

182 def __init__(self, *args, **kwargs): 

183 pipeBase.Task.__init__(self, *args, **kwargs) 

184 

185 # flux at mag=0 is 10^(zeroPoint/2.5) because m = -2.5*log10(F/F0) 

186 fluxMag0 = 10**(0.4 * self.config.zeroPoint) 

187 self._photoCalib = afwImage.makePhotoCalibFromCalibZeroPoint(fluxMag0, 0.0) 

188 

189 def run(self, exposure, dataRef=None): 

190 """Scale the specified exposure to the desired photometric zeropoint. 

191 

192 Parameters 

193 ---------- 

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

195 Exposure to scale; masked image is scaled in place. 

196 dataRef : `Unknown`, optional 

197 Data reference for exposure. 

198 Not used, but in API so that users can switch between spatially variant 

199 and invariant tasks. 

200 

201 Returns 

202 ------- 

203 result : `~lsst.pipe.base.Struct` 

204 Results as a struct with attributes: 

205 

206 ``imageScaler`` 

207 The image scaling object used to scale exposure. 

208 """ 

209 imageScaler = self.computeImageScaler(exposure=exposure, dataRef=dataRef) 

210 mi = exposure.getMaskedImage() 

211 imageScaler.scaleMaskedImage(mi) 

212 return pipeBase.Struct( 

213 imageScaler=imageScaler, 

214 ) 

215 

216 def computeImageScaler(self, exposure, dataRef=None): 

217 """Compute image scaling object for a given exposure. 

218 

219 Parameters 

220 ---------- 

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

222 Exposure for which scaling is desired. 

223 dataRef : `Unknown`, optional 

224 Data reference for exposure. 

225 Not used, but in API so that users can switch between spatially variant 

226 and invariant tasks. 

227 """ 

228 scale = self.scaleFromPhotoCalib(exposure.getPhotoCalib()).scale 

229 return ImageScaler(scale) 

230 

231 def getPhotoCalib(self): 

232 """Get desired PhotoCalib. 

233 

234 Returns 

235 ------- 

236 calibration : `lsst.afw.image.PhotoCalib` 

237 Calibration with ``fluxMag0`` set appropriately for config.zeroPoint. 

238 """ 

239 return self._photoCalib 

240 

241 def scaleFromPhotoCalib(self, calib): 

242 """Compute the scale for the specified PhotoCalib. 

243 

244 Parameter 

245 --------- 

246 calib : `lsst.afw.image.PhotoCalib` 

247 PhotoCalib object to compute the scale from. 

248 

249 Returns 

250 ------- 

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

252 Results as a struct with attributes: 

253 

254 `scale` 

255 

256 Scale, such that if pixelCalib describes the photometric 

257 zeropoint of a pixel then the following scales that pixel to 

258 the photometric zeropoint specified by config.zeroPoint: 

259 ``scale = computeScale(pixelCalib) pixel *= scale`` 

260 

261 Notes 

262 ----- 

263 Returns a struct to leave room for scaleErr in a future implementation. 

264 """ 

265 fluxAtZeroPoint = calib.magnitudeToInstFlux(self.config.zeroPoint) 

266 return pipeBase.Struct( 

267 scale=1.0 / fluxAtZeroPoint, 

268 ) 

269 

270 def scaleFromFluxMag0(self, fluxMag0): 

271 """Compute the scale for the specified fluxMag0. 

272 

273 This is a wrapper around scaleFromPhotoCalib, which see for more information. 

274 

275 Parameters 

276 ---------- 

277 fluxMag0 : `float` 

278 Flux at magnitude zero. 

279 

280 Returns 

281 ------- 

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

283 Results as a struct with attributes: 

284 

285 `scale` 

286 

287 Scale, such that if pixelCalib describes the photometric zeropoint 

288 of a pixel then the following scales that pixel to the photometric 

289 zeropoint specified by config.zeroPoint: 

290 ``scale = computeScale(pixelCalib)`` 

291 ``pixel *= scale`` 

292 """ 

293 calib = afwImage.makePhotoCalibFromCalibZeroPoint(fluxMag0, 0.0) 

294 return self.scaleFromPhotoCalib(calib) 

295 

296 

297class SpatialScaleZeroPointTask(ScaleZeroPointTask): 

298 """Compute spatially varying scale factor to scale exposures to a desired photometric zero point. 

299 """ 

300 

301 ConfigClass = SpatialScaleZeroPointConfig 

302 _DefaultName = "scaleZeroPoint" 

303 

304 def __init__(self, *args, **kwargs): 

305 ScaleZeroPointTask.__init__(self, *args, **kwargs) 

306 self.makeSubtask("selectFluxMag0") 

307 

308 def run(self, exposure, dataRef): 

309 """Scale the specified exposure to the desired photometric zeropoint. 

310 

311 Parameters 

312 ---------- 

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

314 Exposure to scale; masked image is scaled in place. 

315 dataRef : `Unknown` 

316 Data reference for exposure. 

317 

318 Returns 

319 ------- 

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

321 Results as a struct with attributes: 

322 

323 ``imageScaler`` 

324 The image scaling object used to scale exposure. 

325 """ 

326 imageScaler = self.computeImageScaler(exposure=exposure, dataRef=dataRef) 

327 mi = exposure.getMaskedImage() 

328 imageScaler.scaleMaskedImage(mi) 

329 return pipeBase.Struct( 

330 imageScaler=imageScaler, 

331 ) 

332 

333 def computeImageScaler(self, exposure, dataRef): 

334 """Compute image scaling object for a given exposure. 

335 

336 Parameters 

337 ---------- 

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

339 Exposure for which scaling is desired. Only wcs and bbox are used. 

340 dataRef : `Unknown` 

341 Data reference of exposure. 

342 dataRef.dataId used to retrieve all applicable fluxMag0's from a database. 

343 

344 Returns 

345 ------- 

346 result : `SpatialImageScaler` 

347 """ 

348 wcs = exposure.getWcs() 

349 

350 fluxMagInfoList = self.selectFluxMag0.run(dataRef.dataId).fluxMagInfoList 

351 

352 xList = [] 

353 yList = [] 

354 scaleList = [] 

355 

356 for fluxMagInfo in fluxMagInfoList: 

357 # find center of field in tract coordinates 

358 if not fluxMagInfo.coordList: 

359 raise RuntimeError("no x,y data for fluxMagInfo") 

360 ctr = geom.Extent2D() 

361 for coord in fluxMagInfo.coordList: 

362 # accumulate x, y 

363 ctr += geom.Extent2D(wcs.skyToPixel(coord)) 

364 # and find average x, y as the center of the chip 

365 ctr = geom.Point2D(ctr / len(fluxMagInfo.coordList)) 

366 xList.append(ctr.getX()) 

367 yList.append(ctr.getY()) 

368 scaleList.append(self.scaleFromFluxMag0(fluxMagInfo.fluxMag0).scale) 

369 

370 self.log.info("Found %d flux scales for interpolation: %s", 

371 len(scaleList), [f"{s:%0.4f}" for s in scaleList]) 

372 return SpatialImageScaler( 

373 interpStyle=self.config.interpStyle, 

374 xList=xList, 

375 yList=yList, 

376 scaleList=scaleList, 

377 )