diff --git a/doc/conf.py b/doc/conf.py index 4707b81..de7fd73 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -300,3 +300,154 @@ def ensure_pandoc_installed(_): def setup(app): app.connect("builder-inited", ensure_pandoc_installed) + # nbsphinx omits image/gif from its MIME-type pipeline, so animated GIFs + # produced by display(IPython.display.Image(...)) fall back to the + # text/plain repr. Three patches are needed: + # + # 1. DISPLAY_DATA_PRIORITY_HTML — tell nbsphinx to *select* image/gif output. + # 2. RST_TEMPLATE — tell the Jinja template to render image/gif via the + # standard ``.. image::`` directive (same as image/png / image/jpeg). + # 3. ExtractOutputPreprocessor.extract_output_types — tell nbconvert to + # actually extract the GIF bytes to a file so the directive has a path. + import logging + _logger = logging.getLogger(__name__) + try: + import nbsphinx as _nbsphinx + + # 1. Priority list + if "image/gif" not in _nbsphinx.DISPLAY_DATA_PRIORITY_HTML: + _priority = list(_nbsphinx.DISPLAY_DATA_PRIORITY_HTML) + try: + idx = _priority.index("image/jpeg") + _priority.insert(idx + 1, "image/gif") + except ValueError: + import warnings + warnings.warn( + "whippersnappy/conf.py: 'image/jpeg' not found in " + "nbsphinx.DISPLAY_DATA_PRIORITY_HTML; appending " + "'image/gif' at the end instead. The GIF may still " + "render correctly, but priority ordering is unknown.", + stacklevel=2, + ) + _priority.append("image/gif") + _nbsphinx.DISPLAY_DATA_PRIORITY_HTML = tuple(_priority) + + # 2. RST template — add image/gif alongside the other raster types. + # We verify the substitution actually changed something; if the + # upstream template text has changed, warn so the breakage is visible + # rather than silently producing a broken GIF rendering. + import warnings as _warnings + _RST_OLD = "datatype in ['image/svg+xml', 'image/png', 'image/jpeg', 'application/pdf']" + _RST_NEW = "datatype in ['image/svg+xml', 'image/png', 'image/jpeg', 'image/gif', 'application/pdf']" + _patched_template = _nbsphinx.RST_TEMPLATE.replace(_RST_OLD, _RST_NEW) + if _patched_template == _nbsphinx.RST_TEMPLATE: + _warnings.warn( + "whippersnappy/conf.py: could not patch nbsphinx.RST_TEMPLATE " + "to add 'image/gif' support — the expected substring was not " + "found. Animated GIFs may not render in the documentation. " + "The nbsphinx template may have changed upstream; please update " + "the patch in doc/conf.py.", + stacklevel=2, + ) + else: + _nbsphinx.RST_TEMPLATE = _patched_template + + # 3. nbconvert extractor — ExtractOutputPreprocessor hard-codes + # {"image/png", "image/jpeg", "application/pdf"} as the types that + # get base64-decoded to binary. image/gif falls into the "else: text" + # branch and is written as a raw base64 string, producing a corrupt + # file. Two sub-patches fix this: + # 3a add "image/gif" to extract_output_types so the extractor + # visits it at all. + # 3b wrap preprocess_cell to strip gif data before the parent runs, + # then decode it to bytes and inject the result into resources. + from nbconvert.preprocessors import ExtractOutputPreprocessor as _EOP + from binascii import a2b_base64 as _a2b + + # 3a — register image/gif in extract_output_types via __init__ patch + _eop_orig_init = _EOP.__init__ + def _eop_patched_init(self, *args, **kwargs): + _eop_orig_init(self, *args, **kwargs) + self.extract_output_types = self.extract_output_types | {"image/gif"} + _EOP.__init__ = _eop_patched_init + + # 3b — patch preprocess_cell to handle image/gif as binary (base64 → bytes). + # The parent hard-codes only png/jpeg/pdf for binary decode; gif falls + # into the "else: text" branch and would be written as a raw base64 + # string (corrupt file). We strip gif data before calling the parent, + # decode it ourselves, and store the binary bytes in resources. + _eop_orig_preprocess_cell = _EOP.preprocess_cell + + def _eop_patched_preprocess_cell(self, cell, resources, cell_index): + # Before calling the original, convert any image/gif from base64 + # string to bytes — but the original then hits the + # `not isinstance(data, str)` → json branch for bytes, so we must + # pre-decode AND bypass the parent entirely for image/gif outputs. + # + # Strategy: strip image/gif from outputs before calling parent, + # then handle extraction ourselves, then restore. + import os as _os + gif_extractions = [] # list of (out, raw_b64) to process after parent + + for out in cell.get("outputs", []): + if out.get("output_type") not in ("display_data", "execute_result"): + continue + data = out.get("data", {}) + if "image/gif" in data and isinstance(data["image/gif"], str): + gif_extractions.append((out, data.pop("image/gif"))) + + # Run original preprocessor (without image/gif in data) + cell, resources = _eop_orig_preprocess_cell(self, cell, resources, cell_index) + + if not gif_extractions: + return cell, resources + + # Now handle image/gif extractions ourselves + unique_key = resources.get("unique_key", "output") + output_files_dir = resources.get("output_files_dir", None) + if not isinstance(resources.get("outputs"), dict): + resources["outputs"] = {} + + outputs_list = cell.get("outputs", []) + for out, raw_b64 in gif_extractions: + # Restore the b64 string in the cell data for the RST template + out["data"]["image/gif"] = raw_b64 + # Find the index of this output in the cell + try: + index = outputs_list.index(out) + except ValueError: + index = 0 + # Build filename + filename = self.output_filename_template.format( + unique_key=unique_key, + cell_index=cell_index, + index=index, + extension=".gif", + ) + if output_files_dir is not None: + filename = _os.path.join(output_files_dir, filename) + # Store binary GIF bytes in resources + resources["outputs"][filename] = _a2b(raw_b64) + # Store filename in output metadata so the Jinja template uses it + if "metadata" not in out: + out["metadata"] = {} + if "filenames" not in out["metadata"]: + out["metadata"]["filenames"] = {} + out["metadata"]["filenames"]["image/gif"] = filename + + return cell, resources + + _EOP.preprocess_cell = _eop_patched_preprocess_cell + except ImportError as exc: + _logger.warning( + "conf.py: could not patch nbsphinx/nbconvert for GIF support " + "(package not installed): %s. Animated GIFs will not render.", + exc, + ) + except AttributeError as exc: + _logger.warning( + "conf.py: nbsphinx or nbconvert API has changed and the GIF patch " + "could not be applied: %s. Animated GIFs will not render. " + "Please update the patch in doc/conf.py.", + exc, + ) diff --git a/tutorials/whippersnappy_tutorial.ipynb b/tutorials/whippersnappy_tutorial.ipynb index f28b561..f5d314c 100644 --- a/tutorials/whippersnappy_tutorial.ipynb +++ b/tutorials/whippersnappy_tutorial.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "2c795daa", + "id": "3de09aec", "metadata": {}, "source": [ "# WhipperSnapPy Tutorial\n", @@ -17,7 +17,7 @@ }, { "cell_type": "markdown", - "id": "8bd0a771", + "id": "c7520b73", "metadata": {}, "source": [ "## Subject Directory\n", @@ -31,7 +31,7 @@ { "cell_type": "code", "execution_count": null, - "id": "30c14cc9", + "id": "75a22f9c", "metadata": {}, "outputs": [], "source": [ @@ -50,12 +50,12 @@ "if not sdir:\n", " sdir = fetch_sample_subject()[\"sdir\"]\n", "\n", - "print(\"Subject directory:\", sdir)\n" + "print(\"Subject directory:\", sdir)" ] }, { "cell_type": "markdown", - "id": "73dd4d58", + "id": "06c859f0", "metadata": {}, "source": [ "### Derive file paths from `sdir`\n", @@ -67,7 +67,7 @@ { "cell_type": "code", "execution_count": null, - "id": "ec559dcd", + "id": "9ca0b13a", "metadata": {}, "outputs": [], "source": [ @@ -89,15 +89,15 @@ "\n", "# Parcellation annotation (DKTatlas)\n", "lh_annot = os.path.join(sdir, \"label\", \"lh.aparc.DKTatlas.mapped.annot\")\n", - "rh_annot = os.path.join(sdir, \"label\", \"rh.aparc.DKTatlas.mapped.annot\")\n" + "rh_annot = os.path.join(sdir, \"label\", \"rh.aparc.DKTatlas.mapped.annot\")" ] }, { "cell_type": "markdown", - "id": "22be9ebf", + "id": "7fdab413", "metadata": {}, "source": [ - "## snap1 — Basic Single View\n", + "## snap1 \u2014 Basic Single View\n", "\n", "`snap1` renders a single static view of a surface mesh into a PIL Image.\n", "Here we render the left hemisphere with curvature texturing only (no overlay),\n", @@ -107,7 +107,7 @@ { "cell_type": "code", "execution_count": null, - "id": "783e547b", + "id": "68e1a46a", "metadata": {}, "outputs": [], "source": [ @@ -116,15 +116,15 @@ "from whippersnappy import snap1\n", "\n", "img = snap1(lh_white, bg_map=lh_curv)\n", - "display(img)\n" + "display(img)" ] }, { "cell_type": "markdown", - "id": "7173d312", + "id": "fbb6b125", "metadata": {}, "source": [ - "## snap1 — With Thickness Overlay\n", + "## snap1 \u2014 With Thickness Overlay\n", "\n", "By passing `overlay` and `roi`, the surface is colored by cortical\n", "thickness values, masked to the cortex label. The `view` parameter selects\n", @@ -134,7 +134,7 @@ { "cell_type": "code", "execution_count": null, - "id": "13407b67", + "id": "82ee25b8", "metadata": {}, "outputs": [], "source": [ @@ -147,15 +147,15 @@ " roi=lh_label,\n", " view=ViewType.LEFT,\n", ")\n", - "display(img)\n" + "display(img)" ] }, { "cell_type": "markdown", - "id": "4217c291", + "id": "043273a1", "metadata": {}, "source": [ - "## snap1 — With Parcellation Annotation\n", + "## snap1 \u2014 With Parcellation Annotation\n", "\n", "`annot` accepts a FreeSurfer `.annot` file and colors each vertex by\n", "its parcellation label. This example uses the DKTatlas parcellation." @@ -164,7 +164,7 @@ { "cell_type": "code", "execution_count": null, - "id": "7271e902", + "id": "3fd2b433", "metadata": {}, "outputs": [], "source": [ @@ -173,15 +173,15 @@ " annot=lh_annot,\n", " bg_map=lh_curv,\n", ")\n", - "display(img)\n" + "display(img)" ] }, { "cell_type": "markdown", - "id": "620c6c43", + "id": "a3b6350b", "metadata": {}, "source": [ - "## snap4 — Four-View Overview\n", + "## snap4 \u2014 Four-View Overview\n", "\n", "`snap4` renders lateral and medial views of both hemispheres and stitches\n", "them into a single composed image. Here we color both hemispheres by\n", @@ -191,7 +191,7 @@ { "cell_type": "code", "execution_count": null, - "id": "903514b8", + "id": "e0d0405b", "metadata": {}, "outputs": [], "source": [ @@ -204,15 +204,15 @@ " colorbar=True,\n", " caption=\"Cortical Thickness (mm)\",\n", ")\n", - "display(img)\n" + "display(img)" ] }, { "cell_type": "markdown", - "id": "5d98c87b", + "id": "f7006557", "metadata": {}, "source": [ - "## plot3d — Interactive 3D Viewer\n", + "## plot3d \u2014 Interactive 3D Viewer\n", "\n", "`plot3d` creates an interactive Three.js/WebGL viewer that works in all\n", "Jupyter environments. You can rotate, zoom, and pan with the mouse.\n", @@ -222,7 +222,7 @@ { "cell_type": "code", "execution_count": null, - "id": "5d5a09c7", + "id": "ba49903c", "metadata": {}, "outputs": [], "source": [ @@ -233,35 +233,39 @@ " bg_map=lh_curv,\n", " overlay=lh_thickness,\n", ")\n", - "display(viewer)\n" + "display(viewer)" ] }, { "cell_type": "markdown", - "id": "dc3970c3", + "id": "f2201a62", "metadata": {}, "source": [ - "## snap_rotate — Rotating 360° Animation\n", + "## snap_rotate \u2014 Rotating 360\u00b0 Animation\n", "\n", - "`snap_rotate` renders a full 360° rotation of the surface. We output an\n", + "`snap_rotate` renders a full 360\u00b0 rotation of the surface. We output an\n", "animated GIF so it displays inline in all Jupyter environments including\n", "PyCharm. Use `.mp4` as `outpath` instead for a smaller file when playing\n", "outside the notebook.\n", - "This cell takes the longest to run — execute it last." + "This cell takes the longest to run \u2014 execute it last." ] }, { "cell_type": "code", "execution_count": null, - "id": "928a68ea", + "id": "15ea8375", "metadata": {}, "outputs": [], "source": [ + "import os\n", + "import tempfile\n", + "\n", "from IPython.display import Image\n", "\n", "from whippersnappy import snap_rotate\n", "\n", - "outpath_gif = \"/tmp/lh_thickness_rotate.gif\"\n", + "_gif_fd, outpath_gif = tempfile.mkstemp(suffix=\".gif\")\n", + "os.close(_gif_fd)\n", "\n", "snap_rotate(\n", " mesh=lh_white,\n", @@ -273,20 +277,18 @@ " fps=24,\n", " width=800,\n", " height=600,\n", - ")\n", - "print(\"GIF saved to:\", outpath_gif)\n" + ")\n" ] }, { "cell_type": "code", "execution_count": null, - "id": "dcd38db4", - "metadata": { - "lines_to_next_cell": 2 - }, + "id": "9f0b2db4", + "metadata": {}, "outputs": [], "source": [ - "display(Image(filename=outpath_gif))\n" + "display(Image(filename=outpath_gif))\n", + "os.unlink(outpath_gif)\n" ] } ], @@ -295,17 +297,8 @@ "cell_metadata_filter": "-all", "main_language": "python", "notebook_metadata_filter": "-all" - }, - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "name": "python", - "version": "3" } }, "nbformat": 4, "nbformat_minor": 5 -} +} \ No newline at end of file