22from __future__
import annotations
24__all__ = [
"ComputeRoughPsfShapeletsTask",
"ComputeRoughPsfShapeletsConfig"]
27from typing
import TYPE_CHECKING, Any, Literal
30from logging
import Logger
33from sklearn.covariance
import MinCovDet
34from sklearn.neighbors
import KernelDensity
37from lsst.afw.image import ImageD, ImageF, MaskedImageF, ExposureSummaryStats
38from lsst.afw.table import Point2DKey, QuadrupoleKey, Schema, SourceCatalog
40from lsst.pex.config import Config, Field, ListField, FieldValidationError
42from lsst.shapelet import LAGUERRE, ShapeletFunction, computeOffset
44from ._algorithmsLib
import SpanSetMoments
47 import matplotlib.axes
48 import matplotlib.figure
49 import matplotlib.image
50 import matplotlib.patches
54 """Exception raised when any of the methods involving selection fail to
55 find any usable stars (e.g. compute_raw_moments, _threshold_with_bounds,
56 _find_first_radius_mode).
65 bad_mask_planes = ListField(
66 "Mask planes to identify pixels to drop from the calculation.",
68 default=[
"SAT",
"SUSPECT",
"INTRP"],
70 bad_pixel_max_fraction = Field(
71 "Maximum fraction of a footprint's pixels that can be bad (according "
72 "to bad_mask_planes) before that footprint is fully dropped.",
76 bad_pixel_exclusion_radius = Field(
77 "If a bad pixel (according to bad_mask_planes) falls within this "
78 "radius of the unweighted centroid of a footprint, drop that footprint "
83 max_footprint_area = Field(
84 "Footprints with a pixel count larger than this threshold are dropped before computing moments.",
89 "Mininum flux S/N for inclusion in the star sample.",
93 max_radius_factor = Field(
94 "Maximium multiple of the mode of the radius distribution for inclusion in the star sample.",
98 max_shape_distance = Field(
99 "Maximum Mahalanobis distance (distance from the center of the shape "
100 "distribution in elliptical sigma units) to select a star from the candidate "
101 "sample, comparing the shape of that source vs. a robust estimate of the "
102 "distribution of the shapes of all sources.",
107 "Minimum number of stars to select. The S/N, radius, and shape distance thresholds are "
108 "relaxed as needed to meet this target.",
113 "Maximum number of stars to select. High shape-distance sources "
114 "are dropped as needed to meet this target.",
118 logarithmic_shapes = Field(
119 "If True, transform the (xx, yy, xy) moments to conformal shear "
120 "and log trace radius when selecting stars in order to map the ellipse "
121 "parameter to a space with (-inf, inf) bounds on all quantities (but "
122 "a less-linear relationship to the pixel data).",
126 radius_mode_min = Field(
127 "Minimum radius in pixels at which to start searching for the first mode. "
128 "This just needs be large enough to avoid unphysically small garbage (e.g. CRs).",
132 radius_kde_bandwidth = Field(
133 "Bandwidth of the Gaussian kernel density estimator used to find the "
134 "first mode of the radius distribution.",
138 shapelet_order = Field(
139 "Order of the shapelet expansion fit to the stars.",
143 shapelet_scale_factor = Field(
144 "Scale factor to apply to the moments ellipse when computing the ellipse for the shapelet basis.",
148 shapelet_circular_basis = Field(
149 "Whether to use a circular shapelet basis with the same moments trace instead of an elliptical one.",
153 non_gaussian_non_atmosphere_index_list = ListField(
154 doc=
"Index list of non-Gaussian, non-atmospheric contributors in the list of shapelet "
155 "coefficients associated with shapelet_order (default accommodates an order of 6)",
157 default=list(range(6, 10)) + list(range(15, 24)),
163 f
"min_n_stars={self.min_n_stars} is greater than max_n_stars={self.max_n_stars}."
166 raise ValueError(f
"shapelet order {self.shapelet_order} must be nonnegative.")
168 raise ValueError(f
"shapelet scale factor {self.shapelet_scale_factor} must be positive.")
174 n_coeff += (i_offset + 1)
176 raise FieldValidationError(
177 ComputeRoughPsfShapeletsConfig,
179 "The largest coefficient index defined in config.non_gaussian_non_atmosphere_index_list "
180 f
"({len(self.non_gaussian_non_atmosphere_index_list)}) is larger than the number "
181 f
"of coefficients expected ({n_coeff}) from the shapelet decomposition order "
182 f
"({self.shapelet_order})."
187 """A task that computes a rough shapelet expansion of the PSF from a set
188 of high S/N detections.
192 This task is expected to be run early in single-epoch processing - just
193 after background subtraction and an initial high S/N detection phase, and
194 before any deblending or measurement - in order to identify out-of-focus
195 or otherwise bad PSFs.
197 Given a background-subtracted `lsst.afw.image.MaskedImage`, an
198 `lsst.afw.table.SourceCatalog` with footprints attached, and a random
199 number generator seed, the `run` method will:
201 - Compute the *unweighted* 0th-2nd moments of every non-child source over
202 the footprint (except certain configurable masked pixels). This is
203 delegated to the `compute_raw_moments` method (which uses the C++
204 `SpanSetMoments` class for the pixel-level processing). Unweighted
205 moments are used to avoid "latching onto" a small piece of PSF
206 substructure, but can be much noiser than the Gaussian-weighed moments
209 - Select a "candidate" sample of sources with successfully measured
210 moments that satisfy a S/N cut and a radius cut (determined from the
211 first mode of the radius distribution, via kernel density estimation),
212 and then use a robust covariance estimator (`scikit_learn.MinCovDet`)
213 to select presumed isolated stars that are close to the center of that
214 distribution, in 3-parameter shape space. This is delegated to the
215 `select_stars` method.
217 - Fit a single shapelet expansion to the selected stars. This is
218 mostly delegated to the `SpanSetMoments.fit_shapelets` method.
220 The radial shapelet terms at 0th, 2nd, and 4th order are expected to form
221 a space in which donut-shaped PSFs are well-separated from those with
222 monotonic profiles. Other terms *may* be useful in identifying other
223 kinds of undesirable PSF structure.
226 ConfigClass = ComputeRoughPsfShapeletsConfig
227 _DefaultName =
"computeRoughPsfShapelets"
228 config: ComputeRoughPsfShapeletsConfig
232 config: ComputeRoughPsfShapeletsConfig |
None =
None,
237 super().
__init__(config=config, **kwargs)
243 doc=
"Unweighted zeroth moment.",
248 doc=
"Uncertainty on the unweighted zeroth moment.",
259 doc=
"Flag set if the raw PSF moments were not computed.",
264 doc=
"Flag set if this source passed the radius_fraction cut (see configuration).",
270 "Flag set if this source passed the radius_fraction and shape_distance cuts "
271 "(see configuration) and was used to fit the shapelet expansion."
275 def run(self, *, masked_image: MaskedImageF, catalog: SourceCatalog, seed: int) -> Struct:
276 """Compute raw moments, select stars, and fit a shapelet expansion to
282 Masked image to measure on. Must be background-subtracted.
284 Catalog of detections to extract footprints from and fill output
285 columns of. Its schema must be a superset of ``self.schema``.
287 A random-number generator seed, used for the robust covariance
292 `lsst.pipe.base.Struct`
293 A struct of results containing:
295 - ``shapelet`` (`lsst.shapelet.ShapeletFunction`): A
296 Gauss-Laguerre (polar shaplet) expansion of the PSF.
297 - ``radial`` (`list` [`float`]): the purely radial coefficients
298 of the shapelet expansion.
299 - all attributes returned by the `select_stars` method.
303 star_moments = [moments[star_id]
for star_id
in result.star_ids]
304 result.shapelet = SpanSetMoments.fit_shapelets(
307 self.config.shapelet_order,
308 self.config.shapelet_scale_factor,
309 self.config.shapelet_circular_basis,
311 result.shapelet.getEllipse().setCore(result.mean_shape)
312 result.shapelet.changeBasisType(LAGUERRE)
313 result.radial = result.shapelet.getCoefficients()[
314 [computeOffset(i)
for i
in range(0, self.config.shapelet_order + 1, 2)]
316 self.log.info(
"Rough PSF shapelet radial terms: %s.", result.radial)
321 self, *, masked_image: MaskedImageF, catalog: SourceCatalog
322 ) -> dict[int, SpanSetMoments]:
323 """Compute the unweighted moments of the footprints in a catalog.
328 Masked image to measure on. Must be background-subtracted.
330 Catalog of detections to extract footprints from and fill output
331 columns of. Its schema must be a superset of ``self.schema``.
335 `dict` [`int`, `SpanSetMoments`]
336 Objects used to construct and hold the unweighted moments and the
337 pixel region used to computed them, keyed by source ID.
339 bitmask = masked_image.mask.getPlaneBitMask(self.config.bad_mask_planes)
340 all_moments: dict[int, SpanSetMoments] = {}
341 for record
in catalog:
342 if record.getParent() != 0:
344 self.log.debug(
"Skipping child source %s", record.getId())
346 footprint_spans = record.getFootprint().getSpans()
347 if footprint_spans.getArea() > self.config.max_footprint_area:
350 "Skipping source %s with footprint area %d > %d.",
352 footprint_spans.getArea(),
353 self.config.max_footprint_area,
356 moments = SpanSetMoments.compute(
357 record.getFootprint().getSpans(),
358 masked_image=masked_image,
360 bad_pixel_max_fraction=self.config.bad_pixel_max_fraction,
361 bad_pixel_exclusion_radius=self.config.bad_pixel_exclusion_radius,
367 record.set(self.
_flag_key, moments.any_flags_set)
368 if not moments.any_flags_set:
369 all_moments[record.getId()] = moments
372 "Successfully measured raw moments for %d of %d sources.",
381 """Select probable stars from the distribution of second moments.
386 Catalog of detections to extract footprints from and fill output
387 columns of. Its schema must be a superset of ``self.schema``.
389 A random-number generator seed, used for the robust covariance
394 `lsst.pipe.base.Struct`
395 A struct of results containing:
397 - ``star_ids`` (`numpy.ndarray`): the source IDs that are expected
399 - ``mean_shape`` (`lsst.afw.geom.ellipses.BaseCore`): the mean of
400 the shape distribution.
401 - ``shape_covariance`` (`numpy.ndarray`): the covariance of the
402 distribution of shapes; a 3x3 matrix. This uses the same
403 parameterization of the shapes as ``mean_shape``.
404 - ``radius_cut`` (`float`): the indended radius cut (i.e. the
405 mode of the radius distribution multipled by the
406 ``radius_factor`` configuration option).
407 - ``radius_kde`` (`sklearn.neighbors.KernelDensity`): kernel
408 density estimator on the radius distribution, used to determine
412 indices = np.arange(len(catalog), dtype=int)[np.logical_not(catalog[self.
_flag_key])]
414 for index
in indices:
420 signalToNoise.append(np.nan)
421 signalToNoise = np.array(signalToNoise)
425 threshold=self.config.min_snr,
426 min_count=self.config.min_n_stars,
427 max_count=len(catalog),
433 radii = np.zeros(indices.size, dtype=np.float64)
434 for n, index
in enumerate(indices):
435 record = catalog[index]
437 radii[n] = shape.getTraceRadius()
439 radius_cut = self.config.max_radius_factor * radius_mode
443 threshold=radius_cut,
444 min_count=self.config.min_n_stars,
445 max_count=len(catalog),
450 shape_data = np.zeros((len(indices), 3), dtype=np.float64)
451 for n, index
in enumerate(indices):
452 record = catalog[index]
455 if self.config.logarithmic_shapes:
457 shape_data[n, :] = shape.getParameterVector()
458 shape_dist = MinCovDet(random_state=seed).fit(shape_data)
459 m_distances = shape_dist.mahalanobis(shape_data)
463 threshold=self.config.max_shape_distance,
464 min_count=self.config.min_n_stars,
465 max_count=self.config.max_n_stars,
466 name=
"Mahalanobis distance",
470 for index
in indices:
472 star_ids = catalog[
"id"][indices]
473 if self.config.logarithmic_shapes:
479 mean_shape=mean_shape,
480 shape_covariance=shape_dist.covariance_,
481 radius_cut=radius_cut,
482 radius_kde=radius_kde,
487 """Compute the image quality "score" that only includes the
488 non-atmospheric coefficents.
490 The "shapelet_only_iq_score" is a dimensionless image quality score
491 as determined from the shapelets decomposition that only includes
492 power from the non-atmospheric coefficents from the shapelet
493 decomposition. The score spans the range [0.0, 1.0] with lower
494 values indicating better image quality.
499 Result struct returned by `run`. To be updated in-place.
503 `lsst.pipe.base.Struct`
504 Additional results struct components include:
505 - ``shapelets_summary`` (`lsst.afw.image.ExposureSummaryStats`):
506 An ExposureSummaryStats object with the following parameters
508 - ``shapeletsCoeffs`` (`list`[`float`]): The coefficients
509 from the shapelet decomposition.
510 - ``nShapeletsStar`` (`int`): The number of sources used in the
511 shapelet decomposition.
512 - ``shapeletsOnlyIqScore`` (`float`): The image quality score
513 which only includes power from the non-atmospheric
514 decomposition coefficients.
515 - ``shapeletsStarEMedian`` (`float`): The median ellipticity
516 (sqrt(starE1**2.0 + starE2**2.0)) of the stars used in the
517 shapelet decomposition.
518 - ``shapeletsStarUnNormalizedEMedian`` (`float`): The median
519 un-normalized ellipticity
520 (sqrt((starXX - starYY)**2.0 + (2.0*starXY)**2.0)) of the
521 stars used in the shapelet decomposition.
523 shapelets_summary = ExposureSummaryStats()
524 results.non_gaussian_non_atmosphere_index_list = \
525 self.config.non_gaussian_non_atmosphere_index_list
526 shapelets_coeffs = results.shapelet.getCoefficients()
527 shapelets_coeffs_list = [float(coeff)
for coeff
in shapelets_coeffs]
528 shapelets_summary.shapeletsCoeffs = shapelets_coeffs_list
529 shapelets_used_cat = results.catalog[
532 shapelets_summary.nShapeletsStar = len(shapelets_used_cat)
540 total_power = np.sum(shapelets_coeffs**2.0)
541 non_atm_power = np.sum(
542 shapelets_coeffs[self.config.non_gaussian_non_atmosphere_index_list]**2.0
544 shapelets_only_iq_score = np.where(total_power > 0, non_atm_power/total_power, 0.0)
545 shapelets_summary.shapeletsOnlyIqScore = float(shapelets_only_iq_score)
547 self.log.info(
"n_shapelets_star = %d; shapelets_only_iq_score = %.5f",
548 len(shapelets_used_cat), shapelets_only_iq_score)
549 shapelets_star_e1 = (shapelets_star_xx - shapelets_star_yy)/(shapelets_star_xx + shapelets_star_yy)
550 shapelets_star_e2 = 2*shapelets_star_xy/(shapelets_star_xx + shapelets_star_yy)
552 shapelets_star_e = np.sqrt(shapelets_star_e1**2.0 + shapelets_star_e2**2.0)
553 shapelets_star_unnormalized_e = np.sqrt(
554 (shapelets_star_xx - shapelets_star_yy)**2.0 + (2.0*shapelets_star_xy)**2.0)
555 shapelets_star_e_median = np.median(shapelets_star_e)
556 shapelets_star_unnormalized_e_median = np.median(shapelets_star_unnormalized_e)
557 shapelets_summary.shapeletsStarEMedian = float(shapelets_star_e_median)
558 shapelets_summary.shapeletsStarUnNormalizedEMedian = float(shapelets_star_unnormalized_e_median)
559 results.shapelets_summary = shapelets_summary
565 centroid_offset_scale: float, shapelets_base_name: str,
566 log: Logger |
None =
None) -> Struct:
567 """Compute the image quality "score" that includes power from the
568 median centroid offset in addition to that of the non-atmospheric
571 The "shapelets_iq_score" is a dimensionless image quality score that
572 includes power from the median centroid offset between those used in
573 shapelet decomposition and those in the centroid slot of the provided
574 source catalog, in addition to the non-atmospheric coefficents from
575 the shapelet decomposition. The score spans the range [0.0, 1.0] with
576 lower values indicating better image quality. When present (non-nan)
577 the shapelets_iq_score should be considered authoritative over the
578 shapelets_only_iq_score.
583 Results struct as returned by `run` method of
584 ComputeRoughPsfShapeletsTask. To be updated in-place.
586 Catalog of detections to extract footprints from and fill output
587 columns of. Its schema must be a superset of ``self.schema``.
588 Must contain the columns added by ComputeRoughPsfShapeletsTask.
589 centroid_offset_scale
590 Value (in pixels) by which to scale the centroid offset to set
591 and appropriate amount by which the centroid offsets can
592 contribute to the computed power.
594 Base name for the catalog columns added by
595 ComputeRoughPsfShapeletsTask.
601 `lsst.pipe.base.Struct`
602 Additional results struct components include:
603 - ``shapelets_summary`` (`lsst.afw.image.ExposureSummaryStats`):
604 An ExposureSummaryStats object with the following parameters
606 - ``shapeletsIqScore`` (`float`): the image quality score which
607 includes power from the centroid shift in addition to the
608 non-atmospheric decomposition coefficients.
609 - ``centroidDiffShapeletsVsSlotMedian`` (`float`): The median
611 (sqrt((slot_x - shapelet_x)**2 + (slot_y - shapelet_y)**2))
612 for sources used in the shapelet decomposition (pixels).
614 if shapelets_base_name +
"_used" not in catalog.schema.getNames():
615 raise RuntimeError(
"Catalog must contain columns added by ComputeRoughPsfShapeletsTask "
616 f
"(e.g.{shapelets_base_name}_used).")
617 shapelets_summary = shapelets_results.shapelets_summary
618 shapelets_coeffs = np.array(shapelets_summary.shapeletsCoeffs)
619 shapelets_used_cat = catalog[(catalog[shapelets_base_name +
"_used"])
620 & (~catalog[shapelets_base_name +
"_flag"])].copy(deep=
True)
621 shapelets_used_finite_centroid_cat = shapelets_used_cat[(
622 (np.isfinite(shapelets_used_cat[
"slot_Centroid_x"]))
623 & (np.isfinite(shapelets_used_cat[
"slot_Centroid_y"])))]
624 min_finite_centroid_sources = 3
625 if len(shapelets_used_finite_centroid_cat) < min_finite_centroid_sources:
627 log.warning(
"Not enough sources used in the shapelet decomposition have finite slot "
628 "centroid measurements (%d < %d). Returning without setting shapelets_iq_score.",
629 len(shapelets_used_finite_centroid_cat), min_finite_centroid_sources)
630 shapelets_results.shapelets_summary = shapelets_summary
631 return shapelets_results
633 centroid_diff_shapelets_vs_slot = np.sqrt(
634 (shapelets_used_finite_centroid_cat[
"slot_Centroid_x"]
635 - shapelets_used_finite_centroid_cat[shapelets_base_name +
"_x"])**2.0
636 + (shapelets_used_finite_centroid_cat[
"slot_Centroid_y"]
637 - shapelets_used_finite_centroid_cat[shapelets_base_name +
"_y"])**2.0
639 centroid_diff_shapelets_vs_slot_median = np.nanmedian(centroid_diff_shapelets_vs_slot)
640 shapelets_summary.centroidDiffShapeletsVsSlotMedian = float(centroid_diff_shapelets_vs_slot_median)
647 centroid_diff_scaled = centroid_diff_shapelets_vs_slot/centroid_offset_scale
648 centroid_diff_scaled_median = np.median(centroid_diff_scaled)
652 total_power_plus_scaled = (np.sum(shapelets_coeffs**2.0)
653 + centroid_diff_scaled_median**2.0)
654 non_atm_power_plus_scaled = (
655 np.sum(shapelets_coeffs[shapelets_results.non_gaussian_non_atmosphere_index_list]**2.0)
656 + centroid_diff_scaled_median**2.0)
658 shapelets_iq_score = np.where(
659 total_power_plus_scaled > 0, non_atm_power_plus_scaled/total_power_plus_scaled, 0.0
661 shapelets_summary.shapeletsIqScore = float(shapelets_iq_score)
664 log.info(
"shapelets_iq_score = %.5f; centroid_diff_shapelets_vs_slot_median = %.3f (pixels), "
665 "centroid_diff_scaled_median (by %.1f pixels) = %.3f",
666 shapelets_iq_score, centroid_diff_shapelets_vs_slot_median,
667 centroid_offset_scale, centroid_diff_scaled_median)
669 shapelets_results.shapelets_summary = shapelets_summary
670 return shapelets_results
673 self, figure: matplotlib.figure.Figure, *, catalog: SourceCatalog, results: Struct
675 """Create plots of the shape distribution space used to select stars.
680 Matplotlib figure to plot to.
682 Catalog of sources with columns populated by the `run` method (at
683 least through the `select_stars` step).
685 Result struct returned by `run` or `select_stars`.
687 from matplotlib.lines
import Line2D
689 shape_data = np.zeros((len(catalog), 3), dtype=np.float64)
690 radii = np.zeros(len(catalog), dtype=np.float64)
691 for n, record
in enumerate(catalog):
695 if self.config.logarithmic_shapes:
697 shape_data[n, :] = shape.getParameterVector()
698 radii[n] = shape.getTraceRadius()
700 candidate_mask = np.logical_and(catalog[self.
_candidate_key], np.logical_not(used_mask))
701 measured_mask = np.logical_and(
705 axes = figure.subplot_mosaic(
707 [
"radius",
"radius",
"radius"],
709 [
"scatter01",
"hist1",
"."],
710 [
"scatter02",
"scatter12",
"hist2"],
712 gridspec_kw=dict(bottom=0.2, top=1.0),
714 axes[
"scatter01"].sharex(axes[
"hist0"])
715 axes[
"scatter02"].sharex(axes[
"hist0"])
716 axes[
"scatter12"].sharex(axes[
"hist1"])
717 axes[
"scatter12"].sharey(axes[
"scatter02"])
718 for tk
in itertools.chain(
719 axes[
"hist0"].get_xticklabels(),
720 axes[
"scatter01"].get_xticklabels(),
721 axes[
"hist1"].get_xticklabels(),
722 axes[
"scatter12"].get_yticklabels(),
724 tk.set_visible(
False)
726 for ax
in [axes[
"hist0"], axes[
"hist1"], axes[
"hist2"]]:
727 ax.yaxis.set_label_position(
"right")
728 ax.yaxis.tick_right()
730 names = [
"Ixx",
"Iyy",
"Ixy"]
if not self.config.logarithmic_shapes
else [
"𝜂1",
"𝜂2",
"ln(r)"]
731 axes[
"scatter02"].set_xlabel(names[0])
732 axes[
"scatter12"].set_xlabel(names[1])
733 axes[
"hist2"].set_xlabel(names[2])
734 axes[
"scatter01"].set_ylabel(names[1])
735 axes[
"scatter02"].set_ylabel(names[2])
737 mu = results.mean_shape.getParameterVector()
738 sigma = np.diagonal(results.shape_covariance) ** 0.5
739 lower_bounds = [max(mu[i] - 3 * sigma[i], min(shape_data[:, i]))
for i
in range(3)]
740 upper_bounds = [min(mu[i] + 3 * sigma[i], max(shape_data[:, i]))
for i
in range(3)]
741 grids = [np.linspace(lower_bounds[i], upper_bounds[i], 50)
for i
in range(3)]
742 axes[
"radius"].axvline(results.radius_cut, color=
"k")
743 for color, mask, alpha
in [
744 (
"grey", measured_mask, 0.5),
745 (
"blue", candidate_mask, 0.75),
746 (
"green", used_mask, 1.0),
754 range=(radii.min(), 15.0),
758 axes[f
"hist{i}"].hist(
761 range=(lower_bounds[i], upper_bounds[i]),
768 if (ax := axes.get(f
"scatter{i}{j}"))
is not None:
778 axes[f
"hist{i}"].
plot(
779 grids[i], scipy.stats.norm.pdf(grids[i], loc=mu[i], scale=sigma[i]),
"k", alpha=0.5
781 axes[f
"hist{i}"].set_xlim(lower_bounds[i], upper_bounds[i])
783 if (ax := axes.get(f
"scatter{i}{j}"))
is not None:
785 results.shape_covariance[i, i],
786 results.shape_covariance[j, j],
787 results.shape_covariance[i, j],
789 for factor
in [1, 2, 3]:
800 ax.set_xlim(lower_bounds[i], upper_bounds[i])
801 ax.set_ylim(lower_bounds[j], upper_bounds[j])
804 Line2D([], [], color=
"green", alpha=1.0),
805 Line2D([], [], color=
"blue", alpha=0.75),
806 Line2D([], [], color=
"gray", alpha=0.5),
809 f
"{self.shapelets_base_name}_used ({used_mask.sum()})",
810 f
"{self.shapelets_base_name}_candidate & ~{self.shapelets_base_name}_used "
811 "({candidate_mask.sum()})",
812 f
"~{self.shapelets_base_name}_flag & ~{self.shapelets_base_name}_candidate "
813 f
"({measured_mask.sum()})",
821 figure: matplotlib.figure.Figure,
824 catalog: SourceCatalog,
827 stamp_size: float = 2.0,
829 """Create data/model/residual plots of stars and the shapelet model.
834 Matplotlib figure to plot to.
836 The image the stars were measured on.
838 Catalog of sources with columns populated by the `run` method .
840 Result struct returned by `run`.
842 Number of stars to include.
844 Stamp size in inches.
846 from matplotlib.colors
import Normalize
848 width = stamp_size * 3 + 1.5
849 figure.set_size_inches(w=width, h=stamp_size * n_stars)
850 axes = figure.subplot_mosaic(
852 [
"image_cbar", f
"d{star_id}", f
"m{star_id}", f
"r{star_id}",
"res_cbar"]
853 for star_id
in results.star_ids[:n_stars]
856 wspace=0.01, hspace=0.01, left=0.5 / width, right=1.0 - 0.5 / width, bottom=0.01, top=0.99
858 width_ratios=[0.25, stamp_size, stamp_size, stamp_size, 0.25],
860 for name, ax
in axes.items():
861 if not name.endswith(
"cbar"):
863 norm: Normalize |
None =
None
864 res_norm: Normalize |
None =
None
865 for star_id
in results.star_ids[:n_stars]:
866 record = catalog.find(star_id)
867 star_bbox = record.getFootprint().getBBox()
868 star_model = ImageD(star_bbox)
872 star_shapelet.setEllipse(star_ellipse)
873 star_shapelet.evaluate().addToImage(star_model)
875 norm = Normalize(vmin=star_model.array.min(), vmax=star_model.array.max())
876 star_image = image[star_bbox].clone()
878 self.
_draw_image(axes[f
"d{star_id}"], star_image, norm=norm, cmap=
"YlGnBu")
879 self.
_draw_ellipse(axes[f
"d{star_id}"], star_ellipse, fill=
False, edgecolor=
"blue", alpha=0.5)
880 image_plot = self.
_draw_image(axes[f
"m{star_id}"], star_model, norm=norm, cmap=
"YlGnBu")
881 self.
_draw_ellipse(axes[f
"m{star_id}"], star_ellipse, fill=
False, edgecolor=
"blue", alpha=0.5)
882 star_image -= star_model.convertF()
883 amax = np.abs(star_image.array).max()
885 res_norm = Normalize(vmin=-amax, vmax=amax)
886 res_plot = self.
_draw_image(axes[f
"r{star_id}"], star_image, norm=res_norm, cmap=
"RdBu")
887 self.
_draw_ellipse(axes[f
"r{star_id}"], star_ellipse, fill=
False, edgecolor=
"blue", alpha=0.5)
888 figure.colorbar(image_plot, cax=axes[
"image_cbar"], location=
"left")
889 figure.colorbar(res_plot, cax=axes[
"res_cbar"], location=
"right")
899 kind: Literal[
"<",
">"],
901 """Return the indices of an array that satisfy an inequality
902 and/or lower and upper bounds on the number of indices returned.
907 Array of values to threshold on.
909 Threshold value that selected elements must be above or below.
911 The minimum number of indices returned. When thresholding would
912 yield fewer than this number, the threshold is ignored. Note that
913 the number of indices may still be less than this if the size of
914 ``values`` is less than this.
916 The maximum number of indices returned.
918 Name of the quantity being thresholded, for log messages.
920 Whether the threshold is a upper bound (``<``) or lower bound
921 (``>``). This also sets how values are ranked when they are added
922 or dropped to satisfy the count constraints.
927 Indices into ``values``.
929 if min_count > len(values):
931 f
"Not enough sources ({len(values)}) for {name} cut that must yield at least {min_count}."
933 sorter = values.argsort()
934 n = np.searchsorted(values[sorter], threshold)
936 sorter = sorter[::-1]
940 "Applying a %s %s %f cut yields only %d sources; keeping the top %d (%s %s %f) instead.",
948 values[sorter[min_count - 1]],
953 "%d sources have %s %s %f; keeping only the top %d (%s %s %f) instead.",
961 values[sorter[max_count - 1]],
965 self.log.verbose(
"Keeping %d sources with %s %s %f.", n, name, kind, threshold)
969 """Find the first peak in a 1-d distribution of radii."""
970 kde = KernelDensity(bandwidth=self.config.radius_kde_bandwidth).fit(radii.reshape(-1, 1))
971 sorted_radii = radii.copy()
973 sorted_radii = sorted_radii[sorted_radii.searchsorted(self.config.radius_mode_min):]
974 scores = kde.score_samples(sorted_radii.reshape(-1, 1))
975 peaks, _ = scipy.signal.find_peaks(scores)
978 return sorted_radii[peaks.min()], kde
982 axes: matplotlib.axes.Axes,
985 x: float |
None =
None,
986 y: float |
None =
None,
989 ) -> matplotlib.patches.Ellipse:
990 from matplotlib.patches
import Ellipse
as EllipsePatch
994 x = ellipse.getCenter().getX()
996 y = ellipse.getCenter().getY()
997 ellipse = ellipse.getCore()
1004 patch = EllipsePatch(
1006 ellipse.getA() * 2 * scale,
1007 ellipse.getB() * 2 * scale,
1008 angle=ellipse.getTheta() * 180.0 / np.pi,
1011 axes.add_patch(patch)
1015 def _draw_image(axes: matplotlib.axes.Axes, image: ImageF, **kwargs: Any) -> matplotlib.image.AxesImage:
1016 fp_bbox =
Box2D(image.getBBox())
1019 interpolation=
"nearest",
1022 extent=(fp_bbox.x.min, fp_bbox.x.max, fp_bbox.y.min, fp_bbox.y.max),
non_gaussian_non_atmosphere_index_list
None plot_shapelets(self, matplotlib.figure.Figure figure, *, ImageF image, SourceCatalog catalog, Struct results, int n_stars=3, float stamp_size=2.0)
tuple[float, KernelDensity] _find_first_radius_mode(self, np.ndarray radii)
Struct compute_shapelets_only_iq_score(self, Struct results)
np.ndarray _threshold_with_bounds(self, np.ndarray values, float threshold, int min_count, int max_count, str name, Literal["<", ">"] kind)
Struct run(self, *, MaskedImageF masked_image, SourceCatalog catalog, int seed)
None plot_selection(self, matplotlib.figure.Figure figure, *, SourceCatalog catalog, Struct results)
dict[int, SpanSetMoments] compute_raw_moments(self, *, MaskedImageF masked_image, SourceCatalog catalog)
matplotlib.patches.Ellipse _draw_ellipse(matplotlib.axes.Axes axes, ellipses.BaseCore|ellipses.Ellipse ellipse, *, float|None x=None, float|None y=None, float scale=1.0, **Any kwargs)
__init__(self, ComputeRoughPsfShapeletsConfig|None config=None, *, Schema schema, **Any kwargs)
Struct compute_shapelets_iq_score(Struct shapelets_results, SourceCatalog catalog, float centroid_offset_scale, str shapelets_base_name, Logger|None log=None)
matplotlib.image.AxesImage _draw_image(matplotlib.axes.Axes axes, ImageF image, **Any kwargs)
Struct select_stars(self, SourceCatalog catalog, int seed)
dict[str, Any] metadata(self)
plot(mag, width, centers, clusterId, marker="o", markersize=2, markeredgewidth=0, ltype='-', magType="model", clear=True)