feabas-workbench 0.3.2__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.
- feabas_workbench/__init__.py +9 -0
- feabas_workbench/__main__.py +4 -0
- feabas_workbench/app.py +165 -0
- feabas_workbench/core/__init__.py +1 -0
- feabas_workbench/core/configs.py +332 -0
- feabas_workbench/core/envs.py +559 -0
- feabas_workbench/core/histmatch.py +158 -0
- feabas_workbench/core/httpserve.py +50 -0
- feabas_workbench/core/images.py +381 -0
- feabas_workbench/core/jobs.py +436 -0
- feabas_workbench/core/masks.py +697 -0
- feabas_workbench/core/maskstore.py +396 -0
- feabas_workbench/core/pipeline.py +104 -0
- feabas_workbench/core/project.py +362 -0
- feabas_workbench/core/steps.py +673 -0
- feabas_workbench/core/synthetic.py +164 -0
- feabas_workbench/core/testruns.py +243 -0
- feabas_workbench/core/tiles.py +805 -0
- feabas_workbench/resources/fold_unet_resnet34_inference_only_fp16.ckpt +4 -0
- feabas_workbench/ui/__init__.py +1 -0
- feabas_workbench/ui/bridge.py +207 -0
- feabas_workbench/ui/dialogs.py +173 -0
- feabas_workbench/ui/external.py +52 -0
- feabas_workbench/ui/main_window.py +410 -0
- feabas_workbench/ui/pages/__init__.py +3 -0
- feabas_workbench/ui/pages/base.py +117 -0
- feabas_workbench/ui/pages/page_align.py +867 -0
- feabas_workbench/ui/pages/page_export.py +290 -0
- feabas_workbench/ui/pages/page_masks.py +1068 -0
- feabas_workbench/ui/pages/page_preprocess.py +470 -0
- feabas_workbench/ui/pages/page_project.py +541 -0
- feabas_workbench/ui/pages/page_setup.py +315 -0
- feabas_workbench/ui/pages/page_stitch.py +469 -0
- feabas_workbench/ui/theme.py +78 -0
- feabas_workbench/ui/threads.py +97 -0
- feabas_workbench/ui/widgets/__init__.py +12 -0
- feabas_workbench/ui/widgets/common.py +174 -0
- feabas_workbench/ui/widgets/config_editor.py +195 -0
- feabas_workbench/ui/widgets/imageview.py +392 -0
- feabas_workbench/ui/widgets/log_panel.py +102 -0
- feabas_workbench/ui/widgets/paths.py +50 -0
- feabas_workbench/ui/widgets/section_picker.py +153 -0
- feabas_workbench/ui/widgets/step_card.py +145 -0
- feabas_workbench/ui/widgets/steps_panel.py +113 -0
- feabas_workbench/ui/widgets/tile_grid.py +88 -0
- feabas_workbench/vendor/feabas_3_0_5/LICENSE +21 -0
- feabas_workbench/vendor/feabas_3_0_5/VERSION.txt +1 -0
- feabas_workbench/vendor/feabas_3_0_5/configs/default_alignment_configs.yaml +107 -0
- feabas_workbench/vendor/feabas_3_0_5/configs/default_material_table.yaml +57 -0
- feabas_workbench/vendor/feabas_3_0_5/configs/default_stitching_configs.yaml +82 -0
- feabas_workbench/vendor/feabas_3_0_5/configs/default_thumbnail_configs.yaml +82 -0
- feabas_workbench/vendor/feabas_3_0_5/scripts/align_main.py +628 -0
- feabas_workbench/vendor/feabas_3_0_5/scripts/stitch_main.py +292 -0
- feabas_workbench/vendor/feabas_3_0_5/scripts/thumbnail_main.py +660 -0
- feabas_workbench/vendor/feabas_3_0_5/tools/compute_alignment_residues.py +76 -0
- feabas_workbench/vendor/feabas_3_0_5/tools/convert_manual_thumbnail_matches.py +43 -0
- feabas_workbench/vendor/feabas_3_0_5/tools/edit_bigwarp.ijm +39 -0
- feabas_workbench/vendor/feabas_3_0_5/tools/export_match_to_bigwarp_csv.py +61 -0
- feabas_workbench/vendor/feabas_3_0_5/tools/merge_manual_match.py +65 -0
- feabas_workbench/vendor/feabas_3_0_5/tools/normalize_aligned_meshes.py +87 -0
- feabas_workbench/vendor/feabas_3_0_5/tools/render_aligned_thumbnails.py +140 -0
- feabas_workbench/vendor/feabas_3_0_5/tools/split_wrinkles.py +39 -0
- feabas_workbench/vendor/feabas_3_0_5/tools/visualize_align_match_coverage.py +165 -0
- feabas_workbench/vendor/winfix/sitecustomize.py +148 -0
- feabas_workbench/workers/__init__.py +7 -0
- feabas_workbench/workers/common.py +38 -0
- feabas_workbench/workers/export_vast.py +216 -0
- feabas_workbench/workers/fold_predict.py +178 -0
- feabas_workbench/workers/fold_train.py +177 -0
- feabas_workbench/workers/histmatch_worker.py +35 -0
- feabas_workbench/workers/n2v_predict.py +74 -0
- feabas_workbench/workers/n2v_train.py +102 -0
- feabas_workbench/workers/reweight_matches.py +88 -0
- feabas_workbench/workers/structure_match.py +246 -0
- feabas_workbench/workers/yolo_detect.py +138 -0
- feabas_workbench/workers/yolo_train.py +58 -0
- feabas_workbench-0.3.2.dist-info/METADATA +250 -0
- feabas_workbench-0.3.2.dist-info/RECORD +84 -0
- feabas_workbench-0.3.2.dist-info/WHEEL +5 -0
- feabas_workbench-0.3.2.dist-info/entry_points.txt +5 -0
- feabas_workbench-0.3.2.dist-info/licenses/LICENSE +202 -0
- feabas_workbench-0.3.2.dist-info/licenses/NOTICE +15 -0
- feabas_workbench-0.3.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +21 -0
- feabas_workbench-0.3.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""FEABAS Workbench: a desktop front end around FEABAS for serial-section EM."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.3.2"
|
|
4
|
+
APP_NAME = "FEABAS Workbench"
|
|
5
|
+
ORG_NAME = "FeabasWorkbench"
|
|
6
|
+
WORKBENCH_REPO = "https://github.com/georgkislinger/feabas-workbench"
|
|
7
|
+
FEABAS_REPO = "https://github.com/YuelongWu/feabas"
|
|
8
|
+
# Wu, Y. & Lichtman, J. W. (2026). FEABAS: A Stitching and Alignment Tool for Serial EM Data. bioRxiv.
|
|
9
|
+
FEABAS_PAPER = "https://doi.org/10.64898/2026.06.07.730510"
|
feabas_workbench/app.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Application entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import traceback
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _excepthook_factory(window_getter):
|
|
13
|
+
def hook(exc_type, exc, tb):
|
|
14
|
+
text = "".join(traceback.format_exception(exc_type, exc, tb))
|
|
15
|
+
sys.__stderr__.write(text)
|
|
16
|
+
try:
|
|
17
|
+
w = window_getter()
|
|
18
|
+
if w is not None:
|
|
19
|
+
w.ctx.log("Unhandled error:\n" + text, "error")
|
|
20
|
+
from PySide6.QtWidgets import QMessageBox
|
|
21
|
+
QMessageBox.critical(w, "Unexpected error", text[-2000:])
|
|
22
|
+
except Exception: # noqa: BLE001
|
|
23
|
+
pass
|
|
24
|
+
return hook
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def selfcheck(out: Path) -> int:
|
|
28
|
+
"""
|
|
29
|
+
Verify the installation without opening a window and write a JSON report: package root,
|
|
30
|
+
vendored FEABAS scripts and configs, the bundled fold checkpoint, and that the worker
|
|
31
|
+
package can be staged for the other interpreters. For CI on the frozen build and for
|
|
32
|
+
bug reports. Exit code 0 when everything is in place.
|
|
33
|
+
"""
|
|
34
|
+
import json
|
|
35
|
+
from . import __version__
|
|
36
|
+
from .core.jobs import package_root, worker_package_root, WORKER_PACKAGE_PARTS
|
|
37
|
+
from .core.masks import bundled_fold_checkpoint
|
|
38
|
+
from .core.project import VENDOR_DIR, DEFAULT_CONFIG_NAMES
|
|
39
|
+
from .core.envs import Settings, settings_dir
|
|
40
|
+
rep: dict = {"version": __version__, "frozen": bool(getattr(sys, "frozen", False)), "python": sys.executable,
|
|
41
|
+
"package_root": str(package_root()), "settings_dir": str(settings_dir()), "problems": []}
|
|
42
|
+
for name in ("scripts/stitch_main.py", "scripts/thumbnail_main.py", "scripts/align_main.py"):
|
|
43
|
+
if not (VENDOR_DIR / name).is_file():
|
|
44
|
+
rep["problems"].append(f"vendored FEABAS script missing: {VENDOR_DIR / name}")
|
|
45
|
+
for name in DEFAULT_CONFIG_NAMES:
|
|
46
|
+
if not (VENDOR_DIR / "configs" / name).is_file():
|
|
47
|
+
rep["problems"].append(f"vendored default config missing: {name}")
|
|
48
|
+
if not (VENDOR_DIR.parent / "winfix" / "sitecustomize.py").is_file():
|
|
49
|
+
rep["problems"].append("vendor/winfix/sitecustomize.py missing (FEABAS run-time fixes)")
|
|
50
|
+
ck = bundled_fold_checkpoint()
|
|
51
|
+
rep["fold_checkpoint"] = str(ck)
|
|
52
|
+
if not ck.is_file():
|
|
53
|
+
rep["problems"].append(f"bundled fold checkpoint missing: {ck}")
|
|
54
|
+
try:
|
|
55
|
+
staged = worker_package_root() / "feabas_workbench"
|
|
56
|
+
rep["worker_package"] = str(staged)
|
|
57
|
+
missing = [part for part in WORKER_PACKAGE_PARTS if not (staged / part).exists()]
|
|
58
|
+
if missing:
|
|
59
|
+
rep["problems"].append(f"worker package staging incomplete, missing {missing}")
|
|
60
|
+
if not (staged / "workers" / "fold_predict.py").is_file():
|
|
61
|
+
rep["problems"].append("worker package has no workers/fold_predict.py")
|
|
62
|
+
except Exception as e: # noqa: BLE001
|
|
63
|
+
rep["problems"].append(f"worker package staging failed: {e}")
|
|
64
|
+
try:
|
|
65
|
+
s = Settings.load()
|
|
66
|
+
rep["settings"] = {"feabas_python": s.feabas_python, "dl_python": s.dl_python}
|
|
67
|
+
except Exception as e: # noqa: BLE001
|
|
68
|
+
rep["problems"].append(f"settings unreadable: {e}")
|
|
69
|
+
rep["ok"] = not rep["problems"]
|
|
70
|
+
out = Path(out)
|
|
71
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
out.write_text(json.dumps(rep, indent=2), encoding="utf-8")
|
|
73
|
+
return 0 if rep["ok"] else 1
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main(argv: list[str] | None = None) -> int:
|
|
77
|
+
ap = argparse.ArgumentParser(prog="feabas-workbench")
|
|
78
|
+
ap.add_argument("--project", help="project folder to open")
|
|
79
|
+
ap.add_argument("--page", type=int, default=None, help="page index to show")
|
|
80
|
+
ap.add_argument("--screenshot", help="render every page to PNG files in this folder and exit (debug)")
|
|
81
|
+
ap.add_argument("--offscreen", action="store_true", help="use the offscreen Qt platform (debug)")
|
|
82
|
+
ap.add_argument("--selfcheck", metavar="REPORT.json",
|
|
83
|
+
help="verify the installation (vendored FEABAS, checkpoint, worker staging) without opening a "
|
|
84
|
+
"window, write a JSON report and exit 0/1")
|
|
85
|
+
args = ap.parse_args(argv)
|
|
86
|
+
|
|
87
|
+
if args.selfcheck:
|
|
88
|
+
return selfcheck(Path(args.selfcheck))
|
|
89
|
+
|
|
90
|
+
if args.offscreen or args.screenshot:
|
|
91
|
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
92
|
+
os.environ.setdefault("QT_ENABLE_HIGHDPI_SCALING", "1")
|
|
93
|
+
|
|
94
|
+
from PySide6.QtWidgets import QApplication
|
|
95
|
+
from PySide6.QtCore import QTimer
|
|
96
|
+
from . import APP_NAME, ORG_NAME
|
|
97
|
+
from .ui.theme import QSS
|
|
98
|
+
from .ui.main_window import MainWindow
|
|
99
|
+
from .core.envs import Settings
|
|
100
|
+
|
|
101
|
+
app = QApplication(sys.argv[:1])
|
|
102
|
+
app.setApplicationName(APP_NAME)
|
|
103
|
+
app.setOrganizationName(ORG_NAME)
|
|
104
|
+
app.setStyle("Fusion")
|
|
105
|
+
app.setStyleSheet(QSS)
|
|
106
|
+
|
|
107
|
+
settings = Settings.load()
|
|
108
|
+
holder = {"w": None}
|
|
109
|
+
prev_excepthook = sys.excepthook
|
|
110
|
+
sys.excepthook = _excepthook_factory(lambda: holder["w"])
|
|
111
|
+
win = MainWindow(settings)
|
|
112
|
+
holder["w"] = win
|
|
113
|
+
win.resize(1500, 950)
|
|
114
|
+
win.show()
|
|
115
|
+
# Never open larger than the screen: a laptop panel (often HiDPI, so a small logical
|
|
116
|
+
# size) can be narrower than 1500 px, and a window wider than its screen pushes the
|
|
117
|
+
# right-hand controls out of reach. Only after show() does win.screen() report the
|
|
118
|
+
# screen the window actually landed on, so shrink here and only when it is needed -
|
|
119
|
+
# on a large monitor the full size is kept. The offscreen platform reports an
|
|
120
|
+
# arbitrary 800x800 virtual screen, so leave --screenshot alone there.
|
|
121
|
+
if app.platformName() != "offscreen":
|
|
122
|
+
avail = win.screen().availableGeometry()
|
|
123
|
+
if win.width() > avail.width() or win.height() > avail.height():
|
|
124
|
+
win.resize(min(win.width(), avail.width()), min(win.height(), avail.height()))
|
|
125
|
+
|
|
126
|
+
if args.project:
|
|
127
|
+
win.open_project(Path(args.project))
|
|
128
|
+
if args.page is not None:
|
|
129
|
+
win.show_page(args.page)
|
|
130
|
+
|
|
131
|
+
if args.screenshot:
|
|
132
|
+
out = Path(args.screenshot)
|
|
133
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
|
|
135
|
+
def shoot():
|
|
136
|
+
for i in range(win.page_count()):
|
|
137
|
+
win.show_page(i)
|
|
138
|
+
app.processEvents()
|
|
139
|
+
win.grab().save(str(out / f"page{i}.png"))
|
|
140
|
+
app.quit()
|
|
141
|
+
|
|
142
|
+
QTimer.singleShot(1500, shoot)
|
|
143
|
+
|
|
144
|
+
code = app.exec()
|
|
145
|
+
|
|
146
|
+
# Teardown, in this order:
|
|
147
|
+
# 1. Pages start QThreads (environment probing, tile scanning). Qt aborts the process
|
|
148
|
+
# if one is still running when it is destroyed. closeEvent does not fire when the
|
|
149
|
+
# app quits without the window being closed (--screenshot), so stop them here.
|
|
150
|
+
win.shutdown()
|
|
151
|
+
# 2. Drop every reference that would outlive `app`. The excepthook closes over
|
|
152
|
+
# `holder`, so leaving it installed keeps the window alive past the end of this
|
|
153
|
+
# function - and a widget destroyed after its QApplication crashes the process.
|
|
154
|
+
sys.excepthook = prev_excepthook
|
|
155
|
+
holder["w"] = None
|
|
156
|
+
# 3. Destroy the window while the QApplication is still there.
|
|
157
|
+
win.close()
|
|
158
|
+
win.deleteLater()
|
|
159
|
+
app.processEvents()
|
|
160
|
+
win = None
|
|
161
|
+
return code
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
sys.exit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Qt-free core: project model, tile discovery, configs, pipeline state, jobs."""
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FEABAS YAML configuration handling.
|
|
3
|
+
|
|
4
|
+
FEABAS merges ``default_<name>.yaml`` (from its default configuration folder)
|
|
5
|
+
with ``<workdir>/configs/<name>.yaml`` (user overrides, may be partial). This
|
|
6
|
+
module gives the GUI one merged view, tracks which keys are overridden, saves
|
|
7
|
+
back only the differences, and harvests the inline comments of the default
|
|
8
|
+
files as per-key hints so the settings editor can explain every field.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import copy
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
|
|
21
|
+
CONFIG_FILES = {
|
|
22
|
+
"stitching": "stitching_configs.yaml",
|
|
23
|
+
"thumbnail": "thumbnail_configs.yaml",
|
|
24
|
+
"alignment": "alignment_configs.yaml",
|
|
25
|
+
"material": "material_table.yaml",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ConfigError(RuntimeError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ----------------------------------------------------------------------
|
|
34
|
+
# yaml helpers
|
|
35
|
+
# ----------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def load_yaml(path: Path) -> dict:
|
|
38
|
+
path = Path(path)
|
|
39
|
+
if not path.is_file():
|
|
40
|
+
return {}
|
|
41
|
+
try:
|
|
42
|
+
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
43
|
+
except yaml.YAMLError as e:
|
|
44
|
+
raise ConfigError(f"{path.name}: {e}") from e
|
|
45
|
+
return data if isinstance(data, dict) else {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dump_yaml(path: Path, data: dict, header: str | None = None) -> None:
|
|
49
|
+
path = Path(path)
|
|
50
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
51
|
+
text = yaml.safe_dump(data, sort_keys=False, default_flow_style=None, allow_unicode=True)
|
|
52
|
+
if header:
|
|
53
|
+
text = "".join(f"# {ln}\n" for ln in header.splitlines()) + text
|
|
54
|
+
path.write_text(text, encoding="utf-8")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def deep_merge(base: dict, over: dict) -> dict:
|
|
58
|
+
out = copy.deepcopy(base)
|
|
59
|
+
for k, v in (over or {}).items():
|
|
60
|
+
if isinstance(v, dict) and isinstance(out.get(k), dict):
|
|
61
|
+
out[k] = deep_merge(out[k], v)
|
|
62
|
+
else:
|
|
63
|
+
out[k] = copy.deepcopy(v)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def deep_diff(base: dict, current: dict) -> dict:
|
|
68
|
+
"""Keys in *current* that differ from *base* (nested)."""
|
|
69
|
+
out: dict = {}
|
|
70
|
+
for k, v in current.items():
|
|
71
|
+
if k not in base:
|
|
72
|
+
out[k] = copy.deepcopy(v)
|
|
73
|
+
elif isinstance(v, dict) and isinstance(base[k], dict):
|
|
74
|
+
sub = deep_diff(base[k], v)
|
|
75
|
+
if sub:
|
|
76
|
+
out[k] = sub
|
|
77
|
+
elif v != base[k]:
|
|
78
|
+
out[k] = copy.deepcopy(v)
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def flatten(data: dict, prefix: str = "") -> list[tuple[str, Any]]:
|
|
83
|
+
out = []
|
|
84
|
+
for k, v in data.items():
|
|
85
|
+
key = f"{prefix}.{k}" if prefix else str(k)
|
|
86
|
+
if isinstance(v, dict):
|
|
87
|
+
out.extend(flatten(v, key))
|
|
88
|
+
else:
|
|
89
|
+
out.append((key, v))
|
|
90
|
+
return out
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_dotted(data: dict, dotted: str, default=None):
|
|
94
|
+
cur = data
|
|
95
|
+
for part in dotted.split("."):
|
|
96
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
97
|
+
return default
|
|
98
|
+
cur = cur[part]
|
|
99
|
+
return cur
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def set_dotted(data: dict, dotted: str, value) -> None:
|
|
103
|
+
parts = dotted.split(".")
|
|
104
|
+
cur = data
|
|
105
|
+
for p in parts[:-1]:
|
|
106
|
+
if not isinstance(cur.get(p), dict):
|
|
107
|
+
cur[p] = {}
|
|
108
|
+
cur = cur[p]
|
|
109
|
+
cur[parts[-1]] = value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def del_dotted(data: dict, dotted: str) -> None:
|
|
113
|
+
parts = dotted.split(".")
|
|
114
|
+
cur = data
|
|
115
|
+
stack = []
|
|
116
|
+
for p in parts[:-1]:
|
|
117
|
+
if not isinstance(cur, dict) or p not in cur:
|
|
118
|
+
return
|
|
119
|
+
stack.append((cur, p))
|
|
120
|
+
cur = cur[p]
|
|
121
|
+
if isinstance(cur, dict):
|
|
122
|
+
cur.pop(parts[-1], None)
|
|
123
|
+
# prune empty parents
|
|
124
|
+
for parent, key in reversed(stack):
|
|
125
|
+
if isinstance(parent.get(key), dict) and not parent[key]:
|
|
126
|
+
del parent[key]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ----------------------------------------------------------------------
|
|
130
|
+
# hints from comments in the default yaml files
|
|
131
|
+
# ----------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def harvest_hints(path: Path) -> dict[str, str]:
|
|
134
|
+
"""Map dotted key -> inline/preceding comment from a YAML file."""
|
|
135
|
+
hints: dict[str, str] = {}
|
|
136
|
+
if not Path(path).is_file():
|
|
137
|
+
return hints
|
|
138
|
+
stack: list[tuple[int, str]] = [] # (indent, key)
|
|
139
|
+
pending: list[str] = []
|
|
140
|
+
for raw in Path(path).read_text(encoding="utf-8").splitlines():
|
|
141
|
+
if not raw.strip():
|
|
142
|
+
pending = []
|
|
143
|
+
continue
|
|
144
|
+
stripped = raw.lstrip()
|
|
145
|
+
indent = len(raw) - len(stripped)
|
|
146
|
+
if stripped.startswith("#"):
|
|
147
|
+
pending.append(stripped.lstrip("#").strip())
|
|
148
|
+
continue
|
|
149
|
+
m = re.match(r"([A-Za-z0-9_\-]+)\s*:\s*(.*)$", stripped)
|
|
150
|
+
if not m:
|
|
151
|
+
continue
|
|
152
|
+
key, rest = m.group(1), m.group(2)
|
|
153
|
+
while stack and stack[-1][0] >= indent:
|
|
154
|
+
stack.pop()
|
|
155
|
+
dotted = ".".join([k for _, k in stack] + [key])
|
|
156
|
+
comment = ""
|
|
157
|
+
if "#" in rest:
|
|
158
|
+
comment = rest.split("#", 1)[1].strip()
|
|
159
|
+
text = " ".join([c for c in pending if c] + ([comment] if comment else []))
|
|
160
|
+
if text:
|
|
161
|
+
hints[dotted] = text
|
|
162
|
+
pending = []
|
|
163
|
+
stack.append((indent, key))
|
|
164
|
+
return hints
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# curated hints for keys whose default comments are terse or missing
|
|
168
|
+
EXTRA_HINTS = {
|
|
169
|
+
"stitching.section_thickness": "Section thickness in nm. Used to pick working mip levels so xy resolution is close to z.",
|
|
170
|
+
"stitching.matching.num_workers": "Parallel processes for tile matching. Each caches images; lower if RAM is tight.",
|
|
171
|
+
"stitching.rendering.driver": "'image' writes PNG tiles per section (simple, VAST-friendly); 'neuroglancer_precomputed' writes a TensorStore volume (faster downstream).",
|
|
172
|
+
"stitching.rendering.loader_settings.apply_CLAHE": "Contrast-limited histogram equalisation during rendering. Usually leave on; turn off if you did your own histogram matching and want the raw look.",
|
|
173
|
+
"stitching.rendering.loader_settings.inverse": "Invert grey values while rendering (BSE images are often acquired inverted).",
|
|
174
|
+
"thumbnail.thumbnail_mip_level": "Mip level of thumbnails (each level halves resolution). Aim for roughly 500-2000 px thumbnails; larger ones make coarse matching very slow.",
|
|
175
|
+
"thumbnail.downsample.thumbnail_highpass": "High-pass before downsampling to enhance somata. Rule of thumb: on for SE images, off for BSE images or very small mip levels.",
|
|
176
|
+
"thumbnail.alignment.compare_distance": "How many neighbouring sections to match (1 = immediate neighbours only). 2 makes the stack more robust to a bad section.",
|
|
177
|
+
"alignment.matching.working_mip_level": "Resolution for fine matching. Pick the mip whose xy pixel size is closest to the section thickness.",
|
|
178
|
+
"alignment.meshing.mesh_size": "Mesh spacing at mip0 pixels. Finer meshes follow local distortions but can overfit real z change.",
|
|
179
|
+
"alignment.meshing.mask_mip_level": "Mip level of the masks in align/material_masks (the fold/tissue masks written by the Masks step).",
|
|
180
|
+
"alignment.optimization.chunk_settings.chunked_to_depth": "0 = sliding window over the whole stack; >0 = align chunks first, then chunks against each other.",
|
|
181
|
+
"alignment.rendering.tile_size": "Output tile size (px) for PNG rendering. 4096 is fine for VAST.",
|
|
182
|
+
"alignment.tensorstore_rendering.driver": "Neuroglancer precomputed is the recommended big-data output.",
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ----------------------------------------------------------------------
|
|
187
|
+
# config store
|
|
188
|
+
# ----------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class ConfigDoc:
|
|
192
|
+
kind: str # stitching | thumbnail | alignment | material
|
|
193
|
+
default_path: Path
|
|
194
|
+
user_path: Path
|
|
195
|
+
defaults: dict = field(default_factory=dict)
|
|
196
|
+
overrides: dict = field(default_factory=dict)
|
|
197
|
+
hints: dict = field(default_factory=dict)
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def merged(self) -> dict:
|
|
201
|
+
return deep_merge(self.defaults, self.overrides)
|
|
202
|
+
|
|
203
|
+
def is_overridden(self, dotted: str) -> bool:
|
|
204
|
+
return get_dotted(self.overrides, dotted, _MISSING) is not _MISSING
|
|
205
|
+
|
|
206
|
+
def set(self, dotted: str, value) -> None:
|
|
207
|
+
if value == get_dotted(self.defaults, dotted, _MISSING):
|
|
208
|
+
del_dotted(self.overrides, dotted)
|
|
209
|
+
else:
|
|
210
|
+
set_dotted(self.overrides, dotted, value)
|
|
211
|
+
|
|
212
|
+
def reset(self, dotted: str) -> None:
|
|
213
|
+
del_dotted(self.overrides, dotted)
|
|
214
|
+
|
|
215
|
+
def hint(self, dotted: str) -> str:
|
|
216
|
+
return EXTRA_HINTS.get(f"{self.kind}.{dotted}") or self.hints.get(dotted, "")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
_MISSING = object()
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
class ConfigStore:
|
|
223
|
+
"""All four FEABAS config documents of a project."""
|
|
224
|
+
|
|
225
|
+
def __init__(self, configs_dir: Path):
|
|
226
|
+
self.configs_dir = Path(configs_dir)
|
|
227
|
+
self.docs: dict[str, ConfigDoc] = {}
|
|
228
|
+
self.reload()
|
|
229
|
+
|
|
230
|
+
def reload(self) -> None:
|
|
231
|
+
self.docs = {}
|
|
232
|
+
for kind, name in CONFIG_FILES.items():
|
|
233
|
+
dpath = self.configs_dir / f"default_{name}"
|
|
234
|
+
upath = self.configs_dir / name
|
|
235
|
+
doc = ConfigDoc(kind, dpath, upath)
|
|
236
|
+
doc.defaults = load_yaml(dpath)
|
|
237
|
+
doc.overrides = load_yaml(upath)
|
|
238
|
+
doc.hints = harvest_hints(dpath)
|
|
239
|
+
self.docs[kind] = doc
|
|
240
|
+
|
|
241
|
+
def __getitem__(self, kind: str) -> ConfigDoc:
|
|
242
|
+
return self.docs[kind]
|
|
243
|
+
|
|
244
|
+
def save(self, kind: str | None = None) -> None:
|
|
245
|
+
for k, doc in self.docs.items():
|
|
246
|
+
if kind and k != kind:
|
|
247
|
+
continue
|
|
248
|
+
if doc.overrides:
|
|
249
|
+
dump_yaml(doc.user_path, doc.overrides,
|
|
250
|
+
header=f"Project overrides for {doc.default_path.name}; unspecified keys use the defaults.")
|
|
251
|
+
elif doc.user_path.is_file():
|
|
252
|
+
doc.user_path.unlink()
|
|
253
|
+
|
|
254
|
+
def get(self, kind: str, dotted: str, default=None):
|
|
255
|
+
return get_dotted(self.docs[kind].merged, dotted, default)
|
|
256
|
+
|
|
257
|
+
def set(self, kind: str, dotted: str, value) -> None:
|
|
258
|
+
self.docs[kind].set(dotted, value)
|
|
259
|
+
|
|
260
|
+
def modified_time(self, kind: str) -> float:
|
|
261
|
+
p = self.docs[kind].user_path
|
|
262
|
+
return p.stat().st_mtime if p.is_file() else 0.0
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ----------------------------------------------------------------------
|
|
266
|
+
# convenience: resolutions and mips
|
|
267
|
+
# ----------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
def mip_resolution(pixel_size_nm: float, mip: int) -> float:
|
|
270
|
+
return float(pixel_size_nm) * (2 ** int(mip))
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def suggest_working_mip(pixel_size_nm: float, section_thickness_nm: float) -> int:
|
|
274
|
+
"""FEABAS rule: mip whose xy resolution is closest to (but not above) the section thickness."""
|
|
275
|
+
import math
|
|
276
|
+
if pixel_size_nm <= 0 or section_thickness_nm <= 0:
|
|
277
|
+
return 2
|
|
278
|
+
return max(0, int(math.floor(math.log2(section_thickness_nm / pixel_size_nm))))
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def suggest_thumbnail_mip(section_w_px: float, section_h_px: float, target_px: float = 1000.0) -> int:
|
|
282
|
+
"""Mip level that brings the longer section side to about target_px."""
|
|
283
|
+
import math
|
|
284
|
+
longest = max(float(section_w_px), float(section_h_px), 1.0)
|
|
285
|
+
return max(0, int(round(math.log2(longest / target_px))))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def set_thumbnail_mip(cs: "ConfigStore", mip: int, highpass: bool | None = None) -> bool:
|
|
289
|
+
"""
|
|
290
|
+
Set the thumbnail mip level (and the mask mip level that goes with it) consistently.
|
|
291
|
+
|
|
292
|
+
FEABAS builds high-pass filtered thumbnails from a coarser intermediate mip level and asserts
|
|
293
|
+
that this level is *below* the thumbnail mip (``highpass_inter_mip_lvl < thumbnail_mip_lvl``).
|
|
294
|
+
At mip 0 no such level exists and the thumbnail step dies with an AssertionError, so the
|
|
295
|
+
high-pass is switched off there. Returns True when it had to be switched off.
|
|
296
|
+
"""
|
|
297
|
+
mip = int(mip)
|
|
298
|
+
cs.set("thumbnail", "thumbnail_mip_level", mip)
|
|
299
|
+
cs.set("alignment", "meshing.mask_mip_level", mip)
|
|
300
|
+
if highpass is None:
|
|
301
|
+
highpass = bool(cs.get("thumbnail", "downsample.thumbnail_highpass", True))
|
|
302
|
+
forced_off = mip <= 0 and highpass
|
|
303
|
+
cs.set("thumbnail", "downsample.thumbnail_highpass", False if forced_off else bool(highpass))
|
|
304
|
+
return forced_off
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def parse_value(text: str, like):
|
|
308
|
+
"""Parse an edited string using the type of the current value."""
|
|
309
|
+
t = text.strip()
|
|
310
|
+
if isinstance(like, bool):
|
|
311
|
+
return t.lower() in ("1", "true", "yes", "on")
|
|
312
|
+
if like is None:
|
|
313
|
+
if t.lower() in ("", "null", "none", "~"):
|
|
314
|
+
return None
|
|
315
|
+
return yaml.safe_load(t)
|
|
316
|
+
if isinstance(like, int) and not isinstance(like, bool):
|
|
317
|
+
try:
|
|
318
|
+
return int(t)
|
|
319
|
+
except ValueError:
|
|
320
|
+
return yaml.safe_load(t)
|
|
321
|
+
if isinstance(like, float):
|
|
322
|
+
try:
|
|
323
|
+
return float(t)
|
|
324
|
+
except ValueError:
|
|
325
|
+
return yaml.safe_load(t)
|
|
326
|
+
if isinstance(like, (list, tuple, dict)):
|
|
327
|
+
return yaml.safe_load(t)
|
|
328
|
+
# a string-valued key keeps strings: 'NONE' is a real FEABAS value (e.g. the
|
|
329
|
+
# rendering blend mode), so only the YAML null spellings clear such a key.
|
|
330
|
+
if t.lower() in ("null", "~"):
|
|
331
|
+
return None
|
|
332
|
+
return t
|