fast-acbf 0.7.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.
fast_acbf/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ from .solver import BFSolver
2
+ from .core.aberrations import AberrationState
3
+ from .optimization.metrics import QualityMetrics
4
+ from .data.dataset4d import Dataset4D
5
+ from .data.bf_extractor import BFExtractor
6
+ from .data.geometry import DetectorGeometry, ScanGeometry, CoordinateTransform
7
+ from .data.imagefft import ImageFFT
8
+ from .recon.pipeline import PipelineManager, PipelineResolution
9
+
10
+ __version__ = "0.7.0" # 2026.07.30
11
+
12
+ __all__ = [
13
+ "BFSolver",
14
+ "AberrationState",
15
+ "QualityMetrics",
16
+ "Dataset4D",
17
+ "BFExtractor",
18
+ "DetectorGeometry",
19
+ "ScanGeometry",
20
+ "CoordinateTransform",
21
+ "ImageFFT",
22
+ "PipelineManager",
23
+ "PipelineResolution",
24
+ ]
File without changes
@@ -0,0 +1,165 @@
1
+ """AberrationState — nn.Module holding detector-frame aberration coefficients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import torch
7
+ import torch.nn as nn
8
+
9
+
10
+ class AberrationState(torch.nn.Module):
11
+ def __init__(self, ab_dict: dict, max_order: int = None, device='cpu', tolerance_factors: dict = None):
12
+ super().__init__()
13
+ self.orig_ab_dict = ab_dict.copy()
14
+ self.device = torch.device(device)
15
+ self.order_keys = []
16
+ self.tolerance_factors = tolerance_factors
17
+ # tolerance_factors: dict {n: Tn_in_ang} — Kirkland tolerance for each order.
18
+ # https://doi.org/10.1016/j.ultramic.2017.12.002
19
+ # When set, parameters are stored in normalized (dimensionless) form internally;
20
+ # all external accessors (get_cartesian_dict, get_flat_coeffs, get_physical) return physical Å values.
21
+
22
+ if max_order is None:
23
+ max_order = max([n for (n, m) in ab_dict.keys()]) if ab_dict else 2
24
+
25
+ self.max_order = max_order
26
+
27
+ self.coeffs = nn.ParameterDict()
28
+
29
+ for n in range(1, max_order + 1):
30
+ start_m = (n + 1) % 2
31
+ Tn = tolerance_factors[n] if tolerance_factors is not None else 1.0
32
+
33
+ for m in range(start_m, n + 2, 2):
34
+ self.order_keys.append((n, m))
35
+
36
+ if m == 0:
37
+ val = ab_dict.get((n, m), 0.0)
38
+ val_clean = float(val) if isinstance(val, (int, float, np.number)) else 0.0
39
+
40
+ key = f"C_{n}_{m}"
41
+ self.coeffs[key] = nn.Parameter(torch.tensor(val_clean / Tn, device=self.device))
42
+
43
+ else:
44
+ val = ab_dict.get((n, m), {'a': 0.0, 'b': 0.0})
45
+ if isinstance(val, (int, float, np.number)):
46
+ val_a, val_b = float(val), 0.0
47
+ else:
48
+ val_a = float(val.get('a', 0.0))
49
+ val_b = float(val.get('b', 0.0))
50
+
51
+ key_a = f"C_{n}_{m}_a"
52
+ key_b = f"C_{n}_{m}_b"
53
+ self.coeffs[key_a] = nn.Parameter(torch.tensor(val_a / Tn, device=self.device))
54
+ self.coeffs[key_b] = nn.Parameter(torch.tensor(val_b / Tn, device=self.device))
55
+
56
+ def get_cartesian_dict(self):
57
+ """Reconstructs the nested dictionary format in physical Å units."""
58
+ out = {}
59
+ for (n, m) in self.order_keys:
60
+ Tn = self.tolerance_factors[n] if self.tolerance_factors is not None else 1.0
61
+ if m == 0:
62
+ out[(n, m)] = self.coeffs[f"C_{n}_{m}"].item() * Tn
63
+ else:
64
+ out[(n, m)] = {
65
+ 'a': self.coeffs[f"C_{n}_{m}_a"].item() * Tn,
66
+ 'b': self.coeffs[f"C_{n}_{m}_b"].item() * Tn
67
+ }
68
+ return out
69
+
70
+ def get_flat_coeffs(self):
71
+ """Returns all coefficients as a 1D tensor in physical Å units."""
72
+ coeffs_list = []
73
+ tn_list = []
74
+ for (n, m) in self.order_keys:
75
+ Tn = self.tolerance_factors[n] if self.tolerance_factors is not None else 1.0
76
+ if m == 0:
77
+ coeffs_list.append(self.coeffs[f"C_{n}_{m}"])
78
+ tn_list.append(Tn)
79
+ else:
80
+ coeffs_list.append(self.coeffs[f"C_{n}_{m}_a"])
81
+ tn_list.append(Tn)
82
+ coeffs_list.append(self.coeffs[f"C_{n}_{m}_b"])
83
+ tn_list.append(Tn)
84
+ stacked = torch.stack(coeffs_list)
85
+ if self.tolerance_factors is not None:
86
+ tn_tensor = stacked.new_tensor(tn_list)
87
+ stacked = stacked * tn_tensor
88
+ return stacked
89
+
90
+ def to_scan_frame(self, rotation_deg: float) -> torch.Tensor:
91
+ """
92
+ Return flat physical coefficients rotated from detector frame to scan frame.
93
+
94
+ Differentiable with respect to the stored detector-frame parameters.
95
+ Symmetric terms (m == 0) are rotation-invariant. Each asymmetric Cartesian
96
+ pair (C_a, C_b) is rotated by m * rotation_deg.
97
+ """
98
+ if rotation_deg == 0.0:
99
+ return self.get_flat_coeffs()
100
+
101
+ flat = self.get_flat_coeffs()
102
+ out = flat.clone()
103
+ idx = 0
104
+ for _, m in self.order_keys:
105
+ if m == 0:
106
+ idx += 1
107
+ continue
108
+
109
+ theta = flat.new_tensor(m * rotation_deg * np.pi / 180.0)
110
+ c = torch.cos(theta)
111
+ s = torch.sin(theta)
112
+ ca = flat[idx]
113
+ cb = flat[idx + 1]
114
+ out[idx] = ca * c - cb * s
115
+ out[idx + 1] = ca * s + cb * c
116
+ idx += 2
117
+ return out
118
+
119
+ def get_physical(self, key: str) -> float:
120
+ """Return the physical Å value for a single coefficient key."""
121
+ n = int(key.split('_')[1])
122
+ raw = self.coeffs[key].detach().item()
123
+ return raw * self.tolerance_factors[n] if self.tolerance_factors is not None else raw
124
+
125
+ def set_physical(self, key: str, phys_value):
126
+ """Write a physical Å value to a coefficient, normalizing internally."""
127
+ n = int(key.split('_')[1])
128
+ val = phys_value.item() if isinstance(phys_value, torch.Tensor) else float(phys_value)
129
+ norm_val = val / self.tolerance_factors[n] if self.tolerance_factors is not None else val
130
+ with torch.no_grad():
131
+ self.coeffs[key].copy_(torch.tensor(norm_val, dtype=torch.float32, device=self.device))
132
+
133
+ def flat_to_cartesian_dict(self, flat: torch.Tensor) -> dict:
134
+ """Convert a flat physical-unit coefficient tensor to the cartesian dict format."""
135
+ out = {}
136
+ idx = 0
137
+ for (n, m) in self.order_keys:
138
+ if m == 0:
139
+ out[(n, m)] = flat[idx].item()
140
+ idx += 1
141
+ else:
142
+ out[(n, m)] = {'a': flat[idx].item(), 'b': flat[idx + 1].item()}
143
+ idx += 2
144
+ return out
145
+
146
+ def rebuild_normalization(self, new_tolerance_factors: dict):
147
+ """Re-normalize all parameters under a new set of tolerance factors.
148
+
149
+ Reads current physical values, updates self.tolerance_factors, then stores
150
+ parameters normalized by the new Tₙ values. Use this after overriding
151
+ individual entries in tolerance_factors to keep physical ↔ internal mapping
152
+ consistent.
153
+ """
154
+ phys = self.get_cartesian_dict()
155
+ self.tolerance_factors = new_tolerance_factors
156
+ with torch.no_grad():
157
+ for (n, m), val in phys.items():
158
+ Tn = new_tolerance_factors[n]
159
+ if m == 0:
160
+ key = f"C_{n}_{m}"
161
+ self.coeffs[key].copy_(torch.tensor(val / Tn, dtype=torch.float32, device=self.device))
162
+ else:
163
+ for suffix, v in [('_a', val['a']), ('_b', val['b'])]:
164
+ key = f"C_{n}_{m}{suffix}"
165
+ self.coeffs[key].copy_(torch.tensor(v / Tn, dtype=torch.float32, device=self.device))
fast_acbf/core/acbf.py ADDED
@@ -0,0 +1,226 @@
1
+ """acBF reconstruction — pure math, no state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import torch
8
+
9
+ from fast_acbf.core.functional import generate_aberration_basis, make_soft_aperture_torch
10
+ from fast_acbf.recon.cache import ACBFGeometryCache, ACBFOpticsCache
11
+
12
+ if TYPE_CHECKING:
13
+ from fast_acbf.data.imagefft import ImageFFT
14
+
15
+
16
+ def compute_transfer(
17
+ geom_chunk: dict,
18
+ optics_chunk: dict | None,
19
+ coeffs: torch.Tensor,
20
+ qx_grid: torch.Tensor,
21
+ qy_grid: torch.Tensor,
22
+ geometry: ACBFGeometryCache,
23
+ device: str,
24
+ ) -> torch.Tensor:
25
+ """
26
+ Compute the detector-wise complex transfer for one chunk.
27
+
28
+ Two paths driven by the presence of an optics chunk:
29
+ Fast path: bases and apertures pre-cached on optics_chunk.
30
+ Lazy path: optics_chunk is None — recompute from geom_chunk + scan grids + physics.
31
+
32
+ Args:
33
+ geom_chunk: One ACBFGeometryCache chunk dict ({kxt, kyt, start, end}).
34
+ optics_chunk: Matching ACBFOpticsCache chunk dict, or None for lazy path.
35
+ coeffs: Flat scan-frame aberration coefficients, shape (num_coeffs,).
36
+ qx_grid: Scan-frame frequency grid, shape (1, 1, Rx).
37
+ qy_grid: Scan-frame frequency grid, shape (1, Ry, 1).
38
+ geometry: ACBFGeometryCache — physics params used by the lazy path.
39
+ device: Target device string.
40
+
41
+ Returns:
42
+ Complex transfer T, shape (chunk_size, Ny, Nx).
43
+ """
44
+ j1 = torch.tensor(1.0j, dtype=torch.complex64, device=device)
45
+
46
+ if optics_chunk is not None:
47
+ b_tr = optics_chunk['b_tr']
48
+ b_t = optics_chunk['b_t']
49
+ b_mt = optics_chunk['b_mt']
50
+ ap_t = optics_chunk['ap_t']
51
+ ap_mt = optics_chunk['ap_mt']
52
+ else:
53
+ kxt = geom_chunk['kxt'].to(device)
54
+ kyt = geom_chunk['kyt'].to(device)
55
+ kx_t, ky_t = qx_grid + kxt, qy_grid + kyt
56
+ kx_mt, ky_mt = qx_grid - kxt, qy_grid - kyt
57
+ ap_t = make_soft_aperture_torch(
58
+ torch.sqrt(kx_t**2 + ky_t**2) * geometry.wavelength,
59
+ geometry.max_alpha, geometry.rolloff)
60
+ ap_mt = make_soft_aperture_torch(
61
+ torch.sqrt(kx_mt**2 + ky_mt**2) * geometry.wavelength,
62
+ geometry.max_alpha, geometry.rolloff)
63
+ b_tr = generate_aberration_basis(
64
+ geometry.max_order, geometry.order_keys, kxt, kyt, geometry.wavelength)
65
+ b_t = generate_aberration_basis(
66
+ geometry.max_order, geometry.order_keys, kx_t, ky_t, geometry.wavelength)
67
+ b_mt = generate_aberration_basis(
68
+ geometry.max_order, geometry.order_keys, -kx_mt, -ky_mt, geometry.wavelength)
69
+
70
+ chi_tr_az = torch.einsum('k, kbxy -> bxy', coeffs, b_tr)
71
+ chi_t = torch.einsum('k, kbxy -> bxy', coeffs, b_t)
72
+ chi_mt = torch.einsum('k, kbxy -> bxy', coeffs, b_mt)
73
+
74
+ term_mt = ap_mt * torch.exp(-j1 * (chi_tr_az - chi_mt))
75
+ term_t = ap_t * torch.exp( j1 * (chi_tr_az - chi_t))
76
+ D = term_mt - term_t
77
+
78
+ # T = -i * D. Keeps the transfer aligned with the acBF phasor convention.
79
+ return (-j1) * D
80
+
81
+
82
+ def _iter_chunks(geometry: ACBFGeometryCache, optics: ACBFOpticsCache | None):
83
+ """Yield (geom_chunk, optics_chunk_or_None) pairs."""
84
+ if optics is None:
85
+ for g in geometry.chunks:
86
+ yield g, None
87
+ return
88
+ if len(geometry.chunks) != len(optics.chunks):
89
+ raise ValueError(
90
+ f"geometry/optics chunk count mismatch: "
91
+ f"{len(geometry.chunks)} vs {len(optics.chunks)}."
92
+ )
93
+ for g, o in zip(geometry.chunks, optics.chunks):
94
+ if g['start'] != o['start'] or g['end'] != o['end']:
95
+ raise ValueError(
96
+ f"geometry/optics chunk bounds mismatch: "
97
+ f"({g['start']},{g['end']}) vs ({o['start']},{o['end']})."
98
+ )
99
+ yield g, o
100
+
101
+
102
+ def reconstruct_acbf(
103
+ provider: ImageFFT,
104
+ qx_grid: torch.Tensor,
105
+ qy_grid: torch.Tensor,
106
+ geometry: ACBFGeometryCache,
107
+ optics: ACBFOpticsCache | None,
108
+ coeffs: torch.Tensor,
109
+ eps: float,
110
+ device: str,
111
+ ) -> torch.Tensor:
112
+ """
113
+ Phase-only acBF reconstruction.
114
+
115
+ Aligns detector contributions by their phase before summation.
116
+
117
+ Args:
118
+ provider: ImageFFT serving (chunk_size, Ry, Rx) complex64 chunks.
119
+ qx_grid: Scan-frame frequency grid, shape (1, 1, Rx).
120
+ qy_grid: Scan-frame frequency grid, shape (1, Ry, 1).
121
+ geometry: ACBFGeometryCache built by pipeline.build_acbf_geometry_cache.
122
+ optics: ACBFOpticsCache (full mode) or None (lazy mode).
123
+ coeffs: Flat scan-frame aberration coefficients, shape (num_coeffs,).
124
+ eps: Small constant for phase normalization stability.
125
+ device: Target device string.
126
+
127
+ Returns:
128
+ Reconstructed acBF image, shape (Ry, Rx), float32.
129
+ """
130
+ Ry_out = qy_grid.shape[-2]
131
+ Rx_out = qx_grid.shape[-1]
132
+ acBF_total = torch.zeros((Ry_out, Rx_out), dtype=torch.float32, device=device)
133
+ for geom_chunk, optics_chunk in _iter_chunks(geometry, optics):
134
+ img_fft_chunk = provider.get_chunk(geom_chunk['start'], geom_chunk['end'])
135
+ transfer = compute_transfer(geom_chunk, optics_chunk, coeffs, qx_grid, qy_grid, geometry, device)
136
+ phasor = transfer / (transfer.abs() + eps)
137
+ acBF_total += torch.sum(torch.fft.ifft2(img_fft_chunk * phasor, dim=(-2, -1)).real, dim=0)
138
+ return acBF_total
139
+
140
+
141
+ def reconstruct_acbf_complex_inversion(
142
+ provider: ImageFFT,
143
+ qx_grid: torch.Tensor,
144
+ qy_grid: torch.Tensor,
145
+ geometry: ACBFGeometryCache,
146
+ optics: ACBFOpticsCache | None,
147
+ coeffs: torch.Tensor,
148
+ device: str,
149
+ regularization: float = 1e-3,
150
+ support_threshold: float = 1e-6,
151
+ return_diagnostics: bool = False,
152
+ ):
153
+ """
154
+ Complex-inversion acBF reconstruction via regularized transfer inversion.
155
+
156
+ Solves a regularized matched-filter inversion of the detector-wise complex transfer:
157
+
158
+ V_hat(q) = M(q) / (S(q) + lambda * S_ref)
159
+
160
+ where:
161
+ M(q) = sum_b T_b(q) * I_b(q)
162
+ S(q) = sum_b |T_b(q)|^2
163
+
164
+ Args:
165
+ provider: ImageFFT serving (chunk_size, Ry, Rx) complex64 chunks.
166
+ qx_grid: Scan-frame frequency grid, shape (1, 1, Rx).
167
+ qy_grid: Scan-frame frequency grid, shape (1, Ry, 1).
168
+ geometry: ACBFGeometryCache.
169
+ optics: ACBFOpticsCache (full mode) or None (lazy mode).
170
+ coeffs: Flat scan-frame aberration coefficients.
171
+ device: Target device string.
172
+ regularization: Non-negative regularization weight lambda.
173
+ support_threshold: Fraction of median transfer power below which Fourier
174
+ components are zeroed.
175
+ return_diagnostics: If True, return full diagnostic dict instead of just image.
176
+
177
+ Returns:
178
+ Reconstructed image (Ry, Rx) float32, or dict if return_diagnostics=True.
179
+ """
180
+ if regularization < 0:
181
+ raise ValueError(f"regularization must be non-negative, got {regularization}.")
182
+ if support_threshold < 0:
183
+ raise ValueError(f"support_threshold must be non-negative, got {support_threshold}.")
184
+
185
+ Ry_out = qy_grid.shape[-2]
186
+ Rx_out = qx_grid.shape[-1]
187
+ out_shape = (Ry_out, Rx_out)
188
+ numerator = torch.zeros(out_shape, dtype=torch.complex64, device=device)
189
+ transfer_power = torch.zeros(out_shape, dtype=torch.float32, device=device)
190
+
191
+ for geom_chunk, optics_chunk in _iter_chunks(geometry, optics):
192
+ img_fft_chunk = provider.get_chunk(geom_chunk['start'], geom_chunk['end'])
193
+ transfer = compute_transfer(geom_chunk, optics_chunk, coeffs, qx_grid, qy_grid, geometry, device)
194
+ numerator.add_(torch.sum(transfer * img_fft_chunk, dim=0))
195
+ transfer_power.add_(torch.sum(transfer.abs().square(), dim=0))
196
+
197
+ positive_power = transfer_power[transfer_power > 0]
198
+ if positive_power.numel() == 0:
199
+ transfer_reference = torch.tensor(1.0, dtype=torch.float32, device=device)
200
+ else:
201
+ transfer_reference = positive_power.median()
202
+
203
+ support = transfer_power > (support_threshold * transfer_reference)
204
+ denom = transfer_power + (regularization * transfer_reference)
205
+ fourier_estimate = torch.where(
206
+ support,
207
+ numerator / denom.to(torch.complex64),
208
+ torch.zeros_like(numerator),
209
+ )
210
+
211
+ complex_image = torch.fft.ifft2(fourier_estimate, dim=(-2, -1))
212
+ reconstructed = complex_image.real
213
+
214
+ if not return_diagnostics:
215
+ return reconstructed
216
+
217
+ return {
218
+ 'image': reconstructed,
219
+ 'complex_image': complex_image,
220
+ 'real_channel': complex_image.real,
221
+ 'imag_channel': complex_image.imag,
222
+ 'fourier_estimate': fourier_estimate,
223
+ 'transfer_power': transfer_power,
224
+ 'support_mask': support,
225
+ 'support_reference': transfer_reference,
226
+ }
@@ -0,0 +1,190 @@
1
+ """Differentiable physics primitives — no state, no side effects. Portable to PtyRAD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+
8
+ from ptyrad.core.functional import fftshift2, ifftshift2, torch_phasor
9
+
10
+
11
+ def imshift_with_batch(imgs, shifts, grid, batch_size=None):
12
+ """
13
+ Generates a batch of shifted images from an input stack of images.
14
+
15
+ This function shifts complex/real-valued input images by applying phase shifts
16
+ in the Fourier domain, achieving subpixel shifts in both x and y directions.
17
+ Optionally applies a CTF correction in the same operation.
18
+
19
+ Args:
20
+ imgs (torch.Tensor): The input images to be shifted.
21
+ Shape must be (Nb, ..., Ny, Nx).
22
+ shifts (torch.Tensor): The shifts to be applied. Shape is (Nb, 2),
23
+ where each slice is (shift_y, shift_x) in pixels.
24
+ grid (torch.Tensor): The k-space grid used for computing the shifts.
25
+ Shape=(2, Ny, Nx), spanning [-0.5, 0.5).
26
+ batch_size (int, optional): The number of images to process at once to avoid OOM.
27
+ If None, processes the entire batch simultaneously.
28
+
29
+ Returns:
30
+ torch.Tensor: The shifted (and optionally CTF-corrected) images.
31
+ Shape remains (Nb, ..., Ny, Nx).
32
+ """
33
+ assert imgs.shape[-2:] == grid.shape[-2:], \
34
+ f"Incompatible dimensions: imgs {imgs.shape[-2:]} vs grid {grid.shape[-2:]}"
35
+ assert imgs.shape[0] == shifts.shape[0], \
36
+ f"Batch size mismatch: imgs has {imgs.shape[0]}, shifts has {shifts.shape[0]}"
37
+
38
+ Nb = imgs.shape[0]
39
+ ndim = imgs.ndim
40
+
41
+ batch_size = batch_size or Nb
42
+
43
+ shift_expand_shape = [-1] + [1] * (ndim - 1)
44
+ grid_expand_shape = [1] * (ndim - 2) + list(grid.shape[-2:])
45
+
46
+ ky = grid[0].view(*grid_expand_shape)
47
+ kx = grid[1].view(*grid_expand_shape)
48
+
49
+ shifted_imgs_list = []
50
+
51
+ for i in range(0, Nb, batch_size):
52
+ end = min(i + batch_size, Nb)
53
+
54
+ imgs_b = imgs[i:end]
55
+ shifts_b = shifts[i:end]
56
+
57
+ shift_y = shifts_b[:, 0].view(*shift_expand_shape)
58
+ shift_x = shifts_b[:, 1].view(*shift_expand_shape)
59
+
60
+ phase = -2 * torch.pi * (shift_x * kx + shift_y * ky)
61
+ w = torch_phasor(phase)
62
+
63
+ img_fft = torch.fft.fft2(imgs_b)
64
+ shifted_img_b = torch.fft.ifft2(img_fft * w)
65
+
66
+ shifted_imgs_list.append(shifted_img_b)
67
+
68
+ return torch.cat(shifted_imgs_list, dim=0)
69
+
70
+
71
+ def make_soft_aperture_torch(alpha: torch.Tensor, max_alpha_mrad: float, rolloff_mrad: float) -> torch.Tensor:
72
+ """
73
+ Creates a differentiable circular aperture mask with a cosine edge taper.
74
+
75
+ Args:
76
+ alpha: Tensor of scattering angles in radians.
77
+ max_alpha_mrad: The semiangle cutoff in milliradians.
78
+ rolloff_mrad: The width of the soft edge transition in milliradians.
79
+ """
80
+ cutoff = max_alpha_mrad / 1000.0
81
+
82
+ if rolloff_mrad <= 0.0:
83
+ return (alpha <= cutoff).to(torch.float32)
84
+
85
+ rolloff = rolloff_mrad / 1000.0
86
+
87
+ transition = 0.5 * (1.0 + torch.cos(torch.pi * (alpha - cutoff + rolloff) / rolloff))
88
+ mask = torch.where(alpha > cutoff, torch.zeros_like(alpha), transition)
89
+ mask = torch.where(alpha < (cutoff - rolloff), torch.ones_like(alpha), mask)
90
+
91
+ return mask
92
+
93
+
94
+ def make_probe_from_chi(chi, mask):
95
+ assert chi.shape[-2:] == mask.shape[-2:]
96
+
97
+ psi = torch_phasor(-1 * chi)
98
+ probe = mask * psi
99
+ probe = fftshift2(torch.fft.ifft2(ifftshift2(probe)))
100
+ probe = probe / torch.sqrt(torch.sum((torch.abs(probe)) ** 2))
101
+ return probe
102
+
103
+
104
+ def generate_shift_basis(order_keys: list, kX: torch.Tensor, kY: torch.Tensor, wavelength: float):
105
+ """Generates the unweighted (C=1) analytic shift basis vectors for dx and dy."""
106
+ alphaX = kX * wavelength
107
+ alphaY = kY * wavelength
108
+ alpha_sq = alphaX**2 + alphaY**2
109
+
110
+ alpha_sq_safe = alpha_sq + 1e-12
111
+
112
+ max_m = max([m for (n, m) in order_keys]) if order_keys else 0
113
+
114
+ X, Y = {}, {}
115
+ X[0] = torch.ones_like(alphaX)
116
+ Y[0] = torch.zeros_like(alphaX)
117
+
118
+ for m in range(max_m):
119
+ X[m+1] = X[m] * alphaX - Y[m] * alphaY
120
+ Y[m+1] = X[m] * alphaY + Y[m] * alphaX
121
+
122
+ basis_dx_list = []
123
+ basis_dy_list = []
124
+
125
+ for (n, m) in order_keys:
126
+ p = (n + 1 - m) / 2.0
127
+
128
+ if n + 1 - m > 0:
129
+ rad_deriv_x = (n + 1 - m) * alphaX * (alpha_sq_safe ** (p - 1.0))
130
+ rad_deriv_y = (n + 1 - m) * alphaY * (alpha_sq_safe ** (p - 1.0))
131
+ else:
132
+ rad_deriv_x = torch.zeros_like(alphaX)
133
+ rad_deriv_y = torch.zeros_like(alphaX)
134
+
135
+ rad_base = alpha_sq ** p
136
+
137
+ if m == 0:
138
+ dx = (rad_deriv_x * X[m]) / (n + 1)
139
+ dy = (rad_deriv_y * X[m]) / (n + 1)
140
+ basis_dx_list.append(dx)
141
+ basis_dy_list.append(dy)
142
+
143
+ else:
144
+ part1_x_a = rad_deriv_x * X[m]
145
+ part1_y_a = rad_deriv_y * X[m]
146
+ part2_x_a = rad_base * (m * X[m-1])
147
+ part2_y_a = rad_base * (m * -Y[m-1])
148
+
149
+ basis_dx_list.append((part1_x_a + part2_x_a) / (n + 1))
150
+ basis_dy_list.append((part1_y_a + part2_y_a) / (n + 1))
151
+
152
+ part1_x_b = rad_deriv_x * Y[m]
153
+ part1_y_b = rad_deriv_y * Y[m]
154
+ part2_x_b = rad_base * (m * Y[m-1])
155
+ part2_y_b = rad_base * (m * X[m-1])
156
+
157
+ basis_dx_list.append((part1_x_b + part2_x_b) / (n + 1))
158
+ basis_dy_list.append((part1_y_b + part2_y_b) / (n + 1))
159
+
160
+ return torch.stack(basis_dx_list, dim=0), torch.stack(basis_dy_list, dim=0)
161
+
162
+
163
+ def generate_aberration_basis(max_order: int, order_keys: list, kX: torch.Tensor, kY: torch.Tensor, wavelength: float):
164
+ """Generates the unweighted (C=1) Cartesian polynomial basis tensors."""
165
+ alphaX = kX * wavelength
166
+ alphaY = kY * wavelength
167
+ alpha_sq = alphaX**2 + alphaY**2
168
+
169
+ X, Y = {}, {}
170
+ X[0] = torch.ones_like(alpha_sq)
171
+ Y[0] = torch.zeros_like(alpha_sq)
172
+
173
+ for m in range(max_order + 1):
174
+ X[m+1] = X[m] * alphaX - Y[m] * alphaY
175
+ Y[m+1] = X[m] * alphaY + Y[m] * alphaX
176
+
177
+ multiplier = (2 * torch.pi / wavelength)
178
+ basis_list = []
179
+
180
+ for (n, m) in order_keys:
181
+ power_rad = (n + 1 - m) / 2.0
182
+ term_radial = alpha_sq ** power_rad
183
+
184
+ if m == 0:
185
+ basis_list.append(term_radial * X[m] / (n + 1) * multiplier)
186
+ else:
187
+ basis_list.append(term_radial * X[m] / (n + 1) * multiplier) # a
188
+ basis_list.append(term_radial * Y[m] / (n + 1) * multiplier) # b
189
+
190
+ return torch.stack(basis_list, dim=0)
fast_acbf/core/tcbf.py ADDED
@@ -0,0 +1,53 @@
1
+ """tcBF reconstruction — pure math, no state."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import torch
8
+
9
+ from fast_acbf.recon.cache import TCBFCache
10
+
11
+ if TYPE_CHECKING:
12
+ from fast_acbf.data.imagefft import ImageFFT
13
+
14
+
15
+ def reconstruct_tcbf(
16
+ provider: ImageFFT,
17
+ qx_grid: torch.Tensor,
18
+ qy_grid: torch.Tensor,
19
+ cache: TCBFCache,
20
+ coeffs: torch.Tensor,
21
+ device: str,
22
+ ) -> torch.Tensor:
23
+ """
24
+ Ultra-lean AD forward pass for tcBF.
25
+
26
+ Calculates exact analytical shifts via Einstein summation over the pre-built
27
+ shift basis stored in the cache.
28
+
29
+ Args:
30
+ provider: ImageFFT serving (chunk_size, Ry, Rx) complex64 chunks.
31
+ qx_grid: Scan-frame frequency grid, shape (1, 1, Rx).
32
+ qy_grid: Scan-frame frequency grid, shape (1, Ry, 1).
33
+ cache: TCBFCache built by pipeline.build_tcbf_cache (basis only).
34
+ coeffs: Flat scan-frame aberration coefficients, shape (num_coeffs,).
35
+ device: Target device string.
36
+
37
+ Returns:
38
+ Reconstructed tcBF image, shape (Ry, Rx), float32.
39
+ """
40
+ neg_two_pi_j = torch.tensor(-2.0j * torch.pi, dtype=torch.complex64, device=device)
41
+ Ry_out = qy_grid.shape[-2]
42
+ Rx_out = qx_grid.shape[-1]
43
+
44
+ tcBF_total = torch.zeros((Ry_out, Rx_out), dtype=torch.float32, device=device)
45
+ for chunk in cache.chunks:
46
+ img_fft_chunk = provider.get_chunk(chunk['start'], chunk['end'])
47
+ shift_dx = torch.einsum('k, kb -> b', coeffs, chunk['b_dx']).view(-1, 1, 1)
48
+ shift_dy = torch.einsum('k, kb -> b', coeffs, chunk['b_dy']).view(-1, 1, 1)
49
+ ramp = shift_dx * qx_grid + shift_dy * qy_grid
50
+ shift_op = torch.exp(neg_two_pi_j * ramp)
51
+ tcBF_total += torch.sum(torch.fft.ifft2(img_fft_chunk * shift_op, dim=(-2, -1)).real, dim=0)
52
+
53
+ return tcBF_total
@@ -0,0 +1,5 @@
1
+ from fast_acbf.data.bf_extractor import BFExtractor
2
+ from fast_acbf.data.bf_preparer import BFPreparer
3
+ from fast_acbf.data.imagefft import ImageFFT
4
+
5
+ __all__ = ["BFExtractor", "BFPreparer", "ImageFFT"]