brkraw-viewer 0.2.5__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.
@@ -0,0 +1,258 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+ from typing import Any, Dict, Iterable, List, Optional, Tuple, Mapping
6
+ import json
7
+ import logging
8
+ import zipfile
9
+ import datetime as dt
10
+
11
+ from brkraw.apps.loader import BrukerLoader
12
+ from brkraw.core.fs import DatasetFS
13
+ from brkraw.dataclasses.study import Study
14
+
15
+ from .frames.viewer_config import registry_path
16
+
17
+ logger = logging.getLogger("brkraw.viewer")
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class RegistryEntry:
22
+ path: str
23
+ basename: str
24
+ study: Dict[str, Any]
25
+ num_scans: int
26
+ kind: str
27
+ added_at: str
28
+ last_seen: str
29
+
30
+ def as_dict(self) -> Dict[str, Any]:
31
+ return {
32
+ "path": self.path,
33
+ "basename": self.basename,
34
+ "study": _json_safe(self.study),
35
+ "num_scans": self.num_scans,
36
+ "kind": self.kind,
37
+ "added_at": self.added_at,
38
+ "last_seen": self.last_seen,
39
+ }
40
+
41
+
42
+ def _now_iso() -> str:
43
+ from datetime import datetime, timezone
44
+
45
+ return datetime.now(timezone.utc).isoformat()
46
+
47
+
48
+ def normalize_path(path: Path) -> str:
49
+ path = path.expanduser()
50
+ try:
51
+ return str(path.resolve())
52
+ except FileNotFoundError:
53
+ return str(path)
54
+
55
+
56
+ def _ensure_registry_path(root: Optional[Path] = None) -> Path:
57
+ reg_path = registry_path(root)
58
+ reg_path.parent.mkdir(parents=True, exist_ok=True)
59
+ if not reg_path.exists():
60
+ reg_path.write_text("", encoding="utf-8")
61
+ return reg_path
62
+
63
+
64
+ def load_registry(root: Optional[Path] = None) -> List[Dict[str, Any]]:
65
+ reg_path = registry_path(root)
66
+ if not reg_path.exists():
67
+ return []
68
+ entries: List[Dict[str, Any]] = []
69
+ for line in reg_path.read_text(encoding="utf-8").splitlines():
70
+ text = line.strip()
71
+ if not text:
72
+ continue
73
+ try:
74
+ payload = json.loads(text)
75
+ except json.JSONDecodeError:
76
+ logger.warning("Skipping invalid registry line: %s", text)
77
+ continue
78
+ if isinstance(payload, dict):
79
+ entries.append(payload)
80
+ return entries
81
+
82
+
83
+ def write_registry(entries: Iterable[Dict[str, Any]], root: Optional[Path] = None) -> None:
84
+ reg_path = _ensure_registry_path(root)
85
+ lines = [json.dumps(_json_safe(entry), ensure_ascii=True) for entry in entries]
86
+ reg_path.write_text("\n".join(lines) + ("\n" if lines else ""), encoding="utf-8")
87
+
88
+
89
+ def _discover_study_paths(path: Path) -> List[Path]:
90
+ fs = DatasetFS.from_path(path)
91
+ studies = Study.discover(fs)
92
+ if not studies:
93
+ return []
94
+ if len(studies) == 1:
95
+ study = studies[0]
96
+ return [fs.root / study.relroot if study.relroot else fs.root]
97
+ if fs._mode == "dir":
98
+ return [fs.root / study.relroot if study.relroot else fs.root for study in studies]
99
+ raise ValueError("Multiple studies found in archive; extract or point to a single study.")
100
+
101
+
102
+ def _discover_dataset_paths(path: Path) -> List[Path]:
103
+ logger.info("Scanning for datasets under %s", path)
104
+ if _is_archive(path):
105
+ logger.debug("Found archive dataset: %s", path)
106
+ return [path]
107
+ if path.is_dir():
108
+ direct = _discover_study_paths(path)
109
+ if direct:
110
+ logger.info("Found study dataset: %s", path)
111
+ return direct
112
+ discovered: List[Path] = []
113
+ for child in sorted(path.iterdir()):
114
+ if child.name.startswith("."):
115
+ continue
116
+ if _is_archive(child):
117
+ logger.debug("Found archive dataset: %s", child)
118
+ discovered.append(child)
119
+ continue
120
+ if child.is_dir():
121
+ try:
122
+ studies = _discover_study_paths(child)
123
+ except ValueError as exc:
124
+ logger.warning("Skipping %s: %s", child, exc)
125
+ continue
126
+ if studies:
127
+ for study in studies:
128
+ logger.info("Found study dataset: %s", study)
129
+ discovered.extend(studies)
130
+ logger.info("Discovery complete: %d dataset(s) under %s", len(discovered), path)
131
+ return discovered
132
+ return []
133
+
134
+
135
+ def _is_archive(path: Path) -> bool:
136
+ return path.is_file() and zipfile.is_zipfile(path)
137
+
138
+
139
+ def _load_study_info(loader: BrukerLoader) -> Dict[str, Any]:
140
+ info: Dict[str, Any] = {}
141
+ try:
142
+ subject = loader.subject
143
+ if isinstance(subject, dict):
144
+ study = subject.get("Study", {})
145
+ if isinstance(study, dict):
146
+ info = dict(study)
147
+ except Exception:
148
+ info = {}
149
+ return info
150
+
151
+
152
+ def _json_safe(value: Any) -> Any:
153
+ if value is None or isinstance(value, (str, int, float, bool)):
154
+ return value
155
+ if isinstance(value, dict):
156
+ return {str(k): _json_safe(v) for k, v in value.items()}
157
+ if isinstance(value, (list, tuple)):
158
+ return [_json_safe(v) for v in value]
159
+ if isinstance(value, dt.datetime):
160
+ return value.date().isoformat()
161
+ if isinstance(value, dt.date):
162
+ return value.isoformat()
163
+ return str(value)
164
+
165
+
166
+ def build_entry(path: Path) -> RegistryEntry:
167
+ norm = normalize_path(path)
168
+ basename = path.name
169
+ kind = "archive" if _is_archive(path) else "study"
170
+ loader = BrukerLoader(path)
171
+ study_info = _load_study_info(loader)
172
+ num_scans = len(loader.avail)
173
+ timestamp = _now_iso()
174
+ return RegistryEntry(
175
+ path=norm,
176
+ basename=basename,
177
+ study=study_info,
178
+ num_scans=num_scans,
179
+ kind=kind,
180
+ added_at=timestamp,
181
+ last_seen=timestamp,
182
+ )
183
+
184
+
185
+ def _merge_entries(
186
+ existing: Dict[str, Dict[str, Any]],
187
+ new_entries: Iterable[RegistryEntry],
188
+ ) -> Tuple[Dict[str, Dict[str, Any]], int]:
189
+ added = 0
190
+ for entry in new_entries:
191
+ current = existing.get(entry.path)
192
+ if current is None:
193
+ existing[entry.path] = entry.as_dict()
194
+ added += 1
195
+ else:
196
+ updated = dict(current)
197
+ updated.update(entry.as_dict())
198
+ updated["added_at"] = current.get("added_at", entry.added_at)
199
+ existing[entry.path] = updated
200
+ return existing, added
201
+
202
+
203
+ def register_paths(paths: Iterable[Path], root: Optional[Path] = None) -> List[RegistryEntry]:
204
+ entries: List[RegistryEntry] = []
205
+ for raw in paths:
206
+ expanded = raw.expanduser()
207
+ if not expanded.exists():
208
+ raise FileNotFoundError(expanded)
209
+ logger.debug("Registering dataset path: %s", expanded)
210
+ try:
211
+ dataset_paths = _discover_dataset_paths(expanded)
212
+ except ValueError as exc:
213
+ raise exc
214
+ if not dataset_paths:
215
+ raise ValueError(f"No Paravision study found under {expanded}")
216
+ for study_path in dataset_paths:
217
+ logger.info("Building registry entry: %s", study_path)
218
+ entries.append(build_entry(study_path))
219
+ existing = {entry["path"]: entry for entry in load_registry(root)}
220
+ merged, _ = _merge_entries(existing, entries)
221
+ write_registry(merged.values(), root=root)
222
+ return entries
223
+
224
+
225
+ def unregister_paths(paths: Iterable[Path], root: Optional[Path] = None) -> int:
226
+ normalized = {normalize_path(path) for path in paths}
227
+ entries = load_registry(root)
228
+ kept = [entry for entry in entries if entry.get("path") not in normalized]
229
+ removed = len(entries) - len(kept)
230
+ write_registry(kept, root=root)
231
+ return removed
232
+
233
+
234
+ def registry_status(root: Optional[Path] = None) -> List[Dict[str, Any]]:
235
+ entries = load_registry(root)
236
+ for entry in entries:
237
+ path = entry.get("path", "")
238
+ entry["missing"] = not Path(str(path)).exists()
239
+ return entries
240
+
241
+
242
+ def resolve_entry_value(entry: Mapping[str, Any], key: str) -> str:
243
+ if key == "basename":
244
+ return str(entry.get("basename", ""))
245
+ if key == "path":
246
+ return str(entry.get("path", ""))
247
+ if key == "num_scans":
248
+ return str(entry.get("num_scans", ""))
249
+ if key == "kind":
250
+ return str(entry.get("kind", ""))
251
+ if key == "missing":
252
+ return "Yes" if entry.get("missing") else "No"
253
+ if key.startswith("Study."):
254
+ study = entry.get("study", {})
255
+ if isinstance(study, dict):
256
+ field = key.split(".", 1)[1]
257
+ return str(study.get(field, ""))
258
+ return str(entry.get(key, ""))
@@ -0,0 +1,4 @@
1
+ Protocol:
2
+ type: mapping
3
+ values:
4
+ "<Protocol>": "<Mapped>"
@@ -0,0 +1,5 @@
1
+ SubjectType:
2
+ type: mapping
3
+ values:
4
+ "biped": "human"
5
+ "quadruped": "animal"
@@ -0,0 +1,10 @@
1
+ info_spec:
2
+ - name: example
3
+ use: example.yaml
4
+ when:
5
+ Method:
6
+ sources:
7
+ - file: method
8
+ key: Method
9
+ if:
10
+ eq: ["$Method", "<METHOD>"]
@@ -0,0 +1,10 @@
1
+ metadata_spec:
2
+ - name: example-metadata
3
+ use: metadata.yaml
4
+ when:
5
+ Protocol:
6
+ sources:
7
+ - file: acqp
8
+ key: ACQ_protocol_name
9
+ if:
10
+ contains: ["$Protocol", "<TAG>"]
@@ -0,0 +1,5 @@
1
+ Protocol:
2
+ sources:
3
+ - file: acqp
4
+ key: ACQ_protocol_name
5
+ transform: strip_jcamp_string
@@ -0,0 +1,5 @@
1
+ EchoTimes:
2
+ sources:
3
+ - file: method
4
+ key: PVM_EchoTime
5
+ transform: to_list
@@ -0,0 +1,5 @@
1
+ FlipAngle:
2
+ sources:
3
+ - file: method
4
+ key: PVM_FlipAngle
5
+ default: 0
@@ -0,0 +1,2 @@
1
+ """Small helper utilities for the viewer."""
2
+
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ import numpy as np
4
+ import nibabel as nib
5
+
6
+
7
+ def reorient_to_ras(data: np.ndarray, affine: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
8
+ data = np.asarray(data)
9
+ affine = np.asarray(affine, dtype=float)
10
+
11
+ ornt = nib.orientations.io_orientation(affine)
12
+ ras_ornt = np.array([[0, 1], [1, 1], [2, 1]]) # RAS
13
+ transform = nib.orientations.ornt_transform(ornt, ras_ornt)
14
+ new_data = nib.orientations.apply_orientation(data, transform)
15
+ new_affine = affine @ nib.orientations.inv_ornt_aff(transform, data.shape)
16
+ return new_data, new_affine
17
+
@@ -0,0 +1,170 @@
1
+ Metadata-Version: 2.4
2
+ Name: brkraw-viewer
3
+ Version: 0.2.5
4
+ Summary: BrkRaw scan viewer plugin for brkraw CLI.
5
+ Author: BrkRaw
6
+ License: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ Requires-Dist: brkraw>=0.5.0rc1
13
+ Requires-Dist: nibabel>=5.0
14
+ Requires-Dist: pillow>=10.0
15
+ Provides-Extra: docs
16
+ Requires-Dist: mkdocs-material>=9.5.0; extra == "docs"
17
+ Provides-Extra: test
18
+ Requires-Dist: pytest>=7.4; extra == "test"
19
+
20
+ <h1 align="left">
21
+ <picture>
22
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/brkraw-viewer-logo-dark.svg">
23
+ <img alt="BrkRaw Viewer" src="docs/assets/brkraw-viewer-logo-light.svg" width="410">
24
+ </picture>
25
+ </h1>
26
+
27
+ BrkRaw Viewer is an interactive dataset viewer implemented as a
28
+ separate CLI plugin for the `brkraw` command.
29
+
30
+ The viewer is intentionally maintained outside the BrkRaw core to
31
+ enable independent development and community contributions around
32
+ user-facing interfaces.
33
+
34
+ ---
35
+
36
+ ## Scope and intent
37
+
38
+ BrkRaw Viewer is designed for **interactive inspection** of Bruker
39
+ Paravision datasets. It focuses on quick exploration and validation
40
+ rather than data conversion or analysis.
41
+
42
+ The goal is to provide practical, researcher-focused features that are
43
+ useful in everyday workflows, such as quick dataset triage, metadata
44
+ checks, and lightweight visual QC.
45
+
46
+ Typical use cases include:
47
+
48
+ - Browsing studies, scans, and reconstructions
49
+ - Verifying scan and reconstruction IDs
50
+ - Inspecting acquisition metadata before conversion
51
+ - Lightweight visual sanity checks
52
+
53
+ All data conversion and reproducible workflows are handled by the
54
+ BrkRaw CLI and Python API.
55
+
56
+ ---
57
+
58
+ ## Why these features exist
59
+
60
+ **Viewer**
61
+ The Viewer tab makes it easy to confirm the right scan and orientation before
62
+ running a larger workflow.
63
+
64
+ **Registry**
65
+ The Registry reduces repeated filesystem navigation and lets you re-open the
66
+ current session with a single menu action.
67
+
68
+ **Extensions/hooks**
69
+ Extensions allow modality-specific panels (MRS, BIDS, etc.) to live outside the
70
+ core viewer so the default install stays lightweight.
71
+
72
+ ---
73
+
74
+ ## Design goal: shared extensibility
75
+
76
+ brkraw-viewer keeps the BrkRaw design philosophy: extend the ecosystem
77
+ without changing core logic. The viewer uses the same rules/spec/layout
78
+ system as the CLI and Python API, and it exposes UI extensions via the
79
+ `brkraw.viewer.hook` entry point so new tabs can be added with standalone
80
+ packages. Viewer hooks can coexist with converter hooks and CLI hooks,
81
+ so modality-specific logic can flow from conversion into UI without
82
+ patching the viewer itself.
83
+
84
+ ---
85
+
86
+ ## UI direction
87
+
88
+ The default viewer targets a **tkinter-based** implementation.
89
+
90
+ This choice is intentional: we want a lightweight tool that can be
91
+ used directly on scanner consoles or constrained environments with
92
+ minimal dependencies.
93
+
94
+ More modern GUI frameworks are welcome, but should be developed as
95
+ separate CLI extensions to keep the default viewer small and easy to
96
+ install.
97
+
98
+ ---
99
+
100
+ ## Viewer hooks
101
+
102
+ Viewer extensions are implemented as hooks discovered through
103
+ `brkraw.viewer.hook`. Each hook can register a new tab and provide
104
+ dataset callbacks, enabling feature panels to live outside the core
105
+ viewer while staying compatible with BrkRaw rules, specs, and converter
106
+ hooks. See `docs/dev/hooks.md` for the hook interface and entry point
107
+ setup.
108
+
109
+ ---
110
+
111
+ ## Installation
112
+
113
+ For development and testing, install in editable mode:
114
+
115
+ pip install -e .
116
+
117
+ ---
118
+
119
+ ## Usage
120
+
121
+ Launch the viewer via the BrkRaw CLI:
122
+
123
+ brkraw viewer /path/to/bruker/study
124
+
125
+ Optional arguments allow opening a specific scan or slice:
126
+
127
+ brkraw viewer /path/to/bruker/study \
128
+ --scan 3 \
129
+ --reco 1
130
+
131
+ The viewer can also open `.zip` or Paravision-exported `.PvDatasets`
132
+ archives using `Load` (folder or archive file).
133
+
134
+ ---
135
+
136
+ ## Update
137
+
138
+ Recent updates:
139
+
140
+ - Open folders or archives (`.zip` / `.PvDatasets`)
141
+ - Viewer: `Space` (`raw/scanner/subject_ras`), nibabel RAS display, click-to-set `X/Y/Z`, optional crosshair + zoom,
142
+ slicepack/frame sliders only when needed
143
+ - Info: rule + spec selection (installed or file), parameter search, lazy Viewer refresh on tab focus
144
+ - Registry: add the current session from the `+` menu when a dataset is loaded
145
+ - Convert: BrkRaw layout engine, template + suffix defaults from `~/.brkraw/config.yaml`, keys browser (click to add),
146
+ optional config `layout_entries`
147
+ - Config: edit `~/.brkraw/config.yaml` in-app; basic focus/icon UX
148
+
149
+ This update keeps dependencies minimal and preserves compatibility with
150
+ the core BrkRaw rule/spec/hook system.
151
+
152
+ ---
153
+
154
+ ## Contributing
155
+
156
+ We welcome contributions related to:
157
+
158
+ - New viewer hooks that add modality-specific panels or workflows
159
+ - Alternative UI implementations delivered as separate CLI extensions
160
+ - fMRI/MRS/BIDS-focused visualization or QC helpers built on hooks
161
+ - Multi-dataset session management and registry enhancements
162
+ - Performance and memory improvements for large datasets
163
+
164
+ Contributions should prefer designs where new hooks extend the viewer
165
+ implicitly through shared BrkRaw abstractions, and where richer UIs are
166
+ provided as optional CLI extensions rather than increasing the default
167
+ dependency footprint.
168
+
169
+ If you are interested in contributing, please start a discussion or
170
+ open an issue describing your use case and goals.
@@ -0,0 +1,28 @@
1
+ brkraw_viewer/__init__.py,sha256=0-N1hhUaTQikf7pDU65gb6LyYAd3zqXCzPH4H7Se-uE,84
2
+ brkraw_viewer/plugin.py,sha256=hwmRF9x20j8EbSk6m8f1sB7cXCBKLhV5NWic545tuQc,4004
3
+ brkraw_viewer/registry.py,sha256=fvLve_dbc4pBKr_akZN87oI9OJJP1Zm39KsO1ZHxH0M,8350
4
+ brkraw_viewer/apps/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ brkraw_viewer/apps/config.py,sha256=1y_1j-PePQ38jfM9OcVgeViUz6MtcvvEH5vOzWRDS_0,4412
6
+ brkraw_viewer/apps/convert.py,sha256=Uq0BhTaDTtZN-7XUEbycXnUF7Fi5-EvxxLgqI0Ly59s,73707
7
+ brkraw_viewer/apps/hooks.py,sha256=o12A5exietCdGLss9Ia2I7-83Bs6P3XV3iKaQsgUdEc,872
8
+ brkraw_viewer/apps/viewer.py,sha256=lSHxtW6fy2WHG_jworgQqK9Ta7TVdj7CtIxiwxziab8,219026
9
+ brkraw_viewer/assets/icon.ico,sha256=QUW0dBcnKbNO7MLh58PeLosoveBasMzlHt8UuH6xmKs,372
10
+ brkraw_viewer/assets/icon.png,sha256=Ag-rdTHDnN_LKI6TxRRrP2OWzqoEKFQR0mrxmmcQZkI,769
11
+ brkraw_viewer/frames/__init__.py,sha256=j0U2uMfovQBYuAC3ZzJlN7ilIxcp9J5KQXQm425fNCA,45
12
+ brkraw_viewer/frames/params_panel.py,sha256=AoNskdln3bX_0x00s6f2BuWaDZhZI8_ARnswj_ykQwM,2949
13
+ brkraw_viewer/frames/viewer_canvas.py,sha256=j8RnE-oWqEbsYnQxL1OQP6OAybi4U9s1xFgPuuxEGZ4,13456
14
+ brkraw_viewer/frames/viewer_config.py,sha256=CMQzB_Naexio6WRWLZYTM67rQ-XY51cFOrXuWlddHtQ,3283
15
+ brkraw_viewer/snippets/context_map/basic.yaml,sha256=GoHvSueX7-5ViFS-cwv12rjah9jf_4mHYUh54sRPJtk,65
16
+ brkraw_viewer/snippets/context_map/enum-map.yaml,sha256=LlX-fKt3XOxQmeuY8NQQyjvxCHkHHvqNGhUJuOPMApw,86
17
+ brkraw_viewer/snippets/rule/basic.yaml,sha256=Qy16CDa0Yc0AB2cWzxrnh0dxN-Yc5dDQMhBUUYWOiK4,183
18
+ brkraw_viewer/snippets/rule/when-contains.yaml,sha256=89CQv6AcaAWdHO0xfyB6om0qPXgivLtDqDl6TbmhYSU,213
19
+ brkraw_viewer/snippets/spec/basic.yaml,sha256=1bhSHRcshvDNL8eIoMbGj5KxlbXsVeQbQivExDc9RP8,99
20
+ brkraw_viewer/snippets/spec/list-source.yaml,sha256=toqz2vEjpxV11Q4Sfkn7fPjaxuXYsQqQDSCygQnU680,86
21
+ brkraw_viewer/snippets/spec/with-default.yaml,sha256=Dvx-LdErlwF0dnP_ocFQzR68eQUGoC0zB2s_BWVhNgk,79
22
+ brkraw_viewer/utils/__init__.py,sha256=syfKEnHaaFrSNCiPTAIHbZpVrEEfllA9qV1XrD_GCj8,46
23
+ brkraw_viewer/utils/orientation.py,sha256=GulLdN5HtF1IjQLfFYSAJiGSSCzCK3AnSZ8HM-DRn8g,596
24
+ brkraw_viewer-0.2.5.dist-info/METADATA,sha256=xwH8n4Y_3Gr56sN8jBkCO8ndSAGcrFdqa4DcJlXfSEs,5403
25
+ brkraw_viewer-0.2.5.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
26
+ brkraw_viewer-0.2.5.dist-info/entry_points.txt,sha256=zd9043V-472NISOxavHd2f4Er7SH_CSSRkDl_Bq7jyY,52
27
+ brkraw_viewer-0.2.5.dist-info/top_level.txt,sha256=pyOwjVrot5Cg6GSh3jbhMag2coTkKAlCT3-c20RuC-M,14
28
+ brkraw_viewer-0.2.5.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [brkraw.cli]
2
+ viewer = brkraw_viewer.plugin:register
@@ -0,0 +1 @@
1
+ brkraw_viewer