LSST Applications g04e9c324dd+8c5ae1fdc5,g0644efc3f0+7e72ced385,g123d84c11c+8c5ae1fdc5,g1ec0fe41b4+6ec6b74de1,g1fd858c14a+1d778e1869,g3533f9d6cb+7e72ced385,g35bb328faa+8c5ae1fdc5,g35ef7ab7cf+460d7c47aa,g53246c7159+8c5ae1fdc5,g60b5630c4e+7e72ced385,g60c8b41c57+095f713f68,g663da51e9b+6aff55297d,g6735e52a0d+29de3d959a,g67b6fd64d1+57193d00fb,g7605de067c+eb240d2b47,g78460c75b0+7e33a9eb6d,g786e29fd12+668abc6043,g844c57033c+03ddc13274,g8852436030+670976b475,g89139ef638+57193d00fb,g989de1cb63+57193d00fb,g9f33ca652e+a4f2b81461,ga1e959baac+5fbc491aed,ga2f891cd6c+7e72ced385,gabe3b4be73+8856018cbb,gabf8522325+cc757f8247,gac2eed3f23+57193d00fb,gb1101e3267+3b5d6a3d0b,gb89ab40317+57193d00fb,gcf25f946ba+670976b475,gd107969129+b3ac407438,gd6cbbdb0b4+8e46defd2a,gde0f65d7ad+eb00ebb37f,ge278dab8ac+2322f1d6ea,ge410e46f29+57193d00fb,gf30d85a44d+9b77053554,gf5e32f922b+8c5ae1fdc5,gff02db199a+bb6b2c7d57,w.2025.28
LSST Data Management Base Package
Loading...
Searching...
No Matches
component.py
Go to the documentation of this file.
1# This file is part of scarlet_lite.
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
22__all__ = [
23 "Component",
24 "FactorizedComponent",
25 "default_fista_parameterization",
26 "default_adaprox_parameterization",
27]
28
29from abc import ABC, abstractmethod
30from functools import partial
31from typing import Callable, cast
32
33import numpy as np
34
35from .bbox import Box
36from .image import Image
37from .operators import Monotonicity, prox_uncentered_symmetry
38from .parameters import AdaproxParameter, FistaParameter, Parameter, parameter, relative_step
39
40
41class Component(ABC):
42 """A base component in scarlet lite.
43
44 Parameters
45 ----------
46 bands:
47 The bands used when the component model is created.
48 bbox: Box
49 The bounding box for this component.
50 """
51
53 self,
54 bands: tuple,
55 bbox: Box,
56 ):
57 self._bands = bands
58 self._bbox = bbox
59
60 @property
61 def bbox(self) -> Box:
62 """The bounding box that contains the component in the full image"""
63 return self._bbox
64
65 @property
66 def bands(self) -> tuple:
67 """The bands in the component model"""
68 return self._bands
69
70 @abstractmethod
71 def resize(self, model_box: Box) -> bool:
72 """Test whether or not the component needs to be resized
73
74 This should be overriden in inherited classes and return `True`
75 if the component needs to be resized.
76 """
77
78 @abstractmethod
79 def update(self, it: int, input_grad: np.ndarray) -> None:
80 """Update the component parameters from an input gradient
81
82 Parameters
83 ----------
84 it:
85 The current iteration of the optimizer.
86 input_grad:
87 Gradient of the likelihood wrt the component model
88 """
89
90 @abstractmethod
91 def get_model(self) -> Image:
92 """Generate a model for the component
93
94 This must be implemented in inherited classes.
95
96 Returns
97 -------
98 model: Image
99 The image of the component model.
100 """
101
102 @abstractmethod
103 def parameterize(self, parameterization: Callable) -> None:
104 """Convert the component parameter arrays into Parameter instances
105
106 Parameters
107 ----------
108 parameterization: Callable
109 A function to use to convert parameters of a given type into
110 a `Parameter` in place. It should take a single argument that
111 is the `Component` or `Source` that is to be parameterized.
112 """
113
114
116 """A component that can be factorized into spectrum and morphology
117 parameters.
118
119 Parameters
120 ----------
121 bands:
122 The bands of the spectral dimension, in order.
123 spectrum:
124 The parameter to store and update the spectrum.
125 morph:
126 The parameter to store and update the morphology.
127 peak:
128 Location of the peak for the source.
129 bbox:
130 The `Box` in the `model_bbox` that contains the source.
131 bg_rms:
132 The RMS of the background used to threshold, grow,
133 and shrink the component.
134 bg_thresh:
135 The threshold to use for the background RMS.
136 If `None`, no background thresholding is applied, otherwise
137 a sparsity constraint is applied to the morpholigy that
138 requires flux in at least one band to be bg_thresh multiplied by
139 `bg_rms` in that band.
140 floor:
141 Minimum value of the spectrum or center morphology pixel
142 (depending on which is normalized).
143 monotonicity:
144 The monotonicity operator to use for making the source monotonic.
145 If this parameter is `None`, the source will not be made monotonic.
146 padding:
147 The amount of padding to add to the component bounding box
148 when resizing the component.
149 is_symmetric:
150 Whether the component is symmetric or not.
151 If `True`, the morphology will be symmetrized using
152 `prox_uncentered_symmetry`.
153 If `False`, the morphology will not be symmetrized.
154 """
155
157 self,
158 bands: tuple,
159 spectrum: Parameter | np.ndarray,
160 morph: Parameter | np.ndarray,
161 bbox: Box,
162 peak: tuple[int, int] | None = None,
163 bg_rms: np.ndarray | None = None,
164 bg_thresh: float | None = 0.25,
165 floor: float = 1e-20,
166 monotonicity: Monotonicity | None = None,
167 padding: int = 5,
168 is_symmetric: bool = False,
169 ):
170 # Initialize all of the base attributes
171 super().__init__(
172 bands=bands,
173 bbox=bbox,
174 )
175 self._spectrum = parameter(spectrum)
176 self._morph = parameter(morph)
177 self._peak = peak
178 self.bg_rms = bg_rms
179 self.bg_thresh = bg_thresh
180
181 self.floor = floor
182 self.monotonicity = monotonicity
183 self.padding = padding
184 self.is_symmetric = is_symmetric
185
186 @property
187 def peak(self) -> tuple[int, int] | None:
188 """The peak of the component
189
190 Returns
191 -------
192 peak:
193 The peak of the component
194 """
195 return self._peak
196
197 @property
198 def component_center(self) -> tuple[int, int] | None:
199 """The center of the component in its bounding box
200
201 This is likely to be different than `Component.center`,
202 since `Component.center` is the center of the component in the
203 full model, whereas `component_center` is the center of the component
204 inside its bounding box.
205
206 Returns
207 -------
208 center:
209 The center of the component in its bounding box
210 """
211 _center = self.peak
212 if _center is None:
213 return None
214 center = (
215 _center[0] - self.bbox.origin[-2],
216 _center[1] - self.bbox.origin[-1],
217 )
218 return center
219
220 @property
221 def spectrum(self) -> np.ndarray:
222 """The array of spectrum values"""
223 return self._spectrum.x
224
225 @property
226 def morph(self) -> np.ndarray:
227 """The array of morphology values"""
228 return self._morph.x
229
230 @property
231 def shape(self) -> tuple:
232 """Shape of the resulting model image"""
233 return self.spectrum.shape + self.morph.shape
234
235 def get_model(self) -> Image:
236 """Build the model from the spectrum and morphology"""
237 # The spectrum and morph might be Parameters,
238 # so cast them as arrays in the model.
239 spectrum = self.spectrum
240 morph = self.morph
241 model = spectrum[:, None, None] * morph[None, :, :]
242 return Image(model, bands=self.bands, yx0=cast(tuple[int, int], self.bbox.origin))
243
244 def grad_spectrum(self, input_grad: np.ndarray, spectrum: np.ndarray, morph: np.ndarray):
245 """Gradient of the spectrum wrt. the component model"""
246 return np.einsum("...jk,jk", input_grad, morph)
247
248 def grad_morph(self, input_grad: np.ndarray, morph: np.ndarray, spectrum: np.ndarray):
249 """Gradient of the morph wrt. the component model"""
250 return np.einsum("i,i...", spectrum, input_grad)
251
252 def prox_spectrum(self, spectrum: np.ndarray) -> np.ndarray:
253 """Apply a prox-like update to the spectrum"""
254 # prevent divergent spectrum
255 spectrum[spectrum < self.floor] = self.floor
256 spectrum[~np.isfinite(spectrum)] = self.floor
257 return spectrum
258
259 def prox_morph(self, morph: np.ndarray) -> np.ndarray:
260 """Apply a prox-like update to the morphology"""
261 # Get the peak position in the current bbox
262 shape = morph.shape
263 if self.peak is None:
264 peak = (shape[0] // 2, shape[1] // 2)
265 else:
266 peak = (
267 self.peak[0] - self.bbox.origin[-2],
268 self.peak[1] - self.bbox.origin[-1],
269 )
270
271 # monotonicity
272 if self.monotonicity is not None:
273 morph = self.monotonicity(morph, cast(tuple[int, int], self.component_center))
274
275 # symmetry
276 if self.is_symmetric:
277 # Apply the symmetry operator
278 morph = prox_uncentered_symmetry(morph, peak, fill=0.0)
279
280 if self.bg_thresh is not None and self.bg_rms is not None:
281 bg_thresh = self.bg_rms * self.bg_thresh
282 # Enforce background thresholding
283 model = self.spectrum[:, None, None] * morph[None, :, :]
284 morph[np.all(model < bg_thresh[:, None, None], axis=0)] = 0
285 else:
286 # enforce positivity
287 morph[morph < 0] = 0
288
289 # prevent divergent morphology
290 morph[peak] = np.max([morph[peak], self.floor])
291
292 # Ensure that the morphology is finite
293 morph[~np.isfinite(morph)] = 0
294
295 # Normalize the morphology
296 max_value = np.max(morph)
297 if max_value > 0:
298 morph[:] = morph / max_value
299 return morph
300
301 def resize(self, model_box: Box) -> bool:
302 """Test whether or not the component needs to be resized"""
303 # No need to resize if there is no size threshold.
304 # To allow box sizing but no thresholding use `bg_thresh=0`.
305 if self.bg_thresh is None or self.bg_rms is None:
306 return False
307
308 model = self.spectrum[:, None, None] * self.morph[None, :, :]
309 bg_thresh = self.bg_rms * self.bg_thresh
310 significant = np.any(model >= bg_thresh[:, None, None], axis=0)
311 if np.sum(significant) == 0:
312 # There are no significant pixels,
313 # so make a small box around the center
314 center = self.peak
315 if center is None:
316 center = (0, 0)
317 new_box = Box((1, 1), center).grow(self.padding) & model_box
318 else:
319 new_box = (
320 Box.from_data(significant, threshold=0).grow(self.padding) + self.bbox.origin # type: ignore
321 ) & model_box
322 if new_box == self.bbox:
323 return False
324
325 old_box = self.bbox
326 self._bbox = new_box
327 self._morph.resize(old_box, new_box)
328 return True
329
330 def update(self, it: int, input_grad: np.ndarray):
331 """Update the spectrum and morphology parameters"""
332 # Store the input spectrum so that the morphology can
333 # have a consistent update
334 spectrum = self.spectrum.copy()
335 self._spectrum.update(it, input_grad, self.morph)
336 self._morph.update(it, input_grad, spectrum)
337
338 def parameterize(self, parameterization: Callable) -> None:
339 """Convert the component parameter arrays into Parameter instances
340
341 Parameters
342 ----------
343 parameterization: Callable
344 A function to use to convert parameters of a given type into
345 a `Parameter` in place. It should take a single argument that
346 is the `Component` or `Source` that is to be parameterized.
347 """
348 # Update the spectrum and morph in place
349 parameterization(self)
350 # update the parameters
351 self._spectrum.grad = self.grad_spectrum
352 self._spectrum.prox = self.prox_spectrum
353 self._morph.grad = self.grad_morph
354 self._morph.prox = self.prox_morph
355
356 def __str__(self):
357 result = (
358 f"FactorizedComponent<\n bands={self.bands},\n center={self.peak},\n "
359 f"spectrum={self.spectrum},\n morph_shape={self.morph.shape}\n>"
360 )
361 return result
362
363 def __repr__(self):
364 return self.__str__()
365
366
367def default_fista_parameterization(component: Component):
368 """Initialize a factorized component to use FISTA PGM for optimization"""
369 if isinstance(component, FactorizedComponent):
370 component._spectrum = FistaParameter(component.spectrum, step=0.5)
371 component._morph = FistaParameter(component.morph, step=0.5)
372 else:
373 raise NotImplementedError(f"Unrecognized component type {component}")
374
375
376def default_adaprox_parameterization(component: Component, noise_rms: float | None = None):
377 """Initialize a factorized component to use Proximal ADAM
378 for optimization
379 """
380 if noise_rms is None:
381 noise_rms = 1e-16
382 if isinstance(component, FactorizedComponent):
383 component._spectrum = AdaproxParameter(
384 component.spectrum,
385 step=partial(relative_step, factor=1e-2, minimum=noise_rms),
386 )
387 component._morph = AdaproxParameter(
388 component.morph,
389 step=1e-2,
390 )
391 else:
392 raise NotImplementedError(f"Unrecognized component type {component}")
A class to represent a 2-dimensional array of pixels.
Definition Image.h:51
bool resize(self, Box model_box)
Definition component.py:71
None update(self, int it, np.ndarray input_grad)
Definition component.py:79
None parameterize(self, Callable parameterization)
Definition component.py:103
__init__(self, tuple bands, Box bbox)
Definition component.py:56
tuple[int, int]|None component_center(self)
Definition component.py:198
update(self, int it, np.ndarray input_grad)
Definition component.py:330
grad_morph(self, np.ndarray input_grad, np.ndarray morph, np.ndarray spectrum)
Definition component.py:248
None parameterize(self, Callable parameterization)
Definition component.py:338
np.ndarray prox_spectrum(self, np.ndarray spectrum)
Definition component.py:252
grad_spectrum(self, np.ndarray input_grad, np.ndarray spectrum, np.ndarray morph)
Definition component.py:244
np.ndarray prox_morph(self, np.ndarray morph)
Definition component.py:259
__init__(self, tuple bands, Parameter|np.ndarray spectrum, Parameter|np.ndarray morph, Box bbox, tuple[int, int]|None peak=None, np.ndarray|None bg_rms=None, float|None bg_thresh=0.25, float floor=1e-20, Monotonicity|None monotonicity=None, int padding=5, bool is_symmetric=False)
Definition component.py:169
default_fista_parameterization(Component component)
Definition component.py:367
default_adaprox_parameterization(Component component, float|None noise_rms=None)
Definition component.py:376