Custom coadd built from the same visits as the DP1 deep_coadd does not match it — how should two coadds be compared?
Hi everyone,
I am testing the custom coadd workflow from tutorial 105.6 “Create custom coadd images” on the Rubin Science Platform (DP1, LSST Science Pipelines v30.0.11, large container). As a sanity check, instead of selecting a subset of visits, I rebuilt the coadd using exactly the same input visits that were used for the official DP1 deep_coadd, expecting to get (nearly) the same image. However, when I subtract the two images, they are clearly not identical.
What I did
-
Tract / patch / band:
tract = 2394,patch = 3,band = 'i',skymap = 'lsst_cells_v1' -
Input visits: the 27 visits from
deep_coadd.getInfo().getCoaddInputs().visits - Config overrides (as in the tutorial):
pipeline.addConfigOverride('makeDirectWarp', 'useVisitSummaryPsf', False)
pipeline.addConfigOverride('makeDirectWarp', 'useVisitSummaryPhotoCalib', False)
pipeline.addConfigOverride('makeDirectWarp', 'useVisitSummaryWcs', False)
pipeline.addConfigOverride('makeDirectWarp', 'connections.calexp_list', 'visit_image')
pipeline.addConfigOverride('makeDirectWarp', 'doSelectPreWarp', False)
- Query string:
query_string = f"tract = {my_tract} AND patch = {my_patch} AND visit IN {my_visits_tupleString} " \
f"AND skymap = 'lsst_cells_v1' AND band = 'i'"
Then I retrieved both images and compared them:
deepCoadd_mine = out_butler.get('deep_coadd_predetection', tract=my_tract, patch=my_patch,
band='i', instrument='LSSTComCam', skymap='lsst_cells_v1',
collections=executor.quantum_graph.metadata["output_run"])
deepCoadd_rubin = butler.get('deep_coadd', tract=my_tract, patch=my_patch,
band='i', instrument='LSSTComCam', skymap='lsst_cells_v1')
diff = deepCoadd_mine.clone()
diff.image.array[:] = deepCoadd_mine.image.array - deepCoadd_rubin.image.array
Results
Side by side, both coadds look very similar (same display scale):
Simple image comparison
But a simple image comparison at the array level (mine - rubin, displayed between the 1st and 99th percentiles) shows:
Alard-Lupton Substraction
We also tried to implement a difference function using ´AlardLuptonSubtractTask´. The approach was adapted from this forum thread, where the DP1 ´deep_coadd´ is used as the template and my custom coadd as the science image (See code below.) . But it also gives some residuals:
Questions
- Is there a recommended method or tool in the Science Pipelines to compare two coadds of the same tract/patch beyond a direct pixel subtraction? Apart from our sanitiy check that our custom coadd reproduces de official one, we would like to compare custom coadds with injected images against the provided deep coadds, for instance.
- Is it expected that the two coadds differ?
Thanks!
def diff_AlardLupton(templateExposure, scienceExposure, warp=True):
"""
The idea was take from:
https://community.lsst.org/t/issues-with-image-subtraction/10429
"""
import numpy as np
import lsst.afw.table as afwTable
import lsst.daf.base as dafBase
from lsst.meas.algorithms.detection import SourceDetectionTask
from lsst.meas.deblender import SourceDeblendTask
from lsst.meas.base import SingleFrameMeasurementTask
from lsst.ip.diffim import AlardLuptonSubtractTask, AlardLuptonSubtractConfig
# Remove the bit mask
print("===> Cleaning masks")
template_masks = set(templateExposure.mask.getMaskPlaneDict())
scienceExposure_masks = set(scienceExposure.mask.getMaskPlaneDict())
common_masks = template_masks & scienceExposure_masks
for exp in [templateExposure, scienceExposure]:
for mask in list(exp.mask.getMaskPlaneDict().keys()):
if mask not in common_masks:
print(f"masks: {mask}")
try:
exp.mask.removeAndClearMaskPlane(mask)
except Exception:
pass
# Make a schema
# Identified sources on the scienceExposure.
# This catalog is used to select sources to perform
# the AL PSF matching on stamp images around them.
schema = afwTable.SourceTable.makeMinimalSchema()
schema.addField("coord_raErr", type="F")
schema.addField("coord_decErr", type="F")
schema.addField("detect_isPrimary", type="F") # Flag
schema.addField("sky_source", type="Flag")
algMetadata = dafBase.PropertyList()
# Detection task
detectConfig = SourceDetectionTask.ConfigClass()
detectConfig.thresholdValue = 5
detectConfig.thresholdType = "stdev"
detectTask = SourceDetectionTask(schema=schema, config=detectConfig)
# here because schema is modify by detectTask.run
deblendTask = SourceDeblendTask(schema=schema)
measConfig = SingleFrameMeasurementTask.ConfigClass()
measTask = SingleFrameMeasurementTask(schema=schema, config=measConfig, algMetadata=algMetadata)
print("===> Run detection")
tab = afwTable.SourceTable.make(schema)
result = detectTask.run(tab, scienceExposure)
sources = result.sources
# Deblend + Measurement
print("===> Run Deblend and Measurement")
deblendTask.run(scienceExposure, sources)
measTask.run(measCat=sources, exposure=scienceExposure)
sources = sources.copy(True)
# Sky sources flag
print("===> Selecting sky sources")
select_sky_sources(sources, schema)
# Warp the templateExposure to match with scienceExposure (imagen + PSF)
if warp:
print("===> Warping")
warp_templateExposure = warp_img(
ref_img=scienceExposure,
img_to_warp=templateExposure)
else:
warp_templateExposure = templateExposure
# Subtraction task
print("===> Subtracting")
# https://pipelines.lsst.io/py-api/lsst.ip.diffim.AlardLuptonSubtractTask.html#lsst.ip.diffim.AlardLuptonSubtractTask.run
config = AlardLuptonSubtractConfig()
subtractTask = AlardLuptonSubtractTask(config=config)
result = subtractTask.run(
template=warp_templateExposure,
science=scienceExposure,
sources=sources
)
diff = result.difference
return diff
def select_sky_sources(sources, schema, max_sky=500):
import random
sky_source_key = schema["sky_source"].asKey()
selected = []
for record in sources:
try:
flux = record.get("base_PsfFlux_instFlux")
flux_err = record.get("base_PsfFlux_instFluxErr")
snr = flux / flux_err if flux_err > 0 else 0
psf_flag = record.get("base_PsfFlux_flag")
saturated = record.get("base_PixelFlags_flag_saturated")
bad = record.get("base_PixelFlags_flag_bad")
edge = record.get("base_PixelFlags_flag_edge")
nchild = record.get("deblend_nChild")
is_sky = (
abs(snr) < 20 and # 2 stricto
not saturated and
not bad and
not edge and
nchild == 0
)
except Exception:
is_sky = False
record.set(sky_source_key, is_sky)
if is_sky:
selected.append(record)
print(f"Sky sources seleccionadas: {len(selected)}")
# FALLBACK SI NO HAY
if len(selected) == 0:
print("No sky sources → usando fallback aleatorio")
n = len(sources)
fallback_idx = random.sample(range(n), max(5, int(0.2 * n)))
for i, record in enumerate(sources):
record.set(sky_source_key, i in fallback_idx)
elif len(selected) > max_sky:
# limitar número
keep = set(random.sample(range(len(selected)), max_sky))
for i, record in enumerate(selected):
if i not in keep:
record.set(sky_source_key, False)
def warp_img(ref_img, img_to_warp, warping_kernel="lanczos5"):
"""
Warp an exposure (image + PSF) onto the coordinate system of another.
"""
import copy
import lsst.afw.math as afwMath
import lsst.afw.geom as afwGeom
import lsst.meas.algorithms as measAlg
# warp imagen
config = afwMath.Warper.ConfigClass()
config.warpingKernelName = warping_kernel
warper = afwMath.Warper.fromConfig(config)
bbox = ref_img.getBBox()
warpedExp = warper.warpExposure(ref_img.wcs, img_to_warp, destBBox=bbox)
warpedExp = copy.deepcopy(warpedExp)
# Warp PSF
psf = img_to_warp.getPsf()
if psf is not None:
xyTransform = afwGeom.makeWcsPairTransform(img_to_warp.getWcs(),
ref_img.getWcs())
warped_psf = measAlg.WarpedPsf(psf, xyTransform)
warpedExp.setPsf(warped_psf)
return warpedExp
diff = diff_AlardLupton(deepCoadd_rubin, deepCoadd_mine, warp=True)
plt.figure(figsize=(8,8))
plt.imshow(diff.image.array, origin="lower", cmap="gray",
vmin=np.percentile(diff.image.array, 0.1),
vmax=np.percentile(diff.image.array, 99.9))
plt.colorbar()
plt.title("Difference of deep_coadd made by me and the one rubin gives using AlardLupton adaptation.")
plt.show()


