solvephase 0.1.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.
- solvephase/__about__.py +3 -0
- solvephase/__init__.py +110 -0
- solvephase/algorithms/__init__.py +4 -0
- solvephase/algorithms/cdi.py +967 -0
- solvephase/algorithms/fast_furious.py +494 -0
- solvephase/algorithms/gerchberg_saxton.py +255 -0
- solvephase/algorithms/lift.py +553 -0
- solvephase/algorithms/phase_diversity.py +658 -0
- solvephase/algorithms/tie.py +693 -0
- solvephase/algorithms/wirtinger.py +865 -0
- solvephase/api.py +228 -0
- solvephase/backend.py +24 -0
- solvephase/basis.py +516 -0
- solvephase/cli.py +131 -0
- solvephase/focal.py +367 -0
- solvephase/interop.py +173 -0
- solvephase/losses.py +169 -0
- solvephase/metrics.py +5 -0
- solvephase/operators.py +391 -0
- solvephase/optimize.py +404 -0
- solvephase/propagation.py +26 -0
- solvephase/pupil.py +5 -0
- solvephase/py.typed +0 -0
- solvephase/result.py +228 -0
- solvephase/retrieval.py +627 -0
- solvephase/simulate.py +113 -0
- solvephase/unwrap.py +5 -0
- solvephase-0.1.0.dist-info/METADATA +198 -0
- solvephase-0.1.0.dist-info/RECORD +32 -0
- solvephase-0.1.0.dist-info/WHEEL +4 -0
- solvephase-0.1.0.dist-info/entry_points.txt +2 -0
- solvephase-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
"""Phase diversity with an unknown extended object.
|
|
2
|
+
|
|
3
|
+
``K`` images ``d_k = o * s_k + n_k`` of the same unknown scene ``o`` are
|
|
4
|
+
recorded through the unknown wavefront plus a known diversity per channel
|
|
5
|
+
(defocus, astigmatism ...). Gonsalves (Opt. Eng. 21, 829, 1982) showed that
|
|
6
|
+
the object can be eliminated analytically. In Fourier space, with
|
|
7
|
+
``D_k = F d_k`` and the optical transfer functions ``S_k = F s_k``, the
|
|
8
|
+
least-squares object for fixed ``S_k`` is the multi-frame Wiener estimate
|
|
9
|
+
|
|
10
|
+
O = sum_k D_k conj(S_k) / (sum_k |S_k|^2 + gamma),
|
|
11
|
+
|
|
12
|
+
and substituting it back leaves a metric of the wavefront alone (Paxman,
|
|
13
|
+
Schulz & Fienup, JOSA A 9, 1072, 1992):
|
|
14
|
+
|
|
15
|
+
L = sum_f [ sum_k |D_k|^2 - |sum_k D_k conj(S_k)|^2 / (sum_k |S_k|^2 + gamma) ].
|
|
16
|
+
|
|
17
|
+
``gamma`` regularizes the division (the inverse signal-to-noise power ratio
|
|
18
|
+
of a Wiener filter). Following Löfdahl & Scharmer (A&AS 107, 243, 1994) the
|
|
19
|
+
data are apodized against periodic-convolution edge effects and the sum is
|
|
20
|
+
restricted to spatial frequencies inside the diffraction cutoff, where the
|
|
21
|
+
transfer functions carry information. Mugnier, Blanc & Idier (Adv. Imaging
|
|
22
|
+
Electron Phys. 141, 1, 2006) review the method.
|
|
23
|
+
|
|
24
|
+
:class:`PhaseDiversityProblem` evaluates ``L`` and its exact gradient with
|
|
25
|
+
respect to the modal coefficients: ``dL/dS_k`` follows from the envelope
|
|
26
|
+
theorem (``O`` is optimal, so its own variation drops out), the real FFT is
|
|
27
|
+
back-propagated to ``dL/d psf_k``, and
|
|
28
|
+
:meth:`~solvephase.focal.FocalPlaneModel.vjp` takes it to the pupil phase.
|
|
29
|
+
The metric is evaluated in the equivalent form
|
|
30
|
+
``sum_k |D_k - O S_k|^2 + gamma |O|^2``, a sum of non-negative terms that
|
|
31
|
+
keeps its accuracy in single precision. :func:`phase_diversity` minimizes it
|
|
32
|
+
with L-BFGS, optionally coarse-to-fine over the number of modes.
|
|
33
|
+
|
|
34
|
+
Internal parameters are those of :class:`~solvephase.retrieval.FocalPlaneProblem`:
|
|
35
|
+
phase coefficients in radians at the reference wavelength (reported in
|
|
36
|
+
metres), a relative zonal amplitude, and per-channel tip/tilt for registration.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import copy
|
|
42
|
+
import math
|
|
43
|
+
import time
|
|
44
|
+
import warnings
|
|
45
|
+
from collections.abc import Callable, Sequence
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
import numpy as np
|
|
49
|
+
|
|
50
|
+
from ..backend import Backend
|
|
51
|
+
from ..basis import Basis
|
|
52
|
+
from ..focal import FocalPlaneModel
|
|
53
|
+
from ..optimize import OptimizeResult, lbfgs
|
|
54
|
+
from ..result import Result
|
|
55
|
+
|
|
56
|
+
__all__ = ["PhaseDiversityProblem", "phase_diversity"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _taper(n: int, kind: str, alpha: float) -> np.ndarray:
|
|
60
|
+
"""One-dimensional apodization profile, symmetric about the array centre."""
|
|
61
|
+
t = (np.arange(n) + 0.5) / n
|
|
62
|
+
if kind == "hann":
|
|
63
|
+
return np.asarray(np.sin(math.pi * t) ** 2)
|
|
64
|
+
edge = np.minimum(t, 1.0 - t)
|
|
65
|
+
half = alpha / 2.0
|
|
66
|
+
out = np.ones(n)
|
|
67
|
+
taper = edge < half
|
|
68
|
+
out[taper] = 0.5 * (1.0 - np.cos(math.pi * edge[taper] / half))
|
|
69
|
+
return out
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _window(window: Any, shape: tuple[int, int], alpha: float) -> np.ndarray:
|
|
73
|
+
"""Apodization window ``(my, mx)``: ``"hann"``, ``"tukey"``, ``None`` or an array."""
|
|
74
|
+
if window is None:
|
|
75
|
+
return np.ones(shape)
|
|
76
|
+
if isinstance(window, str):
|
|
77
|
+
kind = window.lower()
|
|
78
|
+
if kind not in ("hann", "tukey"):
|
|
79
|
+
raise ValueError(f"window must be 'hann', 'tukey', None or an array; got {window!r}")
|
|
80
|
+
if kind == "tukey" and not 0.0 < alpha <= 1.0:
|
|
81
|
+
raise ValueError(f"window_alpha must be in (0, 1], got {alpha}")
|
|
82
|
+
return _taper(shape[0], kind, alpha)[:, None] * _taper(shape[1], kind, alpha)[None, :]
|
|
83
|
+
arr = np.asarray(window, dtype=np.float64)
|
|
84
|
+
if arr.shape != shape:
|
|
85
|
+
raise ValueError(f"window has shape {arr.shape}; the images are {shape}")
|
|
86
|
+
if not np.all(np.isfinite(arr)) or np.any(arr < 0):
|
|
87
|
+
raise ValueError("window must be finite and non-negative")
|
|
88
|
+
return arr
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _otf_extent(model: FocalPlaneModel) -> float:
|
|
92
|
+
"""Pupil extent in metres that sets the OTF cutoff (its widest baseline, bounded above)."""
|
|
93
|
+
pupil = model.pupil
|
|
94
|
+
y, x = pupil.coordinates()
|
|
95
|
+
r = np.hypot(y[pupil.mask], x[pupil.mask])
|
|
96
|
+
return float(max(pupil.diameter, 2.0 * (r.max() + pupil.pitch / math.sqrt(2.0))))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class PhaseDiversityProblem:
|
|
100
|
+
"""Phase diversity of an unknown extended object, with the object eliminated.
|
|
101
|
+
|
|
102
|
+
Parameters
|
|
103
|
+
----------
|
|
104
|
+
model:
|
|
105
|
+
Forward model with one channel per image. Its ``image_shape`` must be
|
|
106
|
+
the data shape; its normalized PSFs (sum ~1) are the convolution
|
|
107
|
+
kernels. Sample at Nyquist or better at the shortest wavelength.
|
|
108
|
+
images:
|
|
109
|
+
``(K, my, mx)`` images of the same scene, ``K >= 2``, in any linear
|
|
110
|
+
units (a constant background is absorbed by the object). Channels
|
|
111
|
+
must be registered to a pixel or better (see ``fit_tilt``).
|
|
112
|
+
basis:
|
|
113
|
+
Wavefront parameterization. ``None`` means zonal; an int ``n`` means
|
|
114
|
+
``Basis.zernike(pupil, n, start=4)`` (defocus upwards), because a
|
|
115
|
+
common tip/tilt only moves the unknown object and is not measurable.
|
|
116
|
+
regularization:
|
|
117
|
+
``gamma`` of the metric, added to ``sum_k |S_k|^2`` (dimensionless:
|
|
118
|
+
the OTFs are 1 at zero frequency). It only needs to stabilize the
|
|
119
|
+
division where every OTF vanishes; larger values bias the wavefront
|
|
120
|
+
towards sharper PSFs. ``"auto"`` is the measured noise power over the
|
|
121
|
+
peak in-band data power (the inverse of the best per-frequency SNR,
|
|
122
|
+
squared).
|
|
123
|
+
object_regularization:
|
|
124
|
+
``gamma`` of the reported object estimate (a Wiener filter, which
|
|
125
|
+
needs more damping than the metric). ``"auto"`` is the noise power
|
|
126
|
+
over the mean in-band data power.
|
|
127
|
+
window:
|
|
128
|
+
Apodization: each image minus its edge level (the mean weighted by
|
|
129
|
+
``1 - window``) is multiplied by the window and the level added back,
|
|
130
|
+
so the frame is periodic and a uniform background passes untouched.
|
|
131
|
+
``"tukey"`` (cosine-tapered edges, flat centre), ``"hann"``, ``None``
|
|
132
|
+
or a ``(my, mx)`` array. Use ``None`` only for scenes whose images
|
|
133
|
+
already fade to a uniform level at the frame edges.
|
|
134
|
+
window_alpha:
|
|
135
|
+
Tapered fraction of each axis for the Tukey window.
|
|
136
|
+
frequency_mask:
|
|
137
|
+
``"diffraction"`` sums the metric over spatial frequencies inside the
|
|
138
|
+
diffraction cutoff ``D / lambda_min``; a float ``a`` uses ``a`` times
|
|
139
|
+
that radius; ``None`` uses every frequency. Zero frequency (the total
|
|
140
|
+
flux, which carries no wavefront information) is always excluded.
|
|
141
|
+
fit_amplitude:
|
|
142
|
+
Also retrieve the pupil amplitude (zonal, on the pupil support).
|
|
143
|
+
fit_tilt:
|
|
144
|
+
Fit a tip/tilt per channel relative to channel 0 (image
|
|
145
|
+
registration).
|
|
146
|
+
equalize_flux:
|
|
147
|
+
Scale every image to the mean total flux, so unequal exposures do not
|
|
148
|
+
bias the metric.
|
|
149
|
+
|
|
150
|
+
Notes
|
|
151
|
+
-----
|
|
152
|
+
The objective is the reduced metric divided by the noise power per
|
|
153
|
+
frequency, so it is a chi-square-like number of order
|
|
154
|
+
``(K - 1) x (in-band frequencies)`` at the solution when the noise power
|
|
155
|
+
can be measured outside the cutoff (otherwise it is divided by the mean
|
|
156
|
+
in-band data power).
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def __init__(
|
|
160
|
+
self,
|
|
161
|
+
model: FocalPlaneModel,
|
|
162
|
+
images: Any,
|
|
163
|
+
*,
|
|
164
|
+
basis: Basis | int | None = None,
|
|
165
|
+
regularization: float | str = "auto",
|
|
166
|
+
object_regularization: float | str = "auto",
|
|
167
|
+
window: Any = "tukey",
|
|
168
|
+
window_alpha: float = 0.25,
|
|
169
|
+
frequency_mask: str | float | None = "diffraction",
|
|
170
|
+
fit_amplitude: bool = False,
|
|
171
|
+
fit_tilt: bool = False,
|
|
172
|
+
equalize_flux: bool = True,
|
|
173
|
+
) -> None:
|
|
174
|
+
self.model = model
|
|
175
|
+
self.backend: Backend = model.backend
|
|
176
|
+
be, xp = self.backend, self.backend.xp
|
|
177
|
+
pupil = model.pupil
|
|
178
|
+
if isinstance(basis, (int, np.integer)):
|
|
179
|
+
basis = Basis.zernike(pupil, int(basis), start=4)
|
|
180
|
+
self.basis = basis if basis is not None else Basis.zonal(pupil)
|
|
181
|
+
if self.basis.pupil.shape != pupil.shape or self.basis.pupil.n_valid != pupil.n_valid:
|
|
182
|
+
raise ValueError("the basis is defined on a different pupil than the model")
|
|
183
|
+
|
|
184
|
+
data = be.asarray(images, dtype="real")
|
|
185
|
+
if data.ndim == 2:
|
|
186
|
+
data = data[None]
|
|
187
|
+
k = model.n_channels
|
|
188
|
+
if data.shape != (k, *model.image_shape):
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"images have shape {tuple(data.shape)} but the model expects "
|
|
191
|
+
f"{(k, *model.image_shape)} ({k} channel(s))"
|
|
192
|
+
)
|
|
193
|
+
if k < 2:
|
|
194
|
+
raise ValueError(
|
|
195
|
+
"phase diversity with an unknown object needs at least two images with "
|
|
196
|
+
"different diversity; with one image the object absorbs the aberration. "
|
|
197
|
+
"For a known point source use solvephase.FocalPlaneProblem."
|
|
198
|
+
)
|
|
199
|
+
if not bool(xp.all(xp.isfinite(data))):
|
|
200
|
+
raise ValueError("images contain non-finite values; replace or inpaint them first")
|
|
201
|
+
if not model.nyquist_sampled:
|
|
202
|
+
warnings.warn(
|
|
203
|
+
"the images are undersampled at the shortest wavelength (< 2 pixels per "
|
|
204
|
+
"lambda/D); the transfer function aliases and the object model is approximate",
|
|
205
|
+
stacklevel=2,
|
|
206
|
+
)
|
|
207
|
+
self.data = data
|
|
208
|
+
self.fit_amplitude = bool(fit_amplitude)
|
|
209
|
+
self.fit_tilt = bool(fit_tilt)
|
|
210
|
+
self.equalize_flux = bool(equalize_flux)
|
|
211
|
+
my, mx = model.image_shape
|
|
212
|
+
self._shape = (my, mx)
|
|
213
|
+
|
|
214
|
+
# ---- data preprocessing: flux equalization and apodization
|
|
215
|
+
host = np.asarray(be.to_numpy(data), dtype=np.float64)
|
|
216
|
+
totals = host.reshape(k, -1).sum(axis=1)
|
|
217
|
+
if self.equalize_flux:
|
|
218
|
+
if np.any(totals <= 0):
|
|
219
|
+
raise ValueError("equalize_flux needs images with positive total flux")
|
|
220
|
+
self.flux_scale = totals.mean() / totals
|
|
221
|
+
else:
|
|
222
|
+
self.flux_scale = np.ones(k)
|
|
223
|
+
win = _window(window, (my, mx), window_alpha)
|
|
224
|
+
self.window = be.asarray(win, dtype="real")
|
|
225
|
+
# Subtract the level at the field edge (the taper-weighted mean), apodize, add it back:
|
|
226
|
+
# a constant convolves to itself, so only the scene's structure meets the taper.
|
|
227
|
+
edge = 1.0 - win
|
|
228
|
+
level = np.sum(host * edge, axis=(1, 2)) / max(float(edge.sum()), 1e-300)
|
|
229
|
+
level_b = be.asarray((level * self.flux_scale)[:, None, None], dtype="real")
|
|
230
|
+
scaled = data * be.asarray(self.flux_scale[:, None, None], dtype="real")
|
|
231
|
+
self.apodized = level_b + self.window * (scaled - level_b)
|
|
232
|
+
self._dhat = be.rfft2(self.apodized) # (K, my, mx//2 + 1)
|
|
233
|
+
|
|
234
|
+
# ---- frequency grid, diffraction cutoff and Hermitian weights
|
|
235
|
+
fy = np.fft.fftfreq(my)[:, None]
|
|
236
|
+
fx = np.fft.rfftfreq(mx)[None, :]
|
|
237
|
+
rho = np.hypot(fy, fx)
|
|
238
|
+
extent = _otf_extent(model)
|
|
239
|
+
self.cutoff = float(model.pixel_scale * extent / model.wavelengths.min()) # cycles/pixel
|
|
240
|
+
if frequency_mask is None:
|
|
241
|
+
inband = np.ones_like(rho, dtype=bool)
|
|
242
|
+
elif isinstance(frequency_mask, str):
|
|
243
|
+
if frequency_mask.lower() != "diffraction":
|
|
244
|
+
raise ValueError(
|
|
245
|
+
f"frequency_mask must be 'diffraction', a float or None; got {frequency_mask!r}"
|
|
246
|
+
)
|
|
247
|
+
inband = rho < self.cutoff
|
|
248
|
+
else:
|
|
249
|
+
frac = float(frequency_mask)
|
|
250
|
+
if not frac > 0:
|
|
251
|
+
raise ValueError("a numeric frequency_mask is a positive fraction of the cutoff")
|
|
252
|
+
inband = rho < frac * self.cutoff
|
|
253
|
+
inband[0, 0] = False
|
|
254
|
+
if not inband.any():
|
|
255
|
+
raise ValueError("the frequency mask selects no frequencies")
|
|
256
|
+
hermitian = np.full(fx.shape, 2.0)
|
|
257
|
+
hermitian[0, 0] = 1.0
|
|
258
|
+
if mx % 2 == 0:
|
|
259
|
+
hermitian[0, -1] = 1.0
|
|
260
|
+
self.frequency_mask = inband
|
|
261
|
+
self._mask = be.asarray(inband.astype(np.float64), dtype="real")
|
|
262
|
+
self._fweight = be.asarray(inband * hermitian, dtype="real")
|
|
263
|
+
self._object_mask = be.asarray((inband | (rho == 0)).astype(np.float64), dtype="real")
|
|
264
|
+
# Optical axis in pixels: the model's offset moves the detector window, so the
|
|
265
|
+
# axis sits at the window centre minus the offset (FocalPlanePropagator convention).
|
|
266
|
+
shift = [(n - 1) / 2.0 - off for n, off in zip((my, mx), model.offset)]
|
|
267
|
+
ramp = np.exp(-2j * math.pi * (fy * shift[0] + fx * shift[1]))
|
|
268
|
+
self._axis_ramp = be.asarray(ramp, dtype="complex")
|
|
269
|
+
|
|
270
|
+
# ---- noise power (outside the cutoff) and normalization
|
|
271
|
+
power = np.asarray(be.to_numpy(xp.abs(self._dhat) ** 2), dtype=np.float64)
|
|
272
|
+
outband = rho > self.cutoff
|
|
273
|
+
mean_power = power.mean(axis=0)[inband]
|
|
274
|
+
mean_inband = float(np.mean(mean_power))
|
|
275
|
+
peak_inband = float(np.max(mean_power))
|
|
276
|
+
self.noise_power: float | None = None
|
|
277
|
+
if np.count_nonzero(outband) >= 8:
|
|
278
|
+
self.noise_power = float(np.mean(power[:, outband]))
|
|
279
|
+
self.regularization = self._gamma(regularization, "regularization", peak_inband)
|
|
280
|
+
self.object_regularization = self._gamma(
|
|
281
|
+
object_regularization, "object_regularization", mean_inband
|
|
282
|
+
)
|
|
283
|
+
norm = self.noise_power if self.noise_power else mean_inband
|
|
284
|
+
self._norm = float(max(norm, 1e-300))
|
|
285
|
+
|
|
286
|
+
# ---- parameterization (as in FocalPlaneProblem)
|
|
287
|
+
mask = pupil.mask
|
|
288
|
+
self._flat_index = xp.asarray(np.flatnonzero(mask.ravel()))
|
|
289
|
+
self._amp0 = be.asarray(pupil.amplitude, dtype="real")
|
|
290
|
+
self._amp_scale = float(np.mean(pupil.amplitude[mask]))
|
|
291
|
+
self._modes = None if self.basis.is_zonal else self.basis.on(be)
|
|
292
|
+
yy, xx = pupil.coordinates()
|
|
293
|
+
w2 = pupil.amplitude**2
|
|
294
|
+
ramps = []
|
|
295
|
+
for c in (xx, yy):
|
|
296
|
+
r = np.where(mask, c - np.sum(w2 * c) / np.sum(w2), 0.0)
|
|
297
|
+
ramps.append(r / math.sqrt(np.sum(w2 * r**2) / np.sum(w2)))
|
|
298
|
+
self._ramps = be.asarray(np.stack(ramps), dtype="real")
|
|
299
|
+
self._set_layout()
|
|
300
|
+
|
|
301
|
+
def _gamma(self, value: float | str, name: str, signal_power: float) -> float:
|
|
302
|
+
"""Resolve a Wiener regularization: a number, or ``"auto"`` = noise / signal power."""
|
|
303
|
+
if isinstance(value, str):
|
|
304
|
+
if value != "auto":
|
|
305
|
+
raise ValueError(f"{name} must be 'auto' or a number, got {value!r}")
|
|
306
|
+
if self.noise_power is None:
|
|
307
|
+
raise ValueError(
|
|
308
|
+
f"{name}='auto' measures the noise power outside the diffraction cutoff, "
|
|
309
|
+
"but these images have no frequencies there (undersampled). Give "
|
|
310
|
+
f"{name} as a number (e.g. 1e-4)."
|
|
311
|
+
)
|
|
312
|
+
return max(self.noise_power / max(signal_power, 1e-300), 1e-12)
|
|
313
|
+
gamma = float(value)
|
|
314
|
+
if not (gamma >= 0 and math.isfinite(gamma)):
|
|
315
|
+
raise ValueError(f"{name} must be finite and non-negative, got {gamma}")
|
|
316
|
+
return gamma
|
|
317
|
+
|
|
318
|
+
def _set_layout(self) -> None:
|
|
319
|
+
n_c = self.basis.n_modes
|
|
320
|
+
n_a = self.model.pupil.n_valid if self.fit_amplitude else 0
|
|
321
|
+
n_t = 2 * (self.n_channels - 1) if self.fit_tilt else 0
|
|
322
|
+
self._coeffs = slice(0, n_c)
|
|
323
|
+
self._amp_sl = slice(n_c, n_c + n_a)
|
|
324
|
+
self._tilt_sl = slice(n_c + n_a, n_c + n_a + n_t)
|
|
325
|
+
self._size = n_c + n_a + n_t
|
|
326
|
+
|
|
327
|
+
def with_basis(self, basis: Basis) -> PhaseDiversityProblem:
|
|
328
|
+
"""The same data and settings with another basis (used for coarse-to-fine solves)."""
|
|
329
|
+
pupil = self.model.pupil
|
|
330
|
+
if basis.pupil.shape != pupil.shape or basis.pupil.n_valid != pupil.n_valid:
|
|
331
|
+
raise ValueError("the basis is defined on a different pupil than the model")
|
|
332
|
+
other = copy.copy(self)
|
|
333
|
+
other.basis = basis
|
|
334
|
+
other._modes = None if basis.is_zonal else basis.on(self.backend)
|
|
335
|
+
other._set_layout()
|
|
336
|
+
return other
|
|
337
|
+
|
|
338
|
+
# ---------------------------------------------------------------- basics
|
|
339
|
+
@property
|
|
340
|
+
def n_params(self) -> int:
|
|
341
|
+
"""Length of the internal parameter vector."""
|
|
342
|
+
return self._size
|
|
343
|
+
|
|
344
|
+
@property
|
|
345
|
+
def n_channels(self) -> int:
|
|
346
|
+
"""Number of images ``K``."""
|
|
347
|
+
return self.model.n_channels
|
|
348
|
+
|
|
349
|
+
def __repr__(self) -> str:
|
|
350
|
+
return (
|
|
351
|
+
f"PhaseDiversityProblem(basis={self.basis.kind!r}[{self.basis.n_modes}], "
|
|
352
|
+
f"channels={self.n_channels}, gamma={self.regularization:.3g}, "
|
|
353
|
+
f"frequencies={int(self.frequency_mask.sum())}, params={self.n_params}, "
|
|
354
|
+
f"backend={self.backend})"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# ------------------------------------------------------------ synthesis
|
|
358
|
+
def _phase_map(self, coeffs: Any) -> Any:
|
|
359
|
+
xp = self.backend.xp
|
|
360
|
+
values = coeffs if self._modes is None else coeffs @ self._modes
|
|
361
|
+
ny, nx = self.model.pupil.shape
|
|
362
|
+
flat = xp.zeros(ny * nx, dtype=values.dtype)
|
|
363
|
+
flat[self._flat_index] = values
|
|
364
|
+
return flat.reshape(ny, nx)
|
|
365
|
+
|
|
366
|
+
def _analyze(self, grad_map: Any) -> Any:
|
|
367
|
+
values = grad_map.reshape(-1)[self._flat_index]
|
|
368
|
+
return values if self._modes is None else self._modes @ values
|
|
369
|
+
|
|
370
|
+
def _amplitude(self, x: Any) -> Any:
|
|
371
|
+
if not self.fit_amplitude:
|
|
372
|
+
return self._amp0
|
|
373
|
+
flat = self._amp0.reshape(-1).copy()
|
|
374
|
+
flat[self._flat_index] = flat[self._flat_index] + self._amp_scale * x[self._amp_sl]
|
|
375
|
+
return flat.reshape(self.model.pupil.shape)
|
|
376
|
+
|
|
377
|
+
def _channel_phase(self, x: Any) -> Any:
|
|
378
|
+
phase = self._phase_map(x[self._coeffs])
|
|
379
|
+
if not self.fit_tilt:
|
|
380
|
+
return phase
|
|
381
|
+
xp = self.backend.xp
|
|
382
|
+
t = x[self._tilt_sl].reshape(-1, 2)
|
|
383
|
+
t = xp.concatenate([xp.zeros((1, 2), dtype=t.dtype), t])
|
|
384
|
+
return phase[None] + xp.tensordot(t, self._ramps, axes=(1, 0))
|
|
385
|
+
|
|
386
|
+
def initial(self, start: Any = None) -> Any:
|
|
387
|
+
"""Internal starting vector.
|
|
388
|
+
|
|
389
|
+
``start`` may be ``None`` (flat wavefront), an OPD map in metres
|
|
390
|
+
``(ny, nx)``, a coefficient vector in metres, or a :class:`Result`
|
|
391
|
+
(its OPD, and its amplitude and tilts when fitted, are reused).
|
|
392
|
+
"""
|
|
393
|
+
be, xp = self.backend, self.backend.xp
|
|
394
|
+
x = xp.zeros(self.n_params, dtype=be.real_dtype)
|
|
395
|
+
k_wave = 2.0 * math.pi / self.model.wavelength
|
|
396
|
+
pupil = self.model.pupil
|
|
397
|
+
if isinstance(start, Result):
|
|
398
|
+
opd = np.asarray(be.to_numpy(start.opd), dtype=np.float64)
|
|
399
|
+
x[self._coeffs] = be.asarray(self.basis.fit(opd) * k_wave, dtype="real")
|
|
400
|
+
if self.fit_amplitude and start.amplitude is not None:
|
|
401
|
+
amp = np.asarray(be.to_numpy(start.amplitude))[pupil.mask]
|
|
402
|
+
rel = (amp - pupil.amplitude[pupil.mask]) / self._amp_scale
|
|
403
|
+
x[self._amp_sl] = be.asarray(rel, dtype="real")
|
|
404
|
+
if self.fit_tilt and start.tilts is not None and len(start.tilts) == self.n_channels:
|
|
405
|
+
t = np.asarray(be.to_numpy(start.tilts))[1:] * k_wave
|
|
406
|
+
x[self._tilt_sl] = be.asarray(t.reshape(-1), dtype="real")
|
|
407
|
+
elif start is not None:
|
|
408
|
+
arr = np.asarray(be.to_numpy(start), dtype=np.float64)
|
|
409
|
+
if arr.shape == pupil.shape:
|
|
410
|
+
coeffs = self.basis.fit(arr)
|
|
411
|
+
elif arr.shape == (self.basis.n_modes,):
|
|
412
|
+
coeffs = arr
|
|
413
|
+
else:
|
|
414
|
+
raise ValueError(
|
|
415
|
+
f"start must be an OPD map {pupil.shape}, {self.basis.n_modes} "
|
|
416
|
+
f"coefficients or a Result; got shape {arr.shape}"
|
|
417
|
+
)
|
|
418
|
+
x[self._coeffs] = be.asarray(coeffs * k_wave, dtype="real")
|
|
419
|
+
return x
|
|
420
|
+
|
|
421
|
+
# ------------------------------------------------------------- objective
|
|
422
|
+
def _evaluate(self, x: Any) -> tuple[Any, Any, Any, Any]:
|
|
423
|
+
"""Forward state, OTFs ``S``, Wiener object ``O`` and residuals ``D - O S``."""
|
|
424
|
+
xp = self.backend.xp
|
|
425
|
+
state = self.model.forward(self._channel_phase(x), self._amplitude(x))
|
|
426
|
+
otf = self.backend.rfft2(state.images)
|
|
427
|
+
num = xp.sum(self._dhat * xp.conj(otf), axis=0)
|
|
428
|
+
den = xp.sum(otf.real**2 + otf.imag**2, axis=0) + self.regularization
|
|
429
|
+
obj = num / den
|
|
430
|
+
resid = self._dhat - obj * otf
|
|
431
|
+
return state, otf, obj, resid
|
|
432
|
+
|
|
433
|
+
def _metric(self, obj: Any, resid: Any) -> float:
|
|
434
|
+
xp = self.backend.xp
|
|
435
|
+
terms = xp.sum(resid.real**2 + resid.imag**2, axis=0)
|
|
436
|
+
if self.regularization:
|
|
437
|
+
terms = terms + self.regularization * (obj.real**2 + obj.imag**2)
|
|
438
|
+
return float(xp.sum(self._fweight * terms)) / self._norm
|
|
439
|
+
|
|
440
|
+
def value(self, x: Any) -> float:
|
|
441
|
+
"""Reduced metric at ``x`` (normalized by the noise power)."""
|
|
442
|
+
_, _, obj, resid = self._evaluate(x)
|
|
443
|
+
return self._metric(obj, resid)
|
|
444
|
+
|
|
445
|
+
def objective(self, x: Any) -> tuple[float, Any]:
|
|
446
|
+
"""Reduced metric and its gradient with respect to the internal vector ``x``."""
|
|
447
|
+
be, xp = self.backend, self.backend.xp
|
|
448
|
+
state, _, obj, resid = self._evaluate(x)
|
|
449
|
+
value = self._metric(obj, resid)
|
|
450
|
+
# dL/d conj(S_k) = -w O* R_k (O fixed: it minimizes the unreduced metric).
|
|
451
|
+
# Through the real FFT, dL/dp = N irfft2(-2 m O* R_k), with m the in-band mask.
|
|
452
|
+
my, mx = self._shape
|
|
453
|
+
g_hat = (-2.0 * my * mx / self._norm) * (self._mask * xp.conj(obj)) * resid
|
|
454
|
+
g_psf = be.irfft2(g_hat, self._shape)
|
|
455
|
+
g_phase, g_amp = self.model.vjp(state, g_psf, amplitude=self.fit_amplitude)
|
|
456
|
+
grad = xp.zeros_like(x)
|
|
457
|
+
grad[self._coeffs] = self._analyze(xp.sum(g_phase, axis=0))
|
|
458
|
+
if self.fit_amplitude:
|
|
459
|
+
grad[self._amp_sl] = self._amp_scale * g_amp.reshape(-1)[self._flat_index]
|
|
460
|
+
if self.fit_tilt:
|
|
461
|
+
gt = xp.tensordot(g_phase[1:], self._ramps, axes=([1, 2], [1, 2]))
|
|
462
|
+
grad[self._tilt_sl] = gt.reshape(-1)
|
|
463
|
+
return value, grad
|
|
464
|
+
|
|
465
|
+
def object_estimate(self, x: Any) -> Any:
|
|
466
|
+
"""Wiener object estimate ``(my, mx)`` at ``x`` on the data grid, in data units.
|
|
467
|
+
|
|
468
|
+
Uses :attr:`object_regularization`. The estimate is registered with the
|
|
469
|
+
optical axis, band-limited to the frequency mask (plus the mean), and
|
|
470
|
+
carries the apodization window and the flux equalization.
|
|
471
|
+
"""
|
|
472
|
+
state = self.model.forward(self._channel_phase(x), self._amplitude(x))
|
|
473
|
+
return self._object_image(self.backend.rfft2(state.images))
|
|
474
|
+
|
|
475
|
+
def _object_image(self, otf: Any) -> Any:
|
|
476
|
+
xp = self.backend.xp
|
|
477
|
+
num = xp.sum(self._dhat * xp.conj(otf), axis=0)
|
|
478
|
+
den = xp.sum(otf.real**2 + otf.imag**2, axis=0) + self.object_regularization
|
|
479
|
+
obj = num / den * self._object_mask * self._axis_ramp
|
|
480
|
+
return self.backend.irfft2(obj, self._shape)
|
|
481
|
+
|
|
482
|
+
# --------------------------------------------------------------- output
|
|
483
|
+
def result(
|
|
484
|
+
self,
|
|
485
|
+
x: Any,
|
|
486
|
+
*,
|
|
487
|
+
opt: OptimizeResult | None = None,
|
|
488
|
+
elapsed: float = 0.0,
|
|
489
|
+
method: str = "phase_diversity",
|
|
490
|
+
) -> Result:
|
|
491
|
+
"""Package an internal vector as a :class:`~solvephase.result.Result`.
|
|
492
|
+
|
|
493
|
+
``model_images`` is the model of the apodized, flux-equalized data
|
|
494
|
+
(``extra["apodized_images"]``). ``extra`` also holds ``"object"``
|
|
495
|
+
(:meth:`object_estimate`), ``"otf"`` (``(K, my, mx // 2 + 1)`` real-FFT
|
|
496
|
+
transfer functions, 1 at zero frequency for an unbounded detector),
|
|
497
|
+
``"regularization"`` and ``"object_regularization"`` (the gammas),
|
|
498
|
+
``"noise_power"``, ``"window"`` and
|
|
499
|
+
``"frequency_mask"`` (real-FFT layout).
|
|
500
|
+
"""
|
|
501
|
+
be = self.backend
|
|
502
|
+
k_wave = 2.0 * math.pi / self.model.wavelength
|
|
503
|
+
coeffs = x[self._coeffs]
|
|
504
|
+
phase = self._phase_map(coeffs)
|
|
505
|
+
_, otf, obj, _ = self._evaluate(x)
|
|
506
|
+
model_images = be.irfft2(obj * self._object_mask * otf, self._shape)
|
|
507
|
+
tilts = None
|
|
508
|
+
if self.fit_tilt:
|
|
509
|
+
t = np.zeros((self.n_channels, 2))
|
|
510
|
+
t[1:] = np.asarray(be.to_numpy(x[self._tilt_sl])).reshape(-1, 2) / k_wave
|
|
511
|
+
tilts = t
|
|
512
|
+
loss_value = opt.fun if opt is not None else self.value(x)
|
|
513
|
+
extra = {
|
|
514
|
+
"object": self._object_image(otf),
|
|
515
|
+
"otf": otf,
|
|
516
|
+
"apodized_images": self.apodized,
|
|
517
|
+
"regularization": self.regularization,
|
|
518
|
+
"object_regularization": self.object_regularization,
|
|
519
|
+
"noise_power": self.noise_power,
|
|
520
|
+
"window": self.window,
|
|
521
|
+
"frequency_mask": self.frequency_mask,
|
|
522
|
+
}
|
|
523
|
+
return Result(
|
|
524
|
+
method=method,
|
|
525
|
+
opd=phase / k_wave,
|
|
526
|
+
phase=phase,
|
|
527
|
+
amplitude=self._amplitude(x),
|
|
528
|
+
pupil=self.model.pupil,
|
|
529
|
+
wavelength=self.model.wavelength,
|
|
530
|
+
coefficients=None
|
|
531
|
+
if self.basis.is_zonal
|
|
532
|
+
else np.asarray(be.to_numpy(coeffs), dtype=np.float64) / k_wave,
|
|
533
|
+
basis=None if self.basis.is_zonal else self.basis,
|
|
534
|
+
model_images=model_images,
|
|
535
|
+
tilts=tilts,
|
|
536
|
+
loss=float(loss_value),
|
|
537
|
+
history=list(opt.history) if opt else [],
|
|
538
|
+
times=list(opt.times) if opt else [],
|
|
539
|
+
n_iter=opt.n_iter if opt else 0,
|
|
540
|
+
converged=opt.converged if opt else False,
|
|
541
|
+
message=opt.message if opt else "",
|
|
542
|
+
elapsed=elapsed,
|
|
543
|
+
device=be.device,
|
|
544
|
+
extra=extra,
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def phase_diversity(
|
|
549
|
+
model: FocalPlaneModel,
|
|
550
|
+
images: Any,
|
|
551
|
+
*,
|
|
552
|
+
basis: Basis | int | None = None,
|
|
553
|
+
regularization: float | str = "auto",
|
|
554
|
+
object_regularization: float | str = "auto",
|
|
555
|
+
window: Any = "tukey",
|
|
556
|
+
window_alpha: float = 0.25,
|
|
557
|
+
frequency_mask: str | float | None = "diffraction",
|
|
558
|
+
fit_amplitude: bool = False,
|
|
559
|
+
fit_tilt: bool = False,
|
|
560
|
+
equalize_flux: bool = True,
|
|
561
|
+
start: Any = None,
|
|
562
|
+
coarse_to_fine: Sequence[int] | None = None,
|
|
563
|
+
max_iter: int = 300,
|
|
564
|
+
callback: Callable[[int, Any, float], bool | None] | None = None,
|
|
565
|
+
**options: Any,
|
|
566
|
+
) -> Result:
|
|
567
|
+
"""Retrieve a wavefront from phase-diverse images of an unknown extended object.
|
|
568
|
+
|
|
569
|
+
Builds a :class:`PhaseDiversityProblem` (see it for ``basis``,
|
|
570
|
+
``regularization``, ``object_regularization``, ``window``, ``window_alpha``, ``frequency_mask``,
|
|
571
|
+
``fit_amplitude``, ``fit_tilt`` and ``equalize_flux``) and minimizes the
|
|
572
|
+
reduced metric with L-BFGS.
|
|
573
|
+
|
|
574
|
+
Parameters
|
|
575
|
+
----------
|
|
576
|
+
model:
|
|
577
|
+
Forward model with one channel per image (diversity included).
|
|
578
|
+
images:
|
|
579
|
+
``(K, my, mx)`` images of the same scene.
|
|
580
|
+
start:
|
|
581
|
+
Starting point: ``None`` (flat), an OPD map or coefficient vector in
|
|
582
|
+
metres, or a :class:`~solvephase.result.Result`.
|
|
583
|
+
coarse_to_fine:
|
|
584
|
+
Increasing mode counts, e.g. ``(5, 10)``: solve with the first ``n``
|
|
585
|
+
modes of ``basis``, then continue with more, ending with the full
|
|
586
|
+
basis. Widens the capture range for large aberrations. Needs a modal
|
|
587
|
+
basis.
|
|
588
|
+
max_iter:
|
|
589
|
+
L-BFGS iteration limit per stage.
|
|
590
|
+
callback:
|
|
591
|
+
``callback(iteration, x, f)``; return ``True`` to stop the stage.
|
|
592
|
+
**options:
|
|
593
|
+
Passed to :func:`solvephase.optimize.lbfgs` (``ftol``, ``gtol``,
|
|
594
|
+
``memory`` ...).
|
|
595
|
+
|
|
596
|
+
Returns
|
|
597
|
+
-------
|
|
598
|
+
Result
|
|
599
|
+
``method="phase_diversity"``; ``coefficients`` are RMS OPD in metres
|
|
600
|
+
and ``extra["object"]`` is the Wiener object estimate on the data grid,
|
|
601
|
+
in data units (see :meth:`PhaseDiversityProblem.result`). A common
|
|
602
|
+
tip/tilt is not measurable and stays at its starting value.
|
|
603
|
+
|
|
604
|
+
Examples
|
|
605
|
+
--------
|
|
606
|
+
>>> pupil = Pupil.circular(64) # doctest: +SKIP
|
|
607
|
+
>>> div = zernike_diversity(pupil, 4, [0.0, 0.29 * 1.6e-6]) # 1 wave PV
|
|
608
|
+
>>> model = FocalPlaneModel(pupil, 1.6e-6, 128, sampling=2.0, diversity=div)
|
|
609
|
+
>>> result = phase_diversity(model, images, basis=21)
|
|
610
|
+
"""
|
|
611
|
+
t0 = time.perf_counter()
|
|
612
|
+
problem = PhaseDiversityProblem(
|
|
613
|
+
model,
|
|
614
|
+
images,
|
|
615
|
+
basis=basis,
|
|
616
|
+
regularization=regularization,
|
|
617
|
+
object_regularization=object_regularization,
|
|
618
|
+
window=window,
|
|
619
|
+
window_alpha=window_alpha,
|
|
620
|
+
frequency_mask=frequency_mask,
|
|
621
|
+
fit_amplitude=fit_amplitude,
|
|
622
|
+
fit_tilt=fit_tilt,
|
|
623
|
+
equalize_flux=equalize_flux,
|
|
624
|
+
)
|
|
625
|
+
full = problem.basis
|
|
626
|
+
stages: list[int] = []
|
|
627
|
+
if coarse_to_fine:
|
|
628
|
+
if full.is_zonal:
|
|
629
|
+
raise ValueError("coarse_to_fine needs a modal basis (give basis= a Basis or an int)")
|
|
630
|
+
stages = sorted({int(n) for n in coarse_to_fine if 0 < int(n) < full.n_modes})
|
|
631
|
+
stages.append(full.n_modes)
|
|
632
|
+
be = problem.backend
|
|
633
|
+
history: list[float] = []
|
|
634
|
+
times: list[float] = []
|
|
635
|
+
n_iter = n_eval = 0
|
|
636
|
+
opt: OptimizeResult | None = None
|
|
637
|
+
current: Any = start
|
|
638
|
+
stage_problem = problem
|
|
639
|
+
for n in stages:
|
|
640
|
+
stage_problem = (
|
|
641
|
+
problem if n == full.n_modes else problem.with_basis(full.subset(slice(0, n)))
|
|
642
|
+
)
|
|
643
|
+
x0 = stage_problem.initial(current)
|
|
644
|
+
offset = time.perf_counter() - t0
|
|
645
|
+
opt = lbfgs(
|
|
646
|
+
stage_problem.objective, x0, be, max_iter=max_iter, callback=callback, **options
|
|
647
|
+
)
|
|
648
|
+
history += opt.history
|
|
649
|
+
times += [offset + t for t in opt.times]
|
|
650
|
+
n_iter += opt.n_iter
|
|
651
|
+
n_eval += opt.n_eval
|
|
652
|
+
current = stage_problem.result(opt.x, opt=opt)
|
|
653
|
+
assert opt is not None
|
|
654
|
+
be.synchronize()
|
|
655
|
+
result = stage_problem.result(opt.x, opt=opt, elapsed=time.perf_counter() - t0)
|
|
656
|
+
result.history, result.times, result.n_iter = history, times, n_iter
|
|
657
|
+
result.extra["n_eval"] = n_eval
|
|
658
|
+
return result
|