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.
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
@@ -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()
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!