Skip to content

Commit 12ca9cf

Browse files
committed
use sigma clipping
1 parent 64486eb commit 12ca9cf

File tree

1 file changed

+85
-16
lines changed

1 file changed

+85
-16
lines changed

python/lsst/ts/wep/task/cutOutDonutsBase.py

Lines changed: 85 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030

3131
import astropy.units as u
3232
import numpy as np
33+
from astropy.stats import sigma_clip
3334
from astropy.table import QTable
3435
from scipy.ndimage import binary_dilation
3536
from scipy.signal import correlate
@@ -323,10 +324,14 @@ def calculateFinalCentroids(
323324
np.array(peakHeight),
324325
)
325326

326-
def calculateSN(self, stamp: DonutStamp) -> dict:
327+
def calculateSN(self, stamp: "DonutStamp") -> dict:
327328
"""
328329
Calculate signal-to-noise ratio.
329330
331+
Uses sigma-clipping to exclude outlier pixels (e.g., unmasked
332+
hot pixels) from the signal calculation to prevent artificially
333+
inflated SNR values from single bright pixels.
334+
330335
Parameters
331336
----------
332337
stamp : lsst.ts.wep.task.donutStamp
@@ -335,7 +340,15 @@ def calculateSN(self, stamp: DonutStamp) -> dict:
335340
Returns
336341
-------
337342
dict
338-
A dictionary of calculated quantities
343+
A dictionary of calculated quantities including both raw and
344+
sigma-clipped SNR values.
345+
346+
Notes
347+
-----
348+
Hot pixels that are not flagged in the mask can cause artificially
349+
high SNR values. This method uses 5-sigma clipping on the donut
350+
pixels to mitigate this issue. The clipped SNR (`SN_clipped`)
351+
should be preferred for quality filtering.
339352
"""
340353

341354
imageArray = stamp.stamp_im.image.array
@@ -383,15 +396,36 @@ def calculateSN(self, stamp: DonutStamp) -> dict:
383396

384397
donutMask = hasDonut & notBlend & notSat & notCr
385398
# Number of pixels taken by the donut in the original donut mask
386-
nPxMask = np.sum(donutMask)
399+
nPxMaskOriginal = np.sum(donutMask)
400+
401+
# Apply sigma-clipping to exclude outlier pixels
402+
# (e.g., unmasked hot pixels). This prevents single bright
403+
# pixels from artificially inflating the SNR.
404+
donutPixels = imageArray[donutMask]
405+
clipped = sigma_clip(donutPixels, sigma=5, maxiters=3, masked=True)
406+
407+
# Count of good (non-clipped) pixels
408+
nPxClipped = int(np.sum(clipped.mask)) # number of rejected pixels
409+
nPxMask = int(np.sum(~clipped.mask)) # number of good pixels
410+
411+
# Log warning if pixels were clipped (likely hot pixels)
412+
if nPxClipped > 0:
413+
self.log.debug(f"Sigma-clipping removed {nPxClipped} outlier pixel(s) from SNR calculation")
414+
415+
# Get good (non-clipped) pixels for signal calculation
416+
goodPixels = clipped.compressed()
387417

388418
# Signal estimate based on the donut mean
389-
signalMean = imageArray[donutMask].mean() # per pixel
419+
signalMean = goodPixels.mean() if len(goodPixels) > 0 else 0.0
390420
ttlSignalMean = nPxMask * signalMean
391421

392422
# Signal estimate based on the sum of donut pixels
423+
# Keep original (unclipped) for backwards compatibility
393424
ttlSignalSum = np.sum(imageArray[donutMask])
394425

426+
# Clipped signal sum (robust to hot pixels)
427+
ttlSignalSumClipped = np.sum(goodPixels)
428+
395429
# Background noise estimate:
396430
# expand the inverted mask to remove donut contribution
397431
# the amount of default dilation was matched
@@ -406,8 +440,9 @@ def calculateSN(self, stamp: DonutStamp) -> dict:
406440
while (~xsection[0]) and (self.bkgDilationIter > 1):
407441
self.bkgDilationIter -= 1
408442
self.log.warning(
409-
f"Binary dilation of donut mask reached the edge of the image; \
410-
reducing the amount of donut mask dilation to {self.bkgDilationIter}"
443+
f"Binary dilation of donut mask reached the edge of the "
444+
f"image; reducing the amount of donut mask dilation to "
445+
f"{self.bkgDilationIter}"
411446
)
412447
bkgndMask = ~binary_dilation(donutMask, iterations=self.bkgDilationIter)
413448
xsection = bkgndMask[:, width // 2]
@@ -426,40 +461,63 @@ def calculateSN(self, stamp: DonutStamp) -> dict:
426461
# outside of the dilated donut mask
427462
backgroundImageVariance = imageArray[bkgndMask].var()
428463

429-
# The mean image value in the background region
464+
# The mean image value in the background region
430465
backgroundImageMean = np.mean(imageArray[bkgndMask])
431466

432467
# Total noise based on the variance of the image background
433468
ttlNoiseBkgndVariance = np.sqrt(backgroundImageVariance * nPxMask)
434469

435-
# Noise based on the sum of variance plane pixels inside the donut mask
436-
ttlNoiseDonutVariance = np.sqrt(varianceArray[donutMask].sum())
470+
# Noise based on the sum of variance plane pixels inside the
471+
# donut mask, excluding the same outlier pixels that were
472+
# sigma-clipped from the signal.
473+
# Map the clipped mask back to the original array indices.
474+
donutIndices = np.where(donutMask)
475+
goodPixelMask = ~clipped.mask
476+
goodDonutMask = np.zeros_like(donutMask)
477+
goodDonutMask[donutIndices[0][goodPixelMask], donutIndices[1][goodPixelMask]] = True
478+
479+
ttlNoiseDonutVariance = np.sqrt(varianceArray[goodDonutMask].sum())
480+
481+
# Also compute noise using original mask for backwards compatibility
482+
ttlNoiseDonutVarianceOriginal = np.sqrt(varianceArray[donutMask].sum())
437483

438484
# Avoid zero division in case variance plane doesn't exist
439-
if ttlNoiseDonutVariance > 0:
440-
sn = ttlSignalSum / ttlNoiseDonutVariance
441-
# Legacy behavior: if variance plance was not calculated,
485+
if ttlNoiseDonutVarianceOriginal > 0:
486+
sn = ttlSignalSum / ttlNoiseDonutVarianceOriginal
487+
# Legacy behavior: if variance plane was not calculated,
442488
# use the image background variance
443489
else:
444490
sn = ttlSignalSum / ttlNoiseBkgndVariance
445491
# Only warn about missing variance plane once per task
446492
if self.varianceWarningSet is False:
447493
self.log.warning(
448-
"Missing variance plane; \
449-
using the variance of image background for noise estimate."
494+
"Missing variance plane; using the variance of image background for noise estimate."
450495
)
451496
self.varianceWarningSet = True
497+
498+
# Calculate clipped SNR (robust to hot pixels)
499+
# This is the preferred value for quality filtering
500+
if ttlNoiseDonutVariance > 0:
501+
snClipped = ttlSignalSumClipped / ttlNoiseDonutVariance
502+
else:
503+
snClipped = ttlSignalSumClipped / ttlNoiseBkgndVariance
504+
452505
snDict = {
506+
# New clipped values (preferred for quality filtering)
507+
"SN_clipped": snClipped,
508+
"signal_sum_clipped": ttlSignalSumClipped,
509+
"n_px_clipped": nPxClipped,
510+
# Original values (kept for backwards compatibility)
453511
"SN": sn,
454512
"signal_mean": ttlSignalMean,
455513
"signal_sum": ttlSignalSum,
456-
"n_px_mask": nPxMask,
514+
"n_px_mask": nPxMaskOriginal,
457515
"background_image_stdev": backgroundImageStdev,
458516
"sqrt_mean_variance": sqrtMeanVariance,
459517
"background_image_variance": backgroundImageVariance,
460518
"background_image_mean": backgroundImageMean,
461519
"ttl_noise_bkgnd_variance": ttlNoiseBkgndVariance,
462-
"ttl_noise_donut_variance": ttlNoiseDonutVariance,
520+
"ttl_noise_donut_variance": ttlNoiseDonutVarianceOriginal,
463521
}
464522

465523
return snDict
@@ -824,6 +882,17 @@ def cutOutStamps(
824882
stampsMetadata["NPX_MASK"] = np.array(
825883
[snQuant[i]["n_px_mask"] for i in range(len(snQuant))], dtype=float
826884
)
885+
886+
stampsMetadata["SN_CLIPPED"] = np.array(
887+
[snQuant[i]["SN_clipped"] for i in range(len(snQuant))], dtype=float
888+
)
889+
stampsMetadata["SIGNAL_SUM_CLIPPED"] = np.array(
890+
[snQuant[i]["signal_sum_clipped"] for i in range(len(snQuant))], dtype=float
891+
)
892+
stampsMetadata["NPX_CLIPPED"] = np.array(
893+
[snQuant[i]["n_px_clipped"] for i in range(len(snQuant))], dtype=int
894+
)
895+
827896
stampsMetadata["BKGD_STDEV"] = np.array(
828897
[snQuant[i]["background_image_stdev"] for i in range(len(snQuant))],
829898
dtype=float,

0 commit comments

Comments
 (0)