22from __future__
import annotations
27 "PrettyPictureConnections",
28 "PrettyPictureConfig",
30 "PrettyMosaicConnections",
32 "PrettyPictureBackgroundFixerConfig",
33 "PrettyPictureBackgroundFixerTask",
34 "PrettyPictureStarFixerConfig",
35 "PrettyPictureStarFixerTask",
38from collections.abc
import Iterable, Mapping
41from typing
import TYPE_CHECKING, cast, Any
44from scipy.stats
import norm, halfnorm, mode
45from scipy.ndimage
import binary_dilation
46from scipy.interpolate
import RBFInterpolator
47from skimage.restoration
import inpaint_biharmonic
49from lsst.daf.butler
import Butler, DeferredDatasetHandle
50from lsst.daf.butler
import DatasetRef
51from lsst.pex.config import Field, Config, ConfigDictField, ConfigField, ListField, ChoiceField
52from lsst.pipe.base
import (
55 PipelineTaskConnections,
57 InMemoryDatasetHandle,
61from lsst.pipe.base.connectionTypes
import Input, Output
62from lsst.geom import Box2I, Point2I, Extent2I
65from ._plugins
import plugins
66from ._colorMapper
import lsstRGB
72 from numpy.typing
import NDArray
73 from lsst.pipe.base
import QuantumContext, InputQuantizedConnection, OutputQuantizedConnection
78 PipelineTaskConnections,
79 dimensions={
"tract",
"patch",
"skymap"},
80 defaultTemplates={
"coaddTypeName":
"deep"},
84 "Model of the static sky, used to find temporal artifacts. Typically a PSF-Matched, "
85 "sigma-clipped coadd. Written if and only if assembleStaticSkyModel.doWrite=True"
88 storageClass=
"ExposureF",
89 dimensions=(
"tract",
"patch",
"skymap",
"band"),
94 doc=
"A RGB image created from the input data stored as a 3d array",
95 name=
"rgb_picture_array",
96 storageClass=
"NumpyArray",
97 dimensions=(
"tract",
"patch",
"skymap"),
100 outputRGBMask = Output(
101 doc=
"A Mask corresponding to the fused masks of the input channels",
102 name=
"rgb_picture_mask",
104 dimensions=(
"tract",
"patch",
"skymap"),
108class ChannelRGBConfig(Config):
109 """This describes the rgb values of a given input channel.
111 For instance if this channel is red the values would be self.r = 1,
112 self.g = 0, self.b = 0. If the channel was cyan the values would be
113 self.r = 0, self.g = 1, self.b = 1.
116 r = Field[float](doc=
"The amount of red contained in this channel")
117 g = Field[float](doc=
"The amount of green contained in this channel")
118 b = Field[float](doc=
"The amount of blue contained in this channel")
121class LumConfig(Config):
122 """Configurations to control how luminance is mapped in the rgb code"""
124 stretch = Field[float](doc=
"The stretch of the luminance in asinh", default=400)
125 max = Field[float](doc=
"The maximum allowed luminance on a 0 to 100 scale", default=85)
126 floor = Field[float](doc=
"A scaling factor to apply to the luminance before asinh scaling", default=0.0)
127 Q = Field[float](doc=
"softening parameter", default=0.7)
128 highlight = Field[float](
129 doc=
"The value of highlights in scaling factor applied to post asinh streaching", default=1.0
131 shadow = Field[float](
132 doc=
"The value of shadows in scaling factor applied to post asinh streaching", default=0.0
134 midtone = Field[float](
135 doc=
"The value of midtone in scaling factor applied to post asinh streaching", default=0.5
139class LocalContrastConfig(Config):
140 """Configuration to control local contrast enhancement of the luminance
143 doLocalContrast = Field[bool](
144 doc=
"Apply local contrast enhancements to the luminance channel", default=
True
146 highlights = Field[float](doc=
"Adjustment factor for the highlights", default=-0.9)
147 shadows = Field[float](doc=
"Adjustment factor for the shadows", default=0.5)
148 clarity = Field[float](doc=
"Amount of clarity to apply to contrast modification", default=0.1)
149 sigma = Field[float](
150 doc=
"The scale size of what is considered local in the contrast enhancement", default=30
152 maxLevel = Field[int](
153 doc=
"The maximum number of scales the contrast should be enhanced over, if None then all",
159class ScaleColorConfig(Config):
160 """Controls color scaling in the RGB generation process."""
162 saturation = Field[float](
164 "The overall saturation factor with the scaled luminance between zero and one. "
165 "A value of one is not recommended as it makes bright pixels very saturated"
169 maxChroma = Field[float](
171 "The maximum chromaticity in the CIELCh color space, large "
172 "values will cause bright pixels to fall outside the RGB gamut."
178class RemapBoundsConfig(Config):
179 """Remaps input images to a known range of values.
181 Often input images are not mapped to any defined range of values
182 (for instance if they are in count units). This controls how the units of
183 and image are mapped to a zero to one range by determining an upper
187 quant = Field[float](
189 "The maximum values of each of the three channels will be multiplied by this factor to "
190 "determine the maximum flux of the image, values larger than this quantity will be clipped."
194 absMax = Field[float](
195 doc=
"Instead of determining the maximum value from the image, use this fixed value instead",
201class PrettyPictureConfig(PipelineTaskConfig, pipelineConnections=PrettyPictureConnections):
202 channelConfig = ConfigDictField(
203 doc=
"A dictionary that maps band names to their rgb channel configurations",
205 itemtype=ChannelRGBConfig,
208 imageRemappingConfig = ConfigField[RemapBoundsConfig](
209 doc=
"Configuration controlling channel normalization process"
211 luminanceConfig = ConfigField[LumConfig](
212 doc=
"Configuration for the luminance scaling when making an RGB image"
214 localContrastConfig = ConfigField[LocalContrastConfig](
215 doc=
"Configuration controlling the local contrast correction in RGB image production"
217 colorConfig = ConfigField[ScaleColorConfig](
218 doc=
"Configuration to control the color scaling process in RGB image production"
220 cieWhitePoint = ListField[float](
221 doc=
"The white point of the input arrays in ciexz coordinates", maxLength=2, default=[0.28, 0.28]
223 arrayType = ChoiceField[str](
224 doc=
"The dataset type for the output image array",
227 "uint8":
"Use 8 bit arrays, 255 max",
228 "uint16":
"Use 16 bit arrays, 65535 max",
229 "half":
"Use 16 bit float arrays, 1 max",
230 "float":
"Use 32 bit float arrays, 1 max",
233 doPSFDeconcovlve = Field[bool](
234 doc=
"Use the PSF in a richardson lucy deconvolution on the luminance channel.", default=
True
236 exposureBrackets = ListField[float](
238 "Exposure scaling factors used in creating multiple exposures with different scalings which will "
239 "then be fused into a final image"
242 default=[1.25, 1, 0.75],
244 doRemapGamut = Field[bool](
245 doc=
"Apply a color correction to unrepresentable colors, if false they will clip", default=
True
247 gamutMethod = ChoiceField[str](
248 doc=
"If doRemapGamut is True this determines the method",
251 "mapping":
"Use a mapping function",
252 "inpaint":
"Use surrounding pixels to determine likely value",
255 recenterNoise = Field[float](
256 doc=
"Recenter the noise away from zero. Supplied value is in units of sigma",
260 noiseSearchThreshold = Field[float](
262 "Flux threshold below which most flux will be considered noise, used to estimate noise properties"
267 def setDefaults(self):
268 self.channelConfig[
"i"] = ChannelRGBConfig(r=1, g=0, b=0)
269 self.channelConfig[
"r"] = ChannelRGBConfig(r=0, g=1, b=0)
270 self.channelConfig[
"g"] = ChannelRGBConfig(r=0, g=0, b=1)
271 return super().setDefaults()
274class PrettyPictureTask(PipelineTask):
275 """Turns inputs into an RGB image."""
277 _DefaultName =
"prettyPictureTask"
278 ConfigClass = PrettyPictureConfig
282 def _find_normal_stats(self, array):
283 """Calculate standard deviation from negative values using half-normal distribution.
287 array : `numpy.array`
288 Input array of numerical values.
293 The central moment of the distribution
295 Estimated standard deviation from negative values. Returns np.inf if:
296 - No negative values exist in the array
297 - Half-normal fitting fails
300 values_noise = array[array < self.config.noiseSearchThreshold]
303 center = mode(np.round(values_noise, 2)).mode
306 values_neg = array[array < center]
309 if values_neg.size == 0:
314 mu, sigma = halfnorm.fit(np.abs(values_neg))
315 except (ValueError, RuntimeError):
321 def _match_sigmas_and_recenter(self, *arrays, factor=1):
322 """Scale array values to match minimum standard deviation across arrays
325 Adjusts values below each array's sigma by scaling and shifting them to
326 align with the minimum sigma value across all input arrays. This operates
327 in-place for efficiency.
331 *arrays : any number of `numpy.array`
332 Variable number of input arrays to process.
333 factor : float, optional
334 Scaling factor for adjustments (default: 1).
341 m, s = self._find_normal_stats(arr)
345 sigmas = np.array(sigmas)
349 if not np.any(np.isfinite(sigmas)):
352 min_sig = np.min(sigmas)
354 for mu, sigma, array
in zip(mus, sigmas, arrays):
356 lower_pos = (array - mu) < sigma
359 if not np.isfinite(sigma):
363 sigma_ratio = min_sig / sigma
366 array[lower_pos] = (array[lower_pos] - mu) * sigma_ratio + min_sig * factor
368 def run(self, images: Mapping[str, Exposure]) -> Struct:
369 """Turns the input arguments in arguments into an RGB array.
373 images : `Mapping` of `str` to `Exposure`
374 A mapping of input images and the band they correspond to.
379 A struct with the corresponding RGB image, and mask used in
380 RGB image construction. The struct will have the attributes
381 outputRGBImage and outputRGBMask. Each of the outputs will
382 be a `NDarray` object.
386 Construction of input images are made easier by use of the
387 makeInputsFrom* methods.
391 jointMask:
None | NDArray =
None
392 maskDict: Mapping[str, int] = {}
393 doJointMaskInit =
False
394 if jointMask
is None:
396 doJointMaskInit =
True
397 for channel, imageExposure
in images.items():
398 imageArray = imageExposure.image.array
400 for plug
in plugins.channel():
402 imageArray, imageExposure.mask.array, imageExposure.mask.getMaskPlaneDict(), self.config
404 channels[channel] = imageArray
407 shape = imageArray.shape
408 maskDict = imageExposure.mask.getMaskPlaneDict()
410 jointMask = np.zeros(shape, dtype=imageExposure.mask.dtype)
411 doJointMaskInit =
False
413 jointMask |= imageExposure.mask.array
416 imageRArray = np.zeros(shape, dtype=np.float32)
417 imageGArray = np.zeros(shape, dtype=np.float32)
418 imageBArray = np.zeros(shape, dtype=np.float32)
420 for band, image
in channels.items():
421 mix = self.config.channelConfig[band]
423 imageRArray += mix.r * image
425 imageGArray += mix.g * image
427 imageBArray += mix.b * image
429 exposure = next(iter(images.values()))
430 box: Box2I = exposure.getBBox()
431 boxCenter = box.getCenter()
433 psf = exposure.psf.computeImage(boxCenter).array
437 if self.config.recenterNoise:
438 self._match_sigmas_and_recenter(
439 imageRArray, imageGArray, imageBArray, factor=self.config.recenterNoise
443 assert jointMask
is not None
445 colorImage = np.zeros((*imageRArray.shape, 3))
446 colorImage[:, :, 0] = imageRArray
447 colorImage[:, :, 1] = imageGArray
448 colorImage[:, :, 2] = imageBArray
449 for plug
in plugins.partial():
450 colorImage = plug(colorImage, jointMask, maskDict, self.config)
458 scaleLumKWargs=self.config.luminanceConfig.toDict(),
459 remapBoundsKwargs=self.config.imageRemappingConfig.toDict(),
460 scaleColorKWargs=self.config.colorConfig.toDict(),
461 **(self.config.localContrastConfig.toDict()),
462 cieWhitePoint=tuple(self.config.cieWhitePoint),
463 psf=psf
if self.config.doPSFDeconcovlve
else None,
464 brackets=list(self.config.exposureBrackets)
if self.config.exposureBrackets
else None,
465 doRemapGamut=self.config.doRemapGamut,
466 gamutMethod=self.config.gamutMethod,
471 match self.config.arrayType:
485 assert True,
"This code path should be unreachable"
491 lsstMask =
Mask(width=jointMask.shape[1], height=jointMask.shape[0], planeDefs=maskDict)
492 lsstMask.array = jointMask
493 return Struct(outputRGB=colorImage.astype(dtype), outputRGBMask=lsstMask)
497 butlerQC: QuantumContext,
498 inputRefs: InputQuantizedConnection,
499 outputRefs: OutputQuantizedConnection,
501 imageRefs: list[DatasetRef] = inputRefs.inputCoadds
502 sortedImages = self.makeInputsFromRefs(imageRefs, butlerQC)
503 outputs = self.run(sortedImages)
504 butlerQC.put(outputs, outputRefs)
507 self, refs: Iterable[DatasetRef], butler: Butler | QuantumContext
508 ) -> dict[str, Exposure]:
509 r"""Make valid inputs for the run method from butler references.
513 refs : `Iterable` of `DatasetRef`
514 Some `Iterable` container of `Butler` `DatasetRef`\ s
515 butler : `Butler` or `QuantumContext`
516 This is the object that fetches the input data.
520 sortedImages : `dict` of `str` to `Exposure`
521 A dictionary of `Exposure`\ s that keyed by the band they
524 sortedImages: dict[str, Exposure] = {}
526 key: str = cast(str, ref.dataId[
"band"])
527 image = butler.get(ref)
528 sortedImages[key] = image
532 r"""Make valid inputs for the run method from numpy arrays.
537 This is standard python kwargs where the left side of the equals
538 is the data band, and the right side is the corresponding `NDArray`
543 sortedImages : `dict` of `str` to `Exposure`
544 A dictionary of `Exposure`\ s that keyed by the band they
549 for key, array
in kwargs.items():
551 temp[key].image.array[:] = array
553 return self.makeInputsFromExposures(**temp)
556 r"""Make valid inputs for the run method from `Exposure` objects.
561 This is standard python kwargs where the left side of the equals
562 is the data band, and the right side is the corresponding
567 sortedImages : `dict` of `str` to `Exposure`
568 A dictionary of `Exposure`\ s that keyed by the band they
572 for key, value
in kwargs.items():
573 sortedImages[key] = value
578 PipelineTaskConnections,
579 dimensions=(
"tract",
"patch",
"skymap",
"band"),
580 defaultTemplates={
"coaddTypeName":
"deep"},
583 doc=(
"Input coadd for which the background is to be removed"),
584 name=
"{coaddTypeName}CoaddPsfMatched",
585 storageClass=
"ExposureF",
586 dimensions=(
"tract",
"patch",
"skymap",
"band"),
588 outputCoadd = Output(
589 doc=
"The coadd with the background fixed and subtracted",
590 name=
"pretty_picture_coadd_bg_subtracted",
591 storageClass=
"ExposureF",
592 dimensions=(
"tract",
"patch",
"skymap",
"band"),
596class PrettyPictureBackgroundFixerConfig(
597 PipelineTaskConfig, pipelineConnections=PrettyPictureBackgroundFixerConnections
602class PrettyPictureBackgroundFixerTask(PipelineTask):
603 """Empirically flatten an images background.
605 Many astrophysical images have backgrounds with imperfections in them.
606 This Task attempts to determine control points which are considered
607 background values, and fits a radial basis function model to those
608 points. This model is then subtracted off the image.
612 _DefaultName =
"prettyPictureBackgroundFixerTask"
613 ConfigClass = PrettyPictureBackgroundFixerConfig
617 def _neg_log_likelihood(self, params, x):
618 """Calculate the negative log-likelihood for a Gaussian distribution.
620 This function computes the negative log-likelihood of a set of data `x`
621 given a Gaussian distribution with parameters `mu` and `sigma`. It's
622 designed to be used as the objective function for a minimization routine
623 to find the best-fit Gaussian parameters.
628 A tuple containing the mean (`mu`) and standard deviation (`sigma`)
629 of the Gaussian distribution.
631 The data samples for which to calculate the log-likelihood.
636 The negative log-likelihood of the data given the Gaussian parameters.
637 Returns infinity if sigma is non-positive or if the mean is less than
638 the maximum value in x (to enforce the constraint that the Gaussian
639 only models the lower tail of the distribution).
648 term = np.log(2) - np.log(sigma) + norm.logpdf(z)
649 loglikelihood = np.sum(term)
650 return -loglikelihood
652 def _tile_slices(self, arr, R, C):
653 """Generate slices for tiling an array.
655 This function divides an array into a grid of tiles and returns a list of
656 slice objects representing each tile. It handles cases where the array
657 dimensions are not evenly divisible by the number of tiles in each
658 dimension, distributing the remainder among the tiles.
663 The input array to be tiled. Used only to determine the array's shape.
665 The number of tiles in the row dimension.
667 The number of tiles in the column dimension.
671 slices : `list` of `tuple`
672 A list of tuples, where each tuple contains two `slice` objects
673 representing the row and column slices for a single tile.
679 def get_slices(total_size, num_divisions):
680 base = total_size // num_divisions
681 remainder = total_size % num_divisions
684 for i
in range(num_divisions):
688 slices.append((start, end))
693 row_slices = get_slices(M, R)
694 col_slices = get_slices(N, C)
698 for rs
in row_slices:
700 for cs
in col_slices:
702 tile_slice = (slice(r_start, r_end), slice(c_start, c_end))
703 tiles.append(tile_slice)
707 def fixBackground(self, image):
708 """Estimate and subtract the background from an image.
710 This function estimates the background level in an image using a median-based
711 approach combined with Gaussian fitting and radial basis function interpolation.
712 It aims to provide a more accurate background estimation than a simple median
713 filter, especially in images with varying background levels.
718 The input image as a NumPy array.
723 An array representing the estimated background level across the image.
728 maxLikely = np.median(image, axis=
None)
733 mask = image < maxLikely
734 initial_std = (image[mask] - maxLikely).
std()
740 _, sigma_hat = halfnorm.fit(np.abs(image[mask] - maxLikely))
743 mu_hat, sigma_hat = (maxLikely, 2 * initial_std)
747 threshhold = mu_hat + sigma_hat
748 image_mask = image < threshhold
751 tiles = self._tile_slices(image, 25, 25)
759 for xslice, yslice
in tiles:
760 ypos = (yslice.stop - yslice.start) / 2 + yslice.start
761 xpos = (xslice.stop - xslice.start) / 2 + xslice.start
764 window = image[yslice, xslice][image_mask[yslice, xslice]]
766 value = np.median(window)
771 positions = np.meshgrid(np.arange(image.shape[0]), np.arange(image.shape[1]))
773 inter = RBFInterpolator(
774 np.vstack((yloc, xloc)).T, values, kernel=
"thin_plate_spline", degree=4, smoothing=0.05
776 backgrounds = inter(np.array(positions)[::-1].reshape(2, -1).T).reshape(image.shape)
780 def run(self, inputCoadd: Exposure):
781 """Estimate a background for an input Exposure and remove it.
785 inputCoadd : `Exposure`
786 The exposure the background will be removed from.
791 A `Struct` that contains the exposure with the background removed.
792 This `Struct` will have an attribute named ``outputCoadd``.
795 background = self.fixBackground(inputCoadd.image.array)
797 output = ExposureF(inputCoadd, deep=
True)
798 output.image.array -= background
799 return Struct(outputCoadd=output)
802class PrettyPictureStarFixerConnections(
803 PipelineTaskConnections,
804 dimensions=(
"tract",
"patch",
"skymap"),
807 doc=(
"Input coadd for which the background is to be removed"),
808 name=
"pretty_picture_coadd_bg_subtracted",
809 storageClass=
"ExposureF",
810 dimensions=(
"tract",
"patch",
"skymap",
"band"),
813 outputCoadd = Output(
814 doc=
"The coadd with the background fixed and subtracted",
815 name=
"pretty_picture_coadd_fixed_stars",
816 storageClass=
"ExposureF",
817 dimensions=(
"tract",
"patch",
"skymap",
"band"),
822class PrettyPictureStarFixerConfig(PipelineTaskConfig, pipelineConnections=PrettyPictureStarFixerConnections):
823 brightnessThresh = Field[float](
824 doc=
"The flux value below which pixels with SAT or NO_DATA bits will be ignored"
828class PrettyPictureStarFixerTask(PipelineTask):
829 """This class fixes up regions in an image where there is no, or bad data.
831 The fixes done by this task are overwhelmingly comprised of the cores of
832 bright stars for which there is no data.
835 _DefaultName =
"prettyPictureStarFixerTask"
836 ConfigClass = PrettyPictureStarFixerConfig
840 def run(self, inputs: Mapping[str, ExposureF]) -> Struct:
841 """Fix areas in an image where this is no data, most likely to be
842 the cores of bright stars.
844 Because we want to have consistent fixes accross bands, this method
845 relies on supplying all bands and fixing pixels that are marked
846 as having a defect in any band even if within one band there is
851 inputs : `Mapping` of `str` to `ExposureF`
852 This mapping has keys of band as a `str` and the corresponding
853 ExposureF as a value.
857 results : `Struct` of `Mapping` of `str` to `ExposureF`
858 A `Struct` that has a mapping of band to `ExposureF`. The `Struct`
859 has an attribute named ``results``.
863 doJointMaskInit =
True
864 for imageExposure
in inputs.values():
865 maskDict = imageExposure.mask.getMaskPlaneDict()
867 jointMask = np.zeros(imageExposure.mask.array.shape, dtype=imageExposure.mask.array.dtype)
868 doJointMaskInit =
False
869 jointMask |= imageExposure.mask.array
871 sat_bit = maskDict[
"SAT"]
872 no_data_bit = maskDict[
"NO_DATA"]
873 together = (jointMask & 2**sat_bit).astype(bool) | (jointMask & 2**no_data_bit).astype(bool)
876 bright_mask = imageExposure.image.array > self.config.brightnessThresh
881 both = together & bright_mask
882 struct = np.array(((0, 1, 0), (1, 1, 1), (0, 1, 0)), dtype=bool)
883 both = binary_dilation(both, struct, iterations=4).astype(bool)
887 for band, imageExposure
in inputs.items():
889 inpainted = inpaint_biharmonic(imageExposure.image.array, both, split_into_regions=
True)
890 imageExposure.image.array[both] = inpainted[both]
891 results[band] = imageExposure
892 return Struct(results=results)
896 butlerQC: QuantumContext,
897 inputRefs: InputQuantizedConnection,
898 outputRefs: OutputQuantizedConnection,
900 refs = inputRefs.inputCoadd
901 sortedImages: dict[str, Exposure] = {}
903 key: str = cast(str, ref.dataId[
"band"])
904 image = butlerQC.get(ref)
905 sortedImages[key] = image
907 outputs = self.run(sortedImages).results
909 for ref
in outputRefs.outputCoadd:
910 sortedOutputs[ref.dataId[
"band"]] = ref
912 for band, data
in outputs.items():
913 butlerQC.put(data, sortedOutputs[band])
916class PrettyMosaicConnections(PipelineTaskConnections, dimensions=(
"tract",
"skymap")):
918 doc=
"Individual RGB images that are to go into the mosaic",
919 name=
"rgb_picture_array",
920 storageClass=
"NumpyArray",
921 dimensions=(
"tract",
"patch",
"skymap"),
927 doc=
"The skymap which the data has been mapped onto",
928 storageClass=
"SkyMap",
929 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME,
930 dimensions=(
"skymap",),
933 inputRGBMask = Input(
934 doc=
"Individual RGB images that are to go into the mosaic",
935 name=
"rgb_picture_mask",
937 dimensions=(
"tract",
"patch",
"skymap"),
942 outputRGBMosaic = Output(
943 doc=
"A RGB mosaic created from the input data stored as a 3d array",
944 name=
"rgb_mosaic_array",
945 storageClass=
"NumpyArray",
946 dimensions=(
"tract",
"skymap"),
950class PrettyMosaicConfig(PipelineTaskConfig, pipelineConnections=PrettyMosaicConnections):
951 binFactor = Field[int](doc=
"The factor to bin by when producing the mosaic")
954class PrettyMosaicTask(PipelineTask):
955 """Combines multiple RGB arrays into one mosaic."""
957 _DefaultName =
"prettyMosaicTask"
958 ConfigClass = PrettyMosaicConfig
964 inputRGB: Iterable[DeferredDatasetHandle],
966 inputRGBMask: Iterable[DeferredDatasetHandle],
968 r"""Assemble individual `NDArrays` into a mosaic.
970 Each input is a `DeferredDatasetHandle` because they're loaded in one
971 at a time to be placed into the mosaic to save memory.
975 inputRGB : `Iterable` of `DeferredDatasetHandle`
976 `DeferredDatasetHandle`\ s pointing to RGB `NDArrays`.
977 skyMap : `BaseSkyMap`
978 The skymap that defines the relative position of each of the input
980 inputRGBMask : `Iterable` of `DeferredDatasetHandle`
981 `DeferredDatasetHandle`\ s pointing to masks for each of the
982 corresponding images.
987 The `Struct` containing the combined mosaic. The `Struct` has
988 and attribute named ``outputRGBMosaic``.
995 for handle
in inputRGB:
996 dataId = handle.dataId
997 tractInfo: TractInfo = skyMap[dataId[
"tract"]]
998 patchInfo: PatchInfo = tractInfo[dataId[
"patch"]]
999 bbox = patchInfo.getOuterBBox()
1001 newBox.include(bbox)
1002 tractMaps.append(tractInfo)
1007 origin = newBox.getBegin()
1008 for iterBox
in boxes:
1009 localOrigin = iterBox.getBegin() - origin
1011 x=int(np.floor(localOrigin.x / self.config.binFactor)),
1012 y=int(np.floor(localOrigin.y / self.config.binFactor)),
1015 x=int(np.floor(iterBox.getWidth() / self.config.binFactor)),
1016 y=int(np.floor(iterBox.getHeight() / self.config.binFactor)),
1018 tmpBox =
Box2I(localOrigin, localExtent)
1019 modifiedBoxes.append(tmpBox)
1020 boxes = modifiedBoxes
1025 x=int(np.floor(newBox.getWidth() / self.config.binFactor)),
1026 y=int(np.floor(newBox.getHeight() / self.config.binFactor)),
1028 newBox =
Box2I(newBoxOrigin, newBoxExtent)
1031 self.imageHandle = tempfile.NamedTemporaryFile()
1032 self.maskHandle = tempfile.NamedTemporaryFile()
1033 consolidatedImage =
None
1034 consolidatedMask =
None
1039 for box, handle, handleMask, tractInfo
in zip(boxes, inputRGB, inputRGBMask, tractMaps):
1041 rgbMask = handleMask.get()
1042 maskDict = rgbMask.getMaskPlaneDict()
1044 if consolidatedImage
is None:
1045 consolidatedImage = np.memmap(
1046 self.imageHandle.name,
1048 shape=(newBox.getHeight(), newBox.getWidth(), 3),
1051 if consolidatedMask
is None:
1052 consolidatedMask = np.memmap(
1053 self.maskHandle.name,
1055 shape=(newBox.getHeight(), newBox.getWidth()),
1056 dtype=rgbMask.array.dtype,
1059 if self.config.binFactor > 1:
1061 shape = tuple(box.getDimensions())[::-1]
1066 fx=shape[0] / self.config.binFactor,
1067 fy=shape[1] / self.config.binFactor,
1069 rgbMask = cv2.resize(
1070 rgbMask.array.astype(np.float32),
1073 fx=shape[0] / self.config.binFactor,
1074 fy=shape[1] / self.config.binFactor,
1076 existing = ~np.all(consolidatedImage[*box.slices] == 0, axis=2)
1077 if tmpImg
is None or tmpImg.shape != rgb.shape:
1078 ramp = np.linspace(0, 1, tractInfo.patch_border * 2)
1079 tmpImg = np.zeros(rgb.shape[:2])
1080 tmpImg[: tractInfo.patch_border * 2, :] = np.repeat(
1081 np.expand_dims(ramp, 1), tmpImg.shape[1], axis=1
1084 tmpImg[-1 * tractInfo.patch_border * 2 :, :] = np.repeat(
1085 np.expand_dims(1 - ramp, 1), tmpImg.shape[1], axis=1
1087 tmpImg[:, : tractInfo.patch_border * 2] = np.repeat(
1088 np.expand_dims(ramp, 0), tmpImg.shape[0], axis=0
1091 tmpImg[:, -1 * tractInfo.patch_border * 2 :] = np.repeat(
1092 np.expand_dims(1 - ramp, 0), tmpImg.shape[0], axis=0
1094 tmpImg = np.repeat(np.expand_dims(tmpImg, 2), 3, axis=2)
1096 consolidatedImage[*box.slices][~existing, :] = rgb[~existing, :]
1097 consolidatedImage[*box.slices][existing, :] = (
1098 tmpImg[existing] * rgb[existing]
1099 + (1 - tmpImg[existing]) * consolidatedImage[*box.slices][existing, :]
1102 tmpMask = np.zeros_like(rgbMask.array)
1103 tmpMask[existing] = np.bitwise_or(
1104 rgbMask.array[existing], consolidatedMask[*box.slices][existing]
1106 tmpMask[~existing] = rgbMask.array[~existing]
1107 consolidatedMask[*box.slices] = tmpMask
1109 for plugin
in plugins.full():
1110 if consolidatedImage
is not None and consolidatedMask
is not None:
1111 consolidatedImage = plugin(consolidatedImage, consolidatedMask, maskDict)
1114 if consolidatedImage
is None:
1115 consolidatedImage = np.zeros((0, 0, 0), dtype=np.uint8)
1117 return Struct(outputRGBMosaic=consolidatedImage)
1121 butlerQC: QuantumContext,
1122 inputRefs: InputQuantizedConnection,
1123 outputRefs: OutputQuantizedConnection,
1125 inputs = butlerQC.get(inputRefs)
1126 outputs = self.run(**inputs)
1127 butlerQC.put(outputs, outputRefs)
1128 if hasattr(self,
"imageHandle"):
1129 self.imageHandle.close()
1130 if hasattr(self,
"maskHandle"):
1131 self.maskHandle.close()
1134 self, inputs: Iterable[tuple[Mapping[str, Any], NDArray]]
1135 ) -> Iterable[DeferredDatasetHandle]:
1136 r"""Make valid inputs for the run method from numpy arrays.
1140 inputs : `Iterable` of `tuple` of `Mapping` and `NDArray`
1141 An iterable where each element is a tuble with the first
1142 element is a mapping that corresponds to an arrays dataId,
1143 and the second is an `NDArray`.
1147 sortedImages : `dict` of `str` to `Exposure`
1148 A dictionary of `Exposure`\ s that keyed by the band they
1151 structuredInputs = []
1152 for dataId, array
in inputs:
1153 structuredInputs.append(InMemoryDatasetHandle(inMemoryDataset=array, **dataId))
1155 return structuredInputs
NDArray lsstRGB(NDArray rArray, NDArray gArray, NDArray bArray, bool doLocalContrast=True, Callable[..., NDArray]|None scaleLum=latLum, Mapping|None scaleLumKWargs=None, Callable[..., tuple[NDArray, NDArray]]|None scaleColor=colorConstantSat, Mapping|None scaleColorKWargs=None, Callable|None remapBounds=mapUpperBounds, Mapping|None remapBoundsKwargs=None, tuple[float, float] cieWhitePoint=(0.28, 0.28), float sigma=30, float highlights=-0.9, float shadows=0.5, float clarity=0.1, int|None maxLevel=None, NDArray|None psf=None, list[float]|None brackets=None, bool doRemapGamut=True, Literal["mapping", "inpaint"] gamutMethod="inpaint")
dict[str, Exposure] makeInputsFromRefs(self, Iterable[DatasetRef] refs, Butler|QuantumContext butler)
Struct run(self, Mapping[str, Exposure] images)
dict[int, DeferredDatasetHandle] makeInputsFromExposures(self, **kwargs)
dict[int, DeferredDatasetHandle] makeInputsFromArrays(self, **kwargs)
None runQuantum(self, QuantumContext butlerQC, InputQuantizedConnection inputRefs, OutputQuantizedConnection outputRefs)