2222__all__ = ["ComputeExposureSummaryStatsTask" , "ComputeExposureSummaryStatsConfig" ]
2323
2424import warnings
25- import numpy as np
26- from scipy .stats import median_abs_deviation as sigmaMad
27- import astropy .units as units
28- from astropy .time import Time
29- from astropy .coordinates import AltAz , SkyCoord , EarthLocation
30- from lsst .daf .base import DateTime
3125
32- import lsst .pipe .base as pipeBase
33- import lsst .pex .config as pexConfig
34- import lsst .afw .math as afwMath
26+ import astropy .units as units
3527import lsst .afw .image as afwImage
28+ import lsst .afw .math as afwMath
3629import lsst .geom as geom
30+ import lsst .ip .isr as ipIsr
31+ import lsst .pex .config as pexConfig
32+ import lsst .pipe .base as pipeBase
33+ import numpy as np
34+ from astropy .coordinates import AltAz , EarthLocation , SkyCoord
35+ from astropy .time import Time
36+ from lsst .daf .base import DateTime
3737from lsst .meas .algorithms import ScienceSourceSelectorTask
3838from lsst .utils .timer import timeMethod
39- import lsst .ip .isr as ipIsr
39+ from scipy .stats import median_abs_deviation as sigmaMad
40+ from skimage .util import view_as_windows
4041
4142
4243class ComputeExposureSummaryStatsConfig (pexConfig .Config ):
@@ -177,6 +178,7 @@ class ComputeExposureSummaryStatsTask(pipeBase.Task):
177178 - zenithDistance
178179 - zeroPoint
179180 - skyBg
181+ - skyBgLumpiness
180182 - skyNoise
181183 - meanVar
182184 - raCorners
@@ -522,7 +524,12 @@ def update_background_stats(self, summary, background):
522524 Parameters
523525 ----------
524526 summary : `lsst.afw.image.ExposureSummaryStats`
525- Summary object to update in-place.
527+ Summary object to update in-place. This method adds the following
528+ fields:
529+ - `skyBg`: Median sky background value across background models.
530+ - `skyBgLumpiness`: A measure of sky background lumpiness based on
531+ the spatial variation of the final background image while remaining
532+ robust to outliers, including possible hot pixels.
526533 background : `lsst.afw.math.BackgroundList` or `None`
527534 Background model. If `None`, all fields that depend on the
528535 background will be reset (generally to NaN).
@@ -538,8 +545,15 @@ def update_background_stats(self, summary, background):
538545 bgStats = (bg [0 ].getStatsImage ().getImage ().array
539546 for bg in background )
540547 summary .skyBg = float (sum (np .median (bg [np .isfinite (bg )]) for bg in bgStats ))
548+ # NOTE: We could ideally use the full background image below to
549+ # compute skyBg, but that slightly changes tests and other results.
550+ # Leaving it as-is for now but this may need to be revisited.
551+ bgarr = background .getImage ().array
552+ bgarr [~ np .isfinite (bgarr )] = np .nan
553+ summary .skyBgLumpiness = compute_lumpiness (bgarr )
541554 else :
542555 summary .skyBg = float ("nan" )
556+ summary .skyBgLumpiness = float ("nan" )
543557
544558 def update_masked_image_stats (self , summary , masked_image ):
545559 """Compute summary-statistic fields that depend on the masked image
@@ -899,6 +913,57 @@ def compute_ap_corr_sigma_scaled_delta(
899913 return ap_corr_sigma_scaled_delta
900914
901915
916+ def compute_lumpiness (imarr : np .ndarray , patch_size : int = 16 ) -> float :
917+ """Estimate spatial roughness or lumpiness of an image.
918+
919+ It is based on the Median Absolute Deviation (MAD) of local patch means,
920+ normalized by the interquartile range (IQR) of the image values to allow
921+ comparison across images with different background levels. In other words,
922+ it answers: how much patch-to-patch variation exists, relative to the
923+ overall variation in the image (as measured by the IQR). The method is
924+ robust to outliers and works best on moderately sized or larger images.
925+
926+ Parameters
927+ ----------
928+ imarr : `numpy.ndarray`
929+ 2D input image array.
930+ patch_size : `int`
931+ Size of square patches to divide the image into (default is 16).
932+ Patches are non-overlapping.
933+
934+ Returns
935+ -------
936+ lumpiness : `float`
937+ A scalar lumpiness value. Higher values indicate more spatial
938+ variability across the image and lower values indicate a more uniform
939+ image.
940+ """
941+ # If the image is smaller than the patch size, return NaN.
942+ if patch_size > min (imarr .shape ):
943+ return float ("nan" )
944+
945+ # Pad image with NaNs to make dimensions divisible by patch_size.
946+ pad_h = (- imarr .shape [0 ]) % patch_size
947+ pad_w = (- imarr .shape [1 ]) % patch_size
948+ imarr = np .pad (imarr , ((0 , pad_h ), (0 , pad_w )), mode = "constant" , constant_values = np .nan )
949+
950+ # Use a memory-efficient view to tile the image into non-overlapping
951+ # patches.
952+ windows = view_as_windows (imarr , (patch_size , patch_size ), step = patch_size )
953+
954+ # Compute the mean of each patch (last two axes) to summarize local values.
955+ local_means = np .nanmean (windows , axis = (- 2 , - 1 )).ravel ()
956+
957+ # Compute the interquartile range (IQR) of the image values.
958+ iqr = np .subtract (* np .nanpercentile (imarr , [75 , 25 ]))
959+
960+ # Estimate lumpiness as the median absolute deviation of local means
961+ # normalized by the interquartile range. MAD is normalized to match
962+ # Gaussian sigma.
963+ lumpiness = float (sigmaMad (local_means , scale = "normal" , nan_policy = "omit" )/ iqr )
964+ return lumpiness
965+
966+
902967def compute_magnitude_limit (
903968 psfArea ,
904969 skyBg ,
0 commit comments