LSST Applications g0f08755f38+82efc23009,g12f32b3c4e+e7bdf1200e,g1653933729+a8ce1bb630,g1a0ca8cf93+50eff2b06f,g28da252d5a+52db39f6a5,g2bbee38e9b+37c5a29d61,g2bc492864f+37c5a29d61,g2cdde0e794+c05ff076ad,g3156d2b45e+41e33cbcdc,g347aa1857d+37c5a29d61,g35bb328faa+a8ce1bb630,g3a166c0a6a+37c5a29d61,g3e281a1b8c+fb992f5633,g414038480c+7f03dfc1b0,g41af890bb2+11b950c980,g5fbc88fb19+17cd334064,g6b1c1869cb+12dd639c9a,g781aacb6e4+a8ce1bb630,g80478fca09+72e9651da0,g82479be7b0+04c31367b4,g858d7b2824+82efc23009,g9125e01d80+a8ce1bb630,g9726552aa6+8047e3811d,ga5288a1d22+e532dc0a0b,gae0086650b+a8ce1bb630,gb58c049af0+d64f4d3760,gc28159a63d+37c5a29d61,gcf0d15dbbd+2acd6d4d48,gd7358e8bfb+778a810b6e,gda3e153d99+82efc23009,gda6a2b7d83+2acd6d4d48,gdaeeff99f8+1711a396fd,ge2409df99d+6b12de1076,ge79ae78c31+37c5a29d61,gf0baf85859+d0a5978c5a,gf3967379c6+4954f8c433,gfb92a5be7c+82efc23009,gfec2e1e490+2aaed99252,w.2024.46
LSST Data Management Base Package
Loading...
Searching...
No Matches
make_direct_warp.py
Go to the documentation of this file.
1# This file is part of pipe_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/>.
21
22from __future__ import annotations
23
24from collections.abc import Mapping
25import dataclasses
26from typing import TYPE_CHECKING, Iterable
27
28import numpy as np
29from lsst.afw.image import ExposureF, Mask
30from lsst.afw.math import BackgroundList, Warper
31from lsst.coadd.utils import copyGoodPixels
32from lsst.daf.butler import DataCoordinate, DeferredDatasetHandle
33from lsst.geom import Box2D
34from lsst.meas.algorithms import CoaddPsf, CoaddPsfConfig
36 CloughTocher2DInterpolateTask,
37)
38from lsst.meas.base import DetectorVisitIdGeneratorConfig
39from lsst.pex.config import (
40 ConfigField,
41 ConfigurableField,
42 Field,
43 RangeField,
44)
45from lsst.pipe.base import (
46 NoWorkFound,
47 PipelineTask,
48 PipelineTaskConfig,
49 PipelineTaskConnections,
50 Struct,
51)
52from lsst.pipe.base.connectionTypes import Input, Output
53from lsst.pipe.tasks.coaddBase import makeSkyInfo
54from lsst.pipe.tasks.selectImages import PsfWcsSelectImagesTask
55from lsst.skymap import BaseSkyMap
56
57from .coaddInputRecorder import CoaddInputRecorderTask
58
59if TYPE_CHECKING:
60 from lsst.afw.image import MaskedImage
61 from lsst.afw.table import ExposureCatalog
62 from lsst.pipe.base import InMemoryDatasetHandle
63
64
65__all__ = (
66 "MakeDirectWarpConfig",
67 "MakeDirectWarpTask",
68)
69
70
71@dataclasses.dataclass
73 """Inputs passed to `MakeDirectWarpTask.run` for a single detector.
74 """
75
76 exposure_or_handle: ExposureF | DeferredDatasetHandle | InMemoryDatasetHandle
77 """Detector image with initial calibration objects, or a deferred-load
78 handle for one.
79 """
80
81 data_id: DataCoordinate
82 """Butler data ID for this detector."""
83
84 background_revert: BackgroundList | None = None
85 """Background model to restore in (i.e. add to) the image."""
86
87 background_apply: BackgroundList | None = None
88 """Background model to apply to (i.e. subtract from) the image."""
89
90 @property
91 def exposure(self) -> ExposureF:
92 """Get the exposure object, loading it if necessary."""
93 if not isinstance(self.exposure_or_handleexposure_or_handle, ExposureF):
95
97
98 def apply_background(self) -> None:
99 """Apply (subtract) the `background_apply` to the exposure in-place.
100
101 Raises
102 ------
103 RuntimeError
104 Raised if `background_apply` is None.
105 """
106 if self.background_apply is None:
107 raise RuntimeError("No background to apply")
108
109 if self.background_apply:
110 # Subtract only if `background_apply` is not a trivial background.
111 self.exposure.maskedImage -= self.background_apply.getImage()
112
113 def revert_background(self) -> None:
114 """Revert (add) the `background_revert` from the exposure in-place.
115
116 Raises
117 ------
118 RuntimeError
119 Raised if `background_revert` is None.
120 """
121 if self.background_revert is None:
122 raise RuntimeError("No background to revert")
123
124 if self.background_revert:
125 # Add only if `background_revert` is not a trivial background.
126 self.exposure.maskedImage += self.background_revert.getImage()
127
128
130 PipelineTaskConnections,
131 dimensions=("tract", "patch", "skymap", "instrument", "visit"),
132 defaultTemplates={
133 "coaddName": "deep",
134 },
135):
136 """Connections for MakeWarpTask"""
137
138 calexp_list = Input(
139 doc="Input exposures to be interpolated and resampled onto a SkyMap "
140 "projection/patch.",
141 name="calexp",
142 storageClass="ExposureF",
143 dimensions=("instrument", "visit", "detector"),
144 multiple=True,
145 deferLoad=True,
146 )
147 background_revert_list = Input(
148 doc="Background to be reverted (i.e., added back to the calexp). "
149 "This connection is used only if doRevertOldBackground=False.",
150 name="calexpBackground",
151 storageClass="Background",
152 dimensions=("instrument", "visit", "detector"),
153 multiple=True,
154 )
155 background_apply_list = Input(
156 doc="Background to be applied (subtracted from the calexp). "
157 "This is used only if doApplyNewBackground=True.",
158 name="skyCorr",
159 storageClass="Background",
160 dimensions=("instrument", "visit", "detector"),
161 multiple=True,
162 )
163 visit_summary = Input(
164 doc="Input visit-summary catalog with updated calibration objects.",
165 name="finalVisitSummary",
166 storageClass="ExposureCatalog",
167 dimensions=("instrument", "visit"),
168 )
169 sky_map = Input(
170 doc="Input definition of geometry/bbox and projection/wcs for warps.",
171 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
172 storageClass="SkyMap",
173 dimensions=("skymap",),
174 )
175 # Declare all possible outputs (except noise, which is configurable)
176 warp = Output(
177 doc="Output direct warped exposure produced by resampling calexps "
178 "onto the skyMap patch geometry.",
179 name="{coaddName}Coadd_directWarp",
180 storageClass="ExposureF",
181 dimensions=("tract", "patch", "skymap", "instrument", "visit"),
182 )
183 masked_fraction_warp = Output(
184 doc="Output masked fraction warped exposure.",
185 name="{coaddName}Coadd_directWarp_maskedFraction",
186 storageClass="ImageF",
187 dimensions=("tract", "patch", "skymap", "instrument", "visit"),
188 )
189
190 def __init__(self, *, config=None):
191 super().__init__(config=config)
192 if not config:
193 return
194
195 if not config.doRevertOldBackground:
196 del self.background_revert_list
197 if not config.doApplyNewBackground:
198 del self.background_apply_list
199
200 if not config.doWarpMaskedFraction:
201 del self.masked_fraction_warp
202
203 # Dynamically set output connections for noise images, depending on the
204 # number of noise realization specified in the config.
205 for n in range(config.numberOfNoiseRealizations):
206 noise_warp = Output(
207 doc=f"Output direct warped noise exposure ({n})",
208 name=f"{config.connections.coaddName}Coadd_directWarp_noise{n}",
209 # Store it as a MaskedImage to preserve the variance plane.
210 storageClass="MaskedImageF",
211 dimensions=("tract", "patch", "skymap", "instrument", "visit"),
212 )
213 setattr(self, f"noise_warp{n}", noise_warp)
214
215
216class MakeDirectWarpConfig(
217 PipelineTaskConfig,
218 pipelineConnections=MakeDirectWarpConnections,
219):
220 """Configuration for the MakeDirectWarpTask.
221
222 The config fields are as similar as possible to the corresponding fields in
223 MakeWarpConfig.
224
225 Notes
226 -----
227 The config fields are in camelCase to match the fields in the earlier
228 version of the makeWarp task as closely as possible.
229 """
230
231 MAX_NUMBER_OF_NOISE_REALIZATIONS = 3
232 """
233 numberOfNoiseRealizations is defined as a RangeField to prevent from
234 making multiple output connections and blowing up the memory usage by
235 accident. An upper bound of 3 is based on the best guess of the maximum
236 number of noise realizations that will be used for metadetection.
237 """
238
239 numberOfNoiseRealizations = RangeField[int](
240 doc="Number of noise realizations to simulate and persist.",
241 default=0,
242 min=0,
243 max=MAX_NUMBER_OF_NOISE_REALIZATIONS,
244 inclusiveMax=True,
245 )
246 seedOffset = Field[int](
247 doc="Offset to the seed used for the noise realization. This can be "
248 "used to create a different noise realization if the default ones "
249 "are catastrophic, or for testing sensitivity to the noise.",
250 default=0,
251 )
252 useMedianVariance = Field[bool](
253 doc="Use the median of variance plane in the input calexp to generate "
254 "noise realizations? If False, per-pixel variance will be used.",
255 default=True,
256 )
257 doRevertOldBackground = Field[bool](
258 doc="Revert the old backgrounds from the `background_revert_list` "
259 "connection?",
260 default=False,
261 )
262 doApplyNewBackground = Field[bool](
263 doc="Apply the new backgrounds from the `background_apply_list` "
264 "connection?",
265 default=False,
266 )
267 useVisitSummaryPsf = Field[bool](
268 doc="If True, use the PSF model and aperture corrections from the "
269 "'visit_summary' connection to make the warp. If False, use the "
270 "PSF model and aperture corrections from the 'calexp' connection.",
271 default=True,
272 )
273 doSelectPreWarp = Field[bool](
274 doc="Select ccds before warping?",
275 default=True,
276 )
277 select = ConfigurableField(
278 doc="Image selection subtask.",
279 target=PsfWcsSelectImagesTask,
280 )
281 doPreWarpInterpolation = Field[bool](
282 doc="Interpolate over bad pixels before warping?",
283 default=False,
284 )
285 preWarpInterpolation = ConfigurableField(
286 doc="Interpolation task to use for pre-warping interpolation",
287 target=CloughTocher2DInterpolateTask,
288 )
289 inputRecorder = ConfigurableField(
290 doc="Subtask that helps fill CoaddInputs catalogs added to the final "
291 "coadd",
292 target=CoaddInputRecorderTask,
293 )
294 includeCalibVar = Field[bool](
295 doc="Add photometric calibration variance to warp variance plane?",
296 default=False,
297 )
298 border = Field[int](
299 doc="Pad the patch boundary of the warp by these many pixels, so as to allow for PSF-matching later",
300 default=256,
301 )
302 warper = ConfigField(
303 doc="Configuration for the warper that warps the image and noise",
304 dtype=Warper.ConfigClass,
305 )
306 doWarpMaskedFraction = Field[bool](
307 doc="Warp the masked fraction image?",
308 default=False,
309 )
310 maskedFractionWarper = ConfigField(
311 doc="Configuration for the warp that warps the mask fraction image",
312 dtype=Warper.ConfigClass,
313 )
314 coaddPsf = ConfigField(
315 doc="Configuration for CoaddPsf",
316 dtype=CoaddPsfConfig,
317 )
318 idGenerator = DetectorVisitIdGeneratorConfig.make_field()
319
320 # Use bgSubtracted and doApplySkyCorr to match the old MakeWarpConfig,
321 # but as properties instead of config fields.
322 @property
323 def bgSubtracted(self) -> bool:
324 return not self.doRevertOldBackground
325
326 @bgSubtracted.setter
327 def bgSubtracted(self, value: bool) -> None:
328 self.doRevertOldBackground = not value
329
330 @property
331 def doApplySkyCorr(self) -> bool:
332 return self.doApplyNewBackground
333
334 @doApplySkyCorr.setter
335 def doApplySkyCorr(self, value: bool) -> None:
336 self.doApplyNewBackground = value
337
338 def setDefaults(self) -> None:
339 super().setDefaults()
340 self.warper.warpingKernelName = "lanczos3"
341 self.warper.cacheSize = 0
342 self.maskedFractionWarper.warpingKernelName = "bilinear"
343
344
345class MakeDirectWarpTask(PipelineTask):
346 """Warp single-detector images onto a common projection.
347
348 This task iterates over multiple images (corresponding to different
349 detectors) from a single visit that overlap the target patch. Pixels that
350 receive no input from any detector are set to NaN in the output image, and
351 NO_DATA bit is set in the mask plane.
352
353 This differs from the standard `MakeWarp` Task in the following
354 ways:
355
356 1. No selection on ccds at the time of warping. This is done later during
357 the coaddition stage.
358 2. Interpolate over a set of masked pixels before warping.
359 3. Generate an image where each pixel denotes how much of the pixel is
360 masked.
361 4. Generate multiple noise warps with the same interpolation applied.
362 5. No option to produce a PSF-matched warp.
363 """
364
365 ConfigClass = MakeDirectWarpConfig
366 _DefaultName = "makeDirectWarp"
367
368 def __init__(self, **kwargs):
369 super().__init__(**kwargs)
370 self.makeSubtask("inputRecorder")
371 self.makeSubtask("preWarpInterpolation")
372 if self.config.doSelectPreWarp:
373 self.makeSubtask("select")
374
375 self.warper = Warper.fromConfig(self.config.warper)
376 if self.config.doWarpMaskedFraction:
377 self.maskedFractionWarper = Warper.fromConfig(self.config.maskedFractionWarper)
378
379 def runQuantum(self, butlerQC, inputRefs, outputRefs):
380 # Docstring inherited.
381
382 inputs: Mapping[int, WarpDetectorInputs] = {} # Detector ID -> WarpDetectorInputs
383 for handle in butlerQC.get(inputRefs.calexp_list):
384 inputs[handle.dataId["detector"]] = WarpDetectorInputs(
385 exposure_or_handle=handle,
386 data_id=handle.dataId,
387 )
388
389 if not inputs:
390 raise NoWorkFound("No input warps provided for co-addition")
391
392 # Add backgrounds to the inputs struct, being careful not to assume
393 # they're present for the same detectors we got image handles for, in
394 # case of upstream errors.
395 for ref in getattr(inputRefs, "background_revert_list", []):
396 inputs[ref.dataId["detector"]].background_revert = butlerQC.get(ref)
397 for ref in getattr(inputRefs, "background_apply_list", []):
398 inputs[ref.dataId["detector"]].background_apply = butlerQC.get(ref)
399
400 visit_summary = butlerQC.get(inputRefs.visit_summary)
401 sky_map = butlerQC.get(inputRefs.sky_map)
402
403 quantumDataId = butlerQC.quantum.dataId
404 sky_info = makeSkyInfo(
405 sky_map,
406 tractId=quantumDataId["tract"],
407 patchId=quantumDataId["patch"],
408 )
409
410 results = self.run(inputs, sky_info, visit_summary=visit_summary)
411 butlerQC.put(results, outputRefs)
412
413 def _preselect_inputs(
414 self,
415 inputs: Mapping[int, WarpDetectorInputs],
416 sky_info: Struct,
417 visit_summary: ExposureCatalog,
418 ) -> dict[int, WarpDetectorInputs]:
419 """Filter the list of inputs via a 'select' subtask.
420
421 Parameters
422 ----------
423 inputs : ``~collections.abc.Mapping` [ `int`, `WarpDetectorInputs` ]
424 Per-detector input structs.
425 sky_info : `lsst.pipe.base.Struct`
426 Structure with information about the tract and patch.
427 visit_summary : `~lsst.afw.table.ExposureCatalog`
428 Record with updated calibration information for the full visit.
429
430 Returns
431 -------
432 filtered_inputs : `dict` [ `int`, `WarpDetectorInputs` ]
433 Like ``inputs``, with rejected detectors dropped.
434 """
435 data_id_list, bbox_list, wcs_list = [], [], []
436 for detector_id, detector_inputs in inputs.items():
437 row = visit_summary.find(detector_id)
438 if row is None:
439 raise RuntimeError(f"Unexpectedly incomplete visit_summary: {detector_id=} is missing.")
440 data_id_list.append(detector_inputs.data_id)
441 bbox_list.append(row.getBBox())
442 wcs_list.append(row.getWcs())
443
444 cornerPosList = Box2D(sky_info.bbox).getCorners()
445 coordList = [sky_info.wcs.pixelToSky(pos) for pos in cornerPosList]
446
447 good_indices = self.select.run(
448 bboxList=bbox_list,
449 wcsList=wcs_list,
450 visitSummary=visit_summary,
451 coordList=coordList,
452 dataIds=data_id_list,
453 )
454 detector_ids = list(inputs.keys())
455 good_detector_ids = [detector_ids[i] for i in good_indices]
456 return {detector_id: inputs[detector_id] for detector_id in good_detector_ids}
457
458 def run(self, inputs: Mapping[int, WarpDetectorInputs], sky_info, visit_summary) -> Struct:
459 """Create a Warp dataset from inputs.
460
461 Parameters
462 ----------
463 inputs : `Mapping` [ `int`, `WarpDetectorInputs` ]
464 Dictionary of input datasets, with the detector id being the key.
465 sky_info : `~lsst.pipe.base.Struct`
466 A Struct object containing wcs, bounding box, and other information
467 about the patches within the tract.
468 visit_summary : `~lsst.afw.table.ExposureCatalog` | None
469 Table of visit summary information. If provided, the visit summary
470 information will be used to update the calibration of the input
471 exposures. If None, the input exposures will be used as-is.
472
473 Returns
474 -------
475 results : `~lsst.pipe.base.Struct`
476 A Struct object containing the warped exposure, noise exposure(s),
477 and masked fraction image.
478 """
479
480 if self.config.doSelectPreWarp:
481 inputs = self._preselect_inputs(inputs, sky_info, visit_summary=visit_summary)
482 if not inputs:
483 raise NoWorkFound("No input warps remain after selection for co-addition")
484
485 sky_info.bbox.grow(self.config.border)
486 target_bbox, target_wcs = sky_info.bbox, sky_info.wcs
487
488 # Initialize the objects that will hold the warp.
489 final_warp = ExposureF(target_bbox, target_wcs)
490
491 for _, warp_detector_input in inputs.items():
492 visit_id = warp_detector_input.data_id["visit"]
493 break # Just need the visit id from any one of the inputs.
494
495 # The warpExposure routine is expensive, and we do not want to call
496 # it twice (i.e., a second time for PSF-matched warps). We do not
497 # want to hold all the warped exposures in memory at once either.
498 # So we create empty exposure(s) to accumulate the warps of each type,
499 # and then process each detector serially.
500 final_warp = self._prepareEmptyExposure(sky_info)
501 final_masked_fraction_warp = self._prepareEmptyExposure(sky_info)
502 final_noise_warps = {
503 n_noise: self._prepareEmptyExposure(sky_info)
504 for n_noise in range(self.config.numberOfNoiseRealizations)
505 }
506
507 # We need a few bookkeeping variables only for the science coadd.
508 totalGoodPixels = 0
509 inputRecorder = self.inputRecorder.makeCoaddTempExpRecorder(
510 visit_id,
511 len(inputs),
512 )
513
514 for index, detector_inputs in enumerate(inputs.values()):
515 self.log.debug(
516 "Warping exposure %d/%d for id=%s",
517 index + 1,
518 len(inputs),
519 detector_inputs.data_id,
520 )
521
522 input_exposure = detector_inputs.exposure
523 # Generate noise image(s) in-situ.
524 seed = self.get_seed_from_data_id(detector_inputs.data_id)
525 rng = np.random.RandomState(seed + self.config.seedOffset)
526
527 # Generate noise images in-situ.
528 noise_calexps = self.make_noise_exposures(input_exposure, rng)
529
530 warpedExposure = self.process(
531 detector_inputs,
532 target_wcs,
533 self.warper,
534 visit_summary,
535 destBBox=target_bbox,
536 )
537
538 if warpedExposure is None:
539 self.log.debug(
540 "Skipping exposure %s because it could not be warped.", detector_inputs.data_id
541 )
542 continue
543
544 if final_warp.photoCalib is not None:
545 ratio = (
546 final_warp.photoCalib.getInstFluxAtZeroMagnitude()
547 / warpedExposure.photoCalib.getInstFluxAtZeroMagnitude()
548 )
549 else:
550 ratio = 1
551
552 self.log.debug("Scaling exposure %s by %f", detector_inputs.data_id, ratio)
553 warpedExposure.maskedImage *= ratio
554
555 # Accumulate the partial warps in an online fashion.
556 nGood = copyGoodPixels(
557 final_warp.maskedImage,
558 warpedExposure.maskedImage,
559 final_warp.mask.getPlaneBitMask(["NO_DATA"]),
560 )
561
562 if final_warp.photoCalib is None and nGood > 0:
563 final_warp.setPhotoCalib(warpedExposure.photoCalib)
564
565 ccdId = self.config.idGenerator.apply(detector_inputs.data_id).catalog_id
566 inputRecorder.addCalExp(input_exposure, ccdId, nGood)
567 totalGoodPixels += nGood
568
569 if self.config.doWarpMaskedFraction:
570 # Obtain the masked fraction exposure and warp it.
571 if self.config.doPreWarpInterpolation:
572 badMaskPlanes = self.preWarpInterpolation.config.badMaskPlanes
573 else:
574 badMaskPlanes = []
575 masked_fraction_exp = self._get_bad_mask(input_exposure, badMaskPlanes)
576
577 masked_fraction_warp = self.maskedFractionWarper.warpExposure(
578 target_wcs, masked_fraction_exp, destBBox=target_bbox
579 )
580
581 copyGoodPixels(
582 final_masked_fraction_warp.maskedImage,
583 masked_fraction_warp.maskedImage,
584 final_masked_fraction_warp.mask.getPlaneBitMask(["NO_DATA"]),
585 )
586
587 # Process and accumulate noise images.
588 for n_noise in range(self.config.numberOfNoiseRealizations):
589 noise_calexp = noise_calexps[n_noise]
590 noise_pseudo_inputs = dataclasses.replace(
591 detector_inputs,
592 exposure_or_handle=noise_calexp,
593 background_revert=BackgroundList(),
594 background_apply=BackgroundList(),
595 )
596 warpedNoise = self.process(
597 noise_pseudo_inputs,
598 target_wcs,
599 self.warper,
600 visit_summary,
601 destBBox=target_bbox,
602 )
603
604 warpedNoise.maskedImage *= ratio
605
606 copyGoodPixels(
607 final_noise_warps[n_noise].maskedImage,
608 warpedNoise.maskedImage,
609 final_noise_warps[n_noise].mask.getPlaneBitMask(["NO_DATA"]),
610 )
611
612 # If there are no good pixels, return a Struct filled with None.
613 if totalGoodPixels == 0:
614 results = Struct(
615 warp=None,
616 masked_fraction_warp=None,
617 )
618 for noise_index in range(self.config.numberOfNoiseRealizations):
619 setattr(results, f"noise_warp{noise_index}", None)
620
621 return results
622
623 # Finish the inputRecorder and add the coaddPsf to the final warp.
624 inputRecorder.finish(final_warp, totalGoodPixels)
625
626 coaddPsf = CoaddPsf(
627 inputRecorder.coaddInputs.ccds,
628 sky_info.wcs,
629 self.config.coaddPsf.makeControl(),
630 )
631
632 final_warp.setPsf(coaddPsf)
633 for _, warp_detector_input in inputs.items():
634 final_warp.setFilter(warp_detector_input.exposure.getFilter())
635 final_warp.getInfo().setVisitInfo(warp_detector_input.exposure.getInfo().getVisitInfo())
636 break # Just need the filter and visit info from any one of the inputs.
637
638 results = Struct(
639 warp=final_warp,
640 )
641
642 if self.config.doWarpMaskedFraction:
643 results.masked_fraction_warp = final_masked_fraction_warp.image
644
645 for noise_index, noise_exposure in final_noise_warps.items():
646 setattr(results, f"noise_warp{noise_index}", noise_exposure.maskedImage)
647
648 return results
649
650 def process(
651 self,
652 detector_inputs: WarpDetectorInputs,
653 target_wcs,
654 warper,
655 visit_summary=None,
656 maxBBox=None,
657 destBBox=None,
658 ) -> ExposureF | None:
659 """Process an exposure.
660
661 There are three processing steps that are applied to the input:
662
663 1. Interpolate over bad pixels before warping.
664 2. Apply all calibrations from visit_summary to the exposure.
665 3. Warp the exposure to the target coordinate system.
666
667 Parameters
668 ----------
669 detector_inputs : `WarpDetectorInputs`
670 The input exposure to be processed, along with any other
671 per-detector modifications.
672 target_wcs : `~lsst.afw.geom.SkyWcs`
673 The WCS of the target patch.
674 warper : `~lsst.afw.math.Warper`
675 The warper to use for warping the input exposure.
676 visit_summary : `~lsst.afw.table.ExposureCatalog` | None
677 Table of visit summary information. If not None, the visit_summary
678 information will be used to update the calibration of the input
679 exposures. Otherwise, the input exposures will be used as-is.
680 maxBBox : `~lsst.geom.Box2I` | None
681 Maximum bounding box of the warped exposure. If None, this is
682 determined automatically.
683 destBBox : `~lsst.geom.Box2I` | None
684 Exact bounding box of the warped exposure. If None, this is
685 determined automatically.
686
687 Returns
688 -------
689 warped_exposure : `~lsst.afw.image.Exposure` | None
690 The processed and warped exposure, if all the calibrations could
691 be applied successfully. Otherwise, None.
692 """
693
694 input_exposure = detector_inputs.exposure
695 if self.config.doPreWarpInterpolation:
696 self.preWarpInterpolation.run(input_exposure.maskedImage)
697
698 success = self._apply_all_calibrations(
699 detector_inputs,
700 visit_summary=visit_summary,
701 includeScaleUncertainty=self.config.includeCalibVar,
702 )
703
704 if not success:
705 return None
706
707 with self.timer("warp"):
708 warped_exposure = warper.warpExposure(
709 target_wcs,
710 input_exposure,
711 maxBBox=maxBBox,
712 destBBox=destBBox,
713 )
714
715 # Potentially a post-warp interpolation here? Relies on DM-38630.
716
717 return warped_exposure
718
719 def _apply_all_calibrations(
720 self,
721 detector_inputs: WarpDetectorInputs,
722 *,
723 visit_summary: ExposureCatalog | None = None,
724 includeScaleUncertainty: bool = False,
725 ) -> bool:
726 """Apply all of the calibrations from visit_summary to the exposure.
727
728 Specifically, this method updates the following (if available) to the
729 input exposure in place from ``visit_summary``:
730
731 - Aperture correction map
732 - Photometric calibration
733 - PSF
734 - WCS
735
736 It also reverts and applies backgrounds in ``detector_inputs``.
737
738 Parameters
739 ----------
740 detector_inputs : `WarpDetectorInputs`
741 The input exposure to be processed, along with any other
742 per-detector modifications.
743 visit_summary : `~lsst.afw.table.ExposureCatalog` | None
744 Table of visit summary information. If not None, the visit summary
745 information will be used to update the calibration of the input
746 exposures. Otherwise, the input exposures will be used as-is.
747 includeScaleUncertainty : bool
748 Whether to include the uncertainty on the calibration in the
749 resulting variance? Passed onto the `calibrateImage` method of the
750 PhotoCalib object attached to ``exp``.
751
752 Returns
753 -------
754 success : `bool`
755 True if all calibrations were successfully applied, False otherwise.
756
757 Raises
758 ------
759 RuntimeError
760 Raised if ``visit_summary`` is provided but does not contain a
761 record corresponding to ``detector_inputs``, or if one of the
762 background fields in ``detector_inputs`` is inconsistent with the
763 task configuration.
764 """
765 if self.config.doRevertOldBackground:
766 detector_inputs.revert_background()
767 elif detector_inputs.background_revert:
768 # This could get trigged only if runQuantum is skipped and run is
769 # called directly.
770 raise RuntimeError(
771 f"doRevertOldBackground is False, but {detector_inputs.data_id} has a background_revert."
772 )
773
774 input_exposure = detector_inputs.exposure
775 if visit_summary is not None:
776 detector = input_exposure.info.getDetector().getId()
777 row = visit_summary.find(detector)
778
779 if row is None:
780 self.log.info(
781 "Unexpectedly incomplete visit_summary: detector = %s is missing. Skipping it.",
782 detector,
783 )
784 return False
785
786 if photo_calib := row.getPhotoCalib():
787 input_exposure.setPhotoCalib(photo_calib)
788 else:
789 self.log.info(
790 "No photometric calibration found in visit summary for detector = %s. Skipping it.",
791 detector,
792 )
793 return False
794
795 if wcs := row.getWcs():
796 input_exposure.setWcs(wcs)
797 else:
798 self.log.info("No WCS found in visit summary for detector = %s. Skipping it.", detector)
799 return False
800
801 if self.config.useVisitSummaryPsf:
802 if psf := row.getPsf():
803 input_exposure.setPsf(psf)
804 else:
805 self.log.info("No PSF found in visit summary for detector = %s. Skipping it.", detector)
806 return False
807
808 if apcorr_map := row.getApCorrMap():
809 input_exposure.setApCorrMap(apcorr_map)
810 else:
811 self.log.info(
812 "No aperture correction map found in visit summary for detector = %s. Skipping it",
813 detector,
814 )
815 return False
816
817 elif visit_summary is not None:
818 # We can only get here by calling `run`, not `runQuantum`.
819 raise RuntimeError(
820 "useVisitSummaryPsf=True, but visit_summary is provided. "
821 )
822
823 if self.config.doApplyNewBackground:
824 detector_inputs.apply_background()
825 elif detector_inputs.background_apply:
826 # This could get trigged only if runQuantum is skipped and run is
827 # called directly.
828 raise RuntimeError(
829 f"doApplyNewBackground is False, but {detector_inputs.data_id} has a background_apply."
830 )
831
832 # Calibrate the (masked) image.
833 # This should likely happen even if visit_summary is None.
834 photo_calib = input_exposure.photoCalib
835 input_exposure.maskedImage = photo_calib.calibrateImage(
836 input_exposure.maskedImage,
837 includeScaleUncertainty=includeScaleUncertainty,
838 )
839 input_exposure.maskedImage /= photo_calib.getCalibrationMean()
840
841 return True
842
843 # This method is copied from makeWarp.py
844 @classmethod
845 def _prepareEmptyExposure(cls, sky_info):
846 """Produce an empty exposure for a given patch.
847
848 Parameters
849 ----------
850 sky_info : `lsst.pipe.base.Struct`
851 Struct from `~lsst.pipe.base.coaddBase.makeSkyInfo` with
852 geometric information about the patch.
853
854 Returns
855 -------
856 exp : `lsst.afw.image.exposure.ExposureF`
857 An empty exposure for a given patch.
858 """
859 exp = ExposureF(sky_info.bbox, sky_info.wcs)
860 exp.getMaskedImage().set(np.nan, Mask.getPlaneBitMask("NO_DATA"), np.inf)
861 return exp
862
863 @staticmethod
864 def compute_median_variance(mi: MaskedImage) -> float:
865 """Compute the median variance across the good pixels of a MaskedImage.
866
867 Parameters
868 ----------
869 mi : `~lsst.afw.image.MaskedImage`
870 The input image on which to compute the median variance.
871
872 Returns
873 -------
874 median_variance : `float`
875 Median variance of the input calexp.
876 """
877 # Shouldn't this exclude pixels that are masked, to be safe?
878 # This is implemented as it was in descwl_coadd.
879 return np.median(mi.variance.array[np.isfinite(mi.variance.array) & np.isfinite(mi.image.array)])
880
881 def get_seed_from_data_id(self, data_id) -> int:
882 """Get a seed value given a data_id.
883
884 This method generates a unique, reproducible pseudo-random number for
885 a data id. This is not affected by ordering of the input, or what
886 set of visits, ccds etc. are given.
887
888 This is implemented as a public method, so that simulations that
889 don't necessary deal with the middleware can mock up a ``data_id``
890 instance, or override this method with a different one to obtain a
891 seed value consistent with the pipeline task.
892
893 Parameters
894 ----------
895 data_id : `~lsst.daf.butler.DataCoordinate`
896 Data identifier dictionary.
897
898 Returns
899 -------
900 seed : `int`
901 A unique seed for this data_id to seed a random number generator.
902 """
903 return self.config.idGenerator.apply(data_id).catalog_id
904
905 def make_noise_exposures(self, calexp: ExposureF, rng) -> dict[int, ExposureF]:
906 """Make pure noise realizations based on ``calexp``.
907
908 Parameters
909 ----------
910 calexp : `~lsst.afw.image.ExposureF`
911 The input exposure on which to base the noise realizations.
912 rng : `np.random.RandomState`
913 Random number generator to use for the noise realizations.
914
915 Returns
916 -------
917 noise_calexps : `dict` [`int`, `~lsst.afw.image.ExposureF`]
918 A mapping of integers ranging from 0 up to
919 config.numberOfNoiseRealizations to the corresponding
920 noise realization exposures.
921 """
922 noise_calexps = {}
923
924 # If no noise exposures are requested, return the empty dictionary
925 # without any further computations.
926 if self.config.numberOfNoiseRealizations == 0:
927 return noise_calexps
928
929 if self.config.useMedianVariance:
930 variance = self.compute_median_variance(calexp.maskedImage)
931 else:
932 variance = calexp.variance.array
933
934 for n_noise in range(self.config.numberOfNoiseRealizations):
935 noise_calexp = calexp.clone()
936 noise_calexp.image.array[:, :] = rng.normal(
937 scale=np.sqrt(variance),
938 size=noise_calexp.image.array.shape,
939 )
940 noise_calexp.variance.array[:, :] = variance
941 noise_calexps[n_noise] = noise_calexp
942
943 return noise_calexps
944
945 @classmethod
946 def _get_bad_mask(cls, exp: ExposureF, badMaskPlanes: Iterable[str]) -> ExposureF:
947 """Get an Exposure of bad mask
948
949 Parameters
950 ----------
951 exp: `lsst.afw.image.Exposure`
952 The exposure data.
953 badMaskPlanes: `list` [`str`]
954 List of mask planes to be considered as bad.
955
956 Returns
957 -------
958 bad_mask: `~lsst.afw.image.Exposure`
959 An Exposure with boolean array with True if inverse variance <= 0
960 or if any of the badMaskPlanes bits are set, and False otherwise.
961 """
962
963 bad_mask = exp.clone()
964
965 var = exp.variance.array
966 mask = exp.mask.array
967
968 bitMask = exp.mask.getPlaneBitMask(badMaskPlanes)
969
970 bad_mask.image.array[:, :] = (var < 0) | np.isinf(var) | ((mask & bitMask) != 0)
971
972 bad_mask.variance.array *= 0.0
973
974 return bad_mask
A floating-point coordinate rectangle geometry.
Definition Box.h:413
CoaddPsf is the Psf derived to be used for non-PSF-matched Coadd images.
Definition CoaddPsf.h:58