stdpipe 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ """PSF-based per-source quality metrics borrowed from the ``crowdsource``
2
+ photometry pipeline (Schlafly et al., MIT-licensed).
3
+
4
+ The metrics operate on stacks of postage stamps:
5
+
6
+ ``impsf`` — neighbour-subtracted data stamps (data minus the model of every
7
+ *other* source). For an isolated source this is just the data stamp.
8
+
9
+ ``im`` — raw data stamps (no neighbour subtraction).
10
+
11
+ ``psf`` — model PSF stamps for each source, normalized to integrate to one.
12
+
13
+ ``weight`` — inverse-sigma stamps; pixels with ``weight == 0`` are treated as
14
+ masked.
15
+
16
+ All inputs are ``(N, S, S)`` arrays. Outputs are length-``N`` 1D arrays.
17
+
18
+ The functions are deliberately decoupled from any particular photometry
19
+ backend so they can be reused from :func:`stdpipe.photometry_psf.measure_objects_psf`,
20
+ :func:`stdpipe.photometry_measure.measure_objects_sep`, or external code.
21
+ """
22
+
23
+ import numpy as np
24
+
25
+
26
+ # Mixture-of-Gaussians representation of the exponential galaxy profile from
27
+ # Hogg & Lang's ``the-tractor`` (also used by ``crowdsource.galconv``). Six
28
+ # isotropic Gaussians whose amplitudes / variances reproduce a 1-pixel-scale
29
+ # exponential.
30
+ _EXP_GAL_AMP = np.array(
31
+ [1.99485977e-04, 2.61612679e-03, 1.89726655e-02,
32
+ 1.00186544e-01, 3.68534484e-01, 5.09490694e-01]
33
+ )
34
+ _EXP_GAL_VAR = np.array(
35
+ [1.20078965e-03, 8.84526493e-03, 3.91463084e-02,
36
+ 1.39976817e-01, 4.60962500e-01, 1.50159566e+00]
37
+ )
38
+
39
+
40
+ def neff_fwhm(stamp):
41
+ """FWHM-like quantity of a 2D PSF stamp, equivalent to
42
+ ``crowdsource.psf.neff_fwhm``.
43
+
44
+ Returns ``1.18 / sqrt(pi * sum((p/norm)^2))``. The ``1.18`` calibrates the
45
+ n_eff-derived width to the actual FWHM of a Gaussian PSF.
46
+ """
47
+ stamp = np.asarray(stamp)
48
+ norm = np.sum(stamp, axis=(-1, -2), keepdims=True)
49
+ norm = norm + (norm == 0)
50
+ return 1.18 * (np.pi * np.sum((stamp / norm) ** 2, axis=(-1, -2))) ** -0.5
51
+
52
+
53
+ def convolve_with_exp_galaxy(psf_stack, re):
54
+ """Convolve each ``(S, S)`` PSF in ``psf_stack`` with a circular exponential
55
+ galaxy of effective radius ``re`` (in pixels).
56
+
57
+ Uses an FFT-based 6-Gaussian mixture approximation of the exponential, so
58
+ the result is the same shape as the input. ``re`` may be a scalar or a
59
+ length-N array (one per source).
60
+ """
61
+ psf_stack = np.asarray(psf_stack, dtype='f4')
62
+ one_d = psf_stack.ndim == 2
63
+ if one_d:
64
+ psf_stack = psf_stack[None, ...]
65
+ N, H, W = psf_stack.shape
66
+ re = np.atleast_1d(re).astype('f4')
67
+ if re.size == 1:
68
+ re = np.full(N, re[0], dtype='f4')
69
+
70
+ u = np.fft.rfftfreq(W).astype('f4')
71
+ v = np.fft.fftfreq(H).astype('f4')
72
+ rr2 = (u[None, :] ** 2 + v[:, None] ** 2) # (H, W//2+1)
73
+
74
+ # F_gal[n, v, u] = sum_k amp_k exp(-2 pi^2 re_n^2 var_k (u^2+v^2))
75
+ coeff = -2.0 * np.pi ** 2 * (re ** 2)[:, None] # (N, K)
76
+ coeff = coeff * _EXP_GAL_VAR[None, :].astype('f4') # (N, K)
77
+ # Build Fgal per source by summing the K Gaussians.
78
+ Fgal = np.zeros((N, rr2.shape[0], rr2.shape[1]), dtype='c8')
79
+ for k in range(_EXP_GAL_AMP.size):
80
+ Fgal += _EXP_GAL_AMP[k] * np.exp(coeff[:, k][:, None, None] * rr2[None, :, :])
81
+
82
+ P = np.fft.rfft2(psf_stack)
83
+ G = np.fft.irfft2(Fgal * P, s=(H, W)).astype('f4')
84
+
85
+ if one_d:
86
+ G = G[0]
87
+ return G
88
+
89
+
90
+ def _normalize_psf(psf_stack):
91
+ """Return PSF stacks scaled so each stamp integrates to 1 (in-place safe)."""
92
+ psf = np.asarray(psf_stack, dtype='f4')
93
+ norm = psf.sum(axis=(-1, -2), keepdims=True)
94
+ norm = norm + (norm == 0) * 1e-30
95
+ return psf / norm
96
+
97
+
98
+ def compute_qf(psf_stack, weight_stack):
99
+ """Per-source PSF quality factor.
100
+
101
+ ``qf = sum(P_normed * (W > 0))`` — the fraction of the PSF footprint that
102
+ overlaps unmasked pixels. ``1.0`` for a fully unmasked source, ``< 1`` if
103
+ pixels are masked or off-image.
104
+ """
105
+ P = _normalize_psf(psf_stack)
106
+ return np.sum(P * (np.asarray(weight_stack) > 0), axis=(-1, -2)).astype('f4')
107
+
108
+
109
+ def compute_fracflux(impsf_stack, im_stack, psf_stack, weight_stack):
110
+ """Per-source fraction-of-flux metric.
111
+
112
+ ``fracflux = sum(impsf * (W > 0) * P_normed) / sum(im * (W > 0) * P_normed)``
113
+
114
+ Approaches 1 for an isolated source whose neighbours have been cleanly
115
+ subtracted, and drops below 1 when nearby sources contribute flux at the
116
+ target's PSF footprint. Useful as a crowding / blendiness indicator.
117
+ """
118
+ P = _normalize_psf(psf_stack)
119
+ w = (np.asarray(weight_stack) > 0).astype('f4')
120
+ num = np.sum(impsf_stack * w * P, axis=(-1, -2))
121
+ den = np.sum(im_stack * w * P, axis=(-1, -2))
122
+ den_safe = den + (den == 0) * 1e-30
123
+ return (num / den_safe).astype('f4')
124
+
125
+
126
+ def compute_spread_model(impsf_stack, psf_stack, weight_stack, fwhm=None):
127
+ """Per-source ``spread_model`` star/galaxy classifier (and its uncertainty).
128
+
129
+ Equivalent to SExtractor's spread_model and to ``crowdsource_base.spread_model``.
130
+ Convolves each PSF with a tiny exponential galaxy (re = fwhm / 16 * 1.6783)
131
+ and compares the data's similarity to PSF vs PSF-convolved-galaxy. Returns
132
+ ``(spread, dspread)``: a star yields ``spread ~ 0``, an extended source
133
+ ``spread > 0``.
134
+
135
+ If ``fwhm`` is None it is estimated per-source from the PSF stamp via
136
+ :func:`neff_fwhm`.
137
+ """
138
+ psf = np.asarray(psf_stack, dtype='f4')
139
+ impsf = np.asarray(impsf_stack, dtype='f4')
140
+ w2 = np.asarray(weight_stack, dtype='f4') ** 2
141
+
142
+ if fwhm is None:
143
+ fwhm = neff_fwhm(psf)
144
+ fwhm = np.atleast_1d(np.asarray(fwhm, dtype='f4'))
145
+ sigma = fwhm / 16.0
146
+ re = sigma * 1.67834699 # exponential scale length matching that sigma
147
+
148
+ G = convolve_with_exp_galaxy(psf, re)
149
+
150
+ # Compute dot products in float64 — for bright sources the cross-terms in
151
+ # the dspread numerator (PWp**2 * GWG, etc.) easily overflow float32.
152
+ psf64 = psf.astype('f8')
153
+ impsf64 = impsf.astype('f8')
154
+ G64 = G.astype('f8')
155
+ w2_64 = w2.astype('f8')
156
+
157
+ GWp = np.sum(G64 * w2_64 * impsf64, axis=(-1, -2))
158
+ PWp = np.sum(psf64 * w2_64 * impsf64, axis=(-1, -2))
159
+ GWP = np.sum(G64 * w2_64 * psf64, axis=(-1, -2))
160
+ PWP = np.sum(psf64 * psf64 * w2_64, axis=(-1, -2))
161
+ GWG = np.sum(G64 * G64 * w2_64, axis=(-1, -2))
162
+
163
+ PWp_safe = PWp + (PWp == 0) * 1e-30
164
+ PWP_safe = PWP + (PWP == 0) * 1e-30
165
+
166
+ spread = (GWp / PWp_safe - GWP / PWP_safe).astype('f4')
167
+ dspread = np.sqrt(
168
+ np.clip(PWp ** 2 * GWG + GWp ** 2 * PWP - 2 * GWp * PWp * GWP, 0.0, np.inf)
169
+ / (PWp_safe ** 4)
170
+ ).astype('f4')
171
+
172
+ return spread, dspread
173
+
174
+
175
+ def compute_psf_quality(impsf_stack, im_stack, psf_stack, weight_stack, fwhm=None):
176
+ """Convenience wrapper: returns dict with ``qf``, ``fracflux``, ``spread_model``,
177
+ and ``dspread_model`` for each source in the input stacks.
178
+ """
179
+ spread, dspread = compute_spread_model(impsf_stack, psf_stack, weight_stack, fwhm=fwhm)
180
+ return {
181
+ 'qf': compute_qf(psf_stack, weight_stack),
182
+ 'fracflux': compute_fracflux(impsf_stack, im_stack, psf_stack, weight_stack),
183
+ 'spread_model': spread,
184
+ 'dspread_model': dspread,
185
+ }