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.
Files changed (84) hide show
  1. feabas_workbench/__init__.py +9 -0
  2. feabas_workbench/__main__.py +4 -0
  3. feabas_workbench/app.py +165 -0
  4. feabas_workbench/core/__init__.py +1 -0
  5. feabas_workbench/core/configs.py +332 -0
  6. feabas_workbench/core/envs.py +559 -0
  7. feabas_workbench/core/histmatch.py +158 -0
  8. feabas_workbench/core/httpserve.py +50 -0
  9. feabas_workbench/core/images.py +381 -0
  10. feabas_workbench/core/jobs.py +436 -0
  11. feabas_workbench/core/masks.py +697 -0
  12. feabas_workbench/core/maskstore.py +396 -0
  13. feabas_workbench/core/pipeline.py +104 -0
  14. feabas_workbench/core/project.py +362 -0
  15. feabas_workbench/core/steps.py +673 -0
  16. feabas_workbench/core/synthetic.py +164 -0
  17. feabas_workbench/core/testruns.py +243 -0
  18. feabas_workbench/core/tiles.py +805 -0
  19. feabas_workbench/resources/fold_unet_resnet34_inference_only_fp16.ckpt +4 -0
  20. feabas_workbench/ui/__init__.py +1 -0
  21. feabas_workbench/ui/bridge.py +207 -0
  22. feabas_workbench/ui/dialogs.py +173 -0
  23. feabas_workbench/ui/external.py +52 -0
  24. feabas_workbench/ui/main_window.py +410 -0
  25. feabas_workbench/ui/pages/__init__.py +3 -0
  26. feabas_workbench/ui/pages/base.py +117 -0
  27. feabas_workbench/ui/pages/page_align.py +867 -0
  28. feabas_workbench/ui/pages/page_export.py +290 -0
  29. feabas_workbench/ui/pages/page_masks.py +1068 -0
  30. feabas_workbench/ui/pages/page_preprocess.py +470 -0
  31. feabas_workbench/ui/pages/page_project.py +541 -0
  32. feabas_workbench/ui/pages/page_setup.py +315 -0
  33. feabas_workbench/ui/pages/page_stitch.py +469 -0
  34. feabas_workbench/ui/theme.py +78 -0
  35. feabas_workbench/ui/threads.py +97 -0
  36. feabas_workbench/ui/widgets/__init__.py +12 -0
  37. feabas_workbench/ui/widgets/common.py +174 -0
  38. feabas_workbench/ui/widgets/config_editor.py +195 -0
  39. feabas_workbench/ui/widgets/imageview.py +392 -0
  40. feabas_workbench/ui/widgets/log_panel.py +102 -0
  41. feabas_workbench/ui/widgets/paths.py +50 -0
  42. feabas_workbench/ui/widgets/section_picker.py +153 -0
  43. feabas_workbench/ui/widgets/step_card.py +145 -0
  44. feabas_workbench/ui/widgets/steps_panel.py +113 -0
  45. feabas_workbench/ui/widgets/tile_grid.py +88 -0
  46. feabas_workbench/vendor/feabas_3_0_5/LICENSE +21 -0
  47. feabas_workbench/vendor/feabas_3_0_5/VERSION.txt +1 -0
  48. feabas_workbench/vendor/feabas_3_0_5/configs/default_alignment_configs.yaml +107 -0
  49. feabas_workbench/vendor/feabas_3_0_5/configs/default_material_table.yaml +57 -0
  50. feabas_workbench/vendor/feabas_3_0_5/configs/default_stitching_configs.yaml +82 -0
  51. feabas_workbench/vendor/feabas_3_0_5/configs/default_thumbnail_configs.yaml +82 -0
  52. feabas_workbench/vendor/feabas_3_0_5/scripts/align_main.py +628 -0
  53. feabas_workbench/vendor/feabas_3_0_5/scripts/stitch_main.py +292 -0
  54. feabas_workbench/vendor/feabas_3_0_5/scripts/thumbnail_main.py +660 -0
  55. feabas_workbench/vendor/feabas_3_0_5/tools/compute_alignment_residues.py +76 -0
  56. feabas_workbench/vendor/feabas_3_0_5/tools/convert_manual_thumbnail_matches.py +43 -0
  57. feabas_workbench/vendor/feabas_3_0_5/tools/edit_bigwarp.ijm +39 -0
  58. feabas_workbench/vendor/feabas_3_0_5/tools/export_match_to_bigwarp_csv.py +61 -0
  59. feabas_workbench/vendor/feabas_3_0_5/tools/merge_manual_match.py +65 -0
  60. feabas_workbench/vendor/feabas_3_0_5/tools/normalize_aligned_meshes.py +87 -0
  61. feabas_workbench/vendor/feabas_3_0_5/tools/render_aligned_thumbnails.py +140 -0
  62. feabas_workbench/vendor/feabas_3_0_5/tools/split_wrinkles.py +39 -0
  63. feabas_workbench/vendor/feabas_3_0_5/tools/visualize_align_match_coverage.py +165 -0
  64. feabas_workbench/vendor/winfix/sitecustomize.py +148 -0
  65. feabas_workbench/workers/__init__.py +7 -0
  66. feabas_workbench/workers/common.py +38 -0
  67. feabas_workbench/workers/export_vast.py +216 -0
  68. feabas_workbench/workers/fold_predict.py +178 -0
  69. feabas_workbench/workers/fold_train.py +177 -0
  70. feabas_workbench/workers/histmatch_worker.py +35 -0
  71. feabas_workbench/workers/n2v_predict.py +74 -0
  72. feabas_workbench/workers/n2v_train.py +102 -0
  73. feabas_workbench/workers/reweight_matches.py +88 -0
  74. feabas_workbench/workers/structure_match.py +246 -0
  75. feabas_workbench/workers/yolo_detect.py +138 -0
  76. feabas_workbench/workers/yolo_train.py +58 -0
  77. feabas_workbench-0.3.2.dist-info/METADATA +250 -0
  78. feabas_workbench-0.3.2.dist-info/RECORD +84 -0
  79. feabas_workbench-0.3.2.dist-info/WHEEL +5 -0
  80. feabas_workbench-0.3.2.dist-info/entry_points.txt +5 -0
  81. feabas_workbench-0.3.2.dist-info/licenses/LICENSE +202 -0
  82. feabas_workbench-0.3.2.dist-info/licenses/NOTICE +15 -0
  83. feabas_workbench-0.3.2.dist-info/licenses/THIRD_PARTY_NOTICES.md +21 -0
  84. 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"
@@ -0,0 +1,4 @@
1
+ from feabas_workbench.app import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
@@ -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