seqeyes-python 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
seqeyes/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """
2
+ SeqEyes — Interactive Pulseq MRI Sequence Viewer
3
+ =================================================
4
+
5
+ Drop‑in replacement for ``pypulseq.Sequence.plot()``.
6
+
7
+ Quick start
8
+ -----------
9
+ >>> import seqeyes
10
+ >>> seqeyes.set(theme="dark") # enable SeqEyes + set defaults
11
+
12
+ >>> seq.plot() # interactive viewer in Jupyter
13
+ >>> seq.plot(show_blocks=True) # per‑call overrides
14
+ >>> seq.plot(time_range=(0, 0.05)) # zoom to first 50 ms
15
+
16
+ >>> seqeyes.reset() # back to matplotlib
17
+
18
+ In a plain ``.py`` script (no Jupyter):
19
+ >>> seq.plot() # opens the viewer in your default web browser
20
+
21
+ API
22
+ ---
23
+ - :func:`set` — enable SeqEyes + configure defaults
24
+ - :func:`reset` — restore matplotlib
25
+ - :class:`SeqEyesViewer` — low‑level viewer
26
+ """
27
+
28
+ from seqeyes._plot import set, reset
29
+ from seqeyes._renderer import SeqEyesViewer
30
+
31
+ __all__ = ["set", "reset", "SeqEyesViewer"]
32
+ __version__ = "0.2.0"
seqeyes/_plot.py ADDED
@@ -0,0 +1,221 @@
1
+ """
2
+ _plot.py — SeqEyes plotting API.
3
+
4
+ Call ``seqeyes.set()`` to switch ``seq.plot()`` to the interactive
5
+ SeqEyes viewer. Call ``seqeyes.reset()`` to restore matplotlib.
6
+
7
+ In Jupyter the viewer renders inline. In a ``.py`` script it opens
8
+ a native desktop pop‑up window (requires ``pywebview``: ``pip install pywebview``).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import tempfile
15
+ from pathlib import Path
16
+ from typing import Any, Optional, Tuple, Union
17
+
18
+
19
+ # ── Module state ──────────────────────────────────────────────────────────
20
+
21
+ _defaults: dict[str, Any] = {}
22
+ _original_plot = None
23
+ _patched = False
24
+
25
+
26
+ # ── Public API ────────────────────────────────────────────────────────────
27
+
28
+ def set(
29
+ *,
30
+ theme: Optional[str] = None,
31
+ show_blocks: Optional[bool] = None,
32
+ time_disp: Optional[str] = None,
33
+ grad_disp: Optional[str] = None,
34
+ time_range: Optional[Tuple[float, float]] = None,
35
+ ) -> None:
36
+ """Enable SeqEyes for all ``seq.plot()`` calls and set global defaults.
37
+
38
+ Call this once at the top of your notebook / script — with or
39
+ without arguments. Any keywords become defaults applied to every
40
+ subsequent ``seq.plot()`` (overridable per‑call).
41
+
42
+ >>> seqeyes.set() # enable, system defaults
43
+ >>> seqeyes.set(theme="dark", time_disp="ms") # enable + defaults
44
+ >>> seq.plot() # uses dark + ms
45
+ >>> seq.plot(theme="light") # overrides theme only
46
+ """
47
+ _store_defaults(theme, show_blocks, time_disp, grad_disp, time_range)
48
+ _ensure_patched()
49
+
50
+
51
+ def reset() -> None:
52
+ """Restore matplotlib ``seq.plot()`` and clear all SeqEyes defaults.
53
+
54
+ Call ``seqeyes.set()`` again to re‑enable at any time.
55
+ """
56
+ global _patched
57
+ _defaults.clear()
58
+ _patched = False
59
+
60
+ try:
61
+ from pypulseq import Sequence as _Seq
62
+ except ImportError:
63
+ return
64
+
65
+ if _original_plot is not None:
66
+ _Seq.plot = _original_plot # type: ignore[attr-defined]
67
+
68
+
69
+ # ── Internal helpers ──────────────────────────────────────────────────────
70
+
71
+ def _store_defaults(
72
+ theme: Optional[str],
73
+ show_blocks: Optional[bool],
74
+ time_disp: Optional[str],
75
+ grad_disp: Optional[str],
76
+ time_range: Optional[Tuple[float, float]],
77
+ ) -> None:
78
+ for name, val in [
79
+ ("theme", theme),
80
+ ("show_blocks", show_blocks),
81
+ ("time_disp", time_disp),
82
+ ("grad_disp", grad_disp),
83
+ ("time_range", time_range),
84
+ ]:
85
+ if val is not None:
86
+ _defaults[name] = val
87
+
88
+
89
+ def _ensure_patched() -> None:
90
+ global _patched, _original_plot
91
+
92
+ if _patched:
93
+ return
94
+ _patched = True
95
+
96
+ try:
97
+ from pypulseq import Sequence as _Seq
98
+ except ImportError:
99
+ raise ImportError("pypulseq is not installed. Install with: pip install pypulseq")
100
+
101
+ if _original_plot is None:
102
+ _original_plot = getattr(_Seq, "plot", None)
103
+
104
+ # ── Replacement .plot() ──────────────────────────────────────────
105
+ def _seqeyes_plot(
106
+ self: object,
107
+ label: str = "",
108
+ show_blocks: bool = False,
109
+ time_range: Any = (0, float("inf")),
110
+ time_disp: str = "s",
111
+ grad_disp: str = "kHz/m",
112
+ **kwargs: Any,
113
+ ) -> None:
114
+ # Merge globals — per‑call args take precedence
115
+ sb = _defaults.get("show_blocks", show_blocks)
116
+ td = _defaults.get("time_disp", time_disp)
117
+ gd = _defaults.get("grad_disp", grad_disp)
118
+ tr = _defaults.get("time_range", time_range)
119
+ th = str(kwargs.pop("theme", _defaults.get("theme", "system")))
120
+
121
+ seq_text = _seq_to_text(self)
122
+ if not seq_text:
123
+ raise RuntimeError("Could not write .seq text from the pypulseq Sequence.")
124
+
125
+ from seqeyes._renderer import SeqEyesViewer
126
+
127
+ viewer = SeqEyesViewer(
128
+ seq_text, label=label, show_blocks=sb,
129
+ time_range=tr, time_disp=td, grad_disp=gd, theme=th,
130
+ )
131
+
132
+ if _in_jupyter():
133
+ from IPython.display import display
134
+ display(viewer)
135
+ else:
136
+ _open_in_browser(viewer)
137
+
138
+ # Preserve original as _plot_matplotlib
139
+ if _original_plot is not None and not hasattr(_Seq, "_plot_matplotlib"):
140
+ _Seq._plot_matplotlib = _original_plot # type: ignore[attr-defined]
141
+
142
+ _Seq.plot = _seqeyes_plot # type: ignore[attr-defined]
143
+
144
+ # _repr_html_ so bare ``seq`` auto‑renders in Jupyter
145
+ def _seq_repr_html_(self: object) -> str:
146
+ from seqeyes._renderer import SeqEyesViewer
147
+ return SeqEyesViewer(_seq_to_text(self))._repr_html_()
148
+
149
+ _Seq._repr_html_ = _seq_repr_html_ # type: ignore[attr-defined]
150
+
151
+
152
+ def _in_jupyter() -> bool:
153
+ try:
154
+ from IPython import get_ipython
155
+ s = get_ipython()
156
+ return s is not None and ("ZMQ" in s.__class__.__name__ or "Shell" in s.__class__.__name__)
157
+ except ImportError:
158
+ return False
159
+
160
+
161
+ def _open_in_browser(viewer: "SeqEyesViewer") -> None: # noqa: F821
162
+ """Open the viewer in a native desktop pop‑up window (pywebview).
163
+
164
+ Spawns a subprocess so ``webview.start()`` runs on its own main
165
+ thread without blocking the calling script. Falls back to the
166
+ system browser if pywebview is not installed.
167
+ """
168
+ html = viewer.to_html()
169
+
170
+ # Write HTML to a temp file — file:// URLs are more reliable with
171
+ # pywebview than inline html= on some backends.
172
+ p = os.path.join(tempfile.gettempdir(), "seqeyes_viewer.html")
173
+ Path(p).write_text(html, encoding="utf-8")
174
+ url = f"file:///{p.replace(os.sep, '/')}"
175
+
176
+ try:
177
+ import webview # type: ignore[import-untyped]
178
+ except ImportError:
179
+ _fallback_browser(url)
180
+ return
181
+
182
+ # Run webview in a subprocess so it gets its own main thread.
183
+ # We shell out to a tiny inline script — this avoids multiprocessing
184
+ # "spawn" issues (re‑importing the parent script on Windows).
185
+ import subprocess
186
+ import sys
187
+
188
+ script = (
189
+ "import webview;"
190
+ f"webview.create_window(title='SeqEyes — Pulseq MRI Sequence Viewer',url={url!r},width=1280,height=800,resizable=True,easy_drag=False);"
191
+ "webview.start()"
192
+ )
193
+ try:
194
+ subprocess.Popen(
195
+ [sys.executable, "-c", script],
196
+ stdout=subprocess.DEVNULL,
197
+ stderr=subprocess.DEVNULL,
198
+ )
199
+ except Exception:
200
+ _fallback_browser(url)
201
+
202
+
203
+ def _fallback_browser(url: str) -> None:
204
+ """Last resort: open the viewer in the system web browser."""
205
+ import webbrowser
206
+ webbrowser.open(url)
207
+
208
+
209
+ def _seq_to_text(seq: object) -> str:
210
+ if hasattr(seq, "write"):
211
+ with tempfile.NamedTemporaryFile(mode="r", suffix=".seq", delete=False, encoding="utf-8") as f:
212
+ tp = f.name
213
+ try:
214
+ seq.write(tp) # type: ignore[union-attr]
215
+ return Path(tp).read_text(encoding="utf-8")
216
+ finally:
217
+ try:
218
+ os.unlink(tp)
219
+ except OSError:
220
+ pass
221
+ return ""
seqeyes/_renderer.py ADDED
@@ -0,0 +1,236 @@
1
+ """
2
+ _renderer.py — Jupyter HTML renderer for the SeqEyes interactive viewer.
3
+
4
+ Generates a self‑contained HTML string that Jupyter displays inline via
5
+ ``_repr_html_()``, exactly the same mechanism Plotly uses. The viewer is
6
+ an iframe embedding the SeqEyes web UI with sequence data injected.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+
18
+ # ── Paths ─────────────────────────────────────────────────────────────────
19
+ _RESOURCES_DIR = Path(__file__).resolve().parent / "resources"
20
+ _VIEWER_TEMPLATE_PATH = _RESOURCES_DIR / "viewer.html"
21
+
22
+ # pulseq-bundle.js lives in the repo root; during dev we read it directly.
23
+ # When installed as a package, a copy should be placed in resources/.
24
+ _BUNDLE_CANDIDATES = [
25
+ _RESOURCES_DIR / "pulseq-bundle.js",
26
+ Path(__file__).resolve().parent.parent.parent.parent / "web" / "pulseq-bundle.js",
27
+ Path.cwd() / "web" / "pulseq-bundle.js",
28
+ ]
29
+
30
+
31
+ def _find_bundle() -> str:
32
+ """Locate and read the pulseq-bundle.js file."""
33
+ for p in _BUNDLE_CANDIDATES:
34
+ if p.is_file():
35
+ return p.read_text(encoding="utf-8")
36
+ raise FileNotFoundError(
37
+ "pulseq-bundle.js not found. "
38
+ "Build it with: npm run build:web (from the repo root), "
39
+ "or copy it to python/src/seqeyes/resources/"
40
+ )
41
+
42
+
43
+ def _read_viewer_template() -> str:
44
+ """Read the standalone viewer HTML template."""
45
+ if _VIEWER_TEMPLATE_PATH.is_file():
46
+ return _VIEWER_TEMPLATE_PATH.read_text(encoding="utf-8")
47
+ alt = Path.cwd() / "python" / "src" / "seqeyes" / "resources" / "viewer.html"
48
+ if alt.is_file():
49
+ return alt.read_text(encoding="utf-8")
50
+ raise FileNotFoundError(
51
+ f"Viewer template not found at {_VIEWER_TEMPLATE_PATH}. "
52
+ "Ensure the package is installed correctly."
53
+ )
54
+
55
+
56
+ def _build_html(
57
+ seq_text: str,
58
+ *,
59
+ theme: Optional[str] = None,
60
+ inject_bundle: bool = True,
61
+ label: str = "",
62
+ show_blocks: bool = False,
63
+ time_range: tuple = (0, float("inf")),
64
+ time_disp: str = "s",
65
+ grad_disp: str = "kHz/m",
66
+ ) -> str:
67
+ """Assemble the complete viewer HTML.
68
+
69
+ Parameters
70
+ ----------
71
+ seq_text : str
72
+ Raw .seq file content.
73
+ theme : str or None
74
+ CSS theme class to apply to ``<body>``.
75
+ inject_bundle : bool
76
+ If True, inline the pulseq-bundle.js parser into the HTML.
77
+ label : str
78
+ Display label (injected as JS variable, not yet rendered).
79
+ show_blocks : bool
80
+ Initial block‑boundary visibility.
81
+ time_range : tuple[float, float]
82
+ Initial viewport range in seconds.
83
+ time_disp : str
84
+ Initial time display unit (``"s"``, ``"ms"``, ``"us"``).
85
+ grad_disp : str
86
+ Initial gradient display unit (``"Hz/m"``, ``"kHz/m"``,
87
+ ``"mT/m"``, ``"G/cm"``).
88
+ """
89
+ template = _read_viewer_template()
90
+ seq_b64 = base64.b64encode(seq_text.encode("utf-8")).decode("ascii")
91
+
92
+ # 1. Inject the pulseq-bundle.js
93
+ bundle_placeholder = "<!-- PULSEQ_BUNDLE_PLACEHOLDER -->"
94
+ if inject_bundle and bundle_placeholder in template:
95
+ bundle_js = _find_bundle()
96
+ template = template.replace(
97
+ bundle_placeholder,
98
+ f"<script>\n{bundle_js}\n</script>",
99
+ )
100
+ elif bundle_placeholder in template:
101
+ template = template.replace(bundle_placeholder, "")
102
+
103
+ # 2. Build options injection block (sequence data + display opts)
104
+ opts = [
105
+ f"window.SEQEYES_RAW_B64 = {json.dumps(seq_b64)};",
106
+ f"window.SEQEYES_LABEL = {json.dumps(label)};",
107
+ f"window.SEQEYES_SHOW_BLOCKS = {json.dumps(show_blocks)};",
108
+ f"window.SEQEYES_TIME_RANGE = {json.dumps(list(time_range))};",
109
+ f"window.SEQEYES_TIME_DISP = {json.dumps(time_disp)};",
110
+ f"window.SEQEYES_GRAD_DISP = {json.dumps(grad_disp)};",
111
+ ]
112
+ opts_block = "\n".join(opts)
113
+
114
+ data_placeholder = "/* SEQEYES_DATA_PLACEHOLDER */"
115
+ if data_placeholder in template:
116
+ template = template.replace(data_placeholder, opts_block)
117
+ else:
118
+ template = template.replace(
119
+ "</body>",
120
+ f"<script>{opts_block}</script>\n</body>",
121
+ )
122
+
123
+ # 3. Apply theme if specified
124
+ if theme:
125
+ template = template.replace(
126
+ "<body>",
127
+ f'<body class="theme-{theme}">',
128
+ )
129
+
130
+ return template
131
+
132
+
133
+ class SeqEyesViewer:
134
+ """Interactive Pulseq sequence viewer for Jupyter notebook output.
135
+
136
+ Normally you don't create this directly — call ``seqeyes.set()``
137
+ once, then use ``seq.plot(...)`` on any ``pypulseq.Sequence``.
138
+
139
+ Parameters
140
+ ----------
141
+ seq_text : str
142
+ Raw .seq file content as a string.
143
+ label : str
144
+ Display label (not yet rendered on the viewer).
145
+ show_blocks : bool
146
+ Whether to show block‑boundary lines initially.
147
+ time_range : tuple[float, float]
148
+ Initial time viewport ``(start_sec, end_sec)``. ``(0, inf)``
149
+ shows the whole sequence.
150
+ time_disp : str
151
+ Time axis unit — ``"s"``, ``"ms"``, or ``"us"``.
152
+ grad_disp : str
153
+ Gradient axis unit — ``"Hz/m"``, ``"kHz/m"``, ``"mT/m"``, ``"G/cm"``.
154
+ theme : str or None
155
+ Colour theme.
156
+ width : str
157
+ CSS width of the iframe (e.g. ``"100%"``).
158
+ height : str
159
+ CSS height of the iframe (e.g. ``"550px"``).
160
+ """
161
+
162
+ def __init__(
163
+ self,
164
+ seq_text: str,
165
+ *,
166
+ label: str = "",
167
+ show_blocks: bool = False,
168
+ time_range: tuple = (0, float("inf")),
169
+ time_disp: str = "s",
170
+ grad_disp: str = "kHz/m",
171
+ theme: Optional[str] = None,
172
+ width: str = "100%",
173
+ height: str = "550px",
174
+ ) -> None:
175
+ self._seq_text = seq_text
176
+ self._label = label
177
+ self._show_blocks = show_blocks
178
+ self._time_range = time_range
179
+ self._time_disp = time_disp
180
+ self._grad_disp = grad_disp
181
+ self._theme = theme
182
+ self._width = width
183
+ self._height = height
184
+
185
+ # ── Jupyter integration ───────────────────────────────────────────
186
+
187
+ def _repr_html_(self) -> str:
188
+ """Return an HTML iframe that Jupyter renders inline."""
189
+ html = _build_html(
190
+ self._seq_text,
191
+ theme=self._theme,
192
+ label=self._label,
193
+ show_blocks=self._show_blocks,
194
+ time_range=self._time_range,
195
+ time_disp=self._time_disp,
196
+ grad_disp=self._grad_disp,
197
+ )
198
+ b64 = base64.b64encode(html.encode("utf-8")).decode("ascii")
199
+ return (
200
+ f'<iframe src="data:text/html;base64,{b64}" '
201
+ f'width="{self._width}" height="{self._height}" '
202
+ f'frameborder="0" '
203
+ f'style="border:none;max-width:100%;overflow:hidden" '
204
+ f'title="SeqEyes Viewer">'
205
+ f"</iframe>"
206
+ )
207
+
208
+ def _ipython_display_(self) -> None:
209
+ """IPython display hook."""
210
+ from IPython.display import HTML, display
211
+
212
+ display(HTML(self._repr_html_()))
213
+
214
+ # ── Convenience ────────────────────────────────────────────────────
215
+
216
+ def show(self) -> "SeqEyesViewer":
217
+ """Display the viewer (useful in IPython when auto‑display is off)."""
218
+ from IPython.display import display
219
+
220
+ display(self)
221
+ return self
222
+
223
+ # ── Direct HTML access (for debugging / custom embedding) ──────────
224
+
225
+ def to_html(self, *, inject_bundle: bool = True) -> str:
226
+ """Return the full standalone HTML string (for saving to a file)."""
227
+ return _build_html(
228
+ self._seq_text,
229
+ theme=self._theme,
230
+ inject_bundle=inject_bundle,
231
+ label=self._label,
232
+ show_blocks=self._show_blocks,
233
+ time_range=self._time_range,
234
+ time_disp=self._time_disp,
235
+ grad_disp=self._grad_disp,
236
+ )