Coverage for python/lsst/drp/tasks/assemble_cell_coadd.py: 72%
417 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-14 08:16 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-14 08:16 +0000
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 (
58 ChoiceField,
59 ConfigField,
60 ConfigurableField,
61 DictField,
62 Field,
63 ListField,
64 RangeField,
65)
66from lsst.pipe.base import (
67 InMemoryDatasetHandle,
68 NoWorkFound,
69 PipelineTask,
70 PipelineTaskConfig,
71 PipelineTaskConnections,
72 Struct,
73)
74from lsst.pipe.base.connectionTypes import Input, Output
75from lsst.pipe.tasks.coaddBase import makeSkyInfo, removeMaskPlanes, setRejectedMaskMapping
76from lsst.pipe.tasks.healSparseMapping import HealSparseInputMapTask
77from lsst.pipe.tasks.interpImage import InterpImageTask
78from lsst.pipe.tasks.scaleZeroPoint import ScaleZeroPointTask
79from lsst.skymap import BaseSkyMap
82@dataclasses.dataclass
83class WarpInputs:
84 """Collection of associate inputs along with warps."""
86 warp: DeferredDatasetHandle | InMemoryDatasetHandle
87 """Handle for the warped exposure."""
89 masked_fraction: DeferredDatasetHandle | InMemoryDatasetHandle | None = None
90 """Handle for the masked fraction image."""
92 artifact_mask: DeferredDatasetHandle | InMemoryDatasetHandle | None = None
93 """Handle for the CompareWarp artifact mask."""
95 noise_warps: list[DeferredDatasetHandle | InMemoryDatasetHandle] = dataclasses.field(default_factory=list)
96 """List of handles for the noise warps"""
98 @property
99 def dataId(self) -> DataCoordinate:
100 """DataID corresponding to the warp.
102 Returns
103 -------
104 data_id : `~lsst.daf.butler.DataCoordinate`
105 DataID of the warp.
106 """
107 return self.warp.dataId
110class AssembleCellCoaddConnections(
111 PipelineTaskConnections,
112 dimensions=("tract", "patch", "band", "skymap"),
113 defaultTemplates={"inputWarpName": "deep", "outputCoaddSuffix": "Cell"},
114):
115 inputWarps = Input(
116 doc="Input warps",
117 name="{inputWarpName}Coadd_directWarp",
118 storageClass="ExposureF",
119 dimensions=("tract", "patch", "skymap", "visit", "instrument"),
120 deferLoad=True,
121 multiple=True,
122 )
124 maskedFractionWarps = Input(
125 doc="Mask fraction warps",
126 name="{inputWarpName}Coadd_directWarp_maskedFraction",
127 storageClass="ImageF",
128 dimensions=("tract", "patch", "skymap", "visit", "instrument"),
129 deferLoad=True,
130 multiple=True,
131 )
133 artifactMasks = Input(
134 doc="Artifact masks to be applied to the input warps",
135 name="compare_warp_artifact_mask",
136 storageClass="Mask",
137 dimensions=("tract", "patch", "skymap", "visit", "instrument"),
138 deferLoad=True,
139 multiple=True,
140 )
142 visitSummaryList = Input(
143 doc="Input visit-summary catalogs with updated calibration objects. Mainly used for coadd weights.",
144 name="finalVisitSummary",
145 storageClass="ExposureCatalog",
146 dimensions=("instrument", "visit"),
147 deferLoad=True,
148 multiple=True,
149 )
151 skyMap = Input(
152 doc="Input definition of geometry/bbox and projection/wcs. This must be cell-based.",
153 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
154 storageClass="SkyMap",
155 dimensions=("skymap",),
156 )
158 multipleCellCoadd = Output(
159 doc="Output multiple cell coadd",
160 name="{inputWarpName}Coadd{outputCoaddSuffix}",
161 storageClass="MultipleCellCoadd",
162 dimensions=("tract", "patch", "band", "skymap"),
163 )
165 inputMap = Output(
166 doc="Output healsparse map of input images",
167 name="{inputWarpName}Coadd_inputMap",
168 storageClass="HealSparseMap",
169 dimensions=("tract", "patch", "band", "skymap"),
170 )
172 config: AssembleCellCoaddConfig
174 def __init__(self, *, config: AssembleCellCoaddConfig | None = None):
175 super().__init__(config=config)
177 if not self.config:
178 return
180 if self.config.do_calculate_weight_from_warp:
181 del self.visitSummaryList
183 if not self.config.do_use_artifact_mask:
184 del self.artifactMasks
186 if not self.config.do_input_map:
187 del self.inputMap
189 # Dynamically set input connections for noise images, depending on the
190 # number of noise realizations specified in the config.
191 for n in range(self.config.num_noise_realizations):
192 noise_warps = Input(
193 doc="Input noise warps",
194 name=f"direct_warp_noise{n}",
195 storageClass="MaskedImageF",
196 dimensions=("tract", "patch", "skymap", "visit", "instrument"),
197 deferLoad=True,
198 multiple=True,
199 )
200 setattr(self, f"noise{n}_warps", noise_warps)
202 if self.config.output_image_type == "future":
203 self.multipleCellCoadd = dataclasses.replace(self.multipleCellCoadd, storageClass="CellCoadd")
206class AssembleCellCoaddConfig(PipelineTaskConfig, pipelineConnections=AssembleCellCoaddConnections):
207 do_interpolate_coadd = Field[bool](doc="Interpolate over pixels with NO_DATA mask set?", default=True)
208 interpolate_coadd = ConfigurableField(
209 target=InterpImageTask,
210 doc="Task to interpolate (and extrapolate) over pixels with NO_DATA mask on cell coadds",
211 )
212 do_scale_zero_point = Field[bool](
213 doc="Scale warps to a common zero point? This is not needed if they have absolute flux calibration.",
214 default=False,
215 deprecated="Now that visits are scaled to nJy it is no longer necessary or "
216 "recommended to scale the zero point, so this will be removed "
217 "after v29.",
218 )
219 scale_zero_point = ConfigurableField(
220 target=ScaleZeroPointTask,
221 doc="Task to scale warps to a common zero point",
222 deprecated="Now that visits are scaled to nJy it is no longer necessary or "
223 "recommended to scale the zero point, so this will be removed "
224 "after v29.",
225 )
226 do_calculate_weight_from_warp = Field[bool](
227 doc="Calculate coadd weight from the input warp? Otherwise, the weight is obtained from the "
228 "visitSummaryList connection. This is meant as a fallback when run outside the pipeline.",
229 default=False,
230 )
231 do_use_artifact_mask = Field[bool](
232 doc="Substitute the mask planes input warp with an alternative artifact mask?",
233 default=True,
234 )
235 do_coadd_inverse_aperture_corrections = Field[bool](
236 doc="Coadd the inverse aperture corrections for each cell? This is formally the more accurate way "
237 "but may be turned off for parity with deepCoadd.",
238 default=False,
239 )
240 min_overlap_fraction = RangeField[float](
241 doc="The minimum overlap fraction required for a single (visit, detector) input to be included in a "
242 "cell.",
243 # A value of 1.0 corresponds to ideal, edge-free cells.
244 # A value of 0.0 corresponds to the deep_coadd style coadds.
245 # This has to be at least 0.5 to ensure that the an input overlaps the
246 # cell center. Inputs will overlap fraction less than 0.25 will
247 # definitely not overlap the cell center.
248 default=1.0,
249 min=0.0,
250 max=1.0,
251 inclusiveMin=True,
252 inclusiveMax=True,
253 )
254 bad_mask_planes = ListField[str](
255 doc="Mask planes that count towards the masked fraction within a cell.",
256 default=("BAD", "NO_DATA", "SAT", "CLIPPED"),
257 )
258 remove_mask_planes = ListField[str](
259 doc="Mask planes to remove before coadding",
260 default=["EDGE", "NOT_DEBLENDED"],
261 )
262 calc_error_from_input_variance = Field[bool](
263 doc="Calculate coadd variance from input variance by stacking "
264 "statistic. Passed to AccumulatorMeanStack.",
265 default=True,
266 )
267 mask_propagation_thresholds = DictField[str, float](
268 doc=(
269 "Threshold (in fractional weight) of rejection at which we "
270 "propagate a mask plane to the coadd; that is, we set the mask "
271 "bit on the coadd if the fraction the rejected frames "
272 "would have contributed exceeds this value."
273 ),
274 default={"SAT": 0.1},
275 )
276 max_maskfrac = RangeField[float](
277 doc="Maximum fraction of masked pixels in a cell for a given warp. "
278 "Warps exceeding this threshold are excluded from the science coadd, "
279 "PSF, aperture corrections, and input maps.",
280 default=0.5,
281 min=0.0,
282 max=1.0,
283 inclusiveMin=True,
284 inclusiveMax=False,
285 )
286 num_noise_realizations = Field[int](
287 default=0,
288 doc=(
289 "Number of noise planes to include in the coadd. "
290 "This should not exceed the corresponding config parameter "
291 "specified in `MakeDirectWarpConfig`. "
292 ),
293 check=lambda x: x >= 0,
294 )
295 psf_warper = ConfigField(
296 doc="Configuration for the warper that warps the PSFs. It must have the same configuration used to "
297 "warp the images.",
298 dtype=afwMath.Warper.ConfigClass,
299 )
300 psf_dimensions = Field[int](
301 default=35,
302 doc="Dimensions of the PSF image stamp size to be assigned to cells (must be odd).",
303 check=lambda x: (x > 0) and (x % 2 == 1),
304 )
305 require_artifact_mask = Field[bool](
306 default=True,
307 doc="Require presence of artifact mask for each warp? Use true if using artifact rejection outputs"
308 " from CompareWarpTask",
309 )
310 do_input_map = Field[bool](
311 default=False,
312 doc="Create a bitwise map of coadd inputs.",
313 )
314 input_mapper = ConfigurableField(
315 target=HealSparseInputMapTask,
316 doc="Input map creation subtask.",
317 )
318 output_image_type = ChoiceField[str](
319 "Which image type to use for the output coadd.",
320 allowed={
321 "legacy": "Write as a lsst.cell_Coadds.MultipleCellCoadd.",
322 "future": "Write as a lsst.images.cells.CellCoadd.",
323 },
324 optional=False,
325 default="legacy",
326 )
329class AssembleCellCoaddTask(PipelineTask):
330 """Assemble a cell-based coadded image from a set of warps.
332 This task reads in the warp one at a time, and accumulates it in all the
333 cells that it completely overlaps with. This is the optimal I/O pattern but
334 this also implies that it is not possible to build one or only a few cells.
336 Each cell coadds is guaranteed to have a well-defined PSF. This is done by
337 1) excluding warps that only partially overlap a cell from that cell coadd;
338 2) interpolating bad pixels in the warps rather than excluding them;
339 3) by computing the coadd as a weighted mean of the warps without clipping;
340 4) by computing the coadd PSF as the weighted mean of the PSF of the warps
341 with the same weights.
343 The cells are (and must be) defined in the skymap, and cannot be configured
344 or redefined here. The cells are assumed to be small enough that the PSF is
345 assumed to be spatially constant within a cell.
347 Raises
348 ------
349 NoWorkFound
350 Raised if no input warps are provided, or no cells could be populated.
351 RuntimeError
352 Raised if the skymap is not cell-based.
354 Notes
355 -----
356 This is not yet a part of the standard DRP pipeline. As such, the Task and
357 especially its Config and Connections are experimental and subject to
358 change any time without a formal RFC or standard deprecation procedures
359 until it is included in the DRP pipeline.
360 """
362 ConfigClass = AssembleCellCoaddConfig
363 _DefaultName = "assembleCellCoadd"
365 def __init__(self, *args, **kwargs):
366 super().__init__(*args, **kwargs)
367 if self.config.do_interpolate_coadd: 367 ↛ 369line 367 didn't jump to line 369 because the condition on line 367 was always true
368 self.makeSubtask("interpolate_coadd")
369 if self.config.do_scale_zero_point:
370 self.makeSubtask("scale_zero_point")
371 if self.config.do_input_map: 371 ↛ 374line 371 didn't jump to line 374 because the condition on line 371 was always true
372 self.makeSubtask("input_mapper")
374 self.psf_warper = afwMath.Warper.fromConfig(self.config.psf_warper)
375 if (warping_kernel_name := self.config.psf_warper.warpingKernelName.lower()).startswith("lanczos"): 375 ↛ 383line 375 didn't jump to line 383 because the condition on line 375 was always true
376 psf_padding = 2 * int(warping_kernel_name.lstrip("lanczos")) - 1
377 self.log.debug(
378 "Padding PSF image by %d pixels since the warping kernel is %s.",
379 psf_padding,
380 self.config.psf_warper.warpingKernelName,
381 )
382 else:
383 psf_padding = 10
384 self.log.info(
385 "Padding PSF image by %d pixels since the warping kernel is not Lanczos.",
386 psf_padding,
387 )
388 self.psf_padding = psf_padding
390 def runQuantum(self, butlerQC, inputRefs, outputRefs):
391 # Docstring inherited.
392 if not inputRefs.inputWarps:
393 raise NoWorkFound("No input warps provided for co-addition")
394 self.log.info("Found %d input warps", len(inputRefs.inputWarps))
396 # Construct skyInfo expected by run
397 # Do not remove skyMap from inputData in case _makeSupplementaryData
398 # needs it
399 skyMap = butlerQC.get(inputRefs.skyMap)
401 if not skyMap.config.tractBuilder.name == "cells":
402 raise RuntimeError("AssembleCellCoaddTask requires a cell-based skymap.")
404 outputDataId = butlerQC.quantum.dataId
406 skyInfo = makeSkyInfo(skyMap, tractId=outputDataId["tract"], patchId=outputDataId["patch"])
407 visitSummaryList = butlerQC.get(getattr(inputRefs, "visitSummaryList", []))
409 units = CoaddUnits.legacy if self.config.do_scale_zero_point else CoaddUnits.nJy
410 self.common = CommonComponents(
411 units=units,
412 wcs=skyInfo.patchInfo.wcs,
413 band=outputDataId.get("band", None),
414 identifiers=PatchIdentifiers.from_data_id(outputDataId),
415 )
417 inputs: dict[DataCoordinate, WarpInputs] = {}
418 for handle in butlerQC.get(inputRefs.inputWarps):
419 inputs[handle.dataId] = WarpInputs(warp=handle, noise_warps=[])
421 for ref in getattr(inputRefs, "artifactMasks", []):
422 inputs[ref.dataId].artifact_mask = butlerQC.get(ref)
423 for ref in getattr(inputRefs, "maskedFractionWarps", []):
424 inputs[ref.dataId].masked_fraction = butlerQC.get(ref)
425 for n in range(self.config.num_noise_realizations):
426 for ref in getattr(inputRefs, f"noise{n}_warps"):
427 inputs[ref.dataId].noise_warps.append(butlerQC.get(ref))
429 returnStruct = self.run(inputs=inputs, skyInfo=skyInfo, visitSummaryList=visitSummaryList)
430 butlerQC.put(returnStruct, outputRefs)
431 return returnStruct
433 @staticmethod
434 def _compute_weight(maskedImage, statsCtrl):
435 """Compute a weight for a masked image.
437 Parameters
438 ----------
439 maskedImage : `~lsst.afw.image.MaskedImage`
440 The masked image to compute the weight.
441 statsCtrl : `~lsst.afw.math.StatisticsControl`
442 A control (config-like) object for StatisticsStack.
444 Returns
445 -------
446 weight : `float`
447 Inverse of the clipped mean variance of the masked image.
448 """
449 statObj = afwMath.makeStatistics(
450 maskedImage.getVariance(), maskedImage.getMask(), afwMath.MEANCLIP, statsCtrl
451 )
452 meanVar, _ = statObj.getResult(afwMath.MEANCLIP)
453 weight = 1.0 / float(meanVar)
454 return weight
456 @staticmethod
457 def _construct_grid(skyInfo):
458 """Construct a UniformGrid object from a SkyInfo struct.
460 Parameters
461 ----------
462 skyInfo : `~lsst.pipe.base.Struct`
463 A Struct object
465 Returns
466 -------
467 grid : `~lsst.cell_coadds.UniformGrid`
468 A UniformGrid object.
469 """
470 padding = skyInfo.patchInfo.getCellBorder()
471 grid_bbox = skyInfo.patchInfo.outer_bbox.erodedBy(padding)
472 grid = UniformGrid.from_bbox_cell_size(
473 grid_bbox,
474 skyInfo.patchInfo.getCellInnerDimensions(),
475 padding=padding,
476 )
477 return grid
479 def _construct_grid_container(self, skyInfo, statsCtrl):
480 """Construct a grid of AccumulatorMeanStack instances.
482 Parameters
483 ----------
484 skyInfo : `~lsst.pipe.base.Struct`
485 A Struct object
486 statsCtrl : `~lsst.afw.math.StatisticsControl`
487 A control (config-like) object for StatisticsStack.
489 Returns
490 -------
491 gc : `~lsst.cell_coadds.GridContainer`
492 A GridContainer object container one AccumulatorMeanStack per cell.
493 """
494 grid = self._construct_grid(skyInfo)
496 maskMap = setRejectedMaskMapping(statsCtrl)
497 self.log.debug("Obtained maskMap = %s for %s", maskMap, skyInfo.patchInfo)
498 thresholdDict = AccumulatorMeanStack.stats_ctrl_to_threshold_dict(statsCtrl)
500 # Initialize the grid container with AccumulatorMeanStacks
501 gc = GridContainer[AccumulatorMeanStack](grid.shape)
502 for cellInfo in skyInfo.patchInfo:
503 stacker = AccumulatorMeanStack(
504 # The shape is for the numpy arrays, hence transposed.
505 shape=(cellInfo.outer_bbox.height, cellInfo.outer_bbox.width),
506 bit_mask_value=statsCtrl.getAndMask(),
507 mask_threshold_dict=thresholdDict,
508 calc_error_from_input_variance=self.config.calc_error_from_input_variance,
509 compute_n_image=False,
510 mask_map=maskMap,
511 no_good_pixels_mask=statsCtrl.getNoGoodPixelsMask(),
512 )
513 gc[cellInfo.index] = stacker
515 return gc
517 def _construct_stats_control(self):
518 """Construct a StatisticsControl object for coadd.
520 Unlike AssembleCoaddTask or CompareWarpAssembleCoaddTask, there is
521 very little to be configured apart from setting the mask planes and
522 optionally mask propagation thresholds.
524 Returns
525 -------
526 statsCtrl : `~lsst.afw.math.StatisticsControl`
527 A control object for StatisticsStack.
528 """
529 statsCtrl = afwMath.StatisticsControl()
530 # Hardcode the numIter parameter to the default config value set in
531 # CompareWarpAssembleCoaddTask to get consistent weights. This is NOT
532 # exposed as a config parameter, since this is only meant to be a
533 # fallback option that is not recommended for production.
534 statsCtrl.setNumIter(2)
535 statsCtrl.setAndMask(afwImage.Mask.getPlaneBitMask(self.config.bad_mask_planes))
536 statsCtrl.setNanSafe(True)
537 for plane, threshold in self.config.mask_propagation_thresholds.items():
538 bit = afwImage.Mask.getMaskPlane(plane)
539 statsCtrl.setMaskPropagationThreshold(bit, threshold)
540 return statsCtrl
542 def _construct_ap_corr_grid_container(self, skyInfo):
543 """Construct a grid of CoaddApCorrMapStacker instances.
545 Parameters
546 ----------
547 skyInfo : `~lsst.pipe.base.Struct`
548 A Struct object
550 Returns
551 -------
552 gc : `~lsst.cell_coadds.GridContainer`
553 A GridContainer object container one CoaddApCorrMapStacker per
554 cell.
555 """
556 grid = self._construct_grid(skyInfo)
558 # Initialize the grid container with CoaddApCorrMapStacker.
559 gc = GridContainer[CoaddApCorrMapStacker](grid.shape)
560 for cellInfo in skyInfo.patchInfo:
561 stacker = CoaddApCorrMapStacker(
562 evaluation_point=cellInfo.inner_bbox.getCenter(),
563 do_coadd_inverse_ap_corr=self.config.do_coadd_inverse_aperture_corrections,
564 )
565 gc[cellInfo.index] = stacker
567 return gc
569 def run(
570 self,
571 *,
572 inputs: dict[DataCoordinate, WarpInputs],
573 skyInfo,
574 visitSummaryList: list | None = None,
575 ):
576 for mask_plane in self.config.bad_mask_planes:
577 afwImage.Mask.addMaskPlane(mask_plane)
578 for mask_plane in self.config.mask_propagation_thresholds:
579 afwImage.Mask.addMaskPlane(mask_plane)
581 statsCtrl = self._construct_stats_control()
583 warp_stacker_gc = self._construct_grid_container(skyInfo, statsCtrl)
584 maskfrac_stacker_gc = self._construct_grid_container(skyInfo, statsCtrl)
585 noise_stacker_gc_list = [
586 self._construct_grid_container(skyInfo, statsCtrl)
587 for n in range(self.config.num_noise_realizations)
588 ]
589 psf_stacker_gc = GridContainer[AccumulatorMeanStack](warp_stacker_gc.shape)
590 psf_bbox_gc = GridContainer[geom.Box2I](warp_stacker_gc.shape)
591 ap_corr_stacker_gc = self._construct_ap_corr_grid_container(skyInfo)
593 # A cell is in "fallback" mode if it does not yet have any warps that
594 # pass the per-detector cuts; in that mode, we accumulate warps
595 # regardless of that cut, but clear the accumulators and start over if
596 # we later see data that does pass the per-detector cuts.
597 is_fallback_gc = GridContainer[bool](warp_stacker_gc.shape)
599 # We accumulate the information to pass to the Healsparse input-map
600 # accumulator instead of calling it directly, so we can do that only
601 # after we've accumulated all warps and hence know which cells will
602 # stay in fallback mode.
603 input_map_data_gc = GridContainer[list](warp_stacker_gc.shape)
605 # Make a container to hold the cell centers in sky coordinates now,
606 # so we don't have to recompute them for each warp
607 # (they share a common WCS). These are needed to find the various
608 # warp + detector combinations that contributed to each cell, and later
609 # get the corresponding PSFs as well.
610 cell_centers_sky = GridContainer[geom.SpherePoint](warp_stacker_gc.shape)
611 # Make a container to hold the observation identifiers for each cell.
612 observation_identifiers_gc = GridContainer[dict](warp_stacker_gc.shape)
614 if self.config.do_input_map: 614 ↛ 629line 614 didn't jump to line 629 because the condition on line 614 was always true
615 # We need to know all the visit + detector pairs in the inputs.
616 warp_input_list = [warp_ref.warp.get(component="coaddInputs") for warp_ref in inputs.values()]
617 visit_detectors = []
618 for warp_input in warp_input_list:
619 for row in warp_input.ccds:
620 visit_detectors.append((int(row["visit"]), int(row["ccd"])))
622 self.input_mapper.initialize_cell_input_map(
623 skyInfo.patchInfo.getOuterBBox(),
624 skyInfo.patchInfo.wcs,
625 visit_detectors,
626 )
628 # Populate them.
629 for cellInfo in skyInfo.patchInfo:
630 # Make a list to hold the observation identifiers for each cell.
631 observation_identifiers_gc[cellInfo.index] = {}
632 cell_center_pixel = geom.Point2D(geom.Point2I(cellInfo.inner_bbox.getCenter()))
633 cell_centers_sky[cellInfo.index] = skyInfo.wcs.pixelToSky(cell_center_pixel)
634 psf_bbox_gc[cellInfo.index] = geom.Box2I.makeCenteredBox(
635 cell_center_pixel,
636 geom.Extent2I(self.config.psf_dimensions, self.config.psf_dimensions),
637 )
638 psf_stacker_gc[cellInfo.index] = AccumulatorMeanStack(
639 # The shape is for the numpy arrays, hence transposed.
640 shape=(self.config.psf_dimensions, self.config.psf_dimensions),
641 bit_mask_value=0,
642 calc_error_from_input_variance=self.config.calc_error_from_input_variance,
643 compute_n_image=False,
644 )
645 is_fallback_gc[cellInfo.index] = True
646 input_map_data_gc[cellInfo.index] = []
648 # visit_summary do not have (tract, patch, band, skymap) dimensions.
649 if not visitSummaryList:
650 visitSummaryList = []
651 visitSummaryRefDict = {
652 visitSummaryRef.dataId["visit"]: visitSummaryRef for visitSummaryRef in visitSummaryList
653 }
655 # Keep track of the polygons corresponding to each (visit, detector).
656 visit_polygons: dict[ObservationIdentifiers, afwGeom.Polygon] = {}
658 # Read in one warp at a time, and accumulate it in all the cells that
659 # it completely overlaps.
660 for warp_input in inputs.values():
661 # warps that have been excluded from CompareWarp via visit
662 # selection from SelectVisitsTasks will not have artifact masks.
663 # Exclude them from the cell coadds too.
664 if self.config.require_artifact_mask and warp_input.artifact_mask is None: 664 ↛ 665line 664 didn't jump to line 665 because the condition on line 664 was never true
665 self.log.info(
666 "Excluding warp %s from cell coadds because it has no artifact mask",
667 warp_input.dataId["visit"],
668 )
669 continue
671 warp = warp_input.warp.get(parameters={"bbox": skyInfo.bbox})
672 masked_fraction_image = (
673 warp_input.masked_fraction.get(parameters={"bbox": skyInfo.bbox})
674 if warp_input.masked_fraction
675 else None
676 )
678 # Pre-process the warp before coadding.
679 # TODO: Can we get these mask names from artifactMask?
680 warp.mask.addMaskPlane("CLIPPED")
681 warp.mask.addMaskPlane("REJECTED")
682 warp.mask.addMaskPlane("SENSOR_EDGE")
683 warp.mask.addMaskPlane("INEXACT_PSF")
685 if artifact_mask_ref := warp_input.artifact_mask: 685 ↛ 687line 685 didn't jump to line 687 because the condition on line 685 was never true
686 # Apply the artifact mask to the warp.
687 artifact_mask = artifact_mask_ref.get()
688 assert (
689 warp.mask.getMaskPlaneDict() == artifact_mask.getMaskPlaneDict()
690 ), "Mask dicts do not agree."
691 warp.mask.array = artifact_mask.array
692 del artifact_mask
694 if self.config.do_scale_zero_point:
695 # Each Warp that goes into a coadd will typically have an
696 # independent photometric zero-point. Therefore, we must scale
697 # each Warp to set it to a common photometric zeropoint.
698 imageScaler = self.scale_zero_point.run(exposure=warp, dataRef=warp_input.warp).imageScaler
699 zero_point_scale_factor = imageScaler.scale
700 self.log.debug(
701 "Scaled the warp %s by %f to match zero points",
702 warp_input.dataId,
703 zero_point_scale_factor,
704 )
705 else:
706 zero_point_scale_factor = 1.0
707 if "BUNIT" not in warp.metadata: 707 ↛ 708line 707 didn't jump to line 708 because the condition on line 707 was never true
708 raise ValueError(f"Warp {warp_input.dataId} has no BUNIT metadata")
709 if warp.metadata["BUNIT"] != "nJy": 709 ↛ 710line 709 didn't jump to line 710 because the condition on line 709 was never true
710 raise ValueError(
711 f"Warp {warp_input.dataId} has BUNIT {warp.metadata['BUNIT']}, expected nJy"
712 )
714 # Only try to remove maks planes that have been registered.
715 to_remove = []
716 for plane in self.config.remove_mask_planes:
717 if plane in warp.mask.getMaskPlaneDict():
718 to_remove.append(plane)
719 removeMaskPlanes(warp.mask, to_remove, self.log)
720 # Instead of using self.config.bad_mask_planes, we explicitly
721 # ask statsCtrl which pixels are going to be ignored/rejected.
722 rejected = afwImage.Mask.getPlaneBitMask(
723 ["CLIPPED", "REJECTED"] + afwImage.Mask.interpret(statsCtrl.getAndMask()).split(",")
724 )
726 # Compute the weight for each CCD in the warp from the visitSummary
727 # or from the warp itself, if not provided. Computing the weight
728 # from the warp is not recommended, and in that case we compute one
729 # weight per warp and not bother with per-detector weights.
730 full_ccd_table = warp.getInfo().getCoaddInputs().ccds
731 weights: dict[int, float] = dict.fromkeys(
732 full_ccd_table["ccd"].tolist(),
733 0.0,
734 ) # Mapping from detector to weight.
736 if visitSummaryRef := visitSummaryRefDict.get(warp_input.dataId["visit"]):
737 visitSummary = visitSummaryRef.get()
738 for detector in full_ccd_table["ccd"].tolist():
739 visitSummaryRow = visitSummary.find(detector)
740 mean_variance = visitSummaryRow["meanVar"]
741 mean_variance *= zero_point_scale_factor**2
742 if warp.metadata.get("BUNIT", None) == "nJy": 742 ↛ 744line 742 didn't jump to line 744 because the condition on line 742 was always true
743 mean_variance *= visitSummaryRow.photoCalib.getCalibrationMean() ** 2
744 weights[detector] = 1.0 / mean_variance
745 del visitSummary
746 else:
747 self.log.debug("No visit summary found for %s; using warp-based weights", warp_input.dataId)
748 weight = self._compute_weight(warp, statsCtrl)
749 if not np.isfinite(weight): 749 ↛ 750line 749 didn't jump to line 750 because the condition on line 749 was never true
750 self.log.warning("Non-finite weight for %s: skipping", warp_input.dataId)
751 continue
753 for detector in weights:
754 weights[detector] = weight
756 noise_warps = [ref.get(parameters={"bbox": skyInfo.bbox}) for ref in warp_input.noise_warps]
758 # Create an image where each pixel value corresponds to the
759 # detector ID that pixel comes from.
760 detector_map = afwImage.ImageI(bbox=warp.getBBox(), initialValue=-1)
761 for row in full_ccd_table:
762 transform = makeWcsPairTransform(row.wcs, warp.wcs)
763 if (src_polygon := row.validPolygon) is None: 763 ↛ 764line 763 didn't jump to line 764 because the condition on line 763 was never true
764 src_polygon = afwGeom.Polygon(geom.Box2D(row.getBBox()))
765 try:
766 dest_polygon = src_polygon.transform(transform).intersectionSingle(
767 geom.Box2D(warp.getBBox())
768 )
769 except SinglePolygonException:
770 continue
772 observation_identifier = ObservationIdentifiers.from_data_id(
773 warp_input.dataId,
774 backup_detector=row["ccd"],
775 )
776 visit_polygons[observation_identifier] = dest_polygon
778 detector_map_slice = dest_polygon.createImage(detector_map.getBBox()).array > 0
779 if not (detector_map.array[detector_map_slice] < 0).all(): 779 ↛ 780line 779 didn't jump to line 780 because the condition on line 779 was never true
780 self.log.warning("Multiple detectors from visit %s are overlapping", warp_input.dataId)
781 detector_map.array[detector_map_slice] = row["ccd"]
783 if (detector_map.array < 0).all(): 783 ↛ 784line 783 didn't jump to line 784 because the condition on line 783 was never true
784 self.log.warning("Unable to split the warp %s into single-detector warps.", warp_input.dataId)
785 detector_map.array[:, :] = 0
787 for cellInfo, ccd_row in itertools.product(skyInfo.patchInfo, full_ccd_table):
788 bbox = cellInfo.outer_bbox
789 inner_bbox = cellInfo.inner_bbox
791 overlap_fraction = (detector_map[inner_bbox].array == ccd_row["ccd"]).mean()
792 assert -1e-4 < overlap_fraction < 1.0001, "Overlap fraction is not within [0, 1]."
793 if (overlap_fraction < self.config.min_overlap_fraction) or (overlap_fraction <= 0.0): 793 ↛ 794line 793 didn't jump to line 794 because the condition on line 793 was never true
794 self.log.debug(
795 "Skipping %s in cell %s because it had only %.3f < %.3f fractional overlap.",
796 warp_input.dataId,
797 cellInfo.index,
798 overlap_fraction,
799 self.config.min_overlap_fraction,
800 )
801 continue
803 weight = weights[int(ccd_row["ccd"])]
804 if not np.isfinite(weight): 804 ↛ 805line 804 didn't jump to line 805 because the condition on line 804 was never true
805 self.log.warning(
806 "Non-finite weight for %s in cell %s: skipping", warp_input.dataId, cellInfo.index
807 )
808 continue
810 if weight == 0: 810 ↛ 811line 810 didn't jump to line 811 because the condition on line 810 was never true
811 self.log.info(
812 "Zero weight for %s in cell %s: skipping", warp_input.dataId, cellInfo.index
813 )
814 continue
816 # Compute the unmasked fraction for this detector in the inner
817 # cell. Used to gate on max_maskfrac.
818 inner_detector_pixels = detector_map[inner_bbox].array == ccd_row["ccd"]
819 inner_unmasked_pixels = (warp[inner_bbox].mask.array & rejected) == 0
820 unmasked_fraction = (
821 inner_detector_pixels & inner_unmasked_pixels
822 ).sum() / inner_detector_pixels.sum()
823 is_fallback = is_fallback_gc[cellInfo.index]
824 if unmasked_fraction <= max(1.0 - self.config.max_maskfrac, 0.0): 824 ↛ 825line 824 didn't jump to line 825 because the condition on line 824 was never true
825 if not is_fallback:
826 # We already have good data in this cell, so we don't
827 # want this heavily masked warp - it will add too much
828 # INEXACT_PSF.
829 self.log.debug(
830 "Skipping %s in cell %s: masked fraction %.3f exceeds threshold %.3f",
831 warp_input.dataId,
832 cellInfo.index,
833 1.0 - unmasked_fraction,
834 self.config.max_maskfrac,
835 )
836 continue
837 else:
838 self.log.debug(
839 "Including %s in cell %s only as potential fallback: "
840 "masked fraction %.3f exceeds threshold %.3f",
841 warp_input.dataId,
842 cellInfo.index,
843 1.0 - unmasked_fraction,
844 self.config.max_maskfrac,
845 )
846 elif is_fallback:
847 # This is the first good data we've gotten for this cell;
848 # wipe out the fallback coadd we've been accumulating so
849 # far, so we can start fresh.
850 warp_stacker_gc[cellInfo.index].reset()
851 maskfrac_stacker_gc[cellInfo.index].reset()
852 for n in range(self.config.num_noise_realizations): 852 ↛ 853line 852 didn't jump to line 853 because the loop on line 852 never started
853 noise_stacker_gc_list[n][cellInfo.index].reset()
854 psf_stacker_gc[cellInfo.index].reset()
855 ap_corr_stacker_gc[cellInfo.index].reset()
856 observation_identifiers_gc[cellInfo.index].clear()
857 input_map_data_gc[cellInfo.index].clear()
858 is_fallback_gc[cellInfo.index] = False
860 overlaps_center = detector_map[geom.Point2I(bbox.getCenter())] == ccd_row["ccd"]
861 if not overlaps_center: 861 ↛ 862line 861 didn't jump to line 862 because the condition on line 861 was never true
862 self.log.debug(
863 "%s does not overlap with the center of the cell %s",
864 warp_input.dataId,
865 cellInfo.index,
866 )
867 continue
869 # Decide if a deep copy is necessary to apply the single
870 # detector cuts since it involves modifying the image in-place.
871 # If within the inner cell, there are three or more different
872 # values that detector map takes, then there are definitely
873 # multiple detectors (one for chip gaps, two for two detectors)
874 deep_copy = len(set(detector_map[inner_bbox].array.ravel())) >= 3
875 if deep_copy: 875 ↛ 876line 875 didn't jump to line 876 because the condition on line 875 was never true
876 single_detector_mask_array = detector_map[bbox].array != ccd_row["ccd"]
878 mi = afwImage.MaskedImageF(warp[bbox].maskedImage, deep=deep_copy)
879 if deep_copy: 879 ↛ 880line 879 didn't jump to line 880 because the condition on line 879 was never true
880 mi.image.array[single_detector_mask_array] = 0.0
881 mi.variance.array[single_detector_mask_array] = np.inf
882 nodata_or_mask = (single_detector_mask_array) * afwImage.Mask.getPlaneBitMask("NO_DATA")
883 mi.mask[bbox].array |= nodata_or_mask
884 warp_stacker_gc[cellInfo.index].add_masked_image(mi, weight=weight)
886 if masked_fraction_image: 886 ↛ 893line 886 didn't jump to line 893 because the condition on line 886 was always true
887 mi = afwImage.ImageF(masked_fraction_image[bbox], deep=True)
888 if deep_copy: 888 ↛ 889line 888 didn't jump to line 889 because the condition on line 888 was never true
889 mi.array[single_detector_mask_array] = 0.0
890 mi.array[(warp[bbox].mask.array & rejected) != 0] = 1.0
891 maskfrac_stacker_gc[cellInfo.index].add_image(mi, weight=weight)
893 for n in range(self.config.num_noise_realizations): 893 ↛ 894line 893 didn't jump to line 894 because the loop on line 893 never started
894 mi = afwImage.MaskedImageF(noise_warps[n][bbox], deep=deep_copy)
895 if deep_copy:
896 mi.image.array[single_detector_mask_array] = 0.0
897 mi.variance.array[single_detector_mask_array] = np.inf
898 mi.mask[bbox].array |= nodata_or_mask
899 noise_stacker_gc_list[n][cellInfo.index].add_masked_image(mi, weight=weight)
901 # Set the defaults for PSF shape quantities.
902 psf_shape = afwGeom.Quadrupole()
903 psf_shape_flag = True
904 psf_eval_point = None
905 try:
906 # The `if` branch is buggy. `dest_polygon` is technically
907 # out of scope, but Python does not raise an error.
908 # TODO: Fix this properly in DM-53479, but sweep it under
909 # the rug for now.
910 if overlap_fraction < 0.5: 910 ↛ 911line 910 didn't jump to line 911 because the condition on line 910 was never true
911 psf_eval_point = dest_polygon.intersectionSingle(
912 geom.Box2D(inner_bbox)
913 ).calculateCenter()
914 else:
915 psf_eval_point = geom.Point2D(geom.Point2I(inner_bbox.getCenter()))
916 psf_shape = warp.psf.computeShape(psf_eval_point)
917 psf_shape_flag = False
918 except SinglePolygonException:
919 self.log.info(
920 "Unable to find the overlapping polygon between %d detector in %s and cell %s",
921 ccd_row["ccd"],
922 warp_input.dataId,
923 cellInfo.index,
924 )
925 except InvalidPsfError:
926 self.log.info(
927 "Unable to compute PSF shape from %d detector in %s at %s",
928 ccd_row["ccd"],
929 warp_input.dataId,
930 psf_eval_point,
931 )
933 observation_identifier = ObservationIdentifiers.from_data_id(
934 warp_input.dataId,
935 backup_detector=int(ccd_row["ccd"]),
936 )
937 observation_identifiers_gc[cellInfo.index][observation_identifier] = CoaddInputs(
938 overlaps_center=overlaps_center,
939 overlap_fraction=overlap_fraction,
940 unmasked_overlap_fraction=unmasked_fraction,
941 weight=weight,
942 psf_shape=psf_shape,
943 psf_shape_flag=psf_shape_flag,
944 )
945 input_map_data_gc[cellInfo.index].append((ccd_row, weight))
947 # Everything below this has to do with the center of the cell
948 calexp_point = ccd_row.getWcs().skyToPixel(cell_centers_sky[cellInfo.index])
949 undistorted_psf_im = ccd_row.getPsf().computeImage(calexp_point)
951 assert undistorted_psf_im.getBBox() == geom.Box2I.makeCenteredBox(
952 calexp_point,
953 undistorted_psf_im.getDimensions(),
954 ), "PSF image does not share the coordinates of the 'calexp'"
956 # Convert the PSF image from Image to MaskedImage and
957 # zero-pad the image.
958 undistorted_psf_bbox = undistorted_psf_im.getBBox()
959 undistorted_psf_maskedImage = afwImage.MaskedImageD(
960 undistorted_psf_bbox.dilatedBy(self.psf_padding)
961 )
962 undistorted_psf_maskedImage.image[undistorted_psf_bbox].array[:, :] = undistorted_psf_im.array
963 # TODO: In DM-43585, use the variance plane value from noise.
964 undistorted_psf_maskedImage.variance += 1.0 # Set variance to 1
966 warped_psf_maskedImage = self.psf_warper.warpImage(
967 destWcs=skyInfo.wcs,
968 srcImage=undistorted_psf_maskedImage,
969 srcWcs=ccd_row.getWcs(),
970 destBBox=psf_bbox_gc[cellInfo.index],
971 )
973 # There may be NaNs in the PSF image. Set them to 0.0
974 warped_psf_maskedImage.variance.array[np.isnan(warped_psf_maskedImage.image.array)] = 1.0
975 warped_psf_maskedImage.image.array[np.isnan(warped_psf_maskedImage.image.array)] = 0.0
977 psf_stacker = psf_stacker_gc[cellInfo.index]
978 psf_stacker.add_masked_image(warped_psf_maskedImage, weight=weight)
980 if not (0.995 < (psf_normalization := warped_psf_maskedImage.image.array.sum()) < 1.005): 980 ↛ 981line 980 didn't jump to line 981 because the condition on line 980 was never true
981 self.log.warning(
982 "PSF image for %s in %s is not normalized to 1.0, but instead %f",
983 warp_input.dataId,
984 cellInfo.index,
985 psf_normalization,
986 )
988 if (ap_corr_map := warp.getInfo().getApCorrMap()) is not None: 988 ↛ 787line 988 didn't jump to line 787 because the condition on line 988 was always true
989 ap_corr_stacker_gc[cellInfo.index].add(ap_corr_map, weight=weight)
991 del warp
993 # Update common with the visit polygons.
994 self.common = dataclasses.replace(
995 self.common,
996 visit_polygons=visit_polygons,
997 )
999 cells: list[SingleCellCoadd] = []
1000 for cellInfo in skyInfo.patchInfo:
1001 if len(observation_identifiers_gc[cellInfo.index]) == 0:
1002 self.log.debug("Skipping cell %s because it has no input warps", cellInfo.index)
1003 continue
1005 cell_masked_image = afwImage.MaskedImageF(cellInfo.outer_bbox)
1006 cell_maskfrac_image = afwImage.ImageF(cellInfo.outer_bbox)
1007 cell_noise_images = [
1008 afwImage.MaskedImageF(cellInfo.outer_bbox) for n in range(self.config.num_noise_realizations)
1009 ]
1010 psf_masked_image = afwImage.MaskedImageF(psf_bbox_gc[cellInfo.index])
1012 warp_stacker_gc[cellInfo.index].fill_stacked_masked_image(cell_masked_image)
1013 maskfrac_stacker_gc[cellInfo.index].fill_stacked_image(cell_maskfrac_image)
1014 for n in range(self.config.num_noise_realizations): 1014 ↛ 1015line 1014 didn't jump to line 1015 because the loop on line 1014 never started
1015 noise_stacker_gc_list[n][cellInfo.index].fill_stacked_masked_image(cell_noise_images[n])
1016 psf_stacker_gc[cellInfo.index].fill_stacked_masked_image(psf_masked_image)
1018 if ap_corr_stacker_gc[cellInfo.index].ap_corr_names: 1018 ↛ 1021line 1018 didn't jump to line 1021 because the condition on line 1018 was always true
1019 ap_corr_map = ap_corr_stacker_gc[cellInfo.index].final_ap_corr_map
1020 else:
1021 ap_corr_map = None
1023 # Post-process the coadd before converting to new data structures.
1024 if np.isnan(cell_masked_image.image.array).all(): 1024 ↛ 1025line 1024 didn't jump to line 1025 because the condition on line 1024 was never true
1025 cell_masked_image.image.array[:, :] = 0.0
1026 cell_masked_image.variance.array[:, :] = np.inf
1027 elif self.config.do_interpolate_coadd: 1027 ↛ 1036line 1027 didn't jump to line 1036 because the condition on line 1027 was always true
1028 self.interpolate_coadd.run(cell_masked_image, planeName="NO_DATA")
1029 for noise_image in cell_noise_images: 1029 ↛ 1030line 1029 didn't jump to line 1030 because the loop on line 1029 never started
1030 self.interpolate_coadd.run(noise_image, planeName="NO_DATA")
1031 # The variance must be positive; work around for DM-3201.
1032 varArray = cell_masked_image.variance.array
1033 with np.errstate(invalid="ignore"):
1034 varArray[:] = np.where(varArray > 0, varArray, np.inf)
1036 afwImage.Mask.addMaskPlane("INEXACT_PSF")
1037 cell_masked_image.mask.array[
1038 (cell_masked_image.mask.array & rejected) > 0
1039 ] |= cell_masked_image.mask.getPlaneBitMask("INEXACT_PSF")
1041 if self.config.do_input_map: 1041 ↛ 1046line 1041 didn't jump to line 1046 because the condition on line 1041 was always true
1042 self.input_mapper.build_cell_input_map(cellInfo)
1043 for ccd_row, weight in input_map_data_gc[cellInfo.index]:
1044 self.input_mapper.add_warp_to_cell_input_map(ccd_row, weight, cellInfo)
1046 image_planes = OwnedImagePlanes.from_masked_image(
1047 masked_image=cell_masked_image,
1048 mask_fractions=cell_maskfrac_image,
1049 noise_realizations=[noise_image.image for noise_image in cell_noise_images],
1050 )
1051 identifiers = CellIdentifiers(
1052 cell=cellInfo.index,
1053 skymap=self.common.identifiers.skymap,
1054 tract=self.common.identifiers.tract,
1055 patch=self.common.identifiers.patch,
1056 band=self.common.identifiers.band,
1057 )
1059 singleCellCoadd = SingleCellCoadd(
1060 outer=image_planes,
1061 psf=psf_masked_image.image,
1062 inner_bbox=cellInfo.inner_bbox,
1063 inputs=observation_identifiers_gc[cellInfo.index],
1064 common=self.common,
1065 identifiers=identifiers,
1066 aperture_correction_map=ap_corr_map,
1067 )
1068 # TODO: Attach transmission curve when they become available.
1069 cells.append(singleCellCoadd)
1071 if not cells:
1072 raise NoWorkFound("No cells could be populated for the cell coadd.")
1074 grid = self._construct_grid(skyInfo)
1075 multipleCellCoadd = MultipleCellCoadd(
1076 cells,
1077 grid=grid,
1078 outer_cell_size=cellInfo.outer_bbox.getDimensions(),
1079 inner_bbox=None,
1080 common=self.common,
1081 psf_image_size=cells[0].psf_image.getDimensions(),
1082 )
1084 if self.config.do_input_map: 1084 ↛ 1087line 1084 didn't jump to line 1087 because the condition on line 1084 was always true
1085 inputMap = self.input_mapper.cell_input_map
1086 else:
1087 inputMap = None
1089 if self.config.output_image_type == "future":
1090 from lsst.images import Box
1091 from lsst.images.cells import CellCoadd
1093 multipleCellCoadd = CellCoadd.from_legacy_cell_coadd(
1094 multipleCellCoadd, tract_info=skyInfo.tractInfo, bbox=Box.from_legacy(grid.bbox_with_padding)
1095 )
1097 return Struct(
1098 multipleCellCoadd=multipleCellCoadd,
1099 inputMap=inputMap,
1100 )
1103class ConvertMultipleCellCoaddToExposureConnections(
1104 PipelineTaskConnections,
1105 dimensions=("tract", "patch", "band", "skymap"),
1106 defaultTemplates={"inputCoaddName": "deep", "inputCoaddSuffix": "Cell"},
1107):
1108 cellCoaddExposure = Input(
1109 doc="Output coadded exposure, produced by stacking input warps",
1110 name="{inputCoaddName}Coadd{inputCoaddSuffix}",
1111 storageClass="MultipleCellCoadd",
1112 dimensions=("tract", "patch", "skymap", "band"),
1113 )
1115 stitchedCoaddExposure = Output(
1116 doc="Output stitched coadded exposure, produced by stacking input warps",
1117 name="{inputCoaddName}Coadd{inputCoaddSuffix}_stitched",
1118 storageClass="ExposureF",
1119 dimensions=("tract", "patch", "skymap", "band"),
1120 )
1123class ConvertMultipleCellCoaddToExposureConfig(
1124 PipelineTaskConfig, pipelineConnections=ConvertMultipleCellCoaddToExposureConnections
1125):
1126 """A trivial PipelineTaskConfig class for
1127 ConvertMultipleCellCoaddToExposureTask.
1128 """
1131class ConvertMultipleCellCoaddToExposureTask(PipelineTask):
1132 """An after burner PipelineTask that converts a cell-based coadd from
1133 `MultipleCellCoadd` format to `ExposureF` format.
1135 The run method stitches the cell-based coadd into contiguous exposure and
1136 returns it in as an `Exposure` object. This is lossy as it preserves only
1137 the pixels in the inner bounding box of the cells and discards the values
1138 in the buffer region.
1140 Notes
1141 -----
1142 This task has no configurable parameters.
1143 """
1145 ConfigClass = ConvertMultipleCellCoaddToExposureConfig
1146 _DefaultName = "convertMultipleCellCoaddToExposure"
1148 def run(self, cellCoaddExposure):
1149 return Struct(
1150 stitchedCoaddExposure=cellCoaddExposure.stitch().asExposure(),
1151 )