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_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()
|