Coverage for python/lsst/meas/extensions/scarlet/source.py: 94%
58 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 09:10 +0000
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-01 09:10 +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/>.
22from __future__ import annotations
24from copy import deepcopy
25from typing import TYPE_CHECKING, Any
27import numpy as np
28from numpy.typing import DTypeLike
30from lsst.afw.detection import Footprint
31from lsst.afw.image import MultibandExposure
32import lsst.scarlet.lite as scl
34from .utils import nonzeroBandSupport
36if TYPE_CHECKING:
37 from .io import IsolatedSourceData
40__all__ = ["IsolatedSource"]
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.
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)]
61 @property
62 def component(self) -> scl.component.CubeComponent:
63 """The single component of this isolated source.
65 Returns
66 -------
67 component :
68 The CubeComponent representing this isolated source.
69 """
70 return self.components[0]
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.
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.
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)
117 @property
118 def bbox(self) -> scl.Box:
119 """The bounding box of the source in the full Blend."""
120 return self.component.bbox
122 @property
123 def bands(self) -> list[str]:
124 """The ordered list of bands in the full source model."""
125 return self.component.bands
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
132 def get_model(self) -> scl.Image:
133 """Get the full 3D (band, y, x) model of the source.
135 Returns
136 -------
137 model :
138 The 3D (band, y, x) model of the source.
139 """
140 return self.component.get_model()
142 def to_data(self) -> IsolatedSourceData:
143 """Convert to a ScarletSourceData representation.
145 Returns
146 -------
147 source_data :
148 The source represented as a ScarletSourceData.
149 """
150 from .io import IsolatedSourceData
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 )
159 def __copy__(self) -> IsolatedSource:
160 """Create a copy of this IsolatedSource.
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 )
173 def __deepcopy__(self, memo: dict[int, Any]) -> IsolatedSource:
174 """Create a deep copy of this IsolatedSource.
176 Parameters
177 ----------
178 memo : dict[int, Any]
179 A memoization dictionary used by `copy.deepcopy`.
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)]
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
198 def __getitem__(self, indices: Any) -> IsolatedSource:
199 """Get a sub-source by slicing along the band axis.
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``.
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.
212 Returns
213 -------
214 source :
215 A new IsolatedSource restricted to the selected bands.
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 )