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