diff --git a/.github/workflows/build_docs.yml b/.github/workflows/build_docs.yml index fa7c2e1e6..e60cc5c9a 100644 --- a/.github/workflows/build_docs.yml +++ b/.github/workflows/build_docs.yml @@ -32,12 +32,12 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y inkscape --no-install-recommends + sudo apt-get install -y inkscape pandoc --no-install-recommends pip install --upgrade pip pip install wheel setuptools numpy cython # force using latest nibabel pip install -U nibabel - pip install -q ipython Sphinx sphinx-gallery numpydoc # TODO: move to pyproject.toml + pip install -q ipython Sphinx sphinx-gallery numpydoc nbsphinx ipykernel # TODO: move to pyproject.toml pip install -e . --no-build-isolation --group dev python -c 'import cortex; print(cortex.__full_version__)' diff --git a/cortex/tests/test_jupyter_widget.py b/cortex/tests/test_jupyter_widget.py new file mode 100644 index 000000000..3b3ec8db2 --- /dev/null +++ b/cortex/tests/test_jupyter_widget.py @@ -0,0 +1,316 @@ +"""Tests for the Jupyter WebGL widget integration.""" + +import os +import re +import unittest +from unittest.mock import MagicMock, patch + + +class TestJupyterImports(unittest.TestCase): + """Test that the jupyter module imports correctly.""" + + def test_import_module(self): + from cortex.webgl import jupyter + + self.assertTrue(hasattr(jupyter, "display")) + self.assertTrue(hasattr(jupyter, "display_iframe")) + self.assertTrue(hasattr(jupyter, "display_static")) + self.assertTrue(hasattr(jupyter, "make_notebook_html")) + self.assertTrue(hasattr(jupyter, "StaticViewer")) + self.assertTrue(hasattr(jupyter, "close_all")) + + def test_import_from_webgl(self): + import cortex + + self.assertTrue(hasattr(cortex.webgl, "jupyter")) + + +class TestFindFreePort(unittest.TestCase): + """Test the _find_free_port helper.""" + + def test_returns_valid_port(self): + from cortex.webgl.jupyter import _find_free_port + + port = _find_free_port() + self.assertIsInstance(port, int) + self.assertGreaterEqual(port, 1024) + self.assertLessEqual(port, 65535) + + def test_returns_different_ports(self): + from cortex.webgl.jupyter import _find_free_port + + ports = {_find_free_port() for _ in range(10)} + self.assertGreater(len(ports), 1) + + +class TestDisplay(unittest.TestCase): + """Test the display() dispatch function.""" + + def test_invalid_method(self): + from cortex.webgl.jupyter import display + + with self.assertRaises(ValueError): + display(None, method="invalid") + + @patch("cortex.webgl.jupyter.display_iframe") + def test_dispatch_iframe(self, mock_iframe): + from cortex.webgl.jupyter import display + + display("fake_data", method="iframe") + mock_iframe.assert_called_once() + + @patch("cortex.webgl.jupyter.display_static") + def test_dispatch_static(self, mock_static): + from cortex.webgl.jupyter import display + + display("fake_data", method="static") + mock_static.assert_called_once() + + @patch("cortex.webgl.jupyter.display_iframe") + def test_forwards_kwargs(self, mock_iframe): + from cortex.webgl.jupyter import display + + display("fake_data", method="iframe", width=800, height=400, port=5555) + mock_iframe.assert_called_once_with( + "fake_data", width=800, height=400, port=5555 + ) + + +class TestDisplayIframe(unittest.TestCase): + """Test the IFrame-based display method.""" + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.show") + def test_calls_show_with_correct_params(self, mock_show, mock_display): + from cortex.webgl.jupyter import display_iframe + + mock_show.return_value = MagicMock() + result = display_iframe("fake_data", port=9999) + + mock_show.assert_called_once() + call_kwargs = mock_show.call_args.kwargs + self.assertFalse(call_kwargs.get("open_browser", True)) + self.assertFalse(call_kwargs.get("autoclose", True)) + self.assertEqual(call_kwargs["port"], 9999) + self.assertEqual(result, mock_show.return_value) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.show") + def test_iframe_url_contains_port(self, mock_show, mock_display): + from cortex.webgl.jupyter import display_iframe + from IPython.display import IFrame + + mock_show.return_value = MagicMock() + display_iframe("fake_data", port=8888) + + mock_display.assert_called_once() + iframe_arg = mock_display.call_args[0][0] + self.assertIsInstance(iframe_arg, IFrame) + self.assertIn("8888", iframe_arg.src) + self.assertIn("mixer.html", iframe_arg.src) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.show") + def test_auto_port_is_valid(self, mock_show, mock_display): + from cortex.webgl.jupyter import display_iframe + + mock_show.return_value = MagicMock() + display_iframe("fake_data") + + port = mock_show.call_args.kwargs["port"] + self.assertGreaterEqual(port, 1024) + self.assertLessEqual(port, 65535) + + +class TestDisplayStatic(unittest.TestCase): + """Test the static HTML display method.""" + + def _fake_make_static(self, outpath, data, **kwargs): + os.makedirs(outpath, exist_ok=True) + with open(os.path.join(outpath, "index.html"), "w") as f: + f.write("test") + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_returns_static_viewer(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import display_static, StaticViewer + + mock_make_static.side_effect = self._fake_make_static + result = display_static("fake_data", height=500) + + mock_make_static.assert_called_once() + self.assertTrue(mock_make_static.call_args.kwargs.get("html_embed", False)) + mock_display.assert_called_once() + self.assertIsInstance(result, StaticViewer) + self.assertTrue(hasattr(result, "close")) + self.assertTrue(hasattr(result, "iframe")) + result.close() + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_close_cleans_tmpdir(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + result = display_static("fake_data") + + self.assertTrue(os.path.isdir(result._tmpdir)) + result.close() + self.assertFalse(os.path.isdir(result._tmpdir)) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_make_static_failure_raises_runtime_error( + self, mock_make_static, mock_display + ): + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = Exception("make_static failed") + + with self.assertRaises(RuntimeError) as ctx: + display_static("fake_data") + self.assertIn("Failed to generate static viewer", str(ctx.exception)) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_uses_os_assigned_port(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + result = display_static("fake_data") + + iframe_arg = mock_display.call_args[0][0] + match = re.search(r":(\d+)/", iframe_arg.src) + self.assertIsNotNone(match) + port = int(match.group(1)) + self.assertGreaterEqual(port, 1024) + self.assertLessEqual(port, 65535) + result.close() + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_width_int_converted(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import display_static + + mock_make_static.side_effect = self._fake_make_static + result = display_static("fake_data", width=800) + + mock_display.assert_called_once() + iframe_arg = mock_display.call_args[0][0] + self.assertEqual("800px", iframe_arg.width) + result.close() + + +class TestViewerRegistry(unittest.TestCase): + """Test the active viewer registry and close_all.""" + + def _fake_make_static(self, outpath, data, **kwargs): + os.makedirs(outpath, exist_ok=True) + with open(os.path.join(outpath, "index.html"), "w") as f: + f.write("test") + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_viewer_registered_on_create(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import _active_viewers, display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + + self.assertIn(viewer, _active_viewers) + viewer.close() + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_close_all_cleans_up(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import close_all, display_static + + mock_make_static.side_effect = self._fake_make_static + v1 = display_static("fake_data") + v2 = display_static("fake_data") + + self.assertTrue(os.path.isdir(v1._tmpdir)) + self.assertTrue(os.path.isdir(v2._tmpdir)) + + close_all() + + self.assertFalse(os.path.isdir(v1._tmpdir)) + self.assertFalse(os.path.isdir(v2._tmpdir)) + + @patch("cortex.webgl.jupyter.ipydisplay") + @patch("cortex.webgl.view.make_static") + def test_close_all_idempotent(self, mock_make_static, mock_display): + from cortex.webgl.jupyter import close_all, display_static + + mock_make_static.side_effect = self._fake_make_static + viewer = display_static("fake_data") + viewer.close() + # Should not raise even though viewer is already closed + close_all() + + +class TestMakeNotebookHtml(unittest.TestCase): + """Test the raw HTML generation function.""" + + @patch("cortex.webgl.view.make_static") + def test_returns_html_string(self, mock_make_static): + from cortex.webgl.jupyter import make_notebook_html + + def fake_make_static(outpath, data, **kwargs): + os.makedirs(outpath, exist_ok=True) + with open(os.path.join(outpath, "index.html"), "w") as f: + f.write("viewer") + + mock_make_static.side_effect = fake_make_static + + html = make_notebook_html("fake_data") + self.assertIn("", html) + self.assertIn("viewer", html) + + @patch("cortex.webgl.view.make_static") + def test_cleans_up_temp_dir(self, mock_make_static): + from cortex.webgl.jupyter import make_notebook_html + + created_dirs = [] + + def fake_make_static(outpath, data, **kwargs): + created_dirs.append(os.path.dirname(outpath)) + os.makedirs(outpath, exist_ok=True) + with open(os.path.join(outpath, "index.html"), "w") as f: + f.write("test") + + mock_make_static.side_effect = fake_make_static + + make_notebook_html("fake_data") + + self.assertEqual(len(created_dirs), 1) + self.assertFalse(os.path.isdir(created_dirs[0])) + + +class TestNotebookTemplate(unittest.TestCase): + """Test that the notebook HTML template exists and is valid.""" + + def test_template_exists(self): + import cortex.webgl + + template_dir = os.path.dirname(cortex.webgl.__file__) + template_path = os.path.join(template_dir, "notebook.html") + self.assertTrue( + os.path.exists(template_path), + "notebook.html template not found at %s" % template_path, + ) + + def test_template_extends_base(self): + import cortex.webgl + + template_dir = os.path.dirname(cortex.webgl.__file__) + template_path = os.path.join(template_dir, "notebook.html") + with open(template_path) as f: + content = f.read() + self.assertIn("extends template.html", content) + self.assertIn("block onload", content) + self.assertIn("block jsinit", content) + + +if __name__ == "__main__": + unittest.main() diff --git a/cortex/webgl/__init__.py b/cortex/webgl/__init__.py index b31971b5e..94f612370 100644 --- a/cortex/webgl/__init__.py +++ b/cortex/webgl/__init__.py @@ -1,5 +1,5 @@ -"""Makes an interactive viewer for viewing data in a browser -""" +"""Makes an interactive viewer for viewing data in a browser""" + from typing import TYPE_CHECKING from ..utils import DocLoader @@ -13,3 +13,10 @@ show = DocLoader("show", ".view", "cortex.webgl", actual_func=_show) make_static = DocLoader("make_static", ".view", "cortex.webgl", actual_func=_static) + +try: + import IPython # noqa: F401 +except ImportError: + pass +else: + from . import jupyter diff --git a/cortex/webgl/jupyter.py b/cortex/webgl/jupyter.py new file mode 100644 index 000000000..1a66530a6 --- /dev/null +++ b/cortex/webgl/jupyter.py @@ -0,0 +1,350 @@ +"""Jupyter notebook integration for pycortex WebGL viewer. + +Provides two approaches for displaying brain surfaces in Jupyter notebooks: + +1. **IFrame-based** (``display_iframe``): Starts a Tornado server and embeds + the viewer in an IFrame. Full interactivity with WebSocket support. + +2. **Static viewer** (``display_static``): Generates a static viewer directory + served via a local HTTP server and embedded in an IFrame. Requires a live + Jupyter environment (will not work in static notebook renderers). + +Usage +----- +>>> import cortex +>>> vol = cortex.Volume.random("S1", "fullhead") +>>> cortex.webgl.jupyter.display(vol) # defaults to iframe method +""" + +import atexit +import http.server +import logging +import os +import shutil +import socket +import tempfile +import threading +import weakref + +from IPython.display import IFrame +from IPython.display import display as ipydisplay + +logger = logging.getLogger(__name__) + +# Registry of active StaticViewer instances for cleanup. +# Uses weak references so viewers that are garbage-collected don't linger here. +_active_viewers = weakref.WeakSet() +_viewer_lock = threading.Lock() + + +def close_all(): + """Close all active static viewers, shutting down servers and removing temp files.""" + with _viewer_lock: + viewers = list(_active_viewers) + closed = 0 + for viewer in viewers: + try: + viewer.close() + closed += 1 + except Exception: + logger.warning("Failed to close viewer during close_all", exc_info=True) + if closed: + logger.info("Closed %d static viewer(s)", closed) + + +atexit.register(close_all) + + +def _find_free_port(): + """Find a free TCP port by binding to port 0 and reading the OS-assigned port. + + Note: There is an inherent TOCTOU race between releasing this socket and + the caller binding the port. For ``display_static`` this is avoided by + binding ``HTTPServer`` to port 0 directly. For ``display_iframe`` the + underlying Tornado server does not support port 0, so this helper is used + as a best-effort fallback. + """ + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("", 0)) + return s.getsockname()[1] + + +class StaticViewer: + """Handle for a static viewer served via a local HTTP server. + + Call ``close()`` to shut down the server and clean up temp files. + + Attributes + ---------- + iframe : IPython.display.IFrame + The IFrame used to display the viewer. + """ + + def __init__(self, iframe, httpd, thread, tmpdir): + self.iframe = iframe + self._httpd = httpd + self._thread = thread + self._tmpdir = tmpdir + self._closed = False + self._lock = threading.Lock() + with _viewer_lock: + _active_viewers.add(self) + + def close(self, timeout=1.0): + """Shut down the HTTP server, wait for the thread, and remove temp files. + + Parameters + ---------- + timeout : float, optional + Maximum seconds to wait for the server thread to finish. + """ + with self._lock: + if self._closed: + return + self._closed = True + + if self._httpd is not None: + try: + self._httpd.shutdown() + self._httpd.server_close() + except Exception: + logger.warning( + "Failed to shut down static viewer server", exc_info=True + ) + + if self._thread is not None and self._thread.is_alive(): + try: + self._thread.join(timeout=timeout) + except Exception: + logger.warning( + "Failed to join static viewer server thread", exc_info=True + ) + + if self._tmpdir is not None: + try: + shutil.rmtree(self._tmpdir, ignore_errors=True) + except Exception: + logger.warning( + "Failed to clean up temp dir %s", self._tmpdir, exc_info=True + ) + + def __del__(self): + try: + self.close(timeout=0.1) + except Exception: + pass + + def _repr_html_(self): + """Allow Jupyter to display this object directly.""" + return self.iframe._repr_html_() + + +def display(data, method="iframe", width="100%", height=600, **kwargs): + """Display brain data in a Jupyter notebook using the WebGL viewer. + + Parameters + ---------- + data : Dataset, Volume, Vertex, or dict + Brain data to display. + method : str, optional + Display method: "iframe" for server-based (interactive, default), + "static" for self-contained HTML (works in nbviewer). + width : str or int, optional + Widget width. Default "100%". + height : int, optional + Widget height in pixels. Default 600. + **kwargs + Additional keyword arguments passed to ``show()`` or ``make_static()``. + + Returns + ------- + For "iframe": the server object (WebApp) + For "static": a StaticViewer handle (call ``.close()`` to clean up) + """ + if method == "iframe": + return display_iframe(data, width=width, height=height, **kwargs) + elif method == "static": + return display_static(data, width=width, height=height, **kwargs) + else: + raise ValueError("method must be 'iframe' or 'static', got %r" % method) + + +def display_iframe(data, width="100%", height=600, port=None, **kwargs): + """Display brain data via an embedded IFrame connected to a Tornado server. + + Starts the pycortex Tornado server and embeds it in an IFrame within the + notebook. Provides full interactivity including surface morphing, data + switching, and WebSocket-based Python control. + + Parameters + ---------- + data : Dataset, Volume, Vertex, or dict + Brain data to display. + width : str or int, optional + IFrame width. Default "100%". + height : int, optional + IFrame height in pixels. Default 600. + port : int or None, optional + Port for the Tornado server. If None, a free port is chosen + automatically. + **kwargs + Additional keyword arguments passed to ``cortex.webgl.show()``. + + Returns + ------- + server : WebApp + The Tornado server object. Can be used to get a JSMixer client for + programmatic control. + """ + from . import view, serve + + if port is None: + port = _find_free_port() + + # Start the server without opening a browser + kwargs["open_browser"] = False + kwargs["autoclose"] = False + server = view.show(data, port=port, **kwargs) + + url = "http://%s:%d/mixer.html" % (serve.hostname, port) + + # Format width for IFrame + if isinstance(width, int): + width = "%dpx" % width + + ipydisplay(IFrame(src=url, width=width, height=height)) + + return server + + +def display_static(data, width="100%", height=600, **kwargs): + """Display brain data using a temporary static WebGL viewer inline. + + Uses ``cortex.webgl.make_static`` to generate a *directory* containing + ``index.html`` plus all required JS/CSS/data assets, then serves that + directory via a lightweight local HTTP server and embeds it in the + notebook inside an IFrame. + + Note + ---- + The output is **not** a single self-contained HTML string; it is a static + viewer directory that must be served for the page to function. This works + in live Jupyter environments but most static notebook renderers will not + display the interactive viewer. + + The bind host defaults to ``127.0.0.1``. For remote notebook setups + (JupyterHub, SSH tunnels), set the ``CORTEX_JUPYTER_STATIC_HOST`` + environment variable to the appropriate hostname. + + Parameters + ---------- + data : Dataset, Volume, Vertex, or dict + Brain data to display. + width : str or int, optional + Viewer width. Default "100%". + height : int, optional + Viewer height in pixels. Default 600. + **kwargs + Additional keyword arguments passed to ``cortex.webgl.make_static()``. + + Returns + ------- + viewer : StaticViewer + Handle for the static viewer. Call ``viewer.close()`` to shut down the + HTTP server and clean up temporary files. + """ + from . import view + + tmpdir = tempfile.mkdtemp(prefix="pycortex_jupyter_") + outpath = os.path.join(tmpdir, "viewer") + + try: + view.make_static(outpath, data, html_embed=True, **kwargs) + except Exception as e: + shutil.rmtree(tmpdir, ignore_errors=True) + raise RuntimeError( + "Failed to generate static viewer. " + "Check that data is valid and cortex is properly configured." + ) from e + + index_html = os.path.join(outpath, "index.html") + if not os.path.isfile(index_html): + shutil.rmtree(tmpdir, ignore_errors=True) + raise FileNotFoundError( + "make_static() did not produce index.html. " + "This may indicate a problem with the static template." + ) + + # Format width + if isinstance(width, int): + width_str = "%dpx" % width + else: + width_str = width + + class _QuietHandler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **handler_kwargs): + super().__init__(*args, directory=outpath, **handler_kwargs) + + def log_message(self, format, *args): + # Log HTTP errors, suppress routine access logs + if args and len(args) >= 2: + try: + status = int(args[1]) + if status >= 400: + logger.warning("Static viewer HTTP %s: %s", args[1], args[0]) + except (ValueError, IndexError): + pass + + host = os.environ.get("CORTEX_JUPYTER_STATIC_HOST", "127.0.0.1") + + httpd = http.server.HTTPServer((host, 0), _QuietHandler) + port = httpd.server_address[1] + + thread = threading.Thread(target=httpd.serve_forever, daemon=True) + thread.start() + + iframe = IFrame( + src="http://%s:%d/index.html" % (host, port), width=width_str, height=height + ) + ipydisplay(iframe) + + return StaticViewer(iframe, httpd, thread, tmpdir) + + +def make_notebook_html(data, template="static.html", types=("inflated",), **kwargs): + """Generate the ``index.html`` for a static WebGL viewer. + + This is a lower-level function that returns the raw HTML string produced + by ``make_static()``. Note that the HTML references external asset files + (CTM meshes, JSON data, PNG colormaps) that ``make_static()`` writes + alongside ``index.html``. The returned string alone is **not** a fully + self-contained viewer -- it must be served from a directory containing + those assets for the viewer to function. + + Parameters + ---------- + data : Dataset, Volume, Vertex, or dict + Brain data to display. + template : str, optional + HTML template name. Default "static.html". + types : tuple, optional + Surface types to include. Default ("inflated",). + **kwargs + Additional keyword arguments passed to ``make_static()``. + + Returns + ------- + html : str + The generated HTML string (requires adjacent assets to function). + """ + from . import view + + with tempfile.TemporaryDirectory(prefix="pycortex_nb_") as tmpdir: + outpath = os.path.join(tmpdir, "viewer") + view.make_static( + outpath, data, template=template, types=types, html_embed=True, **kwargs + ) + + index_html = os.path.join(outpath, "index.html") + with open(index_html, "r") as f: + return f.read() diff --git a/cortex/webgl/notebook.html b/cortex/webgl/notebook.html new file mode 100644 index 000000000..4f43b32cf --- /dev/null +++ b/cortex/webgl/notebook.html @@ -0,0 +1,25 @@ +{% autoescape None %} +{% extends template.html %} +{% block jsinit %} + var viewer, subjects, datasets, figure, sock, viewopts; +{% end %} +{% block onload %} + viewopts = {{viewopts}}; + subjects = {{subjects}}; + for (var name in subjects) { + subjects[name] = new mriview.Surface(subjects[name]); + } + + figure = new jsplot.W2Figure(); + viewer = figure.add(mriview.Viewer, "main", true); + + dataviews = dataset.fromJSON({{data}}); + viewer.addData(dataviews); + + // Notebook-specific: auto-resize to fill iframe + window.addEventListener('resize', function() { + if (viewer && viewer.canvas) { + viewer.resize(); + } + }); +{% end %} diff --git a/docs/conf.py b/docs/conf.py index 38020b289..b5cc1566f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -11,62 +11,93 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +import sys +import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('sphinxext')) -sys.path.insert(0, os.path.abspath('..')) +# sys.path.insert(0, os.path.abspath('sphinxext')) +sys.path.insert(0, os.path.abspath("..")) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +# needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. -extensions = ['sphinx.ext.autodoc', - 'sphinx.ext.todo', - 'sphinx.ext.mathjax', - 'sphinx.ext.viewcode', - 'sphinx.ext.autosummary', - 'numpydoc', - 'sphinx.ext.githubpages', - 'sphinx_gallery.gen_gallery'] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.todo", + "sphinx.ext.mathjax", + "sphinx.ext.viewcode", + "sphinx.ext.autosummary", + "numpydoc", + "sphinx.ext.githubpages", + "sphinx_gallery.gen_gallery", +] + +exclude_patterns = ["_build", "auto_examples/**/*.ipynb"] + +extensions.append("nbsphinx") +nbsphinx_execute = "always" autosummary_generate = True -numpydoc_show_class_members=False +numpydoc_show_class_members = False + + +def _copy_notebook_artifacts(app, exception): + """Copy static viewer files generated by notebook execution into the + build output so IFrame references resolve correctly.""" + if exception is not None: + return + + import shutil + + src = os.path.join(app.srcdir, "notebooks", "static_viewer") + dst = os.path.join(app.outdir, "notebooks", "static_viewer") + if os.path.isdir(src): + if os.path.isdir(dst): + shutil.rmtree(dst) + shutil.copytree(src, dst) + + +def setup(app): + app.connect("build-finished", _copy_notebook_artifacts) + # Sphinx-gallery sphinx_gallery_conf = { # path to your examples scripts - 'examples_dirs' : '../examples', + "examples_dirs": "../examples", # path where to save gallery generated examples - 'gallery_dirs' : 'auto_examples', + "gallery_dirs": "auto_examples", # which files to execute? only those starting with "plot_" - 'filename_pattern' : '/plot_'} + "filename_pattern": "/plot_", +} # Add any paths that contain templates here, relative to this directory. -templates_path = ['_templates'] +templates_path = ["_templates"] # The suffix of source filenames. -source_suffix = '.rst' +source_suffix = ".rst" # The encoding of source files. -#source_encoding = 'utf-8-sig' +# source_encoding = 'utf-8-sig' # The main toctree document. -main_doc = 'index' +main_doc = "index" # General information about the project. -project = u'pycortex' -copyright = u'2012, James Gao' +project = "pycortex" +copyright = "2012, James Gao" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. import cortex # noqa + # The short X.Y version. version = ".".join(cortex.__version__.split(".")[:2]) # The full version, including alpha/beta/rc tags. @@ -74,23 +105,24 @@ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. -#language = None +# language = None # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: -#today = '' +# today = '' # Else, today_fmt is used as the format for a strftime call. -#today_fmt = '%B %d, %Y' +# today_fmt = '%B %d, %Y' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = ['_build'] +# (exclude_patterns is defined near the top of this file, before the +# nbsphinx conditional block that may append to it) # The reST default role (used for this markup: `text`) to use for all documents. -#default_role = None +# default_role = None # If true, '()' will be appended to :func: etc. cross-reference text. -#add_function_parentheses = True +# add_function_parentheses = True # If true, the current module name will be prepended to all description # unit titles (such as .. function::). @@ -98,161 +130,155 @@ # If true, sectionauthor and moduleauthor directives will be shown in the # output. They are ignored by default. -#show_authors = False +# show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +pygments_style = "sphinx" # A list of ignored prefixes for module index sorting. -#modindex_common_prefix = [] +# modindex_common_prefix = [] # -- Options for HTML output --------------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'alabaster' +html_theme = "alabaster" # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. html_theme_options = { # 'logo': 'logo.png', - 'github_user': 'gallantlab', - 'github_repo': 'pycortex', - 'github_type': 'star', + "github_user": "gallantlab", + "github_repo": "pycortex", + "github_type": "star", } # Add any paths that contain custom themes here, relative to this directory. -#html_theme_path = [] +# html_theme_path = [] # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". -#html_title = None +# html_title = None # A shorter title for the navigation bar. Default is the same as html_title. -#html_short_title = None +# html_short_title = None # The name of an image file (relative to this directory) to place at the top # of the sidebar. -#html_logo = None +# html_logo = None # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. -#html_favicon = None +# html_favicon = None # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -html_static_path = ['_static'] +html_static_path = ["_static"] # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. -#html_last_updated_fmt = '%b %d, %Y' +# html_last_updated_fmt = '%b %d, %Y' # If true, SmartyPants will be used to convert quotes and dashes to # typographically correct entities. -#html_use_smartypants = True +# html_use_smartypants = True # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} +# html_sidebars = {} html_sidebars = { - '**': [ - 'about.html', - 'navigation.html', - 'relations.html', - 'searchbox.html', - 'donate.html', + "**": [ + "about.html", + "navigation.html", + "relations.html", + "searchbox.html", + "donate.html", ] } # Additional templates that should be rendered to pages, maps page names to # template names. -#html_additional_pages = {} +# html_additional_pages = {} # If false, no module index is generated. -#html_domain_indices = True +# html_domain_indices = True # If false, no index is generated. -#html_use_index = True +# html_use_index = True # If true, the index is split into individual pages for each letter. -#html_split_index = False +# html_split_index = False # If true, links to the reST sources are added to the pages. -#html_show_sourcelink = True +# html_show_sourcelink = True # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. -#html_show_sphinx = True +# html_show_sphinx = True # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. -#html_show_copyright = True +# html_show_copyright = True # If true, an OpenSearch description file will be output, and all pages will # contain a tag referring to it. The value of this option must be the # base URL from which the finished HTML is served. -#html_use_opensearch = '' +# html_use_opensearch = '' # This is the file name suffix for HTML files (e.g. ".xhtml"). -#html_file_suffix = None +# html_file_suffix = None # Output file base name for HTML help builder. -htmlhelp_basename = 'pycortexdoc' +htmlhelp_basename = "pycortexdoc" # -- Options for LaTeX output -------------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [ - ('index', 'pycortex.tex', u'pycortex Documentation', - u'James Gao', 'manual'), + ("index", "pycortex.tex", "pycortex Documentation", "James Gao", "manual"), ] # The name of an image file (relative to this directory) to place at the top of # the title page. -#latex_logo = None +# latex_logo = None # For "manual" documents, if this is true, then toplevel headings are parts, # not chapters. -#latex_use_parts = False +# latex_use_parts = False # If true, show page references after internal links. -#latex_show_pagerefs = False +# latex_show_pagerefs = False # If true, show URL addresses after external links. -#latex_show_urls = False +# latex_show_urls = False # Documents to append as an appendix to all manuals. -#latex_appendices = [] +# latex_appendices = [] # If false, no module index is generated. -#latex_domain_indices = True +# latex_domain_indices = True # -- Options for manual page output -------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). -man_pages = [ - ('index', 'pycortex', u'pycortex Documentation', - [u'James Gao'], 1) -] +man_pages = [("index", "pycortex", "pycortex Documentation", ["James Gao"], 1)] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------------ @@ -261,59 +287,65 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'pycortex', u'pycortex Documentation', - u'James Gao', 'pycortex', 'One line description of project.', - 'Miscellaneous'), + ( + "index", + "pycortex", + "pycortex Documentation", + "James Gao", + "pycortex", + "One line description of project.", + "Miscellaneous", + ), ] # Documents to append as an appendix to all manuals. -#texinfo_appendices = [] +# texinfo_appendices = [] # If false, no module index is generated. -#texinfo_domain_indices = True +# texinfo_domain_indices = True # How to display URL addresses: 'footnote', 'no', or 'inline'. -#texinfo_show_urls = 'footnote' +# texinfo_show_urls = 'footnote' # -- Options for Epub output --------------------------------------------------- # Bibliographic Dublin Core info. -epub_title = u'pycortex' -epub_author = u'James Gao' -epub_publisher = u'James Gao' -epub_copyright = u'2012, James Gao' +epub_title = "pycortex" +epub_author = "James Gao" +epub_publisher = "James Gao" +epub_copyright = "2012, James Gao" # The language of the text. It defaults to the language option # or en if the language is not set. -#epub_language = '' +# epub_language = '' # The scheme of the identifier. Typical schemes are ISBN or URL. -#epub_scheme = '' +# epub_scheme = '' # The unique identifier of the text. This can be a ISBN number # or the project homepage. -#epub_identifier = '' +# epub_identifier = '' # A unique identification for the text. -#epub_uid = '' +# epub_uid = '' # A tuple containing the cover image and cover page html template filenames. -#epub_cover = () +# epub_cover = () # HTML files that should be inserted before the pages created by sphinx. # The format is a list of tuples containing the path and title. -#epub_pre_files = [] +# epub_pre_files = [] # HTML files that should be inserted after the pages created by sphinx. # The format is a list of tuples containing the path and title. -#epub_post_files = [] +# epub_post_files = [] # A list of files that should not be packed into the epub file. -#epub_exclude_files = [] +# epub_exclude_files = [] # The depth of the table of contents in toc.ncx. -#epub_tocdepth = 3 +# epub_tocdepth = 3 # Allow duplicate toc entries. -#epub_tocdup = True +# epub_tocdup = True diff --git a/docs/index.rst b/docs/index.rst index 1c4d421ad..cf289b9f6 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -30,8 +30,9 @@ Example Gallery --------------- .. toctree:: :maxdepth: 3 - + auto_examples/index + notebooks/jupyter_notebook API Reference ------------- diff --git a/docs/notebooks/.gitignore b/docs/notebooks/.gitignore new file mode 100644 index 000000000..2fcc2d401 --- /dev/null +++ b/docs/notebooks/.gitignore @@ -0,0 +1,3 @@ +# Generated during notebook execution by nbsphinx +static_viewer/ +viewer.html diff --git a/docs/notebooks/jupyter_notebook.ipynb b/docs/notebooks/jupyter_notebook.ipynb new file mode 100644 index 000000000..248dfe673 --- /dev/null +++ b/docs/notebooks/jupyter_notebook.ipynb @@ -0,0 +1,151 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Display WebGL Viewer in a Jupyter Notebook\n", + "\n", + "Pycortex can embed the interactive WebGL brain viewer directly in a Jupyter\n", + "notebook cell. There are two methods:\n", + "\n", + "1. **IFrame mode** (default): Starts a background Tornado server and embeds\n", + " it in an IFrame. Provides full interactivity including surface morphing,\n", + " data switching, and programmatic control from Python via WebSocket.\n", + "\n", + "2. **Static mode**: Generates a self-contained HTML viewer served by a\n", + " lightweight local HTTP server. Works in static notebook renderers.\n", + "\n", + "Both methods are **non-blocking** — subsequent notebook cells execute immediately." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create some example data" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import cortex\n", + "import numpy as np\n", + "\n", + "np.random.seed(1234)\n", + "volume = cortex.Volume.random(subject=\"S1\", xfmname=\"fullhead\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Static viewer\n", + "\n", + "`cortex.webgl.make_static()` generates a self-contained viewer directory\n", + "with the HTML, CTM surface meshes, and data files. We write it to a\n", + "local directory and embed it in an IFrame." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from IPython.display import IFrame\n", + "\n", + "# Generate the full static viewer (HTML + CTM + data files)\n", + "cortex.webgl.make_static(\"static_viewer\", volume, html_embed=True)\n", + "\n", + "IFrame(src=\"static_viewer/index.html\", width=\"100%\", height=\"600px\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## IFrame mode (for live Jupyter sessions)\n", + "\n", + "When running in a live Jupyter session, the IFrame mode starts a Tornado\n", + "server in a background thread and embeds the viewer in an IFrame. This\n", + "provides full interactivity including surface morphing, data switching,\n", + "and programmatic control from Python via WebSocket.\n", + "\n", + "```python\n", + "# Start the viewer (non-blocking)\n", + "server = cortex.webgl.jupyter.display(volume)\n", + "\n", + "# In a subsequent cell, get a control handle\n", + "client = server.get_client()\n", + "\n", + "# Rotate the view\n", + "client._set_view(azimuth=45, altitude=30)\n", + "\n", + "# Switch to inflated surface (mix=1.0 is fully inflated)\n", + "client._set_view(mix=1.0)\n", + "\n", + "# Capture a screenshot\n", + "client.getImage(\"notebook_screenshot.png\")\n", + "```\n", + "\n", + "> **Note:** `get_client()` blocks until the browser connects via WebSocket,\n", + "> so call it in a **separate cell** from `display()`." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Convenience wrapper: `display(method=\"static\")`\n", + "\n", + "For quick use in a live notebook, you can also call `display()` with\n", + "`method=\"static\"` which generates the static viewer and serves it via a\n", + "lightweight local HTTP server:\n", + "\n", + "```python\n", + "viewer = cortex.webgl.jupyter.display(volume, method=\"static\")\n", + "\n", + "# When done, clean up the server and temp files:\n", + "viewer.close()\n", + "```" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Customizing the viewer\n", + "\n", + "Both methods accept the same keyword arguments as `cortex.webgl.show()`\n", + "and `cortex.webgl.make_static()`. For example:\n", + "\n", + "```python\n", + "cortex.webgl.jupyter.display(\n", + " volume,\n", + " types=(\"inflated\",), # Surface types to include\n", + " overlays_visible=(\"sulci\",), # Show sulci overlay by default\n", + " height=400, # Shorter viewer\n", + " title=\"My Experiment\",\n", + ")\n", + "```" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.9.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +}