Coverage for python/lsst/images/psfs/_gaussian.py: 89%
84 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-22 01:54 -0700
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-22 01:54 -0700
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.
12from __future__ import annotations
14__all__ = (
15 "GaussianPSFSerializationModel",
16 "GaussianPointSpreadFunction",
17)
19from functools import cached_property
20from typing import Any, ClassVar
22import numpy as np
23import pydantic
25from lsst.images._image import Image
27from .. import serialization
28from .._concrete_bounds import SerializableBounds
29from .._geom import Bounds, Box
30from ..utils import round_half_up
31from ._base import PointSpreadFunction
34class GaussianPointSpreadFunction(PointSpreadFunction):
35 """A PSF with a spatially-invariant circular Gaussian profile."""
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
49 def __eq__(self, other: Any) -> bool:
50 if not isinstance(other, GaussianPointSpreadFunction): 50 ↛ 51line 50 didn't jump to line 51 because the condition on line 50 was never true
51 return NotImplemented
52 if self.sigma != other.sigma: 52 ↛ 53line 52 didn't jump to line 53 because the condition on line 52 was never true
53 return False
54 if self._stamp_size != other._stamp_size: 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true
55 return False
56 if self._bounds != other._bounds: 56 ↛ 57line 56 didn't jump to line 57 because the condition on line 56 was never true
57 return False
58 return True
60 def __repr__(self) -> str:
61 return (
62 f"GaussianPointSpreadFunction({self.sigma}, "
63 f"stamp_size={self._stamp_size}, bounds={self._bounds!r})"
64 )
66 @property
67 def bounds(self) -> Bounds:
68 return self._bounds
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]
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)
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
87 def compute_kernel_image(self, *, x: float, y: float) -> Image:
88 return Image(self._kernel_array.copy(), bbox=self.kernel_bbox)
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)
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]
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 )
109 @staticmethod
110 def _get_archive_tree_type(
111 pointer_type: type[pydantic.BaseModel],
112 ) -> type[GaussianPSFSerializationModel]:
113 """Return the serialization model type for this object for an archive
114 type that uses the given pointer type.
115 """
116 return GaussianPSFSerializationModel
119class GaussianPSFSerializationModel(serialization.ArchiveTree):
120 SCHEMA_NAME: ClassVar[str] = "gaussian_psf"
121 SCHEMA_VERSION: ClassVar[str] = "1.0.0"
122 MIN_READ_VERSION: ClassVar[int] = 1
123 PUBLIC_TYPE: ClassVar[type] = GaussianPointSpreadFunction
125 sigma: float = pydantic.Field(
126 description="Gaussian sigma for the PSF.",
127 )
128 stamp_size: int = pydantic.Field(
129 description="Width of the (square) images returned by this PSF's methods."
130 )
131 bounds: SerializableBounds = pydantic.Field(
132 description="The bounds object that represents the PSF's validity region."
133 )
135 def deserialize(
136 self, archive: serialization.InputArchive[Any], **kwargs: Any
137 ) -> GaussianPointSpreadFunction:
138 if kwargs: 138 ↛ 139line 138 didn't jump to line 139 because the condition on line 138 was never true
139 raise serialization.InvalidParameterError(
140 f"Unrecognized parameters for GaussianPointSpreadFunction: {set(kwargs.keys())}."
141 )
142 return GaussianPointSpreadFunction(
143 sigma=self.sigma, bounds=self.bounds.deserialize(), stamp_size=self.stamp_size
144 )