monocle-viewer 0.1.0__tar.gz
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.
- monocle_viewer-0.1.0/LICENSE +21 -0
- monocle_viewer-0.1.0/PKG-INFO +94 -0
- monocle_viewer-0.1.0/README.md +76 -0
- monocle_viewer-0.1.0/monocle/__init__.py +4 -0
- monocle_viewer-0.1.0/monocle/cli.py +175 -0
- monocle_viewer-0.1.0/monocle/emit.py +197 -0
- monocle_viewer-0.1.0/monocle/normalize.py +435 -0
- monocle_viewer-0.1.0/monocle/session.py +400 -0
- monocle_viewer-0.1.0/monocle/static/monocle.umd.js +226 -0
- monocle_viewer-0.1.0/monocle_viewer.egg-info/PKG-INFO +94 -0
- monocle_viewer-0.1.0/monocle_viewer.egg-info/SOURCES.txt +15 -0
- monocle_viewer-0.1.0/monocle_viewer.egg-info/dependency_links.txt +1 -0
- monocle_viewer-0.1.0/monocle_viewer.egg-info/entry_points.txt +2 -0
- monocle_viewer-0.1.0/monocle_viewer.egg-info/requires.txt +1 -0
- monocle_viewer-0.1.0/monocle_viewer.egg-info/top_level.txt +1 -0
- monocle_viewer-0.1.0/pyproject.toml +31 -0
- monocle_viewer-0.1.0/setup.cfg +4 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Andrew Hoopes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: monocle-viewer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python interface for the Monocle radiology viewer
|
|
5
|
+
Author: Andrew Hoopes
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Keywords: medical-imaging,radiology,mri,ct,visualization,viewer,volume,nifti,dicom
|
|
8
|
+
Classifier: Intended Audience :: Science/Research
|
|
9
|
+
Classifier: Intended Audience :: Healthcare Industry
|
|
10
|
+
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
|
|
11
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
License-File: LICENSE
|
|
16
|
+
Requires-Dist: numpy
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# Monocle - a Python interface to the Monocle viewer
|
|
20
|
+
|
|
21
|
+
`monocle` is a Python interface to Monocle, a browser-based radiology viewer for
|
|
22
|
+
interacting with 3D medical images and segmentations. It targets clinical,
|
|
23
|
+
multimodal imaging. Load a study from Python (or the command line), and `monocle`
|
|
24
|
+
emits a self-contained HTML page and opens it in the browser.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
pip install monocle-viewer
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Command line
|
|
31
|
+
|
|
32
|
+
Pip also installs a `monocle` console command that opens the viewer on files
|
|
33
|
+
from the shell:
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
monocle t1.nii.gz t2.nii.gz # positional images
|
|
37
|
+
monocle t1.nii.gz -s tumor.nii.gz # image + segmentation
|
|
38
|
+
monocle -i t1.nii.gz -i t2.nii.gz # -i is the explicit image flag
|
|
39
|
+
monocle t1.nii.gz --html scan.html # write a shareable file (no browser)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Positionals are images; `-i/--image` adds more; `-s/--seg` adds segmentation
|
|
43
|
+
label masks. Without `--html` it opens the browser via the one-shot local server.
|
|
44
|
+
|
|
45
|
+
Nifti and ordinary images (png/jpeg) are embedded as-is and parsed in the
|
|
46
|
+
browser. DICOM series need `dicom2nifti` and MGH files need `surfa` to load.
|
|
47
|
+
|
|
48
|
+
## The Monocle python builder
|
|
49
|
+
|
|
50
|
+
For per-volume display options and session-wide viewing config within python,
|
|
51
|
+
use the builder class. `Monocle(...)` sets session config, each `.volume(...)`
|
|
52
|
+
adds a scan:
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
import monocle
|
|
56
|
+
|
|
57
|
+
m = monocle.Monocle(title='Patient 123')
|
|
58
|
+
m.volume(t1, name='T1')
|
|
59
|
+
m.volume(flair, name='FLAIR')
|
|
60
|
+
m.segmentation(label, name='Tumor')
|
|
61
|
+
|
|
62
|
+
m.show() # open in browser (no file, see below)
|
|
63
|
+
m.write('scan.html') # write a persistent file
|
|
64
|
+
html = m.html() # or get the HTML string
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`.volume(...)` and `.segmentation(...)` accept a range of sources and handle
|
|
68
|
+
the conversion:
|
|
69
|
+
|
|
70
|
+
- `voxel` volumes
|
|
71
|
+
- `nibabel` images
|
|
72
|
+
- `torch` tensors
|
|
73
|
+
- `numpy` arrays
|
|
74
|
+
- File paths
|
|
75
|
+
|
|
76
|
+
## Convenience methods
|
|
77
|
+
|
|
78
|
+
`monocle.show(sources, ...)` is a one-liner for a quick look. It builds a `Monocle`
|
|
79
|
+
instance, adds each source (with optional `names=` and `affine=`), and serves the
|
|
80
|
+
page in the browser. Session config kwargs (`title=`, `mode=`, …) pass straight through.
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
monocle.show(t1, flair, names=['T1', 'FLAIR'], title='Patient 123')
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
If you work with `voxel` Volumes, they carry a built-in `volume.show()` that
|
|
87
|
+
visualizes the scan through monocle directly — `monocle` ships with `voxel` by
|
|
88
|
+
default, so there's nothing extra to install.
|
|
89
|
+
|
|
90
|
+
## The embedded viewer bundle
|
|
91
|
+
|
|
92
|
+
Offline mode (the default, `inline=True`) pastes the viewer's UMD bundle into
|
|
93
|
+
the HTML so the file works from `file://` with no network. That bundle ships as
|
|
94
|
+
package data at `monocle/static/monocle.umd.js`.
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Monocle - a Python interface to the Monocle viewer
|
|
2
|
+
|
|
3
|
+
`monocle` is a Python interface to Monocle, a browser-based radiology viewer for
|
|
4
|
+
interacting with 3D medical images and segmentations. It targets clinical,
|
|
5
|
+
multimodal imaging. Load a study from Python (or the command line), and `monocle`
|
|
6
|
+
emits a self-contained HTML page and opens it in the browser.
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
pip install monocle-viewer
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Command line
|
|
13
|
+
|
|
14
|
+
Pip also installs a `monocle` console command that opens the viewer on files
|
|
15
|
+
from the shell:
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
monocle t1.nii.gz t2.nii.gz # positional images
|
|
19
|
+
monocle t1.nii.gz -s tumor.nii.gz # image + segmentation
|
|
20
|
+
monocle -i t1.nii.gz -i t2.nii.gz # -i is the explicit image flag
|
|
21
|
+
monocle t1.nii.gz --html scan.html # write a shareable file (no browser)
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Positionals are images; `-i/--image` adds more; `-s/--seg` adds segmentation
|
|
25
|
+
label masks. Without `--html` it opens the browser via the one-shot local server.
|
|
26
|
+
|
|
27
|
+
Nifti and ordinary images (png/jpeg) are embedded as-is and parsed in the
|
|
28
|
+
browser. DICOM series need `dicom2nifti` and MGH files need `surfa` to load.
|
|
29
|
+
|
|
30
|
+
## The Monocle python builder
|
|
31
|
+
|
|
32
|
+
For per-volume display options and session-wide viewing config within python,
|
|
33
|
+
use the builder class. `Monocle(...)` sets session config, each `.volume(...)`
|
|
34
|
+
adds a scan:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import monocle
|
|
38
|
+
|
|
39
|
+
m = monocle.Monocle(title='Patient 123')
|
|
40
|
+
m.volume(t1, name='T1')
|
|
41
|
+
m.volume(flair, name='FLAIR')
|
|
42
|
+
m.segmentation(label, name='Tumor')
|
|
43
|
+
|
|
44
|
+
m.show() # open in browser (no file, see below)
|
|
45
|
+
m.write('scan.html') # write a persistent file
|
|
46
|
+
html = m.html() # or get the HTML string
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`.volume(...)` and `.segmentation(...)` accept a range of sources and handle
|
|
50
|
+
the conversion:
|
|
51
|
+
|
|
52
|
+
- `voxel` volumes
|
|
53
|
+
- `nibabel` images
|
|
54
|
+
- `torch` tensors
|
|
55
|
+
- `numpy` arrays
|
|
56
|
+
- File paths
|
|
57
|
+
|
|
58
|
+
## Convenience methods
|
|
59
|
+
|
|
60
|
+
`monocle.show(sources, ...)` is a one-liner for a quick look. It builds a `Monocle`
|
|
61
|
+
instance, adds each source (with optional `names=` and `affine=`), and serves the
|
|
62
|
+
page in the browser. Session config kwargs (`title=`, `mode=`, …) pass straight through.
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
monocle.show(t1, flair, names=['T1', 'FLAIR'], title='Patient 123')
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
If you work with `voxel` Volumes, they carry a built-in `volume.show()` that
|
|
69
|
+
visualizes the scan through monocle directly — `monocle` ships with `voxel` by
|
|
70
|
+
default, so there's nothing extra to install.
|
|
71
|
+
|
|
72
|
+
## The embedded viewer bundle
|
|
73
|
+
|
|
74
|
+
Offline mode (the default, `inline=True`) pastes the viewer's UMD bundle into
|
|
75
|
+
the HTML so the file works from `file://` with no network. That bundle ships as
|
|
76
|
+
package data at `monocle/static/monocle.umd.js`.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The `monocle` console command to open the viewer from the shell.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from .normalize import display_name
|
|
13
|
+
from .session import Monocle
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _flatten(groups: list) -> list:
|
|
17
|
+
"""
|
|
18
|
+
Flatten argparse append+nargs groups into a single list.
|
|
19
|
+
"""
|
|
20
|
+
return [item for group in groups for item in group]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_mgz(path: str):
|
|
24
|
+
"""
|
|
25
|
+
Load an MGZ/MGH volume natively via surfa (the browser can't parse them).
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
path: The source file path.
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
A surfa Volume the `Monocle` builder can normalize.
|
|
32
|
+
"""
|
|
33
|
+
try:
|
|
34
|
+
import surfa
|
|
35
|
+
except ImportError:
|
|
36
|
+
sys.exit('reading MGZ files needs surfa: pip install surfa')
|
|
37
|
+
return surfa.load_volume(path)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _load_dicom(path: str) -> list:
|
|
41
|
+
"""
|
|
42
|
+
Convert DICOM (a file or a directory of series) to in-memory nibabel
|
|
43
|
+
images via dicom2nifti — the browser can't parse DICOM, so it's decoded
|
|
44
|
+
here and embedded as voxels.
|
|
45
|
+
|
|
46
|
+
Slices are grouped by series so a directory of mixed series yields one
|
|
47
|
+
image each; series dicom2nifti can't handle (e.g. some vendor mosaics) are
|
|
48
|
+
skipped with a warning rather than aborting the batch.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
path: A `.dcm` file or a directory containing DICOM files.
|
|
52
|
+
|
|
53
|
+
Returns:
|
|
54
|
+
A list of `(name, nibabel_image)` pairs, one per converted series.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
import pydicom
|
|
58
|
+
from dicom2nifti.convert_dicom import dicom_array_to_nifti
|
|
59
|
+
except ImportError:
|
|
60
|
+
sys.exit('reading DICOM needs dicom2nifti: pip install dicom2nifti')
|
|
61
|
+
|
|
62
|
+
files = [path] if os.path.isfile(path) else [
|
|
63
|
+
os.path.join(d, f) for d, _, fs in os.walk(path) for f in fs
|
|
64
|
+
]
|
|
65
|
+
# Group readable image slices by series.
|
|
66
|
+
series: dict = {}
|
|
67
|
+
for f in files:
|
|
68
|
+
try:
|
|
69
|
+
ds = pydicom.dcmread(f)
|
|
70
|
+
except Exception:
|
|
71
|
+
continue # non-DICOM (DICOMDIR, sidecars) — skip
|
|
72
|
+
if not hasattr(ds, 'PixelData') or not hasattr(ds, 'SeriesInstanceUID'):
|
|
73
|
+
continue
|
|
74
|
+
series.setdefault(ds.SeriesInstanceUID, []).append(ds)
|
|
75
|
+
if not series:
|
|
76
|
+
sys.exit(f'{path!r}: no DICOM image series found')
|
|
77
|
+
|
|
78
|
+
images = []
|
|
79
|
+
# Stable order: by series number, then description.
|
|
80
|
+
for uid in sorted(series, key=lambda u: (
|
|
81
|
+
int(getattr(series[u][0], 'SeriesNumber', 0) or 0),
|
|
82
|
+
str(getattr(series[u][0], 'SeriesDescription', '')))):
|
|
83
|
+
datasets = series[uid]
|
|
84
|
+
first = datasets[0]
|
|
85
|
+
name = (str(getattr(first, 'SeriesDescription', '')).strip()
|
|
86
|
+
or f'Series {getattr(first, "SeriesNumber", "?")}')
|
|
87
|
+
try:
|
|
88
|
+
# reorient_nifti=False keeps it fully in memory (reorientation
|
|
89
|
+
# would round-trip through a file path).
|
|
90
|
+
result = dicom_array_to_nifti(datasets, None, reorient_nifti=False)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
print(f'skipping series {name!r}: {type(e).__name__}: {e}', file=sys.stderr)
|
|
93
|
+
continue
|
|
94
|
+
images.append((name, result['NII']))
|
|
95
|
+
if not images:
|
|
96
|
+
sys.exit(f'{path!r}: no DICOM series could be converted')
|
|
97
|
+
return images
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _add(m: Monocle, path: str, *, seg: bool) -> None:
|
|
101
|
+
"""
|
|
102
|
+
Add one input to the session, choosing the native-vs-browser load path.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
m: The session builder.
|
|
106
|
+
path: A file path, or a directory of DICOM series.
|
|
107
|
+
seg: Whether the input is a segmentation label mask.
|
|
108
|
+
"""
|
|
109
|
+
add = m.segmentation if seg else m.volume
|
|
110
|
+
lower = path.lower()
|
|
111
|
+
if os.path.isdir(path) or lower.endswith('.dcm'):
|
|
112
|
+
for name, img in _load_dicom(path):
|
|
113
|
+
add(img, name=name)
|
|
114
|
+
elif lower.endswith(('.mgz', '.mgh')):
|
|
115
|
+
add(_load_mgz(path), name=display_name(path))
|
|
116
|
+
else:
|
|
117
|
+
# Raw passthrough: embedded verbatim, parsed by the browser.
|
|
118
|
+
add(path)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main(argv: Optional[list] = None) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Parse arguments and open (or write) the Monocle viewer.
|
|
124
|
+
"""
|
|
125
|
+
parser = argparse.ArgumentParser(
|
|
126
|
+
prog='monocle',
|
|
127
|
+
description='Open the Monocle viewer.',
|
|
128
|
+
)
|
|
129
|
+
parser.add_argument(
|
|
130
|
+
'images', nargs='*', help='image files or dicom directories to view')
|
|
131
|
+
parser.add_argument(
|
|
132
|
+
'-i', '--image', action='append', nargs='+', default=[],
|
|
133
|
+
metavar='IMAGE', help='additional image file(s); repeatable')
|
|
134
|
+
parser.add_argument(
|
|
135
|
+
'-s', '--seg', '--segmentation', action='append', nargs='+', default=[],
|
|
136
|
+
dest='segs', metavar='SEG', help='segmentation label mask(s); repeatable')
|
|
137
|
+
parser.add_argument('--title', help='session title')
|
|
138
|
+
parser.add_argument(
|
|
139
|
+
'--mode', choices=('panels', 'stack'), help='initial view mode')
|
|
140
|
+
parser.add_argument(
|
|
141
|
+
'--panels', type=int, metavar='N',
|
|
142
|
+
help='initial number of viewer panels (snapped up to 1, 2, 3, or 6)')
|
|
143
|
+
parser.add_argument(
|
|
144
|
+
'--no-populate', dest='populate', action='store_const',
|
|
145
|
+
const=False, default=None,
|
|
146
|
+
help='start with empty panels instead of filling them with images')
|
|
147
|
+
parser.add_argument(
|
|
148
|
+
'--html', metavar='PATH',
|
|
149
|
+
help='write a shareable standalone HTML file instead of opening a browser')
|
|
150
|
+
args = parser.parse_args(argv)
|
|
151
|
+
|
|
152
|
+
images = list(args.images) + _flatten(args.image)
|
|
153
|
+
segs = _flatten(args.segs)
|
|
154
|
+
# No inputs is fine — opens a blank viewer to drag files into.
|
|
155
|
+
|
|
156
|
+
m = Monocle(
|
|
157
|
+
title=args.title,
|
|
158
|
+
mode=args.mode,
|
|
159
|
+
populate=args.populate,
|
|
160
|
+
panel_count=args.panels)
|
|
161
|
+
|
|
162
|
+
for path in images:
|
|
163
|
+
_add(m, path, seg=False)
|
|
164
|
+
for path in segs:
|
|
165
|
+
_add(m, path, seg=True)
|
|
166
|
+
|
|
167
|
+
if args.html:
|
|
168
|
+
m.write(args.html)
|
|
169
|
+
print(f'wrote {args.html}')
|
|
170
|
+
else:
|
|
171
|
+
m.show()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == '__main__':
|
|
175
|
+
main()
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Render a self-contained standalone HTML file embedding a Monocle session.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
import gzip
|
|
8
|
+
import json
|
|
9
|
+
import webbrowser
|
|
10
|
+
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Callable, Optional, Sequence
|
|
14
|
+
|
|
15
|
+
from .normalize import RawFile, Volume
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# library bundle package data
|
|
19
|
+
_BUNDLE = Path(__file__).parent / 'static' / 'monocle.umd.js'
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _bundle_source() -> str:
|
|
23
|
+
if not _BUNDLE.exists():
|
|
24
|
+
raise FileNotFoundError(
|
|
25
|
+
f'Bundled viewer not found at {_BUNDLE}. Build it with '
|
|
26
|
+
'`npm run build:lib` in frontend/ and copy frontend/dist-lib/monocle.umd.js there, or call '
|
|
27
|
+
'with inline=False and a monocle_url=.'
|
|
28
|
+
)
|
|
29
|
+
return _BUNDLE.read_text(encoding='utf-8')
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _render_html(manifest, payload_blocks, inline, monocle_url, title) -> str:
|
|
33
|
+
# escape `<` so embedded JSON can't break out of its <script> block
|
|
34
|
+
manifest_json = json.dumps(manifest).replace('<', '\\u003c')
|
|
35
|
+
blocks = '\n'.join(
|
|
36
|
+
f'<script type="application/octet-stream" id="monocle-vol-{vid}">{b64}</script>'
|
|
37
|
+
for vid, b64 in payload_blocks
|
|
38
|
+
)
|
|
39
|
+
if inline:
|
|
40
|
+
src = _bundle_source().replace('</script', '<\\/script')
|
|
41
|
+
js_block = f'<script>{src}</script>'
|
|
42
|
+
else:
|
|
43
|
+
if not monocle_url:
|
|
44
|
+
raise ValueError('inline=False requires monocle_url=')
|
|
45
|
+
js_block = f'<script src="{monocle_url}"></script>'
|
|
46
|
+
|
|
47
|
+
return f"""<!doctype html>
|
|
48
|
+
<html lang="en">
|
|
49
|
+
<head>
|
|
50
|
+
<meta charset="utf-8" />
|
|
51
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
52
|
+
<title>{title}</title>
|
|
53
|
+
<style>html,body{{height:100%;margin:0}}#root{{position:fixed;inset:0}}</style>
|
|
54
|
+
</head>
|
|
55
|
+
<body>
|
|
56
|
+
<div id="root"></div>
|
|
57
|
+
<script type="application/monocle+json" id="monocle-session">{manifest_json}</script>
|
|
58
|
+
{blocks}
|
|
59
|
+
{js_block}
|
|
60
|
+
<script>Monocle.mountEmbedded('#root')</script>
|
|
61
|
+
</body>
|
|
62
|
+
</html>
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _encode_volumes(
|
|
67
|
+
volumes: Sequence[Volume],
|
|
68
|
+
id_prefix: str,
|
|
69
|
+
compression: str,
|
|
70
|
+
payload_blocks: list) -> list[dict]:
|
|
71
|
+
"""
|
|
72
|
+
Build manifest entries for `volumes` and append their payload blocks.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
volumes: The volumes to encode.
|
|
76
|
+
id_prefix: Prefix for the generated per-volume ids.
|
|
77
|
+
compression: Payload compression, 'gzip' or 'none'.
|
|
78
|
+
payload_blocks: The `(id, base64)` list to append payloads to.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
The manifest entries.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ValueError: If `compression` is unknown.
|
|
85
|
+
"""
|
|
86
|
+
if compression not in ('gzip', 'none'):
|
|
87
|
+
raise ValueError(f'Unknown compression: {compression!r}')
|
|
88
|
+
entries = []
|
|
89
|
+
for i, v in enumerate(volumes):
|
|
90
|
+
vid = f'{id_prefix}-{i}'
|
|
91
|
+
entry = v.manifest_entry(vid)
|
|
92
|
+
raw = v.raw_bytes()
|
|
93
|
+
# Raw source files that are already compressed (.nii.gz, .png, …) are
|
|
94
|
+
# embedded verbatim; everything else honors the requested transport.
|
|
95
|
+
mode = 'none' if isinstance(v, RawFile) and v.precompressed else compression
|
|
96
|
+
payload = gzip.compress(raw) if mode == 'gzip' else raw
|
|
97
|
+
entry['compression'] = mode
|
|
98
|
+
entries.append(entry)
|
|
99
|
+
payload_blocks.append((vid, base64.b64encode(payload).decode('ascii')))
|
|
100
|
+
return entries
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render_html(
|
|
104
|
+
volumes: Sequence[Volume],
|
|
105
|
+
*,
|
|
106
|
+
annotations: Optional[Sequence[Volume]] = None,
|
|
107
|
+
inline: bool = True,
|
|
108
|
+
monocle_url: Optional[str] = None,
|
|
109
|
+
compression: str = 'gzip',
|
|
110
|
+
config: Optional[dict] = None) -> str:
|
|
111
|
+
"""
|
|
112
|
+
Build the self-contained standalone HTML for `volumes`.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
volumes: The scans to embed.
|
|
116
|
+
annotations: Segmentation label masks overlaid onto the scans (emitted
|
|
117
|
+
as a separate `annotations` array).
|
|
118
|
+
inline: Inline the viewer bundle; if False, reference `monocle_url`.
|
|
119
|
+
monocle_url: URL of a hosted viewer bundle.
|
|
120
|
+
compression: Volume payload compression, 'gzip' or 'none'.
|
|
121
|
+
config: Session-level viewing config (viewMode, title, global
|
|
122
|
+
toggles); its `title` also becomes the HTML document title.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The HTML document as a string.
|
|
126
|
+
"""
|
|
127
|
+
payload_blocks: list = []
|
|
128
|
+
scans = _encode_volumes(volumes, 'vol', compression, payload_blocks)
|
|
129
|
+
anno_entries = _encode_volumes(annotations or [], 'anno', compression, payload_blocks)
|
|
130
|
+
|
|
131
|
+
manifest = {'scans': scans}
|
|
132
|
+
if anno_entries:
|
|
133
|
+
manifest['annotations'] = anno_entries
|
|
134
|
+
if config:
|
|
135
|
+
manifest['config'] = config
|
|
136
|
+
title = (config or {}).get('title') or 'Monocle'
|
|
137
|
+
return _render_html(manifest, payload_blocks, inline, monocle_url, title)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def write_html(volumes: Sequence[Volume], path: str, **kwargs) -> str:
|
|
141
|
+
"""
|
|
142
|
+
Write the standalone HTML for `volumes` to a file.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
volumes: The scans to embed.
|
|
146
|
+
path: Destination file path.
|
|
147
|
+
**kwargs: Forwarded to `render_html`.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
The path written.
|
|
151
|
+
"""
|
|
152
|
+
Path(path).write_text(render_html(volumes, **kwargs), encoding='utf-8')
|
|
153
|
+
return path
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def open_html_in_browser(
|
|
157
|
+
html: str,
|
|
158
|
+
*,
|
|
159
|
+
browser: Optional[str] = None,
|
|
160
|
+
_open: Optional[Callable[[str], object]] = None) -> None:
|
|
161
|
+
"""
|
|
162
|
+
Serve `html` from a one-shot local HTTP server and open the browser to it.
|
|
163
|
+
|
|
164
|
+
Mirrors plotly's `open_html_in_browser`: bind a random free port on
|
|
165
|
+
127.0.0.1, open the browser there, serve exactly one GET, then shut down —
|
|
166
|
+
so nothing is written to disk. Blocks until that single request is served
|
|
167
|
+
(which keeps the process alive long enough for the browser to fetch).
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
html: The document to serve.
|
|
171
|
+
browser: A `webbrowser` browser name; defaults to the system browser.
|
|
172
|
+
_open: An injectable opener (receives the URL) used for testing
|
|
173
|
+
without a real browser; defaults to the resolved `webbrowser`
|
|
174
|
+
opener.
|
|
175
|
+
"""
|
|
176
|
+
payload = html.encode('utf-8')
|
|
177
|
+
|
|
178
|
+
class OneShotRequestHandler(BaseHTTPRequestHandler):
|
|
179
|
+
def do_GET(self): # noqa: N802 (http.server API)
|
|
180
|
+
self.send_response(200)
|
|
181
|
+
self.send_header('Content-type', 'text/html')
|
|
182
|
+
self.end_headers()
|
|
183
|
+
buffer_size = 1024 * 1024
|
|
184
|
+
for i in range(0, len(payload), buffer_size):
|
|
185
|
+
self.wfile.write(payload[i : i + buffer_size])
|
|
186
|
+
|
|
187
|
+
def log_message(self, format, *args): # silence stderr logging
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
server = HTTPServer(('127.0.0.1', 0), OneShotRequestHandler)
|
|
191
|
+
url = 'http://127.0.0.1:%s' % server.server_port
|
|
192
|
+
try:
|
|
193
|
+
opener = _open if _open is not None else webbrowser.get(browser).open
|
|
194
|
+
opener(url)
|
|
195
|
+
server.handle_request()
|
|
196
|
+
finally:
|
|
197
|
+
server.server_close()
|