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.
@@ -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,4 @@
1
+ from .normalize import Volume, to_volume
2
+ from .session import Monocle, show
3
+
4
+ __all__ = ['Monocle', 'show', 'to_volume', 'Volume']
@@ -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()