dcmview-py 0.2.2__py3-none-win_amd64.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.
dcmview_py/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Python wrapper package for launching dcmview."""
2
+
3
+ from .wrapper import ShutdownHandle, view
4
+
5
+ __all__ = ["ShutdownHandle", "view"]
dcmview_py/__main__.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import re
5
+ import subprocess
6
+ import sys
7
+ from importlib import metadata
8
+ from pathlib import Path
9
+ from typing import Optional, Sequence
10
+
11
+ from .wrapper import view
12
+
13
+
14
+ def _package_version() -> str:
15
+ try:
16
+ return metadata.version("dcmview-py")
17
+ except metadata.PackageNotFoundError:
18
+ pyproject = Path(__file__).resolve().parents[2] / "pyproject.toml"
19
+ if not pyproject.is_file():
20
+ return "unknown"
21
+ match = re.search(
22
+ r'(?m)^\[project\]\s*(?:\n(?!\[).*)*?\nversion\s*=\s*"([^"]+)"',
23
+ pyproject.read_text(encoding="utf-8"),
24
+ )
25
+ return match.group(1) if match else "unknown"
26
+
27
+
28
+ def _build_parser() -> argparse.ArgumentParser:
29
+ parser = argparse.ArgumentParser(prog="python -m dcmview_py")
30
+ parser.add_argument("--version", action="version", version=f"dcmview {_package_version()}")
31
+ parser.add_argument("paths", nargs="+", help="One or more DICOM file or directory paths")
32
+ parser.add_argument("-p", "--port", type=int, default=0)
33
+ parser.add_argument("--host", default="127.0.0.1")
34
+ parser.add_argument("--no-browser", action="store_true")
35
+ parser.add_argument("--tunnel", action="store_true")
36
+ parser.add_argument("--tunnel-host")
37
+ parser.add_argument("--tunnel-port", type=int, default=0)
38
+ parser.add_argument("--timeout", type=int)
39
+ parser.add_argument("--no-recursive", action="store_true")
40
+ parser.add_argument("--annotations")
41
+ return parser
42
+
43
+
44
+ def run_cli(argv: Optional[Sequence[str]] = None) -> int:
45
+ parser = _build_parser()
46
+ args = parser.parse_args(argv)
47
+
48
+ try:
49
+ view(
50
+ args.paths,
51
+ port=args.port,
52
+ host=args.host,
53
+ browser=not args.no_browser,
54
+ tunnel=args.tunnel,
55
+ tunnel_host=args.tunnel_host,
56
+ tunnel_port=args.tunnel_port,
57
+ recursive=not args.no_recursive,
58
+ timeout=args.timeout,
59
+ annotations=args.annotations,
60
+ block=True,
61
+ )
62
+ except subprocess.CalledProcessError as error:
63
+ return int(error.returncode)
64
+ except (RuntimeError, ValueError, TypeError) as error:
65
+ print(str(error), file=sys.stderr)
66
+ return 1
67
+
68
+ return 0
69
+
70
+
71
+ def main() -> None:
72
+ raise SystemExit(run_cli())
73
+
74
+
75
+ if __name__ == "__main__":
76
+ main()
Binary file
dcmview_py/wrapper.py ADDED
@@ -0,0 +1,474 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import json
5
+ import shutil
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import urllib.error
11
+ import urllib.request
12
+ from pathlib import Path
13
+ from typing import Iterable, Optional, Union
14
+
15
+ _STARTUP_PREFIX = "dcmview: server running at "
16
+ _STARTUP_EVENT_TYPE = "server_started"
17
+ _URL_WAIT_SECONDS = 5.0
18
+ _STOP_TIMEOUT_SECONDS = 5.0
19
+ _BINARY_ENV = "DCMVIEW_BINARY"
20
+ _VSCODE_BRIDGE_URL_ENV = "DCMVIEW_VSCODE_BRIDGE_URL"
21
+ _VSCODE_BRIDGE_TOKEN_ENV = "DCMVIEW_VSCODE_BRIDGE_TOKEN"
22
+ _VSCODE_BRIDGE_BYPASS_ENV = "DCMVIEW_VSCODE_BYPASS"
23
+
24
+ PathInput = Union[str, os.PathLike[str]]
25
+
26
+
27
+ class _OutputMonitor:
28
+ def __init__(self, process: subprocess.Popen[str]) -> None:
29
+ self._process = process
30
+ self._url: Optional[str] = None
31
+ self._url_lock = threading.Lock()
32
+ self._startup_json_unsupported = False
33
+ self._startup_json_unsupported_lock = threading.Lock()
34
+ self._url_ready = threading.Event()
35
+ self._thread = threading.Thread(target=self._run, name="dcmview-py-output", daemon=True)
36
+
37
+ def start(self) -> None:
38
+ self._thread.start()
39
+
40
+ def join(self) -> None:
41
+ self._thread.join()
42
+
43
+ def wait_for_url(self, timeout: float) -> Optional[str]:
44
+ self._url_ready.wait(timeout)
45
+ return self.url
46
+
47
+ @property
48
+ def url(self) -> Optional[str]:
49
+ with self._url_lock:
50
+ return self._url
51
+
52
+ @property
53
+ def startup_json_unsupported(self) -> bool:
54
+ with self._startup_json_unsupported_lock:
55
+ return self._startup_json_unsupported
56
+
57
+ def _set_url(self, url: str) -> None:
58
+ with self._url_lock:
59
+ if self._url is None:
60
+ self._url = url
61
+ self._url_ready.set()
62
+
63
+ def _set_startup_json_unsupported(self) -> None:
64
+ with self._startup_json_unsupported_lock:
65
+ self._startup_json_unsupported = True
66
+
67
+ def _run(self) -> None:
68
+ stdout = self._process.stdout
69
+ if stdout is None:
70
+ self._url_ready.set()
71
+ return
72
+
73
+ try:
74
+ for line in stdout:
75
+ sys.stdout.write(line)
76
+ sys.stdout.flush()
77
+ url = _parse_startup_url(line)
78
+ if url is not None:
79
+ self._set_url(url)
80
+ if _is_startup_json_unsupported_line(line):
81
+ self._set_startup_json_unsupported()
82
+ finally:
83
+ stdout.close()
84
+ self._url_ready.set()
85
+
86
+
87
+ class ShutdownHandle:
88
+ """Handle for controlling a non-blocking dcmview subprocess."""
89
+
90
+ def __init__(self, process: subprocess.Popen[str], monitor: _OutputMonitor) -> None:
91
+ self._process = process
92
+ self._monitor = monitor
93
+
94
+ @property
95
+ def url(self) -> Optional[str]:
96
+ return self._monitor.url
97
+
98
+ def stop(self, timeout: float = _STOP_TIMEOUT_SECONDS) -> int:
99
+ if self._process.poll() is not None:
100
+ self._monitor.join()
101
+ return int(self._process.returncode or 0)
102
+
103
+ try:
104
+ self._process.send_signal(_graceful_stop_signal())
105
+ except (ProcessLookupError, ValueError):
106
+ self._monitor.join()
107
+ return int(self._process.returncode or 0)
108
+ try:
109
+ return_code = self._process.wait(timeout=timeout)
110
+ except subprocess.TimeoutExpired:
111
+ self._process.terminate()
112
+ try:
113
+ return_code = self._process.wait(timeout=timeout)
114
+ except subprocess.TimeoutExpired:
115
+ self._process.kill()
116
+ return_code = self._process.wait(timeout=timeout)
117
+
118
+ self._monitor.join()
119
+ return int(return_code)
120
+
121
+ def __enter__(self) -> ShutdownHandle:
122
+ return self
123
+
124
+ def __exit__(self, _exc_type, _exc, _tb) -> None:
125
+ self.stop()
126
+
127
+
128
+ class BridgeShutdownHandle:
129
+ """Handle for controlling a VS Code-managed dcmview session."""
130
+
131
+ def __init__(self, session_id: str, url: str) -> None:
132
+ self._session_id = session_id
133
+ self._url = url
134
+
135
+ @property
136
+ def url(self) -> Optional[str]:
137
+ return self._url
138
+
139
+ def stop(self, timeout: float = _STOP_TIMEOUT_SECONDS) -> int:
140
+ _bridge_json_request("POST", f"/sessions/{self._session_id}/stop", timeout=timeout)
141
+ response = _bridge_json_request("GET", f"/sessions/{self._session_id}/wait", timeout=timeout)
142
+ return int(response.get("exitCode") or 0)
143
+
144
+ def __enter__(self) -> BridgeShutdownHandle:
145
+ return self
146
+
147
+ def __exit__(self, _exc_type, _exc, _tb) -> None:
148
+ self.stop()
149
+
150
+
151
+ def view(
152
+ files: PathInput | Iterable[PathInput],
153
+ *,
154
+ port: int = 0,
155
+ host: str = "127.0.0.1",
156
+ browser: bool = True,
157
+ tunnel: bool = False,
158
+ tunnel_host: Optional[str] = None,
159
+ tunnel_port: int = 0,
160
+ block: bool = True,
161
+ recursive: bool = True,
162
+ timeout: Optional[int] = None,
163
+ annotations: Optional[PathInput] = None,
164
+ ) -> Optional[ShutdownHandle | BridgeShutdownHandle]:
165
+ """Launch dcmview for one or more filesystem paths."""
166
+
167
+ paths = _normalize_files(files)
168
+ annotation_path = _normalize_optional_path(annotations, field_name="annotations")
169
+ args = _build_args(
170
+ paths,
171
+ port=port,
172
+ host=host,
173
+ browser=browser,
174
+ tunnel=tunnel,
175
+ tunnel_host=tunnel_host,
176
+ tunnel_port=tunnel_port,
177
+ recursive=recursive,
178
+ timeout=timeout,
179
+ annotations=annotation_path,
180
+ )
181
+ if _bridge_available():
182
+ try:
183
+ return _view_via_vscode_bridge(args, block=block)
184
+ except (RuntimeError, OSError, urllib.error.URLError) as error:
185
+ print(
186
+ f"dcmview: VS Code bridge unavailable ({error}); falling back to local viewer",
187
+ file=sys.stderr,
188
+ )
189
+
190
+ for include_startup_json in (True, False):
191
+ command = _build_command(
192
+ paths,
193
+ port=port,
194
+ host=host,
195
+ browser=browser,
196
+ tunnel=tunnel,
197
+ tunnel_host=tunnel_host,
198
+ tunnel_port=tunnel_port,
199
+ recursive=recursive,
200
+ timeout=timeout,
201
+ annotations=annotation_path,
202
+ include_startup_json=include_startup_json,
203
+ )
204
+
205
+ process = subprocess.Popen(command, **_popen_options())
206
+ monitor = _OutputMonitor(process)
207
+ monitor.start()
208
+
209
+ if block:
210
+ return_code = process.wait()
211
+ monitor.join()
212
+ if return_code != 0:
213
+ if include_startup_json and monitor.startup_json_unsupported:
214
+ continue
215
+ raise subprocess.CalledProcessError(return_code, command)
216
+ return None
217
+
218
+ monitor.wait_for_url(_URL_WAIT_SECONDS)
219
+ if process.poll() is not None and process.returncode not in (0, None):
220
+ monitor.join()
221
+ if include_startup_json and monitor.startup_json_unsupported:
222
+ continue
223
+ raise subprocess.CalledProcessError(int(process.returncode), command)
224
+
225
+ return ShutdownHandle(process, monitor)
226
+
227
+ raise RuntimeError("dcmview failed to start")
228
+
229
+
230
+ def _view_via_vscode_bridge(
231
+ args: list[str],
232
+ *,
233
+ block: bool,
234
+ ) -> Optional[BridgeShutdownHandle]:
235
+ response = _bridge_json_request(
236
+ "POST",
237
+ "/launch",
238
+ {
239
+ "program": "dcmview_py",
240
+ "args": args,
241
+ "cwd": os.getcwd(),
242
+ "wait": False,
243
+ },
244
+ )
245
+ session_id = str(response["sessionId"])
246
+ url = str(response["url"])
247
+ print(f"dcmview: opened in VS Code at {url}")
248
+
249
+ if not block:
250
+ return BridgeShutdownHandle(session_id, url)
251
+
252
+ wait_response = _bridge_json_request("GET", f"/sessions/{session_id}/wait")
253
+ exit_code = int(wait_response.get("exitCode") or 0)
254
+ if exit_code != 0:
255
+ raise subprocess.CalledProcessError(exit_code, ["dcmview-vscode-bridge", *args])
256
+ return None
257
+
258
+
259
+ def _normalize_files(files: PathInput | Iterable[PathInput]) -> list[str]:
260
+ if isinstance(files, (str, os.PathLike)):
261
+ candidates: list[PathInput] = [files]
262
+ else:
263
+ candidates = list(files)
264
+
265
+ if not candidates:
266
+ raise ValueError("at least one file path is required")
267
+
268
+ normalized: list[str] = []
269
+ for candidate in candidates:
270
+ if not isinstance(candidate, (str, os.PathLike)):
271
+ raise TypeError("files must be path-like values")
272
+ normalized.append(str(Path(candidate)))
273
+ return normalized
274
+
275
+
276
+ def _normalize_optional_path(path: Optional[PathInput], *, field_name: str) -> Optional[str]:
277
+ if path is None:
278
+ return None
279
+ if not isinstance(path, (str, os.PathLike)):
280
+ raise TypeError(f"{field_name} must be a path-like value")
281
+ return str(Path(path))
282
+
283
+
284
+ def _build_command(
285
+ paths: list[str],
286
+ *,
287
+ port: int,
288
+ host: str,
289
+ browser: bool,
290
+ tunnel: bool,
291
+ tunnel_host: Optional[str],
292
+ tunnel_port: int,
293
+ recursive: bool,
294
+ timeout: Optional[int],
295
+ annotations: Optional[str],
296
+ include_startup_json: bool = True,
297
+ ) -> list[str]:
298
+ return [
299
+ _resolve_binary(),
300
+ *_build_args(
301
+ paths,
302
+ port=port,
303
+ host=host,
304
+ browser=browser,
305
+ tunnel=tunnel,
306
+ tunnel_host=tunnel_host,
307
+ tunnel_port=tunnel_port,
308
+ recursive=recursive,
309
+ timeout=timeout,
310
+ annotations=annotations,
311
+ include_startup_json=include_startup_json,
312
+ ),
313
+ ]
314
+
315
+
316
+ def _build_args(
317
+ paths: list[str],
318
+ *,
319
+ port: int,
320
+ host: str,
321
+ browser: bool,
322
+ tunnel: bool,
323
+ tunnel_host: Optional[str],
324
+ tunnel_port: int,
325
+ recursive: bool,
326
+ timeout: Optional[int],
327
+ annotations: Optional[str],
328
+ include_startup_json: bool = True,
329
+ ) -> list[str]:
330
+ if tunnel and not tunnel_host:
331
+ raise ValueError("tunnel_host is required when tunnel=True")
332
+
333
+ command = ["--port", str(port), "--host", host]
334
+ if include_startup_json:
335
+ command.append("--startup-json")
336
+ if not browser:
337
+ command.append("--no-browser")
338
+ if tunnel:
339
+ command.append("--tunnel")
340
+ command.extend(["--tunnel-host", str(tunnel_host), "--tunnel-port", str(tunnel_port)])
341
+ if timeout is not None:
342
+ command.extend(["--timeout", str(timeout)])
343
+ if not recursive:
344
+ command.append("--no-recursive")
345
+ if annotations is not None:
346
+ command.extend(["--annotations", annotations])
347
+ command.extend(paths)
348
+ return command
349
+
350
+
351
+ def _parse_startup_url(line: str) -> Optional[str]:
352
+ trimmed = line.strip()
353
+ if trimmed.startswith("{"):
354
+ try:
355
+ event = json.loads(trimmed)
356
+ except json.JSONDecodeError:
357
+ return None
358
+ if (
359
+ isinstance(event, dict)
360
+ and event.get("type") == _STARTUP_EVENT_TYPE
361
+ and isinstance(event.get("url"), str)
362
+ and event["url"]
363
+ ):
364
+ return event["url"]
365
+ return None
366
+
367
+ if trimmed.startswith(_STARTUP_PREFIX):
368
+ url = trimmed[len(_STARTUP_PREFIX) :].strip()
369
+ return url or None
370
+ return None
371
+
372
+
373
+ def _is_startup_json_unsupported_line(line: str) -> bool:
374
+ normalized = line.lower()
375
+ return "--startup-json" in normalized and any(
376
+ marker in normalized
377
+ for marker in [
378
+ "unexpected",
379
+ "unrecognized",
380
+ "unknown",
381
+ "wasn't expected",
382
+ "was not expected",
383
+ "found argument",
384
+ ]
385
+ )
386
+
387
+
388
+ def _bridge_available() -> bool:
389
+ return (
390
+ os.environ.get(_VSCODE_BRIDGE_BYPASS_ENV) != "1"
391
+ and bool(os.environ.get(_VSCODE_BRIDGE_URL_ENV))
392
+ and bool(os.environ.get(_VSCODE_BRIDGE_TOKEN_ENV))
393
+ )
394
+
395
+
396
+ def _bridge_json_request(
397
+ method: str,
398
+ path: str,
399
+ payload: Optional[dict[str, object]] = None,
400
+ *,
401
+ timeout: float = _STOP_TIMEOUT_SECONDS,
402
+ ) -> dict[str, object]:
403
+ base_url = os.environ[_VSCODE_BRIDGE_URL_ENV].rstrip("/")
404
+ token = os.environ[_VSCODE_BRIDGE_TOKEN_ENV]
405
+ body = None if payload is None else json.dumps(payload).encode("utf-8")
406
+ request = urllib.request.Request(
407
+ f"{base_url}{path}",
408
+ data=body,
409
+ method=method,
410
+ headers={
411
+ "Authorization": f"Bearer {token}",
412
+ "Content-Type": "application/json",
413
+ },
414
+ )
415
+ with urllib.request.urlopen(request, timeout=timeout) as response:
416
+ return json.loads(response.read().decode("utf-8"))
417
+
418
+
419
+ def _resolve_binary() -> str:
420
+ configured = os.environ.get(_BINARY_ENV)
421
+ if configured:
422
+ candidate = Path(configured).expanduser()
423
+ if candidate.is_file():
424
+ _ensure_executable(candidate)
425
+ return str(candidate)
426
+ raise RuntimeError(f"{_BINARY_ENV} points to a missing file: {candidate}")
427
+
428
+ bundled = Path(__file__).resolve().parent / "bin" / _binary_name()
429
+ if bundled.is_file():
430
+ _ensure_executable(bundled)
431
+ return str(bundled)
432
+
433
+ path_binary = shutil.which(_binary_name())
434
+ if path_binary is not None:
435
+ return path_binary
436
+
437
+ raise RuntimeError(
438
+ "dcmview binary not found — install a bundled wheel or install the Rust binary separately"
439
+ )
440
+
441
+
442
+ def _binary_name() -> str:
443
+ return "dcmview.exe" if _is_windows() else "dcmview"
444
+
445
+
446
+ def _is_windows() -> bool:
447
+ return os.name == "nt"
448
+
449
+
450
+ def _popen_options() -> dict[str, object]:
451
+ options: dict[str, object] = {
452
+ "stdout": subprocess.PIPE,
453
+ "stderr": subprocess.STDOUT,
454
+ "text": True,
455
+ "bufsize": 1,
456
+ }
457
+ if _is_windows():
458
+ options["creationflags"] = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
459
+ return options
460
+
461
+
462
+ def _graceful_stop_signal() -> signal.Signals | int:
463
+ if _is_windows():
464
+ return getattr(signal, "CTRL_BREAK_EVENT", signal.SIGTERM)
465
+ return signal.SIGINT
466
+
467
+
468
+ def _ensure_executable(path: Path) -> None:
469
+ if _is_windows() or os.access(path, os.X_OK):
470
+ return
471
+
472
+ mode = path.stat().st_mode
473
+ exec_bits = (mode & 0o444) >> 2
474
+ path.chmod(mode | exec_bits)
@@ -0,0 +1,437 @@
1
+ Metadata-Version: 2.4
2
+ Name: dcmview-py
3
+ Version: 0.2.2
4
+ Summary: Fast temporary DICOM viewer for local and remote research workflows
5
+ Author: Beatrice Brown-Mulry
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Programming Language :: Python :: 3 :: Only
9
+ Classifier: Operating System :: OS Independent
10
+ Requires-Python: >=3.9
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Dynamic: license-file
14
+
15
+ # dcmview
16
+
17
+ `dcmview` is a fast, temporary DICOM viewer for research and development work.
18
+ Point it at one or more DICOM files from the command line or Python, and it
19
+ starts a local browser viewer for images, tags, cine playback, and rectangular
20
+ ROI annotations. Stop the process and the server is gone.
21
+
22
+ The main problem it solves is remote-server inspection. Medical imaging research
23
+ often happens where the data already live: an SSH session, a shared compute
24
+ server, or a locked-down institutional network. Viewing those images usually
25
+ means choosing between slow notebook plots, setting up a web viewer on the
26
+ server, opening firewall ports, or uploading data and annotations into a
27
+ third-party cloud tool. `dcmview` keeps the workflow local to the machine with
28
+ the files: start the viewer, forward the loopback port over SSH when needed, and
29
+ inspect the study in seconds.
30
+
31
+ `dcmview` is intended for developer and research inspection, not clinical
32
+ diagnosis.
33
+
34
+ ## Why use it?
35
+
36
+ - Inspect DICOM files where they already are, including remote servers.
37
+ - Avoid notebook-based frame rendering for multi-frame studies.
38
+ - Keep data off third-party viewers when all you need is quick review.
39
+ - Open a browser UI with familiar viewer tools: pan, zoom, scroll,
40
+ window/level, flips, rotation, tags, and cine playback.
41
+ - Load, edit, and export rectangular ROI annotations without modifying the
42
+ source DICOM files.
43
+ - Use the same tool from a shell command, a Python script, or a notebook.
44
+ - Run as an ephemeral server with no database, config file, or persistent state.
45
+
46
+ ## Install
47
+
48
+ On supported Linux platforms, the Python package bundles the `dcmview` binary:
49
+
50
+ ```bash
51
+ python -m pip install --user dcmview-py
52
+ dcmview --help
53
+ ```
54
+
55
+ The package installs both `dcmview` and `dcmview-py`; `dcmview` is the primary
56
+ command.
57
+
58
+ On macOS, use the published Homebrew tap or download a prebuilt archive from
59
+ GitHub Releases.
60
+
61
+ Source builds are available for contributors and unsupported platforms:
62
+
63
+ ```bash
64
+ cargo install --path .
65
+ ```
66
+
67
+ Build prerequisites for source installs:
68
+
69
+ - Rust stable 1.75+
70
+ - Node.js 18+ and npm at build time
71
+ - `ssh` on `PATH` only when using SSH forwarding helpers
72
+
73
+ ## Quick Start
74
+
75
+ Open one file:
76
+
77
+ ```bash
78
+ dcmview ./scan.dcm
79
+ ```
80
+
81
+ Scan a study directory recursively:
82
+
83
+ ```bash
84
+ dcmview ./study_dir
85
+ ```
86
+
87
+ Run without opening a browser, useful on a remote server:
88
+
89
+ ```bash
90
+ dcmview --no-browser ./study_dir
91
+ ```
92
+
93
+ When ready, `dcmview` prints a URL:
94
+
95
+ ```text
96
+ dcmview: server running at http://127.0.0.1:<port>
97
+ ```
98
+
99
+ Press Ctrl+C to stop the server.
100
+
101
+ ## Remote Server Workflow
102
+
103
+ The safest default is to keep `dcmview` bound to loopback on the remote machine
104
+ and access it through SSH port forwarding.
105
+
106
+ On the remote server:
107
+
108
+ ```bash
109
+ dcmview --no-browser --port 8888 /path/to/dicom_or_study_dir
110
+ ```
111
+
112
+ On your local machine:
113
+
114
+ ```bash
115
+ ssh -L 8888:localhost:8888 user@remote-server
116
+ ```
117
+
118
+ Then open:
119
+
120
+ ```text
121
+ http://localhost:8888
122
+ ```
123
+
124
+ You can also let `dcmview` use an auto-assigned port by omitting `--port`; copy
125
+ the printed port into your SSH command. The optional `--tunnel` flags are
126
+ available for environments where the `dcmview` process can start the SSH helper
127
+ itself.
128
+
129
+ The HTTP server is unauthenticated. It binds to `127.0.0.1` by default. If you
130
+ bind to `0.0.0.0` or another public interface, use your own network access
131
+ controls.
132
+
133
+ ## Python Usage
134
+
135
+ `dcmview-py` is a small subprocess wrapper around the Rust binary. It is useful
136
+ when a script or notebook has already selected the cases to inspect.
137
+
138
+ ```python
139
+ from dcmview_py import view
140
+
141
+ # Blocking call; returns when dcmview exits.
142
+ view(["./scan.dcm"], browser=False, timeout=300)
143
+
144
+ # Non-blocking call.
145
+ handle = view(["./study_dir"], browser=False, block=False)
146
+ print(handle.url)
147
+ handle.stop()
148
+ ```
149
+
150
+ Context manager:
151
+
152
+ ```python
153
+ from dcmview_py import view
154
+
155
+ with view(["./study_dir"], browser=False, block=False) as handle:
156
+ print(handle.url)
157
+ ```
158
+
159
+ The module CLI mirrors the Rust options:
160
+
161
+ ```bash
162
+ python -m dcmview_py --no-browser --timeout 120 ./study_dir
163
+ ```
164
+
165
+ ## Viewer Features
166
+
167
+ The embedded browser viewer includes:
168
+
169
+ - File tabs labeled from `PatientID`, `Modality`, and `StudyDate` when present.
170
+ - Canvas-based image viewing with pan, zoom, scroll, window/level, reset,
171
+ horizontal/vertical flips, and 90-degree rotation.
172
+ - Window presets including DICOM defaults, full dynamic range, and common CT
173
+ presets.
174
+ - Multi-frame controls with previous/next, cine playback, FPS selection, loop,
175
+ and sweep.
176
+ - Lazy DICOM tag browsing with filtering, sequence expansion, binary length
177
+ display, resizable columns, and click-to-copy values.
178
+ - Rectangular ROI annotation display and editing, including draw, select, move,
179
+ resize, delete, frame scoping, and CSV export.
180
+
181
+ Common shortcuts:
182
+
183
+ | Action | Shortcut |
184
+ |---|---|
185
+ | Previous/next frame | Left/Right arrows or `[` / `]` |
186
+ | Play/pause cine | Space |
187
+ | Window/level tool | `W` |
188
+ | Pan tool | `P` |
189
+ | Zoom tool | `Z` |
190
+ | Scroll tool | `S` |
191
+ | ROI tool | `R` |
192
+ | Reset viewport | Double-click |
193
+
194
+ Right-drag always zooms, middle-drag always pans, the wheel scrolls frames, and
195
+ Ctrl/Cmd+wheel zooms.
196
+
197
+ ## Annotations
198
+
199
+ `--annotations` loads an EMBED-style ROI CSV into memory:
200
+
201
+ ```bash
202
+ dcmview --annotations ./embed_annotations.csv ./study_dir
203
+ ```
204
+
205
+ `dcmview` never modifies the input CSV or DICOM files. Viewer edits are kept in
206
+ memory and can be downloaded with **Export ROIs**.
207
+
208
+ Required columns:
209
+
210
+ - `anon_dicom_path`
211
+ - `ROI_coords`
212
+
213
+ Optional columns:
214
+
215
+ - `num_ROI`; when present, it must equal `len(ROI_coords)`
216
+ - `ROI_frames`; when omitted or `[]`, ROIs apply to all frames
217
+
218
+ `ROI_coords` is a JSON array of `[ymin, xmin, ymax, xmax]` boxes. `ROI_frames`
219
+ is a JSON array of frame-index lists. JSON-valued fields must be CSV-quoted.
220
+
221
+ ```csv
222
+ anon_dicom_path,num_ROI,ROI_coords,ROI_frames
223
+ /path/to/dbt_case.dcm,2,"[[120,340,220,430],[400,510,480,590]]","[[0,1,2],[5,6]]"
224
+ /path/to/ffdm_case.dcm,1,"[[80,150,190,260]]","[]"
225
+ ```
226
+
227
+ Matching uses normalized path equality against loaded DICOM paths. Frame indices
228
+ are zero-based and must be less than `NumberOfFrames`.
229
+
230
+ ## CLI Reference
231
+
232
+ ```text
233
+ dcmview [OPTIONS] <PATH> [PATH ...]
234
+ ```
235
+
236
+ | Option | Default | Description |
237
+ |---|---:|---|
238
+ | `<PATH>...` | required | DICOM files or directories to inspect |
239
+ | `-p, --port <u16>` | `0` | Bind port; `0` auto-assigns an available port |
240
+ | `--host <addr>` | `127.0.0.1` | Bind address |
241
+ | `--no-browser` | false | Do not open the browser automatically |
242
+ | `--timeout <seconds>` | none | Exit after this many seconds without API/browser requests |
243
+ | `--no-recursive` | false | Scan only the top level of input directories |
244
+ | `--tunnel` | false | Start an SSH local port-forward helper |
245
+ | `--tunnel-host <host>` | none | SSH target used with `--tunnel` |
246
+ | `--tunnel-port <u16>` | `0` | Forwarded local port; `0` uses the server port |
247
+ | `--annotations <csv>` | none | Load EMBED-style ROI annotations |
248
+
249
+ Examples:
250
+
251
+ ```bash
252
+ dcmview ./scan.dcm
253
+ dcmview --no-recursive ./study_dir
254
+ dcmview --host 127.0.0.1 --port 8888 --no-browser ./study_dir
255
+ dcmview --timeout 300 ./study_dir
256
+ dcmview --annotations ./embed_annotations.csv ./study_dir
257
+ ```
258
+
259
+ ## HTTP API
260
+
261
+ The browser UI uses a small local HTTP API. It is also useful for scripts that
262
+ need the same decoded frame, tag, or annotation data while the server is running.
263
+
264
+ Static frontend assets are served at `/` and `/assets/*`.
265
+
266
+ | Method | Path | Description |
267
+ |---|---|---|
268
+ | GET | `/api/health` | Ready-state probe with file count and server start time |
269
+ | GET | `/api/files` | File registry and server metadata |
270
+ | GET | `/api/file/:index/info` | Frame metadata for one file |
271
+ | GET | `/api/file/:index/frame/:frame` | Display frame; supported image transfer syntaxes return PNG |
272
+ | GET | `/api/file/:index/frame/:frame/raw` | Decoded frame sample bytes for client-side rendering |
273
+ | GET | `/api/file/:index/tags` | Lazy DICOM tag tree |
274
+ | GET | `/api/file/:index/annotations` | Current in-memory ROI annotations |
275
+ | PUT | `/api/file/:index/annotations` | Replace in-memory ROI annotations for one file |
276
+ | GET | `/api/annotations/export.csv` | Download current annotations as EMBED CSV |
277
+
278
+ ### Health and Files
279
+
280
+ `GET /api/health` returns:
281
+
282
+ ```json
283
+ {
284
+ "status": "ok",
285
+ "file_count": 2,
286
+ "server_start_ms": 1714300000000
287
+ }
288
+ ```
289
+
290
+ `GET /api/files` returns:
291
+
292
+ ```json
293
+ {
294
+ "files": [
295
+ {
296
+ "index": 0,
297
+ "path": "/path/to/scan.dcm",
298
+ "label": "PATIENT - MG - 20240101",
299
+ "has_pixels": true,
300
+ "frame_count": 60,
301
+ "rows": 3000,
302
+ "columns": 2500,
303
+ "transfer_syntax_uid": "1.2.840.10008.1.2.4.50",
304
+ "default_window": { "center": 200.0, "width": 4000.0 }
305
+ }
306
+ ],
307
+ "tunnelled": false,
308
+ "tunnel_host": null,
309
+ "server_start_ms": 1714300000000
310
+ }
311
+ ```
312
+
313
+ `GET /api/file/:index/info` returns `frame_count`, `rows`, `columns`,
314
+ `transfer_syntax`, `has_pixels`, and `default_window`.
315
+
316
+ ### Display Frames
317
+
318
+ `GET /api/file/:index/frame/:frame` returns `image/png` for supported display
319
+ paths.
320
+
321
+ Query parameters:
322
+
323
+ | Param | Description |
324
+ |---|---|
325
+ | `wc` | Window center; used with `ww` in default mode |
326
+ | `ww` | Window width; used with `wc` in default mode |
327
+ | `mode` | `default` or `full_dynamic` |
328
+
329
+ Window selection:
330
+
331
+ - `default`: explicit `wc` and `ww`, then DICOM Window Center/Width, then
332
+ 1st/99th percentile fallback.
333
+ - `full_dynamic`: true min/max of the current frame; ignores DICOM defaults and
334
+ query window values.
335
+
336
+ Transfer syntax behavior:
337
+
338
+ | Transfer syntax | Display behavior |
339
+ |---|---|
340
+ | JPEG Baseline / Extended | Decoded server-side and PNG-encoded |
341
+ | JPEG Lossless / Lossless SV1 | Decoded server-side and PNG-encoded |
342
+ | JPEG 2000 lossless/lossy | Decoded server-side and PNG-encoded |
343
+ | Implicit LE / Explicit LE / Explicit BE | Windowed server-side and PNG-encoded |
344
+ | JPEG-LS / RLE / other | `422 {"error": "unsupported transfer syntax: ..."}` |
345
+
346
+ Response headers include `X-Cache: HIT` or `X-Cache: MISS`.
347
+
348
+ ### Raw Frames
349
+
350
+ `GET /api/file/:index/frame/:frame/raw` returns `application/octet-stream` plus
351
+ metadata headers. This is a decoded sample transport for the frontend, not a
352
+ copy of the original DICOM Pixel Data element for compressed syntaxes.
353
+
354
+ Supported raw paths:
355
+
356
+ - Uncompressed frames: native sample bytes, normalized to little-endian by
357
+ `dicom-object`.
358
+ - JPEG Baseline / Extended: decoded to 8-bit grayscale samples.
359
+ - JPEG Lossless: decoded to 8-bit or 16-bit grayscale samples when supported by
360
+ the codec stack.
361
+ - Grayscale JPEG 2000: decoded to 8-bit or 16-bit samples.
362
+
363
+ JPEG-LS, RLE, unsupported syntaxes, and multi-component JP2 raw decoding return
364
+ 422 or a decode error.
365
+
366
+ ### Tags and Annotations
367
+
368
+ `GET /api/file/:index/tags` returns an array of DICOM tag nodes. Pixel data and
369
+ other binary VRs are represented by byte length, not by full values. Long
370
+ numeric arrays and sequences may be truncated with `truncated` and `total`
371
+ fields.
372
+
373
+ `GET /api/file/:index/annotations` returns:
374
+
375
+ ```json
376
+ {
377
+ "num_roi": 2,
378
+ "roi_coords": [[120, 340, 220, 430], [400, 510, 480, 590]],
379
+ "roi_frames": [[0, 1, 2], [5, 6]]
380
+ }
381
+ ```
382
+
383
+ `PUT /api/file/:index/annotations` replaces one file's in-memory annotations and
384
+ returns the canonicalized payload. Invalid coordinates or frame mappings return
385
+ `400 {"error": "..."}`.
386
+
387
+ ## Development
388
+
389
+ Frontend:
390
+
391
+ ```bash
392
+ npm --prefix frontend ci
393
+ dcmview --no-browser --host 127.0.0.1 --port 8888 tests/fixtures
394
+ npm --prefix frontend run dev
395
+ npm --prefix frontend run build
396
+ ```
397
+
398
+ The Vite dev server proxies `/api` to `http://127.0.0.1:8888`, so start a
399
+ backend on that host and port before using the standalone frontend dev server.
400
+
401
+ Backend:
402
+
403
+ ```bash
404
+ cargo fmt --all
405
+ cargo fmt --all -- --check
406
+ cargo check
407
+ cargo build
408
+ cargo build --release
409
+ ```
410
+
411
+ Tests:
412
+
413
+ ```bash
414
+ cargo test
415
+ ```
416
+
417
+ Integration tests use real DICOM fixtures and cover discovery, display-frame
418
+ decoding, raw-frame transport, cache headers, tag serialization, annotations,
419
+ and tunnel fallback.
420
+
421
+ Architecture summary:
422
+
423
+ - Backend: Rust, Axum, Tokio
424
+ - DICOM: `dicom-rs`, `dicom-pixeldata`, `jpeg2k`
425
+ - Pixel pipeline: server-side display PNGs, raw sample transport for
426
+ interactive rendering, LRU caches
427
+ - Frontend: Svelte 5, Vite, TypeScript, embedded via `rust-embed`
428
+ - Distribution: one executable with no runtime frontend assets
429
+
430
+ Backend frame cache budgets are currently 256 MiB for display PNGs and 384 MiB
431
+ for raw sample frames. The frontend also keeps active frame blobs, raw buffers,
432
+ and rendered bitmaps in memory for responsiveness, so cache budget changes
433
+ should consider total browser plus server memory pressure.
434
+
435
+ ## License
436
+
437
+ MIT
@@ -0,0 +1,10 @@
1
+ dcmview_py/__init__.py,sha256=T3u48aRHRGNpbXK1jWKmmP-K14qwaJZbjKWbfzxBld0,138
2
+ dcmview_py/__main__.py,sha256=PEqmFmXg9r_Uf-fX8tNuaE3T6pOdbtH-3SQeXW16Urg,2183
3
+ dcmview_py/wrapper.py,sha256=ckOm_KaL3aRdFv7qUOIHMrVUaFEubsxtLvT6s-aOI9Q,12542
4
+ dcmview_py/bin/dcmview.exe,sha256=6HqAQE95QNPZDyFGYSyXVwoyQlfMfb4HPSbVgga-nhY,12989952
5
+ dcmview_py-0.2.2.dist-info/licenses/LICENSE,sha256=mTvPbRX_DTDxgsWVdcjAAARlsBCdYE-ITOwubz9p8dg,1098
6
+ dcmview_py-0.2.2.dist-info/METADATA,sha256=aymtb6NFIZCVQbwy6VzJ5EMKqZB8yACDApAIT-b1Mzk,13403
7
+ dcmview_py-0.2.2.dist-info/WHEEL,sha256=GjDPPQwEcripVP6P2r3RxLa-h5Lb9ifGB7FYYtbLDT0,98
8
+ dcmview_py-0.2.2.dist-info/entry_points.txt,sha256=Gb1QTe6yv_jHX-GIONDYUo2KAYtgeGcesRffPpKxRYY,91
9
+ dcmview_py-0.2.2.dist-info/top_level.txt,sha256=OBCmc-D0FYpXs3BzBqDGTxteXYJnpTNyr4Ickd7wEuo,11
10
+ dcmview_py-0.2.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: false
4
+ Tag: py3-none-win_amd64
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ dcmview = dcmview_py.__main__:main
3
+ dcmview-py = dcmview_py.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Beatrice Brown-Mulry
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 @@
1
+ dcmview_py