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 +24 -0
- fast_acbf/core/__init__.py +0 -0
- fast_acbf/core/aberrations.py +165 -0
- fast_acbf/core/acbf.py +226 -0
- fast_acbf/core/functional.py +190 -0
- fast_acbf/core/tcbf.py +53 -0
- fast_acbf/data/__init__.py +5 -0
- fast_acbf/data/bf_extractor.py +149 -0
- fast_acbf/data/bf_preparer.py +275 -0
- fast_acbf/data/dataset4d.py +252 -0
- fast_acbf/data/geometry.py +191 -0
- fast_acbf/data/imagefft.py +197 -0
- fast_acbf/optimization/__init__.py +0 -0
- fast_acbf/optimization/metrics.py +93 -0
- fast_acbf/optimization/refinement.py +919 -0
- fast_acbf/recon/__init__.py +0 -0
- fast_acbf/recon/cache.py +128 -0
- fast_acbf/recon/pipeline.py +376 -0
- fast_acbf/recon/reconstructor.py +311 -0
- fast_acbf/solver.py +890 -0
- fast_acbf/vis/__init__.py +0 -0
- fast_acbf/vis/plotting.py +190 -0
- fast_acbf-0.7.0.dist-info/METADATA +148 -0
- fast_acbf-0.7.0.dist-info/RECORD +27 -0
- fast_acbf-0.7.0.dist-info/WHEEL +5 -0
- fast_acbf-0.7.0.dist-info/licenses/LICENSE +165 -0
- fast_acbf-0.7.0.dist-info/top_level.txt +1 -0
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
|