sem2surface 0.2.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.
- sem2surface-0.2.0.dist-info/METADATA +306 -0
- sem2surface-0.2.0.dist-info/RECORD +9 -0
- sem2surface-0.2.0.dist-info/WHEEL +5 -0
- sem2surface-0.2.0.dist-info/entry_points.txt +5 -0
- sem2surface-0.2.0.dist-info/licenses/LICENSE +30 -0
- sem2surface-0.2.0.dist-info/top_level.txt +3 -0
- sem2surface.py +724 -0
- sem2surface_cli.py +108 -0
- sem2surface_gui.py +473 -0
sem2surface.py
ADDED
|
@@ -0,0 +1,724 @@
|
|
|
1
|
+
"""Core routines for reconstructing surfaces from multi-detector SEM images."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime as _datetime
|
|
6
|
+
import re
|
|
7
|
+
import warnings
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import IO, Sequence
|
|
10
|
+
|
|
11
|
+
import matplotlib
|
|
12
|
+
|
|
13
|
+
# The core only writes figures. A non-interactive backend also makes the module
|
|
14
|
+
# safe to call from the GUI worker thread and on headless machines.
|
|
15
|
+
matplotlib.use("Agg")
|
|
16
|
+
|
|
17
|
+
import matplotlib.gridspec as gridspec
|
|
18
|
+
import matplotlib.pyplot as plt
|
|
19
|
+
import numpy as np
|
|
20
|
+
from mpl_toolkits.axes_grid1 import make_axes_locatable
|
|
21
|
+
from PIL import Image
|
|
22
|
+
from scipy.ndimage import gaussian_filter
|
|
23
|
+
from skimage.transform import radon
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__version__ = "0.2.0"
|
|
27
|
+
DEFAULT_PIXEL_SIZE = 1e-6
|
|
28
|
+
_PIXEL_WIDTH_TAGS = ("PixelWidth=", "Image Pixel Size =")
|
|
29
|
+
|
|
30
|
+
plt.rcParams["font.family"] = "serif"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def write_vtk(filename: str | Path, X: np.ndarray, Y: np.ndarray, z: np.ndarray) -> None:
|
|
34
|
+
"""Write a surface as a VTK XML structured grid."""
|
|
35
|
+
try:
|
|
36
|
+
import vtk
|
|
37
|
+
except ImportError as exc: # pragma: no cover - optional package
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"VTK export requires: pip install 'sem2surface[vtk]'"
|
|
40
|
+
) from exc
|
|
41
|
+
|
|
42
|
+
X, Y, Z = np.asarray(X), np.asarray(Y), np.asarray(z)
|
|
43
|
+
if not (X.shape == Y.shape == Z.shape):
|
|
44
|
+
raise ValueError("X, Y, and Z must have the same dimensions")
|
|
45
|
+
if X.ndim != 2:
|
|
46
|
+
raise ValueError("X, Y, and Z must be two-dimensional")
|
|
47
|
+
|
|
48
|
+
ny, nx = X.shape
|
|
49
|
+
points = vtk.vtkPoints()
|
|
50
|
+
for j in range(ny):
|
|
51
|
+
for i in range(nx):
|
|
52
|
+
points.InsertNextPoint(float(X[j, i]), float(Y[j, i]), float(Z[j, i]))
|
|
53
|
+
|
|
54
|
+
grid = vtk.vtkStructuredGrid()
|
|
55
|
+
grid.SetDimensions(nx, ny, 1)
|
|
56
|
+
grid.SetPoints(points)
|
|
57
|
+
|
|
58
|
+
z_values = vtk.vtkDoubleArray()
|
|
59
|
+
z_values.SetName("Z-Value")
|
|
60
|
+
z_values.SetNumberOfComponents(1)
|
|
61
|
+
z_values.SetNumberOfTuples(nx * ny)
|
|
62
|
+
for j in range(ny):
|
|
63
|
+
for i in range(nx):
|
|
64
|
+
z_values.SetValue(j * nx + i, float(Z[j, i]))
|
|
65
|
+
grid.GetPointData().SetScalars(z_values)
|
|
66
|
+
|
|
67
|
+
writer = vtk.vtkXMLStructuredGridWriter()
|
|
68
|
+
writer.SetFileName(str(filename))
|
|
69
|
+
writer.SetInputData(grid)
|
|
70
|
+
if writer.Write() != 1:
|
|
71
|
+
raise OSError(f"VTK failed to write {filename}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def log(log_file: IO[str], text: str) -> None:
|
|
75
|
+
"""Write a message to both stdout and the reconstruction log."""
|
|
76
|
+
print("* " + text)
|
|
77
|
+
log_file.write(text + "\n")
|
|
78
|
+
log_file.flush()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def parabolic_surface(params: Sequence[float], X: np.ndarray, Y: np.ndarray) -> np.ndarray:
|
|
82
|
+
"""Return a centred separable paraboloid with radii ``a`` and ``b``."""
|
|
83
|
+
a, b, c = params
|
|
84
|
+
x0 = 0.5 * (np.max(X) + np.min(X))
|
|
85
|
+
y0 = 0.5 * (np.max(Y) + np.min(Y))
|
|
86
|
+
return (X - x0) ** 2 / (2 * a) + (Y - y0) ** 2 / (2 * b) + c
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def objective_function(
|
|
90
|
+
params: Sequence[float], X: np.ndarray, Y: np.ndarray, Z: np.ndarray
|
|
91
|
+
) -> float:
|
|
92
|
+
return float(np.sum((Z - parabolic_surface(params, X, Y)) ** 2))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def remove_outside_central_circle(img: np.ndarray) -> np.ndarray:
|
|
96
|
+
"""Return a copy with pixels outside the largest central circle set to zero."""
|
|
97
|
+
modified = np.asarray(img).copy()
|
|
98
|
+
rows, columns = np.ogrid[: modified.shape[0], : modified.shape[1]]
|
|
99
|
+
radius = min(modified.shape) // 2
|
|
100
|
+
center_row = modified.shape[0] // 2
|
|
101
|
+
center_column = modified.shape[1] // 2
|
|
102
|
+
outside = (rows - center_row) ** 2 + (columns - center_column) ** 2 > radius**2
|
|
103
|
+
modified[outside] = 0
|
|
104
|
+
return modified
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_length(value: str) -> float | None:
|
|
108
|
+
match = re.search(
|
|
109
|
+
r"([-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?)\s*([a-zA-Zµμ]*)",
|
|
110
|
+
value,
|
|
111
|
+
)
|
|
112
|
+
if not match:
|
|
113
|
+
return None
|
|
114
|
+
magnitude = float(match.group(1))
|
|
115
|
+
unit = match.group(2).lower().replace("μ", "µ")
|
|
116
|
+
factors = {
|
|
117
|
+
"": 1.0,
|
|
118
|
+
"m": 1.0,
|
|
119
|
+
"meter": 1.0,
|
|
120
|
+
"meters": 1.0,
|
|
121
|
+
"nm": 1e-9,
|
|
122
|
+
"nanometer": 1e-9,
|
|
123
|
+
"nanometers": 1e-9,
|
|
124
|
+
"um": 1e-6,
|
|
125
|
+
"µm": 1e-6,
|
|
126
|
+
"micron": 1e-6,
|
|
127
|
+
"microns": 1e-6,
|
|
128
|
+
"micrometer": 1e-6,
|
|
129
|
+
"micrometers": 1e-6,
|
|
130
|
+
"mm": 1e-3,
|
|
131
|
+
"cm": 1e-2,
|
|
132
|
+
}
|
|
133
|
+
factor = factors.get(unit)
|
|
134
|
+
if factor is None:
|
|
135
|
+
return None
|
|
136
|
+
result = magnitude * factor
|
|
137
|
+
return result if np.isfinite(result) and result > 0 else None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_pixel_width(filename: str | Path) -> float:
|
|
141
|
+
"""Read the physical pixel width, in metres, from SEM TIFF metadata."""
|
|
142
|
+
path = Path(filename)
|
|
143
|
+
content = path.read_bytes().decode("ISO-8859-1")
|
|
144
|
+
for tag in _PIXEL_WIDTH_TAGS:
|
|
145
|
+
start = content.find(tag)
|
|
146
|
+
if start < 0:
|
|
147
|
+
continue
|
|
148
|
+
start += len(tag)
|
|
149
|
+
ends = [
|
|
150
|
+
position
|
|
151
|
+
for position in (content.find("\n", start), content.find("\x00", start))
|
|
152
|
+
if position >= 0
|
|
153
|
+
]
|
|
154
|
+
raw_value = content[start : min(ends, default=len(content))].strip()
|
|
155
|
+
value = _parse_length(raw_value)
|
|
156
|
+
if value is not None:
|
|
157
|
+
return value
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"No physical pixel width was found in {path.name}; enter it manually."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def reconstruct_surface_fft(
|
|
164
|
+
gradient_rows: np.ndarray, gradient_columns: np.ndarray, cutoff: float = 0.0
|
|
165
|
+
) -> np.ndarray:
|
|
166
|
+
"""Integrate two gradients using the Frankot-Chellappa FFT method.
|
|
167
|
+
|
|
168
|
+
``cutoff`` is a fraction of the Nyquist frequency and must be in [0, 1].
|
|
169
|
+
Historical frequency normalization is retained so existing calibration
|
|
170
|
+
factors remain valid, while ``fftfreq`` fixes odd-sized images.
|
|
171
|
+
"""
|
|
172
|
+
gx = np.asarray(gradient_rows, dtype=np.float64)
|
|
173
|
+
gy = np.asarray(gradient_columns, dtype=np.float64)
|
|
174
|
+
if gx.shape != gy.shape or gx.ndim != 2:
|
|
175
|
+
raise ValueError("Both gradients must be two-dimensional and have the same shape")
|
|
176
|
+
if not 0 <= cutoff <= 1:
|
|
177
|
+
raise ValueError("FFT cutoff must be between 0 and 1")
|
|
178
|
+
|
|
179
|
+
rows, columns = gx.shape
|
|
180
|
+
row_frequencies = np.fft.fftfreq(rows) * rows
|
|
181
|
+
column_frequencies = np.fft.fftfreq(columns) * columns
|
|
182
|
+
k_columns, k_rows = np.meshgrid(column_frequencies, row_frequencies)
|
|
183
|
+
|
|
184
|
+
transformed_x = np.fft.fft2(gx)
|
|
185
|
+
transformed_y = np.fft.fft2(gy)
|
|
186
|
+
if cutoff > 0:
|
|
187
|
+
cutoff_squared = (min(rows, columns) * cutoff / 2) ** 2
|
|
188
|
+
high_frequency = k_rows**2 + k_columns**2 > cutoff_squared
|
|
189
|
+
transformed_x[high_frequency] = 0
|
|
190
|
+
transformed_y[high_frequency] = 0
|
|
191
|
+
|
|
192
|
+
denominator = k_rows**2 + k_columns**2
|
|
193
|
+
transformed_surface = np.zeros_like(transformed_x, dtype=np.complex128)
|
|
194
|
+
nonzero = denominator > 0
|
|
195
|
+
transformed_surface[nonzero] = (
|
|
196
|
+
-1j
|
|
197
|
+
* (
|
|
198
|
+
k_rows[nonzero] * transformed_x[nonzero]
|
|
199
|
+
+ k_columns[nonzero] * transformed_y[nonzero]
|
|
200
|
+
)
|
|
201
|
+
/ denominator[nonzero]
|
|
202
|
+
)
|
|
203
|
+
surface = np.fft.ifft2(transformed_surface).real
|
|
204
|
+
return surface - np.mean(surface)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
# Backwards-compatible spelling used by v0.1 scripts.
|
|
208
|
+
reconstruct_surface_FFT = reconstruct_surface_fft
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def compute_image_gradients(
|
|
212
|
+
imgs: Sequence[np.ndarray],
|
|
213
|
+
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
214
|
+
"""Compute the intensity image and two normalized principal images."""
|
|
215
|
+
if len(imgs) < 3:
|
|
216
|
+
raise ValueError("At least three detector images are required")
|
|
217
|
+
shapes = {np.asarray(img).shape for img in imgs}
|
|
218
|
+
if len(shapes) != 1:
|
|
219
|
+
raise ValueError("All input images must have the same shape")
|
|
220
|
+
if len(next(iter(shapes))) != 2:
|
|
221
|
+
raise ValueError("Detector images must be two-dimensional")
|
|
222
|
+
|
|
223
|
+
image_stack = np.asarray(imgs, dtype=np.float64)
|
|
224
|
+
image_matrix = image_stack.reshape(image_stack.shape[0], -1)
|
|
225
|
+
correlation = image_matrix @ image_matrix.T
|
|
226
|
+
eigenvectors, _, _ = np.linalg.svd(correlation, full_matrices=False)
|
|
227
|
+
|
|
228
|
+
# Eigenvector signs are arbitrary. Fix each one by its largest loading so
|
|
229
|
+
# different BLAS implementations produce consistent output.
|
|
230
|
+
for component in range(3):
|
|
231
|
+
pivot = int(np.argmax(np.abs(eigenvectors[:, component])))
|
|
232
|
+
if eigenvectors[pivot, component] < 0:
|
|
233
|
+
eigenvectors[:, component] *= -1
|
|
234
|
+
|
|
235
|
+
principal = np.tensordot(eigenvectors[:, :3].T, image_stack, axes=(1, 0))
|
|
236
|
+
intensity, component_1, component_2 = principal
|
|
237
|
+
if np.mean(intensity) < 0:
|
|
238
|
+
intensity *= -1
|
|
239
|
+
component_1 *= -1
|
|
240
|
+
component_2 *= -1
|
|
241
|
+
|
|
242
|
+
threshold = max(float(np.max(np.abs(intensity))) * 1e-12, np.finfo(float).tiny)
|
|
243
|
+
signs = np.where(intensity < 0, -1.0, 1.0)
|
|
244
|
+
safe_intensity = np.where(np.abs(intensity) > threshold, intensity, signs * threshold)
|
|
245
|
+
return intensity, component_1 / safe_intensity, component_2 / safe_intensity
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def convert_to_grayscale(img: np.ndarray) -> np.ndarray:
|
|
249
|
+
"""Convert an RGB/RGBA array to floating-point grayscale."""
|
|
250
|
+
array = np.asarray(img)
|
|
251
|
+
if array.ndim == 2:
|
|
252
|
+
return array
|
|
253
|
+
if array.ndim != 3 or array.shape[2] < 3:
|
|
254
|
+
raise ValueError(f"Unsupported image shape: {array.shape}")
|
|
255
|
+
return np.dot(array[..., :3], [0.299, 0.587, 0.114])
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _read_image(path: Path) -> np.ndarray:
|
|
259
|
+
with Image.open(path) as image:
|
|
260
|
+
array = np.asarray(image)
|
|
261
|
+
return np.asarray(convert_to_grayscale(array), dtype=np.float64)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _plot_image_decomposition(
|
|
265
|
+
imgs: np.ndarray,
|
|
266
|
+
intensity: np.ndarray,
|
|
267
|
+
gradient_1: np.ndarray,
|
|
268
|
+
gradient_2: np.ndarray,
|
|
269
|
+
filename: Path,
|
|
270
|
+
) -> None:
|
|
271
|
+
columns = max(len(imgs), 3)
|
|
272
|
+
fig, axes = plt.subplots(2, columns, figsize=(4 * columns, 8), squeeze=False)
|
|
273
|
+
for axis in axes.flat:
|
|
274
|
+
axis.set_axis_off()
|
|
275
|
+
for index, image in enumerate(imgs):
|
|
276
|
+
axes[0, index].imshow(image, cmap="gray")
|
|
277
|
+
axes[0, index].set_title(f"Detector image {index + 1}")
|
|
278
|
+
components = (
|
|
279
|
+
(intensity, "Principal intensity image"),
|
|
280
|
+
(gradient_1, "Normalized principal image 2"),
|
|
281
|
+
(gradient_2, "Normalized principal image 3"),
|
|
282
|
+
)
|
|
283
|
+
for index, (image, title) in enumerate(components):
|
|
284
|
+
axes[1, index].imshow(image)
|
|
285
|
+
axes[1, index].set_title(title)
|
|
286
|
+
fig.tight_layout()
|
|
287
|
+
fig.savefig(filename, dpi=300)
|
|
288
|
+
plt.close(fig)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _plot_radon_rms(
|
|
292
|
+
angles: np.ndarray,
|
|
293
|
+
rms: np.ndarray,
|
|
294
|
+
theta_1: float,
|
|
295
|
+
theta_2: float,
|
|
296
|
+
filename: Path,
|
|
297
|
+
) -> None:
|
|
298
|
+
fig, axis = plt.subplots(figsize=(6, 4))
|
|
299
|
+
maximum = float(np.max(rms))
|
|
300
|
+
normalized = rms / maximum if maximum > 0 else np.zeros_like(rms)
|
|
301
|
+
axis.plot(angles, normalized, "k-", label="Radon RMS")
|
|
302
|
+
axis.scatter(angles, normalized, c="green", marker="o", s=20, zorder=10)
|
|
303
|
+
axis.set_ylim(None, 1.0)
|
|
304
|
+
for angle, height, label in ((theta_1, 0.5, "1"), (theta_2, 0.1, "2")):
|
|
305
|
+
axis.axvline(x=angle, color="k", linestyle="--")
|
|
306
|
+
axis.text(angle + 2, height, f"theta_{label} = {angle:.2f} deg", color="k")
|
|
307
|
+
axis.set_xlim(0, 180)
|
|
308
|
+
axis.set_xlabel("Angle (degrees)")
|
|
309
|
+
axis.set_ylabel("Normalized Radon RMS value")
|
|
310
|
+
axis.set_title("Radon transform RMS vs angle")
|
|
311
|
+
axis.grid(True, linestyle="--", alpha=0.7)
|
|
312
|
+
axis.legend()
|
|
313
|
+
fig.tight_layout()
|
|
314
|
+
fig.savefig(filename)
|
|
315
|
+
plt.close(fig)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _find_principal_angle(
|
|
319
|
+
gradient: np.ndarray, log_file: IO[str]
|
|
320
|
+
) -> tuple[float, np.ndarray, np.ndarray]:
|
|
321
|
+
dissection = 10
|
|
322
|
+
angle_start = 0.0
|
|
323
|
+
angle_end = 180.0
|
|
324
|
+
all_angles: list[float] = []
|
|
325
|
+
all_rms: list[float] = []
|
|
326
|
+
circular_gradient = remove_outside_central_circle(gradient)
|
|
327
|
+
|
|
328
|
+
for iteration in range(5):
|
|
329
|
+
log(
|
|
330
|
+
log_file,
|
|
331
|
+
f" / Radon search: iteration {iteration} "
|
|
332
|
+
f"angle_start = {angle_start} angle_end = {angle_end}",
|
|
333
|
+
)
|
|
334
|
+
theta = np.linspace(angle_start, angle_end, dissection, endpoint=False)
|
|
335
|
+
transformed = radon(circular_gradient, theta=theta, circle=True)
|
|
336
|
+
rms = np.sum((transformed - np.mean(transformed, axis=0)) ** 2, axis=0)
|
|
337
|
+
all_angles.extend(theta.tolist())
|
|
338
|
+
all_rms.extend(rms.tolist())
|
|
339
|
+
theta_1 = float(theta[np.argmin(rms)])
|
|
340
|
+
# Preserve the v0.1 refinement trajectory: the published calibration
|
|
341
|
+
# examples and their scaling factors were obtained with this narrowing
|
|
342
|
+
# rule. Changing it would silently require recalibration.
|
|
343
|
+
previous_end = angle_end
|
|
344
|
+
angle_start = theta_1 - 2 * (previous_end - angle_start) / dissection
|
|
345
|
+
angle_end = theta_1 + 2 * (previous_end - angle_start) / dissection
|
|
346
|
+
|
|
347
|
+
theta_1 %= 180.0
|
|
348
|
+
angles = np.mod(np.asarray(all_angles), 180.0)
|
|
349
|
+
values = np.asarray(all_rms)
|
|
350
|
+
order = np.argsort(angles)
|
|
351
|
+
return theta_1, angles[order], values[order]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _remove_curvature(
|
|
355
|
+
z: np.ndarray,
|
|
356
|
+
X: np.ndarray,
|
|
357
|
+
Y: np.ndarray,
|
|
358
|
+
mode: str,
|
|
359
|
+
manual_rx: float | None,
|
|
360
|
+
manual_ry: float | None,
|
|
361
|
+
log_file: IO[str],
|
|
362
|
+
) -> tuple[np.ndarray, str]:
|
|
363
|
+
if mode == "manual":
|
|
364
|
+
if manual_rx is None or manual_ry is None or manual_rx == 0 or manual_ry == 0:
|
|
365
|
+
raise ValueError("Manual curvature correction requires nonzero Rx and Ry")
|
|
366
|
+
rx_um = manual_rx * 1e6
|
|
367
|
+
ry_um = manual_ry * 1e6
|
|
368
|
+
base = parabolic_surface((rx_um, ry_um, 0.0), X, Y)
|
|
369
|
+
shift = float(np.mean(z - base))
|
|
370
|
+
result = z - parabolic_surface((rx_um, ry_um, shift), X, Y)
|
|
371
|
+
log(
|
|
372
|
+
log_file,
|
|
373
|
+
f"Curvature manually removed: Rx = {manual_rx:.2e} m, "
|
|
374
|
+
f"Ry = {manual_ry:.2e} m, optimal dz = {shift:.2f} um",
|
|
375
|
+
)
|
|
376
|
+
return result, ""
|
|
377
|
+
if mode != "automatic":
|
|
378
|
+
raise ValueError("Curvature mode must be 'automatic' or 'manual'")
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
center_x = 0.5 * (np.max(X) + np.min(X))
|
|
382
|
+
center_y = 0.5 * (np.max(Y) + np.min(Y))
|
|
383
|
+
scale_x = max(0.5 * (np.max(X) - np.min(X)), 1.0)
|
|
384
|
+
scale_y = max(0.5 * (np.max(Y) - np.min(Y)), 1.0)
|
|
385
|
+
x_squared = ((X - center_x) / scale_x) ** 2
|
|
386
|
+
y_squared = ((Y - center_y) / scale_y) ** 2
|
|
387
|
+
design = np.column_stack(
|
|
388
|
+
(x_squared.ravel(), y_squared.ravel(), np.ones(z.size))
|
|
389
|
+
)
|
|
390
|
+
coefficients, _, rank, _ = np.linalg.lstsq(design, z.ravel(), rcond=None)
|
|
391
|
+
if rank < 3 or not np.all(np.isfinite(coefficients)):
|
|
392
|
+
raise RuntimeError("the paraboloid fit is rank-deficient")
|
|
393
|
+
coefficient_x = coefficients[0] / scale_x**2
|
|
394
|
+
coefficient_y = coefficients[1] / scale_y**2
|
|
395
|
+
if coefficient_x == 0 or coefficient_y == 0:
|
|
396
|
+
raise RuntimeError("the fitted curvature is zero")
|
|
397
|
+
rx_fit = 1.0 / (2.0 * coefficient_x)
|
|
398
|
+
ry_fit = 1.0 / (2.0 * coefficient_y)
|
|
399
|
+
dz_fit = float(coefficients[2])
|
|
400
|
+
fitted_surface = (
|
|
401
|
+
coefficients[0] * x_squared
|
|
402
|
+
+ coefficients[1] * y_squared
|
|
403
|
+
+ coefficients[2]
|
|
404
|
+
)
|
|
405
|
+
result = z - fitted_surface
|
|
406
|
+
if rx_fit * ry_fit < 0:
|
|
407
|
+
message = (
|
|
408
|
+
"Warning: Wrong order of images. The result is not reliable. "
|
|
409
|
+
"Reshuffle images and run again."
|
|
410
|
+
)
|
|
411
|
+
log(log_file, f"WARNING: {message} Rx = {rx_fit:.2f} um, Ry = {ry_fit:.2f} um")
|
|
412
|
+
else:
|
|
413
|
+
message = ""
|
|
414
|
+
log(
|
|
415
|
+
log_file,
|
|
416
|
+
f"Curvature removed: Rx = {rx_fit:.2f} um, Ry = {ry_fit:.2f} um, "
|
|
417
|
+
f"dz = {dz_fit:.2f} um",
|
|
418
|
+
)
|
|
419
|
+
if rx_fit > 0 and ry_fit > 0:
|
|
420
|
+
result *= -1
|
|
421
|
+
log(log_file, "The reconstructed surface was flipped.")
|
|
422
|
+
return result, message
|
|
423
|
+
except (RuntimeError, ValueError, FloatingPointError) as exc:
|
|
424
|
+
log(log_file, f"Curvature removal skipped because fitting failed: {exc}")
|
|
425
|
+
return z, f"Warning: curvature removal failed: {exc}"
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def construct_surface(
|
|
429
|
+
img_names: Sequence[str | Path],
|
|
430
|
+
*,
|
|
431
|
+
plot_images_decomposition: bool = False,
|
|
432
|
+
gaussian_filter_enabled: bool = False,
|
|
433
|
+
sigma: float = 1.0,
|
|
434
|
+
remove_curvature: bool = False,
|
|
435
|
+
curvature_mode: str = "automatic",
|
|
436
|
+
manual_rx: float | None = None,
|
|
437
|
+
manual_ry: float | None = None,
|
|
438
|
+
cutoff_frequency: float = 0.0,
|
|
439
|
+
save_file_type: str = "",
|
|
440
|
+
time_stamp: bool = False,
|
|
441
|
+
pixelsize: float | None = None,
|
|
442
|
+
z_scaling_factor_per_pixel: float = 1.0,
|
|
443
|
+
output_dir: str | Path = ".",
|
|
444
|
+
log_file: IO[str] | None = None,
|
|
445
|
+
) -> tuple[str, np.ndarray, np.ndarray, np.ndarray, str]:
|
|
446
|
+
"""Reconstruct a surface from three or more detector images.
|
|
447
|
+
|
|
448
|
+
Pixel size and curvature radii are expressed in metres. Returned X, Y and Z
|
|
449
|
+
arrays are expressed in micrometres.
|
|
450
|
+
"""
|
|
451
|
+
paths = [Path(name).expanduser() for name in img_names]
|
|
452
|
+
if len(paths) < 3:
|
|
453
|
+
raise ValueError("At least three detector images are required")
|
|
454
|
+
missing = [str(path) for path in paths if not path.is_file()]
|
|
455
|
+
if missing:
|
|
456
|
+
raise FileNotFoundError("Input image(s) not found: " + ", ".join(missing))
|
|
457
|
+
if sigma < 0:
|
|
458
|
+
raise ValueError("Gaussian sigma cannot be negative")
|
|
459
|
+
if not 0 <= cutoff_frequency <= 1:
|
|
460
|
+
raise ValueError("FFT cutoff must be between 0 and 1")
|
|
461
|
+
if pixelsize is None:
|
|
462
|
+
pixelsize = get_pixel_width(paths[0])
|
|
463
|
+
if not np.isfinite(pixelsize) or pixelsize <= 0:
|
|
464
|
+
raise ValueError("Pixel size must be a positive number in metres")
|
|
465
|
+
if not np.isfinite(z_scaling_factor_per_pixel):
|
|
466
|
+
raise ValueError("Z scaling factor must be finite")
|
|
467
|
+
|
|
468
|
+
output_directory = Path(output_dir).expanduser()
|
|
469
|
+
output_directory.mkdir(parents=True, exist_ok=True)
|
|
470
|
+
timestamp = (
|
|
471
|
+
"_" + _datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
|
472
|
+
if time_stamp
|
|
473
|
+
else ""
|
|
474
|
+
)
|
|
475
|
+
owns_log = log_file is None
|
|
476
|
+
if log_file is None:
|
|
477
|
+
log_file = (output_directory / f"log{timestamp}.log").open("a", encoding="utf-8")
|
|
478
|
+
try:
|
|
479
|
+
return _construct_surface(
|
|
480
|
+
paths,
|
|
481
|
+
plot_images_decomposition=plot_images_decomposition,
|
|
482
|
+
gaussian_filter_enabled=gaussian_filter_enabled,
|
|
483
|
+
sigma=sigma,
|
|
484
|
+
remove_curvature=remove_curvature,
|
|
485
|
+
curvature_mode=curvature_mode,
|
|
486
|
+
manual_rx=manual_rx,
|
|
487
|
+
manual_ry=manual_ry,
|
|
488
|
+
cutoff_frequency=cutoff_frequency,
|
|
489
|
+
save_file_type=save_file_type,
|
|
490
|
+
time_stamp=timestamp,
|
|
491
|
+
pixelsize=float(pixelsize),
|
|
492
|
+
z_scaling_factor_per_pixel=float(z_scaling_factor_per_pixel),
|
|
493
|
+
output_directory=output_directory,
|
|
494
|
+
log_file=log_file,
|
|
495
|
+
)
|
|
496
|
+
finally:
|
|
497
|
+
if owns_log:
|
|
498
|
+
log_file.close()
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _construct_surface(
|
|
502
|
+
paths: Sequence[Path],
|
|
503
|
+
*,
|
|
504
|
+
plot_images_decomposition: bool,
|
|
505
|
+
gaussian_filter_enabled: bool,
|
|
506
|
+
sigma: float,
|
|
507
|
+
remove_curvature: bool,
|
|
508
|
+
curvature_mode: str,
|
|
509
|
+
manual_rx: float | None,
|
|
510
|
+
manual_ry: float | None,
|
|
511
|
+
cutoff_frequency: float,
|
|
512
|
+
save_file_type: str,
|
|
513
|
+
time_stamp: str,
|
|
514
|
+
pixelsize: float,
|
|
515
|
+
z_scaling_factor_per_pixel: float,
|
|
516
|
+
output_directory: Path,
|
|
517
|
+
log_file: IO[str],
|
|
518
|
+
) -> tuple[str, np.ndarray, np.ndarray, np.ndarray, str]:
|
|
519
|
+
log(log_file, f"All output is saved in {output_directory.resolve()}")
|
|
520
|
+
log(log_file, "Parameters:")
|
|
521
|
+
log(log_file, f" / Detector images = {len(paths)}")
|
|
522
|
+
log(log_file, f" / Plot intermediate images = {plot_images_decomposition}")
|
|
523
|
+
log(log_file, f" / Gaussian filter = {gaussian_filter_enabled}")
|
|
524
|
+
log(log_file, f" / Gaussian sigma = {sigma}")
|
|
525
|
+
log(log_file, f" / Remove curvature = {remove_curvature}")
|
|
526
|
+
log(log_file, f" / FFT cutoff frequency = {cutoff_frequency}")
|
|
527
|
+
log(log_file, f" / Output file type = {save_file_type or 'do not save'}")
|
|
528
|
+
log(log_file, f" / Pixel size = {pixelsize} m")
|
|
529
|
+
log(log_file, f" / Z scaling factor per pixel = {z_scaling_factor_per_pixel} 1/m")
|
|
530
|
+
log(log_file, f"Images folder: {paths[0].resolve().parent}")
|
|
531
|
+
log(log_file, "Image names:")
|
|
532
|
+
for path in paths:
|
|
533
|
+
log(log_file, f" / {path.name}")
|
|
534
|
+
|
|
535
|
+
images = [_read_image(path) for path in paths]
|
|
536
|
+
widths = {image.shape[1] for image in images}
|
|
537
|
+
if len(widths) != 1:
|
|
538
|
+
raise ValueError("All detector images must have the same width")
|
|
539
|
+
|
|
540
|
+
# Preserve the footer marker used by the reference data while applying the
|
|
541
|
+
# same crop safely to every detector image.
|
|
542
|
+
crop_rows: list[int] = []
|
|
543
|
+
for image in images:
|
|
544
|
+
crop = image.shape[0]
|
|
545
|
+
row_means = np.mean(image, axis=1)
|
|
546
|
+
marker = np.flatnonzero(np.abs(row_means[1:] - 1437.0) < 2)
|
|
547
|
+
if marker.size:
|
|
548
|
+
crop = max(1, int(marker[0]))
|
|
549
|
+
crop_rows.append(crop)
|
|
550
|
+
cut_y = min(crop_rows)
|
|
551
|
+
if cut_y < 2:
|
|
552
|
+
raise ValueError("Automatic footer detection left too little image data")
|
|
553
|
+
log(log_file, f"SEM data rows retained: {cut_y}")
|
|
554
|
+
image_stack = np.stack(
|
|
555
|
+
[np.nan_to_num(image[:cut_y, :], copy=False) for image in images], axis=0
|
|
556
|
+
)
|
|
557
|
+
|
|
558
|
+
if gaussian_filter_enabled and sigma > 0:
|
|
559
|
+
image_stack = gaussian_filter(image_stack, sigma=(0, sigma, sigma))
|
|
560
|
+
log(log_file, f"Gaussian filter with sigma = {sigma} applied to all images.")
|
|
561
|
+
|
|
562
|
+
intensity, gradient_1, gradient_2 = compute_image_gradients(image_stack)
|
|
563
|
+
if plot_images_decomposition:
|
|
564
|
+
filename = output_directory / f"Images_decomposition{time_stamp}.png"
|
|
565
|
+
_plot_image_decomposition(image_stack, intensity, gradient_1, gradient_2, filename)
|
|
566
|
+
log(log_file, f"Images decomposition saved to {filename}")
|
|
567
|
+
|
|
568
|
+
theta_1, angles, rms = _find_principal_angle(gradient_1, log_file)
|
|
569
|
+
theta_2 = (theta_1 + 90.0) % 180.0
|
|
570
|
+
log(log_file, f"theta1 = {theta_1}")
|
|
571
|
+
log(log_file, f"theta2 = {theta_2}")
|
|
572
|
+
if plot_images_decomposition:
|
|
573
|
+
filename = output_directory / f"RadonTransformRMS{time_stamp}.pdf"
|
|
574
|
+
_plot_radon_rms(angles, rms, theta_1, theta_2, filename)
|
|
575
|
+
log(log_file, f"Radon transform RMS saved to {filename}")
|
|
576
|
+
|
|
577
|
+
radians_1, radians_2 = np.deg2rad((theta_1, theta_2))
|
|
578
|
+
gradient_rows = np.cos(radians_1) * gradient_1 + np.cos(radians_2) * gradient_2
|
|
579
|
+
gradient_columns = np.sin(radians_1) * gradient_1 + np.sin(radians_2) * gradient_2
|
|
580
|
+
gradient_rows -= np.mean(gradient_rows)
|
|
581
|
+
gradient_columns -= np.mean(gradient_columns)
|
|
582
|
+
|
|
583
|
+
if plot_images_decomposition:
|
|
584
|
+
figure = plt.figure(figsize=(15, 8))
|
|
585
|
+
grid = gridspec.GridSpec(2, 2, height_ratios=[1, 0.05])
|
|
586
|
+
for index, (gradient, title) in enumerate(
|
|
587
|
+
((gradient_rows, "Gradient along rows"), (gradient_columns, "Gradient along columns"))
|
|
588
|
+
):
|
|
589
|
+
axis = figure.add_subplot(grid[0, index])
|
|
590
|
+
color_axis = figure.add_subplot(grid[1, index])
|
|
591
|
+
shown = axis.imshow(gradient)
|
|
592
|
+
figure.colorbar(shown, cax=color_axis, orientation="horizontal")
|
|
593
|
+
axis.set_title(title)
|
|
594
|
+
figure.tight_layout()
|
|
595
|
+
filename = output_directory / f"Gradients{time_stamp}.png"
|
|
596
|
+
figure.savefig(filename, dpi=300)
|
|
597
|
+
plt.close(figure)
|
|
598
|
+
log(log_file, f"Gradient images saved to {filename}")
|
|
599
|
+
|
|
600
|
+
z = reconstruct_surface_fft(gradient_rows, gradient_columns, cutoff_frequency)
|
|
601
|
+
z *= 1e6 * z_scaling_factor_per_pixel * pixelsize
|
|
602
|
+
|
|
603
|
+
rows, columns = z.shape
|
|
604
|
+
pre_x, pre_y = np.meshgrid(
|
|
605
|
+
np.arange(columns) * pixelsize * 1e6,
|
|
606
|
+
np.arange(rows) * pixelsize * 1e6,
|
|
607
|
+
)
|
|
608
|
+
return_message = ""
|
|
609
|
+
if remove_curvature:
|
|
610
|
+
z, return_message = _remove_curvature(
|
|
611
|
+
z, pre_x, pre_y, curvature_mode, manual_rx, manual_ry, log_file
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
z = np.rot90(z)
|
|
615
|
+
x = np.arange(z.shape[1]) * pixelsize * 1e6
|
|
616
|
+
y = np.arange(z.shape[0]) * pixelsize * 1e6
|
|
617
|
+
X, Y = np.meshgrid(x, y)
|
|
618
|
+
|
|
619
|
+
figure, axis = plt.subplots(figsize=(8, 10))
|
|
620
|
+
extent = [x[-1] if x.size else 0, 0, 0, y[-1] if y.size else 0]
|
|
621
|
+
shown = axis.imshow(z, extent=extent, interpolation="none")
|
|
622
|
+
axis.set_xlabel("y (micrometres)")
|
|
623
|
+
axis.set_ylabel("x (micrometres)")
|
|
624
|
+
axis.set_title("Reconstructed surface")
|
|
625
|
+
divider = make_axes_locatable(axis)
|
|
626
|
+
color_axis = divider.append_axes("top", size="5%", pad=0.5)
|
|
627
|
+
colorbar = figure.colorbar(shown, cax=color_axis, orientation="horizontal")
|
|
628
|
+
colorbar.set_label("z (micrometres)")
|
|
629
|
+
color_axis.xaxis.set_ticks_position("top")
|
|
630
|
+
color_axis.xaxis.set_label_position("top")
|
|
631
|
+
figure.tight_layout()
|
|
632
|
+
surface_image = output_directory / f"Surface_FFT{time_stamp}.png"
|
|
633
|
+
figure.savefig(surface_image, dpi=300, bbox_inches="tight")
|
|
634
|
+
plt.close(figure)
|
|
635
|
+
log(log_file, f"FFT-reconstructed surface saved to {surface_image}")
|
|
636
|
+
|
|
637
|
+
figure, axis = plt.subplots(figsize=(8, 8 * z.shape[0] / z.shape[1]))
|
|
638
|
+
axis.imshow(z, cmap="gray")
|
|
639
|
+
axis.set_axis_off()
|
|
640
|
+
axis.set_aspect("auto")
|
|
641
|
+
figure.subplots_adjust(left=0, right=1, top=1, bottom=0)
|
|
642
|
+
grayscale_image = output_directory / f"Surface_BW_FFT{time_stamp}.png"
|
|
643
|
+
figure.savefig(grayscale_image, dpi=300)
|
|
644
|
+
plt.close(figure)
|
|
645
|
+
|
|
646
|
+
output_type = save_file_type.strip().upper()
|
|
647
|
+
if output_type == "CSV":
|
|
648
|
+
filename = output_directory / f"Surface{time_stamp}.csv"
|
|
649
|
+
with filename.open("w", encoding="utf-8", newline="") as stream:
|
|
650
|
+
stream.write("# x (um), y (um), z (um)\n")
|
|
651
|
+
for row in range(z.shape[0]):
|
|
652
|
+
for column in range(z.shape[1]):
|
|
653
|
+
stream.write(
|
|
654
|
+
f"{X[row, column]:.6f},{Y[row, column]:.6f},"
|
|
655
|
+
f"{z[row, column]:.6f}\n"
|
|
656
|
+
)
|
|
657
|
+
log(log_file, f"Surface saved to {filename}")
|
|
658
|
+
elif output_type == "NPZ":
|
|
659
|
+
filename = output_directory / f"Surface{time_stamp}.npz"
|
|
660
|
+
np.savez(filename, X=X, Y=Y, Z=z)
|
|
661
|
+
log(log_file, f"Surface saved to {filename}")
|
|
662
|
+
elif output_type == "VTK":
|
|
663
|
+
filename = output_directory / f"Surface{time_stamp}.vts"
|
|
664
|
+
write_vtk(filename, X, Y, z)
|
|
665
|
+
log(log_file, f"Surface saved to {filename}")
|
|
666
|
+
elif output_type not in ("", "DO NOT SAVE", "NONE"):
|
|
667
|
+
raise ValueError("Output type must be CSV, NPZ, VTK, or empty")
|
|
668
|
+
else:
|
|
669
|
+
log(log_file, "Surface data were not saved")
|
|
670
|
+
|
|
671
|
+
log(log_file, f"RMS of the surface = {np.std(z)}")
|
|
672
|
+
log(
|
|
673
|
+
log_file,
|
|
674
|
+
"Successfully finished at " + _datetime.datetime.now().isoformat(timespec="seconds"),
|
|
675
|
+
)
|
|
676
|
+
return str(surface_image), X, Y, z, return_message
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def constructSurface(
|
|
680
|
+
imgNames: Sequence[str | Path],
|
|
681
|
+
Plot_images_decomposition: bool = False,
|
|
682
|
+
GaussFilter: bool = False,
|
|
683
|
+
sigma: float = 1.0,
|
|
684
|
+
ReconstructionMode: str = "FFT",
|
|
685
|
+
RemoveCurvature: bool = False,
|
|
686
|
+
curvature_mode: str = "automatic",
|
|
687
|
+
manual_rx: float | None = None,
|
|
688
|
+
manual_ry: float | None = None,
|
|
689
|
+
cutoff_frequency: float = 0.0,
|
|
690
|
+
save_file_type: str = "",
|
|
691
|
+
time_stamp: bool = False,
|
|
692
|
+
pixelsize: float | None = None,
|
|
693
|
+
ZscalingFactorPerPixel: float = 1.0,
|
|
694
|
+
Z_ref: float | None = None,
|
|
695
|
+
Z_current: float | None = None,
|
|
696
|
+
logFile: IO[str] | None = None,
|
|
697
|
+
output_dir: str | Path = ".",
|
|
698
|
+
):
|
|
699
|
+
"""Compatibility wrapper for the v0.1 camelCase API."""
|
|
700
|
+
if ReconstructionMode != "FFT":
|
|
701
|
+
raise ValueError("Direct integration was removed; only FFT reconstruction is supported")
|
|
702
|
+
if Z_ref is not None or Z_current is not None:
|
|
703
|
+
warnings.warn(
|
|
704
|
+
"Z_ref and Z_current are deprecated and ignored; use the calibrated scaling factor only",
|
|
705
|
+
DeprecationWarning,
|
|
706
|
+
stacklevel=2,
|
|
707
|
+
)
|
|
708
|
+
return construct_surface(
|
|
709
|
+
imgNames,
|
|
710
|
+
plot_images_decomposition=Plot_images_decomposition,
|
|
711
|
+
gaussian_filter_enabled=GaussFilter,
|
|
712
|
+
sigma=sigma,
|
|
713
|
+
remove_curvature=RemoveCurvature,
|
|
714
|
+
curvature_mode=curvature_mode,
|
|
715
|
+
manual_rx=manual_rx,
|
|
716
|
+
manual_ry=manual_ry,
|
|
717
|
+
cutoff_frequency=cutoff_frequency,
|
|
718
|
+
save_file_type=save_file_type,
|
|
719
|
+
time_stamp=time_stamp,
|
|
720
|
+
pixelsize=pixelsize,
|
|
721
|
+
z_scaling_factor_per_pixel=ZscalingFactorPerPixel,
|
|
722
|
+
output_dir=output_dir,
|
|
723
|
+
log_file=logFile,
|
|
724
|
+
)
|