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

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