Coverage for python/lsst/meas/algorithms/brightStarStamps.py: 22%
140 statements
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-19 09:22 +0000
« prev ^ index » next coverage.py v7.2.5, created at 2023-05-19 09:22 +0000
1# This file is part of meas_algorithms.
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/>.
22"""Collection of small images (stamps), each centered on a bright star."""
24__all__ = ["BrightStarStamp", "BrightStarStamps"]
26from collections.abc import Collection
27from dataclasses import dataclass
28from functools import reduce
29from operator import ior
31import numpy as np
32from lsst.afw.geom import SpanSet, Stencil
33from lsst.afw.image import MaskedImageF
34from lsst.afw.math import Property, StatisticsControl, makeStatistics, stringToStatisticsProperty
35from lsst.afw.table.io import Persistable
36from lsst.geom import Point2I
38from .stamps import AbstractStamp, Stamps, readFitsWithOptions
41@dataclass
42class BrightStarStamp(AbstractStamp):
43 """Single stamp centered on a bright star, normalized by its annularFlux.
45 Parameters
46 ----------
47 stamp_im : `~lsst.afw.image.MaskedImage`
48 Pixel data for this postage stamp
49 gaiaGMag : `float`
50 Gaia G magnitude for the object in this stamp
51 gaiaId : `int`
52 Gaia object identifier
53 position : `~lsst.geom.Point2I`
54 Origin of the stamps in its origin exposure (pixels)
55 archive_element : `~lsst.afw.table.io.Persistable` or None, optional
56 Archive element (e.g. Transform or WCS) associated with this stamp.
57 annularFlux : `float` or None, optional
58 Flux in an annulus around the object
59 """
61 stamp_im: MaskedImageF
62 gaiaGMag: float
63 gaiaId: int
64 position: Point2I
65 archive_element: Persistable | None = None
66 annularFlux: float | None = None
68 @classmethod
69 def factory(cls, stamp_im, metadata, idx, archive_element=None):
70 """This method is needed to service the FITS reader. We need a standard
71 interface to construct objects like this. Parameters needed to
72 construct this object are passed in via a metadata dictionary and then
73 passed to the constructor of this class. This particular factory
74 method requires keys: G_MAGS, GAIA_IDS, and ANNULAR_FLUXES. They should
75 each point to lists of values.
77 Parameters
78 ----------
79 stamp_im : `~lsst.afw.image.MaskedImage`
80 Pixel data to pass to the constructor
81 metadata : `dict`
82 Dictionary containing the information
83 needed by the constructor.
84 idx : `int`
85 Index into the lists in ``metadata``
86 archive_element : `~lsst.afw.table.io.Persistable` or None, optional
87 Archive element (e.g. Transform or WCS) associated with this stamp.
89 Returns
90 -------
91 brightstarstamp : `BrightStarStamp`
92 An instance of this class
93 """
94 if "X0S" in metadata and "Y0S" in metadata:
95 x0 = metadata.getArray("X0S")[idx]
96 y0 = metadata.getArray("Y0S")[idx]
97 position = Point2I(x0, y0)
98 else:
99 position = None
100 return cls(
101 stamp_im=stamp_im,
102 gaiaGMag=metadata.getArray("G_MAGS")[idx],
103 gaiaId=metadata.getArray("GAIA_IDS")[idx],
104 position=position,
105 archive_element=archive_element,
106 annularFlux=metadata.getArray("ANNULAR_FLUXES")[idx],
107 )
109 def measureAndNormalize(
110 self,
111 annulus: SpanSet,
112 statsControl: StatisticsControl = StatisticsControl(),
113 statsFlag: Property = stringToStatisticsProperty("MEAN"),
114 badMaskPlanes: Collection[str] = ("BAD", "SAT", "NO_DATA"),
115 ):
116 """Compute "annularFlux", the integrated flux within an annulus
117 around an object's center, and normalize it.
119 Since the center of bright stars are saturated and/or heavily affected
120 by ghosts, we measure their flux in an annulus with a large enough
121 inner radius to avoid the most severe ghosts and contain enough
122 non-saturated pixels.
124 Parameters
125 ----------
126 annulus : `~lsst.afw.geom.spanSet.SpanSet`
127 SpanSet containing the annulus to use for normalization.
128 statsControl : `~lsst.afw.math.statistics.StatisticsControl`, optional
129 StatisticsControl to be used when computing flux over all pixels
130 within the annulus.
131 statsFlag : `~lsst.afw.math.statistics.Property`, optional
132 statsFlag to be passed on to ``afwMath.makeStatistics`` to compute
133 annularFlux. Defaults to a simple MEAN.
134 badMaskPlanes : `collections.abc.Collection` [`str`]
135 Collection of mask planes to ignore when computing annularFlux.
136 """
137 stampSize = self.stamp_im.getDimensions()
138 # create image: same pixel values within annulus, NO_DATA elsewhere
139 maskPlaneDict = self.stamp_im.mask.getMaskPlaneDict()
140 annulusImage = MaskedImageF(stampSize, planeDict=maskPlaneDict)
141 annulusMask = annulusImage.mask
142 annulusMask.array[:] = 2 ** maskPlaneDict["NO_DATA"]
143 annulus.copyMaskedImage(self.stamp_im, annulusImage)
144 # set mask planes to be ignored
145 andMask = reduce(ior, (annulusMask.getPlaneBitMask(bm) for bm in badMaskPlanes))
146 statsControl.setAndMask(andMask)
147 # compute annularFlux
148 annulusStat = makeStatistics(annulusImage, statsFlag, statsControl)
149 self.annularFlux = annulusStat.getValue()
150 if np.isnan(self.annularFlux):
151 raise RuntimeError("Annular flux computation failed, likely because no pixels were valid.")
152 # normalize stamps
153 self.stamp_im.image.array /= self.annularFlux
154 return None
157class BrightStarStamps(Stamps):
158 """Collection of bright star stamps and associated metadata.
160 Parameters
161 ----------
162 starStamps : `collections.abc.Sequence` [`BrightStarStamp`]
163 Sequence of star stamps. Cannot contain both normalized and
164 unnormalized stamps.
165 innerRadius : `int`, optional
166 Inner radius value, in pixels. This and ``outerRadius`` define the
167 annulus used to compute the ``"annularFlux"`` values within each
168 ``starStamp``. Must be provided if ``normalize`` is True.
169 outerRadius : `int`, optional
170 Outer radius value, in pixels. This and ``innerRadius`` define the
171 annulus used to compute the ``"annularFlux"`` values within each
172 ``starStamp``. Must be provided if ``normalize`` is True.
173 nb90Rots : `int`, optional
174 Number of 90 degree rotations required to compensate for detector
175 orientation.
176 metadata : `~lsst.daf.base.PropertyList`, optional
177 Metadata associated with the bright stars.
178 use_mask : `bool`
179 If `True` read and write mask data. Default `True`.
180 use_variance : `bool`
181 If ``True`` read and write variance data. Default ``False``.
182 use_archive : `bool`
183 If ``True`` read and write an Archive that contains a Persistable
184 associated with each stamp. In the case of bright stars, this is
185 usually a ``TransformPoint2ToPoint2``, used to warp each stamp
186 to the same pixel grid before stacking.
188 Raises
189 ------
190 ValueError
191 Raised if one of the star stamps provided does not contain the
192 required keys.
193 AttributeError
194 Raised if there is a mix-and-match of normalized and unnormalized
195 stamps, stamps normalized with different annulus definitions, or if
196 stamps are to be normalized but annular radii were not provided.
198 Notes
199 -----
200 A butler can be used to read only a part of the stamps, specified by a
201 bbox:
203 >>> starSubregions = butler.get(
204 "brightStarStamps",
205 dataId,
206 parameters={"bbox": bbox}
207 )
208 """
210 def __init__(
211 self,
212 starStamps,
213 innerRadius=None,
214 outerRadius=None,
215 nb90Rots=None,
216 metadata=None,
217 use_mask=True,
218 use_variance=False,
219 use_archive=False,
220 ):
221 super().__init__(starStamps, metadata, use_mask, use_variance, use_archive)
222 # Ensure stamps contain a flux measurement if and only if they are
223 # already expected to be normalized
224 self._checkNormalization(False, innerRadius, outerRadius)
225 self._innerRadius, self._outerRadius = innerRadius, outerRadius
226 if innerRadius is not None and outerRadius is not None:
227 self.normalized = True
228 else:
229 self.normalized = False
230 self.nb90Rots = nb90Rots
232 @classmethod
233 def initAndNormalize(
234 cls,
235 starStamps,
236 innerRadius,
237 outerRadius,
238 nb90Rots=None,
239 metadata=None,
240 use_mask=True,
241 use_variance=False,
242 use_archive=False,
243 imCenter=None,
244 discardNanFluxObjects=True,
245 statsControl=StatisticsControl(),
246 statsFlag=stringToStatisticsProperty("MEAN"),
247 badMaskPlanes=("BAD", "SAT", "NO_DATA"),
248 ):
249 """Normalize a set of bright star stamps and initialize a
250 BrightStarStamps instance.
252 Since the center of bright stars are saturated and/or heavily affected
253 by ghosts, we measure their flux in an annulus with a large enough
254 inner radius to avoid the most severe ghosts and contain enough
255 non-saturated pixels.
257 Parameters
258 ----------
259 starStamps : `collections.abc.Sequence` [`BrightStarStamp`]
260 Sequence of star stamps. Cannot contain both normalized and
261 unnormalized stamps.
262 innerRadius : `int`
263 Inner radius value, in pixels. This and ``outerRadius`` define the
264 annulus used to compute the ``"annularFlux"`` values within each
265 ``starStamp``.
266 outerRadius : `int`
267 Outer radius value, in pixels. This and ``innerRadius`` define the
268 annulus used to compute the ``"annularFlux"`` values within each
269 ``starStamp``.
270 nb90Rots : `int`, optional
271 Number of 90 degree rotations required to compensate for detector
272 orientation.
273 metadata : `~lsst.daf.base.PropertyList`, optional
274 Metadata associated with the bright stars.
275 use_mask : `bool`
276 If `True` read and write mask data. Default `True`.
277 use_variance : `bool`
278 If ``True`` read and write variance data. Default ``False``.
279 use_archive : `bool`
280 If ``True`` read and write an Archive that contains a Persistable
281 associated with each stamp. In the case of bright stars, this is
282 usually a ``TransformPoint2ToPoint2``, used to warp each stamp
283 to the same pixel grid before stacking.
284 imCenter : `collections.abc.Sequence`, optional
285 Center of the object, in pixels. If not provided, the center of the
286 first stamp's pixel grid will be used.
287 discardNanFluxObjects : `bool`
288 Whether objects with NaN annular flux should be discarded.
289 If False, these objects will not be normalized.
290 statsControl : `~lsst.afw.math.statistics.StatisticsControl`, optional
291 StatisticsControl to be used when computing flux over all pixels
292 within the annulus.
293 statsFlag : `~lsst.afw.math.statistics.Property`, optional
294 statsFlag to be passed on to ``~lsst.afw.math.makeStatistics`` to
295 compute annularFlux. Defaults to a simple MEAN.
296 badMaskPlanes : `collections.abc.Collection` [`str`]
297 Collection of mask planes to ignore when computing annularFlux.
299 Raises
300 ------
301 ValueError
302 Raised if one of the star stamps provided does not contain the
303 required keys.
304 AttributeError
305 Raised if there is a mix-and-match of normalized and unnormalized
306 stamps, stamps normalized with different annulus definitions, or if
307 stamps are to be normalized but annular radii were not provided.
308 """
309 if imCenter is None:
310 stampSize = starStamps[0].stamp_im.getDimensions()
311 imCenter = stampSize[0] // 2, stampSize[1] // 2
312 # Create SpanSet of annulus
313 outerCircle = SpanSet.fromShape(outerRadius, Stencil.CIRCLE, offset=imCenter)
314 innerCircle = SpanSet.fromShape(innerRadius, Stencil.CIRCLE, offset=imCenter)
315 annulus = outerCircle.intersectNot(innerCircle)
316 # Initialize (unnormalized) brightStarStamps instance
317 bss = cls(
318 starStamps,
319 innerRadius=None,
320 outerRadius=None,
321 nb90Rots=nb90Rots,
322 metadata=metadata,
323 use_mask=use_mask,
324 use_variance=use_variance,
325 use_archive=use_archive,
326 )
327 # Ensure no stamps had already been normalized
328 bss._checkNormalization(True, innerRadius, outerRadius)
329 bss._innerRadius, bss._outerRadius = innerRadius, outerRadius
330 # Apply normalization
331 for j, stamp in enumerate(bss._stamps):
332 try:
333 stamp.measureAndNormalize(
334 annulus, statsControl=statsControl, statsFlag=statsFlag, badMaskPlanes=badMaskPlanes
335 )
336 except RuntimeError:
337 # Optionally keep NaN flux objects, for bookkeeping purposes,
338 # and to avoid having to re-find and redo the preprocessing
339 # steps needed before bright stars can be subtracted.
340 if discardNanFluxObjects:
341 bss._stamps.pop(j)
342 else:
343 stamp.annularFlux = np.nan
344 bss.normalized = True
345 return bss
347 def _refresh_metadata(self):
348 """Refresh metadata. Should be called before writing the object out."""
349 # add full list of positions, Gaia magnitudes, IDs and annularFlxes to
350 # shared metadata
351 self._metadata["G_MAGS"] = self.getMagnitudes()
352 self._metadata["GAIA_IDS"] = self.getGaiaIds()
353 positions = self.getPositions()
354 self._metadata["X0S"] = [xy0[0] for xy0 in positions]
355 self._metadata["Y0S"] = [xy0[1] for xy0 in positions]
356 self._metadata["ANNULAR_FLUXES"] = self.getAnnularFluxes()
357 self._metadata["NORMALIZED"] = self.normalized
358 self._metadata["INNER_RADIUS"] = self._innerRadius
359 self._metadata["OUTER_RADIUS"] = self._outerRadius
360 if self.nb90Rots is not None:
361 self._metadata["NB_90_ROTS"] = self.nb90Rots
362 return None
364 @classmethod
365 def readFits(cls, filename):
366 """Build an instance of this class from a file.
368 Parameters
369 ----------
370 filename : `str`
371 Name of the file to read
372 """
373 return cls.readFitsWithOptions(filename, None)
375 @classmethod
376 def readFitsWithOptions(cls, filename, options):
377 """Build an instance of this class with options.
379 Parameters
380 ----------
381 filename : `str`
382 Name of the file to read
383 options : `PropertyList`
384 Collection of metadata parameters
385 """
386 stamps, metadata = readFitsWithOptions(filename, BrightStarStamp.factory, options)
387 nb90Rots = metadata["NB_90_ROTS"] if "NB_90_ROTS" in metadata else None
388 if metadata["NORMALIZED"]:
389 return cls(
390 stamps,
391 innerRadius=metadata["INNER_RADIUS"],
392 outerRadius=metadata["OUTER_RADIUS"],
393 nb90Rots=nb90Rots,
394 metadata=metadata,
395 use_mask=metadata["HAS_MASK"],
396 use_variance=metadata["HAS_VARIANCE"],
397 use_archive=metadata["HAS_ARCHIVE"],
398 )
399 else:
400 return cls(
401 stamps,
402 nb90Rots=nb90Rots,
403 metadata=metadata,
404 use_mask=metadata["HAS_MASK"],
405 use_variance=metadata["HAS_VARIANCE"],
406 use_archive=metadata["HAS_ARCHIVE"],
407 )
409 def append(self, item, innerRadius=None, outerRadius=None):
410 """Add an additional bright star stamp.
412 Parameters
413 ----------
414 item : `BrightStarStamp`
415 Bright star stamp to append.
416 innerRadius : `int`, optional
417 Inner radius value, in pixels. This and ``outerRadius`` define the
418 annulus used to compute the ``"annularFlux"`` values within each
419 ``BrightStarStamp``.
420 outerRadius : `int`, optional
421 Outer radius value, in pixels. This and ``innerRadius`` define the
422 annulus used to compute the ``"annularFlux"`` values within each
423 ``BrightStarStamp``.
424 """
425 if not isinstance(item, BrightStarStamp):
426 raise ValueError(f"Can only add instances of BrightStarStamp, got {type(item)}.")
427 if (item.annularFlux is None) == self.normalized:
428 raise AttributeError(
429 "Trying to append an unnormalized stamp to a normalized BrightStarStamps "
430 "instance, or vice-versa."
431 )
432 else:
433 self._checkRadius(innerRadius, outerRadius)
434 self._stamps.append(item)
435 return None
437 def extend(self, bss):
438 """Extend BrightStarStamps instance by appending elements from another
439 instance.
441 Parameters
442 ----------
443 bss : `BrightStarStamps`
444 Other instance to concatenate.
445 """
446 if not isinstance(bss, BrightStarStamps):
447 raise ValueError(f"Can only extend with a BrightStarStamps object. Got {type(bss)}.")
448 self._checkRadius(bss._innerRadius, bss._outerRadius)
449 self._stamps += bss._stamps
451 def getMagnitudes(self):
452 """Retrieve Gaia G magnitudes for each star.
454 Returns
455 -------
456 gaiaGMags : `list` [`float`]
457 """
458 return [stamp.gaiaGMag for stamp in self._stamps]
460 def getGaiaIds(self):
461 """Retrieve Gaia IDs for each star.
463 Returns
464 -------
465 gaiaIds : `list` [`int`]
466 """
467 return [stamp.gaiaId for stamp in self._stamps]
469 def getAnnularFluxes(self):
470 """Retrieve normalization factors for each star.
472 These are computed by integrating the flux in annulus centered on the
473 bright star, far enough from center to be beyond most severe ghosts and
474 saturation. The inner and outer radii that define the annulus can be
475 recovered from the metadata.
477 Returns
478 -------
479 annularFluxes : `list` [`float`]
480 """
481 return [stamp.annularFlux for stamp in self._stamps]
483 def selectByMag(self, magMin=None, magMax=None):
484 """Return the subset of bright star stamps for objects with specified
485 magnitude cuts (in Gaia G).
487 Parameters
488 ----------
489 magMin : `float`, optional
490 Keep only stars fainter than this value.
491 magMax : `float`, optional
492 Keep only stars brighter than this value.
493 """
494 subset = [
495 stamp
496 for stamp in self._stamps
497 if (magMin is None or stamp.gaiaGMag > magMin) and (magMax is None or stamp.gaiaGMag < magMax)
498 ]
499 # This is an optimization to save looping over the init argument when
500 # it is already guaranteed to be the correct type
501 instance = BrightStarStamps(
502 (), innerRadius=self._innerRadius, outerRadius=self._outerRadius, metadata=self._metadata
503 )
504 instance._stamps = subset
505 return instance
507 def _checkRadius(self, innerRadius, outerRadius):
508 """Ensure provided annulus radius is consistent with that already
509 present in the instance, or with arguments passed on at initialization.
510 """
511 if innerRadius != self._innerRadius or outerRadius != self._outerRadius:
512 raise AttributeError(
513 "Trying to mix stamps normalized with annulus radii "
514 f"{innerRadius, outerRadius} with those of BrightStarStamp instance\n"
515 f"(computed with annular radii {self._innerRadius, self._outerRadius})."
516 )
518 def _checkNormalization(self, normalize, innerRadius, outerRadius):
519 """Ensure there is no mixing of normalized and unnormalized stars, and
520 that, if requested, normalization can be performed.
521 """
522 noneFluxCount = self.getAnnularFluxes().count(None)
523 nStamps = len(self)
524 nFluxVals = nStamps - noneFluxCount
525 if noneFluxCount and noneFluxCount < nStamps:
526 # at least one stamp contains an annularFlux value (i.e. has been
527 # normalized), but not all of them do
528 raise AttributeError(
529 f"Only {nFluxVals} stamps contain an annularFlux value.\nAll stamps in a "
530 "BrightStarStamps instance must either be normalized with the same annulus "
531 "definition, or none of them can contain an annularFlux value."
532 )
533 elif normalize:
534 # stamps are to be normalized; ensure annular radii are specified
535 # and they have no annularFlux
536 if innerRadius is None or outerRadius is None:
537 raise AttributeError(
538 "For stamps to be normalized (normalize=True), please provide a valid "
539 "value (in pixels) for both innerRadius and outerRadius."
540 )
541 elif noneFluxCount < nStamps:
542 raise AttributeError(
543 f"{nFluxVals} stamps already contain an annularFlux value. For stamps to "
544 "be normalized, all their annularFlux must be None."
545 )
546 elif innerRadius is not None and outerRadius is not None:
547 # Radii provided, but normalize=False; check that stamps
548 # already contain annularFluxes
549 if noneFluxCount:
550 raise AttributeError(
551 f"{noneFluxCount} stamps contain no annularFlux, but annular radius "
552 "values were provided and normalize=False.\nTo normalize stamps, set "
553 "normalize to True."
554 )
555 else:
556 # At least one radius value is missing; ensure no stamps have
557 # already been normalized
558 if nFluxVals:
559 raise AttributeError(
560 f"{nFluxVals} stamps contain an annularFlux value. If stamps have "
561 "been normalized, the innerRadius and outerRadius values used must "
562 "be provided."
563 )
564 return None