Coverage for python / lsst / scarlet / lite / models / parametric.py: 32%
315 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-22 07:47 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-22 07:47 +0000
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/>.
22from __future__ import annotations
24__all__ = [
25 "bounded_prox",
26 "gaussian2d",
27 "grad_gaussian2",
28 "circular_gaussian",
29 "grad_circular_gaussian",
30 "integrated_gaussian",
31 "grad_integrated_gaussian",
32 "sersic",
33 "grad_sersic",
34 "CartesianFrame",
35 "EllipseFrame",
36 "ParametricComponent",
37 "EllipticalParametricComponent",
38]
40from copy import deepcopy
41from typing import TYPE_CHECKING, Any, Callable, Sequence, cast
43import numpy as np
44from scipy.special import erf
45from scipy.stats import gamma
47from ..bbox import Box
48from ..component import Component
49from ..image import Image
50from ..parameters import Parameter, parameter
51from ..utils import convert_indices
53if TYPE_CHECKING:
54 from ..io import ScarletComponentBaseData
56# Some operations fail at the origin in radial coordinates,
57# so we make use of a very small offset.
58MIN_RADIUS = 1e-20
60# Useful constants
61SQRT_PI_2 = np.sqrt(np.pi / 2)
63# Stored sersic constants
64SERSIC_B1 = gamma.ppf(0.5, 2)
67class CartesianFrame:
68 """A grid of X and Y values contained in a bbox
70 Parameters
71 ----------
72 bbox:
73 The bounding box that contains this frame.
74 """
76 def __init__(self, bbox: Box):
77 # Store the new bounding box
78 self._bbox = bbox
79 # Get the range of x and y
80 yi, xi = bbox.start[-2:]
81 yf, xf = bbox.stop[-2:]
82 height, width = bbox.shape[-2:]
83 y = np.linspace(yi, yf - 1, height)
84 x = np.linspace(xi, xf - 1, width)
85 # Create the grid used to create the image of the frame
86 self._x, self._y = np.meshgrid(x, y)
87 self._r = None
88 self._r2 = None
90 @property
91 def shape(self) -> tuple[int, ...]:
92 """Shape of the frame."""
93 return self._bbox.shape
95 @property
96 def bbox(self) -> Box:
97 """Bounding box containing the frame"""
98 return self._bbox
100 @property
101 def x_grid(self) -> np.ndarray:
102 """The grid of x-values for the entire frame"""
103 return self._x
105 @property
106 def y_grid(self) -> np.ndarray:
107 """The grid of y-values for the entire frame"""
108 return self._y
111class EllipseFrame(CartesianFrame):
112 """Frame to scale the radius based on the parameters of an ellipse
114 This frame is used to calculate the coordinates of the
115 radius and radius**2 from a given center location,
116 based on the semi-major axis, semi-minor axis, and rotation angle.
117 It is also used to calculate the gradient wrt either the
118 radius**2 or radius for all of the model parameters.
120 Parameters
121 ----------
122 y0: float
123 The y-center of the ellipse.
124 x0: float
125 The x-center of the ellipse.
126 major: float
127 The length of the semi-major axis.
128 minor: float
129 The length of the semi-minor axis.
130 theta: float
131 The counter-clockwise rotation angle
132 from the semi-major axis.
133 bbox: Box
134 The bounding box that contains the entire frame.
135 r_min: float
136 The minimum value of the radius.
137 This is used to prevent divergences that occur
138 when calculating the gradient at radius == 0.
139 """
141 def __init__(
142 self,
143 y0: float,
144 x0: float,
145 major: float,
146 minor: float,
147 theta: float,
148 bbox: Box,
149 r_min: float = 1e-20,
150 ):
151 super().__init__(bbox)
152 # Set some useful parameters for derivations
153 sin = np.sin(theta)
154 cos = np.cos(theta)
156 # Rotate into the frame with xMajor as the x-axis
157 # and xMinor as the y-axis
158 self._xMajor = (self._x - x0) * cos + (self._y - y0) * sin
159 self._xMinor = -(self._x - x0) * sin + (self._y - y0) * cos
160 # The scaled major and minor axes
161 self._xa = self._xMajor / major
162 self._yb = self._xMinor / minor
164 # Store parameters useful for gradient calculation
165 self._y0, self._x0 = y0, x0
166 self._theta = theta
167 self._major = major
168 self._minor = minor
169 self._sin, self._cos = sin, cos
170 self._bbox = bbox
171 self._rMin = r_min
172 # Store the scaled radius**2 and radius
173 self._radius2 = self._xa**2 + self._yb**2
174 self._radius: np.ndarray | None = None
176 def grad_x0(self, input_grad: np.ndarray, use_r2: bool) -> float:
177 """The gradient of either the radius or radius**2 wrt. the x-center
179 Parameters
180 ----------
181 input_grad:
182 Gradient of the likelihood wrt the component model
183 use_r2:
184 Whether to calculate the gradient of the radius**2
185 (``useR2==True``) or the radius (``useR2==False``).
187 Returns
188 -------
189 result:
190 The gradient of the likelihood wrt x0.
191 """
192 grad = -self._xa * self._cos / self._major + self._yb * self._sin / self._minor
193 if use_r2:
194 grad *= 2
195 else:
196 r = self.r_grid
197 grad *= 1 / r
198 return np.sum(grad * input_grad) # type: ignore
200 def grad_y0(self, input_grad: np.ndarray, use_r2: bool) -> float:
201 """The gradient of either the radius or radius**2 wrt. the y-center
203 Parameters
204 ----------
205 input_grad:
206 Gradient of the likelihood wrt the component model
207 use_r2:
208 Whether to calculate the gradient of the radius**2
209 (``useR2==True``) or the radius (``useR2==False``).
211 Returns
212 -------
213 result:
214 The gradient of the likelihood wrt y0.
215 """
216 grad = -(self._xa * self._sin / self._major + self._yb * self._cos / self._minor)
217 if use_r2:
218 grad *= 2
219 else:
220 r = self.r_grid
221 grad *= 1 / r
222 return np.sum(grad * input_grad) # type: ignore
224 def grad_major(self, input_grad: np.ndarray, use_r2: bool) -> float:
225 """The gradient of either the radius or radius**2 wrt.
226 the semi-major axis
228 Parameters
229 ----------
230 input_grad:
231 Gradient of the likelihood wrt the component model
232 use_r2:
233 Whether to calculate the gradient of the radius**2
234 (``use_r2==True``) or the radius (``use_r2==False``).
236 Returns
237 -------
238 result:
239 The gradient of the likelihood wrt the semi-major axis.
240 """
241 # ``grad`` here is half of d(r**2)/d(major), matching the
242 # convention used by ``grad_x0`` / ``grad_y0`` / ``grad_theta``
243 # so the post-multiplications below produce d(r**2) and d(r)
244 # respectively.
245 grad = -1 / self._major * self._xa**2
246 if use_r2:
247 grad *= 2
248 else:
249 r = self.r_grid
250 grad *= 1 / r
251 return np.sum(grad * input_grad) # type: ignore
253 def grad_minor(self, input_grad: np.ndarray, use_r2: bool) -> float:
254 """The gradient of either the radius or radius**2 wrt.
255 the semi-minor axis
257 Parameters
258 ----------
259 input_grad:
260 Gradient of the likelihood wrt the component model
261 use_r2:
262 Whether to calculate the gradient of the radius**2
263 (``useR2==True``) or the radius (``useR2==False``).
265 Returns
266 -------
267 result:
268 The gradient of the likelihood wrt the semi-minor axis.
269 """
270 # See ``grad_major`` for the half-r**2 convention.
271 grad = -1 / self._minor * self._yb**2
272 if use_r2:
273 grad *= 2
274 else:
275 r = self.r_grid
276 grad *= 1 / r
277 return np.sum(grad * input_grad) # type: ignore
279 def grad_theta(self, input_grad: np.ndarray, use_r2: bool) -> float:
280 """The gradient of either the radius or radius**2 wrt.
281 the rotation angle
283 Parameters
284 ----------
285 input_grad:
286 Gradient of the likelihood wrt the component model
287 use_r2:
288 Whether to calculate the gradient of the radius**2
289 (``useR2==True``) or the radius (``useR2==False``).
291 Returns
292 -------
293 result:
294 The gradient of the likelihood wrt the rotation angle.
295 """
296 grad = self._xa * self._xMinor / self._major - self._yb * self._xMajor / self._minor
297 if use_r2:
298 grad *= 2
299 else:
300 r = self.r_grid
301 grad *= 1 / r
302 return np.sum(grad * input_grad) # type: ignore
304 @property
305 def x0(self) -> float:
306 """The x-center"""
307 return self._x0
309 @property
310 def y0(self) -> float:
311 """The y-center"""
312 return self._y0
314 @property
315 def major(self) -> float:
316 """The semi-major axis"""
317 return self._major
319 @property
320 def minor(self) -> float:
321 """The semi-minor axis"""
322 return self._minor
324 @property
325 def theta(self) -> float:
326 """The rotation angle"""
327 return self._theta
329 @property
330 def bbox(self) -> Box:
331 """The bounding box to hold the model"""
332 return self._bbox
334 @property
335 def r_grid(self) -> np.ndarray:
336 """The radial coordinates of each pixel"""
337 if self._radius is None:
338 self._radius = np.sqrt(self.r2_grid)
339 return self._radius
341 @property
342 def r2_grid(self) -> np.ndarray:
343 """The radius squared located at each pixel"""
344 return self._radius2 + self._rMin**2
347def gaussian2d(params: np.ndarray, ellipse: EllipseFrame) -> np.ndarray:
348 """Model of a 2D elliptical gaussian
350 Parameters
351 ----------
352 params:
353 The parameters of the function.
354 In this case there are none outside of the ellipticity
355 ellipse:
356 The ellipse parameters to scale the radius in all directions.
358 Returns
359 -------
360 result:
361 The 2D guassian for the given ellipse parameters
362 """
363 return np.exp(-ellipse.r2_grid)
366def grad_gaussian2(
367 input_grad: np.ndarray,
368 params: np.ndarray,
369 morph: np.ndarray,
370 spectrum: np.ndarray,
371 ellipse: EllipseFrame,
372) -> np.ndarray:
373 """Gradient of the the component model wrt the Gaussian
374 morphology parameters
376 Parameters
377 ----------
378 input_grad:
379 Gradient of the likelihood wrt the component model
380 params:
381 The parameters of the morphology.
382 morph:
383 The model of the morphology.
384 spectrum:
385 The model of the spectrum.
386 ellipse:
387 The ellipse parameters to scale the radius in all directions.
388 """
389 # Calculate the gradient of the likelihod
390 # wrt the Gaussian e^-r**2
391 _grad = -morph * np.einsum("i,i...", spectrum, input_grad)
392 d_y0 = ellipse.grad_y0(_grad, True)
393 d_x0 = ellipse.grad_x0(_grad, True)
394 d_sigma_y = ellipse.grad_major(_grad, True)
395 d_sigma_x = ellipse.grad_minor(_grad, True)
396 d_theta = ellipse.grad_theta(_grad, True)
397 return np.array([d_y0, d_x0, d_sigma_y, d_sigma_x, d_theta], dtype=params.dtype)
400def circular_gaussian(center: Sequence[int], frame: CartesianFrame, sigma: float) -> np.ndarray:
401 """Model of a circularly symmetric Gaussian
403 Parameters
404 ----------
405 center:
406 The center of the Gaussian.
407 frame:
408 The frame in which to generate the image of the circular Gaussian
409 sigma:
410 The standard deviation.
412 Returns
413 -------
414 result:
415 The image of the circular Gaussian.
416 """
417 y0, x0 = center[:2]
418 two_sigma = 2 * sigma
419 r2 = ((frame.x_grid - x0) / two_sigma) ** 2 + ((frame.y_grid - y0) / two_sigma) ** 2
420 return np.exp(-r2)
423def grad_circular_gaussian(
424 input_grad: np.ndarray,
425 params: np.ndarray,
426 morph: np.ndarray,
427 spectrum: np.ndarray,
428 frame: CartesianFrame,
429 sigma: float,
430) -> np.ndarray:
431 """Gradient of the the component model wrt the Gaussian
432 morphology parameters
434 Parameters
435 ----------
436 input_grad:
437 Gradient of the likelihood wrt the component model
438 params:
439 The parameters of the morphology.
440 morph:
441 The model of the morphology.
442 spectrum:
443 The model of the spectrum.
444 frame:
445 The frame in which to generate the image of the circular Gaussian.
446 sigma:
447 The standard deviation.
448 """
449 # Calculate the gradient of the likelihod
450 # wrt the Gaussian e^-r**2
452 _grad = -morph * np.einsum("i,i...", spectrum, input_grad)
454 y0, x0 = params[:2]
455 # d morph / d y0 = morph * (y - y0) / (2 * sigma**2), and similarly for x0,
456 # because r2 = ((x-x0)/(2*sigma))**2 + ((y-y0)/(2*sigma))**2.
457 inv_two_sigma_sq = 1.0 / (2.0 * sigma**2)
458 d_y0 = -inv_two_sigma_sq * np.sum((frame.y_grid - y0) * _grad)
459 d_x0 = -inv_two_sigma_sq * np.sum((frame.x_grid - x0) * _grad)
460 return np.array([d_y0, d_x0], dtype=params.dtype)
463def integrated_gaussian(params: np.ndarray, frame: CartesianFrame):
464 """Model of a circularly symmetric Gaussian integrated over pixels
466 This differs from `circularGaussian` because the gaussian function
467 is integrated over each pixel to replicate the pixelated image
468 version of a Gaussian function.
470 Parameters
471 ----------
472 params:
473 The center of the Gaussian.
474 frame:
475 The frame in which to generate the image of the circular Gaussian
477 Returns
478 -------
479 result:
480 The image of the circular Gaussian.
481 """
482 # Unpack the parameters and define constants
483 y0, x0, sigma = params
484 r = np.sqrt((frame.x_grid - x0) ** 2 + (frame.y_grid - y0) ** 2)
485 sqrt_c = 1 / np.sqrt(2) / sigma
486 # Integrate from half a pixel left and right
487 lhs = erf((r - 0.5) * sqrt_c)
488 rhs = erf((r + 0.5) * sqrt_c)
489 z = 0.5 * np.sqrt(np.pi) / sqrt_c * (rhs - lhs)
490 return z
493def grad_integrated_gaussian(
494 input_grad: np.ndarray,
495 params: np.ndarray,
496 morph: np.ndarray,
497 spectrum: np.ndarray,
498 frame: CartesianFrame,
499) -> np.ndarray:
500 """Gradient of the component model wrt the Gaussian
501 morphology parameters
503 Parameters
504 ----------
505 input_grad:
506 Gradient of the likelihood wrt the component model parameters.
507 params:
508 The parameters of the morphology.
509 morph:
510 The model of the morphology.
511 spectrum:
512 The model of the spectrum.
513 frame:
514 The frame in which to generate the image of the circular Gaussian.
516 Returns
517 -------
518 result:
519 The gradient of the component morphology.
520 """
521 # Calculate the gradient of the likelihood
522 # wrt the Gaussian e^-r**2
523 _grad = np.einsum("i,i...", spectrum, input_grad)
525 # Extract the parameters
526 y0, x0, sigma = params
527 # define useful constants
528 x = frame.x_grid - x0
529 y = frame.y_grid - y0
530 c = 0.5 / sigma**2
531 sqrt_c = np.sqrt(c)
532 # Add a small constant to the radius to prevent a divergence at r==0
533 r = np.sqrt(x**2 + y**2) + MIN_RADIUS
534 # Shift half a pixel in each direction for the integration
535 r1 = r - 0.5
536 r2 = r + 0.5
537 # Calculate the gradient of the ERF wrt. each shifted radius
538 d_model1 = np.exp(-c * r1**2)
539 d_model2 = np.exp(-c * r2**2)
540 # Calculate the gradients of the parameters
541 d_x0 = np.sum(-x / r * (d_model2 - d_model1) * _grad)
542 d_y0 = np.sum(-y / r * (d_model2 - d_model1) * _grad)
543 d_sigma1 = -(r1 * d_model1 / sigma - SQRT_PI_2 * erf(r1 * sqrt_c))
544 d_sigma2 = -(r2 * d_model2 / sigma - SQRT_PI_2 * erf(r2 * sqrt_c))
545 d_sigma = np.sum((d_sigma2 - d_sigma1) * _grad)
547 return np.array([d_y0, d_x0, d_sigma])
550def bounded_prox(params: np.ndarray, proxmin: np.ndarray, proxmax: np.ndarray) -> np.ndarray:
551 """A bounded proximal operator
553 This function updates `params` in place.
555 Parameters
556 ----------
557 params:
558 The array of parameters to constrain.
559 proxmin:
560 The array of minimum values for each parameter.
561 proxmax:
562 The array of maximum values for each parameter.
564 Returns
565 -------
566 result:
567 The updated parameters.
568 """
569 cuts = params < proxmin
570 params[cuts] = proxmin[cuts]
571 cuts = params > proxmax
572 params[cuts] = proxmax[cuts]
573 return params
576def sersic(params: np.ndarray, ellipse: EllipseFrame) -> np.ndarray:
577 """Generate a Sersic Model.
579 Parameters
580 ----------
581 params:
582 The parameters of the function.
583 In this case the only parameter is the sersic index ``n``.
584 ellipse:
585 The ellipse parameters to scale the radius in all directions.
587 Returns
588 -------
589 result:
590 The model for the given ellipse parameters
591 """
592 (n,) = params
594 r = ellipse.r_grid
596 if n == 1:
597 result = np.exp(-SERSIC_B1 * r)
598 else:
599 bn = gamma.ppf(0.5, 2 * n)
600 result = np.exp(-bn * (r ** (1 / n) - 1))
601 return result
604def grad_sersic(
605 input_grad: np.ndarray,
606 params: np.ndarray,
607 morph: np.ndarray,
608 spectrum: np.ndarray,
609 ellipse: EllipseFrame,
610) -> np.ndarray:
611 """Gradient of the component model wrt the morphology parameters
613 Parameters
614 ----------
615 input_grad:
616 Gradient of the likelihood wrt the component model
617 params:
618 The parameters of the morphology.
619 morph:
620 The model of the morphology.
621 spectrum:
622 The model of the spectrum.
623 ellipse:
624 The ellipse parameters to scale the radius in all directions.
625 """
626 n = params[5]
627 bn = gamma.ppf(0.5, 2 * n)
628 if n == 1:
629 # Use a simplified model for faster calculation
630 d_exp = -SERSIC_B1 * morph
631 else:
632 r = ellipse.r_grid
633 d_exp = -bn / n * morph * r ** (1 / n - 1)
635 _grad = np.einsum("i,i...", spectrum, input_grad)
636 d_n = np.sum(_grad * bn * morph * ellipse.r_grid ** (1 / n) * np.log(ellipse.r_grid) / n**2)
637 _grad = _grad * d_exp
638 d_y0 = ellipse.grad_y0(_grad, False)
639 d_x0 = ellipse.grad_x0(_grad, False)
640 d_sigma_y = ellipse.grad_major(_grad, False)
641 d_sigma_x = ellipse.grad_minor(_grad, False)
642 d_theta = ellipse.grad_theta(_grad, False)
643 return np.array([d_y0, d_x0, d_sigma_y, d_sigma_x, d_theta, d_n], dtype=params.dtype)
646class ParametricComponent(Component):
647 """A parametric model of an astrophysical source
649 Parameters
650 ----------
651 bands:
652 The bands used in the model.
653 bbox:
654 The bounding box that holds the model.
655 spectrum:
656 The spectrum of the component.
657 morph_params:
658 The parameters of the morphology.
659 morph_func:
660 The function to generate the 2D morphology image
661 based on `morphParams`.
662 morph_grad:
663 The function to calculate the gradient of the
664 likelihood wrt the morphological parameters.
665 morph_prox:
666 The proximal operator for the morphology parameters.
667 morph_step:
668 The function that calculates the gradient of the
669 morphological model.
670 prox_spectrum:
671 Proximal operator for the spectrum.
672 If `prox_spectrum` is `None` then the default proximal
673 operator `self.prox_spectrum` is used.
674 floor:
675 The minimum value of the spectrum, used to prevent
676 divergences in the gradients.
677 """
679 def __init__(
680 self,
681 bands: tuple,
682 bbox: Box,
683 spectrum: Parameter | np.ndarray,
684 morph_params: Parameter | np.ndarray,
685 morph_func: Callable,
686 morph_grad: Callable,
687 morph_prox: Callable,
688 morph_step: Callable | np.ndarray,
689 prox_spectrum: Callable | None = None,
690 floor: float = 1e-20,
691 ):
692 super().__init__(bands=bands, bbox=bbox)
694 self._spectrum = parameter(spectrum)
695 self._params = parameter(morph_params)
696 self._func = morph_func
697 self._morph_grad = morph_grad
698 self._morph_prox = morph_prox
699 self._morph_step = morph_step
700 self._bbox = bbox
701 if prox_spectrum is None:
702 self._prox_spectrum: Callable = self.prox_spectrum
703 else:
704 self._prox_spectrum = prox_spectrum
705 self.floor = floor
707 @property
708 def peak(self) -> tuple[float, float]:
709 """The center of the component"""
710 return self.y0, self.x0
712 @property
713 def y0(self) -> float:
714 """The y-center of the component"""
715 return self._params.x[0]
717 @property
718 def x0(self) -> float:
719 """The x-center of the component"""
720 return self._params.x[1]
722 @property
723 def spectrum(self) -> np.ndarray:
724 """The array of spectrum values"""
725 return self._spectrum.x
727 @property
728 def frame(self) -> CartesianFrame:
729 """The coordinate system that contains the model"""
730 return CartesianFrame(self._bbox)
732 @property
733 def radial_params(self) -> np.ndarray:
734 """The parameters used to model the radial function"""
735 return self._params.x
737 def _get_morph(self, frame: CartesianFrame | None = None) -> np.ndarray:
738 """The 2D image of the morphology
740 This callable generates an image of the morphology
741 in the given frame.
743 Parameters
744 ----------
745 frame:
746 The frame (bounding box, pixel grid) that the image is
747 placed in.
749 Returns
750 -------
751 result:
752 The image of the morphology in the `frame`.
753 """
754 if frame is None:
755 frame = self.frame
756 return self._func(self.radial_params, frame)
758 @property
759 def morph(self, frame: CartesianFrame | None = None) -> np.ndarray:
760 """The morphological model"""
761 return self._get_morph(frame)
763 @property
764 def prox_morph(self) -> Callable:
765 """The function used to constrain the morphological model"""
766 return self._morph_prox
768 @property
769 def grad_morph(self) -> Callable:
770 """The function that calculates the gradient of the
771 morphological model
772 """
773 return self._morph_grad
775 @property
776 def morph_step(self) -> Callable:
777 """The function that calculates the gradient of the
778 morphological model
779 """
780 return cast(Callable, self._morph_step)
782 def get_model(self, frame: CartesianFrame | None = None) -> Image:
783 """Generate the full model for this component"""
784 model = self.spectrum[:, None, None] * self._get_morph(frame)[None, :, :]
785 return Image(model, bands=self.bands, yx0=cast(tuple[int, int], self.bbox.origin[-2:]))
787 def prox_spectrum(self, spectrum: np.ndarray) -> np.ndarray:
788 """Apply a prox-like update to the spectrum
790 Parameters
791 ----------
792 spectrum:
793 The spectrum of the model.
794 """
795 # prevent divergent spectrum
796 spectrum[spectrum < self.floor] = self.floor
797 return spectrum
799 def grad_spectrum(self, input_grad: np.ndarray, spectrum: np.ndarray, morph: np.ndarray) -> np.ndarray:
800 """Gradient of the spectrum wrt. the component model
802 Parameters
803 ----------
804 input_grad:
805 Gradient of the likelihood wrt the component model
806 spectrum:
807 The model of the spectrum.
808 morph:
809 The model of the morphology.
811 Returns
812 -------
813 result:
814 The gradient of the likelihood wrt. the spectrum.
815 """
816 return np.einsum("...jk,jk", input_grad, morph)
818 def update(self, it: int, input_grad: np.ndarray):
819 """Update the component parameters from an input gradient
821 Parameters
822 ----------
823 it:
824 The current iteration of the optimizer.
825 input_grad:
826 Gradient of the likelihood wrt the component model
827 """
828 spectrum = self.spectrum.copy()
829 morph = self.morph
830 self._spectrum.update(it, input_grad, morph)
831 self._params.update(it, input_grad, morph, spectrum, self.frame)
833 def resize(self, model_box: Box) -> bool:
834 """Resize the box that contains the model
836 Not yet implemented, so for now the model box
837 does not grow. If this is ever implemented in production,
838 in the long run this will be based on a cutoff value for the model.
839 """
840 return False
842 def parameterize(self, parameterization: Callable) -> None:
843 """Convert the component parameter arrays into Parameter instances"""
844 # Update the spectrum and morph in place
845 parameterization(self)
846 # update the parameters
847 self._spectrum.grad = self.grad_spectrum
848 self._spectrum.prox = self.prox_spectrum
849 self._params.grad = self.grad_morph
850 self._params.prox = self.prox_morph
852 def to_data(self) -> ScarletComponentBaseData:
853 raise NotImplementedError("Saving elliptical parametric components is not yet implemented")
855 def __getitem__(self, indices: Any) -> ParametricComponent:
856 """Get a sub-component corresponding to the given indices.
858 Parameters
859 ----------
860 indices: Any
861 The indices to use to slice the component model.
863 Returns
864 -------
865 component: ParametricComponent
866 A new component that is a sub-component of this one.
868 Raises
869 ------
870 IndexError :
871 If the index includes a ``Box`` or spatial indices.
872 """
873 # Update the bands
874 if indices in self._bands:
875 # Single band case
876 bands = (indices,)
877 else:
878 # Multiple bands case
879 bands = tuple(indices)
881 # Convert the band indices into numerical indices
882 band_indices = convert_indices(self.bands, indices)
884 # Slice the spectrum
885 spectrum = self._spectrum.x[band_indices]
887 return ParametricComponent(
888 bands=bands,
889 bbox=self.bbox,
890 spectrum=spectrum,
891 morph_params=self.radial_params,
892 morph_func=self._func,
893 morph_grad=self._morph_grad,
894 morph_prox=self._morph_prox,
895 morph_step=self._morph_step,
896 prox_spectrum=self._prox_spectrum,
897 floor=self.floor,
898 )
900 def __deepcopy__(self, memo: dict[int, Any]) -> ParametricComponent:
901 """Create a deep copy of this component
903 Parameters
904 ----------
905 memo:
906 The memoization dictionary used by `copy.deepcopy`.
907 Returns
908 -------
909 component : ParametricComponent
910 A new component that is a deep copy of this one.
911 """
912 if id(self) in memo:
913 return memo[id(self)]
915 component = ParametricComponent.__new__(ParametricComponent)
916 memo[id(self)] = component
918 component.__init__( # type: ignore[misc]
919 bands=deepcopy(self.bands),
920 bbox=deepcopy(self.bbox),
921 spectrum=deepcopy(self.spectrum),
922 morph_params=deepcopy(self.radial_params),
923 morph_func=self._func,
924 morph_grad=self._morph_grad,
925 morph_prox=self._morph_prox,
926 morph_step=self._morph_step,
927 prox_spectrum=self._prox_spectrum,
928 floor=self.floor,
929 )
931 return component
933 def __copy__(self) -> ParametricComponent:
934 """Create a copy of this component
936 Returns
937 -------
938 component : ParametricComponent
939 A new component that is a shallow copy of this one.
940 """
941 return ParametricComponent(
942 bands=self.bands,
943 bbox=self.bbox,
944 spectrum=self.spectrum,
945 morph_params=self.radial_params,
946 morph_func=self._func,
947 morph_grad=self._morph_grad,
948 morph_prox=self._morph_prox,
949 morph_step=self._morph_step,
950 prox_spectrum=self._prox_spectrum,
951 floor=self.floor,
952 )
955class EllipticalParametricComponent(ParametricComponent):
956 """A radial density/surface brightness profile with elliptical symmetry
958 Parameters
959 ----------
960 bands:
961 The bands used in the model.
962 bbox:
963 The bounding box that holds this component model.
964 spectrum:
965 The spectrum of the component.
966 morph_params:
967 The parameters passed to `morph_func` to
968 generate the morphology in image space.
969 morph_func:
970 The function to generate the morphology
971 based on `morphParams`.
972 morph_grad:
973 The function to calculate the gradient of the
974 likelihood wrt the morphological parameters.
975 morph_prox:
976 The proximal operator for the morphology parameters.
977 prox_spectrum:
978 Proximal operator for the spectrum.
979 If `prox_spectrum` is `None` then the default proximal
980 operator `self.prox_spectrum` is used.
981 floor:
982 The minimum value of the spectrum, used to prevent
983 divergences in the gradients.
984 """
986 def __init__(
987 self,
988 bands: tuple,
989 bbox: Box,
990 spectrum: Parameter | np.ndarray,
991 morph_params: Parameter | np.ndarray,
992 morph_func: Callable,
993 morph_grad: Callable,
994 morph_prox: Callable,
995 morph_step: Callable | np.ndarray,
996 prox_spectrum: Callable | None = None,
997 floor: float = 1e-20,
998 ):
999 super().__init__(
1000 bands=bands,
1001 bbox=bbox,
1002 spectrum=spectrum,
1003 morph_params=morph_params,
1004 morph_func=morph_func,
1005 morph_grad=morph_grad,
1006 morph_prox=morph_prox,
1007 morph_step=morph_step,
1008 prox_spectrum=prox_spectrum,
1009 floor=floor,
1010 )
1012 @property
1013 def semi_major(self) -> float:
1014 """The length of the semi-major axis of the model"""
1015 return self._params.x[2]
1017 @property
1018 def semi_minor(self) -> float:
1019 """The length of the semi-minor axis of the model"""
1020 return self._params.x[3]
1022 @property
1023 def theta(self) -> float:
1024 """The counter-clockwise rotation angle of the model from the
1025 x-axis.
1026 """
1027 return self._params.x[4]
1029 @property
1030 def ellipse_params(self) -> np.ndarray:
1031 """The parameters used to generate the scaled radius"""
1032 return self._params.x[:5]
1034 @property
1035 def radial_params(self) -> np.ndarray:
1036 """The parameters used to model the radial function"""
1037 return self._params.x[5:]
1039 @property
1040 def frame(self) -> EllipseFrame:
1041 """The `EllipseFrame` that parameterizes the model"""
1042 return EllipseFrame(*self.ellipse_params, self._bbox) # type: ignore
1044 @property
1045 def morph_prox(self) -> Callable:
1046 """The function used to constrain the morphological model"""
1047 return self._morph_prox
1049 @property
1050 def morph_grad(self) -> Callable:
1051 """The function that calculates the gradient of the
1052 morphological model
1053 """
1054 return self._morph_grad
1056 def update(self, it: int, input_grad: np.ndarray):
1057 """Update the component
1059 Parameters
1060 ----------
1061 it:
1062 The current iteration of the optimizer.
1063 input_grad:
1064 Gradient of the likelihood wrt the component model
1065 """
1066 ellipse = self.frame
1067 spectrum = self.spectrum.copy()
1068 morph = self._func(self.radial_params, ellipse)
1069 self._spectrum.update(it, input_grad, morph)
1070 self._params.update(it, input_grad, morph, spectrum, ellipse)