Coverage for python/lsst/ip/diffim/getTemplate.py: 77%
501 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-10 01:41 -0800
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-10 01:41 -0800
1# This file is part of ip_diffim.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21import collections
22import dataclasses
23import functools
25import astropy.units
26from astropy.stats import gaussian_sigma_to_fwhm
27import numpy as np
29import lsst.afw.detection as afwDetection
30import lsst.afw.image as afwImage
31import lsst.geom as geom
32import lsst.afw.geom as afwGeom
33from lsst.afw.image import VisitInfo
34import lsst.afw.table as afwTable
35from lsst.afw.math._warper import computeWarpedBBox
36import lsst.afw.math as afwMath
37from lsst.daf.base import DateTime
38import lsst.pex.config as pexConfig
39import lsst.pex.exceptions
40import lsst.pipe.base as pipeBase
42from lsst.images import (
43 Box,
44 DifferenceImage,
45 DifferenceImageTemplateInfo,
46 DifferenceImageTemplateInput,
47 GeneralFrame,
48 NoOverlapError,
49 Polygon,
50 Transform,
51 get_legacy_optional_mask_planes,
52 get_legacy_template_mask_planes,
53)
54from lsst.skymap import BaseSkyMap
55from lsst.ip.diffim.dcrModel import DcrModel
56from lsst.ip.diffim.utils import evaluateMeanPsfFwhm, getPsfFwhm, record_from_obs_info
57from lsst.meas.algorithms import CoaddPsf, CoaddPsfConfig, SubtractBackgroundTask, ScaleVarianceTask
58from lsst.utils.timer import timeMethod
60__all__ = [
61 "GetTemplateTask",
62 "GetTemplateConfig",
63 "GetDcrTemplateTask",
64 "GetDcrTemplateConfig",
65]
67_PSF_STAMP_SIGMAS = 5
68"""Width, in N sigma, of the Gaussian approximation to the CoaddPsf."""
70_UNKNOWN_INSTRUMENT = "UNKNOWN"
71"""Instrument recorded for a coadd or template made from observations of an
72unknown instrument.
73"""
75_FOOTPRINT_MARGIN = 32.0
76"""Distance, in coadd pixels, by which the science image's footprint in a
77coadd is grown before a coadd input is clipped to it. It must exceed twice
78the distance by which a distorted science image edge departs from a straight
79line between its corners, and limits how far outside the science image its
80WCS is evaluated.
81"""
84def _pixelTransform(fromWcs, toWcs):
85 """Return a chainable transformation between two pixel frames.
87 Parameters
88 ----------
89 fromWcs, toWcs : `lsst.afw.geom.SkyWcs`
90 WCSs of the input and output pixels.
92 Returns
93 -------
94 transform : `lsst.images.Transform`
95 Transform from ``fromWcs`` pixels to ``toWcs`` pixels.
96 """
97 return Transform.from_legacy(afwGeom.makeWcsPairTransform(fromWcs, toWcs),
98 GeneralFrame(unit=astropy.units.pix),
99 GeneralFrame(unit=astropy.units.pix)
100 )
103def _getImageOverlap(record, coaddWcs, scienceFootprint, coaddToScienceTransform, scienceBox, border):
104 """Return the region enclosing the overlap of the science image and one
105 input image of the coadd.
107 Note that there are three different WCS's involved here: the WCS of the
108 original image that was warped to contribute to the coadd, the coadd WCS,
109 and the current science image WCS that we are building a template for.
111 Parameters
112 ----------
113 record : `lsst.afw.table.ExposureRecord`
114 Input record of a coadd, with the input's WCS, bounding box and
115 optional valid polygon.
116 coaddWcs : `lsst.afw.geom.SkyWcs`
117 WCS of the coadd.
118 scienceFootprint : `lsst.images.Region`
119 ``scienceBox`` in coadd pixels, grown by ``_FOOTPRINT_MARGIN``.
120 coaddToScienceTransform : `lsst.images.Transform`
121 Transform from coadd pixels to science image pixels.
122 scienceBox : `lsst.images.Box`
123 Bounding box of the science image.
124 border : `float`
125 Width, in input pixels, of the border of the input that warping left
126 out of the coadd.
128 Returns
129 -------
130 footprint : `lsst.images.Polygon` or `None`
131 The part of ``scienceBox`` the input's warp covers, in science image
132 pixels, or `None` if it covers none of it.
133 """
134 inputBox = geom.Box2D(record.getBBox())
135 inputBox.grow(-border)
136 inputBounds = Polygon.from_legacy(afwGeom.Polygon(inputBox))
137 polygon = record.getValidPolygon()
138 inputToCoadd = _pixelTransform(record.getWcs(), coaddWcs)
139 try:
140 if polygon is not None: 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 inputBounds = inputBounds.intersection(Polygon.from_legacy(polygon))
142 # Only vertices are transformed, so the edges of the input stay
143 # straight.
144 footprint = (
145 inputBounds.transform(inputToCoadd)
146 .intersection(scienceFootprint)
147 .transform(coaddToScienceTransform)
148 )
149 # The convex hull keeps the footprint in one piece when it is clipped.
150 footprint = footprint.convex_hull().intersection(scienceBox)
151 except NoOverlapError:
152 return None
153 return Polygon.from_box(footprint) if isinstance(footprint, Box) else footprint
156def _warpBorder(kernelName):
157 """Return the width of the border of an image that warping leaves out.
159 A warped pixel is computed only where the whole kernel lies on the image.
161 Parameters
162 ----------
163 kernelName : `str`
164 Name of the warping kernel, as `lsst.afw.math.Warper` takes it.
166 Returns
167 -------
168 border : `float`
169 Width of the border on each side, in pixels from the edge of the
170 image's bounding box; 2.5 for ``lanczos3``.
171 """
172 kernel = afwMath.WarpingControl(kernelName).getWarpingKernel()
173 return kernel.getCtr().getX() + 0.5
176@functools.cache
177def _warpKernelSumSq(kernelName, nPhase=100):
178 """Compute the phase-averaged sum of squared values of a warping kernel.
180 Warping with a kernel normalized to unit sum multiplies the variance by
181 this sum, which is less than one. The power the warp took off the
182 diagonal became covariance between neighboring pixels, which a variance
183 plane cannot represent, so dividing by it restores the noise power: the
184 variance of a flux measured over a region and not a single pixel.
186 Parameters
187 ----------
188 kernelName : `str`
189 Name of the warping kernel, as `lsst.afw.math.Warper` takes it.
190 nPhase : `int`, optional
191 Number of sub-pixel phases to average over.
193 Returns
194 -------
195 sumSq : `float`
196 Sum of the squared 2-D kernel values, averaged over sub-pixel
197 phase. Converges with large ``nPhase`` to 0.798 for ``lanczos3``,
198 4/9 for ``bilinear``, and 1 for no interpolation.
200 Notes
201 -----
202 The kernels are separable and the phases of the two axes independent,
203 so this is the square of the 1-D average. The warper normalizes the
204 kernel to unit sum at each phase, which the analytic Lanczos integral
205 does not include, so the average is computed from the kernel itself.
206 """
207 # Disable the cache since we are evaluating fewer phase points than the
208 # default cache size.
209 kernel = afwMath.Warper(kernelName, cacheSize=0).getWarpingKernel()
210 image = afwImage.ImageD(kernel.getDimensions())
211 total = 0.0
212 for phase in (np.arange(nPhase) + 0.5)/nPhase:
213 kernel.setKernelParameters((float(phase), 0.0))
214 kernel.computeImage(image, doNormalize=True)
215 total += float((image.array.sum(axis=0)**2).sum())
216 return (total/nPhase)**2
219class GetTemplateConnections(
220 pipeBase.PipelineTaskConnections,
221 dimensions=("instrument", "visit", "detector"),
222 defaultTemplates={"coaddName": "goodSeeing", "warpTypeSuffix": "", "fakesType": ""},
223):
224 bbox = pipeBase.connectionTypes.Input(
225 doc="Bounding box of exposure to determine the geometry of the output template.",
226 name="{fakesType}calexp.bbox",
227 storageClass="Box2I",
228 dimensions=("instrument", "visit", "detector"),
229 deprecated="Replaced by the `detector` connection. Will be removed after v31.",
230 )
231 detector = pipeBase.connectionTypes.Input(
232 doc="Detector of the exposure that we will construct the template for."
233 " Its bounding box sets the geometry of the output template.",
234 name="{fakesType}calexp.detector",
235 storageClass="Detector",
236 dimensions=("instrument", "visit", "detector"),
237 )
238 obs_info = pipeBase.connectionTypes.Input(
239 doc="Observation metadata of the exposure the template is built for."
240 " Only read with output_image_type='future', which needs this to set"
241 " the obs_info component of a lsst.images.VisitImage.",
242 name="{fakesType}calexp.obs_info",
243 storageClass="ObservationInfo",
244 dimensions=("instrument", "visit", "detector"),
245 )
246 wcs = pipeBase.connectionTypes.Input(
247 doc="WCS of the exposure that we will construct the template for.",
248 name="{fakesType}calexp.wcs",
249 storageClass="Wcs",
250 dimensions=("instrument", "visit", "detector"),
251 )
252 skyMap = pipeBase.connectionTypes.Input(
253 doc="Geometry of the tracts and patches that the coadds are defined on.",
254 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
255 dimensions=("skymap",),
256 storageClass="SkyMap",
257 )
258 coaddExposures = pipeBase.connectionTypes.Input(
259 doc="Coadds that may overlap the desired region, as possible inputs to the template."
260 " Will be restricted to those that directly overlap the projected bounding box.",
261 dimensions=("tract", "patch", "skymap", "band"),
262 storageClass="ExposureF",
263 name="{fakesType}{coaddName}Coadd{warpTypeSuffix}",
264 multiple=True,
265 deferLoad=True,
266 deferGraphConstraint=True,
267 )
269 template = pipeBase.connectionTypes.Output(
270 doc="Warped template, pixel matched to the bounding box and WCS.",
271 dimensions=("instrument", "visit", "detector"),
272 storageClass="ExposureF",
273 name="{fakesType}{coaddName}Diff_templateExp{warpTypeSuffix}",
274 )
276 def __init__(self, *, config=None):
277 super().__init__(config=config)
278 # Kept only so that older config files still load.
279 del self.bbox
280 if config.requireCoaddAtGraphBuild: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true
281 self.coaddExposures = dataclasses.replace(
282 self.coaddExposures,
283 deferGraphConstraint=False,
284 )
285 if config.output_image_type == "future":
286 self.template = dataclasses.replace(self.template, storageClass="DifferenceImage")
287 else:
288 del self.obs_info
291class GetTemplateConfig(
292 pipeBase.PipelineTaskConfig, pipelineConnections=GetTemplateConnections
293):
294 templateBorderSize = pexConfig.Field(
295 dtype=int,
296 default=20,
297 doc="Number of pixels to grow the requested template image to account for warping",
298 )
299 warp = pexConfig.ConfigField(
300 dtype=afwMath.Warper.ConfigClass,
301 doc="warper configuration",
302 )
303 doCorrectVarianceWarpCorrelation = pexConfig.Field(
304 dtype=bool,
305 default=True,
306 doc="Correct the template variance for the correlation that warping"
307 " introduced between neighboring pixels? This covers both warps: the"
308 " one that built the coadds, named by coaddWarpKernel, and the one"
309 " this task performs, named by warp.warpingKernelName. It is a single"
310 " scalar factor, fixed entirely by those two kernels and independent"
311 " of the pixel scales involved.",
312 )
313 coaddWarpKernel = pexConfig.ChoiceField(
314 dtype=str,
315 default="lanczos3",
316 allowed={
317 "bilinear": "bilinear interpolation",
318 "lanczos3": "Lanczos kernel of order 3",
319 "lanczos4": "Lanczos kernel of order 4",
320 "lanczos5": "Lanczos kernel of order 5",
321 },
322 doc="Warping kernel that was used to build the input coadds. This"
323 " cannot be recovered from the coadds themselves. It is used to"
324 " correct the variance if doCorrectVarianceWarpCorrelation is True,"
325 " and to trim the coadd inputs bboxes to account for the size of the"
326 " warping kernel.",
327 )
328 coaddPsf = pexConfig.ConfigField(
329 doc="Configuration for CoaddPsf",
330 dtype=CoaddPsfConfig,
331 )
332 fwhmExposureGrid = pexConfig.Field(
333 doc="Grid size to compute the average PSF FWHM in an exposure (pixels)",
334 dtype=int,
335 default=10,
336 )
337 fwhmExposureBuffer = pexConfig.Field(
338 doc="Fractional buffer margin to be left out of all sides of the image during construction"
339 " of grid to compute average PSF FWHM in an exposure",
340 dtype=float,
341 default=0.05,
342 )
343 varianceBackground = pexConfig.ConfigurableField(
344 target=SubtractBackgroundTask,
345 doc="Task to estimate the background variance.",
346 )
347 highVarianceThreshold = pexConfig.RangeField(
348 dtype=float,
349 default=4,
350 min=1,
351 doc="Set the HIGH_VARIANCE mask plane for regions with variance"
352 " greater than the median by this factor.",
353 )
354 highVarianceMaskFraction = pexConfig.Field(
355 dtype=float,
356 default=0.1,
357 doc="Minimum fraction of unmasked pixels needed to set the"
358 " HIGH_VARIANCE mask plane.",
359 )
360 doScaleVariance = pexConfig.Field(
361 dtype=bool,
362 default=False,
363 doc="Scale the variance of the template image to match its per-pixel"
364 " scatter? Warping correlates the noise, so when combined with"
365 " doCorrectVarianceWarpCorrelation this overcounts the correction.",
366 )
367 scaleVariance = pexConfig.ConfigurableField(
368 target=ScaleVarianceTask,
369 doc="Subtask to rescale the variance of the template to the statistically expected level."
370 )
371 requireCoaddAtGraphBuild = pexConfig.Field(
372 dtype=bool,
373 default=False,
374 doc="If True, include the coadd dataset existence in the"
375 " initial butler query during QuantumGraph generation.",
376 )
377 output_image_type = pexConfig.ChoiceField[str](
378 "Which image type to use for the output template.",
379 allowed={
380 "legacy": "Write as a lsst.afw.image.ExposureF.",
381 "future": "Write as a lsst.images.DifferenceImage.",
382 },
383 optional=False,
384 default="legacy",
385 )
386 raiseOnUndefinedMaskMap = pexConfig.Field(
387 dtype=bool,
388 default=False,
389 doc="Raise if the template has pixels set in a mask plane that"
390 " `lsst.images` does not map? If False, log a warning and drop that"
391 " plane from the output instead."
392 " Only used with output_image_type='future'.",
393 )
395 def setDefaults(self):
396 # Use a smaller cache: per SeparableKernel.computeCache, this should
397 # give a warping error of a fraction of a count (these must match).
398 self.warp.cacheSize = 100000
399 self.coaddPsf.cacheSize = self.warp.cacheSize
400 # The WCS for LSST should be smoothly varying, so we can use a longer
401 # interpolation length for WCS evaluations.
402 self.warp.interpLength = 100
403 self.warp.warpingKernelName = "lanczos3"
404 self.coaddPsf.warpingKernelName = self.warp.warpingKernelName
406 # Background subtraction of the variance plane
407 self.varianceBackground.algorithm = "LINEAR"
408 self.varianceBackground.binSize = 32
409 self.varianceBackground.useApprox = False
410 self.varianceBackground.statisticsProperty = "MEDIAN"
411 self.varianceBackground.doFilterSuperPixels = True
412 self.varianceBackground.ignoredPixelMask = ["BAD",
413 "EDGE",
414 "DETECTED",
415 "DETECTED_NEGATIVE",
416 "NO_DATA",
417 ]
420class GetTemplateTask(pipeBase.PipelineTask):
421 ConfigClass = GetTemplateConfig
422 _DefaultName = "getTemplate"
424 def __init__(self, *args, **kwargs):
425 super().__init__(*args, **kwargs)
426 if self.config.doScaleVariance:
427 self.makeSubtask("scaleVariance")
428 self.warper = afwMath.Warper.fromConfig(self.config.warp)
429 self.schema = afwTable.ExposureTable.makeMinimalSchema()
430 self.schema.addField(
431 "tract", type=np.int32, doc="Which tract this exposure came from."
432 )
433 self.schema.addField(
434 "patch",
435 type=np.int32,
436 doc="Which patch in the tract this exposure came from.",
437 )
438 self.schema.addField(
439 "weight",
440 type=float,
441 doc="Weight for each exposure, used to make the CoaddPsf; should always be 1.",
442 )
443 self.schema.addField(
444 "instrument",
445 type=str,
446 size=64,
447 doc="Instrument whose observations were coadded to make the template, or UNKNOWN.",
448 )
449 self.makeSubtask("varianceBackground")
451 def runQuantum(self, butlerQC, inputRefs, outputRefs):
452 inputs = butlerQC.get(inputRefs)
453 detector = inputs.pop("detector")
454 bbox = detector.getBBox()
455 wcs = inputs.pop("wcs")
456 obsInfo = inputs.pop("obs_info", None)
457 coaddExposures = inputs.pop("coaddExposures")
458 skymap = inputs.pop("skyMap")
460 # This should not happen with a properly configured execution context.
461 assert not inputs, "runQuantum got more inputs than expected"
463 results = self.getExposures(coaddExposures, bbox, skymap, wcs)
464 physical_filter = butlerQC.quantum.dataId["physical_filter"]
465 outputs = self.run(
466 coaddExposureHandles=results.coaddExposures,
467 bbox=bbox,
468 wcs=wcs,
469 dataIds=results.dataIds,
470 physical_filter=physical_filter,
471 visit=outputRefs.template.dataId["visit"],
472 instrument=butlerQC.quantum.dataId["instrument"],
473 )
474 if self.config.output_image_type == "future":
475 dataId = butlerQC.quantum.dataId
476 exposureRecord = record_from_obs_info(obsInfo, dataId["instrument"], dataId["visit"],
477 dataId.universe)
478 outputs.template = self.convert_outputs_to_future(outputs.template,
479 inputRefs.coaddExposures,
480 detector=detector,
481 exposureRecord=exposureRecord,
482 templateInputs=outputs.templateInputs)
483 butlerQC.put(outputs, outputRefs)
485 def convert_outputs_to_future(self, template, coadd_refs, detector, exposureRecord,
486 templateInputs=None):
487 """Convert a template image to DifferenceImage format.
489 This replaces ``template`` with an `lsst.images.DifferenceImage`
490 instance and records the coadds contributed to it, along with the
491 instrument they were made from and their input image identifiers.
492 The Gaussian PSF set by `run` is converted to an
493 `lsst.images.psfs.GaussianPointSpreadFunction`.
495 Parameters
496 ----------
497 template : `lsst.afw.image.Exposure`
498 Template to convert. Its detector is replaced with ``detector``
499 and its mask is modified in place.
500 coadd_refs : `list` [`lsst.daf.butler.DatasetRef`]
501 References to the coadds that may have gone into the template.
502 May be a superset of the coadds that were used; supplies the
503 dataset id and RUN collection recorded for each coadd that was.
504 detector : `lsst.afw.cameraGeom.Detector`
505 Detector of the science image that the template was built for.
506 exposureRecord : `lsst.daf.butler.DimensionRecord`
507 The ``exposure`` record of the observation the template was built
508 for, which supplies the observation metadata recorded on the
509 output.
510 templateInputs : `dict` [`tuple` [`int`, `int`], \
511 `list` [`lsst.images.DifferenceImageTemplateInput`]], optional
512 Input image records of each coadd, keyed by tract and patch, as
513 returned by `run`. Coadds not listed record no inputs.
515 Returns
516 -------
517 convertedTemplate : `lsst.images.DifferenceImage`
518 The converted image.
520 Raises
521 ------
522 RuntimeError
523 Raised if the template has pixels set in a mask plane that
524 `lsst.images.get_legacy_template_mask_planes` does not map and
525 ``config.raiseOnUndefinedMaskMap`` is True, or if ``coadd_refs``
526 spans more than one skymap.
527 """
528 # The template PSF is a Gaussian, so rebuild the CoaddPsf from the
529 # template inputs to record which coadds contributed.
530 catalog = template.getInfo().getCoaddInputs().ccds
531 coaddPsf = self._makePsf(template, catalog, template.getWcs())
532 # `run` records the template's instrument on every input.
533 instrument = catalog[0]["instrument"]
534 template.setDetector(detector)
535 skymaps = {ref.dataId["skymap"] for ref in coadd_refs}
536 if len(skymaps) != 1: 536 ↛ 537line 536 didn't jump to line 537 because the condition on line 536 was never true
537 raise RuntimeError("Expected the template inputs to come from one skymap;"
538 f" got {sorted(skymaps)}.")
539 butler_info = {(ref.dataId["tract"], ref.dataId["patch"]): (ref.id, ref.run)
540 for ref in coadd_refs}
541 planeMap = get_legacy_template_mask_planes()
542 if not self.config.raiseOnUndefinedMaskMap:
543 self._clearUnmappedMaskPlanes(template.mask, planeMap)
544 convertedTemplate = DifferenceImage.from_legacy(
545 template,
546 exposure_record=exposureRecord,
547 unit=astropy.units.nJy,
548 plane_map=planeMap,
549 )
550 del template
551 convertedTemplate.templates = DifferenceImageTemplateInfo.from_legacy_psf(
552 convertedTemplate.sky_projection.pixel_frame,
553 coaddPsf,
554 skymaps.pop(),
555 butler_info,
556 log=self.log,
557 instrument=instrument,
558 )
559 if templateInputs:
560 for info in convertedTemplate.templates:
561 info.inputs = templateInputs.get((info.tract, info.patch), [])
562 return convertedTemplate
564 def _clearUnmappedMaskPlanes(self, mask, planeMap):
565 """Clear the pixels of every mask plane that conversion would reject.
567 Parameters
568 ----------
569 mask : `lsst.afw.image.Mask`
570 Template mask to modify in place.
571 planeMap : `dict` [`str`, `lsst.images.MaskPlane`]
572 Plane map the template will be converted with. The optional
573 planes of `lsst.images.get_legacy_optional_mask_planes` are kept
574 as well, since conversion adds them when they have pixels set.
575 """
576 keep = planeMap.keys() | get_legacy_optional_mask_planes().keys()
577 for name in mask.getMaskPlaneDict():
578 if name in keep:
579 continue
580 bitmask = mask.getPlaneBitMask(name)
581 nSet = np.count_nonzero(mask.array & bitmask)
582 if nSet:
583 self.log.warning("Dropping mask plane %s from the template: %d pixels are set, but"
584 " the template plane map does not include it.", name, nSet)
585 mask.array &= ~bitmask
587 def getExposures(self, coaddExposureHandles, bbox, skymap, wcs):
588 """Return a data structure containing the coadds that overlap the
589 specified bbox projected onto the sky, and a corresponding data
590 structure of their dataIds.
591 These are the appropriate inputs to this task's `run` method.
593 The spatial index in the butler registry has generous padding and often
594 supplies patches near, but not directly overlapping the desired region.
595 This method filters the inputs so that `run` does not have to read in
596 all possibly-matching coadd exposures.
598 Parameters
599 ----------
600 coaddExposureHandles : `iterable` \
601 [`lsst.daf.butler.DeferredDatasetHandle` of \
602 `lsst.afw.image.Exposure`]
603 Dataset handles to exposures that might overlap the desired
604 region.
605 bbox : `lsst.geom.Box2I`
606 Template bounding box of the pixel geometry onto which the
607 coaddExposures will be resampled.
608 skymap : `lsst.skymap.SkyMap`
609 Geometry of the tracts and patches the coadds are defined on.
610 wcs : `lsst.afw.geom.SkyWcs`
611 Template WCS onto which the coadds will be resampled.
613 Returns
614 -------
615 result : `lsst.pipe.base.Struct`
616 A struct with attributes:
618 ``coaddExposures``
619 Dict of coadd exposures that overlap the projected bbox,
620 indexed on tract id
621 (`dict` [`int`, `list` [`lsst.daf.butler.DeferredDatasetHandle` of
622 `lsst.afw.image.Exposure`] ]).
623 ``dataIds``
624 Dict of data IDs of the coadd exposures that overlap the
625 projected bbox, indexed on tract id
626 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]).
628 Raises
629 ------
630 NoWorkFound
631 Raised if no patches overlap the input detector bbox, or the input
632 WCS is None.
633 """
634 if wcs is None:
635 raise pipeBase.NoWorkFound(
636 "WCS is None; cannot find overlapping exposures."
637 )
639 # Exposure's validPolygon would be more accurate
640 detectorPolygon = geom.Box2D(bbox)
641 detectorCorners = wcs.pixelToSky(detectorPolygon.getCorners())
642 overlappingArea = 0
643 coaddExposures = collections.defaultdict(list)
644 dataIds = collections.defaultdict(list)
646 for coaddRef in coaddExposureHandles:
647 dataId = coaddRef.dataId
648 patchWcs = skymap[dataId["tract"]].getWcs()
649 patchBBox = skymap[dataId["tract"]][dataId["patch"]].getOuterBBox()
650 patchPolygon = afwGeom.Polygon(geom.Box2D(patchBBox))
651 # Calculate detector/patch overlap in patch coordinates rather than
652 # detector coordinates because the skymap's inverse mapping
653 # (patchWcs.skyToPixel()) is more stable than the detector's for
654 # arbitrary sky coordinates.
655 detectorInPatchCoordinates = afwGeom.Polygon(patchWcs.skyToPixel(detectorCorners))
656 if patchPolygon.intersection(detectorInPatchCoordinates):
657 overlappingArea += patchPolygon.intersectionSingle(
658 detectorInPatchCoordinates
659 ).calculateArea()
660 self.log.info(
661 "Using template input tract=%s, patch=%s",
662 dataId["tract"],
663 dataId["patch"],
664 )
665 coaddExposures[dataId["tract"]].append(coaddRef)
666 dataIds[dataId["tract"]].append(dataId)
668 if not overlappingArea:
669 raise pipeBase.NoWorkFound("No patches overlap detector")
671 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds)
673 @timeMethod
674 def run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter, visit=None, instrument=None):
675 """Warp coadds from multiple tracts and patches to form a template to
676 subtract from a science image.
678 Tract and patch overlap regions are combined by a variance-weighted
679 average, and the variance planes are combined with the same weights,
680 not added in quadrature; the overlap regions are not statistically
681 independent, because they're derived from the same original data.
682 The PSF of the template is a Gaussian with the same width as the
683 CoaddPsf evaluated at the center of the detector.
685 The template is built from coadds of one instrument: the science
686 image's ``instrument`` if any coadd was made from it, and otherwise the
687 one that most of the coadds were made from. Coadds made from another
688 instrument are dropped, and so are coadds that record no instrument
689 unless none of the coadds records one.
691 Parameters
692 ----------
693 coaddExposureHandles : `dict` [`int`, `list` of \
694 [`lsst.daf.butler.DeferredDatasetHandle` of \
695 `lsst.afw.image.Exposure`]]
696 Coadds to be mosaicked, indexed on tract id.
697 bbox : `lsst.geom.Box2I`
698 Input Bounding box of the detector geometry onto which to
699 resample the ``coaddExposureHandles``. The output template bbox
700 will be grown by ``templateBorderSize``.
701 wcs : `lsst.afw.geom.SkyWcs`
702 Template WCS onto which to resample the ``coaddExposureHandles``.
703 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]]
704 Record of the tract and patch of each coaddExposure, indexed on
705 tract id.
706 physical_filter : `str`
707 Physical filter of the science image.
708 visit : `int`, optional
709 If supplied, over-write the visit ID in the template's visitInfo
710 so that downstream source injection tasks can link the template and
711 science image for the visit.
712 instrument : `str`, optional
713 Instrument of the science image. Coadds made from it are used in
714 preference to coadds from any other instrument.
716 Returns
717 -------
718 result : `lsst.pipe.base.Struct`
719 A struct with attributes:
721 ``template``
722 A template coadd exposure assembled out of patches, with
723 pixels in nJy. Its PSF is a Gaussian approximation to the
724 CoaddPsf of its inputs (`lsst.afw.image.ExposureF`).
725 ``templateInputs``
726 Input image records of each coadd that cover part of the
727 science image, keyed by tract and patch (`dict`
728 [`tuple` [`int`, `int`],
729 `list` [`lsst.images.DifferenceImageTemplateInput`]]). Empty
730 unless ``config.output_image_type`` is ``"future"``.
732 Raises
733 ------
734 NoWorkFound
735 If no coadds are found with sufficient un-masked pixels.
736 RuntimeError
737 If the template is not known to be from the science image's
738 instrument and no coadd records the plate scale of its input
739 images, so the template variance cannot be corrected.
740 """
741 band, photoCalib, templateInstrument = self._checkInputs(dataIds, coaddExposureHandles, instrument)
743 scienceBBox = bbox
744 bbox = geom.Box2I(bbox)
745 bbox.grow(self.config.templateBorderSize)
747 warped = {}
748 catalogs = []
749 inputs = []
750 plateScaleFactor = 1.0 if instrument == templateInstrument else None
752 for tract in coaddExposureHandles:
753 maskedImages, catalog, totalBox, coaddInputs = self._makeExposureCatalog(
754 coaddExposureHandles[tract], dataIds[tract], templateInstrument
755 )
756 if len(catalog) == 0:
757 # Every coadd in this tract was dropped for its instrument.
758 continue
759 # Determine the ratio of the original pixel area to the pixel area
760 # of the science image. This will only be different from 1 if the
761 # coadd was from a different instrument. A single correction factor
762 # is calculated from the first usable coadd and applied to all.
763 if plateScaleFactor is None:
764 plateScaleFactor = self._plateScaleFactor(coaddInputs[0], wcs, bbox)
765 warpedBox = computeWarpedBBox(catalog[0].wcs, bbox, wcs)
766 warpedBox.grow(5) # to ensure we catch all relevant input pixels
767 # Combine images from individual patches together.
768 unwarped, count, included = self._merge(
769 maskedImages, warpedBox, catalog[0].wcs
770 )
771 # Delete `maskedImages` after combining into one large image to reduce peak memory use
772 del maskedImages
773 if count == 0:
774 self.log.info(
775 "No valid pixels from coadd patches in tract %s; not including in output.",
776 tract,
777 )
778 continue
779 warpedBox.clip(totalBox)
780 potentialInput = self.warper.warpExposure(
781 wcs, unwarped.subset(warpedBox), destBBox=bbox
782 )
783 # Delete the single large `unwarped` image after warping to reduce peak memory use
784 del unwarped
785 if np.all(
786 potentialInput.mask.array
787 & potentialInput.mask.getPlaneBitMask("NO_DATA")
788 ):
789 self.log.info(
790 "No overlap from coadd patches in tract %s; not including in output.",
791 tract,
792 )
793 continue
795 # Trim the exposure catalog to just the patches that were used.
796 tempCatalog = afwTable.ExposureCatalog(self.schema)
797 tempCatalog.reserve(len(included))
798 for i in included:
799 tempCatalog.append(catalog[i])
800 inputs.append(coaddInputs[i])
801 catalogs.append(tempCatalog)
802 warped[tract] = potentialInput.maskedImage
804 if len(warped) == 0:
805 raise pipeBase.NoWorkFound("No patches found to overlap science exposure.")
807 # At this point, all entries will be valid, so we can ignore included.
808 template, count, _ = self._merge(warped, bbox, wcs)
809 if count == 0: 809 ↛ 810line 809 didn't jump to line 810 because the condition on line 809 was never true
810 raise pipeBase.NoWorkFound("No valid pixels in warped template.")
812 if self.config.doScaleVariance:
813 # Scale the variance of the template image before subtraction, if
814 # needed. Note that the science variance is scaled
815 # independently in ``AlardLuptonSubtractTask``.
816 varianceFactor = self.scaleVariance.run(template.maskedImage)
817 self.log.info("Template variance scaling factor: %.2f", varianceFactor)
818 self.metadata["scaleTemplateVarianceFactor"] = varianceFactor
820 self._correctVariance(template, plateScaleFactor)
822 # Make a single catalog containing all the inputs that were accepted.
823 catalog = afwTable.ExposureCatalog(self.schema)
824 catalog.reserve(sum([len(c) for c in catalogs]))
825 for c in catalogs:
826 catalog.extend(c)
827 # Only the future output type records the inputs.
828 if self.config.output_image_type == "future":
829 templateInputs = self._collectTemplateInputs(catalog, inputs, wcs, scienceBBox)
830 else:
831 templateInputs = {}
833 # Set a mask plane for any regions with exceptionally high variance.
834 self.checkHighVariance(template)
835 if visit is not None:
836 template.getInfo().setVisitInfo(VisitInfo(id=visit))
837 template.setFilter(afwImage.FilterLabel(band, physical_filter))
838 identity = afwImage.PhotoCalib(1.0)
839 if photoCalib is not None and photoCalib != identity:
840 template.maskedImage = photoCalib.calibrateImage(template.maskedImage)
841 photoCalib = identity
842 template.setPhotoCalib(photoCalib)
843 template.setPsf(self._makePsf(template, catalog, wcs))
844 template.setPsf(self._makeGaussianPsf(template))
846 # Record the input coadd patches as the template's coadd inputs.
847 coaddInputs = afwImage.CoaddInputs(afwTable.ExposureTable.makeMinimalSchema(), self.schema)
848 coaddInputs.ccds.extend(catalog, deep=True)
849 template.getInfo().setCoaddInputs(coaddInputs)
850 return pipeBase.Struct(template=template, templateInputs=templateInputs)
852 def _correctVariance(self, template, plateScaleFactor):
853 """Correct the template variance plane for the effects of warping.
855 Warping introduces a covariance between neighboring pixels, while the
856 input variance plane captures only the diagonal term. Compute a
857 multiplicative correction factor to apply to the final variance so that
858 the detection significance on the convolved difference image is correct
859 on average.
861 Separately, also apply a scalar multiplicative correction factor to the
862 template variance if the plate scale of the coadd's constituent images
863 is different than the science image the template is being constructed
864 for. This should only be necessary if the coadd images were from a
865 different instrument than the science image, since the scale change
866 otherwise cancels between the two warps. If the plate scale of the
867 science instrument is smaller than the plate scale of the coadd
868 instrument, then the template pixels will be correlated and the true
869 variance will be higher than the image pixel noise level would suggest.
871 Parameters
872 ----------
873 template : `lsst.afw.image.Exposure`
874 Assembled template; its variance plane is modified in place.
875 plateScaleFactor : `float` or `None`
876 Correction for the change in pixel area, from `_plateScaleFactor`.
878 Raises
879 ------
880 RuntimeError
881 Raised if ``plateScaleFactor`` is `None`.
882 """
883 if plateScaleFactor is None:
884 raise RuntimeError(
885 "Could not determine the plate scale of the images the template coadds were made from,"
886 " so the template variance cannot be corrected for the change in pixel area."
887 )
888 self.metadata["variancePlateScaleFactor"] = plateScaleFactor
889 correlationFactor = 1.0
890 if self.config.doCorrectVarianceWarpCorrelation:
891 # Both warps correlate the noise: the one that built the coadds,
892 # and the one this task just performed.
893 correlationFactor = 1/(_warpKernelSumSq(self.config.coaddWarpKernel)
894 * _warpKernelSumSq(self.config.warp.warpingKernelName))
895 self.metadata["varianceWarpCorrelationFactor"] = correlationFactor
896 scale = correlationFactor*plateScaleFactor
897 if scale == 1.0:
898 return
899 template.variance.array *= scale
900 self.metadata["templateVarianceCorrectionFactor"] = scale
901 self.log.info(
902 "Variance correction factor due to plate scale changes: %.4f",
903 plateScaleFactor,
904 )
905 self.log.info(
906 "Variance correction factor due to warping correlations: %.4f",
907 correlationFactor,
908 )
909 self.log.info(
910 "Overall template variance correction factor: %.4f",
911 scale,
912 )
914 def _plateScaleFactor(self, coaddInputs, wcs, bbox):
915 """Compute the variance correction for the total change in pixel area
916 between the coadd's constituent images and the science image.
918 Parameters
919 ----------
920 coaddInputs : `lsst.afw.image.CoaddInputs` or `None`
921 Input records of one of the coadd patches.
922 wcs : `lsst.afw.geom.SkyWcs`
923 WCS of the science image the template is being built for.
924 bbox : `lsst.geom.Box2I`
925 Bounding box of the template, used only to choose where to
926 evaluate the science image pixel scale.
928 Returns
929 -------
930 factor : `float` or `None`
931 The correction factor equivalent to the ratio of the area of a pixel
932 from the coadd's instrument to the science instrument, or `None` if
933 this patch carries no input record from which it could be computed.
934 """
935 if coaddInputs is None: 935 ↛ 936line 935 didn't jump to line 936 because the condition on line 935 was never true
936 return None
937 scienceScale = wcs.getPixelScale(geom.Box2D(bbox).getCenter()).asArcseconds()
939 for record in coaddInputs.ccds:
940 # Iterate through the records, and use the first one with a WCS
941 # that gives a usable pixel area.
942 originalWcs = record.getWcs()
943 if originalWcs is None:
944 continue
945 center = geom.Box2D(record.getBBox()).getCenter()
946 pviScale = originalWcs.getPixelScale(center).asArcseconds()
947 # The area of one original pixel, expressed in science pixels.
948 factor = (pviScale/scienceScale)**2
949 if np.isfinite(factor) and factor > 0: 949 ↛ 939line 949 didn't jump to line 939 because the condition on line 949 was always true
950 return factor
951 return None
953 def _collectTemplateInputs(self, catalog, coaddInputs, wcs, bbox):
954 """Find the input images and epochs of each coadd in the template.
956 Parameters
957 ----------
958 catalog : `lsst.afw.table.ExposureCatalog`
959 Coadds the template was built from, with their tract, patch and
960 WCS.
961 coaddInputs : `list` [`lsst.afw.image.CoaddInputs` or `None`]
962 Input records of each coadd in ``catalog``, in the same order.
963 wcs : `lsst.afw.geom.SkyWcs`
964 WCS of the science image.
965 bbox : `lsst.geom.Box2I`
966 Bounding box of the science image.
968 Returns
969 -------
970 templateInputs : `dict` [`tuple` [`int`, `int`], \
971 `list` [`lsst.images.DifferenceImageTemplateInput`]]
972 Inputs of each coadd that cover part of the science image, keyed
973 by tract and patch, with the part their warps cover in science
974 image pixels.
975 """
976 scienceBox = Box.from_legacy(bbox)
977 border = _warpBorder(self.config.coaddWarpKernel)
978 regions = {}
979 templateInputs = {}
980 for coadd, coaddInput in zip(catalog, coaddInputs, strict=True):
981 coaddList = templateInputs.setdefault((coadd["tract"], coadd["patch"]), [])
982 if coaddInput is None: 982 ↛ 983line 982 didn't jump to line 983 because the condition on line 982 was never true
983 continue
984 scienceToCoadd = _pixelTransform(wcs, coadd.getWcs())
985 scienceFootprint = scienceBox.transform(scienceToCoadd).dilated_by(_FOOTPRINT_MARGIN)
986 coaddToScience = scienceToCoadd.inverted()
987 for record in coaddInput.ccds:
988 visitInfo = record.getVisitInfo()
989 weight = record["weight"]
990 if visitInfo is None or not visitInfo.getDate().isValid() or not weight > 0:
991 continue
992 key = (record["visit"], record["ccd"])
993 # Record a single region for a ccd in overlapping patches.
994 if key not in regions:
995 regions[key] = _getImageOverlap(record, coadd.getWcs(), scienceFootprint, coaddToScience,
996 scienceBox, border)
997 region = regions[key]
998 if region is None:
999 continue
1000 mjd = visitInfo.getDate().get(DateTime.MJD, DateTime.TAI)
1001 coaddList.append(DifferenceImageTemplateInput(visit=key[0], detector=key[1],
1002 mid_mjd_tai=mjd, weight=weight,
1003 bounds=region))
1004 return templateInputs
1006 def checkHighVariance(self, template):
1007 """Set a mask plane for regions with unusually high variance.
1009 Parameters
1010 ----------
1011 template : `lsst.afw.image.Exposure`
1012 The warped template exposure, which will be modified in place.
1013 """
1014 highVarianceMaskPlaneBit = template.mask.addMaskPlane("HIGH_VARIANCE")
1015 ignoredPixelBits = template.mask.getPlaneBitMask(self.varianceBackground.config.ignoredPixelMask)
1016 goodMask = (template.mask.array & ignoredPixelBits) == 0
1017 goodFraction = np.count_nonzero(goodMask)/template.mask.array.size
1018 if goodFraction < self.config.highVarianceMaskFraction:
1019 self.log.info("Not setting HIGH_VARIANCE mask plane, only %2.1f%% of"
1020 " pixels were unmasked for background estimation, but"
1021 " %2.1f%% are required", 100*goodFraction, 100*self.config.highVarianceMaskFraction)
1022 else:
1023 varianceExposure = template.clone()
1024 varianceExposure.image.array = varianceExposure.variance.array
1025 varianceBackground = self.varianceBackground.run(varianceExposure).background.getImage().array
1026 threshold = self.config.highVarianceThreshold*np.nanmedian(varianceBackground)
1027 highVariancePix = varianceBackground > threshold
1028 template.mask.array[highVariancePix] |= 2**highVarianceMaskPlaneBit
1030 @staticmethod
1031 def _checkInputs(dataIds, coaddExposures, scienceInstrument=None):
1032 """Check that the all the dataIds are from the same band and that
1033 the exposures all have the same photometric calibration, and choose
1034 the instrument of the template.
1036 Parameters
1037 ----------
1038 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]]
1039 Record of the tract and patch of each coaddExposure.
1040 coaddExposures : `dict` [`int`, `list` of \
1041 [`lsst.daf.butler.DeferredDatasetHandle` of \
1042 `lsst.afw.image.Exposure` or
1043 `lsst.afw.image.Exposure`]]
1044 Coadds to be mosaicked.
1045 scienceInstrument : `str`, optional
1046 Instrument of the science image.
1048 Returns
1049 -------
1050 band : `str`
1051 Filter band of all the input exposures.
1052 photoCalib : `lsst.afw.image.PhotoCalib`
1053 Photometric calibration of all of the input exposures.
1054 instrument : `str`
1055 ``scienceInstrument`` if any coadd was made from it, otherwise the
1056 instrument that the most coadds were made from, or ``"UNKNOWN"`` if
1057 no coadd records one. A tie goes to the instrument whose name sorts
1058 first.
1060 Raises
1061 ------
1062 RuntimeError
1063 Raised if the bands or calibrations of the input exposures are not
1064 all the same.
1065 """
1066 bands = set(dataId["band"] for tract in dataIds for dataId in dataIds[tract])
1067 if len(bands) > 1: 1067 ↛ 1068line 1067 didn't jump to line 1068 because the condition on line 1067 was never true
1068 raise RuntimeError(f"GetTemplateTask called with multiple bands: {bands}")
1069 band = bands.pop()
1070 photoCalibs = []
1071 instrumentCounts = collections.Counter()
1072 for exposures in coaddExposures.values():
1073 for exposure in exposures:
1074 # Read both components of a coadd in turn, so a butler cache
1075 # can serve the second read from the first download.
1076 photoCalibs.append(exposure.get(component="photoCalib"))
1077 instrumentCounts[GetTemplateTask._getInstrument(exposure.get(component="coaddInputs"))] += 1
1078 if not all([photoCalibs[0] == x for x in photoCalibs]): 1078 ↛ 1079line 1078 didn't jump to line 1079 because the condition on line 1078 was never true
1079 msg = f"GetTemplateTask called with exposures with different photoCalibs: {photoCalibs}"
1080 raise RuntimeError(msg)
1081 photoCalib = photoCalibs[0]
1082 # Coadds that record no instrument do not count toward the choice.
1083 del instrumentCounts[_UNKNOWN_INSTRUMENT]
1084 if scienceInstrument in instrumentCounts:
1085 return band, photoCalib, scienceInstrument
1086 if not instrumentCounts:
1087 return band, photoCalib, _UNKNOWN_INSTRUMENT
1088 instrument = max(sorted(instrumentCounts), key=instrumentCounts.get)
1089 return band, photoCalib, instrument
1091 @staticmethod
1092 def _getInstrument(coaddInputs):
1093 """Return the instrument whose observations were coadded to make a
1094 coadd.
1096 Parameters
1097 ----------
1098 coaddInputs : `lsst.afw.image.CoaddInputs` or `None`
1099 Input records of the coadd.
1101 Returns
1102 -------
1103 instrument : `str`
1104 Instrument label of the coadd, or ``"UNKNOWN"`` if it records none.
1106 Notes
1107 -----
1108 Every input of a coadd comes from the same instrument, so the first
1109 input record stands for all of them.
1110 """
1111 if coaddInputs is None or len(coaddInputs.ccds) == 0:
1112 return _UNKNOWN_INSTRUMENT
1113 visitInfo = coaddInputs.ccds[0].getVisitInfo()
1114 label = visitInfo.getInstrumentLabel() if visitInfo is not None else ""
1115 return label or _UNKNOWN_INSTRUMENT
1117 def _makeExposureCatalog(self, exposureRefs, dataIds, instrument=_UNKNOWN_INSTRUMENT):
1118 """Make an exposure catalog for one tract.
1120 Parameters
1121 ----------
1122 exposureRefs : `list` of [`lsst.daf.butler.DeferredDatasetHandle` of \
1123 `lsst.afw.image.Exposure`]
1124 Exposures to include in the catalog.
1125 dataIds : `list` [`lsst.daf.butler.DataCoordinate`]
1126 Data ids of each of the included exposures; must have "tract" and
1127 "patch" entries.
1128 instrument : `str`, optional
1129 Instrument of the template, recorded in the catalog. Exposures
1130 made from another instrument are dropped, as are exposures that
1131 record no instrument unless ``instrument`` is ``"UNKNOWN"``.
1133 Returns
1134 -------
1135 images : `dict` [`lsst.afw.image.MaskedImage`]
1136 MaskedImages of each of the input exposures, for warping.
1137 catalog : `lsst.afw.table.ExposureCatalog`
1138 Catalog of metadata for each exposure
1139 totalBox : `lsst.geom.Box2I`
1140 The union of the bounding boxes of all the input exposures.
1141 coaddInputs : `list` [`lsst.afw.image.CoaddInputs` or `None`]
1142 Input records of each exposure in ``catalog``, in the same order.
1143 """
1144 catalog = afwTable.ExposureCatalog(self.schema)
1145 catalog.reserve(len(exposureRefs))
1146 exposures = (exposureRef.get() for exposureRef in exposureRefs)
1147 images = {}
1148 totalBox = geom.Box2I()
1149 keptCoaddInputs = []
1151 for coadd, dataId in zip(exposures, dataIds):
1152 coaddInputs = coadd.getInfo().getCoaddInputs()
1153 coaddInstrument = self._getInstrument(coaddInputs)
1154 if coaddInstrument != instrument:
1155 self.log.info(
1156 "Dropping template coadd %s: it was made from %s, but the template is made from %s.",
1157 dataId,
1158 coaddInstrument,
1159 instrument,
1160 )
1161 continue
1162 keptCoaddInputs.append(coaddInputs)
1163 images[dataId] = coadd.maskedImage
1164 bbox = coadd.getBBox()
1165 totalBox = totalBox.expandedTo(bbox)
1166 record = catalog.addNew()
1167 record.setPsf(coadd.psf)
1168 record.setWcs(coadd.wcs)
1169 record.setPhotoCalib(coadd.photoCalib)
1170 record.setBBox(bbox)
1171 record.setValidPolygon(afwGeom.Polygon(geom.Box2D(bbox).getCorners()))
1172 record.set("tract", dataId["tract"])
1173 record.set("patch", dataId["patch"])
1174 record.set("instrument", instrument)
1175 # Weight is used by CoaddPsf, but the PSFs from overlapping patches
1176 # should be very similar, so this value mostly shouldn't matter.
1177 record.set("weight", 1)
1179 return images, catalog, totalBox, keptCoaddInputs
1181 def _merge(self, maskedImages, bbox, wcs):
1182 """Merge the images that came from one tract into one larger image,
1183 ignoring NaN pixels and non-finite variance pixels from individual
1184 exposures.
1186 Parameters
1187 ----------
1188 maskedImages : `dict` [`lsst.afw.image.MaskedImage` or
1189 `lsst.afw.image.Exposure`]
1190 Images to be merged into one larger bounding box.
1191 bbox : `lsst.geom.Box2I`
1192 Bounding box defining the image to merge into.
1193 wcs : `lsst.afw.geom.SkyWcs`
1194 WCS of all of the input images to set on the output image.
1196 Returns
1197 -------
1198 merged : `lsst.afw.image.MaskedImage`
1199 Merged image with all of the inputs at their respective bbox
1200 positions.
1201 count : `int`
1202 Count of the number of good pixels (those with positive weights)
1203 in the merged image.
1204 included : `list` [`int`]
1205 List of indexes of patches that were included in the merged
1206 result, to be used to trim the exposure catalog.
1207 """
1208 merged = afwImage.ExposureF(bbox, wcs)
1209 weights = afwImage.ImageF(bbox)
1210 included = [] # which patches were included in the result
1211 for i, (dataId, maskedImage) in enumerate(maskedImages.items()):
1212 # Only merge into the trimmed box, to save memory
1213 clippedBox = geom.Box2I(maskedImage.getBBox())
1214 clippedBox.clip(bbox)
1215 if clippedBox.area == 0:
1216 self.log.debug("%s does not overlap template region.", dataId)
1217 continue # nothing in this image overlaps the output
1218 maskedImage = maskedImage.subset(clippedBox)
1219 # Catch both zero-value and NaN variance plane pixels
1220 good = (maskedImage.variance.array > 0) & (
1221 np.isfinite(maskedImage.variance.array)
1222 )
1223 weight = maskedImage.variance.array[good] ** (-0.5)
1224 bad = np.isnan(maskedImage.image.array) | ~good
1225 # Note that modifying the patch MaskedImage in place is fine;
1226 # we're throwing it away at the end anyway.
1227 maskedImage.image.array[bad] = 0.0
1228 maskedImage.variance.array[bad] = 0.0
1229 # Reset mask, too, since these pixels don't contribute to sum.
1230 maskedImage.mask.array[bad] = 0
1231 # Cannot use `merged.maskedImage *= weight` because that operator
1232 # multiplies the variance by the weight twice; in this case
1233 # `weight` are the exact values we want to scale by.
1234 maskedImage.image.array[good] *= weight
1235 maskedImage.variance.array[good] *= weight
1236 weights[clippedBox].array[good] += weight
1237 # Free memory before creating new large arrays
1238 del weight
1239 merged.maskedImage[clippedBox] += maskedImage
1240 included.append(i)
1242 good = weights.array > 0
1244 # Cannot use `merged.maskedImage /= weights` because that
1245 # operator divides the variance by the weight twice; in this case
1246 # `weights` are the exact values we want to scale by.
1247 weights = weights.array[good]
1248 merged.image.array[good] /= weights
1249 merged.variance.array[good] /= weights
1251 merged.mask.array[~good] |= merged.mask.getPlaneBitMask("NO_DATA")
1253 return merged, good.sum(), included
1255 def _makePsf(self, template, catalog, wcs):
1256 """Return a PSF containing the PSF at each of the input regions.
1258 Note that although this includes all the exposures from the catalog,
1259 the PSF knows which part of the template the inputs came from, so when
1260 evaluated at a given position it will not include inputs that never
1261 went in to those pixels.
1263 Parameters
1264 ----------
1265 template : `lsst.afw.image.Exposure`
1266 Generated template the PSF is for.
1267 catalog : `lsst.afw.table.ExposureCatalog`
1268 Catalog of exposures that went into the template that contains all
1269 of the input PSFs.
1270 wcs : `lsst.afw.geom.SkyWcs`
1271 WCS of the template, to warp the PSFs to.
1273 Returns
1274 -------
1275 coaddPsf : `lsst.meas.algorithms.CoaddPsf`
1276 The meta-psf constructed from all of the input catalogs.
1277 """
1278 # CoaddPsf centroid not only must overlap image, but must overlap the
1279 # part of image with data. Use centroid of region with data.
1280 boolmask = template.mask.array & template.mask.getPlaneBitMask("NO_DATA") == 0
1281 maskx = afwImage.makeMaskFromArray(boolmask.astype(afwImage.MaskPixel))
1282 centerCoord = afwGeom.SpanSet.fromMask(maskx, 1).computeCentroid()
1284 ctrl = self.config.coaddPsf.makeControl()
1285 coaddPsf = CoaddPsf(
1286 catalog, wcs, centerCoord, ctrl.warpingKernelName, ctrl.cacheSize
1287 )
1288 return coaddPsf
1290 def _makeGaussianPsf(self, template):
1291 """Return a Gaussian PSF with the width of the template CoaddPsf.
1293 Parameters
1294 ----------
1295 template : `lsst.afw.image.Exposure`
1296 Template with the CoaddPsf to approximate. The width is measured
1297 at the average position of the PSF, or averaged over a grid of
1298 positions if the PSF cannot be evaluated there.
1300 Returns
1301 -------
1302 psf : `lsst.afw.detection.GaussianPsf`
1303 Gaussian with the width of the template PSF.
1305 Raises
1306 ------
1307 ValueError
1308 Raised if the PSF cannot be evaluated at any grid position.
1309 """
1310 # The CoaddPsf is not defined where the template has no inputs, which
1311 # can include its average position.
1312 try:
1313 fwhm = getPsfFwhm(template.psf)
1314 except lsst.pex.exceptions.Exception:
1315 # Catch a broad range of exceptions, since some are C++ only
1316 # Catching:
1317 # - lsst::geom::SingularTransformException
1318 # - lsst.pex.exceptions.InvalidParameterError
1319 # - lsst.pex.exceptions.RangeError
1320 self.log.info("Unable to evaluate PSF at the average position. "
1321 "Evaluating PSF on a grid of points.")
1322 fwhm = evaluateMeanPsfFwhm(template,
1323 fwhmExposureBuffer=self.config.fwhmExposureBuffer,
1324 fwhmExposureGrid=self.config.fwhmExposureGrid)
1325 sigma = fwhm/gaussian_sigma_to_fwhm
1326 size = 2*int(np.ceil(_PSF_STAMP_SIGMAS*sigma)) + 1
1327 self.metadata["templatePsfSigma"] = float(sigma)
1328 self.log.info("Approximated the template PSF with a Gaussian of sigma %.3f pixels.", sigma)
1329 return afwDetection.GaussianPsf(size, size, sigma)
1332class GetDcrTemplateConnections(
1333 GetTemplateConnections,
1334 dimensions=("instrument", "visit", "detector"),
1335 defaultTemplates={"coaddName": "dcr", "warpTypeSuffix": "", "fakesType": ""},
1336):
1337 visitInfo = pipeBase.connectionTypes.Input(
1338 doc="VisitInfo of calexp used to determine observing conditions.",
1339 name="{fakesType}calexp.visitInfo",
1340 storageClass="VisitInfo",
1341 dimensions=("instrument", "visit", "detector"),
1342 )
1343 dcrCoadds = pipeBase.connectionTypes.Input(
1344 doc="Input DCR template to match and subtract from the exposure",
1345 name="{fakesType}dcrCoadd{warpTypeSuffix}",
1346 storageClass="ExposureF",
1347 dimensions=("tract", "patch", "skymap", "band", "subfilter"),
1348 multiple=True,
1349 deferLoad=True,
1350 )
1352 def __init__(self, *, config=None):
1353 super().__init__(config=config)
1354 self.inputs.remove("coaddExposures")
1357class GetDcrTemplateConfig(
1358 GetTemplateConfig, pipelineConnections=GetDcrTemplateConnections
1359):
1360 """Config for GetDcrTemplateTask.
1362 Only ``output_image_type="legacy"`` is supported: the task always writes
1363 an `lsst.afw.image.ExposureF`.
1364 """
1366 numSubfilters = pexConfig.Field(
1367 doc="Number of subfilters in the DcrCoadd.",
1368 dtype=int,
1369 default=3,
1370 )
1371 effectiveWavelength = pexConfig.Field(
1372 doc="Effective wavelength of the filter in nm.",
1373 optional=False,
1374 dtype=float,
1375 )
1376 bandwidth = pexConfig.Field(
1377 doc="Bandwidth of the physical filter.",
1378 optional=False,
1379 dtype=float,
1380 )
1382 def validate(self):
1383 if self.effectiveWavelength is None or self.bandwidth is None:
1384 raise ValueError(
1385 "The effective wavelength and bandwidth of the physical filter "
1386 "must be set in the getTemplate config for DCR coadds. "
1387 "Required until transmission curves are used in DM-13668."
1388 )
1391class GetDcrTemplateTask(GetTemplateTask):
1392 ConfigClass = GetDcrTemplateConfig
1393 _DefaultName = "getDcrTemplate"
1395 def runQuantum(self, butlerQC, inputRefs, outputRefs):
1396 inputs = butlerQC.get(inputRefs)
1397 detector = inputs.pop("detector")
1398 bbox = detector.getBBox()
1399 wcs = inputs.pop("wcs")
1400 dcrCoaddExposureHandles = inputs.pop("dcrCoadds")
1401 skymap = inputs.pop("skyMap")
1402 visitInfo = inputs.pop("visitInfo")
1403 # This task never converts its output, so the observation metadata
1404 # that the conversion needs is dropped here.
1405 inputs.pop("obs_info", None)
1407 # This should not happen with a properly configured execution context.
1408 assert not inputs, "runQuantum got more inputs than expected"
1410 results = self.getExposures(
1411 dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo
1412 )
1413 physical_filter = butlerQC.quantum.dataId["physical_filter"]
1414 outputs = self.run(
1415 coaddExposureHandles=results.coaddExposures,
1416 bbox=bbox,
1417 wcs=wcs,
1418 dataIds=results.dataIds,
1419 physical_filter=physical_filter,
1420 visit=outputRefs.template.dataId["visit"],
1421 instrument=butlerQC.quantum.dataId["instrument"],
1422 )
1423 butlerQC.put(outputs, outputRefs)
1425 def getExposures(self, dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo):
1426 """Return lists of coadds and their corresponding dataIds that overlap
1427 the detector.
1429 The spatial index in the registry has generous padding and often
1430 supplies patches near, but not directly overlapping the detector.
1431 Filters inputs so that we don't have to read in all input coadds.
1433 Parameters
1434 ----------
1435 dcrCoaddExposureHandles : `list` \
1436 [`lsst.daf.butler.DeferredDatasetHandle` of \
1437 `lsst.afw.image.Exposure`]
1438 Data references to exposures that might overlap the detector.
1439 bbox : `lsst.geom.Box2I`
1440 Template Bounding box of the detector geometry onto which to
1441 resample the coaddExposures.
1442 skymap : `lsst.skymap.SkyMap`
1443 Input definition of geometry/bbox and projection/wcs for
1444 template exposures.
1445 wcs : `lsst.afw.geom.SkyWcs`
1446 Template WCS onto which to resample the coaddExposures.
1447 visitInfo : `lsst.afw.image.VisitInfo`
1448 Metadata for the science image.
1450 Returns
1451 -------
1452 result : `lsst.pipe.base.Struct`
1453 A struct with attibutes:
1455 ``coaddExposures``
1456 Dict of coadd exposures that overlap the projected bbox,
1457 indexed on tract id
1458 (`dict` [`int`, `list` [`lsst.afw.image.Exposure`] ]).
1459 ``dataIds``
1460 Dict of data IDs of the coadd exposures that overlap the
1461 projected bbox, indexed on tract id
1462 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]).
1464 Raises
1465 ------
1466 pipeBase.NoWorkFound
1467 Raised if no patches overlatp the input detector bbox.
1468 """
1469 # Check that the patches actually overlap the detector
1470 # Exposure's validPolygon would be more accurate
1471 if wcs is None:
1472 raise pipeBase.NoWorkFound("Exposure has no WCS; cannot create a template.")
1474 detectorPolygon = geom.Box2D(bbox)
1475 overlappingArea = 0
1476 dataIds = collections.defaultdict(list)
1477 patchList = dict()
1478 for coaddRef in dcrCoaddExposureHandles:
1479 dataId = coaddRef.dataId
1480 subfilter = dataId["subfilter"]
1481 patchWcs = skymap[dataId["tract"]].getWcs()
1482 patchBBox = skymap[dataId["tract"]][dataId["patch"]].getOuterBBox()
1483 patchCorners = patchWcs.pixelToSky(geom.Box2D(patchBBox).getCorners())
1484 patchPolygon = afwGeom.Polygon(wcs.skyToPixel(patchCorners))
1485 if patchPolygon.intersection(detectorPolygon):
1486 overlappingArea += patchPolygon.intersectionSingle(
1487 detectorPolygon
1488 ).calculateArea()
1489 self.log.info(
1490 "Using template input tract=%s, patch=%s, subfilter=%s"
1491 % (dataId["tract"], dataId["patch"], dataId["subfilter"])
1492 )
1493 if dataId["tract"] in patchList:
1494 patchList[dataId["tract"]].append(dataId["patch"])
1495 else:
1496 patchList[dataId["tract"]] = [
1497 dataId["patch"],
1498 ]
1499 if subfilter == 0:
1500 dataIds[dataId["tract"]].append(dataId)
1502 if not overlappingArea:
1503 raise pipeBase.NoWorkFound("No patches overlap detector")
1505 self.checkPatchList(patchList)
1507 coaddExposures = self.getDcrModel(patchList, dcrCoaddExposureHandles, visitInfo)
1508 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds)
1510 def checkPatchList(self, patchList):
1511 """Check that all of the DcrModel subfilters are present for each
1512 patch.
1514 Parameters
1515 ----------
1516 patchList : `dict`
1517 Dict of the patches containing valid data for each tract.
1519 Raises
1520 ------
1521 RuntimeError
1522 If the number of exposures found for a patch does not match the
1523 number of subfilters.
1524 """
1525 for tract in patchList:
1526 for patch in set(patchList[tract]):
1527 if patchList[tract].count(patch) != self.config.numSubfilters:
1528 raise RuntimeError(
1529 "Invalid number of DcrModel subfilters found: %d vs %d expected",
1530 patchList[tract].count(patch),
1531 self.config.numSubfilters,
1532 )
1534 def getDcrModel(self, patchList, coaddRefs, visitInfo):
1535 """Build DCR-matched coadds from a list of exposure references.
1537 Parameters
1538 ----------
1539 patchList : `dict`
1540 Dict of the patches containing valid data for each tract.
1541 coaddRefs : `list` [`lsst.daf.butler.DeferredDatasetHandle`]
1542 Data references to `~lsst.afw.image.Exposure` representing
1543 DcrModels that overlap the detector.
1544 visitInfo : `lsst.afw.image.VisitInfo`
1545 Metadata for the science image.
1547 Returns
1548 -------
1549 coaddExposures : `list` [`lsst.afw.image.Exposure`]
1550 Coadd exposures that overlap the detector.
1551 """
1552 coaddExposures = collections.defaultdict(list)
1553 for tract in patchList:
1554 for patch in set(patchList[tract]):
1555 coaddRefList = [
1556 coaddRef
1557 for coaddRef in coaddRefs
1558 if _selectDataRef(coaddRef, tract, patch)
1559 ]
1561 dcrModel = DcrModel.fromQuantum(
1562 coaddRefList,
1563 self.config.effectiveWavelength,
1564 self.config.bandwidth,
1565 self.config.numSubfilters,
1566 )
1567 coaddExposures[tract].append(dcrModel.buildMatchedExposureHandle(visitInfo=visitInfo))
1568 return coaddExposures
1571def _selectDataRef(coaddRef, tract, patch):
1572 condition = (coaddRef.dataId["tract"] == tract) & (
1573 coaddRef.dataId["patch"] == patch
1574 )
1575 return condition