Coverage for python / lsst / pipe / tasks / prettyPictureMaker / _task.py: 23%

403 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-03-01 02:17 -0800

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 

24__all__ = ( 

25 "ChannelRGBConfig", 

26 "PrettyPictureTask", 

27 "PrettyPictureConnections", 

28 "PrettyPictureConfig", 

29 "PrettyMosaicTask", 

30 "PrettyMosaicConnections", 

31 "PrettyMosaicConfig", 

32 "PrettyPictureBackgroundFixerConfig", 

33 "PrettyPictureBackgroundFixerTask", 

34 "PrettyPictureStarFixerConfig", 

35 "PrettyPictureStarFixerTask", 

36) 

37 

38from collections.abc import Iterable, Mapping 

39from lsst.afw.image import ExposureF 

40import numpy as np 

41from typing import TYPE_CHECKING, cast, Any 

42from lsst.skymap import BaseSkyMap 

43 

44from scipy.stats import norm, halfnorm, mode 

45from scipy.ndimage import binary_dilation 

46from scipy.interpolate import RBFInterpolator 

47from skimage.restoration import inpaint_biharmonic 

48 

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 ( 

53 PipelineTask, 

54 PipelineTaskConfig, 

55 PipelineTaskConnections, 

56 Struct, 

57 InMemoryDatasetHandle, 

58) 

59import cv2 

60 

61from lsst.pipe.base.connectionTypes import Input, Output 

62from lsst.geom import Box2I, Point2I, Extent2I 

63from lsst.afw.image import Exposure, Mask 

64 

65from ._plugins import plugins 

66from ._colorMapper import lsstRGB 

67 

68import tempfile 

69 

70 

71if TYPE_CHECKING: 

72 from numpy.typing import NDArray 

73 from lsst.pipe.base import QuantumContext, InputQuantizedConnection, OutputQuantizedConnection 

74 from lsst.skymap import TractInfo, PatchInfo 

75 

76 

77class PrettyPictureConnections( 

78 PipelineTaskConnections, 

79 dimensions={"tract", "patch", "skymap"}, 

80 defaultTemplates={"coaddTypeName": "deep"}, 

81): 

82 inputCoadds = Input( 

83 doc=( 

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" 

86 ), 

87 name="pretty_coadd", 

88 storageClass="ExposureF", 

89 dimensions=("tract", "patch", "skymap", "band"), 

90 multiple=True, 

91 ) 

92 

93 outputRGB = Output( 

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"), 

98 ) 

99 

100 outputRGBMask = Output( 

101 doc="A Mask corresponding to the fused masks of the input channels", 

102 name="rgb_picture_mask", 

103 storageClass="Mask", 

104 dimensions=("tract", "patch", "skymap"), 

105 ) 

106 

107 

108class ChannelRGBConfig(Config): 

109 """This describes the rgb values of a given input channel. 

110 

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. 

114 """ 

115 

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") 

119 

120 

121class LumConfig(Config): 

122 """Configurations to control how luminance is mapped in the rgb code""" 

123 

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 

130 ) 

131 shadow = Field[float]( 

132 doc="The value of shadows in scaling factor applied to post asinh streaching", default=0.0 

133 ) 

134 midtone = Field[float]( 

135 doc="The value of midtone in scaling factor applied to post asinh streaching", default=0.5 

136 ) 

137 

138 

139class LocalContrastConfig(Config): 

140 """Configuration to control local contrast enhancement of the luminance 

141 channel.""" 

142 

143 doLocalContrast = Field[bool]( 

144 doc="Apply local contrast enhancements to the luminance channel", default=True 

145 ) 

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 

151 ) 

152 maxLevel = Field[int]( 

153 doc="The maximum number of scales the contrast should be enhanced over, if None then all", 

154 default=4, 

155 optional=True, 

156 ) 

157 

158 

159class ScaleColorConfig(Config): 

160 """Controls color scaling in the RGB generation process.""" 

161 

162 saturation = Field[float]( 

163 doc=( 

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" 

166 ), 

167 default=0.5, 

168 ) 

169 maxChroma = Field[float]( 

170 doc=( 

171 "The maximum chromaticity in the CIELCh color space, large " 

172 "values will cause bright pixels to fall outside the RGB gamut." 

173 ), 

174 default=50.0, 

175 ) 

176 

177 

178class RemapBoundsConfig(Config): 

179 """Remaps input images to a known range of values. 

180 

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 

184 bound. 

185 """ 

186 

187 quant = Field[float]( 

188 doc=( 

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." 

191 ), 

192 default=0.8, 

193 ) 

194 absMax = Field[float]( 

195 doc="Instead of determining the maximum value from the image, use this fixed value instead", 

196 default=220, 

197 optional=True, 

198 ) 

199 

200 

201class PrettyPictureConfig(PipelineTaskConfig, pipelineConnections=PrettyPictureConnections): 

202 channelConfig = ConfigDictField( 

203 doc="A dictionary that maps band names to their rgb channel configurations", 

204 keytype=str, 

205 itemtype=ChannelRGBConfig, 

206 default={}, 

207 ) 

208 imageRemappingConfig = ConfigField[RemapBoundsConfig]( 

209 doc="Configuration controlling channel normalization process" 

210 ) 

211 luminanceConfig = ConfigField[LumConfig]( 

212 doc="Configuration for the luminance scaling when making an RGB image" 

213 ) 

214 localContrastConfig = ConfigField[LocalContrastConfig]( 

215 doc="Configuration controlling the local contrast correction in RGB image production" 

216 ) 

217 colorConfig = ConfigField[ScaleColorConfig]( 

218 doc="Configuration to control the color scaling process in RGB image production" 

219 ) 

220 cieWhitePoint = ListField[float]( 

221 doc="The white point of the input arrays in ciexz coordinates", maxLength=2, default=[0.28, 0.28] 

222 ) 

223 arrayType = ChoiceField[str]( 

224 doc="The dataset type for the output image array", 

225 default="uint8", 

226 allowed={ 

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", 

231 }, 

232 ) 

233 doPSFDeconcovlve = Field[bool]( 

234 doc="Use the PSF in a richardson lucy deconvolution on the luminance channel.", default=True 

235 ) 

236 exposureBrackets = ListField[float]( 

237 doc=( 

238 "Exposure scaling factors used in creating multiple exposures with different scalings which will " 

239 "then be fused into a final image" 

240 ), 

241 optional=True, 

242 default=[1.25, 1, 0.75], 

243 ) 

244 doRemapGamut = Field[bool]( 

245 doc="Apply a color correction to unrepresentable colors, if false they will clip", default=True 

246 ) 

247 gamutMethod = ChoiceField[str]( 

248 doc="If doRemapGamut is True this determines the method", 

249 default="inpaint", 

250 allowed={ 

251 "mapping": "Use a mapping function", 

252 "inpaint": "Use surrounding pixels to determine likely value", 

253 }, 

254 ) 

255 recenterNoise = Field[float]( 

256 doc="Recenter the noise away from zero. Supplied value is in units of sigma", 

257 optional=True, 

258 default=None, 

259 ) 

260 noiseSearchThreshold = Field[float]( 

261 doc=( 

262 "Flux threshold below which most flux will be considered noise, used to estimate noise properties" 

263 ), 

264 default=10, 

265 ) 

266 

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() 

272 

273 

274class PrettyPictureTask(PipelineTask): 

275 """Turns inputs into an RGB image.""" 

276 

277 _DefaultName = "prettyPictureTask" 

278 ConfigClass = PrettyPictureConfig 

279 

280 config: ConfigClass 

281 

282 def _find_normal_stats(self, array): 

283 """Calculate standard deviation from negative values using half-normal distribution. 

284 

285 Parameters 

286 ---------- 

287 array : `numpy.array` 

288 Input array of numerical values. 

289 

290 Returns 

291 ------- 

292 mean : `float` 

293 The central moment of the distribution 

294 sigma : `float` 

295 Estimated standard deviation from negative values. Returns np.inf if: 

296 - No negative values exist in the array 

297 - Half-normal fitting fails 

298 """ 

299 # Extract negative values efficiently 

300 values_noise = array[array < self.config.noiseSearchThreshold] 

301 

302 # find the mode 

303 center = mode(np.round(values_noise, 2)).mode 

304 

305 # extract the negative values 

306 values_neg = array[array < center] 

307 

308 # Return infinity if no negative values found 

309 if values_neg.size == 0: 

310 return 0, np.inf 

311 

312 try: 

313 # Fit half-normal distribution to absolute negative values 

314 mu, sigma = halfnorm.fit(np.abs(values_neg)) 

315 except (ValueError, RuntimeError): 

316 # Handle fitting failures (e.g., constant data, optimization issues) 

317 return 0, np.inf 

318 

319 return center, sigma 

320 

321 def _match_sigmas_and_recenter(self, *arrays, factor=1): 

322 """Scale array values to match minimum standard deviation across arrays 

323 and recenter noise. 

324 

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. 

328 

329 Parameters 

330 ---------- 

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). 

335 

336 """ 

337 # Calculate standard deviations for all arrays 

338 sigmas = [] 

339 mus = [] 

340 for arr in arrays: 

341 m, s = self._find_normal_stats(arr) 

342 mus.append(m) 

343 sigmas.append(s) 

344 mus = np.array(mus) 

345 sigmas = np.array(sigmas) 

346 

347 # If no sigmas could be determined, return the original 

348 # arrays. 

349 if not np.any(np.isfinite(sigmas)): 

350 return 

351 

352 min_sig = np.min(sigmas) 

353 

354 for mu, sigma, array in zip(mus, sigmas, arrays): 

355 # Identify values below the array's sigma threshold 

356 lower_pos = (array - mu) < sigma 

357 

358 # Skip processing if sigma is invalid 

359 if not np.isfinite(sigma): 

360 continue 

361 

362 # Calculate scaling ratio relative to minimum sigma 

363 sigma_ratio = min_sig / sigma 

364 

365 # Apply adjustment to qualifying values 

366 array[lower_pos] = (array[lower_pos] - mu) * sigma_ratio + min_sig * factor 

367 

368 def run(self, images: Mapping[str, Exposure]) -> Struct: 

369 """Turns the input arguments in arguments into an RGB array. 

370 

371 Parameters 

372 ---------- 

373 images : `Mapping` of `str` to `Exposure` 

374 A mapping of input images and the band they correspond to. 

375 

376 Returns 

377 ------- 

378 result : `Struct` 

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. 

383 

384 Notes 

385 ----- 

386 Construction of input images are made easier by use of the 

387 makeInputsFrom* methods. 

388 """ 

389 channels = {} 

390 shape = (0, 0) 

391 jointMask: None | NDArray = None 

392 maskDict: Mapping[str, int] = {} 

393 doJointMaskInit = False 

394 if jointMask is None: 

395 doJointMask = True 

396 doJointMaskInit = True 

397 for channel, imageExposure in images.items(): 

398 imageArray = imageExposure.image.array 

399 # run all the plugins designed for array based interaction 

400 for plug in plugins.channel(): 

401 imageArray = plug( 

402 imageArray, imageExposure.mask.array, imageExposure.mask.getMaskPlaneDict(), self.config 

403 ).astype(np.float32) 

404 channels[channel] = imageArray 

405 # These operations are trivial look-ups and don't matter if they 

406 # happen in each loop. 

407 shape = imageArray.shape 

408 maskDict = imageExposure.mask.getMaskPlaneDict() 

409 if doJointMaskInit: 

410 jointMask = np.zeros(shape, dtype=imageExposure.mask.dtype) 

411 doJointMaskInit = False 

412 if doJointMask: 

413 jointMask |= imageExposure.mask.array 

414 

415 # mix the images to RGB 

416 imageRArray = np.zeros(shape, dtype=np.float32) 

417 imageGArray = np.zeros(shape, dtype=np.float32) 

418 imageBArray = np.zeros(shape, dtype=np.float32) 

419 

420 for band, image in channels.items(): 

421 mix = self.config.channelConfig[band] 

422 if mix.r: 

423 imageRArray += mix.r * image 

424 if mix.g: 

425 imageGArray += mix.g * image 

426 if mix.b: 

427 imageBArray += mix.b * image 

428 

429 exposure = next(iter(images.values())) 

430 box: Box2I = exposure.getBBox() 

431 boxCenter = box.getCenter() 

432 try: 

433 psf = exposure.psf.computeImage(boxCenter).array 

434 except Exception: 

435 psf = None 

436 

437 if self.config.recenterNoise: 

438 self._match_sigmas_and_recenter( 

439 imageRArray, imageGArray, imageBArray, factor=self.config.recenterNoise 

440 ) 

441 

442 # assert for typing reasons 

443 assert jointMask is not None 

444 # Run any image level correction plugins 

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) 

451 

452 # Ignore type because Exposures do in fact have a bbox, but it's c++ 

453 # and not typed. 

454 colorImage = lsstRGB( 

455 colorImage[:, :, 0], 

456 colorImage[:, :, 1], 

457 colorImage[:, :, 2], 

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), # type: ignore 

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, 

467 ) 

468 

469 # Find the dataset type and thus the maximum values as well 

470 maxVal: int | float 

471 match self.config.arrayType: 

472 case "uint8": 

473 dtype = np.uint8 

474 maxVal = 255 

475 case "uint16": 

476 dtype = np.uint16 

477 maxVal = 65535 

478 case "half": 

479 dtype = np.half 

480 maxVal = 1.0 

481 case "float": 

482 dtype = np.float32 

483 maxVal = 1.0 

484 case _: 

485 assert True, "This code path should be unreachable" 

486 

487 # lsstRGB returns an image in 0-1 scale it to the maximum value 

488 colorImage *= maxVal # type: ignore 

489 

490 # pack the joint mask back into a mask object 

491 lsstMask = Mask(width=jointMask.shape[1], height=jointMask.shape[0], planeDefs=maskDict) 

492 lsstMask.array = jointMask # type: ignore 

493 return Struct(outputRGB=colorImage.astype(dtype), outputRGBMask=lsstMask) # type: ignore 

494 

495 def runQuantum( 

496 self, 

497 butlerQC: QuantumContext, 

498 inputRefs: InputQuantizedConnection, 

499 outputRefs: OutputQuantizedConnection, 

500 ) -> None: 

501 imageRefs: list[DatasetRef] = inputRefs.inputCoadds 

502 sortedImages = self.makeInputsFromRefs(imageRefs, butlerQC) 

503 outputs = self.run(sortedImages) 

504 butlerQC.put(outputs, outputRefs) 

505 

506 def makeInputsFromRefs( 

507 self, refs: Iterable[DatasetRef], butler: Butler | QuantumContext 

508 ) -> dict[str, Exposure]: 

509 r"""Make valid inputs for the run method from butler references. 

510 

511 Parameters 

512 ---------- 

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. 

517 

518 Returns 

519 ------- 

520 sortedImages : `dict` of `str` to `Exposure` 

521 A dictionary of `Exposure`\ s that keyed by the band they 

522 correspond to. 

523 """ 

524 sortedImages: dict[str, Exposure] = {} 

525 for ref in refs: 

526 key: str = cast(str, ref.dataId["band"]) 

527 image = butler.get(ref) 

528 sortedImages[key] = image 

529 return sortedImages 

530 

531 def makeInputsFromArrays(self, **kwargs) -> dict[int, DeferredDatasetHandle]: 

532 r"""Make valid inputs for the run method from numpy arrays. 

533 

534 Parameters 

535 ---------- 

536 kwargs : `NDArray` 

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` 

539 array. 

540 

541 Returns 

542 ------- 

543 sortedImages : `dict` of `str` to `Exposure` 

544 A dictionary of `Exposure`\ s that keyed by the band they 

545 correspond to. 

546 """ 

547 # ignore type because there aren't proper stubs for afw 

548 temp = {} 

549 for key, array in kwargs.items(): 

550 temp[key] = Exposure(Box2I(Point2I(0, 0), Extent2I(*array.shape)), dtype=array.dtype) 

551 temp[key].image.array[:] = array 

552 

553 return self.makeInputsFromExposures(**temp) 

554 

555 def makeInputsFromExposures(self, **kwargs) -> dict[int, DeferredDatasetHandle]: 

556 r"""Make valid inputs for the run method from `Exposure` objects. 

557 

558 Parameters 

559 ---------- 

560 kwargs : `Exposure` 

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 

563 `Exposure`. 

564 

565 Returns 

566 ------- 

567 sortedImages : `dict` of `str` to `Exposure` 

568 A dictionary of `Exposure`\ s that keyed by the band they 

569 correspond to. 

570 """ 

571 sortedImages = {} 

572 for key, value in kwargs.items(): 

573 sortedImages[key] = value 

574 return sortedImages 

575 

576 

577class PrettyPictureBackgroundFixerConnections( 

578 PipelineTaskConnections, 

579 dimensions=("tract", "patch", "skymap", "band"), 

580 defaultTemplates={"coaddTypeName": "deep"}, 

581): 

582 inputCoadd = Input( 

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"), 

587 ) 

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"), 

593 ) 

594 

595 

596class PrettyPictureBackgroundFixerConfig( 

597 PipelineTaskConfig, pipelineConnections=PrettyPictureBackgroundFixerConnections 

598): 

599 pass 

600 

601 

602class PrettyPictureBackgroundFixerTask(PipelineTask): 

603 """Empirically flatten an images background. 

604 

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. 

609 

610 """ 

611 

612 _DefaultName = "prettyPictureBackgroundFixerTask" 

613 ConfigClass = PrettyPictureBackgroundFixerConfig 

614 

615 config: ConfigClass 

616 

617 def _neg_log_likelihood(self, params, x): 

618 """Calculate the negative log-likelihood for a Gaussian distribution. 

619 

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. 

624 

625 Parameters 

626 ---------- 

627 params : `tuple` 

628 A tuple containing the mean (`mu`) and standard deviation (`sigma`) 

629 of the Gaussian distribution. 

630 x : `NDArray` 

631 The data samples for which to calculate the log-likelihood. 

632 

633 Returns 

634 ------- 

635 float 

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). 

640 """ 

641 mu, sigma = params 

642 if sigma <= 0: 

643 return np.inf 

644 M = np.max(x) 

645 if mu < M - 1e-8: # Allow for floating point precision issues 

646 return np.inf 

647 z = (x - mu) / sigma 

648 term = np.log(2) - np.log(sigma) + norm.logpdf(z) 

649 loglikelihood = np.sum(term) 

650 return -loglikelihood 

651 

652 def _tile_slices(self, arr, R, C): 

653 """Generate slices for tiling an array. 

654 

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. 

659 

660 Parameters 

661 ---------- 

662 arr : `NDArray` 

663 The input array to be tiled. Used only to determine the array's shape. 

664 R : `int` 

665 The number of tiles in the row dimension. 

666 C : `int` 

667 The number of tiles in the column dimension. 

668 

669 Returns 

670 ------- 

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. 

674 """ 

675 M = arr.shape[0] 

676 N = arr.shape[1] 

677 

678 # Function to compute slices for a given dimension size and number of divisions 

679 def get_slices(total_size, num_divisions): 

680 base = total_size // num_divisions 

681 remainder = total_size % num_divisions 

682 slices = [] 

683 start = 0 

684 for i in range(num_divisions): 

685 end = start + base 

686 if i < remainder: 

687 end += 1 

688 slices.append((start, end)) 

689 start = end 

690 return slices 

691 

692 # Get row and column slices 

693 row_slices = get_slices(M, R) 

694 col_slices = get_slices(N, C) 

695 

696 # Generate all possible tile combinations of row and column slices 

697 tiles = [] 

698 for rs in row_slices: 

699 r_start, r_end = rs 

700 for cs in col_slices: 

701 c_start, c_end = cs 

702 tile_slice = (slice(r_start, r_end), slice(c_start, c_end)) 

703 tiles.append(tile_slice) 

704 

705 return tiles 

706 

707 def fixBackground(self, image): 

708 """Estimate and subtract the background from an image. 

709 

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. 

714 

715 Parameters 

716 ---------- 

717 image : `NDArray` 

718 The input image as a NumPy array. 

719 

720 Returns 

721 ------- 

722 numpy.ndarray 

723 An array representing the estimated background level across the image. 

724 """ 

725 # Find the median value in the image, which is likely to be 

726 # close to average background. Note this doesn't work well 

727 # in fields with high density or diffuse flux. 

728 maxLikely = np.median(image, axis=None) 

729 

730 # find all the pixels that are fainter than this 

731 # and find the std. This is just used as an initialization 

732 # parameter and doesn't need to be accurate. 

733 mask = image < maxLikely 

734 initial_std = (image[mask] - maxLikely).std() 

735 

736 # Don't do anything if there are no pixels to check 

737 if np.any(mask): 

738 # use a minimizer to determine best mu and sigma for a Gaussian 

739 # given only samples below the mean of the Gaussian. 

740 _, sigma_hat = halfnorm.fit(np.abs(image[mask] - maxLikely)) 

741 mu_hat = maxLikely 

742 else: 

743 mu_hat, sigma_hat = (maxLikely, 2 * initial_std) 

744 

745 # create a new masking threshold that is the determined 

746 # mean plus std from the fit 

747 threshhold = mu_hat + sigma_hat 

748 image_mask = image < threshhold 

749 

750 # create python slices that tile the image. 

751 tiles = self._tile_slices(image, 25, 25) 

752 

753 yloc = [] 

754 xloc = [] 

755 values = [] 

756 

757 # for each box find the middle position and the median background 

758 # value in the window. 

759 for xslice, yslice in tiles: 

760 ypos = (yslice.stop - yslice.start) / 2 + yslice.start 

761 xpos = (xslice.stop - xslice.start) / 2 + xslice.start 

762 yloc.append(ypos) 

763 xloc.append(xpos) 

764 window = image[yslice, xslice][image_mask[yslice, xslice]] 

765 if window.size > 0: 

766 value = np.median(window) 

767 else: 

768 value = 0 

769 values.append(value) 

770 

771 positions = np.meshgrid(np.arange(image.shape[0]), np.arange(image.shape[1])) 

772 # create an interpolant for the background and interpolate over the image. 

773 inter = RBFInterpolator( 

774 np.vstack((yloc, xloc)).T, values, kernel="thin_plate_spline", degree=4, smoothing=0.05 

775 ) 

776 backgrounds = inter(np.array(positions)[::-1].reshape(2, -1).T).reshape(image.shape) 

777 

778 return backgrounds 

779 

780 def run(self, inputCoadd: Exposure): 

781 """Estimate a background for an input Exposure and remove it. 

782 

783 Parameters 

784 ---------- 

785 inputCoadd : `Exposure` 

786 The exposure the background will be removed from. 

787 

788 Returns 

789 ------- 

790 result : `Struct` 

791 A `Struct` that contains the exposure with the background removed. 

792 This `Struct` will have an attribute named ``outputCoadd``. 

793 

794 """ 

795 background = self.fixBackground(inputCoadd.image.array) 

796 # create a copy to mutate 

797 output = ExposureF(inputCoadd, deep=True) 

798 output.image.array -= background 

799 return Struct(outputCoadd=output) 

800 

801 

802class PrettyPictureStarFixerConnections( 

803 PipelineTaskConnections, 

804 dimensions=("tract", "patch", "skymap"), 

805): 

806 inputCoadd = Input( 

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"), 

811 multiple=True, 

812 ) 

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"), 

818 multiple=True, 

819 ) 

820 

821 

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" 

825 ) 

826 

827 

828class PrettyPictureStarFixerTask(PipelineTask): 

829 """This class fixes up regions in an image where there is no, or bad data. 

830 

831 The fixes done by this task are overwhelmingly comprised of the cores of 

832 bright stars for which there is no data. 

833 """ 

834 

835 _DefaultName = "prettyPictureStarFixerTask" 

836 ConfigClass = PrettyPictureStarFixerConfig 

837 

838 config: ConfigClass 

839 

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. 

843 

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 

847 no issue. 

848 

849 Parameters 

850 ---------- 

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. 

854 

855 Returns 

856 ------- 

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``. 

860 

861 """ 

862 # make the joint mask of all the channels 

863 doJointMaskInit = True 

864 for imageExposure in inputs.values(): 

865 maskDict = imageExposure.mask.getMaskPlaneDict() 

866 if doJointMaskInit: 

867 jointMask = np.zeros(imageExposure.mask.array.shape, dtype=imageExposure.mask.array.dtype) 

868 doJointMaskInit = False 

869 jointMask |= imageExposure.mask.array 

870 

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) 

874 

875 # use the last imageExposure as it is likely close enough across all bands 

876 bright_mask = imageExposure.image.array > self.config.brightnessThresh 

877 

878 # dilate the mask a bit, this helps get a bit fainter mask without starting 

879 # to include pixels in an irregular shape, as only the star cores should be 

880 # fixed. 

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) 

884 

885 # do the actual fixing of values 

886 results = {} 

887 for band, imageExposure in inputs.items(): 

888 if np.sum(both) > 0: 

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) 

893 

894 def runQuantum( 

895 self, 

896 butlerQC: QuantumContext, 

897 inputRefs: InputQuantizedConnection, 

898 outputRefs: OutputQuantizedConnection, 

899 ) -> None: 

900 refs = inputRefs.inputCoadd 

901 sortedImages: dict[str, Exposure] = {} 

902 for ref in refs: 

903 key: str = cast(str, ref.dataId["band"]) 

904 image = butlerQC.get(ref) 

905 sortedImages[key] = image 

906 

907 outputs = self.run(sortedImages).results 

908 sortedOutputs = {} 

909 for ref in outputRefs.outputCoadd: 

910 sortedOutputs[ref.dataId["band"]] = ref 

911 

912 for band, data in outputs.items(): 

913 butlerQC.put(data, sortedOutputs[band]) 

914 

915 

916class PrettyMosaicConnections(PipelineTaskConnections, dimensions=("tract", "skymap")): 

917 inputRGB = Input( 

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"), 

922 multiple=True, 

923 deferLoad=True, 

924 ) 

925 

926 skyMap = Input( 

927 doc="The skymap which the data has been mapped onto", 

928 storageClass="SkyMap", 

929 name=BaseSkyMap.SKYMAP_DATASET_TYPE_NAME, 

930 dimensions=("skymap",), 

931 ) 

932 

933 inputRGBMask = Input( 

934 doc="Individual RGB images that are to go into the mosaic", 

935 name="rgb_picture_mask", 

936 storageClass="Mask", 

937 dimensions=("tract", "patch", "skymap"), 

938 multiple=True, 

939 deferLoad=True, 

940 ) 

941 

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"), 

947 ) 

948 

949 

950class PrettyMosaicConfig(PipelineTaskConfig, pipelineConnections=PrettyMosaicConnections): 

951 binFactor = Field[int](doc="The factor to bin by when producing the mosaic") 

952 

953 

954class PrettyMosaicTask(PipelineTask): 

955 """Combines multiple RGB arrays into one mosaic.""" 

956 

957 _DefaultName = "prettyMosaicTask" 

958 ConfigClass = PrettyMosaicConfig 

959 

960 config: ConfigClass 

961 

962 def run( 

963 self, 

964 inputRGB: Iterable[DeferredDatasetHandle], 

965 skyMap: BaseSkyMap, 

966 inputRGBMask: Iterable[DeferredDatasetHandle], 

967 ) -> Struct: 

968 r"""Assemble individual `NDArrays` into a mosaic. 

969 

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. 

972 

973 Parameters 

974 ---------- 

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 

979 images. 

980 inputRGBMask : `Iterable` of `DeferredDatasetHandle` 

981 `DeferredDatasetHandle`\ s pointing to masks for each of the 

982 corresponding images. 

983 

984 Returns 

985 ------- 

986 result : `Struct` 

987 The `Struct` containing the combined mosaic. The `Struct` has 

988 and attribute named ``outputRGBMosaic``. 

989 """ 

990 # create the bounding region 

991 newBox = Box2I() 

992 # store the bounds as they are retrieved from the skymap 

993 boxes = [] 

994 tractMaps = [] 

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() 

1000 boxes.append(bbox) 

1001 newBox.include(bbox) 

1002 tractMaps.append(tractInfo) 

1003 

1004 # fixup the boxes to be smaller if needed, and put the origin at zero, 

1005 # this must be done after constructing the complete outer box 

1006 modifiedBoxes = [] 

1007 origin = newBox.getBegin() 

1008 for iterBox in boxes: 

1009 localOrigin = iterBox.getBegin() - origin 

1010 localOrigin = Point2I( 

1011 x=int(np.floor(localOrigin.x / self.config.binFactor)), 

1012 y=int(np.floor(localOrigin.y / self.config.binFactor)), 

1013 ) 

1014 localExtent = Extent2I( 

1015 x=int(np.floor(iterBox.getWidth() / self.config.binFactor)), 

1016 y=int(np.floor(iterBox.getHeight() / self.config.binFactor)), 

1017 ) 

1018 tmpBox = Box2I(localOrigin, localExtent) 

1019 modifiedBoxes.append(tmpBox) 

1020 boxes = modifiedBoxes 

1021 

1022 # scale the container box 

1023 newBoxOrigin = Point2I(0, 0) 

1024 newBoxExtent = Extent2I( 

1025 x=int(np.floor(newBox.getWidth() / self.config.binFactor)), 

1026 y=int(np.floor(newBox.getHeight() / self.config.binFactor)), 

1027 ) 

1028 newBox = Box2I(newBoxOrigin, newBoxExtent) 

1029 

1030 # Allocate storage for the mosaic 

1031 self.imageHandle = tempfile.NamedTemporaryFile() 

1032 self.maskHandle = tempfile.NamedTemporaryFile() 

1033 consolidatedImage = None 

1034 consolidatedMask = None 

1035 

1036 # Actually assemble the mosaic 

1037 maskDict = {} 

1038 tmpImg = None 

1039 for box, handle, handleMask, tractInfo in zip(boxes, inputRGB, inputRGBMask, tractMaps): 

1040 rgb = handle.get() 

1041 rgbMask = handleMask.get() 

1042 maskDict = rgbMask.getMaskPlaneDict() 

1043 # allocate the memory for the mosaic 

1044 if consolidatedImage is None: 

1045 consolidatedImage = np.memmap( 

1046 self.imageHandle.name, 

1047 mode="w+", 

1048 shape=(newBox.getHeight(), newBox.getWidth(), 3), 

1049 dtype=rgb.dtype, 

1050 ) 

1051 if consolidatedMask is None: 

1052 consolidatedMask = np.memmap( 

1053 self.maskHandle.name, 

1054 mode="w+", 

1055 shape=(newBox.getHeight(), newBox.getWidth()), 

1056 dtype=rgbMask.array.dtype, 

1057 ) 

1058 

1059 if self.config.binFactor > 1: 

1060 # opencv wants things in x, y dimensions 

1061 shape = tuple(box.getDimensions())[::-1] 

1062 rgb = cv2.resize( 

1063 rgb, 

1064 dst=None, 

1065 dsize=shape, 

1066 fx=shape[0] / self.config.binFactor, 

1067 fy=shape[1] / self.config.binFactor, 

1068 ) 

1069 rgbMask = cv2.resize( 

1070 rgbMask.array.astype(np.float32), 

1071 dst=None, 

1072 dsize=shape, 

1073 fx=shape[0] / self.config.binFactor, 

1074 fy=shape[1] / self.config.binFactor, 

1075 ) 

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 

1082 ) 

1083 

1084 tmpImg[-1 * tractInfo.patch_border * 2 :, :] = np.repeat( # noqa: E203 

1085 np.expand_dims(1 - ramp, 1), tmpImg.shape[1], axis=1 

1086 ) 

1087 tmpImg[:, : tractInfo.patch_border * 2] = np.repeat( 

1088 np.expand_dims(ramp, 0), tmpImg.shape[0], axis=0 

1089 ) 

1090 

1091 tmpImg[:, -1 * tractInfo.patch_border * 2 :] = np.repeat( # noqa: E203 

1092 np.expand_dims(1 - ramp, 0), tmpImg.shape[0], axis=0 

1093 ) 

1094 tmpImg = np.repeat(np.expand_dims(tmpImg, 2), 3, axis=2) 

1095 

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, :] 

1100 ) 

1101 

1102 tmpMask = np.zeros_like(rgbMask.array) 

1103 tmpMask[existing] = np.bitwise_or( 

1104 rgbMask.array[existing], consolidatedMask[*box.slices][existing] 

1105 ) 

1106 tmpMask[~existing] = rgbMask.array[~existing] 

1107 consolidatedMask[*box.slices] = tmpMask 

1108 

1109 for plugin in plugins.full(): 

1110 if consolidatedImage is not None and consolidatedMask is not None: 

1111 consolidatedImage = plugin(consolidatedImage, consolidatedMask, maskDict) 

1112 # If consolidated image still None, that means there was no work to do. 

1113 # Return an empty image instead of letting this task fail. 

1114 if consolidatedImage is None: 

1115 consolidatedImage = np.zeros((0, 0, 0), dtype=np.uint8) 

1116 

1117 return Struct(outputRGBMosaic=consolidatedImage) 

1118 

1119 def runQuantum( 

1120 self, 

1121 butlerQC: QuantumContext, 

1122 inputRefs: InputQuantizedConnection, 

1123 outputRefs: OutputQuantizedConnection, 

1124 ) -> None: 

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() 

1132 

1133 def makeInputsFromArrays( 

1134 self, inputs: Iterable[tuple[Mapping[str, Any], NDArray]] 

1135 ) -> Iterable[DeferredDatasetHandle]: 

1136 r"""Make valid inputs for the run method from numpy arrays. 

1137 

1138 Parameters 

1139 ---------- 

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`. 

1144 

1145 Returns 

1146 ------- 

1147 sortedImages : `dict` of `str` to `Exposure` 

1148 A dictionary of `Exposure`\ s that keyed by the band they 

1149 correspond to. 

1150 """ 

1151 structuredInputs = [] 

1152 for dataId, array in inputs: 

1153 structuredInputs.append(InMemoryDatasetHandle(inMemoryDataset=array, **dataId)) 

1154 

1155 return structuredInputs