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.
- flimkit/FLIM/__init__.py +0 -0
- flimkit/FLIM/assemble.py +254 -0
- flimkit/FLIM/batch.py +681 -0
- flimkit/FLIM/bg_tools.py +51 -0
- flimkit/FLIM/fit_tools.py +244 -0
- flimkit/FLIM/fitters.py +1471 -0
- flimkit/FLIM/irf_tools.py +617 -0
- flimkit/FLIM/models.py +391 -0
- flimkit/GPU/__init__.py +85 -0
- flimkit/GPU/_base.py +391 -0
- flimkit/GPU/cuda.py +10 -0
- flimkit/GPU/mlx_backend.py +381 -0
- flimkit/GPU/mps.py +10 -0
- flimkit/GPU/rocm.py +10 -0
- flimkit/GPU/torch_backend.py +385 -0
- flimkit/UI/app_state.py +10 -0
- flimkit/UI/controller.py +139 -0
- flimkit/UI/expert_settings.py +248 -0
- flimkit/UI/fit_help.py +206 -0
- flimkit/UI/fov_preview.py +1085 -0
- flimkit/UI/gui.py +3919 -0
- flimkit/UI/icon.icns +0 -0
- flimkit/UI/icon.ico +0 -0
- flimkit/UI/icon.png +0 -0
- flimkit/UI/irf_widget.py +103 -0
- flimkit/UI/mode_controller.py +118 -0
- flimkit/UI/modes/__init__.py +0 -0
- flimkit/UI/modes/base.py +3 -0
- flimkit/UI/modes/batch_mode.py +312 -0
- flimkit/UI/modes/fov_mode.py +164 -0
- flimkit/UI/modes/irf_mode.py +80 -0
- flimkit/UI/modes/phasor_mode.py +131 -0
- flimkit/UI/modes/stitch_mode.py +254 -0
- flimkit/UI/phasor_panel.py +1087 -0
- flimkit/UI/progress_window.py +113 -0
- flimkit/UI/project_panel.py +262 -0
- flimkit/UI/results_panel.py +332 -0
- flimkit/UI/roi_tools.py +794 -0
- flimkit/UI/utils.py +217 -0
- flimkit/__init__.py +0 -0
- flimkit/_version.py +41 -0
- flimkit/cli.py +120 -0
- flimkit/configs.py +148 -0
- flimkit/dialogs.py +46 -0
- flimkit/formats/BH/__init__.py +0 -0
- flimkit/formats/BH/reader.py +296 -0
- flimkit/formats/BH/writer.py +86 -0
- flimkit/formats/ISS/__init__.py +0 -0
- flimkit/formats/ISS/fdflim.py +86 -0
- flimkit/formats/ISS/image.py +114 -0
- flimkit/formats/ISS/reader.py +223 -0
- flimkit/formats/PS/__init__.py +0 -0
- flimkit/formats/PS/reader.py +202 -0
- flimkit/formats/PTU/__init__.py +0 -0
- flimkit/formats/PTU/decode.py +27 -0
- flimkit/formats/PTU/phu.py +85 -0
- flimkit/formats/PTU/reader.py +235 -0
- flimkit/formats/PTU/series.py +258 -0
- flimkit/formats/PTU/stitch.py +1182 -0
- flimkit/formats/PTU/tools.py +94 -0
- flimkit/formats/__init__.py +2 -0
- flimkit/formats/flim_file.py +232 -0
- flimkit/formats/phasor.py +132 -0
- flimkit/formats/signal.py +170 -0
- flimkit/image/tools.py +124 -0
- flimkit/interactive.py +1857 -0
- flimkit/mpl_backend.py +22 -0
- flimkit/phasor/__init__.py +40 -0
- flimkit/phasor/filters.py +127 -0
- flimkit/phasor/fret.py +654 -0
- flimkit/phasor/interactive.py +556 -0
- flimkit/phasor/peaks.py +186 -0
- flimkit/phasor/signal.py +90 -0
- flimkit/phasor_launcher.py +314 -0
- flimkit/plugins/__init__.py +137 -0
- flimkit/plugins/bindings.py +116 -0
- flimkit/plugins/builtin/__init__.py +3 -0
- flimkit/plugins/builtin/core_tools.py +28 -0
- flimkit/plugins/loader.py +371 -0
- flimkit/plugins/registry.py +406 -0
- flimkit/project.py +197 -0
- flimkit/synth.py +145 -0
- flimkit/utils/__init__.py +0 -0
- flimkit/utils/batch_fit.py +301 -0
- flimkit/utils/config_manager.py +119 -0
- flimkit/utils/config_snapshot.py +30 -0
- flimkit/utils/crash_handler.py +183 -0
- flimkit/utils/display.py +197 -0
- flimkit/utils/enhanced_outputs.py +345 -0
- flimkit/utils/fancy.py +103 -0
- flimkit/utils/lifetime_image.py +243 -0
- flimkit/utils/misc.py +111 -0
- flimkit/utils/plotting.py +190 -0
- flimkit/utils/roi.py +370 -0
- flimkit/utils/session.py +51 -0
- flimkit/utils/update_check.py +198 -0
- flimkit/utils/xlsx_tools.py +97 -0
- flimkit/utils/xml_utils.py +219 -0
- flimkit-0.12.0.dist-info/METADATA +356 -0
- flimkit-0.12.0.dist-info/RECORD +104 -0
- flimkit-0.12.0.dist-info/WHEEL +5 -0
- flimkit-0.12.0.dist-info/entry_points.txt +2 -0
- flimkit-0.12.0.dist-info/licenses/LICENSE.md +11 -0
- flimkit-0.12.0.dist-info/top_level.txt +1 -0
flimkit/utils/session.py
ADDED
|
@@ -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
|