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 +5 -0
- dcmview_py/__main__.py +76 -0
- dcmview_py/bin/dcmview.exe +0 -0
- dcmview_py/wrapper.py +474 -0
- dcmview_py-0.2.2.dist-info/METADATA +437 -0
- dcmview_py-0.2.2.dist-info/RECORD +10 -0
- dcmview_py-0.2.2.dist-info/WHEEL +5 -0
- dcmview_py-0.2.2.dist-info/entry_points.txt +3 -0
- dcmview_py-0.2.2.dist-info/licenses/LICENSE +21 -0
- dcmview_py-0.2.2.dist-info/top_level.txt +1 -0
dcmview_py/__init__.py
ADDED
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,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
|