Coverage for python/lsst/images/_difference_image.py: 57%

75 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-18 18:15 +0000

1# This file is part of lsst-images. 

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# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14__all__ = ("DifferenceImage", "DifferenceImageSerializationModel") 

15 

16from collections.abc import Mapping 

17from types import EllipsisType 

18from typing import TYPE_CHECKING, Any, ClassVar, Literal 

19 

20import astropy.io.fits 

21import astropy.units 

22import astropy.wcs 

23import pydantic 

24from astro_metadata_translator import ObservationInfo 

25 

26from ._backgrounds import BackgroundMap 

27from ._geom import Bounds, Box 

28from ._image import Image 

29from ._mask import Mask, MaskPlane, MaskSchema, get_legacy_difference_image_mask_planes 

30from ._observation_summary_stats import ObservationSummaryStats 

31from ._transforms import DetectorFrame, SkyProjection 

32from ._visit_image import VisitImage, VisitImageSerializationModel 

33from .aperture_corrections import ( 

34 ApertureCorrectionMap, 

35) 

36from .cameras import Detector 

37from .fields import Field 

38from .psfs import ( 

39 PointSpreadFunction, 

40) 

41from .serialization import ArchiveReadError, InputArchive, InvalidParameterError, MetadataValue, OutputArchive 

42 

43if TYPE_CHECKING: 

44 try: 

45 from lsst.afw.image import Exposure as LegacyExposure 

46 except ImportError: 

47 type LegacyExposure = Any # type: ignore[no-redef] 

48 

49 

50class DifferenceImage(VisitImage): 

51 """A calibrated single-visit image. 

52 

53 Parameters 

54 ---------- 

55 image 

56 The main image plane. If this has a `SkyProjection`, it will be used 

57 for all planes unless a ``sky_projection`` is passed separately. 

58 mask 

59 A bitmask image that annotates the main image plane. Must have the 

60 same bounding box as ``image`` if provided. Any attached 

61 ``sky_projection`` is replaced (possibly by `None`). 

62 variance 

63 The per-pixel uncertainty of the main image as an image of variance 

64 values. Must have the same bounding box as ``image`` if provided, and 

65 its units must be the square of ``image.unit`` or `None`. 

66 Values default to ``1.0``. Any attached sky_projection is replaced 

67 (possibly by `None`). 

68 mask_schema 

69 Schema for the mask plane. Must be provided if and only if ``mask`` is 

70 not provided. 

71 sky_projection 

72 Projection that maps the pixel grid to the sky. Can only be `None` if 

73 a ``sky_projection`` is already attached to ``image``. 

74 bounds 

75 The region where this image's pixels and other properties are valid. 

76 If not provided, the bounding box of the image is used. Other 

77 components (``psf``, ``sky_projection``, ``aperture_corrections``, 

78 etc.) are assumed to have their own bounds which may or may not be the 

79 same as the image bounds. If ``bounds`` extends beyond the image 

80 bounding box, the intersection between ``bounds`` and the image 

81 bounding box is used instead. 

82 obs_info 

83 General information about this visit in standardized form. 

84 summary_stats 

85 Summary statistics associated with this visit. Initialized to default 

86 values if not provided. 

87 photometric_scaling 

88 Field that can be used to multiply a post-ISR image units to yield 

89 calibrated image units. This may be a scaling that was already 

90 applied (so dividing by it will recover the post-ISR units) or a 

91 scaling that has not been applied, depending on ``image.unit``. 

92 psf 

93 Point-spread function model for this image, or an exception explaining 

94 why it could not be read (to be raised if the PSF is requested later). 

95 detector 

96 Geometry and electronic information for the detector attached to this 

97 image. 

98 aperture_corrections : `dict` [`str`, `~fields.BaseField`] 

99 Mapping from photometry algorithm name to the aperture correction for 

100 that algorithm. 

101 backgrounds 

102 Background models associated with this image. 

103 band 

104 Name of the passband the image was observed with (this is a shorter, 

105 less specific version of ``obs_info.physical_filter``). 

106 metadata 

107 Arbitrary flexible metadata to associate with the image. 

108 """ 

109 

110 def __init__( 

111 self, 

112 image: Image, 

113 *, 

114 mask: Mask | None = None, 

115 variance: Image | None = None, 

116 mask_schema: MaskSchema | None = None, 

117 sky_projection: SkyProjection[DetectorFrame] | None = None, 

118 bounds: Bounds | None = None, 

119 obs_info: ObservationInfo | None = None, 

120 summary_stats: ObservationSummaryStats | None = None, 

121 photometric_scaling: Field | None = None, 

122 psf: PointSpreadFunction | ArchiveReadError, 

123 detector: Detector, 

124 aperture_corrections: ApertureCorrectionMap | None = None, 

125 backgrounds: BackgroundMap | None = None, 

126 band: str, 

127 metadata: dict[str, MetadataValue] | None = None, 

128 ): 

129 super().__init__( 

130 image, 

131 mask=mask, 

132 variance=variance, 

133 mask_schema=mask_schema, 

134 sky_projection=sky_projection, 

135 bounds=bounds, 

136 obs_info=obs_info, 

137 summary_stats=summary_stats, 

138 photometric_scaling=photometric_scaling, 

139 psf=psf, 

140 detector=detector, 

141 aperture_corrections=aperture_corrections, 

142 backgrounds=backgrounds, 

143 band=band, 

144 metadata=metadata, 

145 ) 

146 

147 @staticmethod 

148 def _from_visit_image(visit_image: VisitImage) -> DifferenceImage: 

149 return visit_image._transfer_metadata( 

150 DifferenceImage( 

151 visit_image.image, 

152 mask=visit_image.mask, 

153 variance=visit_image.variance, 

154 sky_projection=visit_image.sky_projection, 

155 bounds=visit_image.bounds, 

156 obs_info=visit_image.obs_info, 

157 summary_stats=visit_image.summary_stats, 

158 photometric_scaling=visit_image.photometric_scaling, 

159 psf=visit_image._psf, # get private attr to avoid triggering on ArchiveReadError early. 

160 detector=visit_image.detector, 

161 aperture_corrections=visit_image.aperture_corrections, 

162 backgrounds=visit_image.backgrounds, 

163 band=visit_image.band, 

164 ), 

165 ) 

166 

167 def __getitem__(self, bbox: Box | EllipsisType) -> DifferenceImage: 

168 if bbox is ...: 

169 return self 

170 return self._from_visit_image(super().__getitem__(bbox)) 

171 

172 def __str__(self) -> str: 

173 return f"DifferenceImage({self.image!s}, {list(self.mask.schema.names)})" 

174 

175 def __repr__(self) -> str: 

176 return f"DifferenceImage({self.image!r}, mask_schema={self.mask.schema!r})" 

177 

178 def copy(self, *, copy_detector: bool = False) -> DifferenceImage: 

179 """Deep-copy the difference image. 

180 

181 Parameters 

182 ---------- 

183 copy_detector 

184 Whether to deep-copy the `detector` attribute. 

185 """ 

186 return self._from_visit_image(super().copy(copy_detector=copy_detector)) 

187 

188 def convert_unit( 

189 self, 

190 unit: astropy.units.UnitBase = astropy.units.nJy, 

191 copy: Literal["as-needed"] | bool = True, 

192 copy_detector: bool = False, 

193 ) -> DifferenceImage: 

194 """Return an equivalent image with different pixel units. 

195 

196 Parameters 

197 ---------- 

198 unit 

199 The unit to transform to. This may be any of the following: 

200 

201 - any unit directly relatable to the current units via Astropy; 

202 - any unit relatable to the product of the current units with the 

203 `photometric_scaling` (i.e. if the current image is in 

204 instrumental units but we know how to calibrate them) 

205 - any unit relatable to the quotient of the current units with the 

206 `photometric_scaling` (i.e. if the current image is in 

207 calibrated units and we want to revert back to instrumental 

208 units). 

209 copy 

210 Whether to copy the images and other components. If `True`, all 

211 components that aren't controlled by some other argument will 

212 always be deep-copied. If `False`, the operation will fail if the 

213 image is not already in the right units. If ``as-needed``, only 

214 the image and variance will be copied, and only if they are not 

215 already in the right units. 

216 copy_detector 

217 Whether to deep-copy the `detector` attribute. 

218 

219 Returns 

220 ------- 

221 `DifferenceImage` 

222 An image with the given units. 

223 """ 

224 return self._from_visit_image(super().convert_unit(unit, copy=copy, copy_detector=copy_detector)) 

225 

226 def serialize(self, archive: OutputArchive[Any]) -> DifferenceImageSerializationModel: 

227 return self._serialize_impl(DifferenceImageSerializationModel, archive) 

228 

229 @staticmethod 

230 def _get_archive_tree_type[P: pydantic.BaseModel]( 

231 pointer_type: type[P], 

232 ) -> type[DifferenceImageSerializationModel[P]]: 

233 """Return the serialization model type for this object for an archive 

234 type that uses the given pointer type. 

235 """ 

236 return DifferenceImageSerializationModel[pointer_type] # type: ignore 

237 

238 @staticmethod 

239 def from_legacy( 

240 legacy: LegacyExposure, 

241 *, 

242 unit: astropy.units.UnitBase | None = None, 

243 plane_map: Mapping[str, MaskPlane] | None = None, 

244 instrument: str | None = None, 

245 visit: int | None = None, 

246 ) -> DifferenceImage: 

247 """Convert from an `lsst.afw.image.Exposure` instance. 

248 

249 Parameters 

250 ---------- 

251 legacy 

252 An `lsst.afw.image.Exposure` instance that will share image and 

253 variance (but not mask) pixel data with the returned object. 

254 unit 

255 Units of the image. If not provided, the ``BUNIT`` metadata 

256 key will be used, if available. 

257 plane_map 

258 A mapping from legacy mask plane name to the new plane name and 

259 description. If `None` (default) 

260 `get_legacy_visit_image_mask_planes` is used. 

261 instrument 

262 Name of the instrument. Extracted from the metadata if not 

263 provided. 

264 visit 

265 ID of the visit. Extracted from the metadata if not provided. 

266 """ 

267 if plane_map is None: 

268 plane_map = get_legacy_difference_image_mask_planes() 

269 return DifferenceImage._from_visit_image( 

270 VisitImage.from_legacy(legacy, unit=unit, plane_map=plane_map, instrument=instrument, visit=visit) 

271 ) 

272 

273 def to_legacy( 

274 self, *, copy: bool | None = None, plane_map: Mapping[str, MaskPlane] | None = None 

275 ) -> LegacyExposure: 

276 """Convert to an `lsst.afw.image.Exposure` instance. 

277 

278 Parameters 

279 ---------- 

280 copy 

281 If `True`, always copy the image and variance pixel data. 

282 If `False`, return a view, and raise `TypeError` if the pixel data 

283 is read-only (this is not supported by afw). If `None`, only copy 

284 if the pixel data is read-only. Mask pixel data is always copied. 

285 plane_map 

286 A mapping from legacy mask plane name to the new plane name and 

287 description. If `None` (default), 

288 `get_legacy_visit_image_mask_planes` is used. 

289 """ 

290 if plane_map is None: 

291 plane_map = get_legacy_difference_image_mask_planes() 

292 return super().to_legacy(copy=copy, plane_map=plane_map) 

293 

294 @staticmethod 

295 def read_legacy( # type: ignore[override] 

296 filename: str, 

297 *, 

298 preserve_quantization: bool = False, 

299 plane_map: Mapping[str, MaskPlane] | None = None, 

300 instrument: str | None = None, 

301 visit: int | None = None, 

302 component: Literal[ 

303 "bbox", 

304 "image", 

305 "mask", 

306 "variance", 

307 "sky_projection", 

308 "psf", 

309 "detector", 

310 "photometric_scaling", 

311 "obs_info", 

312 "summary_stats", 

313 "aperture_corrections", 

314 ] 

315 | None = None, 

316 ) -> Any: 

317 """Read a FITS file written by `lsst.afw.image.Exposure.writeFits`. 

318 

319 Parameters 

320 ---------- 

321 filename 

322 Full name of the file. 

323 preserve_quantization 

324 If `True`, ensure that writing the masked image back out again will 

325 exactly preserve quantization-compressed pixel values. This causes 

326 the image and variance plane arrays to be marked as read-only and 

327 stores the original binary table data for those planes in memory. 

328 If the `MaskedImage` is copied, the precompressed pixel values are 

329 not transferred to the copy. 

330 plane_map 

331 A mapping from legacy mask plane name to the new plane name and 

332 description. If `None` (default) 

333 `get_legacy_visit_image_mask_planes` is used. 

334 instrument 

335 Name of the instrument. Read from the primary header if not 

336 provided. 

337 visit 

338 ID of the visit. Read from the primary header if not 

339 provided. 

340 component 

341 A component to read instead of the full image. 

342 """ 

343 if plane_map is None: 

344 plane_map = get_legacy_difference_image_mask_planes() 

345 result = VisitImage.read_legacy( 

346 filename, 

347 preserve_quantization=preserve_quantization, 

348 plane_map=plane_map, 

349 instrument=instrument, 

350 visit=visit, 

351 component=component, 

352 ) 

353 if component is None: 

354 return DifferenceImage._from_visit_image(result) 

355 return result 

356 

357 

358class DifferenceImageSerializationModel[P: pydantic.BaseModel](VisitImageSerializationModel[P]): 

359 """A Pydantic model used to represent a serialized `DifferenceImage`.""" 

360 

361 SCHEMA_NAME: ClassVar[str] = "difference_image" 

362 SCHEMA_VERSION: ClassVar[str] = "1.0.0" 

363 MIN_READ_VERSION: ClassVar[int] = 1 

364 PUBLIC_TYPE: ClassVar[type] = DifferenceImage 

365 

366 def deserialize( 

367 self, archive: InputArchive[Any], *, bbox: Box | None = None, **kwargs: Any 

368 ) -> DifferenceImage: 

369 if kwargs: 369 ↛ 370line 369 didn't jump to line 370 because the condition on line 369 was never true

370 raise InvalidParameterError(f"Unrecognized parameters for DifferenceImage: {set(kwargs.keys())}.") 

371 return DifferenceImage._from_visit_image(super().deserialize(archive, bbox=bbox)) 

372 

373 def deserialize_component(self, component: str, archive: InputArchive[Any], **kwargs: Any) -> Any: 

374 if kwargs and component not in ("image", "mask", "variance"): 

375 raise InvalidParameterError( 

376 f"Unsupported parameters for DifferenceImage component {component}: {set(kwargs.keys())}." 

377 ) 

378 return super().deserialize_component(component, archive)