Coverage for python/lsst/meas/algorithms/brightStarStamps.py : 24%

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 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/>.
21#
22"""Collection of small images (stamps), each centered on a bright star.
23"""
25__all__ = ["BrightStarStamp", "BrightStarStamps"]
27from dataclasses import dataclass
28from operator import ior
29from functools import reduce
30from typing import Optional
31import numpy as np
33from lsst.afw.image import MaskedImageF
34from lsst.afw import geom as afwGeom
35from lsst.afw import math as afwMath
36from lsst.afw import table as afwTable
37from .stamps import StampsBase, AbstractStamp, readFitsWithOptions
40@dataclass
41class BrightStarStamp(AbstractStamp):
42 """Single stamp centered on a bright star, normalized by its
43 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 annularFlux : `Optional[float]`
54 Flux in an annulus around the object
55 """
56 stamp_im: MaskedImageF
57 gaiaGMag: float
58 gaiaId: int
59 archive_element: Optional[afwTable.io.Persistable] = None
60 annularFlux: Optional[float] = None
62 @classmethod
63 def factory(cls, stamp_im, metadata, idx, archive_element=None):
64 """This method is needed to service the FITS reader.
65 We need a standard interface to construct objects like this.
66 Parameters needed to construct this object are passed in via
67 a metadata dictionary and then passed to the constructor of
68 this class. This particular factory method requires keys:
69 G_MAGS, GAIA_IDS, and ANNULAR_FLUXES. They should each
70 point to lists of values.
72 Parameters
73 ----------
74 stamp_im : `lsst.afw.image.MaskedImage`
75 Pixel data to pass to the constructor
76 metadata : `dict`
77 Dictionary containing the information
78 needed by the constructor.
79 idx : `int`
80 Index into the lists in ``metadata``
81 archive_element : `lsst.afwTable.io.Persistable`, optional
82 Archive element (e.g. Transform or WCS) associated with this stamp.
84 Returns
85 -------
86 brightstarstamp : `BrightStarStamp`
87 An instance of this class
88 """
89 return cls(stamp_im=stamp_im,
90 gaiaGMag=metadata.getArray('G_MAGS')[idx],
91 gaiaId=metadata.getArray('GAIA_IDS')[idx],
92 archive_element=archive_element,
93 annularFlux=metadata.getArray('ANNULAR_FLUXES')[idx])
95 def measureAndNormalize(self, annulus, statsControl=afwMath.StatisticsControl(),
96 statsFlag=afwMath.stringToStatisticsProperty("MEAN"),
97 badMaskPlanes=('BAD', 'SAT', 'NO_DATA')):
98 """Compute "annularFlux", the integrated flux within an annulus
99 around an object's center, and normalize it.
101 Since the center of bright stars are saturated and/or heavily affected
102 by ghosts, we measure their flux in an annulus with a large enough
103 inner radius to avoid the most severe ghosts and contain enough
104 non-saturated pixels.
106 Parameters
107 ----------
108 annulus : `lsst.afw.geom.spanSet.SpanSet`
109 SpanSet containing the annulus to use for normalization.
110 statsControl : `lsst.afw.math.statistics.StatisticsControl`, optional
111 StatisticsControl to be used when computing flux over all pixels
112 within the annulus.
113 statsFlag : `lsst.afw.math.statistics.Property`, optional
114 statsFlag to be passed on to ``afwMath.makeStatistics`` to compute
115 annularFlux. Defaults to a simple MEAN.
116 badMaskPlanes : `collections.abc.Collection` [`str`]
117 Collection of mask planes to ignore when computing annularFlux.
118 """
119 stampSize = self.stamp_im.getDimensions()
120 # create image with the same pixel values within annulus, NO_DATA
121 # elsewhere
122 maskPlaneDict = self.stamp_im.mask.getMaskPlaneDict()
123 annulusImage = MaskedImageF(stampSize, planeDict=maskPlaneDict)
124 annulusMask = annulusImage.mask
125 annulusMask.array[:] = 2**maskPlaneDict['NO_DATA']
126 annulus.copyMaskedImage(self.stamp_im, annulusImage)
127 # set mask planes to be ignored
128 andMask = reduce(ior, (annulusMask.getPlaneBitMask(bm) for bm in badMaskPlanes))
129 statsControl.setAndMask(andMask)
130 # compute annularFlux
131 annulusStat = afwMath.makeStatistics(annulusImage, statsFlag, statsControl)
132 self.annularFlux = annulusStat.getValue()
133 if np.isnan(self.annularFlux):
134 raise RuntimeError("Annular flux computation failed, likely because no pixels were valid.")
135 # normalize stamps
136 self.stamp_im.image.array /= self.annularFlux
137 return None
140class BrightStarStamps(StampsBase):
141 """Collection of bright star stamps and associated metadata.
143 Parameters
144 ----------
145 starStamps : `collections.abc.Sequence` [`BrightStarStamp`]
146 Sequence of star stamps. Cannot contain both normalized and
147 unnormalized stamps.
148 innerRadius : `int`, optional
149 Inner radius value, in pixels. This and ``outerRadius`` define the
150 annulus used to compute the ``"annularFlux"`` values within each
151 ``starStamp``. Must be provided if ``normalize`` is True.
152 outerRadius : `int`, optional
153 Outer radius value, in pixels. This and ``innerRadius`` define the
154 annulus used to compute the ``"annularFlux"`` values within each
155 ``starStamp``. Must be provided if ``normalize`` is True.
156 metadata : `lsst.daf.base.PropertyList`, optional
157 Metadata associated with the bright stars.
158 use_mask : `bool`
159 If `True` read and write mask data. Default `True`.
160 use_variance : `bool`
161 If ``True`` read and write variance data. Default ``False``.
162 use_archive : `bool`
163 If ``True`` read and write an Archive that contains a Persistable
164 associated with each stamp. In the case of bright stars, this is
165 usually a ``TransformPoint2ToPoint2``, used to warp each stamp
166 to the same pixel grid before stacking.
168 Raises
169 ------
170 ValueError
171 Raised if one of the star stamps provided does not contain the
172 required keys.
173 AttributeError
174 Raised if there is a mix-and-match of normalized and unnormalized
175 stamps, stamps normalized with different annulus definitions, or if
176 stamps are to be normalized but annular radii were not provided.
179 Notes
180 -----
181 A butler can be used to read only a part of the stamps, specified by a
182 bbox:
184 >>> starSubregions = butler.get("brightStarStamps", dataId, parameters={'bbox': bbox})
185 """
187 def __init__(self, starStamps, innerRadius=None, outerRadius=None,
188 metadata=None, use_mask=True, use_variance=False, use_archive=False):
189 super().__init__(starStamps, metadata, use_mask, use_variance, use_archive)
190 # Ensure stamps contain a flux measurement if and only if they are
191 # already expected to be normalized
192 self._checkNormalization(False, innerRadius, outerRadius)
193 self._innerRadius, self._outerRadius = innerRadius, outerRadius
194 if innerRadius is not None and outerRadius is not None:
195 self.normalized = True
196 else:
197 self.normalized = False
199 @classmethod
200 def initAndNormalize(cls, starStamps, innerRadius, outerRadius,
201 metadata=None, use_mask=True, use_variance=False,
202 imCenter=None, discardNanFluxObjects=True,
203 statsControl=afwMath.StatisticsControl(),
204 statsFlag=afwMath.stringToStatisticsProperty("MEAN"),
205 badMaskPlanes=('BAD', 'SAT', 'NO_DATA')):
206 """Normalize a set of bright star stamps and initialize a
207 BrightStarStamps instance.
209 Since the center of bright stars are saturated and/or heavily affected
210 by ghosts, we measure their flux in an annulus with a large enough
211 inner radius to avoid the most severe ghosts and contain enough
212 non-saturated pixels.
214 Parameters
215 ----------
216 starStamps : `collections.abc.Sequence` [`BrightStarStamp`]
217 Sequence of star stamps. Cannot contain both normalized and
218 unnormalized stamps.
219 innerRadius : `int`
220 Inner radius value, in pixels. This and ``outerRadius`` define the
221 annulus used to compute the ``"annularFlux"`` values within each
222 ``starStamp``.
223 outerRadius : `int`
224 Outer radius value, in pixels. This and ``innerRadius`` define the
225 annulus used to compute the ``"annularFlux"`` values within each
226 ``starStamp``.
227 metadata : `lsst.daf.base.PropertyList`, optional
228 Metadata associated with the bright stars.
229 use_mask : `bool`
230 If `True` read and write mask data. Default `True`.
231 use_variance : `bool`
232 If ``True`` read and write variance data. Default ``False``.
233 imCenter : `collections.abc.Sequence`, optional
234 Center of the object, in pixels. If not provided, the center of the
235 first stamp's pixel grid will be used.
236 discardNanFluxObjects : `bool`
237 Whether objects with NaN annular flux should be discarded.
238 If False, these objects will not be normalized.
239 statsControl : `lsst.afw.math.statistics.StatisticsControl`, optional
240 StatisticsControl to be used when computing flux over all pixels
241 within the annulus.
242 statsFlag : `lsst.afw.math.statistics.Property`, optional
243 statsFlag to be passed on to ``afwMath.makeStatistics`` to compute
244 annularFlux. Defaults to a simple MEAN.
245 badMaskPlanes : `collections.abc.Collection` [`str`]
246 Collection of mask planes to ignore when computing annularFlux.
248 Raises
249 ------
250 ValueError
251 Raised if one of the star stamps provided does not contain the
252 required keys.
253 AttributeError
254 Raised if there is a mix-and-match of normalized and unnormalized
255 stamps, stamps normalized with different annulus definitions, or if
256 stamps are to be normalized but annular radii were not provided.
257 """
258 if imCenter is None:
259 stampSize = starStamps[0].stamp_im.getDimensions()
260 imCenter = stampSize[0]//2, stampSize[1]//2
261 # Create SpanSet of annulus
262 outerCircle = afwGeom.SpanSet.fromShape(outerRadius, afwGeom.Stencil.CIRCLE, offset=imCenter)
263 innerCircle = afwGeom.SpanSet.fromShape(innerRadius, afwGeom.Stencil.CIRCLE, offset=imCenter)
264 annulus = outerCircle.intersectNot(innerCircle)
265 # Initialize (unnormalized) brightStarStamps instance
266 bss = cls(starStamps, innerRadius=None, outerRadius=None,
267 metadata=metadata, use_mask=use_mask,
268 use_variance=use_variance)
269 # Ensure no stamps had already been normalized
270 bss._checkNormalization(True, innerRadius, outerRadius)
271 bss._innerRadius, bss._outerRadius = innerRadius, outerRadius
272 # Apply normalization
273 for j, stamp in enumerate(bss._stamps):
274 try:
275 stamp.measureAndNormalize(annulus, statsControl=statsControl, statsFlag=statsFlag,
276 badMaskPlanes=badMaskPlanes)
277 except RuntimeError:
278 # Optionally keep NaN flux objects, for bookkeeping purposes,
279 # and to avoid having to re-find and redo the preprocessing
280 # steps needed before bright stars can be subtracted.
281 if discardNanFluxObjects:
282 bss._stamps.pop(j)
283 else:
284 stamp.annularFlux = np.nan
285 bss.normalized = True
286 return bss
288 def _refresh_metadata(self):
289 """Refresh the metadata. Should be called before writing this object
290 out.
291 """
292 # add full list of Gaia magnitudes, IDs and annularFlxes to shared
293 # metadata
294 self._metadata["G_MAGS"] = self.getMagnitudes()
295 self._metadata["GAIA_IDS"] = self.getGaiaIds()
296 self._metadata["ANNULAR_FLUXES"] = self.getAnnularFluxes()
297 self._metadata["NORMALIZED"] = self.normalized
298 self._metadata["INNER_RADIUS"] = self._innerRadius
299 self._metadata["OUTER_RADIUS"] = self._outerRadius
300 return None
302 @classmethod
303 def readFits(cls, filename):
304 """Build an instance of this class from a file.
306 Parameters
307 ----------
308 filename : `str`
309 Name of the file to read
310 """
311 return cls.readFitsWithOptions(filename, None)
313 @classmethod
314 def readFitsWithOptions(cls, filename, options):
315 """Build an instance of this class with options.
317 Parameters
318 ----------
319 filename : `str`
320 Name of the file to read
321 options : `PropertyList`
322 Collection of metadata parameters
323 """
324 stamps, metadata = readFitsWithOptions(filename, BrightStarStamp.factory, options)
325 if metadata["NORMALIZED"]:
326 return cls(stamps,
327 innerRadius=metadata["INNER_RADIUS"], outerRadius=metadata["OUTER_RADIUS"],
328 metadata=metadata, use_mask=metadata['HAS_MASK'],
329 use_variance=metadata['HAS_VARIANCE'], use_archive=metadata['HAS_ARCHIVE'])
330 else:
331 return cls(stamps, metadata=metadata, use_mask=metadata['HAS_MASK'],
332 use_variance=metadata['HAS_VARIANCE'], use_archive=metadata['HAS_ARCHIVE'])
334 def append(self, item, innerRadius=None, outerRadius=None):
335 """Add an additional bright star stamp.
337 Parameters
338 ----------
339 item : `BrightStarStamp`
340 Bright star stamp to append.
341 innerRadius : `int`, optional
342 Inner radius value, in pixels. This and ``outerRadius`` define the
343 annulus used to compute the ``"annularFlux"`` values within each
344 ``BrightStarStamp``.
345 outerRadius : `int`, optional
346 Outer radius value, in pixels. This and ``innerRadius`` define the
347 annulus used to compute the ``"annularFlux"`` values within each
348 ``BrightStarStamp``.
349 """
350 if not isinstance(item, BrightStarStamp):
351 raise ValueError(f"Can only add instances of BrightStarStamp, got {type(item)}.")
352 if (item.annularFlux is None) == self.normalized:
353 raise AttributeError("Trying to append an unnormalized stamp to a normalized BrightStarStamps "
354 "instance, or vice-versa.")
355 else:
356 self._checkRadius(innerRadius, outerRadius)
357 self._stamps.append(item)
358 return None
360 def extend(self, bss):
361 """Extend BrightStarStamps instance by appending elements from another
362 instance.
364 Parameters
365 ----------
366 bss : `BrightStarStamps`
367 Other instance to concatenate.
368 """
369 if not isinstance(bss, BrightStarStamps):
370 raise ValueError('Can only extend with a BrightStarStamps object. '
371 f'Got {type(bss)}.')
372 self._checkRadius(bss._innerRadius, bss._outerRadius)
373 self._stamps += bss._stamps
375 def getMagnitudes(self):
376 """Retrieve Gaia G magnitudes for each star.
378 Returns
379 -------
380 gaiaGMags : `list` [`float`]
381 """
382 return [stamp.gaiaGMag for stamp in self._stamps]
384 def getGaiaIds(self):
385 """Retrieve Gaia IDs for each star.
387 Returns
388 -------
389 gaiaIds : `list` [`int`]
390 """
391 return [stamp.gaiaId for stamp in self._stamps]
393 def getAnnularFluxes(self):
394 """Retrieve normalization factors for each star.
396 These are computed by integrating the flux in annulus centered on the
397 bright star, far enough from center to be beyond most severe ghosts and
398 saturation. The inner and outer radii that define the annulus can be
399 recovered from the metadata.
401 Returns
402 -------
403 annularFluxes : `list` [`float`]
404 """
405 return [stamp.annularFlux for stamp in self._stamps]
407 def selectByMag(self, magMin=None, magMax=None):
408 """Return the subset of bright star stamps for objects with specified
409 magnitude cuts (in Gaia G).
411 Parameters
412 ----------
413 magMin : `float`, optional
414 Keep only stars fainter than this value.
415 magMax : `float`, optional
416 Keep only stars brighter than this value.
417 """
418 subset = [stamp for stamp in self._stamps
419 if (magMin is None or stamp.gaiaGMag > magMin)
420 and (magMax is None or stamp.gaiaGMag < magMax)]
421 # This is an optimization to save looping over the init argument when
422 # it is already guaranteed to be the correct type
423 instance = BrightStarStamps((),
424 innerRadius=self._innerRadius, outerRadius=self._outerRadius,
425 metadata=self._metadata)
426 instance._stamps = subset
427 return instance
429 def _checkRadius(self, innerRadius, outerRadius):
430 """Ensure provided annulus radius is consistent with that already
431 present in the instance, or with arguments passed on at initialization.
432 """
433 if innerRadius != self._innerRadius or outerRadius != self._outerRadius:
434 raise AttributeError("Trying to mix stamps normalized with annulus radii "
435 f"{innerRadius, outerRadius} with those of BrightStarStamp instance\n"
436 f"(computed with annular radii {self._innerRadius, self._outerRadius}).")
438 def _checkNormalization(self, normalize, innerRadius, outerRadius):
439 """Ensure there is no mixing of normalized and unnormalized stars, and
440 that, if requested, normalization can be performed.
441 """
442 noneFluxCount = self.getAnnularFluxes().count(None)
443 nStamps = len(self)
444 nFluxVals = nStamps - noneFluxCount
445 if noneFluxCount and noneFluxCount < nStamps:
446 # at least one stamp contains an annularFlux value (i.e. has been
447 # normalized), but not all of them do
448 raise AttributeError(f"Only {nFluxVals} stamps contain an annularFlux value.\nAll stamps in a "
449 "BrightStarStamps instance must either be normalized with the same annulus "
450 "definition, or none of them can contain an annularFlux value.")
451 elif normalize:
452 # stamps are to be normalized; ensure annular radii are specified
453 # and they have no annularFlux
454 if innerRadius is None or outerRadius is None:
455 raise AttributeError("For stamps to be normalized (normalize=True), please provide a valid "
456 "value (in pixels) for both innerRadius and outerRadius.")
457 elif noneFluxCount < nStamps:
458 raise AttributeError(f"{nFluxVals} stamps already contain an annularFlux value. For stamps to"
459 " be normalized, all their annularFlux must be None.")
460 elif innerRadius is not None and outerRadius is not None:
461 # Radii provided, but normalize=False; check that stamps
462 # already contain annularFluxes
463 if noneFluxCount:
464 raise AttributeError(f"{noneFluxCount} stamps contain no annularFlux, but annular radius "
465 "values were provided and normalize=False.\nTo normalize stamps, set "
466 "normalize to True.")
467 else:
468 # At least one radius value is missing; ensure no stamps have
469 # already been normalized
470 if nFluxVals:
471 raise AttributeError(f"{nFluxVals} stamps contain an annularFlux value. If stamps have "
472 "been normalized, the innerRadius and outerRadius values used must "
473 "be provided.")
474 return None