Coverage for python / lsst / images / psfs / _gaussian.py: 39%

79 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-25 08:35 +0000

1# This file is part of lsst-images. 

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# Use of this source code is governed by a 3-clause BSD-style 

10# license that can be found in the LICENSE file. 

11 

12from __future__ import annotations 

13 

14__all__ = ( 

15 "GaussianPSFSerializationModel", 

16 "GaussianPointSpreadFunction", 

17) 

18 

19from functools import cached_property 

20from typing import Any 

21 

22import numpy as np 

23import pydantic 

24 

25from lsst.images._image import Image 

26 

27from .. import serialization 

28from .._concrete_bounds import SerializableBounds 

29from .._geom import Bounds, Box 

30from ..utils import round_half_up 

31from ._base import PointSpreadFunction 

32 

33 

34class GaussianPointSpreadFunction(PointSpreadFunction): 

35 """A PSF with a spatially-invariant circular Gaussian profile.""" 

36 

37 def __init__(self, sigma: float, bounds: Bounds, stamp_size: int) -> None: 

38 if sigma <= 0: 

39 raise ValueError(f"sigma must be positive; got {sigma}.") 

40 if stamp_size <= 0: 

41 raise ValueError(f"stamp_size must be positive; got {stamp_size}.") 

42 if stamp_size % 2 != 1: 

43 raise ValueError(f"stamp_size must be odd number, got {stamp_size}") 

44 self.sigma = float(sigma) 

45 self._stamp_size = stamp_size 

46 self._bounds = bounds 

47 self._sigma2 = self.sigma * self.sigma 

48 

49 def __eq__(self, other: Any) -> bool: 

50 if not isinstance(other, GaussianPointSpreadFunction): 

51 return NotImplemented 

52 if self.sigma != other.sigma: 

53 return False 

54 if self._stamp_size != other._stamp_size: 

55 return False 

56 if self._bounds != other._bounds: 

57 return False 

58 return True 

59 

60 def __repr__(self) -> str: 

61 return ( 

62 f"GaussianPointSpreadFunction({self.sigma}, " 

63 f"stamp_size={self._stamp_size}, bounds={self._bounds!r})" 

64 ) 

65 

66 @property 

67 def bounds(self) -> Bounds: 

68 return self._bounds 

69 

70 @cached_property 

71 def kernel_bbox(self) -> Box: 

72 r = self._stamp_size // 2 

73 return Box.factory[-r : r + 1, -r : r + 1] 

74 

75 @cached_property 

76 def _centered_coordinates(self) -> np.ndarray: 

77 r = self._stamp_size // 2 

78 return np.arange(-r, r + 1, dtype=np.float64) 

79 

80 @cached_property 

81 def _kernel_array(self) -> np.ndarray: 

82 profile = np.exp(-0.5 * np.square(self._centered_coordinates) / self._sigma2) 

83 kernel = np.multiply.outer(profile, profile) 

84 kernel /= kernel.sum() 

85 return kernel 

86 

87 def compute_kernel_image(self, *, x: float, y: float) -> Image: 

88 return Image(self._kernel_array.copy(), bbox=self.kernel_bbox) 

89 

90 def compute_stellar_image(self, *, x: float, y: float) -> Image: 

91 bbox = self.compute_stellar_bbox(x=x, y=y) 

92 x_profile = np.exp(-0.5 * np.square(bbox.x.arange - x) / self._sigma2) 

93 y_profile = np.exp(-0.5 * np.square(bbox.y.arange - y) / self._sigma2) 

94 image = np.multiply.outer(y_profile, x_profile) 

95 image /= image.sum() 

96 return Image(image, bbox=bbox) 

97 

98 def compute_stellar_bbox(self, *, x: float, y: float) -> Box: 

99 r = self._stamp_size // 2 

100 xi = round_half_up(x) 

101 yi = round_half_up(y) 

102 return Box.factory[yi - r : yi + r + 1, xi - r : xi + r + 1] 

103 

104 def serialize(self, archive: serialization.OutputArchive[Any]) -> GaussianPSFSerializationModel: 

105 return GaussianPSFSerializationModel( 

106 sigma=self.sigma, stamp_size=self._stamp_size, bounds=self._bounds.serialize() 

107 ) 

108 

109 @classmethod 

110 def deserialize( 

111 cls, model: GaussianPSFSerializationModel, archive: serialization.InputArchive[Any] 

112 ) -> GaussianPointSpreadFunction: 

113 return cls(sigma=model.sigma, bounds=Bounds.deserialize(model.bounds), stamp_size=model.stamp_size) 

114 

115 @staticmethod 

116 def _get_archive_tree_type( 

117 pointer_type: type[pydantic.BaseModel], 

118 ) -> type[GaussianPSFSerializationModel]: 

119 """Return the serialization model type for this object for an archive 

120 type that uses the given pointer type. 

121 """ 

122 return GaussianPSFSerializationModel 

123 

124 

125class GaussianPSFSerializationModel(serialization.ArchiveTree): 

126 sigma: float = pydantic.Field( 

127 description="Gaussian sigma for the PSF.", 

128 ) 

129 stamp_size: int = pydantic.Field( 

130 description="Width of the (square) images returned by this PSF's methods." 

131 ) 

132 bounds: SerializableBounds = pydantic.Field( 

133 description="The bounds object that represents the PSF's validity region." 

134 )