Skip to content

Commit 38e8a00

Browse files
committed
Add background tweak to final background estimation
This is done via sky sources. It has proven to help with the generic oversubtraction of the background that has long plagued detector level estimation in single frame processing.
1 parent f885f48 commit 38e8a00

File tree

2 files changed

+192
-2
lines changed

2 files changed

+192
-2
lines changed

python/lsst/pipe/tasks/calibrateImage.py

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,12 @@
2626
import requests
2727
import 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
3031
import lsst.afw.table as afwTable
3132
import lsst.afw.image as afwImage
3233
import lsst.afw.math as afwMath
34+
import lsst.geom as geom
3335
from lsst.ip.diffim.utils import evaluateMaskFraction, populate_sattle_visit_cache
3436
import lsst.meas.algorithms
3537
import 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

tests/test_calibrateImage.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ def test_run_adaptive_threshold_deteection(self):
261261
subString = "Using adaptive threshold detection "
262262
self.assertTrue(any(subString in s for s in cm.output))
263263

264-
self._check_run(calibrate, result, expect_n_background=1)
264+
self._check_run(calibrate, result, expect_n_background=2)
265265

266266
def test_run_downsample(self):
267267
"""Test that run() runs with downsample.

0 commit comments

Comments
 (0)