23 "ExtendedPsfStackConnections",
24 "ExtendedPsfStackConfig",
25 "ExtendedPsfStackTask",
28from collections.abc
import Sequence
31from astropy.modeling.fitting
import LMLSQFitter
32from astropy.modeling.models
import Moffat2D
35from lsst.afw.math import StatisticsControl, statisticsStack, stringToStatisticsProperty
36from lsst.images
import Image
38from lsst.pipe.base
import PipelineTask, PipelineTaskConfig, PipelineTaskConnections, Struct
39from lsst.pipe.base.connectionTypes
import Input, Output
40from lsst.utils.timer
import timeMethod
42from .extended_psf_candidates
import ExtendedPsfCandidate, ExtendedPsfCandidates
43from .extended_psf_cutout
import NEIGHBOR_MASK_PLANE
44from .extended_psf_fit
import ExtendedPsfMoffatFit
45from .extended_psf_image
import ExtendedPsfImage, ExtendedPsfImageInfo
49 PipelineTaskConnections,
50 dimensions=(
"instrument",
"visit",
"detector"),
52 """Connections for ExtendedPsfStackTask."""
54 extended_psf_candidates = Input(
55 name=
"extended_psf_candidates",
56 storageClass=
"ExtendedPsfCandidates",
57 doc=
"Set of preprocessed cutouts, each centered on a single star.",
58 dimensions=(
"visit",
"detector"),
60 extended_psf = Output(
62 storageClass=
"ExtendedPsfImage",
63 doc=
"Extended PSF model built from stacking candidate cutouts.",
64 dimensions=(
"visit",
"detector"),
70 pipelineConnections=ExtendedPsfStackConnections,
72 """Configuration parameters for ExtendedPsfStackTask."""
75 bad_mask_planes = ListField[str](
76 doc=
"Mask planes that identify excluded (masked) pixels.",
89 psf_masked_flux_frac_threshold = Field[float](
90 doc=
"Maximum allowed fraction of masked PSF flux for fitting to occur.",
93 min_focal_plane_radius = Field[float](
94 doc=
"Minimum distance to the center of the focal plane, in mm. "
95 "Candidates with smaller focal plane radius will be omitted.",
98 max_focal_plane_radius = Field[float](
99 doc=
"Maximum distance to the center of the focal plane, in mm. "
100 "Candidates with larger focal plane radius will be omitted.",
105 stack_type = Field[str](
107 doc=
"Statistic name to use for stacking (from `~lsst.afw.math.Property`)",
109 stack_num_sigma_clip = Field[float](
110 doc=
"Number of sigma to use for clipping when stacking.",
113 stack_num_iter = Field[int](
114 doc=
"Number of iterations to use for clipping when stacking.",
119 use_median_variance = Field[bool](
120 doc=
"Use the median of the variance plane for fitting.",
123 fit_iterations = Field[int](
124 doc=
"Number of iterations over pedestal-gradient and scaling fit.",
127 bg_order = Field[int](
128 doc=
"Order of polynomial to fit for background in candidate cutouts.",
134 """Stack candidate cutouts to produce an extended PSF model."""
136 ConfigClass = ExtendedPsfStackConfig
137 _DefaultName =
"extendedPsfStack"
138 config: ExtendedPsfStackConfig
140 def __init__(self, initInputs=None, *args, **kwargs):
143 order = self.config.bg_order
144 self.
_bg_powers = [(i, j)
for i
in range(order + 1)
for j
in range(order + 1)
if i + j <= order]
147 inputs = butlerQC.get(inputRefs)
148 output = self.
run(**inputs)
151 butlerQC.put(output, outputRefs)
156 extended_psf_candidates: ExtendedPsfCandidates,
158 """Run the stacking task to produce an extended PSF model.
162 extended_psf_candidates : `ExtendedPsfCandidates`
163 Set of preprocessed cutouts, each centered on a single star.
168 `~lsst.pipe.tasks.extended_psf.ExtendedPsfImage` or `None`
169 The extended PSF model if stacking succeeds; `None` if stacking
170 was unsuccessful (e.g, no valid candidates).
173 if len(candidates) == 0:
177 if extended_psf
is None:
182 image=extended_psf.image,
183 variance=extended_psf.variance,
184 info=extended_psf.info,
186 metadata=extended_psf.metadata,
189 return Struct(extended_psf=extended_psf)
193 radius = candidate.star_info.focal_plane_radius
196 if hasattr(radius,
"to_value"):
197 return float(radius.to_value(
"mm"))
200 def _get_candidates(self, extended_psf_candidates: ExtendedPsfCandidates) -> list[ExtendedPsfCandidate]:
201 """Get candidates that are within the configured focal-plane radius.
205 extended_psf_candidates : `ExtendedPsfCandidates`
206 Candidate cutout collection.
210 candidates : `list` [`ExtendedPsfCandidate`]
211 Candidates within the configured focal-plane radius range.
214 for candidate
in extended_psf_candidates:
216 if radius_mm
is None:
218 if self.config.min_focal_plane_radius <= radius_mm <= self.config.max_focal_plane_radius:
219 candidates.append(candidate)
221 if len(candidates) == 0:
223 "No candidate cutouts found within focal-plane radius range [%s, %s] mm.",
224 self.config.min_focal_plane_radius,
225 self.config.max_focal_plane_radius,
227 elif len(candidates) < len(extended_psf_candidates):
229 "Only %d of %d candidate cutouts are within focal-plane radius range [%s, %s] mm.",
231 len(extended_psf_candidates),
232 self.config.min_focal_plane_radius,
233 self.config.max_focal_plane_radius,
237 "Constructing an extended PSF from %d candidate cutout%s.",
239 "s" if len(candidates) > 1
else "",
244 """Stack candidate cutouts to produce an extended PSF model.
248 candidates : `list` [`ExtendedPsfCandidate`]
249 Candidate cutouts to stack.
253 extended_psf : `ExtendedPsfImage` or `None`
254 The stacked extended PSF model, or `None` if stacking fails.
256 stack_type_property = stringToStatisticsProperty(self.config.stack_type)
258 numSigmaClip=self.config.stack_num_sigma_clip,
259 numIter=self.config.stack_num_iter,
261 bad_mask_bit_mask = (
262 candidates[0].to_legacy(copy=
False).mask.getPlaneBitMask(self.config.bad_mask_planes)
264 statistics_control.setAndMask(bad_mask_bit_mask)
266 psf_amplitudes: dict[int, float |
None] = {
267 candidate_id:
None for candidate_id
in range(len(candidates))
272 for iteration
in range(self.config.fit_iterations):
273 normalized_cutouts = []
275 for candidate_id, candidate
in enumerate(candidates):
277 if extended_psf
is None:
278 psf_image = candidate.psf_kernel_image
280 psf_image = extended_psf.image
284 psf_amplitudes[candidate_id],
286 if normalized_image
is not None and psf_amplitude
is not None:
287 normalized_cutouts.append(normalized_image)
288 psf_amplitudes[candidate_id] = psf_amplitude
290 if len(normalized_cutouts) == 0:
292 "Iteration %d: no candidates were successfully fit and normalized. "
293 "No further iterations will be attempted.",
299 "Iteration %d: fit and normalized %d out of %d candidate cutout%s using the %s model.",
301 len(normalized_cutouts),
303 "s" if len(candidates) > 1
else "",
304 "kernel-image" if extended_psf
is None else "extended PSF",
308 normalized_cutouts_legacy = [cutout.to_legacy(copy=
False)
for cutout
in normalized_cutouts]
309 extended_psf_legacy = MaskedImageF(normalized_cutouts_legacy[0].getBBox())
312 normalized_cutouts_legacy,
317 image=Image.from_legacy(extended_psf_legacy.image),
318 variance=Image.from_legacy(extended_psf_legacy.variance),
326 candidate: ExtendedPsfCandidate,
328 psf_amplitude: float |
None,
329 ) -> tuple[ExtendedPsfCandidate |
None, float |
None]:
330 """Fit and normalize a single candidate cutout.
334 candidate : `ExtendedPsfCandidate`
335 Candidate cutout image to fit.
336 psf_image : `~lsst.images.Image`
337 PSF model image for fitting.
338 psf_amplitude : `float` | `None`
339 Prior PSF scaling to use during background fitting. If `None`, no
340 PSF subtraction is performed during background fitting.
344 candidate_normalized : `ExtendedPsfCandidate` | `None`
345 Normalized candidate cutout image, or `None` if fitting fails.
346 psf_amplitude : `float` | `None`
347 Fitted PSF amplitude, or `None` if fitting fails.
349 bit_mask_bad = candidate.mask.schema.bitmask(*self.config.bad_mask_planes)
352 psf_mask = ((candidate.mask[psf_image.bbox].array & bit_mask_bad) != 0).any(axis=-1)
353 psf_masked_flux = np.dot(psf_image.array.flat, psf_mask.astype(np.float64).flat).astype(np.float64)
354 psf_masked_flux_frac = psf_masked_flux / psf_image.array.sum()
355 if psf_masked_flux_frac > self.config.psf_masked_flux_frac_threshold:
361 list(self.config.bad_mask_planes) + [
"DETECTED"],
367 candidate_fit: ExtendedPsfCandidate = candidate.copy()
368 candidate_fit.image.array -= bg_image.array
371 psf_image_padded = Image(0.0, dtype=psf_image.array.dtype, bbox=candidate.bbox, unit=psf_image.unit)
372 psf_image_padded[psf_image.bbox] = psf_image
376 self.config.bad_mask_planes,
378 if psf_amplitude
is None or psf_amplitude <= 0:
380 candidate_fit.image.array /= psf_amplitude
381 candidate_fit.variance.array /= psf_amplitude**2
383 return candidate_fit, psf_amplitude
387 candidate: ExtendedPsfCandidate,
388 mask_planes: Sequence[str],
390 psf_amplitude: float |
None,
392 """Fit a polynomial background to a candidate cutout.
396 candidate : `ExtendedPsfCandidate`
397 Candidate cutout image to fit.
398 mask_planes : `Sequence` [`str`]
399 Mask planes for identifying bad pixels.
400 psf_image : `~lsst.images.Image`
401 PSF image to optionally subtract prior to fitting.
402 psf_amplitude : `float` | `None`
403 Amplitude to scale ``psf_image`` before subtraction. If `None`, no
404 PSF subtraction is performed.
408 bg_image : `~lsst.images.Image` | `None`
409 Fitted background image, or `None` if fitting fails.
411 if psf_amplitude
is not None:
412 psf_image_scaled = psf_image.copy()
413 psf_image_scaled.array *= psf_amplitude
418 subtract_image=psf_image_scaled
if psf_amplitude
is not None else None,
420 bbox = candidate.bbox
421 grid = bbox.meshgrid()
422 grid_x, grid_y = grid.x, grid.y
423 spans_x = grid_x[good_pixels]
424 spans_y = grid_y[good_pixels]
426 design_matrix = np.ones((len(image), len(self.
_bg_powers)), dtype=float)
429 design_matrix[:, k] /= sigma
431 design_matrix[:, k] = (spans_x**i * spans_y**j) / sigma
434 if solutions
is None:
437 bg_array = sum(s * (grid_x**i * grid_y**j)
for s, (i, j)
in zip(solutions, self.
_bg_powers))
439 return Image(bg_array.astype(np.float32), bbox=candidate.bbox, unit=candidate.unit)
443 candidate: ExtendedPsfCandidate,
445 mask_planes: Sequence[str],
446 mask_zeros: bool =
True,
448 """Fit the amplitude of the PSF model to one candidate cutout.
452 candidate : `ExtendedPsfCandidate`
453 Candidate cutout image to fit.
454 psf_image : `~lsst.images.Image`
455 PSF image to fit to the data.
456 mask_planes : `Sequence` [`str`]
457 Mask planes for identifying bad pixels.
458 mask_zeros : `bool`, optional
459 Whether to additionally mask zero-valued PSF pixels.
463 psf_amplitude : `float` | `None`
464 Fitted PSF amplitude, or `None` if fitting fails.
468 NaN and negative values in the PSF image are always masked.
470 psf_mask_array = np.isnan(psf_image.array) | (psf_image.array < 0)
472 psf_mask_array |= psf_image.array == 0
474 good_pixels, image, sigma = self.
_get_span_data(candidate, mask_planes, psf_mask_array)
475 psf = psf_image.array[good_pixels]
477 design_matrix = psf[:,
None]
480 if solutions
is None:
483 return float(solutions[0])
487 candidate: ExtendedPsfCandidate,
488 mask_planes: Sequence[str],
489 additional_mask: np.ndarray |
None =
None,
490 subtract_image: Image |
None =
None,
491 ) -> tuple[np.ndarray, np.ndarray, np.ndarray | float]:
492 """Get fitting data and a boolean mask of good pixels.
496 candidate : `ExtendedPsfCandidate`
497 Candidate cutout from which to extract fitting data.
498 mask_planes : `Sequence` [`str`]
499 Mask planes for identifying bad pixels.
500 additional_mask : `numpy.ndarray` or `None`, optional
501 Additional boolean mask to combine with the image mask.
502 subtract_image : `~lsst.images.Image` or `None`, optional
503 Image to subtract before fitting (e.g., PSF model).
507 good_pixels : `numpy.ndarray`
508 Boolean array selecting good pixels.
509 image : `numpy.ndarray`
510 Pixel values at good pixels after optional subtraction.
511 sigma : `numpy.ndarray` | `float`
512 Pixel-wise uncertainties at good pixels.
514 bit_mask = candidate.mask.schema.bitmask(*mask_planes)
515 bad_pixels = ((candidate.mask.array & bit_mask) != 0).any(axis=-1)
516 if additional_mask
is not None:
517 bad_pixels |= additional_mask
518 if subtract_image
is None:
519 image_data = candidate.image.array
521 image_data_image = candidate.image.copy()
522 image_data_image[subtract_image.bbox].array -= subtract_image.array
523 image_data = image_data_image.array
524 good_pixels = ~bad_pixels
525 image = image_data[good_pixels]
526 variance = candidate.variance.array[good_pixels]
527 if self.config.use_median_variance:
528 variance = float(np.median(variance))
529 if np.isscalar(variance):
530 sigma = float(np.sqrt(variance))
534 sigma = np.sqrt(variance)
535 sigma[sigma == 0] = np.inf
536 return good_pixels, image / sigma, sigma
540 design_matrix: np.ndarray,
542 calculate_covariance: bool =
False,
543 ) -> tuple[np.ndarray |
None, np.ndarray |
None]:
544 """Solve a linear system for the given design matrix and data.
548 design_matrix : `numpy.ndarray`
549 Design matrix for the linear system.
550 data : `numpy.ndarray`
552 calculate_covariance : `bool`, optional
553 Whether to return the covariance matrix.
557 solutions : `numpy.ndarray` or `None`
558 Solution vector, or `None` if solving fails.
559 covariance_matrix : `numpy.ndarray` or `None`
560 Covariance matrix, if requested and available.
562 covariance_matrix =
None
564 solutions, sum_squared_residuals, *_ = np.linalg.lstsq(design_matrix, data, rcond=
None)
565 if calculate_covariance:
566 covariance_matrix = np.linalg.pinv(design_matrix.T @ design_matrix)
567 except np.linalg.LinAlgError:
569 if sum_squared_residuals.size == 0:
571 return solutions, covariance_matrix
573 def _fit_moffat(self, extended_psf: ExtendedPsfImage, fix_center: bool =
False) -> ExtendedPsfMoffatFit:
574 """Fit a Moffat2D model to the stacked extended PSF image.
578 extended_psf : `ExtendedPsfImage`
579 Stacked extended PSF image to fit.
580 fix_center : `bool`, optional
581 Whether to fix the Moffat2D center at the image center.
585 fit : `ExtendedPsfMoffatFit`
586 Moffat fit summary and fit statistics.
588 bbox = extended_psf.bbox
589 grid = bbox.meshgrid()
590 grid_x, grid_y = grid.x, grid.y
592 extended_psf_image = extended_psf.image.array
593 extended_psf_sigma = np.sqrt(extended_psf.variance.array)
595 fitter = LMLSQFitter()
596 moffat_init = Moffat2D(
597 amplitude=extended_psf_image.max(),
602 fixed={
"x_0": fix_center,
"y_0": fix_center},
604 weights = 1.0 / np.clip(extended_psf_sigma, 1e-12,
None)
605 moffat_fit = fitter(moffat_init, grid_x, grid_y, extended_psf_image, weights=weights)
607 residuals = extended_psf_image - moffat_fit(grid_x, grid_y)
608 chi2 = np.sum((residuals / extended_psf_sigma) ** 2)
609 dof = extended_psf_image.size - len(moffat_fit.parameters)
610 reduced_chi2 = np.nan
if dof <= 0
else chi2 / dof
613 "Extended PSF Moffat fit x_0=%.2f, y_0=%.2f, gamma=%.2f, alpha=%.2f, reduced_chi2=%.2f.",
614 moffat_fit.x_0.value,
615 moffat_fit.y_0.value,
616 moffat_fit.gamma.value,
617 moffat_fit.alpha.value,
624 reduced_chi2=float(reduced_chi2),
625 amplitude=float(moffat_fit.amplitude.value),
626 x_0=float(moffat_fit.x_0.value),
627 y_0=float(moffat_fit.y_0.value),
628 gamma=float(moffat_fit.gamma.value),
629 alpha=float(moffat_fit.alpha.value),
ExtendedPsfMoffatFit _fit_moffat(self, ExtendedPsfImage extended_psf, bool fix_center=False)
run(self, ExtendedPsfCandidates extended_psf_candidates)
float|None _fit_psf_amplitude(self, ExtendedPsfCandidate candidate, Image psf_image, Sequence[str] mask_planes, bool mask_zeros=True)
tuple[np.ndarray|None, np.ndarray|None] _solve_design_matrix(self, np.ndarray design_matrix, np.ndarray data, bool calculate_covariance=False)
Image|None _fit_bg(self, ExtendedPsfCandidate candidate, Sequence[str] mask_planes, Image psf_image, float|None psf_amplitude)
__init__(self, initInputs=None, *args, **kwargs)
tuple[ExtendedPsfCandidate|None, float|None] _fit_and_normalize(self, ExtendedPsfCandidate candidate, Image psf_image, float|None psf_amplitude)
float|None _focal_plane_radius_mm(ExtendedPsfCandidate candidate)
ExtendedPsfImage|None _make_extended_psf(self, list[ExtendedPsfCandidate] candidates)
runQuantum(self, butlerQC, inputRefs, outputRefs)
list[ExtendedPsfCandidate] _get_candidates(self, ExtendedPsfCandidates extended_psf_candidates)
tuple[np.ndarray, np.ndarray, np.ndarray|float] _get_span_data(self, ExtendedPsfCandidate candidate, Sequence[str] mask_planes, np.ndarray|None additional_mask=None, Image|None subtract_image=None)
ExtendedPsfImageInfo info