Coverage for python/lsst/meas/algorithms/brightStarStamps.py: 20%
193 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-04 12:18 +0000
« prev ^ index » next coverage.py v7.4.0, created at 2024-01-04 12:18 +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"]
26import logging
27from collections.abc import Collection
28from dataclasses import dataclass
29from functools import reduce
30from operator import ior
32import numpy as np
33from lsst.afw.geom import SpanSet, Stencil
34from lsst.afw.image import MaskedImageF
35from lsst.afw.math import Property, StatisticsControl, makeStatistics, stringToStatisticsProperty
36from lsst.afw.table.io import Persistable
37from lsst.geom import Point2I
39from .stamps import AbstractStamp, Stamps, readFitsWithOptions
41logger = logging.getLogger(__name__)
44@dataclass
45class BrightStarStamp(AbstractStamp):
46 """Single stamp centered on a bright star, normalized by its annularFlux.
48 Parameters
49 ----------
50 stamp_im : `~lsst.afw.image.MaskedImage`
51 Pixel data for this postage stamp
52 gaiaGMag : `float`
53 Gaia G magnitude for the object in this stamp
54 gaiaId : `int`
55 Gaia object identifier
56 position : `~lsst.geom.Point2I`
57 Origin of the stamps in its origin exposure (pixels)
58 archive_element : `~lsst.afw.table.io.Persistable` or None, optional
59 Archive element (e.g. Transform or WCS) associated with this stamp.
60 annularFlux : `float` or None, optional
61 Flux in an annulus around the object
62 """
64 stamp_im: MaskedImageF
65 gaiaGMag: float
66 gaiaId: int
67 position: Point2I
68 archive_element: Persistable | None = None
69 annularFlux: float | None = None
70 minValidAnnulusFraction: float = 0.0
71 optimalInnerRadius: int | None = None
72 optimalOuterRadius: int | None = None
74 @classmethod
75 def factory(cls, stamp_im, metadata, idx, archive_element=None, minValidAnnulusFraction=0.0):
76 """This method is needed to service the FITS reader. We need a standard
77 interface to construct objects like this. Parameters needed to
78 construct this object are passed in via a metadata dictionary and then
79 passed to the constructor of this class. This particular factory
80 method requires keys: G_MAGS, GAIA_IDS, and ANNULAR_FLUXES. They should
81 each point to lists of values.
83 Parameters
84 ----------
85 stamp_im : `~lsst.afw.image.MaskedImage`
86 Pixel data to pass to the constructor
87 metadata : `dict`
88 Dictionary containing the information
89 needed by the constructor.
90 idx : `int`
91 Index into the lists in ``metadata``
92 archive_element : `~lsst.afw.table.io.Persistable` or None, optional
93 Archive element (e.g. Transform or WCS) associated with this stamp.
94 minValidAnnulusFraction : `float`, optional
95 The fraction of valid pixels within the normalization annulus of a
96 star.
98 Returns
99 -------
100 brightstarstamp : `BrightStarStamp`
101 An instance of this class
102 """
103 if "X0S" in metadata and "Y0S" in metadata:
104 x0 = metadata.getArray("X0S")[idx]
105 y0 = metadata.getArray("Y0S")[idx]
106 position = Point2I(x0, y0)
107 else:
108 position = None
109 return cls(
110 stamp_im=stamp_im,
111 gaiaGMag=metadata.getArray("G_MAGS")[idx],
112 gaiaId=metadata.getArray("GAIA_IDS")[idx],
113 position=position,
114 archive_element=archive_element,
115 annularFlux=metadata.getArray("ANNULAR_FLUXES")[idx],
116 minValidAnnulusFraction=minValidAnnulusFraction,
117 )
119 def measureAndNormalize(
120 self,
121 annulus: SpanSet,
122 statsControl: StatisticsControl = StatisticsControl(),
123 statsFlag: Property = stringToStatisticsProperty("MEAN"),
124 badMaskPlanes: Collection[str] = ("BAD", "SAT", "NO_DATA"),
125 ):
126 """Compute "annularFlux", the integrated flux within an annulus
127 around an object's center, and normalize it.
129 Since the center of bright stars are saturated and/or heavily affected
130 by ghosts, we measure their flux in an annulus with a large enough
131 inner radius to avoid the most severe ghosts and contain enough
132 non-saturated pixels.
134 Parameters
135 ----------
136 annulus : `~lsst.afw.geom.spanSet.SpanSet`
137 SpanSet containing the annulus to use for normalization.
138 statsControl : `~lsst.afw.math.statistics.StatisticsControl`, optional
139 StatisticsControl to be used when computing flux over all pixels
140 within the annulus.
141 statsFlag : `~lsst.afw.math.statistics.Property`, optional
142 statsFlag to be passed on to ``afwMath.makeStatistics`` to compute
143 annularFlux. Defaults to a simple MEAN.
144 badMaskPlanes : `collections.abc.Collection` [`str`]
145 Collection of mask planes to ignore when computing annularFlux.
146 """
147 stampSize = self.stamp_im.getDimensions()
148 # Create image: science pixel values within annulus, NO_DATA elsewhere
149 maskPlaneDict = self.stamp_im.mask.getMaskPlaneDict()
150 annulusImage = MaskedImageF(stampSize, planeDict=maskPlaneDict)
151 annulusMask = annulusImage.mask
152 annulusMask.array[:] = 2 ** maskPlaneDict["NO_DATA"]
153 annulus.copyMaskedImage(self.stamp_im, annulusImage)
154 # Set mask planes to be ignored.
155 andMask = reduce(ior, (annulusMask.getPlaneBitMask(bm) for bm in badMaskPlanes))
156 statsControl.setAndMask(andMask)
158 annulusStat = makeStatistics(annulusImage, statsFlag, statsControl)
159 # Determine the number of valid (unmasked) pixels within the annulus.
160 unMasked = annulusMask.array.size - np.count_nonzero(annulusMask.array)
161 logger.info(
162 "The Star's annulus contains %s valid pixels and the annulus itself contains %s pixels.",
163 unMasked,
164 annulus.getArea(),
165 )
166 if unMasked > (annulus.getArea() * self.minValidAnnulusFraction):
167 # Compute annularFlux.
168 self.annularFlux = annulusStat.getValue()
169 logger.info("Annular flux is: %s", self.annularFlux)
170 else:
171 raise RuntimeError(
172 f"Less than {self.minValidAnnulusFraction * 100}% of pixels within the annulus are valid."
173 )
174 if np.isnan(self.annularFlux):
175 raise RuntimeError("Annular flux computation failed, likely because there are no valid pixels.")
176 if self.annularFlux < 0:
177 raise RuntimeError("The annular flux is negative. The stamp can not be normalized!")
178 # Normalize stamps.
179 self.stamp_im.image.array /= self.annularFlux
180 return None
183class BrightStarStamps(Stamps):
184 """Collection of bright star stamps and associated metadata.
186 Parameters
187 ----------
188 starStamps : `collections.abc.Sequence` [`BrightStarStamp`]
189 Sequence of star stamps. Cannot contain both normalized and
190 unnormalized stamps.
191 innerRadius : `int`, optional
192 Inner radius value, in pixels. This and ``outerRadius`` define the
193 annulus used to compute the ``"annularFlux"`` values within each
194 ``starStamp``. Must be provided if ``normalize`` is True.
195 outerRadius : `int`, optional
196 Outer radius value, in pixels. This and ``innerRadius`` define the
197 annulus used to compute the ``"annularFlux"`` values within each
198 ``starStamp``. Must be provided if ``normalize`` is True.
199 nb90Rots : `int`, optional
200 Number of 90 degree rotations required to compensate for detector
201 orientation.
202 metadata : `~lsst.daf.base.PropertyList`, optional
203 Metadata associated with the bright stars.
204 use_mask : `bool`
205 If `True` read and write mask data. Default `True`.
206 use_variance : `bool`
207 If ``True`` read and write variance data. Default ``False``.
208 use_archive : `bool`
209 If ``True`` read and write an Archive that contains a Persistable
210 associated with each stamp. In the case of bright stars, this is
211 usually a ``TransformPoint2ToPoint2``, used to warp each stamp
212 to the same pixel grid before stacking.
214 Raises
215 ------
216 ValueError
217 Raised if one of the star stamps provided does not contain the
218 required keys.
219 AttributeError
220 Raised if there is a mix-and-match of normalized and unnormalized
221 stamps, stamps normalized with different annulus definitions, or if
222 stamps are to be normalized but annular radii were not provided.
224 Notes
225 -----
226 A butler can be used to read only a part of the stamps, specified by a
227 bbox:
229 >>> starSubregions = butler.get(
230 "brightStarStamps",
231 dataId,
232 parameters={"bbox": bbox}
233 )
234 """
236 def __init__(
237 self,
238 starStamps,
239 innerRadius=None,
240 outerRadius=None,
241 nb90Rots=None,
242 metadata=None,
243 use_mask=True,
244 use_variance=False,
245 use_archive=False,
246 ):
247 super().__init__(starStamps, metadata, use_mask, use_variance, use_archive)
248 # Ensure stamps contain a flux measure if expected to be normalized.
249 self._checkNormalization(False, innerRadius, outerRadius)
250 self._innerRadius, self._outerRadius = innerRadius, outerRadius
251 if innerRadius is not None and outerRadius is not None:
252 self.normalized = True
253 else:
254 self.normalized = False
255 self.nb90Rots = nb90Rots
257 @classmethod
258 def initAndNormalize(
259 cls,
260 starStamps,
261 innerRadius,
262 outerRadius,
263 nb90Rots=None,
264 metadata=None,
265 use_mask=True,
266 use_variance=False,
267 use_archive=False,
268 imCenter=None,
269 discardNanFluxObjects=True,
270 forceFindFlux=False,
271 statsControl=StatisticsControl(),
272 statsFlag=stringToStatisticsProperty("MEAN"),
273 badMaskPlanes=("BAD", "SAT", "NO_DATA"),
274 ):
275 """Normalize a set of bright star stamps and initialize a
276 BrightStarStamps instance.
278 Since the center of bright stars are saturated and/or heavily affected
279 by ghosts, we measure their flux in an annulus with a large enough
280 inner radius to avoid the most severe ghosts and contain enough
281 non-saturated pixels.
283 Parameters
284 ----------
285 starStamps : `collections.abc.Sequence` [`BrightStarStamp`]
286 Sequence of star stamps. Cannot contain both normalized and
287 unnormalized stamps.
288 innerRadius : `int`
289 Inner radius value, in pixels. This and ``outerRadius`` define the
290 annulus used to compute the ``"annularFlux"`` values within each
291 ``starStamp``.
292 outerRadius : `int`
293 Outer radius value, in pixels. This and ``innerRadius`` define the
294 annulus used to compute the ``"annularFlux"`` values within each
295 ``starStamp``.
296 nb90Rots : `int`, optional
297 Number of 90 degree rotations required to compensate for detector
298 orientation.
299 metadata : `~lsst.daf.base.PropertyList`, optional
300 Metadata associated with the bright stars.
301 use_mask : `bool`
302 If `True` read and write mask data. Default `True`.
303 use_variance : `bool`
304 If ``True`` read and write variance data. Default ``False``.
305 use_archive : `bool`
306 If ``True`` read and write an Archive that contains a Persistable
307 associated with each stamp. In the case of bright stars, this is
308 usually a ``TransformPoint2ToPoint2``, used to warp each stamp
309 to the same pixel grid before stacking.
310 imCenter : `collections.abc.Sequence`, optional
311 Center of the object, in pixels. If not provided, the center of the
312 first stamp's pixel grid will be used.
313 discardNanFluxObjects : `bool`
314 Whether objects with NaN annular flux should be discarded.
315 If False, these objects will not be normalized.
316 forceFindFlux : `bool`
317 Whether to try to find the flux of objects with NaN annular flux
318 at a different annulus.
319 statsControl : `~lsst.afw.math.statistics.StatisticsControl`, optional
320 StatisticsControl to be used when computing flux over all pixels
321 within the annulus.
322 statsFlag : `~lsst.afw.math.statistics.Property`, optional
323 statsFlag to be passed on to ``~lsst.afw.math.makeStatistics`` to
324 compute annularFlux. Defaults to a simple MEAN.
325 badMaskPlanes : `collections.abc.Collection` [`str`]
326 Collection of mask planes to ignore when computing annularFlux.
328 Raises
329 ------
330 ValueError
331 Raised if one of the star stamps provided does not contain the
332 required keys.
333 AttributeError
334 Raised if there is a mix-and-match of normalized and unnormalized
335 stamps, stamps normalized with different annulus definitions, or if
336 stamps are to be normalized but annular radii were not provided.
337 """
338 stampSize = starStamps[0].stamp_im.getDimensions()
339 if imCenter is None:
340 imCenter = stampSize[0] // 2, stampSize[1] // 2
342 # Create SpanSet of annulus.
343 outerCircle = SpanSet.fromShape(outerRadius, Stencil.CIRCLE, offset=imCenter)
344 innerCircle = SpanSet.fromShape(innerRadius, Stencil.CIRCLE, offset=imCenter)
345 annulusWidth = outerRadius - innerRadius
346 if annulusWidth < 1:
347 raise ValueError("The annulus width must be greater than 1 pixel.")
348 annulus = outerCircle.intersectNot(innerCircle)
350 # Initialize (unnormalized) brightStarStamps instance.
351 bss = cls(
352 starStamps,
353 innerRadius=None,
354 outerRadius=None,
355 nb90Rots=nb90Rots,
356 metadata=metadata,
357 use_mask=use_mask,
358 use_variance=use_variance,
359 use_archive=use_archive,
360 )
362 # Ensure that no stamps have already been normalized.
363 bss._checkNormalization(True, innerRadius, outerRadius)
364 bss._innerRadius, bss._outerRadius = innerRadius, outerRadius
366 # Apply normalization.
367 rejects = []
368 badStamps = []
369 for stamp in bss._stamps:
370 try:
371 stamp.measureAndNormalize(
372 annulus, statsControl=statsControl, statsFlag=statsFlag, badMaskPlanes=badMaskPlanes
373 )
374 # Stars that are missing from input bright star stamps may
375 # still have a flux within the normalization annulus. The
376 # following two lines make sure that these stars are included
377 # in the subtraction process. Failing to assign the optimal
378 # radii values may result in an error in the `createAnnulus`
379 # method of the `SubtractBrightStarsTask` class. An alternative
380 # to handle this is to create two types of stamps that are
381 # missing from the input brightStarStamps object. One for those
382 # that have flux within the normalization annulus and another
383 # for those that do not have a flux within the normalization
384 # annulus.
385 stamp.optimalOuterRadius = outerRadius
386 stamp.optimalInnerRadius = innerRadius
387 except RuntimeError as err:
388 logger.error(err)
389 # Optionally keep NaN flux objects, for bookkeeping purposes,
390 # and to avoid having to re-find and redo the preprocessing
391 # steps needed before bright stars can be subtracted.
392 if discardNanFluxObjects:
393 rejects.append(stamp)
394 elif forceFindFlux:
395 newInnerRadius = innerRadius
396 newOuterRadius = outerRadius
397 while True:
398 newOuterRadius += annulusWidth
399 newInnerRadius += annulusWidth
400 if newOuterRadius > min(imCenter):
401 logger.info("No flux found for the star with Gaia ID of %s", stamp.gaiaId)
402 stamp.annularFlux = None
403 badStamps.append(stamp)
404 break
405 newOuterCircle = SpanSet.fromShape(newOuterRadius, Stencil.CIRCLE, offset=imCenter)
406 newInnerCircle = SpanSet.fromShape(newInnerRadius, Stencil.CIRCLE, offset=imCenter)
407 newAnnulus = newOuterCircle.intersectNot(newInnerCircle)
408 try:
409 stamp.measureAndNormalize(
410 newAnnulus,
411 statsControl=statsControl,
412 statsFlag=statsFlag,
413 badMaskPlanes=badMaskPlanes,
414 )
416 except RuntimeError:
417 stamp.annularFlux = np.nan
418 logger.error(
419 "The annular flux was not found for radii %d and %d",
420 newInnerRadius,
421 newOuterRadius,
422 )
423 if stamp.annularFlux and stamp.annularFlux > 0:
424 logger.info("The flux is found within an optimized annulus.")
425 logger.info(
426 "The optimized annulus radii are %d and %d and the flux is %f",
427 newInnerRadius,
428 newOuterRadius,
429 stamp.annularFlux,
430 )
431 stamp.optimalOuterRadius = newOuterRadius
432 stamp.optimalInnerRadius = newInnerRadius
433 break
434 else:
435 stamp.annularFlux = np.nan
437 # Remove rejected stamps.
438 bss.normalized = True
439 if discardNanFluxObjects:
440 for reject in rejects:
441 bss._stamps.remove(reject)
442 elif forceFindFlux:
443 for badStamp in badStamps:
444 bss._stamps.remove(badStamp)
445 bss._innerRadius, bss._outerRadius = None, None
446 return bss, badStamps
447 return bss
449 def _refresh_metadata(self):
450 """Refresh metadata. Should be called before writing the object out.
452 This method adds full lists of positions, Gaia magnitudes, IDs and
453 annular fluxes to the shared metadata.
454 """
455 self._metadata["G_MAGS"] = self.getMagnitudes()
456 self._metadata["GAIA_IDS"] = self.getGaiaIds()
457 positions = self.getPositions()
458 self._metadata["X0S"] = [xy0[0] for xy0 in positions]
459 self._metadata["Y0S"] = [xy0[1] for xy0 in positions]
460 self._metadata["ANNULAR_FLUXES"] = self.getAnnularFluxes()
461 self._metadata["NORMALIZED"] = self.normalized
462 self._metadata["INNER_RADIUS"] = self._innerRadius
463 self._metadata["OUTER_RADIUS"] = self._outerRadius
464 if self.nb90Rots is not None:
465 self._metadata["NB_90_ROTS"] = self.nb90Rots
466 return None
468 @classmethod
469 def readFits(cls, filename):
470 """Build an instance of this class from a file.
472 Parameters
473 ----------
474 filename : `str`
475 Name of the file to read.
476 """
477 return cls.readFitsWithOptions(filename, None)
479 @classmethod
480 def readFitsWithOptions(cls, filename, options):
481 """Build an instance of this class with options.
483 Parameters
484 ----------
485 filename : `str`
486 Name of the file to read.
487 options : `PropertyList`
488 Collection of metadata parameters.
489 """
490 stamps, metadata = readFitsWithOptions(filename, BrightStarStamp.factory, options)
491 nb90Rots = metadata["NB_90_ROTS"] if "NB_90_ROTS" in metadata else None
492 if metadata["NORMALIZED"]:
493 return cls(
494 stamps,
495 innerRadius=metadata["INNER_RADIUS"],
496 outerRadius=metadata["OUTER_RADIUS"],
497 nb90Rots=nb90Rots,
498 metadata=metadata,
499 use_mask=metadata["HAS_MASK"],
500 use_variance=metadata["HAS_VARIANCE"],
501 use_archive=metadata["HAS_ARCHIVE"],
502 )
503 else:
504 return cls(
505 stamps,
506 nb90Rots=nb90Rots,
507 metadata=metadata,
508 use_mask=metadata["HAS_MASK"],
509 use_variance=metadata["HAS_VARIANCE"],
510 use_archive=metadata["HAS_ARCHIVE"],
511 )
513 def append(self, item, innerRadius=None, outerRadius=None):
514 """Add an additional bright star stamp.
516 Parameters
517 ----------
518 item : `BrightStarStamp`
519 Bright star stamp to append.
520 innerRadius : `int`, optional
521 Inner radius value, in pixels. This and ``outerRadius`` define the
522 annulus used to compute the ``"annularFlux"`` values within each
523 ``BrightStarStamp``.
524 outerRadius : `int`, optional
525 Outer radius value, in pixels. This and ``innerRadius`` define the
526 annulus used to compute the ``"annularFlux"`` values within each
527 ``BrightStarStamp``.
528 """
529 if not isinstance(item, BrightStarStamp):
530 raise ValueError(f"Can only add instances of BrightStarStamp, got {type(item)}.")
531 if (item.annularFlux is None) == self.normalized:
532 raise AttributeError(
533 "Trying to append an unnormalized stamp to a normalized BrightStarStamps "
534 "instance, or vice-versa."
535 )
536 else:
537 self._checkRadius(innerRadius, outerRadius)
538 self._stamps.append(item)
539 return None
541 def extend(self, bss):
542 """Extend BrightStarStamps instance by appending elements from another
543 instance.
545 Parameters
546 ----------
547 bss : `BrightStarStamps`
548 Other instance to concatenate.
549 """
550 if not isinstance(bss, BrightStarStamps):
551 raise ValueError(f"Can only extend with a BrightStarStamps object. Got {type(bss)}.")
552 self._checkRadius(bss._innerRadius, bss._outerRadius)
553 self._stamps += bss._stamps
555 def getMagnitudes(self):
556 """Retrieve Gaia G-band magnitudes for each star.
558 Returns
559 -------
560 gaiaGMags : `list` [`float`]
561 Gaia G-band magnitudes for each star.
562 """
563 return [stamp.gaiaGMag for stamp in self._stamps]
565 def getGaiaIds(self):
566 """Retrieve Gaia IDs for each star.
568 Returns
569 -------
570 gaiaIds : `list` [`int`]
571 Gaia IDs for each star.
572 """
573 return [stamp.gaiaId for stamp in self._stamps]
575 def getAnnularFluxes(self):
576 """Retrieve normalization factor for each star.
578 These are computed by integrating the flux in annulus centered on the
579 bright star, far enough from center to be beyond most severe ghosts and
580 saturation.
581 The inner and outer radii that define the annulus can be recovered from
582 the metadata.
584 Returns
585 -------
586 annularFluxes : `list` [`float`]
587 Annular fluxes which give the normalization factor for each star.
588 """
589 return [stamp.annularFlux for stamp in self._stamps]
591 def selectByMag(self, magMin=None, magMax=None):
592 """Return the subset of bright star stamps for objects with specified
593 magnitude cuts (in Gaia G).
595 Parameters
596 ----------
597 magMin : `float`, optional
598 Keep only stars fainter than this value.
599 magMax : `float`, optional
600 Keep only stars brighter than this value.
601 """
602 subset = [
603 stamp
604 for stamp in self._stamps
605 if (magMin is None or stamp.gaiaGMag > magMin) and (magMax is None or stamp.gaiaGMag < magMax)
606 ]
607 # This saves looping over init when guaranteed to be the correct type.
608 instance = BrightStarStamps(
609 (), innerRadius=self._innerRadius, outerRadius=self._outerRadius, metadata=self._metadata
610 )
611 instance._stamps = subset
612 return instance
614 def _checkRadius(self, innerRadius, outerRadius):
615 """Ensure provided annulus radius is consistent with that already
616 present in the instance, or with arguments passed on at initialization.
617 """
618 if innerRadius != self._innerRadius or outerRadius != self._outerRadius:
619 raise AttributeError(
620 f"Trying to mix stamps normalized with annulus radii {innerRadius, outerRadius} with those "
621 "of BrightStarStamp instance\n"
622 f"(computed with annular radii {self._innerRadius, self._outerRadius})."
623 )
625 def _checkNormalization(self, normalize, innerRadius, outerRadius):
626 """Ensure there is no mixing of normalized and unnormalized stars, and
627 that, if requested, normalization can be performed.
628 """
629 noneFluxCount = self.getAnnularFluxes().count(None)
630 nStamps = len(self)
631 nFluxVals = nStamps - noneFluxCount
632 if noneFluxCount and noneFluxCount < nStamps:
633 # At least one stamp contains an annularFlux value (i.e. has been
634 # normalized), but not all of them do.
635 raise AttributeError(
636 f"Only {nFluxVals} stamps contain an annularFlux value.\nAll stamps in a BrightStarStamps "
637 "instance must either be normalized with the same annulus definition, or none of them can "
638 "contain an annularFlux value."
639 )
640 elif normalize:
641 # Stamps are to be normalized; ensure annular radii are specified
642 # and they have no annularFlux.
643 if innerRadius is None or outerRadius is None:
644 raise AttributeError(
645 "For stamps to be normalized (normalize=True), please provide a valid value (in pixels) "
646 "for both innerRadius and outerRadius."
647 )
648 elif noneFluxCount < nStamps:
649 raise AttributeError(
650 f"{nFluxVals} stamps already contain an annularFlux value. For stamps to be normalized, "
651 "all their annularFlux must be None."
652 )
653 elif innerRadius is not None and outerRadius is not None:
654 # Radii provided, but normalize=False; check that stamps already
655 # contain annularFluxes.
656 if noneFluxCount:
657 raise AttributeError(
658 f"{noneFluxCount} stamps contain no annularFlux, but annular radius values were provided "
659 "and normalize=False.\nTo normalize stamps, set normalize to True."
660 )
661 else:
662 # At least one radius value is missing; ensure no stamps have
663 # already been normalized.
664 if nFluxVals:
665 raise AttributeError(
666 f"{nFluxVals} stamps contain an annularFlux value. If stamps have been normalized, the "
667 "innerRadius and outerRadius values used must be provided."
668 )
669 return None