3030
3131import astropy .units as u
3232import numpy as np
33+ from astropy .stats import sigma_clip
3334from astropy .table import QTable
3435from scipy .ndimage import binary_dilation
3536from 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