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_cli.py ADDED
@@ -0,0 +1,108 @@
1
+ """Command-line interface for sem2surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from importlib.metadata import PackageNotFoundError, version
7
+ from pathlib import Path
8
+ from typing import Sequence
9
+
10
+
11
+ try:
12
+ VERSION = version("sem2surface")
13
+ except PackageNotFoundError: # Running directly from an unpacked source tree.
14
+ VERSION = "0.2.0"
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(
19
+ prog="sem2surface",
20
+ description="Reconstruct a 3D surface from three or more SEM/BSE detector images.",
21
+ )
22
+ parser.add_argument("images", nargs="+", type=Path, help="detector images in acquisition order")
23
+ parser.add_argument(
24
+ "--pixel-size-um",
25
+ type=float,
26
+ help="pixel size in micrometres; by default it is read from the first TIFF",
27
+ )
28
+ parser.add_argument(
29
+ "--z-scale",
30
+ type=float,
31
+ default=2.1727243e2,
32
+ help="calibrated Z scaling factor per pixel in 1/m (default: %(default)g)",
33
+ )
34
+ parser.add_argument(
35
+ "--cutoff",
36
+ type=float,
37
+ default=0.0,
38
+ metavar="FRACTION",
39
+ help="FFT cutoff as a fraction of Nyquist, from 0 to 1",
40
+ )
41
+ parser.add_argument(
42
+ "--gaussian-sigma",
43
+ type=float,
44
+ metavar="PIXELS",
45
+ help="enable Gaussian filtering with this standard deviation",
46
+ )
47
+ parser.add_argument(
48
+ "--curvature",
49
+ choices=("none", "automatic", "manual"),
50
+ default="automatic",
51
+ help="curvature correction mode (default: %(default)s)",
52
+ )
53
+ parser.add_argument("--rx", type=float, help="manual X curvature radius in metres")
54
+ parser.add_argument("--ry", type=float, help="manual Y curvature radius in metres")
55
+ parser.add_argument(
56
+ "--save",
57
+ choices=("none", "csv", "npz", "vtk"),
58
+ default="npz",
59
+ help="surface data format (default: %(default)s)",
60
+ )
61
+ parser.add_argument(
62
+ "--output-dir", type=Path, default=Path.cwd(), help="output directory"
63
+ )
64
+ parser.add_argument("--timestamp", action="store_true", help="timestamp output filenames")
65
+ parser.add_argument(
66
+ "--extra-images", action="store_true", help="save decomposition and gradient figures"
67
+ )
68
+ parser.add_argument("--version", action="version", version=f"%(prog)s {VERSION}")
69
+ return parser
70
+
71
+
72
+ def main(argv: Sequence[str] | None = None) -> int:
73
+ parser = build_parser()
74
+ args = parser.parse_args(argv)
75
+ if len(args.images) < 3:
76
+ parser.error("at least three detector images are required")
77
+ if args.pixel_size_um is not None and args.pixel_size_um <= 0:
78
+ parser.error("--pixel-size-um must be positive")
79
+ if args.curvature == "manual" and (args.rx is None or args.ry is None):
80
+ parser.error("manual curvature correction requires both --rx and --ry")
81
+
82
+ from sem2surface import construct_surface
83
+
84
+ pixelsize = args.pixel_size_um * 1e-6 if args.pixel_size_um is not None else None
85
+ image_name, _, _, _, warning = construct_surface(
86
+ args.images,
87
+ plot_images_decomposition=args.extra_images,
88
+ gaussian_filter_enabled=args.gaussian_sigma is not None,
89
+ sigma=args.gaussian_sigma or 0.0,
90
+ remove_curvature=args.curvature != "none",
91
+ curvature_mode=("automatic" if args.curvature == "none" else args.curvature),
92
+ manual_rx=args.rx,
93
+ manual_ry=args.ry,
94
+ cutoff_frequency=args.cutoff,
95
+ save_file_type="" if args.save == "none" else args.save,
96
+ time_stamp=args.timestamp,
97
+ pixelsize=pixelsize,
98
+ z_scaling_factor_per_pixel=args.z_scale,
99
+ output_dir=args.output_dir,
100
+ )
101
+ print(f"Reconstruction image: {image_name}")
102
+ if warning:
103
+ print(warning)
104
+ return 0
105
+
106
+
107
+ if __name__ == "__main__":
108
+ raise SystemExit(main())
sem2surface_gui.py ADDED
@@ -0,0 +1,473 @@
1
+ """Tkinter interface for sem2surface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import queue
6
+ import threading
7
+ import tkinter as tk
8
+ from pathlib import Path
9
+ from tkinter import filedialog, messagebox
10
+
11
+ import numpy as np
12
+ from PIL import Image, ImageTk
13
+
14
+ from sem2surface import construct_surface, get_pixel_width
15
+
16
+
17
+ DEFAULT_Z_SCALE = 2.1727243e2
18
+ DEFAULT_GAUSSIAN_SIGMA = 1.0
19
+
20
+
21
+ def _prepare_preview_image(source: Image.Image) -> Image.Image:
22
+ """Return an 8-bit RGB image suitable for display by Tk.
23
+
24
+ Pillow clips ``I;16`` SEM images when they are converted directly to RGB.
25
+ Normalizing the finite data range first preserves their visible contrast.
26
+ """
27
+ if source.mode == "F" or source.mode == "I" or source.mode.startswith("I;16"):
28
+ pixels = np.asarray(source, dtype=np.float64)
29
+ finite = np.isfinite(pixels)
30
+ preview = np.zeros(pixels.shape, dtype=np.uint8)
31
+ if finite.any():
32
+ minimum = float(pixels[finite].min())
33
+ maximum = float(pixels[finite].max())
34
+ if maximum > minimum:
35
+ scaled = (pixels[finite] - minimum) * (255.0 / (maximum - minimum))
36
+ preview[finite] = np.clip(scaled, 0, 255).astype(np.uint8)
37
+ return Image.fromarray(preview, mode="L").convert("RGB")
38
+ return source.convert("RGB")
39
+
40
+
41
+ def header() -> None:
42
+ print("************************************************")
43
+ print("* SEM/BSE 3D surface reconstruction *")
44
+ print("************************************************")
45
+
46
+
47
+ class SEMto3Dinterface:
48
+ """Desktop interface for reconstruction from three to five images."""
49
+
50
+ def __init__(self, root: tk.Tk):
51
+ self.root = root
52
+ self.root.title("SEM/BSE 3D surface reconstruction")
53
+ self.root.minsize(980, 720)
54
+ self.root.protocol("WM_DELETE_WINDOW", self.exit_application)
55
+
56
+ self.filepaths: list[Path] = []
57
+ self.image_references: list[ImageTk.PhotoImage | None] = [None] * 5
58
+ self.after_id: str | None = None
59
+ self.worker: threading.Thread | None = None
60
+ self.worker_results: queue.Queue[tuple[str, object]] = queue.Queue()
61
+
62
+ self._build_controls()
63
+ self._build_detector_panel()
64
+ self._build_result_panel()
65
+ self._build_information_panel()
66
+
67
+ self.root.grid_columnconfigure(2, weight=1)
68
+ self.root.grid_rowconfigure(0, weight=1)
69
+ self.root.bind("<Configure>", self.on_resize)
70
+
71
+ def _build_controls(self) -> None:
72
+ self.left_frame = tk.Frame(self.root)
73
+ self.left_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ns")
74
+
75
+ button_frame = tk.Frame(self.left_frame)
76
+ button_frame.pack(pady=2)
77
+ self.upload_button = tk.Button(
78
+ button_frame, text="Upload Files", command=self.upload_files
79
+ )
80
+ self.upload_button.grid(row=0, column=0, padx=2, pady=2)
81
+ self.reshuffle_button = tk.Button(
82
+ button_frame,
83
+ text="Reshuffle Images",
84
+ command=self.reshuffle_images,
85
+ state=tk.DISABLED,
86
+ )
87
+ self.reshuffle_button.grid(row=0, column=1, padx=2, pady=2)
88
+ self.run_button = tk.Button(
89
+ button_frame, text="Run 3D reconstruction", command=self.run, state=tk.DISABLED
90
+ )
91
+ self.run_button.grid(row=1, column=0, padx=2, pady=2)
92
+ self.exit_button = tk.Button(
93
+ button_frame, text="Exit", command=self.exit_application
94
+ )
95
+ self.exit_button.grid(row=1, column=1, padx=2, pady=2)
96
+
97
+ scale_frame = tk.LabelFrame(
98
+ self.left_frame, text="Z scaling factor per pixel (1/m)", padx=5, pady=5
99
+ )
100
+ scale_frame.pack(pady=3, fill="x")
101
+ self.z_scale_entry = tk.Entry(scale_frame)
102
+ self.z_scale_entry.insert(0, str(DEFAULT_Z_SCALE))
103
+ self.z_scale_entry.pack(fill="x")
104
+
105
+ format_frame = tk.LabelFrame(
106
+ self.left_frame, text="Output", padx=5, pady=5
107
+ )
108
+ format_frame.pack(pady=3, fill="x")
109
+ self.output_format = tk.StringVar(value="do not save")
110
+ format_row = tk.Frame(format_frame)
111
+ format_row.pack(fill="x")
112
+ for label, value in (
113
+ ("CSV", "CSV"),
114
+ ("NPZ", "NPZ"),
115
+ ("VTK", "VTK"),
116
+ ("do not save", "do not save"),
117
+ ):
118
+ tk.Radiobutton(
119
+ format_row, text=label, variable=self.output_format, value=value
120
+ ).pack(side=tk.LEFT, padx=(0, 7))
121
+
122
+ self.output_directory = tk.StringVar(value=str(Path.cwd()))
123
+ folder_row = tk.Frame(format_frame)
124
+ folder_row.pack(fill="x", pady=(5, 0))
125
+ tk.Button(folder_row, text="Output folder...", command=self.choose_output_folder).pack(
126
+ side=tk.LEFT
127
+ )
128
+ self.output_directory_label = tk.Label(
129
+ folder_row,
130
+ text=self._short_path(Path(self.output_directory.get())),
131
+ anchor="w",
132
+ width=24,
133
+ )
134
+ self.output_directory_label.pack(side=tk.LEFT, padx=(5, 0), fill="x", expand=True)
135
+
136
+ filters = tk.Frame(self.left_frame)
137
+ filters.pack(pady=3, fill="x")
138
+ cutoff_frame = tk.LabelFrame(filters, text="FFT cutoff", padx=5, pady=5)
139
+ cutoff_frame.pack(side=tk.LEFT, fill="both", expand=True)
140
+ self.cutoff_slider = tk.Scale(
141
+ cutoff_frame, from_=0, to=100, orient=tk.HORIZONTAL, label="Cutoff (%)"
142
+ )
143
+ self.cutoff_slider.pack(fill="x")
144
+
145
+ gaussian_frame = tk.LabelFrame(filters, text="Gaussian filter", padx=5, pady=5)
146
+ gaussian_frame.pack(side=tk.LEFT, fill="both", expand=True, padx=(5, 0))
147
+ self.gaussian_enabled = tk.BooleanVar(value=False)
148
+ tk.Checkbutton(
149
+ gaussian_frame,
150
+ text="Enable",
151
+ variable=self.gaussian_enabled,
152
+ command=self.toggle_gaussian_entry,
153
+ ).pack(anchor="w")
154
+ self.gaussian_sigma = tk.DoubleVar(value=DEFAULT_GAUSSIAN_SIGMA)
155
+ self.gaussian_entry = tk.Entry(
156
+ gaussian_frame, textvariable=self.gaussian_sigma, width=10, state=tk.DISABLED
157
+ )
158
+ self.gaussian_entry.pack(anchor="w")
159
+
160
+ pixel_frame = tk.LabelFrame(
161
+ self.left_frame, text="Pixel size (micrometres)", padx=5, pady=5
162
+ )
163
+ pixel_frame.pack(pady=3, fill="x")
164
+ pixel_row = tk.Frame(pixel_frame)
165
+ pixel_row.pack(fill="x")
166
+ self.use_tiff_pixel_size = tk.BooleanVar(value=True)
167
+ tk.Checkbutton(
168
+ pixel_row,
169
+ text="From TIFF",
170
+ variable=self.use_tiff_pixel_size,
171
+ command=self.toggle_pixel_size_entry,
172
+ ).pack(side=tk.LEFT)
173
+ tk.Label(pixel_row, text="manual").pack(side=tk.LEFT, padx=(10, 2))
174
+ self.pixel_size_entry = tk.Entry(pixel_row, width=12, state=tk.DISABLED)
175
+ self.pixel_size_entry.pack(side=tk.LEFT)
176
+
177
+ curvature_frame = tk.LabelFrame(
178
+ self.left_frame, text="Curvature", padx=5, pady=5
179
+ )
180
+ curvature_frame.pack(pady=3, fill="x")
181
+ self.remove_curvature = tk.BooleanVar(value=True)
182
+ tk.Checkbutton(
183
+ curvature_frame,
184
+ text="Remove curvature",
185
+ variable=self.remove_curvature,
186
+ command=self.toggle_curvature_options,
187
+ ).pack(anchor="w")
188
+ self.curvature_mode = tk.StringVar(value="automatic")
189
+ mode_row = tk.Frame(curvature_frame)
190
+ mode_row.pack(anchor="w", padx=(20, 0))
191
+ self.automatic_radio = tk.Radiobutton(
192
+ mode_row,
193
+ text="automatic",
194
+ variable=self.curvature_mode,
195
+ value="automatic",
196
+ command=self.toggle_manual_curvature_entries,
197
+ )
198
+ self.automatic_radio.pack(side=tk.LEFT)
199
+ self.manual_radio = tk.Radiobutton(
200
+ mode_row,
201
+ text="manual",
202
+ variable=self.curvature_mode,
203
+ value="manual",
204
+ command=self.toggle_manual_curvature_entries,
205
+ )
206
+ self.manual_radio.pack(side=tk.LEFT)
207
+
208
+ radii_row = tk.Frame(curvature_frame)
209
+ radii_row.pack(anchor="w", padx=(20, 0))
210
+ tk.Label(radii_row, text="Rx (m)").pack(side=tk.LEFT)
211
+ self.rx_entry = tk.Entry(radii_row, width=10, state=tk.DISABLED)
212
+ self.rx_entry.pack(side=tk.LEFT, padx=(2, 8))
213
+ tk.Label(radii_row, text="Ry (m)").pack(side=tk.LEFT)
214
+ self.ry_entry = tk.Entry(radii_row, width=10, state=tk.DISABLED)
215
+ self.ry_entry.pack(side=tk.LEFT, padx=2)
216
+
217
+ options_frame = tk.LabelFrame(self.left_frame, text="Options", padx=5, pady=5)
218
+ options_frame.pack(pady=3, fill="x")
219
+ self.timestamp_enabled = tk.BooleanVar(value=False)
220
+ tk.Checkbutton(
221
+ options_frame, text="Add time stamp", variable=self.timestamp_enabled
222
+ ).pack(anchor="w")
223
+ self.save_images = tk.BooleanVar(value=False)
224
+ tk.Checkbutton(
225
+ options_frame, text="Save extra images", variable=self.save_images
226
+ ).pack(anchor="w")
227
+
228
+ def _build_detector_panel(self) -> None:
229
+ detector_frame = tk.Frame(self.root, width=180)
230
+ detector_frame.grid(row=0, column=1, padx=5, pady=5, sticky="ns")
231
+ self.detector_canvases: list[tk.Canvas] = []
232
+ self.filename_labels: list[tk.Label] = []
233
+ for index in range(5):
234
+ frame = tk.LabelFrame(detector_frame, text=f"Detector {index + 1}")
235
+ frame.pack(pady=2, fill="x")
236
+ canvas = tk.Canvas(frame, width=145, height=82, relief=tk.SUNKEN, borderwidth=1)
237
+ canvas.pack(padx=4, pady=2)
238
+ label = tk.Label(frame, text="", wraplength=150)
239
+ label.pack(padx=2, pady=2)
240
+ self.detector_canvases.append(canvas)
241
+ self.filename_labels.append(label)
242
+
243
+ def _build_result_panel(self) -> None:
244
+ result_frame = tk.LabelFrame(self.root, text="Reconstruction")
245
+ result_frame.grid(row=0, column=2, padx=5, pady=5, sticky="nsew")
246
+ self.result_canvas = tk.Canvas(result_frame, width=500, height=500)
247
+ self.result_canvas.pack(expand=True, fill="both", padx=5, pady=5)
248
+
249
+ def _build_information_panel(self) -> None:
250
+ info_frame = tk.LabelFrame(self.root, text="Information")
251
+ info_frame.grid(row=1, column=0, columnspan=3, padx=10, pady=5, sticky="ew")
252
+ self.information = tk.Label(info_frame, text="Select three to five detector images.")
253
+ self.information.pack(padx=5, pady=5)
254
+
255
+ @staticmethod
256
+ def _short_path(path: Path) -> str:
257
+ text = str(path)
258
+ return text if len(text) <= 32 else "..." + text[-29:]
259
+
260
+ def choose_output_folder(self) -> None:
261
+ selected = filedialog.askdirectory(
262
+ title="Select output folder", initialdir=self.output_directory.get()
263
+ )
264
+ if selected:
265
+ self.output_directory.set(selected)
266
+ self.output_directory_label.config(text=self._short_path(Path(selected)))
267
+
268
+ def exit_application(self) -> None:
269
+ if self.worker is not None and self.worker.is_alive():
270
+ messagebox.showwarning(
271
+ "Reconstruction running", "Wait for the current reconstruction to finish."
272
+ )
273
+ return
274
+ self.root.destroy()
275
+
276
+ def on_resize(self, _event: tk.Event | None = None) -> None:
277
+ if self.after_id is not None:
278
+ self.root.after_cancel(self.after_id)
279
+ self.after_id = self.root.after(250, self.update_images)
280
+
281
+ def _preview(self, path: Path, width: int, height: int) -> ImageTk.PhotoImage:
282
+ with Image.open(path) as source:
283
+ image = _prepare_preview_image(source)
284
+ image.thumbnail((max(width, 1), max(height, 1)), Image.Resampling.LANCZOS)
285
+ return ImageTk.PhotoImage(image)
286
+
287
+ def update_images(self) -> None:
288
+ self.after_id = None
289
+ for index, canvas in enumerate(self.detector_canvases):
290
+ canvas.delete("all")
291
+ if index >= len(self.filepaths):
292
+ self.image_references[index] = None
293
+ self.filename_labels[index].config(text="")
294
+ continue
295
+ width = max(canvas.winfo_width() - 6, 1)
296
+ height = max(canvas.winfo_height() - 6, 1)
297
+ try:
298
+ photo = self._preview(self.filepaths[index], width, height)
299
+ except (OSError, ValueError) as exc:
300
+ self.image_references[index] = None
301
+ canvas.create_text(width // 2, height // 2, text="Preview unavailable")
302
+ self.information.config(text=f"Could not preview {self.filepaths[index].name}: {exc}")
303
+ else:
304
+ self.image_references[index] = photo
305
+ canvas.create_image(width // 2, height // 2, anchor=tk.CENTER, image=photo)
306
+ self.filename_labels[index].config(text=self.filepaths[index].name)
307
+
308
+ def upload_files(self) -> None:
309
+ selected = filedialog.askopenfilenames(
310
+ title="Select three to five detector images",
311
+ filetypes=[
312
+ ("Image files", "*.png *.jpg *.jpeg *.tif *.tiff *.bmp"),
313
+ ("All files", "*.*"),
314
+ ],
315
+ )
316
+ if not selected:
317
+ return
318
+ self.filepaths = [Path(path) for path in selected[:5]]
319
+ self.output_directory.set(str(self.filepaths[0].parent))
320
+ self.output_directory_label.config(
321
+ text=self._short_path(self.filepaths[0].parent)
322
+ )
323
+ count = len(self.filepaths)
324
+ valid = 3 <= count <= 5
325
+ self.run_button.config(state=tk.NORMAL if valid else tk.DISABLED)
326
+ self.reshuffle_button.config(state=tk.NORMAL if valid else tk.DISABLED)
327
+ self.information.config(
328
+ text=(
329
+ f"Loaded {count} detector images."
330
+ if valid
331
+ else "At least three detector images are required."
332
+ )
333
+ )
334
+ self.update_images()
335
+
336
+ def reshuffle_images(self) -> None:
337
+ count = len(self.filepaths)
338
+ if count == 3:
339
+ self.filepaths[1], self.filepaths[2] = self.filepaths[2], self.filepaths[1]
340
+ elif count in (4, 5):
341
+ self.filepaths = [self.filepaths[-1], *self.filepaths[:-1]]
342
+ self.filepaths[-2], self.filepaths[-1] = (
343
+ self.filepaths[-1],
344
+ self.filepaths[-2],
345
+ )
346
+ self.update_images()
347
+
348
+ def display_reconstruction(self, image_path: str | Path) -> None:
349
+ width = max(self.result_canvas.winfo_width() - 10, 1)
350
+ height = max(self.result_canvas.winfo_height() - 10, 1)
351
+ photo = self._preview(Path(image_path), width, height)
352
+ self.result_canvas.delete("all")
353
+ self.result_canvas.create_image(width // 2, height // 2, anchor=tk.CENTER, image=photo)
354
+ self.result_canvas.image = photo
355
+
356
+ def toggle_pixel_size_entry(self) -> None:
357
+ state = tk.DISABLED if self.use_tiff_pixel_size.get() else tk.NORMAL
358
+ self.pixel_size_entry.config(state=state)
359
+
360
+ def toggle_gaussian_entry(self) -> None:
361
+ state = tk.NORMAL if self.gaussian_enabled.get() else tk.DISABLED
362
+ self.gaussian_entry.config(state=state)
363
+
364
+ def toggle_curvature_options(self) -> None:
365
+ state = tk.NORMAL if self.remove_curvature.get() else tk.DISABLED
366
+ self.automatic_radio.config(state=state)
367
+ self.manual_radio.config(state=state)
368
+ self.toggle_manual_curvature_entries()
369
+
370
+ def toggle_manual_curvature_entries(self) -> None:
371
+ manual = self.remove_curvature.get() and self.curvature_mode.get() == "manual"
372
+ state = tk.NORMAL if manual else tk.DISABLED
373
+ self.rx_entry.config(state=state)
374
+ self.ry_entry.config(state=state)
375
+
376
+ def _read_parameters(self) -> dict[str, object]:
377
+ if len(self.filepaths) < 3:
378
+ raise ValueError("Select at least three detector images")
379
+ z_scale = float(self.z_scale_entry.get())
380
+ if self.use_tiff_pixel_size.get():
381
+ pixel_size = get_pixel_width(self.filepaths[0])
382
+ else:
383
+ manual = float(self.pixel_size_entry.get())
384
+ if manual <= 0:
385
+ raise ValueError("Manual pixel size must be positive")
386
+ pixel_size = manual * 1e-6 # GUI value is in micrometres.
387
+
388
+ remove_curvature = self.remove_curvature.get()
389
+ curvature_mode = self.curvature_mode.get()
390
+ manual_rx = manual_ry = None
391
+ if remove_curvature and curvature_mode == "manual":
392
+ manual_rx = float(self.rx_entry.get())
393
+ manual_ry = float(self.ry_entry.get())
394
+ if manual_rx == 0 or manual_ry == 0:
395
+ raise ValueError("Manual curvature radii must be nonzero")
396
+
397
+ return {
398
+ "plot_images_decomposition": self.save_images.get(),
399
+ "gaussian_filter_enabled": self.gaussian_enabled.get(),
400
+ "sigma": self.gaussian_sigma.get() if self.gaussian_enabled.get() else 0.0,
401
+ "remove_curvature": remove_curvature,
402
+ "curvature_mode": curvature_mode,
403
+ "manual_rx": manual_rx,
404
+ "manual_ry": manual_ry,
405
+ "cutoff_frequency": self.cutoff_slider.get() / 100.0,
406
+ "save_file_type": self.output_format.get(),
407
+ "time_stamp": self.timestamp_enabled.get(),
408
+ "pixelsize": pixel_size,
409
+ "z_scaling_factor_per_pixel": z_scale,
410
+ "output_dir": Path(self.output_directory.get()),
411
+ }
412
+
413
+ def _set_running(self, running: bool) -> None:
414
+ state = tk.DISABLED if running else tk.NORMAL
415
+ self.upload_button.config(state=state)
416
+ self.reshuffle_button.config(state=state)
417
+ self.run_button.config(state=state)
418
+ self.exit_button.config(state=state)
419
+
420
+ def run(self) -> None:
421
+ try:
422
+ parameters = self._read_parameters()
423
+ except (OSError, ValueError) as exc:
424
+ messagebox.showerror("Invalid parameters", str(exc))
425
+ return
426
+ self._set_running(True)
427
+ self.information.config(text="Reconstruction is running...")
428
+ self.worker = threading.Thread(
429
+ target=self._run_worker, args=(list(self.filepaths), parameters), daemon=True
430
+ )
431
+ self.worker.start()
432
+ self.root.after(100, self._poll_worker)
433
+
434
+ def _run_worker(self, paths: list[Path], parameters: dict[str, object]) -> None:
435
+ try:
436
+ result = construct_surface(paths, **parameters)
437
+ except Exception as exc: # transferred to the UI thread
438
+ self.worker_results.put(("error", exc))
439
+ else:
440
+ self.worker_results.put(("success", result))
441
+
442
+ def _poll_worker(self) -> None:
443
+ try:
444
+ status, payload = self.worker_results.get_nowait()
445
+ except queue.Empty:
446
+ if self.worker is not None and self.worker.is_alive():
447
+ self.root.after(100, self._poll_worker)
448
+ return
449
+
450
+ self._set_running(False)
451
+ if status == "error":
452
+ self.information.config(text="Reconstruction failed.")
453
+ messagebox.showerror("Reconstruction failed", str(payload))
454
+ return
455
+
456
+ image_name, _, _, _, warning = payload
457
+ self.display_reconstruction(image_name)
458
+ output = Path(image_name).parent
459
+ self.information.config(text=f"Reconstruction finished. Output: {output}")
460
+ if warning:
461
+ messagebox.showwarning("Reconstruction warning", warning)
462
+
463
+
464
+ def main() -> None:
465
+ """Launch the desktop application."""
466
+ header()
467
+ root = tk.Tk()
468
+ SEMto3Dinterface(root)
469
+ root.mainloop()
470
+
471
+
472
+ if __name__ == "__main__":
473
+ main()