2626import requests
2727import os
2828
29- from lsst .afw .geom import SpanSet
29+ from lsst .afw .detection import FootprintSet
30+ from lsst .afw .geom import makeCdMatrix , makeSkyWcs , SpanSet
3031import lsst .afw .table as afwTable
3132import lsst .afw .image as afwImage
3233import lsst .afw .math as afwMath
34+ import lsst .geom as geom
3335from lsst .ip .diffim .utils import evaluateMaskFraction , populate_sattle_visit_cache
3436import lsst .meas .algorithms
3537import lsst .meas .algorithms .installGaussianPsf
@@ -322,6 +324,10 @@ class CalibrateImageConfig(pipeBase.PipelineTaskConfig, pipelineConnections=Cali
322324 target = lsst .meas .algorithms .SubtractBackgroundTask ,
323325 doc = "Task to perform final background subtraction, just before photoCal." ,
324326 )
327+ star_background_sky_sources = pexConfig .ConfigurableField (
328+ target = lsst .meas .algorithms .SkyObjectsTask ,
329+ doc = "Task to generate sky sources ('empty' regions where there are no detections)." ,
330+ )
325331 star_detection = pexConfig .ConfigurableField (
326332 target = lsst .meas .algorithms .SourceDetectionTask ,
327333 doc = "Task to detect stars to return in the output catalog."
@@ -330,6 +336,13 @@ class CalibrateImageConfig(pipeBase.PipelineTaskConfig, pipelineConnections=Cali
330336 target = lsst .meas .algorithms .SkyObjectsTask ,
331337 doc = "Task to generate sky sources ('empty' regions where there are no detections)." ,
332338 )
339+ allowMaskErode = pexConfig .Field (
340+ dtype = bool ,
341+ default = True ,
342+ doc = "Crowded/large fill-factor fields make it difficult to find suitable locations "
343+ "to lay down sky sources. To allow for best effort sky source placement, if "
344+ "True, this allows for a slight erosion of the detection masks."
345+ )
333346 do_downsample_footprints = pexConfig .Field (
334347 dtype = bool ,
335348 default = False ,
@@ -726,6 +739,7 @@ def __init__(self, initial_stars_schema=None, **kwargs):
726739
727740 afwTable .CoordKey .addErrorFields (initial_stars_schema )
728741 self .makeSubtask ("star_background" )
742+ self .makeSubtask ("star_background_sky_sources" , schema = initial_stars_schema )
729743 self .makeSubtask ("star_detection" , schema = initial_stars_schema )
730744 self .makeSubtask ("star_sky_sources" , schema = initial_stars_schema )
731745 self .makeSubtask ("star_deblend" , schema = initial_stars_schema )
@@ -753,6 +767,18 @@ def __init__(self, initial_stars_schema=None, **kwargs):
753767 afwTable .SourceCatalog (initial_stars_schema )
754768 )
755769
770+ # Set up forced measurement.
771+ forcedConfig = lsst .meas .base .ForcedMeasurementTask .ConfigClass ()
772+ forcedConfig .plugins .names = ["base_TransformedCentroid" , "base_PsfFlux" , "base_CircularApertureFlux" ]
773+
774+ # We only need the "centroid" and "psfFlux" slots
775+ for slot in ("shape" , "psfShape" , "apFlux" , "modelFlux" , "gaussianFlux" , "calibFlux" ):
776+ setattr (forcedConfig .slots , slot , None )
777+ forcedConfig .copyColumns = {}
778+ self .skySchema = afwTable .SourceTable .makeMinimalSchema ()
779+ self .skyMeasurement = lsst .meas .base .ForcedMeasurementTask (
780+ config = forcedConfig , name = "skyMeasurement" , parentTask = self , refSchema = self .skySchema )
781+
756782 def runQuantum (self , butlerQC , inputRefs , outputRefs ):
757783 inputs = butlerQC .get (inputRefs )
758784 exposures = inputs .pop ("exposures" )
@@ -1876,6 +1902,11 @@ def _remeasure_star_background(self, result):
18761902 star_background = self .star_background .run (exposure = result .exposure ).background
18771903 result .background .append (star_background [0 ])
18781904
1905+ # See what skySources have to say...
1906+ seed = result .exposure .info .getId ()
1907+ bgLevelFromSkySources = self ._calculateBgFromSkySources (result .exposure , seed )
1908+ self ._tweakBackground (result .exposure , bgLevelFromSkySources , bgList = result .background )
1909+
18791910 # Clear detected mask plane before final round of detection
18801911 mask = result .exposure .mask
18811912 for mp in detected_mask_planes :
@@ -1884,3 +1915,162 @@ def _remeasure_star_background(self, result):
18841915 mask &= ~ mask .getPlaneBitMask (detected_mask_planes )
18851916
18861917 return result
1918+
1919+ def _calculateBgFromSkySources (self , exposure , seed , minFractionSources = 0.05 , nPixMaskErode = None ,
1920+ maxMaskErodeIter = 10 ):
1921+ """Calculate current background level via sky sources.
1922+
1923+ We identify sky objects and perform forced PSF photometry on
1924+ them. Using those PSF flux measurements, the background is
1925+ computed as the median of psfFlux/psfArea.
1926+
1927+ Parameters
1928+ ----------
1929+ exposure : `lsst.afw.image.Exposure`
1930+ Exposure on which we're detecting sources.
1931+ seed : `int`
1932+ RNG seed to use for finding sky objects.
1933+ minFractionSources : `float`
1934+ Minimum fraction of requested number of sources required to be
1935+ considered to provide a reliable background measurement.
1936+ nPixMaskErode : `int`, optional
1937+ Number of pixels by which to erode the detection masks on each
1938+ iteration of best-effort sky object placement. Will be set
1939+ automatically if None.
1940+ maxMaskErodeIter : `int`, optional
1941+ Maximum number of iterations for the detection mask erosion.
1942+
1943+ Returns
1944+ -------
1945+ bgLevel : `flaot`
1946+ The computed background level.
1947+
1948+ Raises
1949+ ------
1950+ NoWorkFound
1951+ Raised if the number of good sky sources found is less than the
1952+ minimum fraction in ``minFractionSources`` of the number requested
1953+ (``self.skyObjects.config.nSources``).
1954+ """
1955+ wcsIsNone = exposure .getWcs () is None
1956+ if wcsIsNone : # create a dummy WCS as needed by ForcedMeasurementTask
1957+ self .log .info ("WCS for exposure is None. Setting a dummy WCS for dynamic detection." )
1958+ exposure .setWcs (makeSkyWcs (crpix = geom .Point2D (0 , 0 ),
1959+ crval = geom .SpherePoint (0 , 0 , geom .degrees ),
1960+ cdMatrix = makeCdMatrix (scale = 1e-5 * geom .degrees )))
1961+ minNumSources = int (0.05 * self .star_background_sky_sources .config .nSources )
1962+ # Reduce the number of sky sources required if requested, but ensure
1963+ # a minumum of 3.
1964+ if minFractionSources != 1.0 :
1965+ minNumSources = max (3 , int (minNumSources * minFractionSources ))
1966+ fp = self .star_background_sky_sources .run (exposure .maskedImage .mask , seed )
1967+
1968+ if self .config .allowMaskErode :
1969+ detectedMaskPlanes = ["DETECTED" , "DETECTED_NEGATIVE" ]
1970+ mask = exposure .maskedImage .mask
1971+ for nIter in range (maxMaskErodeIter ):
1972+ if nIter > 0 :
1973+ fp = self .star_background_sky_sources .run (mask , seed )
1974+ if len (fp ) < int (2 * minNumSources ): # Allow for measurement failures
1975+ self .log .info ("Current number of sky sources is below 2*minimum required "
1976+ "(%d < %d, allowing for some subsequent measurement failures). "
1977+ "Allowing erosion of detected mask planes for sky placement "
1978+ "nIter: %d [of %d max]" ,
1979+ len (fp ), 2 * minNumSources , nIter , maxMaskErodeIter )
1980+ if nPixMaskErode is None :
1981+ if len (fp ) == 0 :
1982+ nPixMaskErode = 4
1983+ elif len (fp ) < int (0.75 * minNumSources ):
1984+ nPixMaskErode = 2
1985+ else :
1986+ nPixMaskErode = 1
1987+ for maskName in detectedMaskPlanes :
1988+ # Compute the eroded detection mask plane using SpanSet
1989+ detectedMaskBit = mask .getPlaneBitMask (maskName )
1990+ detectedMaskSpanSet = SpanSet .fromMask (mask , detectedMaskBit )
1991+ detectedMaskSpanSet = detectedMaskSpanSet .eroded (nPixMaskErode )
1992+ # Clear the detected mask plane
1993+ detectedMask = mask .getMaskPlane (maskName )
1994+ mask .clearMaskPlane (detectedMask )
1995+ # Set the mask plane to the eroded one
1996+ detectedMaskSpanSet .setMask (mask , detectedMaskBit )
1997+ else :
1998+ break
1999+
2000+ skyFootprints = FootprintSet (exposure .getBBox ())
2001+ skyFootprints .setFootprints (fp )
2002+ table = afwTable .SourceTable .make (self .skyMeasurement .schema )
2003+ catalog = afwTable .SourceCatalog (table )
2004+ catalog .reserve (len (skyFootprints .getFootprints ()))
2005+ skyFootprints .makeSources (catalog )
2006+ key = catalog .getCentroidSlot ().getMeasKey ()
2007+ for source in catalog :
2008+ peaks = source .getFootprint ().getPeaks ()
2009+ assert len (peaks ) == 1
2010+ source .set (key , peaks [0 ].getF ())
2011+ # Coordinate covariance is not used, so don't bother calulating it.
2012+ source .updateCoord (exposure .getWcs (), include_covariance = False )
2013+
2014+ # Perform forced photometry on sky objects.
2015+ self .skyMeasurement .run (catalog , exposure , catalog , exposure .getWcs ())
2016+
2017+ # Calculate new threshold
2018+ fluxes = catalog ["base_PsfFlux_instFlux" ]
2019+ area = catalog ["base_PsfFlux_area" ]
2020+ good = (~ catalog ["base_PsfFlux_flag" ] & np .isfinite (fluxes ))
2021+
2022+ if good .sum () < minNumSources :
2023+ msg = (f"Insufficient good sky source flux measurements ({ good .sum ()} < "
2024+ f"{ minNumSources } ) for background tweak calculation." )
2025+
2026+ nPix = exposure .mask .array .size
2027+ badPixelMask = afwImage .Mask .getPlaneBitMask (["NO_DATA" , "BAD" ])
2028+ nGoodPix = np .sum (exposure .mask .array & badPixelMask == 0 )
2029+ if nGoodPix / nPix > 0.2 :
2030+ detectedPixelMask = afwImage .Mask .getPlaneBitMask (["DETECTED" , "DETECTED_NEGATIVE" ])
2031+ nDetectedPix = np .sum (exposure .mask .array & detectedPixelMask != 0 )
2032+ msg += (f" However, { nGoodPix } /{ nPix } pixels are not marked NO_DATA or BAD, "
2033+ "so there should be sufficient area to locate suitable sky sources. "
2034+ f"Note that { nDetectedPix } of { nGoodPix } \" good\" pixels were marked "
2035+ "as DETECTED or DETECTED_NEGATIVE." )
2036+ # raise RuntimeError(msg)
2037+ raise RuntimeError (msg )
2038+ # raise pipeBase.NoWorkFound(msg)
2039+
2040+ self .log .info ("Number of good sky sources used for sky source background measurement: "
2041+ "%d (of %d requested)." ,
2042+ good .sum (), self .star_background_sky_sources .config .nSources )
2043+
2044+ bgLevel = np .median ((fluxes / area )[good ])
2045+ if wcsIsNone :
2046+ exposure .setWcs (None )
2047+ return bgLevel
2048+
2049+ def _tweakBackground (self , exposure , bgLevel , bgList = None ):
2050+ """Modify the background by a constant value
2051+
2052+ Parameters
2053+ ----------
2054+ exposure : `lsst.afw.image.Exposure`
2055+ Exposure for which to tweak background.
2056+ bgLevel : `float`
2057+ Background level to remove
2058+ bgList : `lsst.afw.math.BackgroundList`, optional
2059+ List of backgrounds to append to.
2060+
2061+ Returns
2062+ -------
2063+ bg : `lsst.afw.math.BackgroundMI`
2064+ Constant background model.
2065+ """
2066+ if bgLevel != 0.0 :
2067+ self .log .info ("Tweaking background by %f to match sky photometry" , bgLevel )
2068+ exposure .image -= bgLevel
2069+ bgStats = afwImage .MaskedImageF (1 , 1 )
2070+ bgStats .set (bgLevel , 0 , bgLevel )
2071+ bg = afwMath .BackgroundMI (exposure .getBBox (), bgStats )
2072+ bgData = (bg , afwMath .Interpolate .LINEAR , afwMath .REDUCE_INTERP_ORDER ,
2073+ afwMath .ApproximateControl .UNKNOWN , 0 , 0 , False )
2074+ if bgList is not None :
2075+ bgList .append (bgData )
2076+ return bg
0 commit comments