22
22
__all__ = ["ComputeExposureSummaryStatsTask" , "ComputeExposureSummaryStatsConfig" ]
23
23
24
24
import 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
31
25
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
35
27
import lsst .afw .image as afwImage
28
+ import lsst .afw .math as afwMath
36
29
import 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
37
37
from lsst .meas .algorithms import ScienceSourceSelectorTask
38
38
from 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
40
41
41
42
42
43
class ComputeExposureSummaryStatsConfig (pexConfig .Config ):
@@ -177,6 +178,7 @@ class ComputeExposureSummaryStatsTask(pipeBase.Task):
177
178
- zenithDistance
178
179
- zeroPoint
179
180
- skyBg
181
+ - skyBgLumpiness
180
182
- skyNoise
181
183
- meanVar
182
184
- raCorners
@@ -522,7 +524,12 @@ def update_background_stats(self, summary, background):
522
524
Parameters
523
525
----------
524
526
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.
526
533
background : `lsst.afw.math.BackgroundList` or `None`
527
534
Background model. If `None`, all fields that depend on the
528
535
background will be reset (generally to NaN).
@@ -538,8 +545,15 @@ def update_background_stats(self, summary, background):
538
545
bgStats = (bg [0 ].getStatsImage ().getImage ().array
539
546
for bg in background )
540
547
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 )
541
554
else :
542
555
summary .skyBg = float ("nan" )
556
+ summary .skyBgLumpiness = float ("nan" )
543
557
544
558
def update_masked_image_stats (self , summary , masked_image ):
545
559
"""Compute summary-statistic fields that depend on the masked image
@@ -899,6 +913,57 @@ def compute_ap_corr_sigma_scaled_delta(
899
913
return ap_corr_sigma_scaled_delta
900
914
901
915
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
+
902
967
def compute_magnitude_limit (
903
968
psfArea ,
904
969
skyBg ,
0 commit comments