22from __future__
import annotations
27 "PrettyPictureConnections",
28 "PrettyPictureConfig",
30 "PrettyMosaicConnections",
32 "PrettyPictureBackgroundFixerConfig",
33 "PrettyPictureBackgroundFixerTask",
34 "PrettyPictureStarFixerConfig",
35 "PrettyPictureStarFixerTask",
40from collections.abc
import Iterable, Mapping
43from typing
import TYPE_CHECKING, cast, Any
46from scipy.stats
import halfnorm, mode
47from scipy.ndimage
import binary_dilation
48from scipy.interpolate
import RBFInterpolator
49from skimage.restoration
import inpaint_biharmonic
51from lsst.daf.butler
import Butler, DeferredDatasetHandle
52from lsst.daf.butler
import DatasetRef
53from lsst.pex.config import Field, Config, ConfigDictField, ListField, ChoiceField
55from lsst.pipe.base
import (
58 PipelineTaskConnections,
60 InMemoryDatasetHandle,
63from lsst.rubinoxide
import rbf_interpolator
66from lsst.pipe.base.connectionTypes
import Input, Output
67from lsst.geom import Box2I, Point2I, Extent2I
70from ._plugins
import plugins
71from ._colorMapper
import lsstRGB
72from ._utils
import FeatheredMosaicCreator
73from ._functors
import (
79 LocalContrastEnhancer,
86 from numpy.typing
import NDArray
87 from lsst.pipe.base
import QuantumContext, InputQuantizedConnection, OutputQuantizedConnection
92 PipelineTaskConnections,
93 dimensions={
"tract",
"patch",
"skymap"},
94 defaultTemplates={
"coaddTypeName":
"deep"},
98 "Model of the static sky, used to find temporal artifacts. Typically a PSF-Matched, "
99 "sigma-clipped coadd. Written if and only if assembleStaticSkyModel.doWrite=True"
102 storageClass=
"ExposureF",
103 dimensions=(
"tract",
"patch",
"skymap",
"band"),
108 doc=
"A RGB image created from the input data stored as a 3d array",
109 name=
"rgb_picture_array",
110 storageClass=
"NumpyArray",
111 dimensions=(
"tract",
"patch",
"skymap"),
114 outputRGBMask = Output(
115 doc=
"A Mask corresponding to the fused masks of the input channels",
116 name=
"rgb_picture_mask",
118 dimensions=(
"tract",
"patch",
"skymap"),
122class ChannelRGBConfig(Config):
123 """This describes the rgb values of a given input channel.
125 For instance if this channel is red the values would be self.r = 1,
126 self.g = 0, self.b = 0. If the channel was cyan the values would be
127 self.r = 0, self.g = 1, self.b = 1.
130 r = Field[float](doc=
"The amount of red contained in this channel")
131 g = Field[float](doc=
"The amount of green contained in this channel")
132 b = Field[float](doc=
"The amount of blue contained in this channel")
135class PrettyPictureConfig(PipelineTaskConfig, pipelineConnections=PrettyPictureConnections):
136 channelConfig = ConfigDictField(
137 doc=
"A dictionary that maps band names to their rgb channel configurations",
139 itemtype=ChannelRGBConfig,
142 cieWhitePoint = ListField[float](
143 doc=
"The white point of the input arrays in ciexz coordinates", maxLength=2, default=[0.28, 0.28]
145 arrayType = ChoiceField[str](
146 doc=
"The dataset type for the output image array",
149 "uint8":
"Use 8 bit arrays, 255 max",
150 "uint16":
"Use 16 bit arrays, 65535 max",
151 "half":
"Use 16 bit float arrays, 1 max",
152 "float":
"Use 32 bit float arrays, 1 max",
155 recenterNoise = Field[float](
156 doc=
"Recenter the noise away from zero. Supplied value is in units of sigma",
160 noiseSearchThreshold = Field[float](
162 "Flux threshold below which most flux will be considered noise, used to estimate noise properties"
166 doPsfDeconvolve = Field[bool](
167 doc=
"Use the PSF in a Richardson-Lucy deconvolution on the luminance channel.", default=
False
169 doPSFDeconcovlve = Field[bool](
170 doc=
"Use the PSF in a Richardson-Lucy deconvolution on the luminance channel.",
172 deprecated=
"This field will be removed in v32. Use doPsfDeconvolve instead.",
175 doRemapGamut = Field[bool](
176 doc=
"Apply a color correction to unrepresentable colors; if False, clip them.", default=
True
178 doExposureBrackets = Field[bool](
179 doc=
"Apply exposure bracketing to aid in dynamic range compression", default=
True
181 doLocalContrast = Field[bool](doc=
"Apply local contrast optimizations to luminance.", default=
True)
183 imageRemappingConfig = ConfigurableActionField[BoundsRemapper](
184 doc=
"Action controlling normalization process"
186 luminanceConfig = ConfigurableActionField[LumCompressor](
187 doc=
"Action controlling luminance scaling when making an RGB image"
189 localContrastConfig = ConfigurableActionField[LocalContrastEnhancer](
190 doc=
"Action controlling the local contrast correction in RGB image production"
192 colorConfig = ConfigurableActionField[ColorScaler](
193 doc=
"Action to control the color scaling process in RGB image production"
195 exposureBracketerConfig = ConfigurableActionField[ExposureBracketer](
197 "Exposure scaling action used in creating multiple exposures with different scalings which will "
198 "then be fused into a final image"
201 gamutMapperConfig = ConfigurableActionField[GamutFixer](
202 doc=
"Action to fix pixels which lay outside RGB color gamut"
205 exposureBrackets = ListField[float](
207 "Exposure scaling factors used in creating multiple exposures with different scalings which will "
208 "then be fused into a final image"
211 default=[1.25, 1, 0.75],
213 "This field will stop working in v31 and be removed in v32, "
214 "please set exposureBracketerConfig.exposureBrackets"
217 gamutMethod = ChoiceField[str](
218 doc=
"If doRemapGamut is True this determines the method",
221 "mapping":
"Use a mapping function",
222 "inpaint":
"Use surrounding pixels to determine likely value",
224 deprecated=
"This field will stop working in v31 and be removed in v32, please set gamutMapperConfig",
227 def setDefaults(self):
228 self.channelConfig[
"i"] = ChannelRGBConfig(r=1, g=0, b=0)
229 self.channelConfig[
"r"] = ChannelRGBConfig(r=0, g=1, b=0)
230 self.channelConfig[
"g"] = ChannelRGBConfig(r=0, g=0, b=1)
231 return super().setDefaults()
233 def _handle_deprecated(self):
234 """Handle deprecated configuration migration.
236 This method migrates deprecated configuration fields to their new
237 locations in sub-configurations. It checks the configuration history
238 to determine if deprecated fields were explicitly set and updates
239 the new configuration locations accordingly.
243 The following deprecated fields are migrated:
244 - ``gamutMethod`` -> ``gamutMapperConfig.gamutMethod``
245 - ``exposureBrackets`` -> ``exposureBracketerConfig.exposureBrackets``
246 - ``doLocalContrast`` -> ``localContrastConfig.doLocalContrast``
247 - ``doPSFDeconcovlve`` -> ``doPsfDeconvolve``
250 if len(self._history[
"gamutMethod"]) > 1:
252 self.gamutMapperConfig.gamutMethod = self.gamutMethod
254 if len(self._history[
"exposureBrackets"]) > 1:
255 self.exposureBracketerConfig.exposureBrackets = self.exposureBrackets
256 if self.exposureBrackets
is None:
257 self.doExposureBrackets =
False
259 if len(self.localContrastConfig._history[
"doLocalContrast"]) > 1:
260 self.doLocalContrast = self.localContrastConfig.doLocalContrast
263 if len(self._history[
"doPSFDeconcovlve"]) > 1:
264 self.doPsfDeconvolve = self.doPSFDeconcovlve
268 if self._frozen
is not True:
269 self._handle_deprecated()
273class PrettyPictureTask(PipelineTask):
274 """Turns inputs into an RGB image."""
276 _DefaultName =
"prettyPicture"
277 ConfigClass = PrettyPictureConfig
281 def _find_normal_stats(self, array):
282 """Calculate standard deviation from negative values using half-normal distribution.
287 Array dimension validation fails.
291 array : `numpy.array`
292 Input array of numerical values.
297 The central moment of the distribution
299 Estimated standard deviation from negative values. Returns np.inf if:
300 - No negative values exist in the array
301 - Half-normal fitting fails
304 values_noise = array[array < self.config.noiseSearchThreshold]
307 center = mode(np.round(values_noise, 2)).mode
310 values_neg = array[array < center]
313 if values_neg.size == 0:
318 mu, sigma = halfnorm.fit(np.abs(values_neg))
319 except (ValueError, RuntimeError):
325 def _match_sigmas_and_recenter(self, *arrays, factor=1):
326 """Scale array values to match minimum standard deviation across arrays
329 Adjusts values below each array's sigma by scaling and shifting them to
330 align with the minimum sigma value across all input arrays. This operates
331 in-place for efficiency.
335 *arrays : any number of `numpy.array`
336 Variable number of input arrays to process.
337 factor : float, optional
338 Scaling factor for adjustments (default: 1).
345 m, s = self._find_normal_stats(arr)
349 sigmas = np.array(sigmas)
353 if not np.any(np.isfinite(sigmas)):
356 min_sig = np.min(sigmas)
358 for mu, sigma, array
in zip(mus, sigmas, arrays):
360 lower_pos = (array - mu) < sigma
363 if not np.isfinite(sigma):
367 sigma_ratio = min_sig / sigma
370 array[lower_pos] = (array[lower_pos] - mu) * sigma_ratio + min_sig * factor
372 def run(self, images: Mapping[str, Exposure]) -> Struct:
373 """Turns the input arguments in arguments into an RGB array.
377 images : `Mapping` of `str` to `Exposure`
378 A mapping of input images and the band they correspond to.
383 A struct with the corresponding RGB image, and mask used in
384 RGB image construction. The struct will have the attributes
385 outputRGBImage and outputRGBMask. Each of the outputs will
386 be a `NDarray` object.
390 Construction of input images are made easier by use of the
391 makeInputsFrom* methods.
395 jointMask:
None | NDArray =
None
396 maskDict: Mapping[str, int] = {}
397 doJointMaskInit =
False
398 if jointMask
is None:
400 doJointMaskInit =
True
401 for channel, imageExposure
in images.items():
402 imageArray = imageExposure.image.array
404 for plug
in plugins.channel():
406 imageArray, imageExposure.mask.array, imageExposure.mask.getMaskPlaneDict(), self.config
408 channels[channel] = imageArray
411 shape = imageArray.shape
412 maskDict = imageExposure.mask.getMaskPlaneDict()
414 jointMask = np.zeros(shape, dtype=imageExposure.mask.dtype)
415 doJointMaskInit =
False
417 jointMask |= imageExposure.mask.array
420 imageRArray = np.zeros(shape, dtype=np.float32)
421 imageGArray = np.zeros(shape, dtype=np.float32)
422 imageBArray = np.zeros(shape, dtype=np.float32)
424 for band, image
in channels.items():
425 if band
not in self.config.channelConfig:
426 self.log.info(f
"{band} image found but not requested in RGB image, skipping")
428 mix = self.config.channelConfig[band]
430 imageRArray += mix.r * image
432 imageGArray += mix.g * image
434 imageBArray += mix.b * image
436 exposure = next(iter(images.values()))
437 box: Box2I = exposure.getBBox()
438 boxCenter = box.getCenter()
440 psf = exposure.psf.computeImage(boxCenter).array
444 if self.config.recenterNoise:
445 self._match_sigmas_and_recenter(
446 imageRArray, imageGArray, imageBArray, factor=self.config.recenterNoise
450 assert jointMask
is not None
452 colorImage = np.zeros((*imageRArray.shape, 3))
453 colorImage[:, :, 0] = imageRArray
454 colorImage[:, :, 1] = imageGArray
455 colorImage[:, :, 2] = imageBArray
456 for plug
in plugins.partial():
457 colorImage = plug(colorImage, jointMask, maskDict, self.config)
461 local_contrast_config = self.config.localContrastConfig.toDict()
463 for k, v
in local_contrast_config[
"diffusionFunction"].items():
466 for item
in to_remove:
467 local_contrast_config[
"diffusionControl"].pop(item)
473 local_contrast=self.config.localContrastConfig
if self.config.doLocalContrast
else None,
474 scale_lum=self.config.luminanceConfig,
475 scale_color=self.config.colorConfig,
476 remap_bounds=self.config.imageRemappingConfig,
477 bracketing_function=(
478 self.config.exposureBracketerConfig
if self.config.doExposureBrackets
else None
480 gamut_remapping_function=self.config.gamutMapperConfig
if self.config.doRemapGamut
else None,
481 cieWhitePoint=tuple(self.config.cieWhitePoint),
482 psf=psf
if self.config.doPsfDeconvolve
else None,
487 match self.config.arrayType:
501 assert True,
"This code path should be unreachable"
507 lsstMask =
Mask(width=jointMask.shape[1], height=jointMask.shape[0], planeDefs=maskDict)
508 lsstMask.array = jointMask
509 return Struct(outputRGB=colorImage.astype(dtype), outputRGBMask=lsstMask)
513 butlerQC: QuantumContext,
514 inputRefs: InputQuantizedConnection,
515 outputRefs: OutputQuantizedConnection,
517 imageRefs: list[DatasetRef] = inputRefs.inputCoadds
518 sortedImages = self.makeInputsFromRefs(imageRefs, butlerQC)
520 requested =
", ".join(self.config.channelConfig.keys())
521 raise NoWorkFound(f
"No input images of band(s) {requested}")
522 outputs = self.run(sortedImages)
523 butlerQC.put(outputs, outputRefs)
526 self, refs: Iterable[DatasetRef], butler: Butler | QuantumContext
527 ) -> dict[str, Exposure]:
528 r"""Make valid inputs for the run method from butler references.
532 refs : `Iterable` of `DatasetRef`
533 Some `Iterable` container of `Butler` `DatasetRef`\ s
534 butler : `Butler` or `QuantumContext`
535 This is the object that fetches the input data.
539 sortedImages : `dict` of `str` to `Exposure`
540 A dictionary of `Exposure`\ s keyed by the band they
543 sortedImages: dict[str, Exposure] = {}
545 key: str = cast(str, ref.dataId[
"band"])
546 image = butler.get(ref)
547 sortedImages[key] = image
551 r"""Make valid inputs for the run method from numpy arrays.
555 kwargs : `numpy.ndarray`
556 This is standard python kwargs where the left side of the equals
557 is the data band, and the right side is the corresponding `numpy.ndarray`
562 sortedImages : `dict` of `str` to \
563 `~lsst.daf.butler.DeferredDatasetHandle`
564 A dictionary of `~lsst.daf.butlger.DeferredDatasetHandle`\ s keyed
565 by the band they correspond to.
569 for key, array
in kwargs.items():
571 temp[key].image.array[:] = array
573 return self.makeInputsFromExposures(**temp)
576 r"""Make valid inputs for the run method from `Exposure` objects.
581 This is standard python kwargs where the left side of the equals
582 is the data band, and the right side is the corresponding
587 sortedImages : `dict` of `int` to \
588 `~lsst.daf.butler.DeferredDatasetHandle`
589 A dictionary of `~lsst.daf.butler.DeferredDatasetHandle`\ s keyed
590 by the band they correspond to.
593 for key, value
in kwargs.items():
594 sortedImages[key] = value
599 PipelineTaskConnections,
600 dimensions=(
"tract",
"patch",
"skymap",
"band"),
601 defaultTemplates={
"coaddTypeName":
"deep"},
604 doc=(
"Input coadd for which the background is to be removed"),
605 name=
"{coaddTypeName}CoaddPsfMatched",
606 storageClass=
"ExposureF",
607 dimensions=(
"tract",
"patch",
"skymap",
"band"),
609 outputCoadd = Output(
610 doc=
"The coadd with the background fixed and subtracted",
611 name=
"pretty_picture_coadd_bg_subtracted",
612 storageClass=
"ExposureF",
613 dimensions=(
"tract",
"patch",
"skymap",
"band"),
617class PrettyPictureBackgroundFixerConfig(
618 PipelineTaskConfig, pipelineConnections=PrettyPictureBackgroundFixerConnections
620 use_detection_mask = Field[bool](
621 doc=
"Use the detection mask to determine background instead of empirically finding it in this task",
624 num_background_bins = Field[int](
625 doc=
"The number of bins along each axis when determining background", default=5
627 min_bin_fraction = Field[float](
628 doc=
"Bins with fewer pixels than this fraction of the total will be ignored", default=0.1
631 pos_sigma_multiplier = Field[float](
632 doc=
"How many sigma to consider as background in the positive direction", default=2
636class PrettyPictureBackgroundFixerTask(PipelineTask):
637 """Empirically flatten an images background.
639 Many astrophysical images have backgrounds with imperfections in them.
640 This Task attempts to determine control points which are considered
641 background values, and fits a radial basis function model to those
642 points. This model is then subtracted off the image.
646 _DefaultName =
"prettyPictureBackgroundFixer"
647 ConfigClass = PrettyPictureBackgroundFixerConfig
651 def _tile_slices(self, arr, R, C):
652 """Generate slices for tiling an array.
654 This function divides an array into a grid of tiles and returns a list of
655 slice objects representing each tile. It handles cases where the array
656 dimensions are not evenly divisible by the number of tiles in each
657 dimension, distributing the remainder among the tiles.
661 arr : `numyp.ndarray`
662 The input array to be tiled. Used only to determine the array's shape.
664 The number of tiles in the row dimension.
666 The number of tiles in the column dimension.
670 slices : `list` of `tuple`
671 A list of tuples, where each tuple contains two `slice` objects
672 representing the row and column slices for a single tile.
678 def get_slices(total_size: int, num_divisions: int) -> list[tuple[int, int]]:
679 """Generate slice ranges for dividing a size into equal parts.
684 Total size to be divided into slices.
685 num_divisions : `int`
686 Number of divisions to create.
690 `list` of `tuple` of `int`
691 List of (start, end) tuples representing each slice.
695 This function divides the total_size into num_divisions equal parts.
696 If the division is not exact, the remainder is distributed by adding
697 1 to the first 'remainder' slices, ensuring balanced distribution.
699 base = total_size // num_divisions
700 remainder = total_size % num_divisions
703 for i
in range(num_divisions):
707 slices.append((start, end))
712 row_slices = get_slices(M, R)
713 col_slices = get_slices(N, C)
717 for rs
in row_slices:
719 for cs
in col_slices:
721 tile_slice = (slice(r_start, r_end), slice(c_start, c_end))
722 tiles.append(tile_slice)
727 def findBackgroundPixels(image, pos_sigma_mult=1):
728 """Find pixels that are likely to be background based on image statistics.
730 This method estimates background pixels by analyzing the distribution of
731 pixel values in the image. It uses the median as an estimate of the background
732 level and fits a half-normal distribution to values below the median to
733 determine the background sigma. Pixels below a threshold (mean + sigma) are
734 classified as background.
738 image : `numpy.ndarray`
739 Input image array for which to find background pixels.
740 pos_sigma_mult : `float`
741 How many sigma to consider as background in the positive direction
745 result : `numpy.ndarray`
746 Boolean mask array where True indicates background pixels.
750 This method works best for images with relatively uniform background. It may
751 not perform well in fields with high density or diffuse flux, as noted in
752 the implementation comments.
757 maxLikely = np.median(image, axis=
None)
762 mask = image < maxLikely
763 initial_std = (image[mask] - maxLikely).
std()
769 mu_hat, sigma_hat = halfnorm.fit(np.abs(image[mask] - maxLikely))
772 mu_hat, sigma_hat = (maxLikely, 2 * initial_std)
776 threshhold = mu_hat + pos_sigma_mult * sigma_hat
777 image_mask = (image < threshhold) * (image > (mu_hat - 5 * sigma_hat))
781 """Estimate and subtract the background from an image.
783 This function estimates the background level in an image using a median-based
784 approach combined with Gaussian fitting and radial basis function interpolation.
785 It aims to provide a more accurate background estimation than a simple median
786 filter, especially in images with varying background levels.
790 image : `numpy.ndarray`
791 The input image as a NumPy array.
796 An array representing the estimated background level across the image.
798 if detection_mask
is None:
799 image_mask = self.findBackgroundPixels(image, self.config.pos_sigma_multiplier)
801 image_mask = detection_mask
804 tiles = self._tile_slices(image, self.config.num_background_bins, self.config.num_background_bins)
812 for xslice, yslice
in tiles:
813 ypos = (yslice.stop - yslice.start) / 2 + yslice.start
814 xpos = (xslice.stop - xslice.start) / 2 + xslice.start
815 window = image[yslice, xslice][image_mask[yslice, xslice]]
817 min_fill = int((yslice.stop - yslice.start) ** 2 * self.config.min_bin_fraction)
818 if window.size > min_fill:
819 value = np.median(window)
828 return np.zeros(image.shape)
831 inter = RBFInterpolator(
832 np.vstack((yloc, xloc)).T,
834 kernel=
"thin_plate_spline",
840 backgrounds = rbf_interpolator.fast_rbf_interpolation_on_grid(inter, image.shape)
844 def run(self, inputCoadd: Exposure):
845 """Estimate a background for an input Exposure and remove it.
849 inputCoadd : `Exposure`
850 The exposure the background will be removed from.
855 A `Struct` that contains the exposure with the background removed.
856 This `Struct` will have an attribute named ``outputCoadd``.
859 if self.config.use_detection_mask:
860 mask_plane_dict = inputCoadd.mask.getMaskPlaneDict()
861 detection_mask = ~(inputCoadd.mask.array & 2 ** mask_plane_dict[
"DETECTED"])
863 detection_mask =
None
864 background = self.fixBackground(inputCoadd.image.array, detection_mask=detection_mask)
866 output = ExposureF(inputCoadd, deep=
True)
867 output.image.array -= background
868 return Struct(outputCoadd=output)
872 PipelineTaskConnections,
873 dimensions=(
"tract",
"patch",
"skymap"),
876 doc=(
"Input coadd for which the background is to be removed"),
877 name=
"pretty_picture_coadd_bg_subtracted",
878 storageClass=
"ExposureF",
879 dimensions=(
"tract",
"patch",
"skymap",
"band"),
882 outputCoadd = Output(
883 doc=
"The coadd with the background fixed and subtracted",
884 name=
"pretty_picture_coadd_fixed_stars",
885 storageClass=
"ExposureF",
886 dimensions=(
"tract",
"patch",
"skymap",
"band"),
892 brightnessThresh = Field[float](
893 doc=
"The flux value below which pixels with SAT or NO_DATA bits will be ignored"
898 """This class fixes up regions in an image where there is no, or bad data.
900 The fixes done by this task are overwhelmingly comprised of the cores of
901 bright stars for which there is no data.
904 _DefaultName =
"prettyPictureStarFixer"
905 ConfigClass = PrettyPictureStarFixerConfig
909 def run(self, inputs: Mapping[str, ExposureF]) -> Struct:
910 """Fix areas in an image where this is no data, most likely to be
911 the cores of bright stars.
913 Because we want to have consistent fixes accross bands, this method
914 relies on supplying all bands and fixing pixels that are marked
915 as having a defect in any band even if within one band there is
920 inputs : `Mapping` of `str` to `ExposureF`
921 This mapping has keys of band as a `str` and the corresponding
922 ExposureF as a value.
926 results : `Struct` of `Mapping` of `str` to `ExposureF`
927 A `Struct` that has a mapping of band to `ExposureF`. The `Struct`
928 has an attribute named ``results``.
932 doJointMaskInit =
True
933 for imageExposure
in inputs.values():
934 maskDict = imageExposure.mask.getMaskPlaneDict()
936 jointMask = np.zeros(imageExposure.mask.array.shape, dtype=imageExposure.mask.array.dtype)
937 doJointMaskInit =
False
938 jointMask |= imageExposure.mask.array
940 sat_bit = maskDict[
"SAT"]
941 no_data_bit = maskDict[
"NO_DATA"]
942 together = (jointMask & 2**sat_bit).astype(bool) | (jointMask & 2**no_data_bit).astype(bool)
945 bright_mask = imageExposure.image.array > self.config.brightnessThresh
950 both = together & bright_mask
951 struct = np.array(((0, 1, 0), (1, 1, 1), (0, 1, 0)), dtype=bool)
952 both = binary_dilation(both, struct, iterations=4).astype(bool)
956 for band, imageExposure
in inputs.items():
958 inpainted = inpaint_biharmonic(imageExposure.image.array, both, split_into_regions=
True)
959 imageExposure.image.array[both] = inpainted[both]
960 results[band] = imageExposure
961 return Struct(results=results)
965 butlerQC: QuantumContext,
966 inputRefs: InputQuantizedConnection,
967 outputRefs: OutputQuantizedConnection,
969 refs = inputRefs.inputCoadd
970 sortedImages: dict[str, Exposure] = {}
972 key: str = cast(str, ref.dataId[
"band"])
973 image = butlerQC.get(ref)
974 sortedImages[key] = image
976 outputs = self.
run(sortedImages).results
978 for ref
in outputRefs.outputCoadd:
979 sortedOutputs[ref.dataId[
"band"]] = ref
981 for band, data
in outputs.items():
982 butlerQC.put(data, sortedOutputs[band])
987 doc=
"Individual RGB images that are to go into the mosaic",
988 name=
"rgb_picture_array",
989 storageClass=
"NumpyArray",
990 dimensions=(
"tract",
"patch",
"skymap"),
996 doc=
"The skymap which the data has been mapped onto",
997 storageClass=
"SkyMap",
998 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
999 dimensions=(
"skymap",),
1002 inputRGBMask = Input(
1003 doc=
"Individual RGB images that are to go into the mosaic",
1004 name=
"rgb_picture_mask",
1005 storageClass=
"Mask",
1006 dimensions=(
"tract",
"patch",
"skymap"),
1011 outputRGBMosaic = Output(
1012 doc=
"A RGB mosaic created from the input data stored as a 3d array",
1013 name=
"rgb_mosaic_array",
1014 storageClass=
"NumpyArray",
1015 dimensions=(
"tract",
"skymap"),
1020 binFactor = Field[int](doc=
"The factor to bin by when producing the mosaic")
1021 doDCID65Convert = Field[bool](
"Force the output to be converted from display p3 to DCI-D65 colorspace.")
1022 useLocalTemp = Field[bool](doc=
"Use the current directory when creating local temp files.", default=
False)
1026 """Combines multiple RGB arrays into one mosaic."""
1028 _DefaultName =
"prettyMosaic"
1029 ConfigClass = PrettyMosaicConfig
1035 inputRGB: Iterable[DeferredDatasetHandle],
1037 inputRGBMask: Iterable[DeferredDatasetHandle],
1039 r"""Assemble individual `numpy.ndarrays` into a mosaic.
1041 Each input is a `~lsst.daf.butler.DeferredDatasetHandle` because
1042 they're loaded in one at a time to be placed into the mosaic to save
1047 inputRGB : `Iterable` of `~lsst.daf.butler.DeferredDatasetHandle`
1048 `~lsst.daf.butler.DeferredDatasetHandle`\ s pointing to RGB
1050 skyMap : `BaseSkyMap`
1051 The skymap that defines the relative position of each of the input
1053 inputRGBMask : `Iterable` of `~lsst.daf.butler.DeferredDatasetHandle`
1054 `~lsst.daf.butler.DeferredDatasetHandle`\ s pointing to masks for
1055 each of the corresponding images.
1060 The `Struct` containing the combined mosaic. The `Struct` has
1061 and attribute named ``outputRGBMosaic``.
1068 for handle
in inputRGB:
1069 dataId = handle.dataId
1070 tractInfo: TractInfo = skyMap[dataId[
"tract"]]
1071 patchInfo: PatchInfo = tractInfo[dataId[
"patch"]]
1072 bbox = patchInfo.getOuterBBox()
1074 newBox.include(bbox)
1075 tractMaps.append(tractInfo)
1078 patch_grow: int = patchInfo.getCellInnerDimensions().getX()
1083 origin = newBox.getBegin()
1084 for iterBox
in boxes:
1085 localOrigin = iterBox.getBegin() - origin
1087 x=int(np.floor(localOrigin.x / self.config.binFactor)),
1088 y=int(np.floor(localOrigin.y / self.config.binFactor)),
1091 x=int(np.floor(iterBox.getWidth() / self.config.binFactor)),
1092 y=int(np.floor(iterBox.getHeight() / self.config.binFactor)),
1094 tmpBox =
Box2I(localOrigin, localExtent)
1095 modifiedBoxes.append(tmpBox)
1096 boxes = modifiedBoxes
1101 x=int(np.floor(newBox.getWidth() / self.config.binFactor)),
1102 y=int(np.floor(newBox.getHeight() / self.config.binFactor)),
1104 newBox =
Box2I(newBoxOrigin, newBoxExtent)
1107 self.
imageHandle = tempfile.NamedTemporaryFile(dir=
"." if self.config.useLocalTemp
else None)
1108 self.
maskHandle = tempfile.NamedTemporaryFile(dir=
"." if self.config.useLocalTemp
else None)
1109 consolidatedImage =
None
1110 consolidatedMask =
None
1113 d65 = copy.deepcopy(colour.models.RGB_COLOURSPACE_DCI_P3)
1114 dp3 = copy.deepcopy(colour.models.RGB_COLOURSPACE_DISPLAY_P3)
1115 d65.whitepoint = dp3.whitepoint
1116 d65.whitepoint_name = dp3.whitepoint_name
1121 for box, handle, handleMask, tractInfo
in zip(boxes, inputRGB, inputRGBMask, tractMaps):
1124 if self.config.doDCID65Convert:
1125 rgb = colour.RGB_to_RGB(np.clip(rgb, 0, 1), dp3, d65)
1126 rgbMask = handleMask.get()
1127 maskDict = rgbMask.getMaskPlaneDict()
1129 if consolidatedImage
is None:
1130 consolidatedImage = np.memmap(
1133 shape=(newBox.getHeight(), newBox.getWidth(), 3),
1136 if consolidatedMask
is None:
1137 consolidatedMask = np.memmap(
1140 shape=(newBox.getHeight(), newBox.getWidth()),
1141 dtype=rgbMask.array.dtype,
1144 if self.config.binFactor > 1:
1146 shape = tuple(box.getDimensions())[::-1]
1151 fx=shape[0] / self.config.binFactor,
1152 fy=shape[1] / self.config.binFactor,
1154 mask_array = rgbMask.array[:: self.config.binFactor, :: self.config.binFactor]
1155 rgbMask =
Mask(*(mask_array.shape[::-1]))
1156 mosaic_maker.add_to_image(consolidatedImage, rgb, newBox, box)
1158 consolidatedMask[*box.slices] = np.bitwise_or(consolidatedMask[*box.slices], rgbMask.array)
1160 for plugin
in plugins.full():
1161 if consolidatedImage
is not None and consolidatedMask
is not None:
1162 consolidatedImage = plugin(consolidatedImage, consolidatedMask, maskDict)
1165 if consolidatedImage
is None:
1166 consolidatedImage = np.zeros((0, 0, 0), dtype=np.uint8)
1168 return Struct(outputRGBMosaic=consolidatedImage)
1172 butlerQC: QuantumContext,
1173 inputRefs: InputQuantizedConnection,
1174 outputRefs: OutputQuantizedConnection,
1176 inputs = butlerQC.get(inputRefs)
1177 outputs = self.
run(**inputs)
1178 butlerQC.put(outputs, outputRefs)
1179 if hasattr(self,
"imageHandle"):
1181 if hasattr(self,
"maskHandle"):
1185 self, inputs: Iterable[tuple[Mapping[str, Any], NDArray]]
1186 ) -> Iterable[DeferredDatasetHandle]:
1187 r"""Make valid inputs for the run method from numpy arrays.
1191 inputs : `Iterable` of `tuple` of `Mapping` and `numpy.ndarray`
1192 An iterable where each element is a tuple with the first
1193 element is a mapping that corresponds to an arrays dataId,
1194 and the second is an `numpy.ndarray`.
1198 sortedImages : `Iterable` of `~lsst.daf.butler.DeferredDatasetHandle`
1199 An iterable of `~lsst.daf.butler.DeferredDatasetHandle`\ s
1200 containing the input data.
1202 structuredInputs = []
1203 for dataId, array
in inputs:
1204 structuredInputs.append(InMemoryDatasetHandle(inMemoryDataset=array, **dataId))
1206 return structuredInputs
Iterable[DeferredDatasetHandle] makeInputsFromArrays(self, Iterable[tuple[Mapping[str, Any], NDArray]] inputs)
Struct run(self, Iterable[DeferredDatasetHandle] inputRGB, BaseSkyMap skyMap, Iterable[DeferredDatasetHandle] inputRGBMask)
None runQuantum(self, QuantumContext butlerQC, InputQuantizedConnection inputRefs, OutputQuantizedConnection outputRefs)
None runQuantum(self, QuantumContext butlerQC, InputQuantizedConnection inputRefs, OutputQuantizedConnection outputRefs)
Struct run(self, Mapping[str, ExposureF] inputs)
RGBImage lsstRGB(FloatImagePlane rArray, FloatImagePlane gArray, FloatImagePlane bArray, LocalContrastFunction|None|_SentinalDefault local_contrast=DEFAULT_FUNCTION, ScaleLumFunction|None|_SentinalDefault scale_lum=DEFAULT_FUNCTION, ScaleColorFunction|None|_SentinalDefault scale_color=DEFAULT_FUNCTION, RemapBoundsFunction|None|_SentinalDefault remap_bounds=DEFAULT_FUNCTION, BracketingFunction|None|_SentinalDefault bracketing_function=DEFAULT_FUNCTION, GamutRemappingFunction|None|_SentinalDefault gamut_remapping_function=DEFAULT_FUNCTION, FloatImagePlane|None psf=None, tuple[float, float] cieWhitePoint=(0.28, 0.28))
dict[str, Exposure] makeInputsFromRefs(self, Iterable[DatasetRef] refs, Butler|QuantumContext butler)
Struct run(self, Mapping[str, Exposure] images)
dict[int, DeferredDatasetHandle] makeInputsFromExposures(self, **kwargs)
fixBackground(self, image, detection_mask=None)
dict[str, DeferredDatasetHandle] makeInputsFromArrays(self, **kwargs)
None runQuantum(self, QuantumContext butlerQC, InputQuantizedConnection inputRefs, OutputQuantizedConnection outputRefs)