Coverage for python/lsst/drp/tasks/assemble_cell_coadd.py: 18%
340 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-05 01:03 -0800
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-05 01:03 -0800
1# This file is part of drp_tasks.
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/>.
22from __future__ import annotations
24__all__ = (
25 "AssembleCellCoaddTask",
26 "AssembleCellCoaddConfig",
27 "ConvertMultipleCellCoaddToExposureTask",
28)
30import dataclasses
31import itertools
33import numpy as np
35import lsst.afw.geom as afwGeom
36import lsst.afw.image as afwImage
37import lsst.afw.math as afwMath
38import lsst.geom as geom
39from lsst.afw.detection import InvalidPsfError
40from lsst.afw.geom import SinglePolygonException, makeWcsPairTransform
41from lsst.cell_coadds import (
42 CellIdentifiers,
43 CoaddApCorrMapStacker,
44 CoaddInputs,
45 CoaddUnits,
46 CommonComponents,
47 GridContainer,
48 MultipleCellCoadd,
49 ObservationIdentifiers,
50 OwnedImagePlanes,
51 PatchIdentifiers,
52 SingleCellCoadd,
53 UniformGrid,
54)
55from lsst.daf.butler import DataCoordinate, DeferredDatasetHandle
56from lsst.meas.algorithms import AccumulatorMeanStack
57from lsst.pex.config import ConfigField, ConfigurableField, DictField, Field, ListField, RangeField
58from lsst.pipe.base import (
59 InMemoryDatasetHandle,
60 NoWorkFound,
61 PipelineTask,
62 PipelineTaskConfig,
63 PipelineTaskConnections,
64 Struct,
65)
66from lsst.pipe.base.connectionTypes import Input, Output
67from lsst.pipe.tasks.coaddBase import makeSkyInfo, removeMaskPlanes, setRejectedMaskMapping
68from lsst.pipe.tasks.interpImage import InterpImageTask
69from lsst.pipe.tasks.scaleZeroPoint import ScaleZeroPointTask
70from lsst.skymap import BaseSkyMap
73@dataclasses.dataclass
74class WarpInputs:
75 """Collection of associate inputs along with warps."""
77 warp: DeferredDatasetHandle | InMemoryDatasetHandle
78 """Handle for the warped exposure."""
80 masked_fraction: DeferredDatasetHandle | InMemoryDatasetHandle | None = None
81 """Handle for the masked fraction image."""
83 artifact_mask: DeferredDatasetHandle | InMemoryDatasetHandle | None = None
84 """Handle for the CompareWarp artifact mask."""
86 noise_warps: list[DeferredDatasetHandle | InMemoryDatasetHandle] = dataclasses.field(default_factory=list)
87 """List of handles for the noise warps"""
89 @property
90 def dataId(self) -> DataCoordinate:
91 """DataID corresponding to the warp.
93 Returns
94 -------
95 data_id : `~lsst.daf.butler.DataCoordinate`
96 DataID of the warp.
97 """
98 return self.warp.dataId
101class AssembleCellCoaddConnections(
102 PipelineTaskConnections,
103 dimensions=("tract", "patch", "band", "skymap"),
104 defaultTemplates={"inputWarpName": "deep", "outputCoaddSuffix": "Cell"},
105):
106 inputWarps = Input(
107 doc="Input warps",
108 name="{inputWarpName}Coadd_directWarp",
109 storageClass="ExposureF",
110 dimensions=("tract", "patch", "skymap", "visit", "instrument"),
111 deferLoad=True,
112 multiple=True,
113 )
115 maskedFractionWarps = Input(
116 doc="Mask fraction warps",
117 name="{inputWarpName}Coadd_directWarp_maskedFraction",
118 storageClass="ImageF",
119 dimensions=("tract", "patch", "skymap", "visit", "instrument"),
120 deferLoad=True,
121 multiple=True,
122 )
124 artifactMasks = Input(
125 doc="Artifact masks to be applied to the input warps",
126 name="compare_warp_artifact_mask",
127 storageClass="Mask",
128 dimensions=("tract", "patch", "skymap", "visit", "instrument"),
129 deferLoad=True,
130 multiple=True,
131 )
133 visitSummaryList = Input(
134 doc="Input visit-summary catalogs with updated calibration objects. Mainly used for coadd weights.",
135 name="finalVisitSummary",
136 storageClass="ExposureCatalog",
137 dimensions=("instrument", "visit"),
138 deferLoad=True,
139 multiple=True,
140 )
142 skyMap = Input(
143 doc="Input definition of geometry/bbox and projection/wcs. This must be cell-based.",
144 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
145 storageClass="SkyMap",
146 dimensions=("skymap",),
147 )
149 multipleCellCoadd = Output(
150 doc="Output multiple cell coadd",
151 name="{inputWarpName}Coadd{outputCoaddSuffix}",
152 storageClass="MultipleCellCoadd",
153 dimensions=("tract", "patch", "band", "skymap"),
154 )
156 def __init__(self, *, config=None):
157 super().__init__(config=config)
159 if not config:
160 return
162 if config.do_calculate_weight_from_warp:
163 del self.visitSummaryList
165 if not config.do_use_artifact_mask:
166 del self.artifactMasks
168 # Dynamically set input connections for noise images, depending on the
169 # number of noise realizations specified in the config.
170 for n in range(config.num_noise_realizations):
171 noise_warps = Input(
172 doc="Input noise warps",
173 name=f"{config.connections.inputWarpName}Coadd_directWarp_noise{n}",
174 storageClass="MaskedImageF",
175 dimensions=("tract", "patch", "skymap", "visit", "instrument"),
176 deferLoad=True,
177 multiple=True,
178 )
179 setattr(self, f"noise{n}_warps", noise_warps)
182class AssembleCellCoaddConfig(PipelineTaskConfig, pipelineConnections=AssembleCellCoaddConnections):
183 do_interpolate_coadd = Field[bool](doc="Interpolate over pixels with NO_DATA mask set?", default=True)
184 interpolate_coadd = ConfigurableField(
185 target=InterpImageTask,
186 doc="Task to interpolate (and extrapolate) over pixels with NO_DATA mask on cell coadds",
187 )
188 do_scale_zero_point = Field[bool](
189 doc="Scale warps to a common zero point? This is not needed if they have absolute flux calibration.",
190 default=False,
191 deprecated="Now that visits are scaled to nJy it is no longer necessary or "
192 "recommended to scale the zero point, so this will be removed "
193 "after v29.",
194 )
195 scale_zero_point = ConfigurableField(
196 target=ScaleZeroPointTask,
197 doc="Task to scale warps to a common zero point",
198 deprecated="Now that visits are scaled to nJy it is no longer necessary or "
199 "recommended to scale the zero point, so this will be removed "
200 "after v29.",
201 )
202 do_calculate_weight_from_warp = Field[bool](
203 doc="Calculate coadd weight from the input warp? Otherwise, the weight is obtained from the "
204 "visitSummaryList connection. This is meant as a fallback when run outside the pipeline.",
205 default=False,
206 )
207 do_use_artifact_mask = Field[bool](
208 doc="Substitute the mask planes input warp with an alternative artifact mask?",
209 default=True,
210 )
211 do_coadd_inverse_aperture_corrections = Field[bool](
212 doc="Coadd the inverse aperture corrections for each cell? This is formally the more accurate way "
213 "but may be turned off for parity with deepCoadd.",
214 default=False,
215 )
216 min_overlap_fraction = RangeField[float](
217 doc="The minimum overlap fraction required for a single (visit, detector) input to be included in a "
218 "cell.",
219 # A value of 1.0 corresponds to ideal, edge-free cells.
220 # A value of 0.0 corresponds to the deep_coadd style coadds.
221 # This has to be at least 0.5 to ensure that the an input overlaps the
222 # cell center. Inputs will overlap fraction less than 0.25 will
223 # definitely not overlap the cell center.
224 default=0.0,
225 min=0.0,
226 max=1.0,
227 inclusiveMin=True,
228 inclusiveMax=True,
229 )
230 bad_mask_planes = ListField[str](
231 doc="Mask planes that count towards the masked fraction within a cell.",
232 default=("BAD", "NO_DATA", "SAT", "CLIPPED"),
233 )
234 remove_mask_planes = ListField[str](
235 doc="Mask planes to remove before coadding",
236 default=["NOT_DEBLENDED"],
237 )
238 calc_error_from_input_variance = Field[bool](
239 doc="Calculate coadd variance from input variance by stacking "
240 "statistic. Passed to AccumulatorMeanStack.",
241 default=True,
242 )
243 mask_propagation_thresholds = DictField[str, float](
244 doc=(
245 "Threshold (in fractional weight) of rejection at which we "
246 "propagate a mask plane to the coadd; that is, we set the mask "
247 "bit on the coadd if the fraction the rejected frames "
248 "would have contributed exceeds this value."
249 ),
250 default={"SAT": 0.1},
251 )
252 max_maskfrac = RangeField[float](
253 doc="Maximum fraction of masked pixels in a cell. This is currently "
254 "just a placeholder and is not used now",
255 default=0.99,
256 min=0.0,
257 max=1.0,
258 inclusiveMin=True,
259 inclusiveMax=False,
260 )
261 num_noise_realizations = Field[int](
262 default=0,
263 doc=(
264 "Number of noise planes to include in the coadd. "
265 "This should not exceed the corresponding config parameter "
266 "specified in `MakeDirectWarpConfig`. "
267 ),
268 check=lambda x: x >= 0,
269 )
270 psf_warper = ConfigField(
271 doc="Configuration for the warper that warps the PSFs. It must have the same configuration used to "
272 "warp the images.",
273 dtype=afwMath.Warper.ConfigClass,
274 )
275 psf_dimensions = Field[int](
276 default=21,
277 doc="Dimensions of the PSF image stamp size to be assigned to cells (must be odd).",
278 check=lambda x: (x > 0) and (x % 2 == 1),
279 )
282class AssembleCellCoaddTask(PipelineTask):
283 """Assemble a cell-based coadded image from a set of warps.
285 This task reads in the warp one at a time, and accumulates it in all the
286 cells that it completely overlaps with. This is the optimal I/O pattern but
287 this also implies that it is not possible to build one or only a few cells.
289 Each cell coadds is guaranteed to have a well-defined PSF. This is done by
290 1) excluding warps that only partially overlap a cell from that cell coadd;
291 2) interpolating bad pixels in the warps rather than excluding them;
292 3) by computing the coadd as a weighted mean of the warps without clipping;
293 4) by computing the coadd PSF as the weighted mean of the PSF of the warps
294 with the same weights.
296 The cells are (and must be) defined in the skymap, and cannot be configured
297 or redefined here. The cells are assumed to be small enough that the PSF is
298 assumed to be spatially constant within a cell.
300 Raises
301 ------
302 NoWorkFound
303 Raised if no input warps are provided, or no cells could be populated.
304 RuntimeError
305 Raised if the skymap is not cell-based.
307 Notes
308 -----
309 This is not yet a part of the standard DRP pipeline. As such, the Task and
310 especially its Config and Connections are experimental and subject to
311 change any time without a formal RFC or standard deprecation procedures
312 until it is included in the DRP pipeline.
313 """
315 ConfigClass = AssembleCellCoaddConfig
316 _DefaultName = "assembleCellCoadd"
318 def __init__(self, *args, **kwargs):
319 super().__init__(*args, **kwargs)
320 if self.config.do_interpolate_coadd:
321 self.makeSubtask("interpolate_coadd")
322 if self.config.do_scale_zero_point:
323 self.makeSubtask("scale_zero_point")
325 self.psf_warper = afwMath.Warper.fromConfig(self.config.psf_warper)
327 def runQuantum(self, butlerQC, inputRefs, outputRefs):
328 # Docstring inherited.
329 if not inputRefs.inputWarps:
330 raise NoWorkFound("No input warps provided for co-addition")
331 self.log.info("Found %d input warps", len(inputRefs.inputWarps))
333 # Construct skyInfo expected by run
334 # Do not remove skyMap from inputData in case _makeSupplementaryData
335 # needs it
336 skyMap = butlerQC.get(inputRefs.skyMap)
338 if not skyMap.config.tractBuilder.name == "cells":
339 raise RuntimeError("AssembleCellCoaddTask requires a cell-based skymap.")
341 outputDataId = butlerQC.quantum.dataId
343 skyInfo = makeSkyInfo(skyMap, tractId=outputDataId["tract"], patchId=outputDataId["patch"])
344 visitSummaryList = butlerQC.get(getattr(inputRefs, "visitSummaryList", []))
346 units = CoaddUnits.legacy if self.config.do_scale_zero_point else CoaddUnits.nJy
347 self.common = CommonComponents(
348 units=units,
349 wcs=skyInfo.patchInfo.wcs,
350 band=outputDataId.get("band", None),
351 identifiers=PatchIdentifiers.from_data_id(outputDataId),
352 )
354 inputs: dict[DataCoordinate, WarpInputs] = {}
355 for handle in butlerQC.get(inputRefs.inputWarps):
356 inputs[handle.dataId] = WarpInputs(warp=handle, noise_warps=[])
358 for ref in getattr(inputRefs, "artifactMasks", []):
359 inputs[ref.dataId].artifact_mask = butlerQC.get(ref)
360 for ref in getattr(inputRefs, "maskedFractionWarps", []):
361 inputs[ref.dataId].masked_fraction = butlerQC.get(ref)
362 for n in range(self.config.num_noise_realizations):
363 for ref in getattr(inputRefs, f"noise{n}_warps"):
364 inputs[ref.dataId].noise_warps.append(butlerQC.get(ref))
366 returnStruct = self.run(inputs=inputs, skyInfo=skyInfo, visitSummaryList=visitSummaryList)
367 butlerQC.put(returnStruct, outputRefs)
368 return returnStruct
370 @staticmethod
371 def _compute_weight(maskedImage, statsCtrl):
372 """Compute a weight for a masked image.
374 Parameters
375 ----------
376 maskedImage : `~lsst.afw.image.MaskedImage`
377 The masked image to compute the weight.
378 statsCtrl : `~lsst.afw.math.StatisticsControl`
379 A control (config-like) object for StatisticsStack.
381 Returns
382 -------
383 weight : `float`
384 Inverse of the clipped mean variance of the masked image.
385 """
386 statObj = afwMath.makeStatistics(
387 maskedImage.getVariance(), maskedImage.getMask(), afwMath.MEANCLIP, statsCtrl
388 )
389 meanVar, _ = statObj.getResult(afwMath.MEANCLIP)
390 weight = 1.0 / float(meanVar)
391 return weight
393 @staticmethod
394 def _construct_grid(skyInfo):
395 """Construct a UniformGrid object from a SkyInfo struct.
397 Parameters
398 ----------
399 skyInfo : `~lsst.pipe.base.Struct`
400 A Struct object
402 Returns
403 -------
404 grid : `~lsst.cell_coadds.UniformGrid`
405 A UniformGrid object.
406 """
407 padding = skyInfo.patchInfo.getCellBorder()
408 grid_bbox = skyInfo.patchInfo.outer_bbox.erodedBy(padding)
409 grid = UniformGrid.from_bbox_cell_size(
410 grid_bbox,
411 skyInfo.patchInfo.getCellInnerDimensions(),
412 padding=padding,
413 )
414 return grid
416 def _construct_grid_container(self, skyInfo, statsCtrl):
417 """Construct a grid of AccumulatorMeanStack instances.
419 Parameters
420 ----------
421 skyInfo : `~lsst.pipe.base.Struct`
422 A Struct object
423 statsCtrl : `~lsst.afw.math.StatisticsControl`
424 A control (config-like) object for StatisticsStack.
426 Returns
427 -------
428 gc : `~lsst.cell_coadds.GridContainer`
429 A GridContainer object container one AccumulatorMeanStack per cell.
430 """
431 grid = self._construct_grid(skyInfo)
433 maskMap = setRejectedMaskMapping(statsCtrl)
434 self.log.debug("Obtained maskMap = %s for %s", maskMap, skyInfo.patchInfo)
435 thresholdDict = AccumulatorMeanStack.stats_ctrl_to_threshold_dict(statsCtrl)
437 # Initialize the grid container with AccumulatorMeanStacks
438 gc = GridContainer[AccumulatorMeanStack](grid.shape)
439 for cellInfo in skyInfo.patchInfo:
440 stacker = AccumulatorMeanStack(
441 # The shape is for the numpy arrays, hence transposed.
442 shape=(cellInfo.outer_bbox.height, cellInfo.outer_bbox.width),
443 bit_mask_value=statsCtrl.getAndMask(),
444 mask_threshold_dict=thresholdDict,
445 calc_error_from_input_variance=self.config.calc_error_from_input_variance,
446 compute_n_image=False,
447 mask_map=maskMap,
448 no_good_pixels_mask=statsCtrl.getNoGoodPixelsMask(),
449 )
450 gc[cellInfo.index] = stacker
452 return gc
454 def _construct_stats_control(self):
455 """Construct a StatisticsControl object for coadd.
457 Unlike AssembleCoaddTask or CompareWarpAssembleCoaddTask, there is
458 very little to be configured apart from setting the mask planes and
459 optionally mask propagation thresholds.
461 Returns
462 -------
463 statsCtrl : `~lsst.afw.math.StatisticsControl`
464 A control object for StatisticsStack.
465 """
466 statsCtrl = afwMath.StatisticsControl()
467 # Hardcode the numIter parameter to the default config value set in
468 # CompareWarpAssembleCoaddTask to get consistent weights. This is NOT
469 # exposed as a config parameter, since this is only meant to be a
470 # fallback option that is not recommended for production.
471 statsCtrl.setNumIter(2)
472 statsCtrl.setAndMask(afwImage.Mask.getPlaneBitMask(self.config.bad_mask_planes))
473 statsCtrl.setNanSafe(True)
474 for plane, threshold in self.config.mask_propagation_thresholds.items():
475 bit = afwImage.Mask.getMaskPlane(plane)
476 statsCtrl.setMaskPropagationThreshold(bit, threshold)
477 return statsCtrl
479 def _construct_ap_corr_grid_container(self, skyInfo):
480 """Construct a grid of CoaddApCorrMapStacker instances.
482 Parameters
483 ----------
484 skyInfo : `~lsst.pipe.base.Struct`
485 A Struct object
487 Returns
488 -------
489 gc : `~lsst.cell_coadds.GridContainer`
490 A GridContainer object container one CoaddApCorrMapStacker per
491 cell.
492 """
493 grid = self._construct_grid(skyInfo)
495 # Initialize the grid container with CoaddApCorrMapStacker.
496 gc = GridContainer[CoaddApCorrMapStacker](grid.shape)
497 for cellInfo in skyInfo.patchInfo:
498 stacker = CoaddApCorrMapStacker(
499 evaluation_point=cellInfo.inner_bbox.getCenter(),
500 do_coadd_inverse_ap_corr=self.config.do_coadd_inverse_aperture_corrections,
501 )
502 gc[cellInfo.index] = stacker
504 return gc
506 def run(
507 self,
508 *,
509 inputs: dict[DataCoordinate, WarpInputs],
510 skyInfo,
511 visitSummaryList: list | None = None,
512 ):
513 for mask_plane in self.config.bad_mask_planes:
514 afwImage.Mask.addMaskPlane(mask_plane)
515 for mask_plane in self.config.mask_propagation_thresholds:
516 afwImage.Mask.addMaskPlane(mask_plane)
518 statsCtrl = self._construct_stats_control()
520 warp_stacker_gc = self._construct_grid_container(skyInfo, statsCtrl)
521 maskfrac_stacker_gc = self._construct_grid_container(skyInfo, statsCtrl)
522 noise_stacker_gc_list = [
523 self._construct_grid_container(skyInfo, statsCtrl)
524 for n in range(self.config.num_noise_realizations)
525 ]
526 psf_stacker_gc = GridContainer[AccumulatorMeanStack](warp_stacker_gc.shape)
527 psf_bbox_gc = GridContainer[geom.Box2I](warp_stacker_gc.shape)
528 ap_corr_stacker_gc = self._construct_ap_corr_grid_container(skyInfo)
530 # Make a container to hold the cell centers in sky coordinates now,
531 # so we don't have to recompute them for each warp
532 # (they share a common WCS). These are needed to find the various
533 # warp + detector combinations that contributed to each cell, and later
534 # get the corresponding PSFs as well.
535 cell_centers_sky = GridContainer[geom.SpherePoint](warp_stacker_gc.shape)
536 # Make a container to hold the observation identifiers for each cell.
537 observation_identifiers_gc = GridContainer[dict](warp_stacker_gc.shape)
538 # Populate them.
539 for cellInfo in skyInfo.patchInfo:
540 # Make a list to hold the observation identifiers for each cell.
541 observation_identifiers_gc[cellInfo.index] = {}
542 cell_centers_sky[cellInfo.index] = skyInfo.wcs.pixelToSky(cellInfo.inner_bbox.getCenter())
543 psf_bbox_gc[cellInfo.index] = geom.Box2I.makeCenteredBox(
544 geom.Point2D(cellInfo.inner_bbox.getCenter()),
545 geom.Extent2I(self.config.psf_dimensions, self.config.psf_dimensions),
546 )
547 psf_stacker_gc[cellInfo.index] = AccumulatorMeanStack(
548 # The shape is for the numpy arrays, hence transposed.
549 shape=(self.config.psf_dimensions, self.config.psf_dimensions),
550 bit_mask_value=0,
551 calc_error_from_input_variance=self.config.calc_error_from_input_variance,
552 compute_n_image=False,
553 )
555 # visit_summary do not have (tract, patch, band, skymap) dimensions.
556 if not visitSummaryList:
557 visitSummaryList = []
558 visitSummaryRefDict = {
559 visitSummaryRef.dataId["visit"]: visitSummaryRef for visitSummaryRef in visitSummaryList
560 }
562 # Read in one warp at a time, and accumulate it in all the cells that
563 # it completely overlaps.
564 for _, warp_input in inputs.items():
565 warp = warp_input.warp.get(parameters={"bbox": skyInfo.bbox})
566 masked_fraction_image = (
567 warp_input.masked_fraction.get(parameters={"bbox": skyInfo.bbox})
568 if warp_input.masked_fraction
569 else None
570 )
572 # Pre-process the warp before coadding.
573 # TODO: Can we get these mask names from artifactMask?
574 warp.mask.addMaskPlane("CLIPPED")
575 warp.mask.addMaskPlane("REJECTED")
576 warp.mask.addMaskPlane("SENSOR_EDGE")
577 warp.mask.addMaskPlane("INEXACT_PSF")
579 if artifact_mask_ref := warp_input.artifact_mask:
580 # Apply the artifact mask to the warp.
581 artifact_mask = artifact_mask_ref.get()
582 assert (
583 warp.mask.getMaskPlaneDict() == artifact_mask.getMaskPlaneDict()
584 ), "Mask dicts do not agree."
585 warp.mask.array = artifact_mask.array
586 del artifact_mask
588 if self.config.do_scale_zero_point:
589 # Each Warp that goes into a coadd will typically have an
590 # independent photometric zero-point. Therefore, we must scale
591 # each Warp to set it to a common photometric zeropoint.
592 imageScaler = self.scale_zero_point.run(exposure=warp, dataRef=warp_input.warp).imageScaler
593 zero_point_scale_factor = imageScaler.scale
594 self.log.debug(
595 "Scaled the warp %s by %f to match zero points",
596 warp_input.dataId,
597 zero_point_scale_factor,
598 )
599 else:
600 zero_point_scale_factor = 1.0
601 if "BUNIT" not in warp.metadata:
602 raise ValueError(f"Warp {warp_input.dataId} has no BUNIT metadata")
603 if warp.metadata["BUNIT"] != "nJy":
604 raise ValueError(
605 f"Warp {warp_input.dataId} has BUNIT {warp.metadata['BUNIT']}, expected nJy"
606 )
608 removeMaskPlanes(warp.mask, self.config.remove_mask_planes, self.log)
610 # Compute the weight for each CCD in the warp from the visitSummary
611 # or from the warp itself, if not provided. Computing the weight
612 # from the warp is not recommended, and in that case we compute one
613 # weight per warp and not bother with per-detector weights.
614 full_ccd_table = warp.getInfo().getCoaddInputs().ccds
615 weights: dict[int, float] = dict.fromkeys(
616 full_ccd_table["ccd"],
617 0.0,
618 ) # Mapping from detector to weight.
620 if visitSummaryRef := visitSummaryRefDict.get(warp_input.dataId["visit"]):
621 visitSummary = visitSummaryRef.get()
622 for detector in full_ccd_table["ccd"]:
623 visitSummaryRow = visitSummary.find(detector)
624 mean_variance = visitSummaryRow["meanVar"]
625 mean_variance *= zero_point_scale_factor**2
626 if warp.metadata.get("BUNIT", None) == "nJy":
627 mean_variance *= visitSummaryRow.photoCalib.getCalibrationMean() ** 2
628 weights[detector] = 1.0 / mean_variance
629 del visitSummary
630 else:
631 self.log.debug("No visit summary found for %s; using warp-based weights", warp_input.dataId)
632 weight = self._compute_weight(warp, statsCtrl)
633 if not np.isfinite(weight):
634 self.log.warn("Non-finite weight for %s: skipping", warp_input.dataId)
635 continue
637 for detector in weights:
638 weights[detector] = weight
640 noise_warps = [ref.get(parameters={"bbox": skyInfo.bbox}) for ref in warp_input.noise_warps]
642 visit_polygons: dict[ObservationIdentifiers, afwGeom.Polygon] = {}
643 # Create an image where each pixel value corresponds to the
644 # detector ID that pixel comes from.
645 detector_map = afwImage.ImageI(bbox=warp.getBBox(), initialValue=-1)
646 for row in full_ccd_table:
647 transform = makeWcsPairTransform(row.wcs, warp.wcs)
648 if (src_polygon := row.validPolygon) is None:
649 src_polygon = afwGeom.Polygon(geom.Box2D(row.getBBox()))
650 try:
651 dest_polygon = src_polygon.transform(transform).intersectionSingle(
652 geom.Box2D(warp.getBBox())
653 )
654 except SinglePolygonException:
655 continue
657 observation_identifier = ObservationIdentifiers.from_data_id(
658 warp_input.dataId,
659 backup_detector=row["ccd"],
660 )
661 visit_polygons[observation_identifier] = dest_polygon
663 detector_map_slice = dest_polygon.createImage(detector_map.getBBox()).array > 0
664 if not (detector_map.array[detector_map_slice] < 0).all():
665 self.log.warning("Multiple detectors from visit %s are overlapping", warp_input.dataId)
666 detector_map.array[detector_map_slice] = row["ccd"]
668 # Update common with the visit polygons.
669 self.common = dataclasses.replace(
670 self.common,
671 visit_polygons=visit_polygons,
672 )
674 if (detector_map.array < 0).all():
675 self.log.warning("Unable to split the warp %s into single-detector warps.", warp_input.dataId)
676 detector_map.array[:, :] = 0
678 for cellInfo, ccd_row in itertools.product(skyInfo.patchInfo, full_ccd_table):
679 bbox = cellInfo.outer_bbox
680 inner_bbox = cellInfo.inner_bbox
682 overlap_fraction = (detector_map[inner_bbox].array == ccd_row["ccd"]).mean()
683 assert -1e-4 < overlap_fraction < 1.0001, "Overlap fraction is not within [0, 1]."
684 if (overlap_fraction < self.config.min_overlap_fraction) or (overlap_fraction <= 0.0):
685 self.log.log(
686 self.log.DEBUG if overlap_fraction == 0.0 else self.log.INFO,
687 "Skipping %s in cell %s because it had only %.3f < %.3f fractional overlap.",
688 warp_input.dataId,
689 cellInfo.index,
690 overlap_fraction,
691 self.config.min_overlap_fraction,
692 )
693 continue
695 weight = weights[ccd_row["ccd"]]
696 if not np.isfinite(weight):
697 self.log.warn(
698 "Non-finite weight for %s in cell %s: skipping", warp_input.dataId, cellInfo.index
699 )
700 continue
702 if weight == 0:
703 self.log.info(
704 "Zero weight for %s in cell %s: skipping", warp_input.dataId, cellInfo.index
705 )
706 continue
708 # Decide if a deep copy is necessary to apply the single
709 # detector cuts since it involves modifying the image in-place.
710 # If within the inner cell, there are three or more different
711 # values that detector map takes, then there are definitely
712 # multiple detectors (one for chip gaps, two for two detectors)
713 deep_copy = len(set(detector_map[inner_bbox].array.ravel())) >= 3
714 if deep_copy:
715 single_detector_mask_array = detector_map[bbox].array != ccd_row["ccd"]
717 mi = afwImage.MaskedImageF(warp[bbox].maskedImage, deep=deep_copy)
718 if deep_copy:
719 mi.image.array[single_detector_mask_array] = 0.0
720 mi.variance.array[single_detector_mask_array] = np.inf
721 nodata_or_mask = (single_detector_mask_array) * afwImage.Mask.getPlaneBitMask("NO_DATA")
722 mi.mask[bbox].array |= nodata_or_mask
723 warp_stacker_gc[cellInfo.index].add_masked_image(mi, weight=weight)
725 if masked_fraction_image:
726 mi = afwImage.ImageF(masked_fraction_image[bbox], deep=deep_copy)
727 if deep_copy:
728 mi.array[single_detector_mask_array] = 0.0
729 maskfrac_stacker_gc[cellInfo.index].add_image(masked_fraction_image[bbox], weight=weight)
731 for n in range(self.config.num_noise_realizations):
732 mi = afwImage.MaskedImageF(noise_warps[n][bbox], deep=deep_copy)
733 if deep_copy:
734 mi.image.array[single_detector_mask_array] = 0.0
735 mi.variance.array[single_detector_mask_array] = np.inf
736 mi.mask[bbox].array |= nodata_or_mask
737 noise_stacker_gc_list[n][cellInfo.index].add_masked_image(mi, weight=weight)
739 # Set the defaults for PSF shape quantities.
740 psf_shape = afwGeom.Quadrupole()
741 psf_shape_flag = True
742 psf_eval_point = None
743 try:
744 if overlap_fraction < 1.0:
745 psf_eval_point = dest_polygon.intersectionSingle(
746 geom.Box2D(inner_bbox)
747 ).calculateCenter()
748 else:
749 psf_eval_point = geom.Point2D(inner_bbox.getCenter())
750 psf_shape = warp.psf.computeShape(psf_eval_point)
751 psf_shape_flag = False
752 except SinglePolygonException:
753 self.log.info(
754 "Unable to find the overlapping polygon between %d detector in %s and cell %s",
755 ccd_row["ccd"],
756 warp_input.dataId,
757 cellInfo.index,
758 )
759 except InvalidPsfError:
760 self.log.info(
761 "Unable to compute PSF shape from %d detector in %s at %s",
762 ccd_row["ccd"],
763 warp_input.dataId,
764 psf_eval_point,
765 )
767 overlaps_center = detector_map[geom.Point2I(bbox.getCenter())] == ccd_row["ccd"]
769 observation_identifier = ObservationIdentifiers.from_data_id(
770 warp_input.dataId,
771 backup_detector=ccd_row["ccd"],
772 )
773 observation_identifiers_gc[cellInfo.index][observation_identifier] = CoaddInputs(
774 overlaps_center=overlaps_center,
775 overlap_fraction=overlap_fraction,
776 weight=weight,
777 psf_shape=psf_shape,
778 psf_shape_flag=psf_shape_flag,
779 )
780 if overlaps_center is False:
781 self.log.debug(
782 "%s does not overlap with the center of the cell %s",
783 warp_input.dataId,
784 cellInfo.index,
785 )
786 continue
788 # Everything below this has to do with the center of the cell
789 calexp_point = ccd_row.getWcs().skyToPixel(cell_centers_sky[cellInfo.index])
790 undistorted_psf_im = ccd_row.getPsf().computeImage(calexp_point)
792 assert undistorted_psf_im.getBBox() == geom.Box2I.makeCenteredBox(
793 calexp_point,
794 undistorted_psf_im.getDimensions(),
795 ), "PSF image does not share the coordinates of the 'calexp'"
797 # Convert the PSF image from Image to MaskedImage.
798 undistorted_psf_maskedImage = afwImage.MaskedImageD(image=undistorted_psf_im)
799 # TODO: In DM-43585, use the variance plane value from noise.
800 undistorted_psf_maskedImage.variance += 1.0 # Set variance to 1
802 warped_psf_maskedImage = self.psf_warper.warpImage(
803 destWcs=skyInfo.wcs,
804 srcImage=undistorted_psf_maskedImage,
805 srcWcs=ccd_row.getWcs(),
806 destBBox=psf_bbox_gc[cellInfo.index],
807 )
809 # There may be NaNs in the PSF image. Set them to 0.0
810 warped_psf_maskedImage.variance.array[np.isnan(warped_psf_maskedImage.image.array)] = 1.0
811 warped_psf_maskedImage.image.array[np.isnan(warped_psf_maskedImage.image.array)] = 0.0
813 psf_stacker = psf_stacker_gc[cellInfo.index]
814 psf_stacker.add_masked_image(warped_psf_maskedImage, weight=weight)
816 if (ap_corr_map := warp.getInfo().getApCorrMap()) is not None:
817 ap_corr_stacker_gc[cellInfo.index].add(ap_corr_map, weight=weight)
819 del warp
821 cells: list[SingleCellCoadd] = []
822 for cellInfo in skyInfo.patchInfo:
823 if len(observation_identifiers_gc[cellInfo.index]) == 0:
824 self.log.debug("Skipping cell %s because it has no input warps", cellInfo.index)
825 continue
827 cell_masked_image = afwImage.MaskedImageF(cellInfo.outer_bbox)
828 cell_maskfrac_image = afwImage.ImageF(cellInfo.outer_bbox)
829 cell_noise_images = [
830 afwImage.MaskedImageF(cellInfo.outer_bbox) for n in range(self.config.num_noise_realizations)
831 ]
832 psf_masked_image = afwImage.MaskedImageF(psf_bbox_gc[cellInfo.index])
834 warp_stacker_gc[cellInfo.index].fill_stacked_masked_image(cell_masked_image)
835 maskfrac_stacker_gc[cellInfo.index].fill_stacked_image(cell_maskfrac_image)
836 for n in range(self.config.num_noise_realizations):
837 noise_stacker_gc_list[n][cellInfo.index].fill_stacked_masked_image(cell_noise_images[n])
838 psf_stacker_gc[cellInfo.index].fill_stacked_masked_image(psf_masked_image)
840 if ap_corr_stacker_gc[cellInfo.index].ap_corr_names:
841 ap_corr_map = ap_corr_stacker_gc[cellInfo.index].final_ap_corr_map
842 else:
843 ap_corr_map = None
845 # Post-process the coadd before converting to new data structures.
846 if np.isnan(cell_masked_image.image.array).all():
847 cell_masked_image.image.array[:, :] = 0.0
848 cell_masked_image.variance.array[:, :] = np.inf
849 elif self.config.do_interpolate_coadd:
850 self.interpolate_coadd.run(cell_masked_image, planeName="NO_DATA")
851 for noise_image in cell_noise_images:
852 self.interpolate_coadd.run(noise_image, planeName="NO_DATA")
853 # The variance must be positive; work around for DM-3201.
854 varArray = cell_masked_image.variance.array
855 with np.errstate(invalid="ignore"):
856 varArray[:] = np.where(varArray > 0, varArray, np.inf)
858 image_planes = OwnedImagePlanes.from_masked_image(
859 masked_image=cell_masked_image,
860 mask_fractions=cell_maskfrac_image,
861 noise_realizations=[noise_image.image for noise_image in cell_noise_images],
862 )
863 identifiers = CellIdentifiers(
864 cell=cellInfo.index,
865 skymap=self.common.identifiers.skymap,
866 tract=self.common.identifiers.tract,
867 patch=self.common.identifiers.patch,
868 band=self.common.identifiers.band,
869 )
871 singleCellCoadd = SingleCellCoadd(
872 outer=image_planes,
873 psf=psf_masked_image.image,
874 inner_bbox=cellInfo.inner_bbox,
875 inputs=observation_identifiers_gc[cellInfo.index],
876 common=self.common,
877 identifiers=identifiers,
878 aperture_correction_map=ap_corr_map,
879 )
880 # TODO: Attach transmission curve when they become available.
881 cells.append(singleCellCoadd)
883 if not cells:
884 raise NoWorkFound("No cells could be populated for the cell coadd.")
886 grid = self._construct_grid(skyInfo)
887 multipleCellCoadd = MultipleCellCoadd(
888 cells,
889 grid=grid,
890 outer_cell_size=cellInfo.outer_bbox.getDimensions(),
891 inner_bbox=None,
892 common=self.common,
893 psf_image_size=cells[0].psf_image.getDimensions(),
894 )
896 return Struct(
897 multipleCellCoadd=multipleCellCoadd,
898 )
901class ConvertMultipleCellCoaddToExposureConnections(
902 PipelineTaskConnections,
903 dimensions=("tract", "patch", "band", "skymap"),
904 defaultTemplates={"inputCoaddName": "deep", "inputCoaddSuffix": "Cell"},
905):
906 cellCoaddExposure = Input(
907 doc="Output coadded exposure, produced by stacking input warps",
908 name="{inputCoaddName}Coadd{inputCoaddSuffix}",
909 storageClass="MultipleCellCoadd",
910 dimensions=("tract", "patch", "skymap", "band"),
911 )
913 stitchedCoaddExposure = Output(
914 doc="Output stitched coadded exposure, produced by stacking input warps",
915 name="{inputCoaddName}Coadd{inputCoaddSuffix}_stitched",
916 storageClass="ExposureF",
917 dimensions=("tract", "patch", "skymap", "band"),
918 )
921class ConvertMultipleCellCoaddToExposureConfig(
922 PipelineTaskConfig, pipelineConnections=ConvertMultipleCellCoaddToExposureConnections
923):
924 """A trivial PipelineTaskConfig class for
925 ConvertMultipleCellCoaddToExposureTask.
926 """
929class ConvertMultipleCellCoaddToExposureTask(PipelineTask):
930 """An after burner PipelineTask that converts a cell-based coadd from
931 `MultipleCellCoadd` format to `ExposureF` format.
933 The run method stitches the cell-based coadd into contiguous exposure and
934 returns it in as an `Exposure` object. This is lossy as it preserves only
935 the pixels in the inner bounding box of the cells and discards the values
936 in the buffer region.
938 Notes
939 -----
940 This task has no configurable parameters.
941 """
943 ConfigClass = ConvertMultipleCellCoaddToExposureConfig
944 _DefaultName = "convertMultipleCellCoaddToExposure"
946 def run(self, cellCoaddExposure):
947 return Struct(
948 stitchedCoaddExposure=cellCoaddExposure.stitch().asExposure(),
949 )