microbridge-lmd 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.
- MicroBridge/CLI/__init__.py +0 -0
- MicroBridge/CLI/cli.py +63 -0
- MicroBridge/Core/__init__.py +0 -0
- MicroBridge/Core/core.py +110 -0
- MicroBridge/GUI/__init__.py +0 -0
- MicroBridge/GUI/gui.py +275 -0
- MicroBridge/__init__.py +0 -0
- MicroBridge/main.py +45 -0
- MicroBridge/utils.py +15 -0
- microbridge_lmd-0.2.0.dist-info/METADATA +172 -0
- microbridge_lmd-0.2.0.dist-info/RECORD +15 -0
- microbridge_lmd-0.2.0.dist-info/WHEEL +5 -0
- microbridge_lmd-0.2.0.dist-info/entry_points.txt +2 -0
- microbridge_lmd-0.2.0.dist-info/licenses/LICENSE +674 -0
- microbridge_lmd-0.2.0.dist-info/top_level.txt +1 -0
|
File without changes
|
MicroBridge/CLI/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from xml.parsers.expat import ExpatError
|
|
7
|
+
|
|
8
|
+
from MicroBridge.Core.core import convert_ndpa_to_lmd_core, derive_output_filename
|
|
9
|
+
|
|
10
|
+
def convert_files(files, output):
|
|
11
|
+
successful = 0
|
|
12
|
+
failures = []
|
|
13
|
+
for file in files:
|
|
14
|
+
if Path(file).suffix != ".ndpa":
|
|
15
|
+
message = f"\nExpected a '.ndpa' file, got a '{Path(file).suffix}' file Instead"
|
|
16
|
+
failures.append((file, message))
|
|
17
|
+
continue
|
|
18
|
+
output_name = derive_output_filename(file)
|
|
19
|
+
if output:
|
|
20
|
+
output_name = str(Path(output) / Path(output_name).name)
|
|
21
|
+
try:
|
|
22
|
+
convert_ndpa_to_lmd_core(file, output_name)
|
|
23
|
+
except (ValueError, FileNotFoundError, IsADirectoryError, ExpatError) as e:
|
|
24
|
+
failures.append((file, e))
|
|
25
|
+
click.echo(f"Converting the ndpa to an LMD xml failed: {e}", err=True)
|
|
26
|
+
else:
|
|
27
|
+
click.echo("Successfully converted ndpa into LMD xml :3")
|
|
28
|
+
successful += 1
|
|
29
|
+
return successful, failures
|
|
30
|
+
|
|
31
|
+
def find_ndpa_files(directory):
|
|
32
|
+
files = []
|
|
33
|
+
for entry in Path(directory).iterdir():
|
|
34
|
+
if entry.suffix == ".ndpa":
|
|
35
|
+
files.append(str(entry))
|
|
36
|
+
return files
|
|
37
|
+
|
|
38
|
+
@click.command()
|
|
39
|
+
@click.pass_context
|
|
40
|
+
@click.option('-b', '--batch',type=click.Path(), nargs=1, required=False, help="Process a Directory of '.ndpa' files in one go.")
|
|
41
|
+
@click.option('-o', '--output',type=click.Path(), nargs=1, required=False, help="Select an output directory for the converted '.ndpa' files to end up in.")
|
|
42
|
+
@click.argument("files", nargs=-1, required=False)
|
|
43
|
+
def run(ctx, files, batch, output):
|
|
44
|
+
if not files and not batch:
|
|
45
|
+
click.echo(ctx.get_help())
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
elif files and batch:
|
|
48
|
+
click.echo(f"You cannot have files and -b dir in one command \n\n{ctx.get_help()}")
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
elif batch:
|
|
51
|
+
files = find_ndpa_files(batch)
|
|
52
|
+
successful, failures = convert_files(files, output)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
click.echo("\n\n\n===============================================")
|
|
56
|
+
if successful == len(files):
|
|
57
|
+
click.secho(f"{successful}/{len(files)} files converted. \nAll files converted fine :3", fg="green",err=False)
|
|
58
|
+
sys.exit(0)
|
|
59
|
+
else:
|
|
60
|
+
click.secho(f"{len(failures)}/{len(files)} failed to convert 3:\nThe files that failed to convert were:\n", fg="red", err=True)
|
|
61
|
+
for filename, err in failures:
|
|
62
|
+
click.secho(f"{filename} errored with: {err}", fg="red", bold=True, err=True)
|
|
63
|
+
sys.exit(1)
|
|
File without changes
|
MicroBridge/Core/core.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
from xml.dom import minidom
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
def derive_output_filename(input_filename: str) -> str:
|
|
5
|
+
p = Path(input_filename)
|
|
6
|
+
new_path = p.parent / (p.stem + "_LMD.xml")
|
|
7
|
+
|
|
8
|
+
return str(new_path)
|
|
9
|
+
|
|
10
|
+
def convert_ndpa_to_lmd_core(input_filename: str, output_filename: str) -> None:
|
|
11
|
+
# 1. Parsing The input
|
|
12
|
+
with open(input_filename, "r", encoding="utf-8") as file:
|
|
13
|
+
ndpa_xml = minidom.parse(file)
|
|
14
|
+
|
|
15
|
+
# Find all 'ndpviewstate' elements
|
|
16
|
+
regions = ndpa_xml.getElementsByTagName("ndpviewstate")
|
|
17
|
+
|
|
18
|
+
# 2. Extract Calibration points
|
|
19
|
+
calibration_points = []
|
|
20
|
+
|
|
21
|
+
for cal_idx in range(min(3, len(regions))):
|
|
22
|
+
region = regions[cal_idx]
|
|
23
|
+
x_um, y_um = None, None
|
|
24
|
+
|
|
25
|
+
# Method 1: checking for circle annotations (Should always use as the instructional documentation states)
|
|
26
|
+
annotations = region.getElementsByTagName("annotation")
|
|
27
|
+
if annotations:
|
|
28
|
+
x_elems = annotations[0].getElementsByTagName("x")
|
|
29
|
+
y_elems = annotations[0].getElementsByTagName("y")
|
|
30
|
+
|
|
31
|
+
if x_elems and y_elems:
|
|
32
|
+
# Data is in nanometers. Divide by 1000 to get micrometers
|
|
33
|
+
x_um = int(round(float(x_elems[0].firstChild.data) / 1000))
|
|
34
|
+
y_um = int(round(float(y_elems[0].firstChild.data) / 1000))
|
|
35
|
+
|
|
36
|
+
if x_um is None: # Method 2: Fallback to Freehand points
|
|
37
|
+
pointlist = region.getElementsByTagName("point")
|
|
38
|
+
if pointlist:
|
|
39
|
+
x_elem = pointlist[0].getElementsByTagName("x")[0]
|
|
40
|
+
y_elem = pointlist[0].getElementsByTagName("y")[0]
|
|
41
|
+
|
|
42
|
+
x_um = int(round(float(x_elem.firstChild.data) / 1000))
|
|
43
|
+
y_um = int(round(float(y_elem.firstChild.data) / 1000))
|
|
44
|
+
|
|
45
|
+
if x_um is None:
|
|
46
|
+
raise ValueError(f"Calibration point {cal_idx + 1} came back malformed or incorrectly made")
|
|
47
|
+
|
|
48
|
+
calibration_points.append((x_um, y_um))
|
|
49
|
+
|
|
50
|
+
# 3. Extracting Capture shapes
|
|
51
|
+
valid_shapes = []
|
|
52
|
+
shape_num = 1
|
|
53
|
+
|
|
54
|
+
for shape_idx in range(3, len(regions)):
|
|
55
|
+
region = regions[shape_idx]
|
|
56
|
+
|
|
57
|
+
# We do this to stop Any rulers from breaking and crashing the program
|
|
58
|
+
annotations = region.getElementsByTagName("annotation")
|
|
59
|
+
if annotations and annotations[0].getAttribute("type") == "linearmeasure":
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
# We use this to extract all the annotations the scientists want
|
|
63
|
+
pointlist = region.getElementsByTagName("point")
|
|
64
|
+
if len(pointlist) > 0:
|
|
65
|
+
points = [] # Could change the name later to reduce the chances of "eye-slip"
|
|
66
|
+
for point_idx, point in enumerate(pointlist):
|
|
67
|
+
try:
|
|
68
|
+
x_elem = point.getElementsByTagName("x")[0]
|
|
69
|
+
y_elem = point.getElementsByTagName("y")[0]
|
|
70
|
+
|
|
71
|
+
x_um = int(round(float(x_elem.firstChild.data) / 1000))
|
|
72
|
+
y_um = int(round(float(y_elem.firstChild.data) / 1000))
|
|
73
|
+
except (IndexError, AttributeError) as e:
|
|
74
|
+
raise ValueError(f"Shape {shape_num} data malformed at point {point_idx + 1}") from e
|
|
75
|
+
points.append((x_um, y_um))
|
|
76
|
+
|
|
77
|
+
valid_shapes.append({
|
|
78
|
+
"shape_num": shape_num,
|
|
79
|
+
"points": points
|
|
80
|
+
})
|
|
81
|
+
shape_num += 1
|
|
82
|
+
|
|
83
|
+
# 4. LMD XML output
|
|
84
|
+
with open(output_filename, "w", encoding="utf-8") as f1:
|
|
85
|
+
f1.write('<?xml version="1.0" encoding="utf-8"?>\n')
|
|
86
|
+
f1.write("<ImageData>\n")
|
|
87
|
+
f1.write(" <GlobalCoordinates>1</GlobalCoordinates>\n")
|
|
88
|
+
|
|
89
|
+
# We write the 3 calibration points first
|
|
90
|
+
for cal_idx, (x_um, y_um) in enumerate(calibration_points):
|
|
91
|
+
f1.write(f" <X_CalibrationPoint_{cal_idx + 1}>{x_um}</X_CalibrationPoint_{cal_idx + 1}>\n")
|
|
92
|
+
f1.write(f" <Y_CalibrationPoint_{cal_idx + 1}>{y_um}</Y_CalibrationPoint_{cal_idx + 1}>\n")
|
|
93
|
+
|
|
94
|
+
f1.write(f" <ShapeCount>{len(valid_shapes)}</ShapeCount>\n")
|
|
95
|
+
|
|
96
|
+
for shape_data in valid_shapes:
|
|
97
|
+
s_num = shape_data["shape_num"]
|
|
98
|
+
points = shape_data["points"]
|
|
99
|
+
|
|
100
|
+
f1.write(f" <Shape_{s_num}>\n")
|
|
101
|
+
f1.write(f" <PointCount>{len(points)}</PointCount>\n")
|
|
102
|
+
|
|
103
|
+
# We write the X/Y cords for the Verticies of this shape
|
|
104
|
+
for point_idx, (x_um, y_um) in enumerate(points):
|
|
105
|
+
f1.write(f" <X_{point_idx + 1}>{x_um}</X_{point_idx + 1}>\n")
|
|
106
|
+
f1.write(f" <Y_{point_idx + 1}>{y_um}</Y_{point_idx + 1}>\n")
|
|
107
|
+
|
|
108
|
+
f1.write(f" </Shape_{s_num}>\n")
|
|
109
|
+
|
|
110
|
+
f1.write("</ImageData>\n")
|
|
File without changes
|
MicroBridge/GUI/gui.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import threading
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from tkinter import filedialog
|
|
5
|
+
|
|
6
|
+
import customtkinter as ctk
|
|
7
|
+
|
|
8
|
+
from MicroBridge.Core.core import convert_ndpa_to_lmd_core, derive_output_filename
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
PAD = 12
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class App(ctk.CTk):
|
|
15
|
+
def __init__(self):
|
|
16
|
+
super().__init__()
|
|
17
|
+
self.title("MicroBridge")
|
|
18
|
+
self.geometry("1050x650")
|
|
19
|
+
self.minsize(850, 450)
|
|
20
|
+
|
|
21
|
+
self._set_window_icon()
|
|
22
|
+
|
|
23
|
+
self.input_files: list[str] = []
|
|
24
|
+
self.output_dir: str | None = None
|
|
25
|
+
self._converting = False
|
|
26
|
+
|
|
27
|
+
self._setup_ui()
|
|
28
|
+
self._log("ready — pick some files")
|
|
29
|
+
|
|
30
|
+
def _set_window_icon(self):
|
|
31
|
+
if sys.platform != "win32":
|
|
32
|
+
return
|
|
33
|
+
base = getattr(sys, "_MEIPASS", None)
|
|
34
|
+
if not base:
|
|
35
|
+
return
|
|
36
|
+
icon = Path(base) / "MicroBridge_Icon.ico"
|
|
37
|
+
if icon.exists():
|
|
38
|
+
self.iconbitmap(str(icon))
|
|
39
|
+
|
|
40
|
+
def _setup_ui(self):
|
|
41
|
+
self.grid_columnconfigure(0, weight=2, uniform="col")
|
|
42
|
+
self.grid_columnconfigure(1, weight=3, uniform="col")
|
|
43
|
+
self.grid_rowconfigure(0, weight=1)
|
|
44
|
+
|
|
45
|
+
self._build_left()
|
|
46
|
+
self._build_right()
|
|
47
|
+
|
|
48
|
+
def _build_left(self):
|
|
49
|
+
left = ctk.CTkFrame(self, corner_radius=12)
|
|
50
|
+
left.grid(row=0, column=0, sticky="nsew", padx=(PAD, 6), pady=PAD)
|
|
51
|
+
left.grid_columnconfigure(0, weight=1)
|
|
52
|
+
left.grid_rowconfigure(6, weight=1)
|
|
53
|
+
|
|
54
|
+
ctk.CTkLabel(
|
|
55
|
+
left,
|
|
56
|
+
text="MicroBridge",
|
|
57
|
+
font=ctk.CTkFont(size=22, weight="bold"),
|
|
58
|
+
).grid(row=0, column=0, padx=PAD, pady=(16, 2), sticky="w")
|
|
59
|
+
|
|
60
|
+
sep = ctk.CTkFrame(left, height=1)
|
|
61
|
+
sep.grid(row=1, column=0, padx=PAD, pady=(4, 12), sticky="ew")
|
|
62
|
+
|
|
63
|
+
ctk.CTkLabel(left, text="Conversion type", anchor="w", font=ctk.CTkFont(size=11)).grid(
|
|
64
|
+
row=2, column=0, padx=PAD, pady=(0, 4), sticky="ew"
|
|
65
|
+
)
|
|
66
|
+
self.type_var = ctk.StringVar(value="NDPA")
|
|
67
|
+
ctk.CTkComboBox(
|
|
68
|
+
left,
|
|
69
|
+
values=["Auto", "NDPA", "CSV"],
|
|
70
|
+
variable=self.type_var,
|
|
71
|
+
state="readonly",
|
|
72
|
+
corner_radius=6,
|
|
73
|
+
).grid(row=3, column=0, padx=PAD, pady=(0, 14), sticky="ew")
|
|
74
|
+
|
|
75
|
+
btn_frame = ctk.CTkFrame(left, fg_color="transparent")
|
|
76
|
+
btn_frame.grid(row=4, column=0, padx=PAD, pady=(0, 2), sticky="ew")
|
|
77
|
+
btn_frame.grid_columnconfigure((0, 1, 2), weight=1)
|
|
78
|
+
|
|
79
|
+
ctk.CTkButton(
|
|
80
|
+
btn_frame, text="Select input files", command=self._select_files, corner_radius=6,
|
|
81
|
+
fg_color="transparent", text_color=("gray10", "gray90"), hover_color=("#d0d0d0", "#333333"),
|
|
82
|
+
border_width=1,
|
|
83
|
+
).grid(row=0, column=0, padx=2, sticky="ew")
|
|
84
|
+
|
|
85
|
+
ctk.CTkButton(
|
|
86
|
+
btn_frame, text="Scan a folder", command=self._select_folder, corner_radius=6,
|
|
87
|
+
fg_color="transparent", text_color=("gray10", "gray90"), hover_color=("#d0d0d0", "#333333"),
|
|
88
|
+
border_width=1,
|
|
89
|
+
).grid(row=0, column=1, padx=2, sticky="ew")
|
|
90
|
+
|
|
91
|
+
ctk.CTkButton(
|
|
92
|
+
btn_frame, text="Pick output folder", command=self._select_output, corner_radius=6,
|
|
93
|
+
fg_color="transparent", text_color=("gray10", "gray90"), hover_color=("#d0d0d0", "#333333"),
|
|
94
|
+
border_width=1,
|
|
95
|
+
).grid(row=0, column=2, padx=2, sticky="ew")
|
|
96
|
+
|
|
97
|
+
self.files_label = ctk.CTkLabel(
|
|
98
|
+
left, text="Selected files: (none)", anchor="w", font=ctk.CTkFont(size=11),
|
|
99
|
+
)
|
|
100
|
+
self.files_label.grid(row=5, column=0, padx=PAD, pady=(10, 2), sticky="ew")
|
|
101
|
+
|
|
102
|
+
self.files_text = ctk.CTkTextbox(left, state="disabled", corner_radius=6)
|
|
103
|
+
self.files_text.grid(row=6, column=0, padx=PAD, pady=(0, 6), sticky="nsew")
|
|
104
|
+
|
|
105
|
+
self.output_label = ctk.CTkLabel(
|
|
106
|
+
left,
|
|
107
|
+
text="Output: (same dir as input)",
|
|
108
|
+
anchor="w",
|
|
109
|
+
font=ctk.CTkFont(size=11),
|
|
110
|
+
)
|
|
111
|
+
self.output_label.grid(row=7, column=0, padx=PAD, pady=(0, 8), sticky="ew")
|
|
112
|
+
|
|
113
|
+
self.convert_btn = ctk.CTkButton(
|
|
114
|
+
left,
|
|
115
|
+
text="Convert",
|
|
116
|
+
height=42,
|
|
117
|
+
font=ctk.CTkFont(size=14, weight="bold"),
|
|
118
|
+
command=self._convert,
|
|
119
|
+
corner_radius=8,
|
|
120
|
+
)
|
|
121
|
+
self.convert_btn.grid(row=8, column=0, padx=PAD, pady=(4, 16), sticky="ew")
|
|
122
|
+
|
|
123
|
+
def _build_right(self):
|
|
124
|
+
right = ctk.CTkFrame(self, corner_radius=12)
|
|
125
|
+
right.grid(row=0, column=1, sticky="nsew", padx=(6, PAD), pady=PAD)
|
|
126
|
+
right.grid_columnconfigure(0, weight=1)
|
|
127
|
+
right.grid_rowconfigure(1, weight=1)
|
|
128
|
+
|
|
129
|
+
hdr = ctk.CTkFrame(right, fg_color="transparent")
|
|
130
|
+
hdr.grid(row=0, column=0, padx=PAD, pady=(14, 6), sticky="ew")
|
|
131
|
+
hdr.grid_columnconfigure(0, weight=1)
|
|
132
|
+
|
|
133
|
+
ctk.CTkLabel(
|
|
134
|
+
hdr,
|
|
135
|
+
text="Log",
|
|
136
|
+
font=ctk.CTkFont(size=16),
|
|
137
|
+
).grid(row=0, column=0, sticky="w")
|
|
138
|
+
|
|
139
|
+
ctk.CTkButton(
|
|
140
|
+
hdr,
|
|
141
|
+
text="Clear",
|
|
142
|
+
width=56,
|
|
143
|
+
height=24,
|
|
144
|
+
font=ctk.CTkFont(size=11),
|
|
145
|
+
command=self._clear_log,
|
|
146
|
+
corner_radius=4,
|
|
147
|
+
fg_color="transparent",
|
|
148
|
+
text_color=("gray10", "gray90"),
|
|
149
|
+
hover_color=("#d0d0d0", "#333333"),
|
|
150
|
+
border_width=1,
|
|
151
|
+
).grid(row=0, column=1)
|
|
152
|
+
|
|
153
|
+
self.log_text = ctk.CTkTextbox(right, state="disabled", corner_radius=6)
|
|
154
|
+
self.log_text.grid(row=1, column=0, padx=PAD, pady=(0, PAD), sticky="nsew")
|
|
155
|
+
|
|
156
|
+
self.log_text.tag_config("ok", foreground="#2b8a5e")
|
|
157
|
+
self.log_text.tag_config("err", foreground="#c0392b")
|
|
158
|
+
self.log_text.tag_config("warn", foreground="#b45309")
|
|
159
|
+
self.log_text.tag_config("muted", foreground="#6b7280")
|
|
160
|
+
|
|
161
|
+
def _log(self, msg: str, tag: str | None = None):
|
|
162
|
+
self.log_text.configure(state="normal")
|
|
163
|
+
if tag:
|
|
164
|
+
self.log_text.insert("end", msg + "\n", tag)
|
|
165
|
+
else:
|
|
166
|
+
self.log_text.insert("end", msg + "\n")
|
|
167
|
+
self.log_text.see("end")
|
|
168
|
+
self.log_text.configure(state="disabled")
|
|
169
|
+
|
|
170
|
+
def _clear_log(self):
|
|
171
|
+
self.log_text.configure(state="normal")
|
|
172
|
+
self.log_text.delete("1.0", "end")
|
|
173
|
+
self.log_text.configure(state="disabled")
|
|
174
|
+
|
|
175
|
+
def _select_files(self):
|
|
176
|
+
files = filedialog.askopenfilenames(
|
|
177
|
+
title="Select input files",
|
|
178
|
+
filetypes=[("NDPA files", "*.ndpa"), ("All files", "*.*")],
|
|
179
|
+
)
|
|
180
|
+
if files:
|
|
181
|
+
self.input_files = list(files)
|
|
182
|
+
self._update_files_display()
|
|
183
|
+
|
|
184
|
+
def _select_folder(self):
|
|
185
|
+
folder = filedialog.askdirectory(title="Select a folder")
|
|
186
|
+
if folder:
|
|
187
|
+
self.input_files = sorted(
|
|
188
|
+
str(f) for f in Path(folder).iterdir() if f.suffix == ".ndpa"
|
|
189
|
+
)
|
|
190
|
+
self._update_files_display()
|
|
191
|
+
|
|
192
|
+
def _select_output(self):
|
|
193
|
+
folder = filedialog.askdirectory(title="Select Output Folder")
|
|
194
|
+
if folder:
|
|
195
|
+
self.output_dir = folder
|
|
196
|
+
short = f"...{folder[-50:]}" if len(folder) > 53 else folder
|
|
197
|
+
self.output_label.configure(text=f"Output: {short}")
|
|
198
|
+
|
|
199
|
+
def _update_files_display(self):
|
|
200
|
+
self.files_text.configure(state="normal")
|
|
201
|
+
self.files_text.delete("1.0", "end")
|
|
202
|
+
for f in self.input_files:
|
|
203
|
+
self.files_text.insert("end", f + "\n")
|
|
204
|
+
self.files_text.configure(state="disabled")
|
|
205
|
+
|
|
206
|
+
count = len(self.input_files)
|
|
207
|
+
self.files_label.configure(text=f"Selected files: ({count})")
|
|
208
|
+
self._log(f"{count} file(s) selected", "ok" if count else None)
|
|
209
|
+
|
|
210
|
+
def _convert(self):
|
|
211
|
+
if self._converting:
|
|
212
|
+
return
|
|
213
|
+
if not self.input_files:
|
|
214
|
+
self._log("nothing to convert — pick files first", "err")
|
|
215
|
+
return
|
|
216
|
+
self._converting = True
|
|
217
|
+
self.convert_btn.configure(
|
|
218
|
+
state="disabled",
|
|
219
|
+
text="Converting...",
|
|
220
|
+
)
|
|
221
|
+
threading.Thread(target=self._convert_worker, daemon=True).start()
|
|
222
|
+
|
|
223
|
+
def _convert_finished(self):
|
|
224
|
+
self._converting = False
|
|
225
|
+
self.convert_btn.configure(
|
|
226
|
+
state="normal",
|
|
227
|
+
text="Convert",
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _convert_worker(self):
|
|
231
|
+
files = self.input_files[:]
|
|
232
|
+
out_dir = self.output_dir
|
|
233
|
+
total = len(files)
|
|
234
|
+
ok = 0
|
|
235
|
+
fails: list[tuple[str, str | Exception]] = []
|
|
236
|
+
|
|
237
|
+
for i, file in enumerate(files):
|
|
238
|
+
self.after(0, self._log, f"[{i+1}/{total}] {Path(file).name} ...", "muted")
|
|
239
|
+
|
|
240
|
+
if Path(file).suffix != ".ndpa":
|
|
241
|
+
msg = f"expected '.ndpa', got '{Path(file).suffix}'"
|
|
242
|
+
fails.append((file, msg))
|
|
243
|
+
self.after(0, self._log, f" {msg}", "err")
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
output = derive_output_filename(file)
|
|
247
|
+
if out_dir:
|
|
248
|
+
output = str(Path(out_dir) / Path(output).name)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
convert_ndpa_to_lmd_core(file, output)
|
|
252
|
+
ok += 1
|
|
253
|
+
self.after(0, self._log, f" done", "ok")
|
|
254
|
+
except (ValueError, FileNotFoundError, IsADirectoryError) as e:
|
|
255
|
+
fails.append((file, e))
|
|
256
|
+
self.after(0, self._log, f" failed: {e}", "err")
|
|
257
|
+
|
|
258
|
+
self.after(0, self._log, "")
|
|
259
|
+
self.after(0, self._log, "─" * 40, "muted")
|
|
260
|
+
if ok == total:
|
|
261
|
+
self.after(0, self._log, f"{ok}/{total} converted", "ok")
|
|
262
|
+
else:
|
|
263
|
+
self.after(0, self._log, f"{len(fails)}/{total} failed", "err")
|
|
264
|
+
self.after(0, self._log, "failed files:", "warn")
|
|
265
|
+
for fname, err in fails:
|
|
266
|
+
self.after(0, self._log, f" {Path(fname).name} — {err}", "err")
|
|
267
|
+
|
|
268
|
+
self.after(0, self._convert_finished)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def run():
|
|
272
|
+
ctk.set_appearance_mode("system")
|
|
273
|
+
ctk.set_default_color_theme("blue")
|
|
274
|
+
app = App()
|
|
275
|
+
app.mainloop()
|
MicroBridge/__init__.py
ADDED
|
File without changes
|
MicroBridge/main.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from MicroBridge.utils import should_use_cli
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def hide_console():
|
|
8
|
+
if os.name == 'nt':
|
|
9
|
+
import ctypes
|
|
10
|
+
kernel32 = ctypes.WinDLL('kernel32')
|
|
11
|
+
user32 = ctypes.WinDLL('user32')
|
|
12
|
+
hWnd = kernel32.GetConsoleWindow()
|
|
13
|
+
if hWnd:
|
|
14
|
+
user32.ShowWindow(hWnd, 0)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def attach_parent_console():
|
|
18
|
+
if os.name != 'nt' or not getattr(sys, 'frozen', False):
|
|
19
|
+
return
|
|
20
|
+
import ctypes
|
|
21
|
+
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
22
|
+
if kernel32.AttachConsole(-1):
|
|
23
|
+
sys.stdout = open('CONOUT$', 'w')
|
|
24
|
+
sys.stderr = open('CONOUT$', 'w')
|
|
25
|
+
sys.stdin = open('CONIN$', 'r')
|
|
26
|
+
else:
|
|
27
|
+
if sys.stdout is None:
|
|
28
|
+
sys.stdout = open(os.devnull, 'w')
|
|
29
|
+
if sys.stderr is None:
|
|
30
|
+
sys.stderr = open(os.devnull, 'w')
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main():
|
|
34
|
+
argv = sys.argv[1:]
|
|
35
|
+
if should_use_cli(argv):
|
|
36
|
+
attach_parent_console()
|
|
37
|
+
from MicroBridge.CLI.cli import run as cli_run
|
|
38
|
+
cli_run.main(args=argv)
|
|
39
|
+
else:
|
|
40
|
+
hide_console()
|
|
41
|
+
from MicroBridge.GUI.gui import run as gui_run
|
|
42
|
+
gui_run()
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
MicroBridge/utils.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Checks to see if the program was ran with the --gui flag or without any arguments
|
|
2
|
+
def should_use_cli(argv: list[str]) -> bool:
|
|
3
|
+
if not argv:
|
|
4
|
+
return False
|
|
5
|
+
lowered = [arg.lower() for arg in argv]
|
|
6
|
+
return "--gui" not in lowered
|
|
7
|
+
|
|
8
|
+
# Simple utility that clears the terminal and uses the correct command based on OS type
|
|
9
|
+
# nt = windows
|
|
10
|
+
# else = macos & linux!
|
|
11
|
+
# Not used atm
|
|
12
|
+
# def clear_terminal():
|
|
13
|
+
# import os
|
|
14
|
+
# import subprocess
|
|
15
|
+
# subprocess.run("cls" if os.name == "nt" else "clear", shell=True)
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: microbridge-lmd
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: NDP/CSV to LMD Converter
|
|
5
|
+
Author: Rose Scott
|
|
6
|
+
License-Expression: GPL-3.0-or-later
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Dist: click
|
|
11
|
+
Requires-Dist: customtkinter
|
|
12
|
+
Dynamic: license-file
|
|
13
|
+
|
|
14
|
+
# MicroBridge
|
|
15
|
+
MicroBridge is a tool that allows file conversion between Slide annotation software like NDP.view2 to an XML format that Leica Microdissection (LMD) microscopes support.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
# Purpose
|
|
19
|
+
Scientists annotate regions of interest in software like NDP.view2, They would then have to re-draw those same annotations in the software for the LMDs.
|
|
20
|
+
MicroBridge takes the annotation files from NDP.view2 and converts them into a format that the LMD can understand, This gives the scientists more time to do experiments rather than annotating files for the 2nd time.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
# Installation
|
|
24
|
+
To install the package do one of the following:
|
|
25
|
+
|
|
26
|
+
A. Grab the release from the release section
|
|
27
|
+
(Mainly for windows)
|
|
28
|
+
|
|
29
|
+
B. Clone the Repo
|
|
30
|
+
Make a virtual environment
|
|
31
|
+
```zsh
|
|
32
|
+
python3 -m venv .venv
|
|
33
|
+
```
|
|
34
|
+
Then:
|
|
35
|
+
```zsh
|
|
36
|
+
pip install -e .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
# Usage
|
|
41
|
+
There are 2 ways to use MicroBridge
|
|
42
|
+
Option 1: Usage via the GUI (graphical user interface)
|
|
43
|
+
Install it first
|
|
44
|
+
And either run
|
|
45
|
+
```zsh
|
|
46
|
+
microbridge
|
|
47
|
+
```
|
|
48
|
+
In your terminal and it will open up
|
|
49
|
+
Or use the desktop icon / entry to open the program
|
|
50
|
+
|
|
51
|
+
Option 2: Usage via the terminal
|
|
52
|
+
For typical usage you would run
|
|
53
|
+
```zsh
|
|
54
|
+
microbridge filename.ndpa file2.ndpa file3.ndpa ...
|
|
55
|
+
```
|
|
56
|
+
There are 2 flags that you can use to help with input and output
|
|
57
|
+
Flag 1: Batch processing
|
|
58
|
+
To process a whole folder / directory run
|
|
59
|
+
```zsh
|
|
60
|
+
microbridge -b directory-path
|
|
61
|
+
```
|
|
62
|
+
Flag 2: output dir
|
|
63
|
+
To set a specific directory / folder for the files be placed into run
|
|
64
|
+
```zsh
|
|
65
|
+
microbridge filename.ndpa -o 'path/to/dir'
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
You can use both flags with each other
|
|
69
|
+
```zsh
|
|
70
|
+
microbridge -b 'path/to/.ndpa/dir' -o 'path/to/output/dir'
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
You can also run
|
|
74
|
+
```zsh
|
|
75
|
+
microbridge --help
|
|
76
|
+
```
|
|
77
|
+
This shows you all the commands and a quick run down of what they do
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
# Tests
|
|
81
|
+
There is information about running the tests locally in the tests directory of the REPO
|
|
82
|
+
|
|
83
|
+
You can run this
|
|
84
|
+
```zsh
|
|
85
|
+
python -m venv .venv
|
|
86
|
+
source .venv/bin/activate
|
|
87
|
+
pip install -e .
|
|
88
|
+
python run_tests.py
|
|
89
|
+
```
|
|
90
|
+
to set up the venv and run the tests
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
## ndpa -> LMD(xml) example
|
|
94
|
+
A raw ndpa looks like this:
|
|
95
|
+
```xml
|
|
96
|
+
<!-- Calibration Point 3 - Circle annotation -->
|
|
97
|
+
<ndpviewstate id="3">
|
|
98
|
+
<title>Calibration_3</title>
|
|
99
|
+
<annotation type="circle">
|
|
100
|
+
<x>200000000</x>
|
|
101
|
+
<y>300000000</y>
|
|
102
|
+
<radius>5000000</radius>
|
|
103
|
+
</annotation>
|
|
104
|
+
</ndpviewstate>
|
|
105
|
+
|
|
106
|
+
<!-- RULER - should be SKIPPED -->
|
|
107
|
+
<ndpviewstate id="4">
|
|
108
|
+
<title>Measurement_1</title>
|
|
109
|
+
<annotation type="linearmeasure" displayname="AnnotateRuler" color="#ff0000">
|
|
110
|
+
<x1>100000000</x1>
|
|
111
|
+
<y1>100000000</y1>
|
|
112
|
+
<x2>200000000</x2>
|
|
113
|
+
<y2>200000000</y2>
|
|
114
|
+
</annotation>
|
|
115
|
+
</ndpviewstate>
|
|
116
|
+
```
|
|
117
|
+
MicroBridge (currently) converts this to an XML for the LMD's which look like this:
|
|
118
|
+
```xml
|
|
119
|
+
<X_CalibrationPoint_3>200000</X_CalibrationPoint_3>
|
|
120
|
+
<Y_CalibrationPoint_3>300000</Y_CalibrationPoint_3>
|
|
121
|
+
<ShapeCount>2</ShapeCount>
|
|
122
|
+
<Shape_1>
|
|
123
|
+
<PointCount>3</PointCount>
|
|
124
|
+
<X_1>300000</X_1>
|
|
125
|
+
<Y_1>400000</Y_1>
|
|
126
|
+
<X_2>350000</X_2>
|
|
127
|
+
<Y_2>400000</Y_2>
|
|
128
|
+
<X_3>350000</X_3>
|
|
129
|
+
<Y_3>450000</Y_3>
|
|
130
|
+
</Shape_1>
|
|
131
|
+
```
|
|
132
|
+
This example above is cut down for length reasons
|
|
133
|
+
|
|
134
|
+
If you want to see a real world example and the full example above you can look here:
|
|
135
|
+
[ndpa --> LMD(xml) examples](ndpa_to_LMD_examples/)
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
## Codebase Layout
|
|
139
|
+
```
|
|
140
|
+
src/
|
|
141
|
+
├── MicroBridge/
|
|
142
|
+
│ ├── __init__.py
|
|
143
|
+
│ ├── core/
|
|
144
|
+
│ │ ├── __init__.py
|
|
145
|
+
│ │ ├── conversion.py
|
|
146
|
+
│ │ └── utils.py
|
|
147
|
+
│ ├── cli/
|
|
148
|
+
│ │ ├── __init__.py
|
|
149
|
+
│ │ └── main.py
|
|
150
|
+
│ └── gui/
|
|
151
|
+
│ ├── __init__.py
|
|
152
|
+
│ └── main.py
|
|
153
|
+
tests/
|
|
154
|
+
├── __init__.py
|
|
155
|
+
├── README.md
|
|
156
|
+
├── test_core.py
|
|
157
|
+
├── test_gui.py
|
|
158
|
+
├── test_intergration.py
|
|
159
|
+
├── test_utils.py
|
|
160
|
+
├── test_cli.py
|
|
161
|
+
└── test_Data/
|
|
162
|
+
└── All the test data, I am not writing that out...
|
|
163
|
+
|
|
164
|
+
pyproject.toml
|
|
165
|
+
run_tests.py
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
# License
|
|
171
|
+
This project is licensed under the [GNU GPLv3.0 License](LICENSE)
|
|
172
|
+
This is important as it support copyleft! and Free software!
|