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.
@@ -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