Coverage for python/lsst/meas/extensions/scarlet/source.py: 94%

58 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 08:59 +0000

1# This file is part of meas_extensions_scarlet. 

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 

24from copy import deepcopy 

25from typing import TYPE_CHECKING, Any 

26 

27import numpy as np 

28from numpy.typing import DTypeLike 

29 

30from lsst.afw.detection import Footprint 

31from lsst.afw.image import MultibandExposure 

32import lsst.scarlet.lite as scl 

33 

34from .utils import nonzeroBandSupport 

35 

36if TYPE_CHECKING: 

37 from .io import IsolatedSourceData 

38 

39 

40__all__ = ["IsolatedSource"] 

41 

42 

43class IsolatedSource(scl.source.SourceBase): 

44 """A scarlet source representation for isolated sources. 

45 """ 

46 def __init__(self, model: scl.Image, peak: tuple[int, int], metadata: dict | None = None): 

47 """Construct an IsolatedSource. 

48 

49 Parameters 

50 ---------- 

51 model : 

52 The 3D (band, y, x) model of the source. 

53 peak : 

54 The (y, x) coordinates of the peak pixel within the model. 

55 metadata : 

56 Optional metadata to store with the source. 

57 """ 

58 self.metadata = metadata 

59 self.components = [scl.component.CubeComponent(model=model, peak=peak)] 

60 

61 @property 

62 def component(self) -> scl.component.CubeComponent: 

63 """The single component of this isolated source. 

64 

65 Returns 

66 ------- 

67 component : 

68 The CubeComponent representing this isolated source. 

69 """ 

70 return self.components[0] 

71 

72 @staticmethod 

73 def from_footprint( 

74 footprint: Footprint, 

75 mCoadd: MultibandExposure, 

76 dtype: DTypeLike, 

77 metadata: dict | None = None, 

78 ) -> IsolatedSource: 

79 """Create an IsolatedSource from a footprint in a multiband coadd. 

80 

81 Parameters 

82 ---------- 

83 footprint : 

84 The footprint of the source in the multiband coadd. 

85 mCoadd : 

86 The multiband coadd containing the source. 

87 dtype : 

88 The desired data type for the source model. 

89 metadata : 

90 Optional metadata to store with the source. 

91 

92 Returns 

93 ------- 

94 source : 

95 The isolated source represented as a ScarletSource. 

96 """ 

97 if len(footprint.peaks) != 1: 97 ↛ 98line 97 didn't jump to line 98 because the condition on line 97 was never true

98 raise ValueError( 

99 "Footprint must have exactly one peak to create an IsolatedSource, " 

100 f"found {len(footprint.peaks)}" 

101 ) 

102 peak = (footprint.peaks[0].getIy(), footprint.peaks[0].getIx()) 

103 bbox = footprint.getBBox() 

104 x0, y0 = bbox.getMin() 

105 width, height = bbox.getDimensions() 

106 # Convert the footprint into a boolean array 

107 footprint_array = footprint.spans.asArray((height, width), (x0, y0)) 

108 # Create the 3D model array by multiplying the footprint by each band 

109 # of the multiband coadd. 

110 model_array = np.empty((len(mCoadd.bands), height, width), dtype=dtype) 

111 for bidx, band in enumerate(mCoadd.bands): 

112 model_array[bidx] = mCoadd[band, bbox].image.array * footprint_array 

113 # Create the model 

114 model = scl.Image(model_array, bands=mCoadd.bands, yx0=(y0, x0)) 

115 return IsolatedSource(model=model, peak=peak, metadata=metadata) 

116 

117 @property 

118 def bbox(self) -> scl.Box: 

119 """The bounding box of the source in the full Blend.""" 

120 return self.component.bbox 

121 

122 @property 

123 def bands(self) -> list[str]: 

124 """The ordered list of bands in the full source model.""" 

125 return self.component.bands 

126 

127 @property 

128 def peak(self) -> tuple[int, int]: 

129 """The (y, x) coordinates of the peak pixel within the model.""" 

130 return self.component.peak 

131 

132 def get_model(self) -> scl.Image: 

133 """Get the full 3D (band, y, x) model of the source. 

134 

135 Returns 

136 ------- 

137 model : 

138 The 3D (band, y, x) model of the source. 

139 """ 

140 return self.component.get_model() 

141 

142 def to_data(self) -> IsolatedSourceData: 

143 """Convert to a ScarletSourceData representation. 

144 

145 Returns 

146 ------- 

147 source_data : 

148 The source represented as a ScarletSourceData. 

149 """ 

150 from .io import IsolatedSourceData 

151 

152 span_array = nonzeroBandSupport(self.component.get_model().data) 

153 return IsolatedSourceData( 

154 span_array=span_array, 

155 origin=self.bbox.origin, 

156 peak=self.peak, 

157 ) 

158 

159 def __copy__(self) -> IsolatedSource: 

160 """Create a copy of this IsolatedSource. 

161 

162 Returns 

163 ------- 

164 source_copy : 

165 A copy of this IsolatedSource. 

166 """ 

167 return IsolatedSource( 

168 model=self.component._model, 

169 peak=self.component.peak, 

170 metadata=self.metadata, 

171 ) 

172 

173 def __deepcopy__(self, memo: dict[int, Any]) -> IsolatedSource: 

174 """Create a deep copy of this IsolatedSource. 

175 

176 Parameters 

177 ---------- 

178 memo : dict[int, Any] 

179 A memoization dictionary used by `copy.deepcopy`. 

180 

181 Returns 

182 ------- 

183 source : 

184 A deep copy of this IsolatedSource. 

185 """ 

186 if id(self) in memo: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 return memo[id(self)] 

188 

189 source = IsolatedSource.__new__(IsolatedSource) 

190 memo[id(self)] = source 

191 source.__init__( # type: ignore[misc] 

192 model=deepcopy(self.component._model, memo), 

193 peak=deepcopy(self.component.peak, memo), 

194 metadata=deepcopy(self.metadata, memo), 

195 ) 

196 return source 

197 

198 def __getitem__(self, indices: Any) -> IsolatedSource: 

199 """Get a sub-source by slicing along the band axis. 

200 

201 Delegates to ``self.component[indices]``; only band labels 

202 are accepted as the selector. Spatial slices and ``Box`` 

203 instances raise ``IndexError`` because they don't appear in 

204 ``self.bands``. 

205 

206 Parameters 

207 ---------- 

208 indices : Any 

209 A single band label, a slice of band labels (e.g. 

210 ``"g":"r"``), or a sequence of band labels. 

211 

212 Returns 

213 ------- 

214 source : 

215 A new IsolatedSource restricted to the selected bands. 

216 

217 Raises 

218 ------ 

219 IndexError : 

220 If ``indices`` is not a valid band selector. 

221 """ 

222 component = self.component[indices] 

223 return IsolatedSource( 

224 model=component._model, 

225 peak=component.peak, 

226 metadata=self.metadata, 

227 )