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.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
+ )