flimkit 0.12.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.
Files changed (104) hide show
  1. flimkit/FLIM/__init__.py +0 -0
  2. flimkit/FLIM/assemble.py +254 -0
  3. flimkit/FLIM/batch.py +681 -0
  4. flimkit/FLIM/bg_tools.py +51 -0
  5. flimkit/FLIM/fit_tools.py +244 -0
  6. flimkit/FLIM/fitters.py +1471 -0
  7. flimkit/FLIM/irf_tools.py +617 -0
  8. flimkit/FLIM/models.py +391 -0
  9. flimkit/GPU/__init__.py +85 -0
  10. flimkit/GPU/_base.py +391 -0
  11. flimkit/GPU/cuda.py +10 -0
  12. flimkit/GPU/mlx_backend.py +381 -0
  13. flimkit/GPU/mps.py +10 -0
  14. flimkit/GPU/rocm.py +10 -0
  15. flimkit/GPU/torch_backend.py +385 -0
  16. flimkit/UI/app_state.py +10 -0
  17. flimkit/UI/controller.py +139 -0
  18. flimkit/UI/expert_settings.py +248 -0
  19. flimkit/UI/fit_help.py +206 -0
  20. flimkit/UI/fov_preview.py +1085 -0
  21. flimkit/UI/gui.py +3919 -0
  22. flimkit/UI/icon.icns +0 -0
  23. flimkit/UI/icon.ico +0 -0
  24. flimkit/UI/icon.png +0 -0
  25. flimkit/UI/irf_widget.py +103 -0
  26. flimkit/UI/mode_controller.py +118 -0
  27. flimkit/UI/modes/__init__.py +0 -0
  28. flimkit/UI/modes/base.py +3 -0
  29. flimkit/UI/modes/batch_mode.py +312 -0
  30. flimkit/UI/modes/fov_mode.py +164 -0
  31. flimkit/UI/modes/irf_mode.py +80 -0
  32. flimkit/UI/modes/phasor_mode.py +131 -0
  33. flimkit/UI/modes/stitch_mode.py +254 -0
  34. flimkit/UI/phasor_panel.py +1087 -0
  35. flimkit/UI/progress_window.py +113 -0
  36. flimkit/UI/project_panel.py +262 -0
  37. flimkit/UI/results_panel.py +332 -0
  38. flimkit/UI/roi_tools.py +794 -0
  39. flimkit/UI/utils.py +217 -0
  40. flimkit/__init__.py +0 -0
  41. flimkit/_version.py +41 -0
  42. flimkit/cli.py +120 -0
  43. flimkit/configs.py +148 -0
  44. flimkit/dialogs.py +46 -0
  45. flimkit/formats/BH/__init__.py +0 -0
  46. flimkit/formats/BH/reader.py +296 -0
  47. flimkit/formats/BH/writer.py +86 -0
  48. flimkit/formats/ISS/__init__.py +0 -0
  49. flimkit/formats/ISS/fdflim.py +86 -0
  50. flimkit/formats/ISS/image.py +114 -0
  51. flimkit/formats/ISS/reader.py +223 -0
  52. flimkit/formats/PS/__init__.py +0 -0
  53. flimkit/formats/PS/reader.py +202 -0
  54. flimkit/formats/PTU/__init__.py +0 -0
  55. flimkit/formats/PTU/decode.py +27 -0
  56. flimkit/formats/PTU/phu.py +85 -0
  57. flimkit/formats/PTU/reader.py +235 -0
  58. flimkit/formats/PTU/series.py +258 -0
  59. flimkit/formats/PTU/stitch.py +1182 -0
  60. flimkit/formats/PTU/tools.py +94 -0
  61. flimkit/formats/__init__.py +2 -0
  62. flimkit/formats/flim_file.py +232 -0
  63. flimkit/formats/phasor.py +132 -0
  64. flimkit/formats/signal.py +170 -0
  65. flimkit/image/tools.py +124 -0
  66. flimkit/interactive.py +1857 -0
  67. flimkit/mpl_backend.py +22 -0
  68. flimkit/phasor/__init__.py +40 -0
  69. flimkit/phasor/filters.py +127 -0
  70. flimkit/phasor/fret.py +654 -0
  71. flimkit/phasor/interactive.py +556 -0
  72. flimkit/phasor/peaks.py +186 -0
  73. flimkit/phasor/signal.py +90 -0
  74. flimkit/phasor_launcher.py +314 -0
  75. flimkit/plugins/__init__.py +137 -0
  76. flimkit/plugins/bindings.py +116 -0
  77. flimkit/plugins/builtin/__init__.py +3 -0
  78. flimkit/plugins/builtin/core_tools.py +28 -0
  79. flimkit/plugins/loader.py +371 -0
  80. flimkit/plugins/registry.py +406 -0
  81. flimkit/project.py +197 -0
  82. flimkit/synth.py +145 -0
  83. flimkit/utils/__init__.py +0 -0
  84. flimkit/utils/batch_fit.py +301 -0
  85. flimkit/utils/config_manager.py +119 -0
  86. flimkit/utils/config_snapshot.py +30 -0
  87. flimkit/utils/crash_handler.py +183 -0
  88. flimkit/utils/display.py +197 -0
  89. flimkit/utils/enhanced_outputs.py +345 -0
  90. flimkit/utils/fancy.py +103 -0
  91. flimkit/utils/lifetime_image.py +243 -0
  92. flimkit/utils/misc.py +111 -0
  93. flimkit/utils/plotting.py +190 -0
  94. flimkit/utils/roi.py +370 -0
  95. flimkit/utils/session.py +51 -0
  96. flimkit/utils/update_check.py +198 -0
  97. flimkit/utils/xlsx_tools.py +97 -0
  98. flimkit/utils/xml_utils.py +219 -0
  99. flimkit-0.12.0.dist-info/METADATA +356 -0
  100. flimkit-0.12.0.dist-info/RECORD +104 -0
  101. flimkit-0.12.0.dist-info/WHEEL +5 -0
  102. flimkit-0.12.0.dist-info/entry_points.txt +2 -0
  103. flimkit-0.12.0.dist-info/licenses/LICENSE.md +11 -0
  104. flimkit-0.12.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,51 @@
1
+ import json
2
+ import re
3
+ import numpy as np
4
+
5
+ def _reconstruct_dict_from_session(session_data: dict, key: str) -> dict:
6
+ result = {}
7
+ json_str = session_data.get(f'{key}_json')
8
+ if json_str:
9
+ if isinstance(json_str, (bytes, np.ndarray)):
10
+ json_str = json_str.item() if hasattr(json_str, 'item') else json_str.decode()
11
+ try:
12
+ result = json.loads(json_str)
13
+ except Exception:
14
+ pass
15
+ prefix = f'{key}_arr_'
16
+ for skey, sval in session_data.items():
17
+ if skey.startswith(prefix) and isinstance(sval, np.ndarray):
18
+ result[skey[len(prefix):]] = sval
19
+ return result
20
+
21
+ def _safe_array_from_json(value) -> np.ndarray:
22
+ if isinstance(value, np.ndarray):
23
+ return value
24
+ if isinstance(value, (bytes, np.ndarray)):
25
+ if hasattr(value, 'item'):
26
+ value = value.item()
27
+ else:
28
+ value = value.decode() if isinstance(value, bytes) else str(value)
29
+ if isinstance(value, str):
30
+ try:
31
+ value = re.sub(r'\s+', ' ', value.strip())
32
+ value = value.replace('e+', 'e+').replace('e-', 'e-')
33
+ return np.fromstring(value.strip('[]'), sep=' ')
34
+ except Exception:
35
+ pass
36
+ return np.asarray(value)
37
+
38
+ def _parse_summary(captured_log: str) -> list:
39
+ rows = []
40
+ for line in captured_log.splitlines():
41
+ if 'tau' in line.lower() and '=' in line:
42
+ parts = line.split('=', 1)
43
+ if len(parts) == 2:
44
+ param = parts[0].strip()
45
+ rest = parts[1].strip()
46
+ val_unit = rest.split()
47
+ if len(val_unit) >= 2:
48
+ rows.append((param, val_unit[0], val_unit[1]))
49
+ else:
50
+ rows.append((param, rest, ''))
51
+ return rows
@@ -0,0 +1,198 @@
1
+ import json
2
+ import re
3
+ import subprocess
4
+ import sys
5
+ import urllib.error
6
+ import urllib.request
7
+ from pathlib import Path
8
+ from flimkit._version import __version__
9
+
10
+ def _run_git(args, cwd):
11
+ try:
12
+ proc = subprocess.run(
13
+ ['git', *args],
14
+ cwd=str(cwd),
15
+ capture_output=True,
16
+ text=True,
17
+ check=False,
18
+ )
19
+ except Exception as exc:
20
+ return False, str(exc)
21
+ if proc.returncode != 0:
22
+ msg = (proc.stderr or proc.stdout or '').strip()
23
+ return False, msg
24
+ return True, (proc.stdout or '').strip()
25
+
26
+ def _parse_github_repo(remote_url):
27
+ remote = (remote_url or '').strip()
28
+ if not remote:
29
+ return None
30
+ remote = remote.replace('.git', '')
31
+ https_match = re.search(r'github\.com[:/]([^/]+)/([^/]+)$', remote)
32
+ if https_match:
33
+ return f"{https_match.group(1)}/{https_match.group(2)}"
34
+ return None
35
+
36
+
37
+ def _parse_version_tuple(value):
38
+ match = re.search(r'(\d+)\.(\d+)\.(\d+)', value or '')
39
+ if not match:
40
+ return None
41
+ return tuple(int(x) for x in match.groups())
42
+
43
+
44
+ def _compare_versions(current_version, latest_version):
45
+ current = _parse_version_tuple(current_version)
46
+ latest = _parse_version_tuple(latest_version)
47
+ if current is None or latest is None:
48
+ return None
49
+ if current == latest:
50
+ return 0
51
+ return -1 if current < latest else 1
52
+
53
+
54
+ def _github_json(url, timeout):
55
+ req = urllib.request.Request(
56
+ url,
57
+ headers={
58
+ 'Accept': 'application/vnd.github+json',
59
+ 'User-Agent': 'FLIMKit-update-check',
60
+ },
61
+ )
62
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
63
+ body = resp.read().decode('utf-8')
64
+ return json.loads(body)
65
+
66
+
67
+ def _get_latest_version_from_github(repo_slug, timeout):
68
+ latest_release_url = f"https://api.github.com/repos/{repo_slug}/releases/latest"
69
+ try:
70
+ rel = _github_json(latest_release_url, timeout=timeout)
71
+ tag = (rel.get('tag_name') or '').strip()
72
+ if tag:
73
+ return tag.lstrip('vV'), 'release', None
74
+ except urllib.error.HTTPError as exc:
75
+ if exc.code != 404:
76
+ return None, None, f"GitHub HTTP error: {exc.code}"
77
+ except Exception as exc:
78
+ return None, None, f"GitHub error: {exc}"
79
+ tags_url = f"https://api.github.com/repos/{repo_slug}/tags?per_page=1"
80
+ try:
81
+ tags = _github_json(tags_url, timeout=timeout)
82
+ if isinstance(tags, list) and tags:
83
+ tag_name = (tags[0].get('name') or '').strip()
84
+ if tag_name:
85
+ return tag_name.lstrip('vV'), 'tag', None
86
+ return None, None, 'No release/tag data found'
87
+ except Exception as exc:
88
+ return None, None, f"GitHub error: {exc}"
89
+
90
+
91
+ def check_installation_freshness(timeout=3.0, do_fetch=True):
92
+ status = {
93
+ 'current_version': __version__,
94
+ 'is_compiled': bool(getattr(sys, 'frozen', False)),
95
+ 'git': {
96
+ 'is_repo': False,
97
+ 'branch': None,
98
+ 'upstream': None,
99
+ 'ahead': None,
100
+ 'behind': None,
101
+ 'is_up_to_date': None,
102
+ 'error': None,
103
+ },
104
+ 'release': {
105
+ 'repo': None,
106
+ 'latest_version': None,
107
+ 'source': None,
108
+ 'is_latest': None,
109
+ 'error': None,
110
+ },
111
+ }
112
+ cwd = Path.cwd()
113
+ ok, repo_root = _run_git(['rev-parse', '--show-toplevel'], cwd=cwd)
114
+ if ok and repo_root:
115
+ repo_path = Path(repo_root)
116
+ status['git']['is_repo'] = True
117
+
118
+ ok, branch = _run_git(['rev-parse', '--abbrev-ref', 'HEAD'], cwd=repo_path)
119
+ if ok:
120
+ status['git']['branch'] = branch
121
+ ok, upstream = _run_git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], cwd=repo_path)
122
+ if ok:
123
+ status['git']['upstream'] = upstream
124
+ if do_fetch:
125
+ _run_git(['fetch', '--quiet'], cwd=repo_path)
126
+ ok, counts = _run_git(['rev-list', '--left-right', '--count', f"HEAD...{upstream}"], cwd=repo_path)
127
+ if ok:
128
+ parts = counts.split()
129
+ if len(parts) >= 2:
130
+ ahead, behind = int(parts[0]), int(parts[1])
131
+ status['git']['ahead'] = ahead
132
+ status['git']['behind'] = behind
133
+ status['git']['is_up_to_date'] = behind == 0
134
+ else:
135
+ status['git']['error'] = counts
136
+ else:
137
+ status['git']['error'] = upstream
138
+ ok, remote_url = _run_git(['config', '--get', 'remote.origin.url'], cwd=repo_path)
139
+ if ok:
140
+ repo_slug = _parse_github_repo(remote_url)
141
+ status['release']['repo'] = repo_slug
142
+ if repo_slug:
143
+ latest, source, err = _get_latest_version_from_github(repo_slug, timeout=timeout)
144
+ status['release']['latest_version'] = latest
145
+ status['release']['source'] = source
146
+ status['release']['error'] = err
147
+ if latest:
148
+ cmp_res = _compare_versions(__version__, latest)
149
+ status['release']['is_latest'] = (cmp_res is not None and cmp_res >= 0)
150
+ else:
151
+ status['release']['error'] = 'Could not parse GitHub repo from remote.origin.url'
152
+ else:
153
+ status['release']['error'] = remote_url
154
+ else:
155
+ status['git']['error'] = repo_root or 'Not a git repository'
156
+ return status
157
+
158
+
159
+ def format_update_report(status):
160
+ git = status.get('git', {})
161
+ rel = status.get('release', {})
162
+ lines = [
163
+ 'Update check:',
164
+ f" Current version: {status.get('current_version')}",
165
+ ]
166
+ if git.get('is_repo'):
167
+ branch = git.get('branch') or 'unknown'
168
+ upstream = git.get('upstream') or '(no upstream)'
169
+ lines.append(f" Git branch/upstream: {branch} -> {upstream}")
170
+ if git.get('ahead') is not None and git.get('behind') is not None:
171
+ lines.append(
172
+ f" Git sync status: ahead {git['ahead']}, behind {git['behind']}"
173
+ )
174
+ if git.get('is_up_to_date'):
175
+ lines.append(' Git result: up to date with upstream')
176
+ else:
177
+ lines.append(' Git result: local branch is behind upstream')
178
+ elif git.get('error'):
179
+ lines.append(f" Git result: unknown ({git['error']})")
180
+ else:
181
+ lines.append(' Git result: not running inside a git checkout')
182
+
183
+ latest = rel.get('latest_version')
184
+ if latest:
185
+ source = rel.get('source') or 'release'
186
+ lines.append(f" Latest available ({source}): {latest}")
187
+ if rel.get('is_latest') is True:
188
+ lines.append(' Version result: local version is up to date')
189
+ elif rel.get('is_latest') is False:
190
+ lines.append(' Version result: local version is older than latest available')
191
+ else:
192
+ lines.append(' Version result: could not compare versions')
193
+ elif rel.get('error'):
194
+ lines.append(f" Latest available: unknown ({rel['error']})")
195
+ else:
196
+ lines.append(' Latest available: unknown')
197
+
198
+ return '\n'.join(lines)
@@ -0,0 +1,97 @@
1
+ from pathlib import Path
2
+
3
+ import pandas as pd
4
+
5
+ def _csv_layout(path: str | Path) -> tuple[str, int]:
6
+ with Path(path).open(encoding='utf-8-sig') as export:
7
+ for line_number, line in enumerate(export):
8
+ if 'time [' not in line.lower():
9
+ continue
10
+ counts = {delimiter: line.count(delimiter) for delimiter in ('\t', ',', ';')}
11
+ delimiter = max(counts, key=lambda item: counts[item])
12
+ if counts[delimiter]:
13
+ return delimiter, line_number
14
+ raise ValueError(
15
+ f'Could not parse {Path(path).name}: no tab, comma or semicolon '
16
+ 'delimited header row containing "Time [" was found.'
17
+ )
18
+
19
+ def _read_export(path: str | Path, header=None) -> pd.DataFrame:
20
+ suffix = Path(path).suffix.lower()
21
+ if suffix == '.xlsx':
22
+ return pd.read_excel(path, sheet_name=0, header=header)
23
+ if suffix in ('.csv', '.tsv', '.txt', '.dat', '.ascii', '.asc'):
24
+ delimiter, header_line = _csv_layout(path)
25
+ decimal = ',' if delimiter == ';' else '.'
26
+ return pd.read_csv(path, sep=delimiter, decimal=decimal, header=header,
27
+ skiprows=header_line, encoding='utf-8-sig')
28
+ raise ValueError(
29
+ f'Unsupported IRF export format: {suffix or "<none>"}. '
30
+ 'Expected .xlsx, or a delimited text export '
31
+ '(.csv, .tsv, .txt, .dat, .ascii, .asc).'
32
+ )
33
+
34
+ def load_irf_export(path: str | Path, debug: bool = False) -> dict:
35
+ df_raw = _read_export(path, header=None)
36
+ if debug:
37
+ print(f' Raw export shape: {df_raw.shape}')
38
+ print(f' First 5 rows:')
39
+ for i in range(min(5, len(df_raw))):
40
+ vals = [str(v) for v in df_raw.iloc[i].values if pd.notna(v)]
41
+ print(f' row {i}: {vals}')
42
+ header_row = None
43
+ for i, row in df_raw.iterrows():
44
+ vals = [str(v).strip().lower() for v in row if pd.notna(v)]
45
+ if any(v.startswith('time [') for v in vals):
46
+ header_row = i
47
+ break
48
+ if header_row is None:
49
+ print(f" No row starting with 'Time [' found - trying row 0 as fallback")
50
+ header_row = 0
51
+ if debug:
52
+ print(f' Detected header row: {header_row}')
53
+ df = _read_export(path, header=header_row)
54
+ df = df.dropna(axis=1, how='all')
55
+ col_names = list(df.columns)
56
+ if debug:
57
+ print(f' Columns after read: {col_names}')
58
+ time_cols = [c for c in col_names if str(c).lower().startswith('time [')]
59
+ decay_cols = [c for c in col_names if 'decay' in str(c).lower() and
60
+ 'counts' in str(c).lower()]
61
+ irf_cols = [c for c in col_names if 'irf' in str(c).lower() and
62
+ 'counts' in str(c).lower()]
63
+ fit_cols = [c for c in col_names if 'fit' in str(c).lower() and
64
+ 'counts' in str(c).lower()]
65
+ res_cols = [c for c in col_names if 'resid' in str(c).lower() and
66
+ 'counts' in str(c).lower()]
67
+ if debug:
68
+ print(f' time_cols : {time_cols}')
69
+ print(f' decay_cols: {decay_cols}')
70
+ print(f' irf_cols : {irf_cols}')
71
+ print(f' fit_cols : {fit_cols}')
72
+ print(f' res_cols : {res_cols}')
73
+ def _safe(col):
74
+ if col is None:
75
+ return None
76
+ arr = df[col].dropna().values
77
+ try:
78
+ return arr.astype(float)
79
+ except (ValueError, TypeError):
80
+ return None
81
+ out = {
82
+ 'decay_t': _safe(time_cols[0] if len(time_cols) > 0 else None),
83
+ 'decay_c': _safe(decay_cols[0] if len(decay_cols) > 0 else None),
84
+ 'irf_t': _safe(time_cols[1] if len(time_cols) > 1 else None),
85
+ 'irf_c': _safe(irf_cols[0] if len(irf_cols) > 0 else None),
86
+ 'fit_t': _safe(time_cols[2] if len(time_cols) > 2 else None),
87
+ 'fit_c': _safe(fit_cols[0] if len(fit_cols) > 0 else None),
88
+ 'res_t': _safe(time_cols[3] if len(time_cols) > 3 else None),
89
+ 'res_c': _safe(res_cols[0] if len(res_cols) > 0 else None),
90
+ }
91
+ for k, v in out.items():
92
+ status = f'{len(v)} pts' if v is not None else 'absent'
93
+ print(f' {k:12s}: {status}')
94
+ return out
95
+
96
+ def load_xlsx(path: str | Path, debug: bool = False) -> dict:
97
+ return load_irf_export(path, debug=debug)
@@ -0,0 +1,219 @@
1
+ import re
2
+ import xml.etree.ElementTree as ET
3
+ from pathlib import Path
4
+ from typing import List, Dict, Any, Tuple, Optional
5
+
6
+ TILESCAN_ATTACHMENT = "./Data/Image/Attachment[@Name='TileScanInfo']"
7
+ DIMENSION_X = "./Data/Image/ImageDescription/Dimensions/DimensionDescription[@DimID='1']"
8
+
9
+ def _tiles_from_tilescan(tile_scan_info, ptu_basename: str) -> List[Dict[str, Any]]:
10
+ tile_positions = []
11
+ for scan_index, tile_elem in enumerate(tile_scan_info.findall('Tile')):
12
+ field_x = int(tile_elem.attrib.get('FieldX', 0))
13
+ field_y = int(tile_elem.attrib.get('FieldY', 0))
14
+ pos_x = float(tile_elem.attrib.get('PosX', 0))
15
+ pos_y = float(tile_elem.attrib.get('PosY', 0))
16
+ filename = f'{ptu_basename}_s{scan_index + 1}.ptu'
17
+ tile_positions.append({
18
+ 'file': filename,
19
+ 'scan_index': scan_index,
20
+ 'field_x': field_x,
21
+ 'field_y': field_y,
22
+ 'pos_x': pos_x,
23
+ 'pos_y': pos_y,
24
+ })
25
+ return tile_positions
26
+
27
+ def parse_xlif_tile_positions(xlif_path: Path, ptu_basename: str = 'R 2') -> List[Dict[str, Any]]:
28
+ tree = ET.parse(xlif_path)
29
+ root = tree.getroot()
30
+ tile_scan_info = root.find(".//Attachment[@Name='TileScanInfo']")
31
+ if tile_scan_info is None:
32
+ raise RuntimeError(f"No TileScanInfo found in {xlif_path}")
33
+ return _tiles_from_tilescan(tile_scan_info, ptu_basename)
34
+
35
+ def _lif_root(lif_path: Path):
36
+ import liffile
37
+ with liffile.LifFile(str(lif_path)) as lif:
38
+ return lif.xml_element
39
+
40
+ def list_lif_mosaics(lif_path: Path) -> List[Dict[str, Any]]:
41
+ root = _lif_root(lif_path)
42
+ parents = {child: parent for parent in root.iter() for child in parent}
43
+ mosaics = []
44
+ for elem in root.iter('Element'):
45
+ tile_scan_info = elem.find(TILESCAN_ATTACHMENT)
46
+ if tile_scan_info is None:
47
+ continue
48
+ tiles = tile_scan_info.findall('Tile')
49
+ if len(tiles) < 2:
50
+ continue
51
+ depth = 0
52
+ node = elem
53
+ while node in parents:
54
+ node = parents[node]
55
+ depth += 1
56
+ mosaics.append({
57
+ 'name': elem.attrib.get('Name', ''),
58
+ 'n_tiles': len(tiles),
59
+ 'depth': depth,
60
+ 'element': elem,
61
+ })
62
+ mosaics.sort(key=lambda m: (m['depth'], m['name']))
63
+ return mosaics
64
+
65
+ def _select_lif_mosaic(lif_path: Path, ptu_basename: str):
66
+ mosaics = list_lif_mosaics(lif_path)
67
+ if not mosaics:
68
+ raise RuntimeError(f'No tilescan (multi-tile TileScanInfo) found in {lif_path}')
69
+ named = [m for m in mosaics if m['name'] == ptu_basename]
70
+ if not named:
71
+ available = ', '.join(f"{m['name']!r} ({m['n_tiles']} tiles)" for m in mosaics)
72
+ raise RuntimeError(
73
+ f'No tilescan named {ptu_basename!r} in {lif_path}. Available: {available}')
74
+ return named[0]['element']
75
+
76
+ def parse_lif_tile_positions(lif_path: Path, ptu_basename: str) -> List[Dict[str, Any]]:
77
+ element = _select_lif_mosaic(lif_path, ptu_basename)
78
+ return _tiles_from_tilescan(element.find(TILESCAN_ATTACHMENT), ptu_basename)
79
+
80
+ def get_pixel_size_from_lif(lif_path: Path, ptu_basename: str) -> Tuple[float, int]:
81
+ element = _select_lif_mosaic(lif_path, ptu_basename)
82
+ dim_desc = element.find(DIMENSION_X)
83
+ if dim_desc is None:
84
+ dim_desc = element.find(".//DimensionDescription[@DimID='1']")
85
+ if dim_desc is not None:
86
+ n_pixels = int(dim_desc.attrib.get('NumberOfElements', 512))
87
+ length_m = float(dim_desc.attrib.get('Length', 1.5377e-4))
88
+ return length_m / n_pixels, n_pixels
89
+ return 1.5377e-4 / 512, 512
90
+
91
+ def list_xlef_references(xlef_path: Path) -> List[Dict[str, Any]]:
92
+ from urllib.parse import unquote
93
+ xlef_path = Path(xlef_path)
94
+ root = ET.parse(xlef_path).getroot()
95
+ found = []
96
+ for reference in root.iter('Reference'):
97
+ raw = reference.attrib.get('File')
98
+ if not raw:
99
+ continue
100
+ relative = unquote(raw).replace('\\', '/')
101
+ target = (xlef_path.parent / relative).resolve()
102
+ if target.suffix.lower() != '.xlif':
103
+ continue
104
+ found.append({'name': target.stem, 'path': target})
105
+ return found
106
+
107
+ def _matches_basename(name: str, ptu_basename: str) -> bool:
108
+ def flatten(value):
109
+ return value.lower().replace(' ', '').replace('_', '').replace('-', '')
110
+ return flatten(name) == flatten(ptu_basename)
111
+
112
+ def resolve_xlef(xlef_path: Path, ptu_basename: str = None) -> Path:
113
+ xlef_path = Path(xlef_path)
114
+ references = list_xlef_references(xlef_path)
115
+ present = [entry for entry in references if entry['path'].exists()]
116
+ if not present:
117
+ raise RuntimeError(
118
+ f'{xlef_path.name} names {len(references)} acquisitions but none of the '
119
+ f'.xlif files are in {xlef_path.parent}')
120
+ if ptu_basename:
121
+ for entry in present:
122
+ if _matches_basename(entry['name'], ptu_basename):
123
+ return entry['path']
124
+ if len(present) == 1:
125
+ return present[0]['path']
126
+ names = ', '.join(entry['name'] for entry in present)
127
+ raise RuntimeError(
128
+ f'{xlef_path.name} holds {len(present)} acquisitions and '
129
+ f'{ptu_basename!r} is not one of them; choose from {names}')
130
+
131
+ def parse_tile_positions(metadata_path: Path, ptu_basename: str) -> List[Dict[str, Any]]:
132
+ metadata_path = Path(metadata_path)
133
+ if metadata_path.suffix.lower() == '.lif':
134
+ return parse_lif_tile_positions(metadata_path, ptu_basename)
135
+ if metadata_path.suffix.lower() == '.xlef':
136
+ return parse_xlif_tile_positions(
137
+ resolve_xlef(metadata_path, ptu_basename), ptu_basename)
138
+ return parse_xlif_tile_positions(metadata_path, ptu_basename)
139
+
140
+ def get_pixel_size(metadata_path: Path, ptu_basename: str = None) -> Tuple[float, int]:
141
+ metadata_path = Path(metadata_path)
142
+ if metadata_path.suffix.lower() == '.lif':
143
+ return get_pixel_size_from_lif(metadata_path, ptu_basename)
144
+ if metadata_path.suffix.lower() == '.xlef':
145
+ return get_pixel_size_from_xlif(resolve_xlef(metadata_path, ptu_basename))
146
+ return get_pixel_size_from_xlif(metadata_path)
147
+
148
+ def get_pixel_size_from_xlif(xlif_path: Path) -> Tuple[float, int]:
149
+ tree = ET.parse(xlif_path)
150
+ root = tree.getroot()
151
+ dim_desc = root.find(".//DimensionDescription[@DimID='1']")
152
+ if dim_desc is not None:
153
+ n_pixels = int(dim_desc.attrib.get('NumberOfElements', 512))
154
+ length_m = float(dim_desc.attrib.get('Length', 1.5377e-4))
155
+ pixel_size_m = length_m / n_pixels
156
+ return pixel_size_m, n_pixels
157
+ return 1.5377e-4 / 512, 512
158
+
159
+ def compute_tile_pixel_positions(
160
+ tile_positions: List[Dict[str, Any]],
161
+ pixel_size_m: float,
162
+ tile_size: int
163
+ ) -> Tuple[List[Dict[str, Any]], int, int]:
164
+ pos_x_list = [t['pos_x'] for t in tile_positions]
165
+ pos_y_list = [t['pos_y'] for t in tile_positions]
166
+ min_pos_x = min(pos_x_list)
167
+ min_pos_y = min(pos_y_list)
168
+ for t in tile_positions:
169
+ t['pixel_x'] = int(round((t['pos_x'] - min_pos_x) / pixel_size_m))
170
+ t['pixel_y'] = int(round((t['pos_y'] - min_pos_y) / pixel_size_m))
171
+ canvas_width = max(t['pixel_x'] for t in tile_positions) + tile_size
172
+ canvas_height = max(t['pixel_y'] for t in tile_positions) + tile_size
173
+ return tile_positions, canvas_width, canvas_height
174
+
175
+ def match_xml_ptu_sets(ptu_dir: Path) -> List[Dict[str, Any]]:
176
+ metadata_dir = ptu_dir / 'Metadata'
177
+ xml_files = []
178
+ if metadata_dir.exists():
179
+ xml_files = (list(metadata_dir.glob('*.xlif')) +
180
+ list(metadata_dir.glob('*.xlof')) +
181
+ list(metadata_dir.glob('*.xml')))
182
+ ptu_files = list(ptu_dir.glob('*.ptu'))
183
+ r_pattern = re.compile(r'R\s*\d+')
184
+ xml_r_map = {}
185
+ for xml in xml_files:
186
+ m = r_pattern.search(xml.name)
187
+ if m:
188
+ r = m.group().replace(' ', '')
189
+ xml_r_map.setdefault(r, []).append(str(xml))
190
+ ptu_r_map = {}
191
+ for ptu in ptu_files:
192
+ m = r_pattern.search(ptu.name)
193
+ if m:
194
+ r = m.group().replace(' ', '')
195
+ ptu_r_map.setdefault(r, []).append(str(ptu))
196
+ results = []
197
+ all_r_numbers = sorted(set(xml_r_map) | set(ptu_r_map))
198
+ for r in all_r_numbers:
199
+ xmls = xml_r_map.get(r, [])
200
+ ptus = ptu_r_map.get(r, [])
201
+ if xmls and ptus:
202
+ status = 'MATCHED'
203
+ elif ptus:
204
+ status = 'MISSING_XML'
205
+ else:
206
+ status = 'MISSING_PTU'
207
+ results.append({
208
+ 'R_number': r,
209
+ 'xml_files': xmls,
210
+ 'ptu_files': ptus,
211
+ 'xml_count': len(xmls),
212
+ 'ptu_count': len(ptus),
213
+ 'status': status
214
+ })
215
+ return results
216
+
217
+ def extract_roi_number(filename: str) -> Optional[int]:
218
+ m = re.search(r'R\s*(\d+)', filename)
219
+ return int(m.group(1)) if m else None