lsst.ip.diffim g4aabf08624+1c8df16e13
Loading...
Searching...
No Matches
getTemplate.py
Go to the documentation of this file.
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
23
24import numpy as np
25
26import lsst.afw.image as afwImage
27import lsst.geom as geom
28import lsst.afw.geom as afwGeom
29from lsst.afw.image import VisitInfo
30import lsst.afw.table as afwTable
31from lsst.afw.math._warper import computeWarpedBBox
32import lsst.afw.math as afwMath
33import lsst.pex.config as pexConfig
34import lsst.pipe.base as pipeBase
35
36from lsst.skymap import BaseSkyMap
37from lsst.ip.diffim.dcrModel import DcrModel
38from lsst.meas.algorithms import CoaddPsf, CoaddPsfConfig, SubtractBackgroundTask, ScaleVarianceTask
39from lsst.utils.timer import timeMethod
40
41__all__ = [
42 "GetTemplateTask",
43 "GetTemplateConfig",
44 "GetDcrTemplateTask",
45 "GetDcrTemplateConfig",
46]
47
48
50 pipeBase.PipelineTaskConnections,
51 dimensions=("instrument", "visit", "detector"),
52 defaultTemplates={"coaddName": "goodSeeing", "warpTypeSuffix": "", "fakesType": ""},
53):
54 bbox = pipeBase.connectionTypes.Input(
55 doc="Bounding box of exposure to determine the geometry of the output template.",
56 name="{fakesType}calexp.bbox",
57 storageClass="Box2I",
58 dimensions=("instrument", "visit", "detector"),
59 )
60 wcs = pipeBase.connectionTypes.Input(
61 doc="WCS of the exposure that we will construct the template for.",
62 name="{fakesType}calexp.wcs",
63 storageClass="Wcs",
64 dimensions=("instrument", "visit", "detector"),
65 )
66 skyMap = pipeBase.connectionTypes.Input(
67 doc="Geometry of the tracts and patches that the coadds are defined on.",
68 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
69 dimensions=("skymap",),
70 storageClass="SkyMap",
71 )
72 coaddExposures = pipeBase.connectionTypes.Input(
73 doc="Coadds that may overlap the desired region, as possible inputs to the template."
74 " Will be restricted to those that directly overlap the projected bounding box.",
75 dimensions=("tract", "patch", "skymap", "band"),
76 storageClass="ExposureF",
77 name="{fakesType}{coaddName}Coadd{warpTypeSuffix}",
78 multiple=True,
79 deferLoad=True,
80 deferGraphConstraint=True,
81 )
82
83 template = pipeBase.connectionTypes.Output(
84 doc="Warped template, pixel matched to the bounding box and WCS.",
85 dimensions=("instrument", "visit", "detector"),
86 storageClass="ExposureF",
87 name="{fakesType}{coaddName}Diff_templateExp{warpTypeSuffix}",
88 )
89
90 def __init__(self, *, config=None):
91 super().__init__(config=config)
92 if config.requireCoaddAtGraphBuild:
93 self.coaddExposures = dataclasses.replace(
94 self.coaddExposures,
95 deferGraphConstraint=False,
96 )
97
98
99class GetTemplateConfig(
100 pipeBase.PipelineTaskConfig, pipelineConnections=GetTemplateConnections
101):
102 templateBorderSize = pexConfig.Field(
103 dtype=int,
104 default=20,
105 doc="Number of pixels to grow the requested template image to account for warping",
106 )
107 warp = pexConfig.ConfigField(
108 dtype=afwMath.Warper.ConfigClass,
109 doc="warper configuration",
110 )
111 coaddPsf = pexConfig.ConfigField(
112 doc="Configuration for CoaddPsf",
113 dtype=CoaddPsfConfig,
114 )
115 varianceBackground = pexConfig.ConfigurableField(
116 target=SubtractBackgroundTask,
117 doc="Task to estimate the background variance.",
118 )
119 highVarianceThreshold = pexConfig.RangeField(
120 dtype=float,
121 default=4,
122 min=1,
123 doc="Set the HIGH_VARIANCE mask plane for regions with variance"
124 " greater than the median by this factor.",
125 )
126 highVarianceMaskFraction = pexConfig.Field(
127 dtype=float,
128 default=0.1,
129 doc="Minimum fraction of unmasked pixels needed to set the"
130 " HIGH_VARIANCE mask plane.",
131 )
132 doScaleVariance = pexConfig.Field(
133 dtype=bool,
134 default=True,
135 doc="Scale variance of the template image?"
136 )
137 scaleVariance = pexConfig.ConfigurableField(
138 target=ScaleVarianceTask,
139 doc="Subtask to rescale the variance of the template to the statistically expected level."
140 )
141 requireCoaddAtGraphBuild = pexConfig.Field(
142 dtype=bool,
143 default=False,
144 doc="If True, include the coadd dataset existence in the"
145 " initial butler query during QuantumGraph generation.",
146 )
147
148 def setDefaults(self):
149 # Use a smaller cache: per SeparableKernel.computeCache, this should
150 # give a warping error of a fraction of a count (these must match).
151 self.warp.cacheSize = 100000
152 self.coaddPsf.cacheSize = self.warp.cacheSize
153 # The WCS for LSST should be smoothly varying, so we can use a longer
154 # interpolation length for WCS evaluations.
155 self.warp.interpLength = 100
156 self.warp.warpingKernelName = "lanczos3"
157 self.coaddPsf.warpingKernelName = self.warp.warpingKernelName
158
159 # Background subtraction of the variance plane
160 self.varianceBackground.algorithm = "LINEAR"
161 self.varianceBackground.binSize = 32
162 self.varianceBackground.useApprox = False
163 self.varianceBackground.statisticsProperty = "MEDIAN"
164 self.varianceBackground.doFilterSuperPixels = True
165 self.varianceBackground.ignoredPixelMask = ["BAD",
166 "EDGE",
167 "DETECTED",
168 "DETECTED_NEGATIVE",
169 "NO_DATA",
170 ]
171
172
173class GetTemplateTask(pipeBase.PipelineTask):
174 ConfigClass = GetTemplateConfig
175 _DefaultName = "getTemplate"
176
177 def __init__(self, *args, **kwargs):
178 super().__init__(*args, **kwargs)
179 if self.config.doScaleVariance:
180 self.makeSubtask("scaleVariance")
181 self.warper = afwMath.Warper.fromConfig(self.config.warp)
182 self.schema = afwTable.ExposureTable.makeMinimalSchema()
183 self.schema.addField(
184 "tract", type=np.int32, doc="Which tract this exposure came from."
185 )
186 self.schema.addField(
187 "patch",
188 type=np.int32,
189 doc="Which patch in the tract this exposure came from.",
190 )
191 self.schema.addField(
192 "weight",
193 type=float,
194 doc="Weight for each exposure, used to make the CoaddPsf; should always be 1.",
195 )
196 self.makeSubtask("varianceBackground")
197
198 def runQuantum(self, butlerQC, inputRefs, outputRefs):
199 inputs = butlerQC.get(inputRefs)
200 bbox = inputs.pop("bbox")
201 wcs = inputs.pop("wcs")
202 coaddExposures = inputs.pop("coaddExposures")
203 skymap = inputs.pop("skyMap")
204
205 # This should not happen with a properly configured execution context.
206 assert not inputs, "runQuantum got more inputs than expected"
207
208 results = self.getExposures(coaddExposures, bbox, skymap, wcs)
209 physical_filter = butlerQC.quantum.dataId["physical_filter"]
210 outputs = self.run(
211 coaddExposureHandles=results.coaddExposures,
212 bbox=bbox,
213 wcs=wcs,
214 dataIds=results.dataIds,
215 physical_filter=physical_filter,
216 visit=outputRefs.template.dataId["visit"],
217 )
218 butlerQC.put(outputs, outputRefs)
219
220 def getExposures(self, coaddExposureHandles, bbox, skymap, wcs):
221 """Return a data structure containing the coadds that overlap the
222 specified bbox projected onto the sky, and a corresponding data
223 structure of their dataIds.
224 These are the appropriate inputs to this task's `run` method.
225
226 The spatial index in the butler registry has generous padding and often
227 supplies patches near, but not directly overlapping the desired region.
228 This method filters the inputs so that `run` does not have to read in
229 all possibly-matching coadd exposures.
230
231 Parameters
232 ----------
233 coaddExposureHandles : `iterable` \
234 [`lsst.daf.butler.DeferredDatasetHandle` of \
235 `lsst.afw.image.Exposure`]
236 Dataset handles to exposures that might overlap the desired
237 region.
238 bbox : `lsst.geom.Box2I`
239 Template bounding box of the pixel geometry onto which the
240 coaddExposures will be resampled.
241 skymap : `lsst.skymap.SkyMap`
242 Geometry of the tracts and patches the coadds are defined on.
243 wcs : `lsst.afw.geom.SkyWcs`
244 Template WCS onto which the coadds will be resampled.
245
246 Returns
247 -------
248 result : `lsst.pipe.base.Struct`
249 A struct with attributes:
250
251 ``coaddExposures``
252 Dict of coadd exposures that overlap the projected bbox,
253 indexed on tract id
254 (`dict` [`int`, `list` [`lsst.daf.butler.DeferredDatasetHandle` of
255 `lsst.afw.image.Exposure`] ]).
256 ``dataIds``
257 Dict of data IDs of the coadd exposures that overlap the
258 projected bbox, indexed on tract id
259 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]).
260
261 Raises
262 ------
263 NoWorkFound
264 Raised if no patches overlap the input detector bbox, or the input
265 WCS is None.
266 """
267 if wcs is None:
268 raise pipeBase.NoWorkFound(
269 "WCS is None; cannot find overlapping exposures."
270 )
271
272 # Exposure's validPolygon would be more accurate
273 detectorPolygon = geom.Box2D(bbox)
274 detectorCorners = wcs.pixelToSky(detectorPolygon.getCorners())
275 overlappingArea = 0
276 coaddExposures = collections.defaultdict(list)
277 dataIds = collections.defaultdict(list)
278
279 for coaddRef in coaddExposureHandles:
280 dataId = coaddRef.dataId
281 patchWcs = skymap[dataId["tract"]].getWcs()
282 patchBBox = skymap[dataId["tract"]][dataId["patch"]].getOuterBBox()
283 patchPolygon = afwGeom.Polygon(geom.Box2D(patchBBox))
284 # Calculate detector/patch overlap in patch coordinates rather than
285 # detector coordinates because the skymap's inverse mapping
286 # (patchWcs.skyToPixel()) is more stable than the detector's for
287 # arbitrary sky coordinates.
288 detectorInPatchCoordinates = afwGeom.Polygon(patchWcs.skyToPixel(detectorCorners))
289 if patchPolygon.intersection(detectorInPatchCoordinates):
290 overlappingArea += patchPolygon.intersectionSingle(
291 detectorInPatchCoordinates
292 ).calculateArea()
293 self.log.info(
294 "Using template input tract=%s, patch=%s",
295 dataId["tract"],
296 dataId["patch"],
297 )
298 coaddExposures[dataId["tract"]].append(coaddRef)
299 dataIds[dataId["tract"]].append(dataId)
300
301 if not overlappingArea:
302 raise pipeBase.NoWorkFound("No patches overlap detector")
303
304 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds)
305
306 @timeMethod
307 def run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter, visit=None):
308 """Warp coadds from multiple tracts and patches to form a template to
309 subtract from a science image.
310
311 Tract and patch overlap regions are combined by a variance-weighted
312 average, and the variance planes are combined with the same weights,
313 not added in quadrature; the overlap regions are not statistically
314 independent, because they're derived from the same original data.
315 The PSF on the template is created by combining the CoaddPsf on each
316 template image into a meta-CoaddPsf.
317
318 Parameters
319 ----------
320 coaddExposureHandles : `dict` [`int`, `list` of \
321 [`lsst.daf.butler.DeferredDatasetHandle` of \
322 `lsst.afw.image.Exposure`]]
323 Coadds to be mosaicked, indexed on tract id.
324 bbox : `lsst.geom.Box2I`
325 Template Bounding box of the detector geometry onto which to
326 resample the ``coaddExposureHandles``. Modified in-place to include the
327 template border.
328 wcs : `lsst.afw.geom.SkyWcs`
329 Template WCS onto which to resample the ``coaddExposureHandles``.
330 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]]
331 Record of the tract and patch of each coaddExposure, indexed on
332 tract id.
333 physical_filter : `str`
334 Physical filter of the science image.
335 visit : `int`, optional
336 If supplied, over-write the visit ID in the template's visitInfo
337 so that downstream source injection tasks can link the template and
338 science image for the visit.
339
340 Returns
341 -------
342 result : `lsst.pipe.base.Struct`
343 A struct with attributes:
344
345 ``template``
346 A template coadd exposure assembled out of patches
347 (`lsst.afw.image.ExposureF`).
348
349 Raises
350 ------
351 NoWorkFound
352 If no coadds are found with sufficient un-masked pixels.
353 """
354 band, photoCalib = self._checkInputs(dataIds, coaddExposureHandles)
355
356 bbox.grow(self.config.templateBorderSize)
357
358 warped = {}
359 catalogs = []
360 for tract in coaddExposureHandles:
361 maskedImages, catalog, totalBox = self._makeExposureCatalog(
362 coaddExposureHandles[tract], dataIds[tract]
363 )
364 warpedBox = computeWarpedBBox(catalog[0].wcs, bbox, wcs)
365 warpedBox.grow(5) # to ensure we catch all relevant input pixels
366 # Combine images from individual patches together.
367 unwarped, count, included = self._merge(
368 maskedImages, warpedBox, catalog[0].wcs
369 )
370 # Delete `maskedImages` after combining into one large image to reduce peak memory use
371 del maskedImages
372 if count == 0:
373 self.log.info(
374 "No valid pixels from coadd patches in tract %s; not including in output.",
375 tract,
376 )
377 continue
378 warpedBox.clip(totalBox)
379 potentialInput = self.warper.warpExposure(
380 wcs, unwarped.subset(warpedBox), destBBox=bbox
381 )
382
383 # Delete the single large `unwarped` image after warping to reduce peak memory use
384 del unwarped
385 if np.all(
386 potentialInput.mask.array
387 & potentialInput.mask.getPlaneBitMask("NO_DATA")
388 ):
389 self.log.info(
390 "No overlap from coadd patches in tract %s; not including in output.",
391 tract,
392 )
393 continue
394
395 # Trim the exposure catalog to just the patches that were used.
396 tempCatalog = afwTable.ExposureCatalog(self.schema)
397 tempCatalog.reserve(len(included))
398 for i in included:
399 tempCatalog.append(catalog[i])
400 catalogs.append(tempCatalog)
401 warped[tract] = potentialInput.maskedImage
402
403 if len(warped) == 0:
404 raise pipeBase.NoWorkFound("No patches found to overlap science exposure.")
405 # At this point, all entries will be valid, so we can ignore included.
406 template, count, _ = self._merge(warped, bbox, wcs)
407 if count == 0:
408 raise pipeBase.NoWorkFound("No valid pixels in warped template.")
409
410 if self.config.doScaleVariance:
411 # Scale the variance of the template image before subtraction, if
412 # needed. Note that the science variance is scaled
413 # independently in ``AlardLuptonSubtractTask``.
414 varianceFactor = self.scaleVariance.run(template.maskedImage)
415 self.log.info("Template variance scaling factor: %.2f", varianceFactor)
416 self.metadata["scaleTemplateVarianceFactor"] = varianceFactor
417
418 # Make a single catalog containing all the inputs that were accepted.
419 catalog = afwTable.ExposureCatalog(self.schema)
420 catalog.reserve(sum([len(c) for c in catalogs]))
421 for c in catalogs:
422 catalog.extend(c)
423
424 # Set a mask plane for any regions with exceptionally high variance.
425 self.checkHighVariance(template)
426 if visit is not None:
427 template.getInfo().setVisitInfo(VisitInfo(id=visit))
428 template.setFilter(afwImage.FilterLabel(band, physical_filter))
429 template.setPhotoCalib(photoCalib)
430 template.setPsf(self._makePsf(template, catalog, wcs))
431
432 # Record the input coadd patches as the template's coadd inputs.
433 coaddInputs = afwImage.CoaddInputs(afwTable.ExposureTable.makeMinimalSchema(), self.schema)
434 coaddInputs.ccds.extend(catalog, deep=True)
435 template.getInfo().setCoaddInputs(coaddInputs)
436 return pipeBase.Struct(template=template)
437
438 def checkHighVariance(self, template):
439 """Set a mask plane for regions with unusually high variance.
440
441 Parameters
442 ----------
443 template : `lsst.afw.image.Exposure`
444 The warped template exposure, which will be modified in place.
445 """
446 highVarianceMaskPlaneBit = template.mask.addMaskPlane("HIGH_VARIANCE")
447 ignoredPixelBits = template.mask.getPlaneBitMask(self.varianceBackground.config.ignoredPixelMask)
448 goodMask = (template.mask.array & ignoredPixelBits) == 0
449 goodFraction = np.count_nonzero(goodMask)/template.mask.array.size
450 if goodFraction < self.config.highVarianceMaskFraction:
451 self.log.info("Not setting HIGH_VARIANCE mask plane, only %2.1f%% of"
452 " pixels were unmasked for background estimation, but"
453 " %2.1f%% are required", 100*goodFraction, 100*self.config.highVarianceMaskFraction)
454 else:
455 varianceExposure = template.clone()
456 varianceExposure.image.array = varianceExposure.variance.array
457 varianceBackground = self.varianceBackground.run(varianceExposure).background.getImage().array
458 threshold = self.config.highVarianceThreshold*np.nanmedian(varianceBackground)
459 highVariancePix = varianceBackground > threshold
460 template.mask.array[highVariancePix] |= 2**highVarianceMaskPlaneBit
461
462 @staticmethod
463 def _checkInputs(dataIds, coaddExposures):
464 """Check that the all the dataIds are from the same band and that
465 the exposures all have the same photometric calibration.
466
467 Parameters
468 ----------
469 dataIds : `dict` [`int`, `list` [`lsst.daf.butler.DataCoordinate`]]
470 Record of the tract and patch of each coaddExposure.
471 coaddExposures : `dict` [`int`, `list` of \
472 [`lsst.daf.butler.DeferredDatasetHandle` of \
473 `lsst.afw.image.Exposure` or
474 `lsst.afw.image.Exposure`]]
475 Coadds to be mosaicked.
476
477 Returns
478 -------
479 band : `str`
480 Filter band of all the input exposures.
481 photoCalib : `lsst.afw.image.PhotoCalib`
482 Photometric calibration of all of the input exposures.
483
484 Raises
485 ------
486 RuntimeError
487 Raised if the bands or calibrations of the input exposures are not
488 all the same.
489 """
490 bands = set(dataId["band"] for tract in dataIds for dataId in dataIds[tract])
491 if len(bands) > 1:
492 raise RuntimeError(f"GetTemplateTask called with multiple bands: {bands}")
493 band = bands.pop()
494 photoCalibs = [
495 exposure.get(component="photoCalib")
496 for exposures in coaddExposures.values()
497 for exposure in exposures
498 ]
499 if not all([photoCalibs[0] == x for x in photoCalibs]):
500 msg = f"GetTemplateTask called with exposures with different photoCalibs: {photoCalibs}"
501 raise RuntimeError(msg)
502 photoCalib = photoCalibs[0]
503 return band, photoCalib
504
505 def _makeExposureCatalog(self, exposureRefs, dataIds):
506 """Make an exposure catalog for one tract.
507
508 Parameters
509 ----------
510 exposureRefs : `list` of [`lsst.daf.butler.DeferredDatasetHandle` of \
511 `lsst.afw.image.Exposure`]
512 Exposures to include in the catalog.
513 dataIds : `list` [`lsst.daf.butler.DataCoordinate`]
514 Data ids of each of the included exposures; must have "tract" and
515 "patch" entries.
516
517 Returns
518 -------
519 images : `dict` [`lsst.afw.image.MaskedImage`]
520 MaskedImages of each of the input exposures, for warping.
521 catalog : `lsst.afw.table.ExposureCatalog`
522 Catalog of metadata for each exposure
523 totalBox : `lsst.geom.Box2I`
524 The union of the bounding boxes of all the input exposures.
525 """
526 catalog = afwTable.ExposureCatalog(self.schema)
527 catalog.reserve(len(exposureRefs))
528 exposures = (exposureRef.get() for exposureRef in exposureRefs)
529 images = {}
530 totalBox = geom.Box2I()
531
532 for coadd, dataId in zip(exposures, dataIds):
533 images[dataId] = coadd.maskedImage
534 bbox = coadd.getBBox()
535 totalBox = totalBox.expandedTo(bbox)
536 record = catalog.addNew()
537 record.setPsf(coadd.psf)
538 record.setWcs(coadd.wcs)
539 record.setPhotoCalib(coadd.photoCalib)
540 record.setBBox(bbox)
541 record.setValidPolygon(afwGeom.Polygon(geom.Box2D(bbox).getCorners()))
542 record.set("tract", dataId["tract"])
543 record.set("patch", dataId["patch"])
544 # Weight is used by CoaddPsf, but the PSFs from overlapping patches
545 # should be very similar, so this value mostly shouldn't matter.
546 record.set("weight", 1)
547
548 return images, catalog, totalBox
549
550 def _merge(self, maskedImages, bbox, wcs):
551 """Merge the images that came from one tract into one larger image,
552 ignoring NaN pixels and non-finite variance pixels from individual
553 exposures.
554
555 Parameters
556 ----------
557 maskedImages : `dict` [`lsst.afw.image.MaskedImage` or
558 `lsst.afw.image.Exposure`]
559 Images to be merged into one larger bounding box.
560 bbox : `lsst.geom.Box2I`
561 Bounding box defining the image to merge into.
562 wcs : `lsst.afw.geom.SkyWcs`
563 WCS of all of the input images to set on the output image.
564
565 Returns
566 -------
567 merged : `lsst.afw.image.MaskedImage`
568 Merged image with all of the inputs at their respective bbox
569 positions.
570 count : `int`
571 Count of the number of good pixels (those with positive weights)
572 in the merged image.
573 included : `list` [`int`]
574 List of indexes of patches that were included in the merged
575 result, to be used to trim the exposure catalog.
576 """
577 merged = afwImage.ExposureF(bbox, wcs)
578 weights = afwImage.ImageF(bbox)
579 included = [] # which patches were included in the result
580 for i, (dataId, maskedImage) in enumerate(maskedImages.items()):
581 # Only merge into the trimmed box, to save memory
582 clippedBox = geom.Box2I(maskedImage.getBBox())
583 clippedBox.clip(bbox)
584 if clippedBox.area == 0:
585 self.log.debug("%s does not overlap template region.", dataId)
586 continue # nothing in this image overlaps the output
587 maskedImage = maskedImage.subset(clippedBox)
588 # Catch both zero-value and NaN variance plane pixels
589 good = (maskedImage.variance.array > 0) & (
590 np.isfinite(maskedImage.variance.array)
591 )
592 weight = maskedImage.variance.array[good] ** (-0.5)
593 bad = np.isnan(maskedImage.image.array) | ~good
594 # Note that modifying the patch MaskedImage in place is fine;
595 # we're throwing it away at the end anyway.
596 maskedImage.image.array[bad] = 0.0
597 maskedImage.variance.array[bad] = 0.0
598 # Reset mask, too, since these pixels don't contribute to sum.
599 maskedImage.mask.array[bad] = 0
600 # Cannot use `merged.maskedImage *= weight` because that operator
601 # multiplies the variance by the weight twice; in this case
602 # `weight` are the exact values we want to scale by.
603 maskedImage.image.array[good] *= weight
604 maskedImage.variance.array[good] *= weight
605 weights[clippedBox].array[good] += weight
606 # Free memory before creating new large arrays
607 del weight
608 merged.maskedImage[clippedBox] += maskedImage
609 included.append(i)
610
611 good = weights.array > 0
612
613 # Cannot use `merged.maskedImage /= weights` because that
614 # operator divides the variance by the weight twice; in this case
615 # `weights` are the exact values we want to scale by.
616 weights = weights.array[good]
617 merged.image.array[good] /= weights
618 merged.variance.array[good] /= weights
619
620 merged.mask.array[~good] |= merged.mask.getPlaneBitMask("NO_DATA")
621
622 return merged, good.sum(), included
623
624 def _makePsf(self, template, catalog, wcs):
625 """Return a PSF containing the PSF at each of the input regions.
626
627 Note that although this includes all the exposures from the catalog,
628 the PSF knows which part of the template the inputs came from, so when
629 evaluated at a given position it will not include inputs that never
630 went in to those pixels.
631
632 Parameters
633 ----------
634 template : `lsst.afw.image.Exposure`
635 Generated template the PSF is for.
636 catalog : `lsst.afw.table.ExposureCatalog`
637 Catalog of exposures that went into the template that contains all
638 of the input PSFs.
639 wcs : `lsst.afw.geom.SkyWcs`
640 WCS of the template, to warp the PSFs to.
641
642 Returns
643 -------
644 coaddPsf : `lsst.meas.algorithms.CoaddPsf`
645 The meta-psf constructed from all of the input catalogs.
646 """
647 # CoaddPsf centroid not only must overlap image, but must overlap the
648 # part of image with data. Use centroid of region with data.
649 boolmask = template.mask.array & template.mask.getPlaneBitMask("NO_DATA") == 0
650 maskx = afwImage.makeMaskFromArray(boolmask.astype(afwImage.MaskPixel))
651 centerCoord = afwGeom.SpanSet.fromMask(maskx, 1).computeCentroid()
652
653 ctrl = self.config.coaddPsf.makeControl()
654 coaddPsf = CoaddPsf(
655 catalog, wcs, centerCoord, ctrl.warpingKernelName, ctrl.cacheSize
656 )
657 return coaddPsf
658
659
661 GetTemplateConnections,
662 dimensions=("instrument", "visit", "detector"),
663 defaultTemplates={"coaddName": "dcr", "warpTypeSuffix": "", "fakesType": ""},
664):
665 visitInfo = pipeBase.connectionTypes.Input(
666 doc="VisitInfo of calexp used to determine observing conditions.",
667 name="{fakesType}calexp.visitInfo",
668 storageClass="VisitInfo",
669 dimensions=("instrument", "visit", "detector"),
670 )
671 dcrCoadds = pipeBase.connectionTypes.Input(
672 doc="Input DCR template to match and subtract from the exposure",
673 name="{fakesType}dcrCoadd{warpTypeSuffix}",
674 storageClass="ExposureF",
675 dimensions=("tract", "patch", "skymap", "band", "subfilter"),
676 multiple=True,
677 deferLoad=True,
678 )
679
680 def __init__(self, *, config=None):
681 super().__init__(config=config)
682 self.inputs.remove("coaddExposures")
683
684
685class GetDcrTemplateConfig(
686 GetTemplateConfig, pipelineConnections=GetDcrTemplateConnections
687):
688 numSubfilters = pexConfig.Field(
689 doc="Number of subfilters in the DcrCoadd.",
690 dtype=int,
691 default=3,
692 )
693 effectiveWavelength = pexConfig.Field(
694 doc="Effective wavelength of the filter in nm.",
695 optional=False,
696 dtype=float,
697 )
698 bandwidth = pexConfig.Field(
699 doc="Bandwidth of the physical filter.",
700 optional=False,
701 dtype=float,
702 )
703
704 def validate(self):
705 if self.effectiveWavelength is None or self.bandwidth is None:
706 raise ValueError(
707 "The effective wavelength and bandwidth of the physical filter "
708 "must be set in the getTemplate config for DCR coadds. "
709 "Required until transmission curves are used in DM-13668."
710 )
711
712
713class GetDcrTemplateTask(GetTemplateTask):
714 ConfigClass = GetDcrTemplateConfig
715 _DefaultName = "getDcrTemplate"
716
717 def runQuantum(self, butlerQC, inputRefs, outputRefs):
718 inputs = butlerQC.get(inputRefs)
719 bbox = inputs.pop("bbox")
720 wcs = inputs.pop("wcs")
721 dcrCoaddExposureHandles = inputs.pop("dcrCoadds")
722 skymap = inputs.pop("skyMap")
723 visitInfo = inputs.pop("visitInfo")
724
725 # This should not happen with a properly configured execution context.
726 assert not inputs, "runQuantum got more inputs than expected"
727
728 results = self.getExposures(
729 dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo
730 )
731 physical_filter = butlerQC.quantum.dataId["physical_filter"]
732 outputs = self.run(
733 coaddExposureHandles=results.coaddExposures,
734 bbox=bbox,
735 wcs=wcs,
736 dataIds=results.dataIds,
737 physical_filter=physical_filter,
738 )
739 butlerQC.put(outputs, outputRefs)
740
741 def getExposures(self, dcrCoaddExposureHandles, bbox, skymap, wcs, visitInfo):
742 """Return lists of coadds and their corresponding dataIds that overlap
743 the detector.
744
745 The spatial index in the registry has generous padding and often
746 supplies patches near, but not directly overlapping the detector.
747 Filters inputs so that we don't have to read in all input coadds.
748
749 Parameters
750 ----------
751 dcrCoaddExposureHandles : `list` \
752 [`lsst.daf.butler.DeferredDatasetHandle` of \
753 `lsst.afw.image.Exposure`]
754 Data references to exposures that might overlap the detector.
755 bbox : `lsst.geom.Box2I`
756 Template Bounding box of the detector geometry onto which to
757 resample the coaddExposures.
758 skymap : `lsst.skymap.SkyMap`
759 Input definition of geometry/bbox and projection/wcs for
760 template exposures.
761 wcs : `lsst.afw.geom.SkyWcs`
762 Template WCS onto which to resample the coaddExposures.
763 visitInfo : `lsst.afw.image.VisitInfo`
764 Metadata for the science image.
765
766 Returns
767 -------
768 result : `lsst.pipe.base.Struct`
769 A struct with attibutes:
770
771 ``coaddExposures``
772 Dict of coadd exposures that overlap the projected bbox,
773 indexed on tract id
774 (`dict` [`int`, `list` [`lsst.afw.image.Exposure`] ]).
775 ``dataIds``
776 Dict of data IDs of the coadd exposures that overlap the
777 projected bbox, indexed on tract id
778 (`dict` [`int`, `list [`lsst.daf.butler.DataCoordinate`] ]).
779
780 Raises
781 ------
782 pipeBase.NoWorkFound
783 Raised if no patches overlatp the input detector bbox.
784 """
785 # Check that the patches actually overlap the detector
786 # Exposure's validPolygon would be more accurate
787 if wcs is None:
788 raise pipeBase.NoWorkFound("Exposure has no WCS; cannot create a template.")
789
790 detectorPolygon = geom.Box2D(bbox)
791 overlappingArea = 0
792 dataIds = collections.defaultdict(list)
793 patchList = dict()
794 for coaddRef in dcrCoaddExposureHandles:
795 dataId = coaddRef.dataId
796 subfilter = dataId["subfilter"]
797 patchWcs = skymap[dataId["tract"]].getWcs()
798 patchBBox = skymap[dataId["tract"]][dataId["patch"]].getOuterBBox()
799 patchCorners = patchWcs.pixelToSky(geom.Box2D(patchBBox).getCorners())
800 patchPolygon = afwGeom.Polygon(wcs.skyToPixel(patchCorners))
801 if patchPolygon.intersection(detectorPolygon):
802 overlappingArea += patchPolygon.intersectionSingle(
803 detectorPolygon
804 ).calculateArea()
805 self.log.info(
806 "Using template input tract=%s, patch=%s, subfilter=%s"
807 % (dataId["tract"], dataId["patch"], dataId["subfilter"])
808 )
809 if dataId["tract"] in patchList:
810 patchList[dataId["tract"]].append(dataId["patch"])
811 else:
812 patchList[dataId["tract"]] = [
813 dataId["patch"],
814 ]
815 if subfilter == 0:
816 dataIds[dataId["tract"]].append(dataId)
817
818 if not overlappingArea:
819 raise pipeBase.NoWorkFound("No patches overlap detector")
820
821 self.checkPatchList(patchList)
822
823 coaddExposures = self.getDcrModel(patchList, dcrCoaddExposureHandles, visitInfo)
824 return pipeBase.Struct(coaddExposures=coaddExposures, dataIds=dataIds)
825
826 def checkPatchList(self, patchList):
827 """Check that all of the DcrModel subfilters are present for each
828 patch.
829
830 Parameters
831 ----------
832 patchList : `dict`
833 Dict of the patches containing valid data for each tract.
834
835 Raises
836 ------
837 RuntimeError
838 If the number of exposures found for a patch does not match the
839 number of subfilters.
840 """
841 for tract in patchList:
842 for patch in set(patchList[tract]):
843 if patchList[tract].count(patch) != self.config.numSubfilters:
844 raise RuntimeError(
845 "Invalid number of DcrModel subfilters found: %d vs %d expected",
846 patchList[tract].count(patch),
847 self.config.numSubfilters,
848 )
849
850 def getDcrModel(self, patchList, coaddRefs, visitInfo):
851 """Build DCR-matched coadds from a list of exposure references.
852
853 Parameters
854 ----------
855 patchList : `dict`
856 Dict of the patches containing valid data for each tract.
857 coaddRefs : `list` [`lsst.daf.butler.DeferredDatasetHandle`]
858 Data references to `~lsst.afw.image.Exposure` representing
859 DcrModels that overlap the detector.
860 visitInfo : `lsst.afw.image.VisitInfo`
861 Metadata for the science image.
862
863 Returns
864 -------
865 coaddExposures : `list` [`lsst.afw.image.Exposure`]
866 Coadd exposures that overlap the detector.
867 """
868 coaddExposures = collections.defaultdict(list)
869 for tract in patchList:
870 for patch in set(patchList[tract]):
871 coaddRefList = [
872 coaddRef
873 for coaddRef in coaddRefs
874 if _selectDataRef(coaddRef, tract, patch)
875 ]
876
877 dcrModel = DcrModel.fromQuantum(
878 coaddRefList,
879 self.config.effectiveWavelength,
880 self.config.bandwidth,
881 self.config.numSubfilters,
882 )
883 coaddExposures[tract].append(dcrModel.buildMatchedExposureHandle(visitInfo=visitInfo))
884 return coaddExposures
885
886
887def _selectDataRef(coaddRef, tract, patch):
888 condition = (coaddRef.dataId["tract"] == tract) & (
889 coaddRef.dataId["patch"] == patch
890 )
891 return condition
run(self, *, coaddExposureHandles, bbox, wcs, dataIds, physical_filter, visit=None)
checkHighVariance(self, template)
_makePsf(self, template, catalog, wcs)
_makeExposureCatalog(self, exposureRefs, dataIds)
getDcrModel(self, patchList, coaddRefs, visitInfo)
_merge(self, maskedImages, bbox, wcs)
checkPatchList(self, patchList)
_selectDataRef(coaddRef, tract, patch)