diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml
index f85d87a84..2ebe5cce1 100644
--- a/.github/workflows/run_tests.yml
+++ b/.github/workflows/run_tests.yml
@@ -65,7 +65,7 @@ jobs:
# A backstop only: pytest-timeout (pytest.ini) already fails a single
# hung test after 240 s. The suite itself takes 25-30 min on a hosted
# runner, mostly headless webgl renders.
- timeout-minutes: 35
+ timeout-minutes: 40
run: pytest --cov=./
- name: Upload coverage to Codecov
diff --git a/.gitignore b/.gitignore
index ec2b5783f..bbda8ab0c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -81,3 +81,4 @@ examples/quickstart/S1_retinotopy.hdf
# Claude Code working directory (per-worktree scratch: launchers, verify
# scripts, plans). Not part of the project.
.claude
+uv.lock
diff --git a/cortex/export/headless.py b/cortex/export/headless.py
index 12d2182bc..3b1ccc563 100644
--- a/cortex/export/headless.py
+++ b/cortex/export/headless.py
@@ -41,9 +41,15 @@
import concurrent.futures
import contextlib
import logging
+import os
+import queue
+import shutil
+import tempfile
import threading
import time
-from typing import Any, Mapping, Optional
+from typing import Any, Callable, Mapping, Optional, TypeVar
+
+T = TypeVar("T")
import cortex
from .. import dataset
@@ -147,16 +153,30 @@ class _PlaywrightThread:
Usage::
- pw_thread = _PlaywrightThread()
+ owns_download_dir = download_dir is None
+ if download_dir is None:
+ download_dir = tempfile.mkdtemp(prefix="pycortex-downloads-")
+ pw_thread = _PlaywrightThread(download_dir=download_dir)
pw_thread.start(url, timeout=60) # blocks until page is loaded
# ... use the pycortex handle (which talks via Tornado, not Playwright) ...
pw_thread.shutdown() # tears down browser + playwright
"""
- def __init__(self) -> None:
+ def __init__(self, download_dir: Optional[str] = None) -> None:
self._ready_future: concurrent.futures.Future[None] = (
concurrent.futures.Future()
)
+ # Downloads the page starts -- the animation panel's rendered movies,
+ # the "Save image" button. The listener only queues them and the poll
+ # loop saves them, both on the worker thread: Download.save_as blocks
+ # until the file is complete, which the sync API cannot do from inside
+ # an event callback. Without a directory they are left unsaved.
+ self._download_dir = download_dir
+ self._pending_downloads: list[Any] = []
+ self._downloads: list[str] = []
+ self._downloads_changed = threading.Condition()
+ # Functions queued by run_on_page, run by the poll loop on the worker.
+ self._page_calls: "queue.Queue[tuple[Callable[[Any], Any], concurrent.futures.Future[Any]]]" = queue.Queue()
self._shutdown_event = threading.Event()
self._error: Optional[BaseException] = None
self._thread: Optional[threading.Thread] = None
@@ -213,6 +233,54 @@ def browser_errors(self) -> list[str]:
with self._errors_lock:
return list(self._browser_errors)
+ @property
+ def downloads(self) -> list[str]:
+ """Paths of the downloads saved so far, oldest first. Thread-safe."""
+ with self._downloads_changed:
+ return list(self._downloads)
+
+ def wait_for_download(self, timeout: float = 60.0, count: int = 1) -> str:
+ """Wait until `count` downloads have been saved; return the last one's path.
+
+ Raises
+ ------
+ TimeoutError
+ If fewer than `count` downloads arrive within `timeout` seconds.
+ """
+ deadline = time.monotonic() + timeout
+ with self._downloads_changed:
+ while len(self._downloads) < count:
+ remaining = deadline - time.monotonic()
+ if remaining <= 0:
+ raise TimeoutError(
+ f"Only {len(self._downloads)} of {count} download(s) "
+ f"arrived within {timeout:.0f} s"
+ )
+ self._downloads_changed.wait(remaining)
+ return self._downloads[count - 1]
+
+ def run_on_page(self, fn: Callable[[Any], T], timeout: float = 60.0) -> T:
+ """Run ``fn(page)`` on the worker thread and return what it returns.
+
+ Playwright's sync objects belong to the thread that made them, so the
+ page can only be touched from the worker: this hands it `fn` and waits.
+ It is for what the websocket interface cannot do -- clicking an element,
+ dragging with the mouse, taking a screenshot of part of the page.
+
+ Raises
+ ------
+ TimeoutError
+ If `fn` has not finished within `timeout` seconds.
+ """
+ future: "concurrent.futures.Future[T]" = concurrent.futures.Future()
+ self._page_calls.put((fn, future))
+ try:
+ return future.result(timeout=timeout)
+ except concurrent.futures.TimeoutError:
+ raise TimeoutError(
+ f"The page did not finish the call within {timeout:.0f} s"
+ ) from None
+
def shutdown(self) -> None:
"""Signal the worker to tear down Playwright and wait for it to finish."""
self._shutdown_event.set()
@@ -245,6 +313,7 @@ def _worker(self) -> None:
# errors that fire during page load (e.g. WebGL failures).
self._page.on("pageerror", self._on_pageerror)
self._page.on("console", self._on_console)
+ self._page.on("download", self._on_download)
self._page.goto(
self._url,
@@ -268,6 +337,8 @@ def _worker(self) -> None:
# console messages undelivered until _cleanup(). Poll instead: the cheap
# round-trip is what makes Playwright dispatch them.
while not self._shutdown_event.wait(EVENT_POLL_INTERVAL):
+ self._save_downloads()
+ self._run_page_calls()
try:
self._page.evaluate("0")
except Exception:
@@ -292,6 +363,42 @@ def _on_pageerror(self, error: Any) -> None:
with self._errors_lock:
self._browser_errors.append(f"[pageerror] {error}")
+ def _on_download(self, download: Any) -> None:
+ """Listener for downloads the page starts; the poll loop saves them."""
+ self._pending_downloads.append(download)
+
+ def _save_downloads(self) -> None:
+ """Save queued downloads into the download directory (worker thread)."""
+ while self._pending_downloads:
+ download = self._pending_downloads.pop(0)
+ if self._download_dir is None:
+ continue
+ path = os.path.join(self._download_dir,
+ os.path.basename(download.suggested_filename))
+ try:
+ download.save_as(path)
+ except Exception:
+ logger.warning("Could not save the download %r",
+ download.suggested_filename, exc_info=True)
+ continue
+ with self._downloads_changed:
+ self._downloads.append(path)
+ self._downloads_changed.notify_all()
+
+ def _run_page_calls(self) -> None:
+ """Run the functions queued by run_on_page (worker thread)."""
+ while True:
+ try:
+ fn, future = self._page_calls.get_nowait()
+ except queue.Empty:
+ return
+ if not future.set_running_or_notify_cancel():
+ continue
+ try:
+ future.set_result(fn(self._page))
+ except BaseException as exc: # noqa: BLE001 - handed to the caller
+ future.set_exception(exc)
+
def _on_console(self, msg: Any) -> None:
"""Listener for console.error / console.warning messages."""
if msg.type in ("error", "warning"):
@@ -329,6 +436,7 @@ def headless_viewer(
viewer_params: Mapping[str, Any],
*,
timeout: float = 60.0,
+ download_dir: Optional[str] = None,
):
"""Context manager that yields a connected ``JSMixer`` handle rendered in a
headless Chromium browser.
@@ -344,6 +452,12 @@ def headless_viewer(
timeout : float
Seconds to wait for the browser to establish the WebSocket connection
and for ``server.get_client()`` to return (default: 60).
+ download_dir : str or None
+ Directory that files the page downloads -- a movie rendered by the
+ animation panel, an image from "Save image" -- are saved into, as
+ ``handle._pw_thread.downloads``; ``handle._pw_thread.wait_for_download()``
+ waits for one. Default None, meaning a temporary directory removed when
+ the viewer closes.
Yields
------
@@ -391,7 +505,10 @@ def headless_viewer(
# does not require a GPU or display server, making it usable in
# CI / Docker / notebooks.
# ------------------------------------------------------------------
- pw_thread = _PlaywrightThread()
+ owns_download_dir = download_dir is None
+ if download_dir is None:
+ download_dir = tempfile.mkdtemp(prefix="pycortex-downloads-")
+ pw_thread = _PlaywrightThread(download_dir=download_dir)
handle = None
# ------------------------------------------------------------------
@@ -495,3 +612,6 @@ def _await_client() -> None:
server.stop()
except Exception:
logger.warning("Failed to stop Tornado server", exc_info=True)
+
+ if owns_download_dir:
+ shutil.rmtree(download_dir, ignore_errors=True)
diff --git a/cortex/export/save_views.py b/cortex/export/save_views.py
index 2d1f6e59f..e250b3cfb 100644
--- a/cortex/export/save_views.py
+++ b/cortex/export/save_views.py
@@ -1,7 +1,12 @@
import contextlib
+import json
+import math
import os
import time
-from typing import Any, Mapping, Sequence, TypedDict, Union
+import warnings
+from typing import Any, Mapping, Optional, Sequence, TypedDict, Union
+
+import numpy as np
import cortex
@@ -15,6 +20,7 @@
"camera.azimuth": float,
"camera.altitude": float,
"camera.target": list[float],
+ "camera.radius": float,
"surface.{subject}.unfold": float,
"surface.{subject}.pivot": float,
"surface.{subject}.shift": float,
@@ -161,6 +167,16 @@ def save_3d_views(
this_view_params.update(interpolation_params)
this_view_params.update(view_params)
this_view_params.update(surface_params)
+
+ # A flattened surface pins the camera square-on and ignores the
+ # angle it is given, so asking for one only makes the settle loop
+ # below report a view that never arrives. Kept when the surface is
+ # tilt-enabled, where the angle does steer the camera.
+ if (this_view_params.get("surface.{subject}.unfold", 0) >= 0.999
+ and not this_view_params.get("surface.{subject}.allow_tilt")):
+ for prop in FLAT_INERT_PROPS:
+ this_view_params.pop(prop, None) # type: ignore[misc]
+
print(this_view_params)
# apply params
@@ -279,9 +295,14 @@ def save_3d_views(
"camera.azimuth": 0,
"camera.altitude": 180,
},
+ # No camera angle: once the surface is flat the controls hold the camera
+ # square-on to it and discard whatever azimuth and altitude they are given
+ # (see setAzimuth/setAltitude in resources/js/movement.js), unless the
+ # surface's allow_tilt is on. Naming them here would do nothing to a flat
+ # view, while making an animation interpolate towards them on the way in --
+ # which spins the brain as it flattens, and leaves the folded camera angle
+ # overwritten when it unfolds again.
"flatmap": {
- "camera.azimuth": 180,
- "camera.altitude": 0,
"surface.{subject}.pivot": 180,
"surface.{subject}.shift": 0,
},
@@ -330,3 +351,352 @@ def save_3d_views(
"surface.{subject}.unfold": 1,
},
}
+
+
+# ---------------------------------------------------------------------------
+# Views every subject gets
+# ---------------------------------------------------------------------------
+#
+# Offered by every viewer, so that a subject with nothing in its filestore
+# views/ directory still has the standard anatomical orientations one click
+# away. A view saved under one of these names takes precedence; see
+# cortex.webgl.view._load_saved_views.
+
+#: Anatomical name to the entry in `angle_view_params` that produces it.
+#:
+#: The viewer's camera sits at
+#: ``radius * (sin(alt)cos(azi+90), sin(alt)sin(azi+90), cos(alt))`` looking at
+#: the target, with up fixed at +z (LandscapeControls.js, axes3d.js). Surfaces
+#: are in surface RAS, so +x is right, +y anterior, +z superior. That puts the
+#: camera left of the brain at azimuth 90 and right of it at 270, above it at
+#: altitude 0 and below at 180; and because `lookAt` resolves the degenerate
+#: straight-up/straight-down cases through the azimuth, anterior ends up at the
+#: top of the image at azimuth 180 seen from above and at azimuth 0 seen from
+#: below. Those are exactly the four angles named here.
+DEFAULT_VIEW_ANGLES: dict[str, str] = {
+ "dorsal": "top", # from above, frontal lobe up
+ "ventral": "bottom", # from below, frontal lobe up
+ "lateral_left": "left", # from the left, brain upright
+ "lateral_right": "right", # from the right, brain upright
+}
+
+#: Suffix added to the inflated counterpart of each of the above.
+INFLATED_SUFFIX = "_inflated"
+
+#: Name of the flattened view.
+FLAT_VIEW_NAME = "flat"
+
+#: View properties a flattened surface ignores, and which a flat view or
+#: keyframe therefore leaves out. The controls pin the camera square-on to the
+#: flatmap and discard both (resources/js/movement.js), so carrying them would
+#: only give an animation something spurious to interpolate towards. They do
+#: steer the camera when the surface's ``allow_tilt`` is on, so a tilted flat
+#: pose keeps them.
+FLAT_INERT_PROPS = ("camera.azimuth", "camera.altitude")
+
+
+def default_subject_views(has_flatmap: bool = True,
+ subject: Optional[str] = None) -> dict[str, ViewParams]:
+ """The views offered for every subject, whether or not any are saved.
+
+ Nine views: the four orientations in `DEFAULT_VIEW_ANGLES` on the fiducial
+ surface, the same four inflated (suffixed `INFLATED_SUFFIX`), and `flat`.
+ They are assembled from `default_view_params`, `angle_view_params` and
+ `unfold_view_params` rather than spelled out, so the camera conventions stay
+ in one place.
+
+ Parameters
+ ----------
+ has_flatmap : bool, optional
+ Whether the subject has a flat surface. Without one there is no `flat`
+ view to offer, and the inflated surface sits at an unfold of 1 rather
+ than 0.5 -- the same correction `save_3d_views` makes. Default True.
+ subject : str or None, optional
+ The subject the views are for. Given one, every view but `flat` also
+ gets the camera target and radius that frame that subject's brain (see
+ `default_view_framing`), so a view always returns the same scene.
+ Default None: angles and unfolding only, the same for every subject.
+
+ Returns
+ -------
+ dict
+ ``{view_name: view_params}``, with the literal ``{subject}`` placeholder
+ left in the keys so one view works in a multi-subject viewer.
+ """
+ def build(*overrides: ViewParams) -> ViewParams:
+ params: ViewParams = default_view_params.copy()
+ for override in overrides:
+ params.update(override)
+ return params
+
+ inflated = unfold_view_params["inflated"].copy()
+ if not has_flatmap:
+ inflated["surface.{subject}.unfold"] = min(
+ inflated["surface.{subject}.unfold"] * 2, 1)
+
+ views: dict[str, ViewParams] = {}
+ for name, angle in DEFAULT_VIEW_ANGLES.items():
+ views[name] = build(angle_view_params[angle],
+ unfold_view_params["fiducial"])
+ views[name + INFLATED_SUFFIX] = build(angle_view_params[angle], inflated)
+
+ if has_flatmap:
+ # The established flatmap preset, the one save_3d_views renders
+ # flatmaps with. It carries neither a camera angle (see
+ # angle_view_params["flatmap"]) nor a camera.radius: a flat surface
+ # ignores the angle, and the zoom is left to whoever renders it --
+ # cortex.webgl.show's handle frames the flatmap the way make_png does
+ # on request, with fit_flat_view.
+ flat = build(angle_view_params["flatmap"],
+ unfold_view_params["flatmap"])
+ for angle in FLAT_INERT_PROPS:
+ flat.pop(angle, None) # type: ignore[misc]
+ # Nor a target: default_view_params' origin is a folded target, which
+ # a flat view has no business setting -- and, applied flat without a
+ # camera.flat_target, it would be read as the flat target (see
+ # JSMixer._set_view) and put the flatmap back where it used to sit,
+ # sixty units low. The viewer starts the flat target at the middle of
+ # the flatmap on its own.
+ flat.pop("camera.target", None) # type: ignore[misc]
+ views[FLAT_VIEW_NAME] = flat
+
+ if subject is not None:
+ for name, framing in default_view_framing(subject).items():
+ if name in views:
+ views[name].update(framing)
+ return views
+
+
+# ---------------------------------------------------------------------------
+# Framing the default views for a subject
+# ---------------------------------------------------------------------------
+#
+# A view is only reproducible if it fixes the whole camera, zoom included, so
+# every default view but `flat` carries a camera target and radius. They cannot
+# be constants: brains differ in size (and pycortex serves macaque as well as
+# human data), and the inflated surface is a different shape from the
+# fiducial. So they are fitted to each subject's own surfaces, as the viewer
+# draws them (see _viewer_points), and cached in the subject's cache directory,
+# since reading the surfaces takes about half a second. `flat` frames itself, the way quickflat frames the image it writes
+# (Viewer.fitFlatView in resources/js/mriview.js).
+
+#: The viewer camera's vertical field of view, in degrees
+#: (``THREE.PerspectiveCamera(45, ...)`` in resources/js/axes3d.js).
+VIEWER_FOV = 45.0
+
+#: The frame the default views are fitted to: the brain fills `FRAMING_FILL`
+#: of the frame's limiting dimension, in a frame `FRAMING_ASPECT` wide for each
+#: unit of height. The lateral views are wider than they are tall, so they are
+#: the ones the aspect ratio decides; 4:3 frames them without clipping in any
+#: window at least that wide.
+FRAMING_ASPECT = 4 / 3
+FRAMING_FILL = 0.85
+
+#: The radii LandscapeControls.setRadius accepts (resources/js/movement.js).
+RADIUS_LIMITS = (10.0, 600.0)
+
+#: Bump when the cached framing changes meaning, so stale caches are refitted.
+_FRAMING_VERSION = 3
+_FRAMING_CACHE = "default_view_framing.json"
+
+# LandscapeControls keeps the altitude strictly inside (0, 180): exactly at a
+# pole the view direction is parallel to the up vector and the image
+# orientation is undefined.
+_POLE_EPSILON = 1e-4
+
+
+def camera_basis(azimuth: float, altitude: float) -> tuple[
+ tuple[float, float, float], tuple[float, float, float],
+ tuple[float, float, float]]:
+ """The viewer camera's axes in world space, for an azimuth and altitude.
+
+ Reproduces the eye position from ``LandscapeControls.update``
+ (resources/js/movement.js), with ``camera.up`` fixed at +z by ``axes3d.js``,
+ then three.js's ``Matrix4.lookAt``.
+
+ Returns
+ -------
+ tuple
+ ``(right, up, back)`` unit vectors: where the image's right edge, its
+ top edge, and the direction from the target towards the camera point in
+ world space.
+ """
+ altitude = min(max(altitude, _POLE_EPSILON), 180 - _POLE_EPSILON)
+ altrad = math.radians(altitude)
+ azirad = math.radians(azimuth + 90)
+ eye = (math.sin(altrad) * math.cos(azirad),
+ math.sin(altrad) * math.sin(azirad),
+ math.cos(altrad))
+
+ def cross(a, b):
+ return (a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0])
+
+ def unit(v):
+ length = math.sqrt(sum(c * c for c in v))
+ return tuple(c / length for c in v)
+
+ back = unit(eye) # the camera looks along -back
+ right = unit(cross((0.0, 0.0, 1.0), back))
+ up = cross(back, right)
+ return right, up, back
+
+
+def _viewer_points(subject: str, kind: str) -> np.ndarray:
+ """The vertices of the surface `kind`, where the viewer draws them.
+
+ Follows brainctm.BrainCTM, which builds the surface packs the viewer loads,
+ and the vertex shader in resources/js/shaderlib.js:
+
+ - A subject with pial and white-matter surfaces is packed on the pial one,
+ with the white matter alongside, and the folded brain is drawn between
+ the two at the cortical depth slider, 0.5 by default -- their midpoint,
+ which is also how pycortex derives a fiducial surface. Without them, the
+ pack and the folded brain are the fiducial surface itself.
+ - Every other folded surface the viewer morphs to, such as the inflated
+ one, is rescaled one hemisphere at a time, axis by axis, into the pack's
+ base surface's bounding box (brainctm.Hemi.addSurf) -- so an inflated
+ brain fills the same box as the pial one, whatever size its file is.
+ """
+ try:
+ pia = cortex.db.get_surf(subject, "pia")
+ wm = cortex.db.get_surf(subject, "wm")
+ base = [hemi[0] for hemi in pia]
+ folded = [(p[0] + w[0]) / 2 for p, w in zip(pia, wm)]
+ except IOError:
+ base = folded = [hemi[0] for hemi in cortex.db.get_surf(subject, "fiducial")]
+
+ if kind == "fiducial":
+ return np.vstack(folded)
+ rescaled = []
+ for (pts, _), box in zip(cortex.db.get_surf(subject, kind), base):
+ low, high = pts.min(0), pts.max(0)
+ rescaled.append((pts - low) / (high - low) * (box.max(0) - box.min(0))
+ + box.min(0))
+ return np.vstack(rescaled)
+
+
+def _fit_view(points: np.ndarray, view: Mapping[str, Any]) -> tuple[list[float], float]:
+ """The target and radius that frame `points` for `view`'s camera angles.
+
+ The target is the middle of the points' bounding box. The radius is the
+ smallest distance from it at which every point projects inside
+ `FRAMING_FILL` of a `FRAMING_ASPECT` frame, through the viewer's
+ perspective camera. For a point p relative to the target, seen from a
+ camera at distance r along `back`, that takes
+ ``r >= p.back + |p.up| / (fill * tan(fov/2))`` vertically and
+ ``r >= p.back + |p.right| / (fill * tan(fov/2) * aspect)`` horizontally.
+ """
+ right, up, back = camera_basis(view["camera.azimuth"], view["camera.altitude"])
+ centre = (points.min(0) + points.max(0)) / 2
+ depth, vertical, horizontal = ((points - centre) @ np.array([back, up, right]).T).T
+ tan = math.tan(math.radians(VIEWER_FOV / 2)) * FRAMING_FILL
+ radius = max((depth + np.abs(vertical) / tan).max(),
+ (depth + np.abs(horizontal) / (tan * FRAMING_ASPECT)).max())
+ return [float(c) for c in centre], float(radius)
+
+
+def _fit_default_views(subject: str, kinds: Mapping[str, str]) -> dict[str, ViewParams]:
+ """Fit every default view but `flat` to `subject`'s surfaces."""
+ points: dict[str, np.ndarray] = {}
+ framing: dict[str, ViewParams] = {}
+ for name, view in default_subject_views(has_flatmap=True).items():
+ if name == FLAT_VIEW_NAME:
+ continue
+ kind = kinds["fiducial" if view["surface.{subject}.unfold"] == 0
+ else "inflated"]
+ if kind not in points:
+ points[kind] = _viewer_points(subject, kind)
+ target, radius = _fit_view(points[kind], view)
+ low, high = RADIUS_LIMITS
+ if not low <= radius <= high:
+ warnings.warn("The %s view of %s needs a camera radius of %.0f, "
+ "outside the viewer's %g-%g; using the nearest"
+ % (name, subject, radius, low, high))
+ radius = min(max(radius, low), high)
+ framing[name] = {"camera.target": target, "camera.radius": radius}
+ return framing
+
+
+def _surface_sources(surfs: Mapping[str, Mapping[str, str]],
+ kind: str) -> dict[str, Mapping[str, str]]:
+ """The files `_viewer_points` reads for `kind`, as ``{name: {hemi: path}}``.
+
+ The folded brain comes from the pial and white-matter surfaces when there
+ are both, and from the fiducial surface otherwise; anything else is its own
+ file, rescaled into the folded brain's box. These decide whether a cached
+ framing is still current. Raises KeyError if a file is missing.
+ """
+ folded = ({"pia": surfs["pia"], "wm": surfs["wm"]}
+ if "pia" in surfs and "wm" in surfs else {"fiducial": surfs["fiducial"]})
+ if kind == "fiducial":
+ return folded
+ return {**folded, kind: surfs[kind]}
+
+
+def default_view_framing(subject: str) -> dict[str, ViewParams]:
+ """The camera target and radius that frame each default view of `subject`.
+
+ Each view but `flat` aims at the middle of the surface it shows (fiducial,
+ or inflated) from the distance at which that surface fills `FRAMING_FILL`
+ of a `FRAMING_ASPECT` frame. Fitted to the subject's own surfaces and
+ cached in its cache directory, so only the first viewer opened for a
+ subject reads them; the cache is refitted when a surface file changes, or
+ when the framing constants do. A cache directory that cannot be written
+ just means no caching.
+
+ Returns
+ -------
+ dict
+ ``{view_name: {"camera.target": [x, y, z], "camera.radius": r}}``. Empty,
+ with a warning, if the subject's surfaces cannot be read -- the views
+ then keep whatever zoom the viewer has, as they did before.
+ """
+ try:
+ surfs = cortex.db.get_paths(subject)["surfs"]
+ # A subject without an inflated surface shows its inflated views on
+ # the fiducial one.
+ kinds = {"fiducial": "fiducial",
+ "inflated": "inflated" if "inflated" in surfs else "fiducial"}
+ depends = {
+ "version": _FRAMING_VERSION, "fov": VIEWER_FOV,
+ "aspect": FRAMING_ASPECT, "fill": FRAMING_FILL,
+ "surfaces": {kind: {source: {hemi: os.path.getmtime(path)
+ for hemi, path in sorted(paths.items())}
+ for source, paths in
+ _surface_sources(surfs, kind).items()}
+ for kind in sorted(set(kinds.values()))},
+ }
+ except Exception as err:
+ warnings.warn("Cannot frame the default views of %s: %s" % (subject, err))
+ return {}
+
+ try:
+ cache: Optional[str] = os.path.join(cortex.db.get_cache(subject),
+ _FRAMING_CACHE)
+ except OSError:
+ cache = None
+
+ if cache is not None and os.path.exists(cache):
+ try:
+ with open(cache) as fp:
+ stored = json.load(fp)
+ if stored.get("depends") == depends:
+ return stored["framing"]
+ except (OSError, ValueError, KeyError, AttributeError):
+ pass # unreadable: refit and overwrite
+
+ try:
+ framing = _fit_default_views(subject, kinds)
+ except Exception as err:
+ warnings.warn("Cannot frame the default views of %s: %s" % (subject, err))
+ return {}
+
+ if cache is not None:
+ try:
+ with open(cache, "w") as fp:
+ json.dump({"depends": depends, "framing": framing}, fp)
+ except OSError:
+ pass # e.g. a read-only shared filestore
+ return framing
diff --git a/cortex/tests/test_default_views.py b/cortex/tests/test_default_views.py
new file mode 100644
index 000000000..311a649e7
--- /dev/null
+++ b/cortex/tests/test_default_views.py
@@ -0,0 +1,488 @@
+"""Tests for the views every subject gets, and the quickflat size hint.
+
+The camera geometry is asserted rather than described: `default_subject_views`
+names its four orientations anatomically ("dorsal", "lateral_left", ...), and
+nothing else in the codebase checks that those names match what the viewer
+actually shows. The helpers below reproduce the viewer's own camera arithmetic
+so the claim is testable without a browser.
+"""
+
+import json
+import math
+
+import pytest
+
+from cortex.export.save_views import (
+ DEFAULT_VIEW_ANGLES,
+ FLAT_INERT_PROPS,
+ FLAT_VIEW_NAME,
+ INFLATED_SUFFIX,
+ angle_view_params,
+ camera_basis,
+ default_subject_views,
+)
+
+# Surface RAS, which is what pycortex surfaces are in.
+RIGHT = (1.0, 0.0, 0.0)
+ANTERIOR = (0.0, 1.0, 0.0)
+SUPERIOR = (0.0, 0.0, 1.0)
+
+def points_along(vector, axis, tol=1e-3):
+ """Whether `vector` points the same way as the unit `axis`."""
+ dot = sum(a * b for a, b in zip(vector, axis))
+ return dot > 1 - tol
+
+
+def basis_of(view):
+ return camera_basis(view["camera.azimuth"], view["camera.altitude"])
+
+
+# ---------------------------------------------------------------------------
+# The set itself
+# ---------------------------------------------------------------------------
+
+
+def test_every_subject_gets_nine_views():
+ views = default_subject_views(has_flatmap=True)
+ expected = set(DEFAULT_VIEW_ANGLES)
+ expected |= {name + INFLATED_SUFFIX for name in DEFAULT_VIEW_ANGLES}
+ expected.add(FLAT_VIEW_NAME)
+ assert set(views) == expected
+ assert len(views) == 9
+
+
+def test_the_named_orientations_are_the_ones_asked_for():
+ assert set(DEFAULT_VIEW_ANGLES) == {
+ "dorsal", "ventral", "lateral_left", "lateral_right"}
+
+
+@pytest.mark.parametrize("name", list(DEFAULT_VIEW_ANGLES))
+def test_the_plain_views_are_on_the_fiducial_surface(name):
+ """"...and inflated" is a separate view, so these must not be inflated."""
+ views = default_subject_views(has_flatmap=True)
+ assert views[name]["surface.{subject}.unfold"] == 0
+
+
+@pytest.mark.parametrize("name", list(DEFAULT_VIEW_ANGLES))
+def test_each_view_has_an_inflated_twin_at_the_same_angle(name):
+ views = default_subject_views(has_flatmap=True)
+ plain, inflated = views[name], views[name + INFLATED_SUFFIX]
+ assert inflated["surface.{subject}.unfold"] == 0.5
+ assert inflated["camera.azimuth"] == plain["camera.azimuth"]
+ assert inflated["camera.altitude"] == plain["camera.altitude"]
+
+
+def test_without_a_flatmap_there_is_no_flat_view():
+ views = default_subject_views(has_flatmap=False)
+ assert FLAT_VIEW_NAME not in views
+ assert len(views) == 8
+
+
+def test_without_a_flatmap_inflated_is_fully_unfolded():
+ """Without a flat surface the inflated surface sits at unfold 1, not 0.5.
+
+ The same correction save_3d_views makes.
+ """
+ views = default_subject_views(has_flatmap=False)
+ for name in DEFAULT_VIEW_ANGLES:
+ assert views[name + INFLATED_SUFFIX]["surface.{subject}.unfold"] == 1
+ assert views[name]["surface.{subject}.unfold"] == 0
+
+
+def test_the_flat_view_is_fully_unfolded_and_pivoted():
+ view = default_subject_views(has_flatmap=True)[FLAT_VIEW_NAME]
+ assert view["surface.{subject}.unfold"] == 1
+ assert view == {**view, **angle_view_params["flatmap"]}
+
+
+def test_the_flat_view_carries_no_camera_angle():
+ """A flattened surface pins the camera square-on and ignores the angle.
+
+ Naming one would do nothing to the view itself while giving an animation
+ something spurious to interpolate towards on the way in -- spinning the
+ brain as it flattens, and overwriting the folded angle it would return to.
+ """
+ view = default_subject_views(has_flatmap=True)[FLAT_VIEW_NAME]
+ assert not set(FLAT_INERT_PROPS) & set(view), view
+ # Not in the table it is built from either, so save_3d_views does not ask
+ # for one when it renders a flatmap.
+ assert not set(FLAT_INERT_PROPS) & set(angle_view_params["flatmap"])
+ # Every other view still names its angle.
+ for name in DEFAULT_VIEW_ANGLES:
+ assert set(FLAT_INERT_PROPS) <= set(default_subject_views(True)[name])
+
+
+def test_the_flat_view_names_no_target():
+ """It says nothing about the folded pose, and the viewer centres the flatmap.
+
+ default_view_params' origin is a folded target; carried by a flat view with
+ no camera.flat_target it would be read as the flat target, and put the
+ flatmap back where it used to sit, sixty units low.
+ """
+ view = default_subject_views(has_flatmap=True)[FLAT_VIEW_NAME]
+ assert "camera.target" not in view
+ assert "camera.flat_target" not in view
+
+
+def test_views_keep_the_subject_placeholder():
+ """So one view still applies in a viewer showing several subjects."""
+ for view in default_subject_views(has_flatmap=True).values():
+ assert any("{subject}" in key for key in view)
+ for key in view:
+ assert "S1" not in key
+
+
+def test_views_survive_the_trip_to_the_browser():
+ """They are shipped inside viewopts, so they have to be JSON."""
+ views = default_subject_views(has_flatmap=True)
+ assert json.loads(json.dumps(views)) == views
+
+
+def test_callers_cannot_corrupt_the_shared_tables():
+ """default_subject_views must hand out copies, not the module's own dicts."""
+ first = default_subject_views(has_flatmap=True)
+ first["dorsal"]["camera.azimuth"] = 12345
+ assert default_subject_views(has_flatmap=True)["dorsal"]["camera.azimuth"] != 12345
+ # ... and the tables it is built from are untouched.
+ assert angle_view_params["top"]["camera.azimuth"] == 180
+
+
+# ---------------------------------------------------------------------------
+# Do the anatomical names describe what the camera actually shows?
+# ---------------------------------------------------------------------------
+
+
+def test_dorsal_looks_down_with_the_frontal_lobe_up():
+ right, up, back = basis_of(default_subject_views()["dorsal"])
+ assert points_along(back, SUPERIOR) # camera above the brain
+ assert points_along(up, ANTERIOR) # frontal lobe at the top
+ assert points_along(right, RIGHT) # subject's right on the right
+
+
+def test_ventral_looks_up_with_the_frontal_lobe_up():
+ right, up, back = basis_of(default_subject_views()["ventral"])
+ assert points_along(back, tuple(-c for c in SUPERIOR)) # camera below
+ assert points_along(up, ANTERIOR) # frontal lobe up
+ # Seen from underneath the subject's left falls on the image right, which
+ # is what looking at the underside of something does.
+ assert points_along(right, tuple(-c for c in RIGHT))
+
+
+def test_lateral_left_looks_from_the_left_with_the_brain_upright():
+ right, up, back = basis_of(default_subject_views()["lateral_left"])
+ assert points_along(back, tuple(-c for c in RIGHT)) # camera on the left
+ assert points_along(up, SUPERIOR) # upright
+ # Facing the left side of a head, the nose points to the image left.
+ assert points_along(right, tuple(-c for c in ANTERIOR))
+
+
+def test_lateral_right_looks_from_the_right_with_the_brain_upright():
+ right, up, back = basis_of(default_subject_views()["lateral_right"])
+ assert points_along(back, RIGHT) # camera on the right
+ assert points_along(up, SUPERIOR) # upright
+ assert points_along(right, ANTERIOR) # nose to the right
+
+
+def test_the_two_lateral_views_are_opposite_each_other():
+ _, _, left_back = basis_of(default_subject_views()["lateral_left"])
+ _, _, right_back = basis_of(default_subject_views()["lateral_right"])
+ dot = sum(a * b for a, b in zip(left_back, right_back))
+ assert dot == pytest.approx(-1, abs=1e-6)
+
+
+@pytest.mark.parametrize("name", list(DEFAULT_VIEW_ANGLES))
+def test_inflating_does_not_move_the_camera(name):
+ views = default_subject_views()
+ assert basis_of(views[name]) == basis_of(views[name + INFLATED_SUFFIX])
+
+
+# ---------------------------------------------------------------------------
+# Serving them: defaults, and the filestore overriding them
+# ---------------------------------------------------------------------------
+
+
+def test_quickflat_size_matches_the_flatmask_formula(monkeypatch):
+ """The reported size must be the one quickflat.make_png actually writes.
+
+ make_png resizes the figure to the flatmap image and saves at `dpi`, so the
+ png comes out exactly that image's pixel size: 1024 tall, and as wide as the
+ flat surface's bounding box makes it.
+ """
+ import numpy as np
+
+ from cortex.webgl import view as webgl_view
+
+ # A bounding box 3 units wide and 2 tall, so the image is 1.5x as wide as
+ # it is tall. The offset is there to catch an implementation using max()
+ # where it should use the span.
+ pts = np.array([[10.0, 5.0, 0.0], [13.0, 7.0, 1.0]])
+ monkeypatch.setattr(webgl_view, "_has_flatmap", lambda subject: True)
+ monkeypatch.setattr(webgl_view.db, "get_surf",
+ lambda *a, **k: (pts, None))
+
+ assert webgl_view._quickflat_size("S1", height=1024) == [1536, 1024]
+ assert webgl_view._quickflat_size("S1", height=512) == [768, 512]
+
+
+def test_quickflat_size_is_none_without_a_flat_surface(monkeypatch):
+ """Asked before reading anything, so no warning and no wasted surface load."""
+ from cortex.webgl import view as webgl_view
+
+ def should_not_be_called(*args, **kwargs):
+ raise AssertionError("get_surf must not be called without a flatmap")
+
+ monkeypatch.setattr(webgl_view, "_has_flatmap", lambda subject: False)
+ monkeypatch.setattr(webgl_view.db, "get_surf", should_not_be_called)
+ assert webgl_view._quickflat_size("S1") is None
+
+
+def test_quickflat_size_warns_if_the_surface_cannot_be_read(monkeypatch):
+ from cortex.webgl import view as webgl_view
+
+ def no_surface(*args, **kwargs):
+ raise ValueError("unreadable")
+
+ monkeypatch.setattr(webgl_view, "_has_flatmap", lambda subject: True)
+ monkeypatch.setattr(webgl_view.db, "get_surf", no_surface)
+ with pytest.warns(UserWarning, match="quickflat size"):
+ assert webgl_view._quickflat_size("S1") is None
+
+
+def test_a_subject_with_no_views_directory_still_gets_the_defaults(monkeypatch,
+ tmp_path):
+ from cortex.webgl import view as webgl_view
+
+ monkeypatch.setattr(webgl_view.db, "filestore", str(tmp_path))
+ monkeypatch.setattr(webgl_view, "_has_flatmap", lambda subject: True)
+
+ loaded = webgl_view._load_saved_views(["S1"])
+ assert set(loaded) == {"S1"}
+ assert loaded["S1"] == default_subject_views(has_flatmap=True)
+
+
+def test_a_saved_view_replaces_the_default_of_the_same_name(monkeypatch,
+ tmp_path):
+ """The override the whole arrangement exists for."""
+ from cortex.webgl import view as webgl_view
+
+ viewdir = tmp_path / "S1" / "views"
+ viewdir.mkdir(parents=True)
+ mine = {"camera.azimuth": 33.0, "camera.altitude": 44.0,
+ "surface.{subject}.unfold": 0.25}
+ (viewdir / "dorsal.json").write_text(json.dumps(mine))
+ (viewdir / "my_own_view.json").write_text(json.dumps(mine))
+
+ monkeypatch.setattr(webgl_view.db, "filestore", str(tmp_path))
+ monkeypatch.setattr(webgl_view, "_has_flatmap", lambda subject: True)
+ loaded = webgl_view._load_saved_views(["S1"])["S1"]
+
+ # The saved one wins outright; it is not merged with the default.
+ assert loaded["dorsal"] == mine
+ # Every other default survives, and unrelated saved views are still added.
+ defaults = default_subject_views(has_flatmap=True)
+ for name in defaults:
+ if name != "dorsal":
+ assert loaded[name] == defaults[name]
+ assert loaded["my_own_view"] == mine
+ assert set(loaded) == set(defaults) | {"my_own_view"}
+
+
+def test_defaults_are_per_subject(monkeypatch, tmp_path):
+ """Overriding a view for one subject must not touch another's."""
+ from cortex.webgl import view as webgl_view
+
+ viewdir = tmp_path / "S1" / "views"
+ viewdir.mkdir(parents=True)
+ mine = {"camera.azimuth": 33.0}
+ (viewdir / "ventral.json").write_text(json.dumps(mine))
+
+ monkeypatch.setattr(webgl_view.db, "filestore", str(tmp_path))
+ monkeypatch.setattr(webgl_view, "_has_flatmap", lambda subject: True)
+ loaded = webgl_view._load_saved_views(["S1", "S2"])
+
+ assert loaded["S1"]["ventral"] == mine
+ assert loaded["S2"]["ventral"] == default_subject_views()["ventral"]
+
+
+# ---------------------------------------------------------------------------
+# Framing the default views for a subject
+# ---------------------------------------------------------------------------
+
+
+def _surface_points(kind):
+ """The surface `kind` as the viewer draws it."""
+ from cortex.export.save_views import _viewer_points
+
+ return _viewer_points("S1", kind)
+
+
+def test_framing_sees_the_surfaces_the_viewer_draws():
+ """The surfaces are fitted as the surface packs lay them out.
+
+ Checked against brainctm itself, which builds the surface packs the
+ viewer loads: framing the raw inflated file instead leaves the inflated
+ views at about half the size they should be.
+ """
+ import numpy as np
+
+ from cortex.brainctm import BrainCTM
+
+ pack = BrainCTM("S1")
+ pack.addSurf("inflated")
+ hemis = (pack.left, pack.right)
+ drawn = np.vstack([hemi.surfs["inflated"][:, :3] for hemi in hemis])
+ assert _surface_points("inflated") == pytest.approx(drawn, abs=1e-3)
+ # S1 is packed on its pial surface with the white matter alongside; the
+ # folded brain is drawn at their midpoint (the depth slider's default).
+ folded = np.vstack([(hemi.pts + hemi.surfs["wm"][:, :3]) / 2 for hemi in hemis])
+ assert _surface_points("fiducial") == pytest.approx(folded, abs=1e-3)
+
+
+def _ndc(points, view, target, radius):
+ """Where each point lands in the frame, in units of the half-frame.
+
+ Through the viewer's perspective camera, for a frame FRAMING_ASPECT wide:
+ +-1 is the frame's edge on each axis.
+ """
+ import numpy as np
+
+ from cortex.export import save_views
+
+ right, up, back = (np.array(v) for v in basis_of(view))
+ rel = points - np.asarray(target)
+ depth = radius - rel @ back
+ tan = math.tan(math.radians(save_views.VIEWER_FOV / 2))
+ return (rel @ right) / (depth * tan * save_views.FRAMING_ASPECT), \
+ (rel @ up) / (depth * tan)
+
+
+@pytest.fixture
+def framing_cache(tmp_path, monkeypatch):
+ """Keep each test's framing cache to itself, out of the S1 filestore."""
+ import cortex
+
+ monkeypatch.setattr(cortex.db, "get_cache", lambda subject: str(tmp_path))
+ return tmp_path
+
+
+def _counting_get_surf(monkeypatch):
+ """Wrap db.get_surf so a test can see whether surfaces were read."""
+ import cortex
+
+ calls = []
+ real = cortex.db.get_surf
+
+ def counted(*args, **kwargs):
+ calls.append(args)
+ return real(*args, **kwargs)
+
+ monkeypatch.setattr(cortex.db, "get_surf", counted)
+ return calls
+
+
+def test_framing_covers_every_view_but_flat(framing_cache):
+ from cortex.export.save_views import RADIUS_LIMITS, default_view_framing
+
+ framing = default_view_framing("S1")
+ views = default_subject_views(has_flatmap=True)
+ assert set(framing) == set(views) - {FLAT_VIEW_NAME}
+ for frame in framing.values():
+ assert set(frame) == {"camera.target", "camera.radius"}
+ assert RADIUS_LIMITS[0] <= frame["camera.radius"] <= RADIUS_LIMITS[1]
+
+
+def test_framing_aims_at_the_middle_of_the_surface_shown(framing_cache):
+ import numpy as np
+
+ from cortex.export.save_views import default_view_framing
+
+ framing = default_view_framing("S1")
+ for kind, names in (("fiducial", DEFAULT_VIEW_ANGLES),
+ ("inflated", [n + INFLATED_SUFFIX for n in DEFAULT_VIEW_ANGLES])):
+ points = _surface_points(kind)
+ centre = (points.min(0) + points.max(0)) / 2
+ for name in names:
+ assert framing[name]["camera.target"] == pytest.approx(centre.tolist())
+
+
+def test_framing_fills_the_frame_and_clips_nothing(framing_cache):
+ """The brain reaches exactly FRAMING_FILL of the half-frame, and no further."""
+ import numpy as np
+
+ from cortex.export.save_views import FRAMING_FILL, default_view_framing
+
+ framing = default_view_framing("S1")
+ views = default_subject_views(has_flatmap=True)
+ for name, frame in framing.items():
+ view = views[name]
+ points = _surface_points(
+ "fiducial" if view["surface.{subject}.unfold"] == 0 else "inflated")
+ x, y = _ndc(points, view, frame["camera.target"], frame["camera.radius"])
+ reach = max(np.abs(x).max(), np.abs(y).max())
+ assert reach == pytest.approx(FRAMING_FILL, abs=1e-6), name
+
+
+def test_framing_is_read_from_the_cache(framing_cache, monkeypatch):
+ from cortex.export.save_views import default_view_framing
+
+ first = default_view_framing("S1")
+ assert (framing_cache / "default_view_framing.json").exists()
+
+ calls = _counting_get_surf(monkeypatch)
+ assert default_view_framing("S1") == first
+ assert calls == []
+
+
+def test_framing_is_refitted_when_a_surface_changes(framing_cache, monkeypatch):
+ import os
+
+ from cortex.export.save_views import default_view_framing
+
+ first = default_view_framing("S1")
+ real = os.path.getmtime
+ monkeypatch.setattr(os.path, "getmtime", lambda path: real(path) + 1)
+ calls = _counting_get_surf(monkeypatch)
+ assert default_view_framing("S1") == first
+ assert calls != []
+
+
+def test_framing_is_refitted_when_the_framing_changes(framing_cache, monkeypatch):
+ from cortex.export import save_views
+
+ first = save_views.default_view_framing("S1")
+ monkeypatch.setattr(save_views, "FRAMING_FILL", save_views.FRAMING_FILL / 2)
+ second = save_views.default_view_framing("S1")
+ for name in first:
+ assert second[name]["camera.radius"] > first[name]["camera.radius"]
+
+
+def test_framing_without_a_writable_cache(tmp_path, monkeypatch):
+ """A read-only filestore just means no caching."""
+ import cortex
+
+ from cortex.export.save_views import default_view_framing
+
+ monkeypatch.setattr(cortex.db, "get_cache",
+ lambda subject: str(tmp_path / "does" / "not" / "exist"))
+ assert len(default_view_framing("S1")) == 8
+
+ def refuse(subject):
+ raise PermissionError("read-only")
+
+ monkeypatch.setattr(cortex.db, "get_cache", refuse)
+ assert len(default_view_framing("S1")) == 8
+
+
+def test_default_views_are_framed_only_for_a_subject(framing_cache):
+ from cortex.export.save_views import default_view_framing
+
+ plain = default_subject_views(has_flatmap=True)
+ assert not any("camera.radius" in view for view in plain.values())
+
+ framed = default_subject_views(has_flatmap=True, subject="S1")
+ framing = default_view_framing("S1")
+ for name, view in framed.items():
+ if name == FLAT_VIEW_NAME:
+ assert "camera.radius" not in view and "camera.target" not in view
+ else:
+ assert view == {**plain[name], **framing[name]}
diff --git a/cortex/tests/test_interpolation.py b/cortex/tests/test_interpolation.py
new file mode 100644
index 000000000..0946c1609
--- /dev/null
+++ b/cortex/tests/test_interpolation.py
@@ -0,0 +1,436 @@
+"""Tests for the keyframe interpolation shared by the viewer and the movie path.
+
+The arithmetic here is duplicated in ``cortex/webgl/resources/js/interpolation.js``
+so that the browser's animation panel and ``JSMixer._get_anim_seq`` produce the
+same trajectories. These tests pin the python half; the two halves are checked
+against each other in ``test_webgl_headless.py``.
+"""
+
+import math
+
+import pytest
+
+from cortex.webgl.interpolation import (
+ Interpolation,
+ Interpolator,
+ Keyframe,
+ build_channels,
+ evaluate,
+ from_values,
+ shortest_step,
+ unwrap_angles,
+ wrap_angle,
+)
+
+# A run with a rise, a fall and a rise, so that every keyframe but the ends is
+# an interior one with neighbours on both sides.
+TIMES = [0.0, 1.0, 2.0, 3.0]
+VALUES = [0.0, 10.0, 4.0, 7.0]
+
+HOLD_OUT = [
+ Interpolation.LinearInHoldOut,
+ Interpolation.BezierInHoldOut,
+ Interpolation.CubicHermiteInHoldOut,
+]
+
+
+def sample(interp, start, stop, count=101):
+ """``count`` evenly spaced samples of ``interp`` over ``[start, stop]``."""
+ step = (stop - start) / (count - 1)
+ return [interp(start + i * step) for i in range(count)]
+
+
+# ---------------------------------------------------------------------------
+# Every mode, at the keyframes themselves
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("mode", list(Interpolation))
+def test_keyframes_are_reproduced_exactly(mode):
+ """Whatever happens in between, a keyframe's own value is returned at its time."""
+ interp = from_values(TIMES, VALUES, mode)
+ for time, value in zip(TIMES, VALUES):
+ assert interp(time) == pytest.approx(value, abs=1e-12)
+
+
+@pytest.mark.parametrize("mode", list(Interpolation))
+def test_values_are_held_outside_the_keyframe_range(mode):
+ interp = from_values(TIMES, VALUES, mode)
+ assert interp(-10.0) == VALUES[0]
+ assert interp(TIMES[-1] + 10.0) == VALUES[-1]
+
+
+@pytest.mark.parametrize("mode", HOLD_OUT)
+def test_hold_out_modes_are_constant_until_the_next_keyframe(mode):
+ """A hold keeps its keyframe's value right up to the following one."""
+ interp = from_values(TIMES, VALUES, mode)
+ for index in range(len(TIMES) - 1):
+ # Stop just short of the next keyframe, which belongs to the next segment.
+ end = TIMES[index + 1] - 1e-9
+ held = sample(interp, TIMES[index], end)
+ assert held == pytest.approx([VALUES[index]] * len(held), abs=1e-9)
+
+
+# ---------------------------------------------------------------------------
+# Over/undershoot
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("mode", [Interpolation.CubicHermite, Interpolation.Bezier])
+@pytest.mark.parametrize("values", [
+ [0.0, 10.0, 4.0, 7.0], # a peak and a dip
+ [0.0, 1.0, 2.0, 3.0], # monotonically increasing
+ [3.0, 2.0, 1.0, 0.0], # monotonically decreasing
+ [5.0, 5.0, 1.0, 1.0], # flat stretches
+])
+def test_smooth_modes_never_leave_the_bracketing_values(mode, values):
+ """The whole point of the automatic tangents: no overshoot anywhere."""
+ interp = from_values(TIMES, values, mode)
+ for index in range(len(TIMES) - 1):
+ low = min(values[index], values[index + 1])
+ high = max(values[index], values[index + 1])
+ seen = sample(interp, TIMES[index], TIMES[index + 1])
+ assert min(seen) >= low - 1e-9
+ assert max(seen) <= high + 1e-9
+
+
+@pytest.mark.parametrize("mode", [Interpolation.LinearInCubicHermiteOut,
+ Interpolation.LinearInBezierOut])
+def test_linear_in_modes_are_allowed_to_overshoot(mode):
+ """These modes carry the entry slope through, so they can and do overshoot.
+
+ Pinned as a test because it is the only thing separating them from the
+ plain smooth modes.
+ """
+ # A steep climb into a long, nearly flat run. Leaving the middle keyframe
+ # along the entry slope has to carry the curve above the final value.
+ # The second segment has to be long: a Bezier stays inside the hull of its
+ # control points, and a short one would keep the handle under the endpoint.
+ interp = from_values([0.0, 1.0, 11.0], [0.0, 10.0, 10.1], mode)
+ assert max(sample(interp, 1.0, 11.0)) > 10.1 + 1e-6
+
+
+# ---------------------------------------------------------------------------
+# Deviations from the reference implementation
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.parametrize("values", [
+ [0.0, 10.0, 4.0, 7.0],
+ [10.0, 2.0, -5.0, -1.0], # descending, where an unsigned offset breaks
+])
+def test_control_points_lie_on_the_keyframe_tangent(values):
+ """Both handles sit on the tangent line through the keyframe.
+
+ The reference implementation takes an unsigned square root for the value
+ offset, which puts the handles of a descending keyframe on the wrong side
+ of it and breaks the C1 continuity the scheme exists to provide.
+ """
+ interp = Interpolator([Keyframe(t, v, Interpolation.Bezier)
+ for t, v in zip(TIMES, values)])
+ for frame in interp.keyframes:
+ for handle in (frame.control_point_1, frame.control_point_2):
+ if handle is None:
+ continue
+ dtime = handle[0] - frame.time
+ if abs(dtime) < 1e-12: # a vertical handle carries no slope
+ continue
+ assert (handle[1] - frame.value) / dtime == pytest.approx(
+ frame.derivative, abs=1e-6)
+
+
+def test_bezier_solves_for_its_parameter():
+ """The parameter is found from the time, not assumed equal to it."""
+ interp = from_values(TIMES, VALUES, Interpolation.Bezier)
+ segment = interp.segments[0]
+ for i in range(101):
+ time = TIMES[0] + (TIMES[1] - TIMES[0]) * i / 100
+ u = segment.parameter_at(time)
+ x = ((1 - u) ** 3 * segment.p1[0] + 3 * (1 - u) ** 2 * u * segment.p2[0]
+ + 3 * (1 - u) * u ** 2 * segment.p3[0] + u ** 3 * segment.p4[0])
+ assert x == pytest.approx(time, abs=1e-9)
+
+
+def test_linear_into_a_smooth_keyframe_enters_along_the_chord():
+ """The combination the reference implementation builds no segment for.
+
+ It must produce a value at all -- the reference desynchronizes its segment
+ and end-time lists here -- and it must leave the first keyframe along the
+ straight line to the second.
+ """
+ interp = Interpolator([
+ Keyframe(0.0, 0.0, Interpolation.Linear),
+ Keyframe(1.0, 10.0, Interpolation.CubicHermite),
+ Keyframe(2.0, 4.0, Interpolation.Bezier),
+ ])
+ chord = (10.0 - 0.0) / (1.0 - 0.0)
+ measured = (interp(1e-6) - interp(0.0)) / 1e-6
+ assert measured == pytest.approx(chord, rel=1e-3)
+ assert interp(0.5) == pytest.approx(6.25, abs=1e-9)
+
+
+def test_a_single_keyframe_is_constant():
+ interp = from_values([5.0], [3.0])
+ assert interp(-1.0) == 3.0
+ assert interp(5.0) == 3.0
+ assert interp(99.0) == 3.0
+
+
+# ---------------------------------------------------------------------------
+# Building the interpolator
+# ---------------------------------------------------------------------------
+
+
+def test_keyframe_order_does_not_matter():
+ forward = from_values(TIMES, VALUES, Interpolation.Bezier)
+ shuffled = from_values([TIMES[i] for i in (2, 0, 3, 1)],
+ [VALUES[i] for i in (2, 0, 3, 1)],
+ Interpolation.Bezier)
+ assert sample(forward, 0.0, 3.0) == pytest.approx(sample(shuffled, 0.0, 3.0))
+
+
+def test_keyframes_sharing_a_time_keep_the_last():
+ """Matching the way the animation panel replaces a keyframe in place."""
+ interp = Interpolator([Keyframe(0.0, 0.0), Keyframe(1.0, 5.0),
+ Keyframe(1.0, 9.0)])
+ assert len(interp) == 2
+ assert interp(1.0) == 9.0
+
+
+def test_an_empty_keyframe_list_is_rejected():
+ with pytest.raises(ValueError):
+ Interpolator([])
+
+
+# ---------------------------------------------------------------------------
+# Angles
+# ---------------------------------------------------------------------------
+
+
+def anim_interp(start, end, t):
+ """``Viewer._animInterp`` from resources/js/mriview.js, for camera.azimuth."""
+ if abs(end - start) >= 180:
+ if start > end:
+ return (start * (1 - t) + (end + 360) * t + 360) % 360
+ return (start * (1 - t) + (end - 360) * t + 360) % 360
+ return start * (1 - t) + end * t
+
+
+@pytest.mark.parametrize("start,end", [
+ (350, 10), (10, 350), (0, 180), (180, 0), (20, 200), (200, 20),
+ (90, 100), (0, 0), (45, 225), (225, 45),
+])
+def test_angle_unwrapping_matches_the_viewer(start, end):
+ """Two keyframes must spin the way the viewer already spins them.
+
+ Half a turn is equally short either way and ``_animInterp`` breaks the tie
+ by travelling against the sign of the raw difference; a symmetric
+ shortest-angle formula would reverse one of those two cases.
+ """
+ unwrapped = unwrap_angles([start, end])
+ for i in range(11):
+ t = i / 10
+ mine = wrap_angle(unwrapped[0] * (1 - t) + unwrapped[1] * t)
+ theirs = anim_interp(start, end, t)
+ # Compare as angles, so 0 and 360 count as equal.
+ assert abs(wrap_angle(mine - theirs + 180) - 180) < 1e-9
+
+
+def test_shortest_step_never_exceeds_half_a_turn():
+ for start in range(0, 360, 7):
+ for end in range(0, 360, 11):
+ assert abs(shortest_step(start, end)) <= 180 + 1e-12
+
+
+def test_azimuth_channel_stays_in_range_across_the_wrap():
+ keyframes = [
+ {"time": 0.0, "camera.azimuth": 300.0},
+ {"time": 1.0, "camera.azimuth": 40.0},
+ {"time": 2.0, "camera.azimuth": 140.0},
+ ]
+ channels = build_channels(keyframes)
+ for i in range(101):
+ azimuth = evaluate(channels, 2.0 * i / 100)["camera.azimuth"]
+ assert 0.0 <= azimuth < 360.0
+
+
+# ---------------------------------------------------------------------------
+# Splitting view dicts into channels
+# ---------------------------------------------------------------------------
+
+
+def channel_keyframes():
+ return [
+ {"time": 0.0, "camera.altitude": 0.0, "camera.target": [0.0, 0.0, 0.0],
+ "surface.S1.layers": 1, "surface.S1.dither": False, "name": "a"},
+ {"time": 1.0, "camera.altitude": 90.0, "camera.target": [10.0, 20.0, 30.0],
+ "surface.S1.layers": 4, "surface.S1.dither": True, "name": "b"},
+ {"time": 2.0, "camera.altitude": 30.0, "camera.target": [5.0, 5.0, 5.0],
+ "surface.S1.layers": 2, "surface.S1.dither": False, "name": "c"},
+ ]
+
+
+def test_bookkeeping_keys_do_not_become_channels():
+ keyframes = channel_keyframes()
+ keyframes[0]["interpolation"] = Interpolation.Linear
+ view = evaluate(build_channels(keyframes), 0.5)
+ assert "time" not in view
+ assert "interpolation" not in view
+ assert "frame" not in view
+
+
+def test_discrete_and_non_numeric_properties_step():
+ """`layers` recompiles shaders, and booleans and strings cannot be blended."""
+ channels = build_channels(channel_keyframes())
+ half = evaluate(channels, 0.5)
+ assert half["surface.S1.layers"] == 1 # not 2.5
+ assert half["surface.S1.dither"] is False
+ assert half["name"] == "a"
+ # ... and they step at the keyframe, not before or after it.
+ assert evaluate(channels, 0.999)["surface.S1.layers"] == 1
+ assert evaluate(channels, 1.0)["surface.S1.layers"] == 4
+
+
+def test_arrays_interpolate_component_by_component():
+ channels = build_channels(channel_keyframes())
+ assert evaluate(channels, 0.5)["camera.target"] == pytest.approx([5.0, 10.0, 15.0])
+ assert evaluate(channels, 0.0)["camera.target"] == pytest.approx([0.0, 0.0, 0.0])
+ assert evaluate(channels, 1.0)["camera.target"] == pytest.approx([10.0, 20.0, 30.0])
+
+
+def test_arrays_of_inconsistent_length_step_instead():
+ keyframes = [
+ {"time": 0.0, "camera.target": [0.0, 0.0, 0.0]},
+ {"time": 1.0, "camera.target": [1.0, 1.0]},
+ ]
+ assert evaluate(build_channels(keyframes), 0.5)["camera.target"] == [0.0, 0.0, 0.0]
+
+
+def test_per_keyframe_modes_are_honoured():
+ """A hold on the first keyframe freezes only its own segment."""
+ keyframes = [
+ {"time": 0.0, "v": 0.0, "interpolation": Interpolation.BezierInHoldOut},
+ {"time": 1.0, "v": 10.0, "interpolation": Interpolation.Linear},
+ {"time": 2.0, "v": 20.0, "interpolation": Interpolation.Linear},
+ ]
+ channels = build_channels(keyframes)
+ assert evaluate(channels, 0.5)["v"] == 0.0 # held
+ assert evaluate(channels, 1.5)["v"] == pytest.approx(15.0) # linear
+
+
+def test_modes_may_be_plain_strings():
+ """Keyframes arriving from the browser carry the mode as JSON text."""
+ keyframes = [{"time": 0.0, "v": 0.0, "interpolation": "LinearInHoldOut"},
+ {"time": 1.0, "v": 10.0, "interpolation": "Linear"}]
+ assert evaluate(build_channels(keyframes), 0.5)["v"] == 0.0
+
+
+def test_an_unknown_mode_falls_back_to_the_default():
+ keyframes = [{"time": 0.0, "v": 0.0, "interpolation": "NoSuchMode"},
+ {"time": 1.0, "v": 10.0}]
+ channels = build_channels(keyframes, default_mode=Interpolation.Linear)
+ assert evaluate(channels, 0.5)["v"] == pytest.approx(5.0)
+
+
+def test_a_property_is_animated_across_the_keyframes_that_carry_it():
+ """A keyframe that leaves a property out does not constrain it.
+
+ Which is what lets a flat keyframe sit in the middle of an animation
+ without touching the camera angle: a flat pose records none, because the
+ flattened surface ignores it.
+ """
+ keyframes = [{"time": 0.0, "a": 0.0, "v": 0.0},
+ {"time": 1.0, "a": 1.0},
+ {"time": 2.0, "a": 2.0, "v": 10.0}]
+ channels = build_channels(keyframes, default_mode=Interpolation.Linear)
+ assert set(channels) == {"a", "v"}
+
+ # v runs straight from its first carrier to its second, as though the
+ # keyframe between them were not there: halfway in time is halfway in
+ # value, which a knot at t=1 would not give.
+ assert evaluate(channels, 1.0)["v"] == pytest.approx(5.0)
+ assert evaluate(channels, 0.5)["v"] == pytest.approx(2.5)
+ # ... while a property every keyframe carries is unaffected.
+ assert evaluate(channels, 0.5)["a"] == pytest.approx(0.5)
+
+
+def test_a_property_only_one_keyframe_carries_is_constant():
+ keyframes = [{"time": 0.0, "a": 0.0},
+ {"time": 1.0, "a": 1.0, "v": 7.0},
+ {"time": 2.0, "a": 2.0}]
+ channels = build_channels(keyframes, default_mode=Interpolation.Linear)
+ assert [evaluate(channels, t)["v"] for t in (0.0, 1.0, 2.0)] == [7.0] * 3
+
+
+def test_a_property_missing_from_the_first_keyframe_still_animates():
+ """It used to be dropped, which silently ignored the later keyframes."""
+ keyframes = [{"time": 0.0, "a": 0.0},
+ {"time": 1.0, "a": 1.0, "b": 2.0},
+ {"time": 2.0, "a": 2.0, "b": 4.0}]
+ channels = build_channels(keyframes, default_mode=Interpolation.Linear)
+ assert set(channels) == {"a", "b"}
+ assert evaluate(channels, 1.5)["b"] == pytest.approx(3.0)
+ # Before its first carrier it holds, the way every channel holds at its ends.
+ assert evaluate(channels, 0.0)["b"] == pytest.approx(2.0)
+
+
+def test_channels_are_indexed_by_a_chosen_time_key():
+ """The browser stores frame numbers under "frame" rather than "time"."""
+ keyframes = [{"frame": 0, "v": 0.0}, {"frame": 10, "v": 10.0}]
+ channels = build_channels(keyframes, time_key="frame",
+ default_mode=Interpolation.Linear)
+ assert evaluate(channels, 5)["v"] == pytest.approx(5.0)
+
+
+def test_nan_and_infinity_step_rather_than_propagating():
+ keyframes = [{"time": 0.0, "v": 1.0}, {"time": 1.0, "v": float("nan")}]
+ value = evaluate(build_channels(keyframes), 0.5)["v"]
+ assert value == 1.0 and not math.isnan(value)
+
+
+# ---------------------------------------------------------------------------
+# The javascript twin
+# ---------------------------------------------------------------------------
+
+
+def test_the_javascript_twin_is_packaged():
+ """setup.py's resources/js/*.js pattern has to actually pick the file up.
+
+ The browser half of this module is a plain file in the webgl resources; if
+ it stops shipping, the animation panel silently falls back to the old
+ linear interpolation rather than failing loudly.
+ """
+ import os
+
+ import cortex.webgl
+
+ path = os.path.join(os.path.dirname(cortex.webgl.__file__),
+ "resources", "js", "interpolation.js")
+ assert os.path.exists(path), path
+
+
+def test_every_mode_is_offered_by_the_javascript_panel():
+ """The dropdown must list all eight, or a mode becomes unreachable.
+
+ Read out of the javascript source rather than a browser, so this runs
+ without playwright; the browser-side check lives in test_webgl_headless.py.
+ """
+ import os
+ import re
+
+ import cortex.webgl
+
+ path = os.path.join(os.path.dirname(cortex.webgl.__file__),
+ "resources", "js", "interpolation.js")
+ with open(path) as handle:
+ source = handle.read()
+
+ order = re.search(r"ip\.MODE_ORDER\s*=\s*\[(.*?)\]", source, re.S)
+ assert order is not None, "MODE_ORDER not found in interpolation.js"
+ listed = set(re.findall(r'"([A-Za-z]+)"', order.group(1)))
+ assert listed == {mode.value for mode in Interpolation}
+
+ labels = re.search(r"ip\.MODE_LABELS\s*=\s*\{(.*?)\n \};", source, re.S)
+ assert labels is not None, "MODE_LABELS not found in interpolation.js"
+ labelled = set(re.findall(r"(\w+):", labels.group(1)))
+ assert labelled == {mode.value for mode in Interpolation}
diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py
index 370275504..afb8764d1 100644
--- a/cortex/tests/test_webgl_headless.py
+++ b/cortex/tests/test_webgl_headless.py
@@ -9,8 +9,10 @@
import json
import os
+import struct
import time
import urllib.request
+import zipfile
import numpy as np
import pytest
@@ -967,3 +969,1839 @@ def capture(handle, name):
"the bumpy flatmap changed the inflated surface; a flatmap-space "
"offset is leaking into the folded surfaces"
)
+
+
+# ---------------------------------------------------------------------------
+# Group 10: Manual visual A/B comparison across all alpha-bearing dataviews
+# ---------------------------------------------------------------------------
+
+
+@pytest.mark.skipif(
+ not os.environ.get("RUN_VISUAL_COMPARISON"),
+ reason="Manual visual comparison; set RUN_VISUAL_COMPARISON=1 to run.",
+)
+def test_visual_comparison_alpha_dataviews(tmp_path):
+ """Render all 6 dataview types via quickshow + webgl, side-by-side.
+
+ Skipped by default — set ``RUN_VISUAL_COMPARISON=1`` to run. Builds a
+ grid where each row is one dataview type (Volume, Vertex, Volume2D,
+ Vertex2D, VolumeRGB, VertexRGB) and the two columns are the matplotlib
+ (``cortex.quickshow``) reference vs the headless WebGL flatmap render.
+ Used as a manual smoke check that the alpha-blend fix
+ (``Package``-side premultiply for VertexRGB + cmap-LUT
+ ``premultiplyAlpha=true`` for the 2D-cmap path) keeps both viewers in
+ visual agreement across every alpha-encoding pattern.
+
+ Plain Volume / Vertex have no native per-element alpha (pycortex's
+ bundled ``*_alpha`` colormaps are all 2D and only apply to the 2D
+ dataview types), so those two rows act as a no-alpha baseline. The
+ other four rows exercise alpha: Volume2D / Vertex2D via the 2D-alpha
+ cmap ``RdBu_r_alpha``, VolumeRGB / VertexRGB via the ``alpha=`` kwarg.
+
+ Renders are intentionally low-resolution (quickshow ``height=256``,
+ webgl ``size=(512, 384)``) so the final composite PNG stays small.
+ Both viewers run with no labels, no ROIs, and curvature underlay on.
+
+ The composite PNG is written under ``tmp_path`` and the absolute path
+ is printed at the end of the test so the file is easy to open.
+ """
+ import matplotlib.pyplot as plt
+
+ import cortex.polyutils
+
+ # ------- Synthesize data and alpha maps (mirrors plot_data_with_alpha.py) -
+
+ # Volumetric
+ zz, yy, xx = np.mgrid[0:31, 0:100, 0:100]
+ data_vol = (xx - 50) / 50.0 # ~ [-1, 1]
+ center = np.array([15, 50, 50])
+ sigma_v = 25.0
+ dist2 = (
+ (zz - center[0]) ** 2 + (yy - center[1]) ** 2 + (xx - center[2]) ** 2
+ )
+ accuracy_vol = np.exp(-dist2 / (2 * sigma_v**2)) # [0, 1] bump
+ red_vol = np.clip(xx / 99.0, 0, 1)
+ green_vol = np.clip(yy / 99.0, 0, 1)
+ blue_vol = np.clip(zz / 30.0, 0, 1)
+
+ # Surface (vertex) — encode by spatial coordinate, not vertex index
+ surfs = [
+ cortex.polyutils.Surface(*d)
+ for d in cortex.db.get_surf(subj, "fiducial")
+ ]
+ num_verts = [s.pts.shape[0] for s in surfs]
+ pts = np.vstack([surfs[0].pts, surfs[1].pts])
+ y_centered = pts[:, 1] - pts[:, 1].mean()
+ data_vtx = y_centered / np.abs(y_centered).max() # [-1, 1]
+ xyz_norm = (pts - pts.min(axis=0)) / (pts.max(axis=0) - pts.min(axis=0))
+
+ def _bump(surf, seed, sigma):
+ d = np.linalg.norm(surf.pts - surf.pts[seed], axis=1)
+ return np.exp(-(d**2) / (2 * sigma**2))
+
+ accuracy_vtx = np.hstack(
+ [
+ _bump(surfs[0], num_verts[0] // 2, sigma=40.0),
+ _bump(surfs[1], num_verts[1] // 2, sigma=40.0),
+ ]
+ )
+
+ # ------- Build the six dataviews ----------------------------------------
+ # Volume / Vertex have no native per-element alpha — pycortex's bundled
+ # `*_alpha` colormaps are all 2D LUTs and only apply to Volume2D /
+ # Vertex2D. So plain Volume / Vertex use a non-alpha cmap (`viridis`)
+ # and serve as the no-alpha baseline; Volume2D / Vertex2D pair data
+ # against accuracy via the 2D-alpha cmap `RdBu_r_alpha`; VolumeRGB /
+ # VertexRGB use the native `alpha=` kwarg.
+
+ cmap_plain = "viridis"
+ cmap_2d = "RdBu_r_alpha"
+
+ dataviews = [
+ (
+ "Volume",
+ cortex.Volume(
+ data_vol, subj, xfmname,
+ cmap=cmap_plain, vmin=-1, vmax=1,
+ ),
+ ),
+ (
+ "Vertex",
+ cortex.Vertex(
+ data_vtx, subj,
+ cmap=cmap_plain, vmin=-1, vmax=1,
+ ),
+ ),
+ (
+ "Volume2D",
+ cortex.Volume2D(
+ data_vol, accuracy_vol, subj, xfmname,
+ cmap=cmap_2d,
+ vmin=-1, vmax=1, vmin2=0, vmax2=1,
+ ),
+ ),
+ (
+ "Vertex2D",
+ cortex.Vertex2D(
+ data_vtx, accuracy_vtx, subj,
+ cmap=cmap_2d,
+ vmin=-1, vmax=1, vmin2=0, vmax2=1,
+ ),
+ ),
+ (
+ "VolumeRGB",
+ cortex.VolumeRGB(
+ cortex.Volume(red_vol, subj, xfmname, vmin=0, vmax=1),
+ cortex.Volume(green_vol, subj, xfmname, vmin=0, vmax=1),
+ cortex.Volume(blue_vol, subj, xfmname, vmin=0, vmax=1),
+ subj, xfmname,
+ alpha=cortex.Volume(accuracy_vol, subj, xfmname, vmin=0, vmax=1),
+ ),
+ ),
+ (
+ "VertexRGB",
+ cortex.VertexRGB(
+ cortex.Vertex(xyz_norm[:, 0], subj, vmin=0, vmax=1),
+ cortex.Vertex(xyz_norm[:, 1], subj, vmin=0, vmax=1),
+ cortex.Vertex(xyz_norm[:, 2], subj, vmin=0, vmax=1),
+ subj,
+ alpha=cortex.Vertex(accuracy_vtx, subj, vmin=0, vmax=1),
+ ),
+ ),
+ ]
+
+ # ------- Render each dataview through both paths ------------------------
+ # Each WebGL render spins up its own headless browser via plot_panels;
+ # six sequential launches × ~15s sleep = ~90s+ end to end. That's fine
+ # for a manual A/B and avoids the broken `addData` path on headless.
+
+ n = len(dataviews)
+ fig, axes = plt.subplots(n, 2, figsize=(7, 2.2 * n))
+
+ flatmap_panel = [
+ {
+ "extent": [0.0, 0.0, 1.0, 1.0],
+ "view": {"angle": "flatmap", "surface": "flatmap"},
+ }
+ ]
+
+ for row, (name, view) in enumerate(dataviews):
+ # quickshow → low-res PNG
+ qs_path = tmp_path / f"qs_{name}.png"
+ qs_fig = cortex.quickshow(
+ view,
+ with_curvature=True,
+ with_rois=False,
+ with_labels=False,
+ with_colorbar=False,
+ with_sulci=False,
+ with_borders=False,
+ height=256,
+ )
+ qs_fig.savefig(qs_path, bbox_inches="tight", pad_inches=0, dpi=80)
+ plt.close(qs_fig)
+
+ # webgl → trimmed flatmap PNG via plot_panels (single flatmap panel)
+ wg_path = str(tmp_path / f"wg_{name}.png")
+ wg_fig = cortex.export.plot_panels(
+ view,
+ panels=flatmap_panel,
+ figsize=(6, 3),
+ windowsize=(512, 384),
+ save_name=wg_path,
+ sleep=10,
+ viewer_params=dict(labels_visible=[], overlays_visible=[]),
+ headless=True,
+ )
+ plt.close(wg_fig)
+
+ ax_qs, ax_wg = axes[row]
+ ax_qs.imshow(plt.imread(qs_path))
+ ax_qs.set_title(f"{name} — quickshow", fontsize=9)
+ ax_qs.axis("off")
+ ax_wg.imshow(plt.imread(wg_path))
+ ax_wg.set_title(f"{name} — webgl (flatmap)", fontsize=9)
+ ax_wg.axis("off")
+
+ fig.suptitle(
+ "Alpha-bearing dataviews: quickshow vs WebGL", fontsize=11,
+ )
+ fig.tight_layout()
+ out_path = tmp_path / "alpha_dataview_comparison.png"
+ fig.savefig(out_path, dpi=100, bbox_inches="tight")
+ plt.close(fig)
+
+ print(f"\nVisual comparison saved to:\n {out_path}\n")
+ assert out_path.exists()
+ assert out_path.stat().st_size > 0
+
+
+
+# ---------------------------------------------------------------------------
+# Group 11: Saved views and the animation GUI
+# ---------------------------------------------------------------------------
+
+
+def _js_attrs(handle, path):
+ """Read a javascript object's properties, with values for the scalar ones.
+
+ ``send(method="get", ...)`` cannot be used for this: for a non-object
+ property, ``Websock.prototype.get`` returns the property *name* rather than
+ its value (that is what makes the "set" method work). ``query`` is the
+ accessor that carries values, and is what ``JSProxy.attrs`` uses.
+ """
+ resp = handle.send(method="query", params=[path])
+ assert isinstance(resp, list) and resp and isinstance(resp[0], dict), resp
+ return resp[0]
+
+
+def _js_run(handle, path, args):
+ """Call a javascript function and return what it gave back.
+
+ ``send`` answers with one entry per connected client, so the value of
+ interest is the first (and, in a headless viewer, only) element.
+ """
+ resp = handle.send(method="run", params=[path, args])
+ assert isinstance(resp, list) and len(resp) == 1, resp
+ return resp[0]
+
+
+def _js_value(handle, path):
+ """Read one scalar javascript property, e.g. viewer.camera.fov."""
+ parent, _, name = path.rpartition(".")
+ entry = _js_attrs(handle, parent)[name]
+ assert len(entry) > 1, f"{path} is not a scalar: {entry}"
+ return entry[1]
+
+
+def test_retrieve_new_views_roundtrip():
+ """Views saved through the GUI come back to python via the handle."""
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ assert handle.retrieve_new_views() == {}
+
+ target = {"camera.azimuth": 90, "camera.altitude": 90}
+ handle._set_view(**target)
+ time.sleep(2)
+
+ # What the "save view" button calls.
+ handle.send(method="run",
+ params=["window.viewer.saveNewView", ["from_gui"]])
+
+ views = handle.retrieve_new_views()
+ assert set(views) == {"from_gui"}
+ saved = views["from_gui"]
+ for key, expected in target.items():
+ assert saved[key] == pytest.approx(expected, abs=1.0)
+
+ # Keys keep the {subject} placeholder, so the view stays interchangeable
+ # with what _capture_view writes and with saved views/*.json files.
+ assert "surface.{subject}.unfold" in saved
+
+ # The javascript capture must be a subset of the python one; otherwise
+ # _set_view would reject keys coming back out of the browser.
+ captured = handle._capture_view()
+ assert set(saved) <= set(captured), set(saved) - set(captured)
+
+ # And it must round-trip back in without complaint.
+ handle._set_view(**saved)
+
+ pageerrors = [e for e in handle._pw_thread.browser_errors if "[pageerror]" in e]
+ assert len(pageerrors) == 0, f"JS errors: {pageerrors}"
+
+
+def test_save_new_views_writes_to_the_filestore():
+ """save_new_views stores GUI views and promotes them out of new_views."""
+ viewdir = os.path.join(cortex.db.filestore, subj, "views")
+ names = ["_pytest_saved_a", "_pytest saved b"]
+ written = []
+
+ try:
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ handle._set_view(**{"camera.azimuth": 90})
+ time.sleep(2)
+ for name in names:
+ handle.send(method="run",
+ params=["window.viewer.saveNewView", [name]])
+ assert set(handle.retrieve_new_views()) == set(names)
+
+ paths = handle.save_new_views()
+ written = list(paths.values())
+ assert set(paths) == set(names)
+
+ for name in names:
+ path = os.path.join(viewdir, name + ".json")
+ assert paths[name] == path
+ assert os.path.isfile(path)
+ with open(path) as fp:
+ stored = json.load(fp)
+ # Stored in _capture_view's format, so it feeds straight back in.
+ assert "surface.{subject}.unfold" in stored
+ assert stored["camera.azimuth"] == pytest.approx(90, abs=1.0)
+ handle._set_view(**stored)
+
+ # Saved views are no longer "new": they move into the views menu.
+ assert handle.retrieve_new_views() == {}
+ buttons = _js_attrs(
+ handle, "window.viewer.ui._desc.camera._desc.views._desc")
+ for name in names:
+ assert name in buttons
+
+ # A second save is a no-op rather than a rewrite, since there is
+ # nothing left to promote.
+ assert handle.save_new_views() == {}
+
+ # Re-saving under an existing name needs is_overwrite.
+ handle.send(method="run",
+ params=["window.viewer.saveNewView", [names[0]]])
+ with pytest.raises(IOError):
+ handle.save_new_views()
+ assert handle.save_new_views(is_overwrite=True) == {
+ names[0]: os.path.join(viewdir, names[0] + ".json")}
+
+ # A name that would escape the views directory is refused outright.
+ handle.send(method="run",
+ params=["window.viewer.saveNewView", ["../_pytest_evil"]])
+ with pytest.raises(ValueError):
+ handle.save_new_views()
+ assert not os.path.exists(
+ os.path.join(cortex.db.filestore, subj, "_pytest_evil.json"))
+
+ with pytest.raises(KeyError):
+ handle.save_new_views(names=["_pytest_no_such_view"])
+
+ pageerrors = [e for e in handle._pw_thread.browser_errors
+ if "[pageerror]" in e]
+ assert len(pageerrors) == 0, f"JS errors: {pageerrors}"
+ finally:
+ # The S1 filestore is checked in; leave nothing behind.
+ for path in set(written) | {os.path.join(viewdir, n + ".json")
+ for n in names}:
+ if os.path.exists(path):
+ os.remove(path)
+
+
+def test_saved_views_are_loaded_into_the_viewer():
+ """views/*.json for the displayed subject reach the browser and the menu."""
+ from cortex.export.save_views import default_view_params
+
+ viewdir = os.path.join(cortex.db.filestore, subj, "views")
+ os.makedirs(viewdir, exist_ok=True)
+ name = "_pytest_tmp_view"
+ viewfile = os.path.join(viewdir, name + ".json")
+ with open(viewfile, "w") as fp:
+ json.dump(dict(default_view_params), fp)
+
+ try:
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ # Only the displayed subject's views are shipped to the browser.
+ assert set(_js_attrs(handle, "window.viewopts.saved_views")) == {subj}
+ assert name in _js_attrs(
+ handle, "window.viewopts.saved_views.%s" % subj)
+
+ # ... and each one becomes a button under camera > views.
+ buttons = _js_attrs(
+ handle, "window.viewer.ui._desc.camera._desc.views._desc")
+ assert name in buttons
+
+ # Clicking it applies the view.
+ handle._set_view(**{"camera.azimuth": 10})
+ time.sleep(1)
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.views._desc"
+ ".%s.action" % name, []])
+ time.sleep(2)
+ assert handle.ui.get("camera.azimuth")[0] == pytest.approx(
+ default_view_params["camera.azimuth"], abs=1.0)
+ finally:
+ os.remove(viewfile)
+
+
+def _post(url, **fields):
+ """POST form fields, returning the HTTP status (including error statuses)."""
+ import urllib.error
+ import urllib.parse
+
+ data = urllib.parse.urlencode(fields).encode()
+ try:
+ with urllib.request.urlopen(url, data=data, timeout=10) as resp:
+ return resp.status
+ except urllib.error.HTTPError as err:
+ return err.code
+
+
+def test_the_server_accepts_no_frames():
+ """Rendered movies are downloaded by the browser; the server writes nothing.
+
+ The animation panel used to POST each frame to a /movie endpoint that wrote
+ it on the serving machine. That endpoint must stay gone.
+ """
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ url = f"http://localhost:{handle.server.port}/movie"
+ # Refused either way: nothing routes /movie any more, and the server's
+ # catch-all file handler (serve.py) only answers GET, hence 405.
+ assert _post(url, name="f", frame=0, png="x") in (404, 405)
+ assert "movie_post" not in _js_attrs(handle, "window.viewopts")
+
+
+def test_static_viewer_can_render_movies(tmp_path):
+ """A static export carries saved views, and renders without a server.
+
+ Movies are built in the page and downloaded, so the viewer only needs its
+ own scripts -- the zip and mp4 writers ship with it -- and nothing that
+ points back at a python server.
+ """
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ outpath = str(tmp_path / "static")
+ cortex.webgl.make_static(outpath, vol, html_embed=False, copy_ctmfiles=False)
+
+ with open(os.path.join(outpath, "index.html")) as fp:
+ html = fp.read()
+ assert "viewtools.js" in html
+ assert "saved_views" in html
+ # Only the script tags can be checked here: make_static does not copy the
+ # resources tree (see test_static_viewer_ships_the_interpolation_module).
+ # They must load before viewtools.js, which uses them when rendering.
+ for script in ("zipstore.js", "mp4mux.js"):
+ assert script in html
+ assert html.index(script) < html.index("viewtools.js")
+ assert "movie_post" not in html
+
+
+# ---------------------------------------------------------------------------
+# Group 12: Smoothed animation trajectories
+# ---------------------------------------------------------------------------
+
+# Keyframes exercising every kind of channel at once: an angle that crosses the
+# 0/360 wrap, a plain scalar, a vector, a discrete property, a boolean and a
+# string -- and a different interpolation mode on each keyframe.
+SMOOTHING_KEYFRAMES = [
+ {"frame": 0, "interpolation": "Bezier",
+ "camera.azimuth": 300.0, "camera.altitude": 10.0,
+ "camera.target": [0.0, 0.0, 0.0], "surface.S1.layers": 1,
+ "surface.S1.dither": False, "surface.S1.sampler": "nearest"},
+ {"frame": 10, "interpolation": "CubicHermite",
+ "camera.azimuth": 40.0, "camera.altitude": 90.0,
+ "camera.target": [10.0, 20.0, 30.0], "surface.S1.layers": 4,
+ "surface.S1.dither": True, "surface.S1.sampler": "trilinear"},
+ {"frame": 20, "interpolation": "BezierInHoldOut",
+ "camera.azimuth": 140.0, "camera.altitude": 30.0,
+ "camera.target": [5.0, 5.0, 5.0], "surface.S1.layers": 2,
+ "surface.S1.dither": False, "surface.S1.sampler": "nearest"},
+ {"frame": 30, "interpolation": "Linear",
+ "camera.azimuth": 200.0, "camera.altitude": 55.0,
+ "camera.target": [1.0, 2.0, 3.0], "surface.S1.layers": 3,
+ "surface.S1.dither": True, "surface.S1.sampler": "trilinear"},
+]
+
+
+def _assert_views_match(expected, actual, tol=1e-6):
+ """Compare two view dicts property by property."""
+ assert set(expected) == set(actual), set(expected) ^ set(actual)
+ for prop, want in expected.items():
+ got = actual[prop]
+ if isinstance(want, list):
+ assert got == pytest.approx(want, abs=tol), prop
+ elif isinstance(want, bool) or not isinstance(want, (int, float)):
+ assert got == want, prop
+ else:
+ assert got == pytest.approx(want, abs=tol), prop
+
+
+def test_interpolation_js_is_loaded_with_all_eight_modes():
+ """The browser knows the same modes python does."""
+ from cortex.webgl.interpolation import Interpolation
+
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ modes = _js_attrs(handle, "window.jsplot.interpolation.Interpolation")
+ assert set(modes) == {mode.value for mode in Interpolation}
+ assert _js_value(handle, "window.jsplot.interpolation.DEFAULT_MODE") == \
+ Interpolation.Bezier.value
+
+
+def test_browser_and_python_interpolate_identically():
+ """The whole point of keeping two implementations: they must agree.
+
+ An animation built in the panel is played back in javascript but rendered
+ to disk through _get_anim_seq in python, so any divergence would show up as
+ a movie that does not match its preview.
+ """
+ from cortex.webgl.interpolation import build_channels, evaluate
+
+ frames = [i * 0.5 for i in range(61)]
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ from_js = _js_run(handle, "window.jsplot.viewtools.viewsAt",
+ [SMOOTHING_KEYFRAMES, frames])
+ assert isinstance(from_js, list) and len(from_js) == len(frames), from_js
+
+ channels = build_channels(SMOOTHING_KEYFRAMES, time_key="frame")
+ for frame, js_view in zip(frames, from_js):
+ _assert_views_match(evaluate(channels, frame), js_view)
+
+ pageerrors = [e for e in handle._pw_thread.browser_errors
+ if "[pageerror]" in e]
+ assert len(pageerrors) == 0, f"JS errors: {pageerrors}"
+
+
+def test_animation_panel_defaults_to_bezier():
+ """Opening the panel sets up per-keyframe smoothing state."""
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ # What the "create animation" button calls. The key has a space in it,
+ # which the dotted-path walker in python_interface.js handles fine.
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.create animation.action", []])
+ time.sleep(1)
+
+ state = _js_attrs(handle, "window.viewer._anim")
+ assert "mode" in state, state
+ assert _js_value(handle, "window.viewer._anim.mode") == "Bezier"
+
+
+def test_get_anim_seq_linear_path_is_unchanged():
+ """The pairwise easings still produce exactly what they always did."""
+ keyframes = [
+ {"time": 0.0, "camera.azimuth": 10.0, "camera.altitude": 20.0},
+ {"time": 1.0, "camera.azimuth": 90.0, "camera.altitude": 60.0},
+ {"time": 2.0, "camera.azimuth": 170.0, "camera.altitude": 40.0},
+ ]
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ seq = handle._get_anim_seq([dict(k) for k in keyframes], fps=30,
+ interpolation="linear")
+ # 30 frames per second over two seconds, plus the closing frame.
+ assert len(seq) == 61
+ assert "time" not in seq[0]
+ assert seq[0]["camera.azimuth"] == pytest.approx(10.0)
+ assert seq[30]["camera.azimuth"] == pytest.approx(90.0)
+ assert seq[-1]["camera.azimuth"] == pytest.approx(170.0)
+ # Straight lines between the keyframes: the quarter point is halfway
+ # from the first keyframe to the second.
+ assert seq[15]["camera.azimuth"] == pytest.approx(50.0)
+ assert seq[15]["camera.altitude"] == pytest.approx(40.0)
+
+
+def test_get_anim_seq_all_linear_modes_reproduce_the_legacy_path():
+ """'linear' and a list of Linear keyframes are the same curve.
+
+ This ties the two code paths together: whatever the smoothed path does to
+ frame times and property handling, it has to land on the old answer when
+ every keyframe is linear.
+ """
+ keyframes = [
+ {"time": 0.0, "camera.altitude": 20.0, "camera.target": [0.0, 0.0, 0.0]},
+ {"time": 0.7, "camera.altitude": 60.0, "camera.target": [3.0, 6.0, 9.0]},
+ {"time": 2.0, "camera.altitude": 40.0, "camera.target": [1.0, 1.0, 1.0]},
+ ]
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ legacy = handle._get_anim_seq([dict(k) for k in keyframes], fps=30,
+ interpolation="linear")
+ smoothed = handle._get_anim_seq([dict(k) for k in keyframes], fps=30,
+ interpolation="Linear")
+ assert len(legacy) == len(smoothed)
+ for want, got in zip(legacy, smoothed):
+ _assert_views_match(want, got)
+
+
+def test_get_anim_seq_honours_per_keyframe_modes():
+ """A keyframe's own mode takes over, and selects the smoothed path."""
+ keyframes = [
+ {"time": 0.0, "camera.altitude": 0.0,
+ "interpolation": "BezierInHoldOut"},
+ {"time": 1.0, "camera.altitude": 40.0, "interpolation": "Linear"},
+ {"time": 2.0, "camera.altitude": 80.0, "interpolation": "Linear"},
+ ]
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ # interpolation defaults to 'linear', but the keyframes override it.
+ seq = handle._get_anim_seq([dict(k) for k in keyframes], fps=30)
+ assert len(seq) == 61
+ # Held across the first second...
+ assert seq[15]["camera.altitude"] == pytest.approx(0.0)
+ assert seq[29]["camera.altitude"] == pytest.approx(0.0)
+ # ... then linear to the end.
+ assert seq[30]["camera.altitude"] == pytest.approx(40.0)
+ assert seq[45]["camera.altitude"] == pytest.approx(60.0)
+ assert seq[-1]["camera.altitude"] == pytest.approx(80.0)
+ assert "interpolation" not in seq[0]
+
+
+def test_get_anim_seq_rejects_mixing_an_easing_with_keyframe_modes():
+ """smoothstep eases a segment and has no per-keyframe equivalent."""
+ keyframes = [
+ {"time": 0.0, "camera.altitude": 0.0, "interpolation": "Bezier"},
+ {"time": 1.0, "camera.altitude": 40.0, "interpolation": "Bezier"},
+ ]
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ with pytest.raises(ValueError, match="whole segment"):
+ handle._get_anim_seq([dict(k) for k in keyframes], fps=30,
+ interpolation="smoothstep")
+ with pytest.raises(ValueError, match="Unknown interpolation"):
+ handle._get_anim_seq([dict(k) for k in keyframes], fps=30,
+ interpolation="wobble")
+
+
+def test_static_viewer_ships_the_interpolation_module(tmp_path):
+ """Smoothing is pure browser-side, so static exports get it too.
+
+ Only the script tag is checked here: make_static does not copy the
+ resources tree, it is either inlined by htmlembed or served alongside.
+ """
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ outpath = str(tmp_path / "static")
+ cortex.webgl.make_static(outpath, vol, html_embed=False, copy_ctmfiles=False)
+
+ with open(os.path.join(outpath, "index.html")) as fp:
+ html = fp.read()
+ assert "interpolation.js" in html
+ # It has to come before viewtools.js, which uses it at panel-open time.
+ assert html.index("interpolation.js") < html.index("viewtools.js")
+
+
+# ---------------------------------------------------------------------------
+# Group 13: Default views every subject gets
+# ---------------------------------------------------------------------------
+
+
+def test_default_views_reach_the_browser_and_the_menu():
+ """Every subject gets the standard orientations without saving anything."""
+ from cortex.export.save_views import default_subject_views
+ from cortex.webgl.view import _has_flatmap
+
+ expected = set(default_subject_views(_has_flatmap(subj)))
+ assert "dorsal" in expected and "lateral_left_inflated" in expected
+
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ shipped = _js_attrs(handle, "window.viewopts.saved_views.%s" % subj)
+ assert expected <= set(shipped), expected - set(shipped)
+
+ buttons = _js_attrs(
+ handle, "window.viewer.ui._desc.camera._desc.views._desc")
+ assert expected <= set(buttons), expected - set(buttons)
+
+
+def test_clicking_a_default_view_applies_it():
+ """The buttons are wired, not just present."""
+ from cortex.export.save_views import default_subject_views
+ from cortex.webgl.view import _has_flatmap
+
+ views = default_subject_views(_has_flatmap(subj))
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ # Somewhere that is not the view we are about to ask for.
+ handle._set_view(**{"camera.azimuth": 10, "camera.altitude": 45})
+ time.sleep(1)
+
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.views._desc"
+ ".lateral_left.action", []])
+ time.sleep(2)
+
+ want = views["lateral_left"]
+ assert handle.ui.get("camera.azimuth")[0] == pytest.approx(
+ want["camera.azimuth"], abs=1.0)
+ assert handle.ui.get("camera.altitude")[0] == pytest.approx(
+ want["camera.altitude"], abs=1.0)
+
+
+def test_a_saved_view_overrides_the_default_of_the_same_name():
+ """A views/dorsal.json in the filestore wins over the built-in dorsal."""
+ from cortex.export.save_views import default_subject_views
+
+ viewdir = os.path.join(cortex.db.filestore, subj, "views")
+ os.makedirs(viewdir, exist_ok=True)
+ viewfile = os.path.join(viewdir, "dorsal.json")
+ assert not os.path.exists(viewfile), (
+ "%s already exists; this test would overwrite it" % viewfile)
+
+ builtin = default_subject_views()["dorsal"]
+ mine = dict(default_view_params)
+ mine["camera.azimuth"] = 123.0
+ mine["camera.altitude"] = 47.0
+ assert mine["camera.azimuth"] != builtin["camera.azimuth"]
+
+ with open(viewfile, "w") as fp:
+ json.dump(mine, fp)
+ try:
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ shipped = _js_attrs(
+ handle, "window.viewopts.saved_views.%s.dorsal" % subj)
+ assert "camera.azimuth" in shipped
+
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.views._desc"
+ ".dorsal.action", []])
+ time.sleep(2)
+ assert handle.ui.get("camera.azimuth")[0] == pytest.approx(
+ mine["camera.azimuth"], abs=1.0)
+
+ # The other defaults are untouched by the override.
+ buttons = _js_attrs(
+ handle, "window.viewer.ui._desc.camera._desc.views._desc")
+ assert "ventral" in buttons and "lateral_right" in buttons
+ finally:
+ os.remove(viewfile)
+
+
+def test_quickflat_size_reaches_the_browser():
+ """The animation panel's flat-render hint is computed in python."""
+ from cortex.webgl.view import _quickflat_size
+
+ expected = _quickflat_size(subj)
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ assert subj in _js_attrs(handle, "window.viewopts.quickflat_size")
+ # .slice() hands back a plain array, which survives the JSON round trip
+ # that a bare property read does not.
+ shipped = _js_run(
+ handle, "window.viewopts.quickflat_size.%s.slice" % subj, [])
+ assert shipped == expected
+
+ if expected is not None:
+ width, height = expected
+ assert height == 1024
+ assert width > 0
+
+
+def test_quickflat_size_matches_a_real_quickflat_png(tmp_path):
+ """The hint has to be the size make_png actually writes, not near it."""
+ from PIL import Image
+
+ from cortex.webgl.view import _quickflat_size
+
+ expected = _quickflat_size(subj)
+ if expected is None:
+ pytest.skip("%s has no flat surface" % subj)
+
+ out = str(tmp_path / "flat.png")
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ cortex.quickflat.make_png(out, vol, with_rois=False, with_labels=False,
+ with_colorbar=False)
+ assert list(Image.open(out).size) == expected
+
+
+def _alpha_mask(path):
+ """Boolean mask of the pixels a png actually drew on."""
+ from PIL import Image
+
+ return np.array(Image.open(path).convert("RGBA"))[..., 3] > 0
+
+
+def _mask_bbox(mask):
+ rows, cols = np.where(mask.any(axis=1))[0], np.where(mask.any(axis=0))[0]
+ assert len(rows) and len(cols), "nothing was drawn"
+ return int(cols[0]), int(rows[0]), int(cols[-1]) + 1, int(rows[-1]) + 1
+
+
+def test_fit_flat_view_frames_the_flat_surface():
+ """The flat view looks at the middle of the flatmap, from far enough back.
+
+ Far enough back being the distance at which the camera's field of view
+ spans the flatmap, which is what makes the render fill the frame the way
+ quickflat's image does.
+ """
+ from cortex.webgl.view import _has_flatmap, _quickflat_size
+
+ if not _has_flatmap(subj):
+ pytest.skip("%s has no flat surface" % subj)
+
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ # Clicking the view in the camera > views menu, the way a person does.
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.views._desc.flat.action", []])
+ time.sleep(3)
+
+ box = _js_run(handle, "window.viewer.flatBBox", [])
+ span = [box["max"][i] - box["min"][i] for i in range(3)]
+
+ # The viewer's flatmap and quickflat's are the same surface, so the
+ # frame quickflat writes has the same shape as the flatmap does here.
+ width, height = _quickflat_size(subj)
+ assert span[0] / span[1] == pytest.approx(width / height, rel=1e-3)
+
+ fov = _js_value(handle, "window.viewer.camera.fov")
+ aspect = _js_value(handle, "window.viewer.camera.aspect")
+ view = handle._capture_view()
+ assert view["camera.flat_target"] == pytest.approx(
+ [(box["min"][0] + box["max"][0]) / 2,
+ (box["min"][1] + box["max"][1]) / 2, 0], abs=1e-3)
+ # As large as fits in the frame on screen: filling it top to bottom,
+ # unless that window is narrower than the flatmap, which would crop it.
+ assert view["camera.radius"] == pytest.approx(
+ max(span[1] / 2, span[0] / 2 / aspect) / np.tan(np.radians(fov / 2)),
+ rel=1e-3)
+ assert _js_run(handle, "window.viewer.isFlatFitted", []) is True
+
+ pageerrors = [e for e in handle._pw_thread.browser_errors
+ if "[pageerror]" in e]
+ assert len(pageerrors) == 0, f"JS errors: {pageerrors}"
+
+
+def test_setting_the_flat_view_from_python_leaves_the_camera_alone():
+ """The framing is asked for, never applied behind a caller's back.
+
+ save_3d_views sets the flat view through _set_view and renders it at its
+ own size; re-framing it there would silently change every flatmap that
+ function has ever written.
+ """
+ from cortex.export.save_views import default_subject_views
+ from cortex.webgl.view import _has_flatmap
+
+ if not _has_flatmap(subj):
+ pytest.skip("%s has no flat surface" % subj)
+
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ before = handle._capture_view()
+ handle._set_view(**default_subject_views(True)["flat"])
+ time.sleep(2)
+
+ after = handle._capture_view()
+ assert after["surface.{subject}.unfold"] == 1
+ assert after["camera.radius"] == pytest.approx(before["camera.radius"])
+ assert _js_run(handle, "window.viewer.isFlatFitted", []) is False
+
+ # ... and asking for it does frame the flatmap.
+ framing = handle.fit_flat_view()
+ assert framing is not None
+ time.sleep(1)
+ assert handle._capture_view()["camera.radius"] == pytest.approx(
+ framing["radius"], rel=1e-3)
+
+
+def test_flat_view_renders_what_quickflat_draws(tmp_path):
+ """The point of the framing: a flat frame is the png make_png writes.
+
+ Rendered at the subject's quickflat size, a framed flat view has to put the
+ flatmap where quickflat puts it -- same position, same scale -- or an
+ animation that visits the flat surface does not line up with a flatmap.
+ """
+ from cortex.export.save_views import default_subject_views
+ from cortex.webgl.view import _has_flatmap, _quickflat_size
+
+ if not _has_flatmap(subj):
+ pytest.skip("%s has no flat surface" % subj)
+
+ size = _quickflat_size(subj)
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+
+ quickflat = str(tmp_path / "quickflat.png")
+ cortex.quickflat.make_png(quickflat, vol, with_rois=False,
+ with_labels=False, with_colorbar=False)
+ drawn = _alpha_mask(quickflat)
+ assert list(drawn.shape[::-1]) == size
+
+ rendered = str(tmp_path / "webgl.png")
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ handle._set_view(**default_subject_views(True)["flat"])
+ time.sleep(2)
+ handle.fit_flat_view()
+ time.sleep(1)
+ # The window is not the shape of the image, so this only lines up
+ # because getImage re-frames for what it is about to write.
+ handle.getImage(rendered, size=tuple(size))
+ wait_for_file(rendered, timeout=60)
+ time.sleep(1)
+
+ shot = _alpha_mask(rendered)
+ assert shot.shape == drawn.shape
+
+ # Both fill the frame: quickflat's fills it exactly, and the render is
+ # within a pixel of that (the frame is a whole number of pixels, so its
+ # aspect ratio is quickflat's rounded).
+ x0, y0, x1, y1 = _mask_bbox(shot)
+ assert (x0, y0) == pytest.approx((0, 0), abs=2)
+ assert (x1, y1) == pytest.approx(shot.shape[::-1], abs=2)
+
+ # And they are the same flatmap in the same place, not merely two things
+ # that happen to fill the frame. What is left is edge pixels: the two
+ # rasterize the outline differently, and nothing else may differ.
+ overlap = (shot & drawn).sum() / (shot | drawn).sum()
+ assert overlap > 0.97, "masks overlap by only %.3f" % overlap
+
+
+# ---------------------------------------------------------------------------
+# A flat pose carries no camera angle
+# ---------------------------------------------------------------------------
+
+
+def test_a_flat_pose_records_no_camera_angle():
+ """Both capture paths agree: azimuth and altitude are not part of it."""
+ from cortex.webgl.view import _has_flatmap
+
+ if not _has_flatmap(subj):
+ pytest.skip("%s has no flat surface" % subj)
+
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ folded = handle._capture_view()
+ assert "camera.azimuth" in folded and "camera.altitude" in folded
+
+ handle._set_view(**{"surface.{subject}.unfold": 1})
+ time.sleep(2)
+
+ flat = handle._capture_view()
+ assert "camera.azimuth" not in flat, flat
+ assert "camera.altitude" not in flat, flat
+
+ # The browser's own capture (vt.captureView, behind "save view") drops
+ # them too, or a view saved in the GUI would carry what python's does
+ # not.
+ handle.send(method="run",
+ params=["window.viewer.saveNewView", ["_pytest_flat"]])
+ saved = handle.retrieve_new_views()["_pytest_flat"]
+ assert "camera.azimuth" not in saved, saved
+ assert "camera.altitude" not in saved, saved
+
+ # Tilting the flat surface gives the angle back its meaning, so it is
+ # recorded again.
+ handle._set_view(**{"surface.{subject}.allow_tilt": True})
+ time.sleep(2)
+ tilted = handle._capture_view()
+ assert "camera.azimuth" in tilted, tilted
+ assert "camera.altitude" in tilted, tilted
+
+
+def test_flattening_leaves_the_camera_angle_alone():
+ """An animation into the flat view must not spin the brain on the way.
+
+ The controls discard the angle once flat and write the *folded* one on the
+ way there, so a keyframe carrying an angle both fights the controls' own
+ blend and leaves the folded view rotated once it unfolds again.
+ """
+ from cortex.webgl.view import _has_flatmap
+
+ if not _has_flatmap(subj):
+ pytest.skip("%s has no flat surface" % subj)
+
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ handle._set_view(**{"camera.azimuth": 45, "camera.altitude": 70,
+ "surface.{subject}.unfold": 0})
+ time.sleep(2)
+ folded = handle._capture_view()
+
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.create animation.action", []])
+ time.sleep(1)
+ handle.send(method="run",
+ params=["window.viewer._animPanel.addKeyframe", []])
+
+ handle.send(method="run",
+ params=["window.viewer._animPanel.setFrame", [30]])
+ time.sleep(1)
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.views._desc.flat.action", []])
+ time.sleep(3)
+ handle.send(method="run",
+ params=["window.viewer._animPanel.addKeyframe", []])
+ time.sleep(1)
+
+ keyframes = _js_run(handle, "window.viewer._anim.keyframes.slice", [])
+ assert len(keyframes) == 2
+ assert "camera.azimuth" not in keyframes[1], keyframes[1]
+
+ # Nothing along the way asks for an angle either: the one channel the
+ # animation has for it is fed by the single keyframe that carries one.
+ frames = [f * 1.0 for f in range(0, 31, 5)]
+ views = _js_run(handle, "window.jsplot.viewtools.viewsAt",
+ [keyframes, frames])
+ azimuths = [view["camera.azimuth"] for view in views]
+ assert azimuths == pytest.approx([folded["camera.azimuth"]] * len(frames))
+
+ # Step the animation through the transition, which is where the angle
+ # used to be rewritten, and then unfold by hand: the folded camera has
+ # to be the one we set, not wherever the flat keyframe dragged it.
+ for f in (10, 20, 30):
+ handle.send(method="run",
+ params=["window.viewer._animPanel.setFrame", [f]])
+ time.sleep(0.5)
+ handle._set_view(**{"surface.{subject}.unfold": 0})
+ time.sleep(2)
+
+ back = handle._capture_view()
+ assert back["camera.azimuth"] == pytest.approx(
+ folded["camera.azimuth"], abs=1.0)
+ assert back["camera.altitude"] == pytest.approx(
+ folded["camera.altitude"], abs=1.0)
+
+ pageerrors = [e for e in handle._pw_thread.browser_errors
+ if "[pageerror]" in e]
+ assert len(pageerrors) == 0, f"JS errors: {pageerrors}"
+
+
+# ---------------------------------------------------------------------------
+# The folded and the flat camera target
+# ---------------------------------------------------------------------------
+
+
+def _flatmap_centre(handle):
+ """The middle of the flatmap, measured while the surface is flat."""
+ box = _js_run(handle, "window.viewer.flatBBox", [])
+ return [(box["min"][0] + box["max"][0]) / 2,
+ (box["min"][1] + box["max"][1]) / 2, 0]
+
+
+def test_flat_target_starts_at_the_flatmap_centre():
+ """Flattening lands centred on the flatmap without anything asking for it.
+
+ The controls used to start the flat target at y = -60, a guess close to
+ S1's flatmap and wrong for other subjects; setting the flat view from
+ python then replaced even that with the origin, putting the flatmap
+ sixty units low.
+ """
+ from cortex.export.save_views import default_subject_views
+ from cortex.webgl.view import _has_flatmap
+
+ if not _has_flatmap(subj):
+ pytest.skip("%s has no flat surface" % subj)
+
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ before = handle._capture_view()
+ assert "camera.flat_target" in before
+
+ handle._set_view(**default_subject_views(True)["flat"])
+ time.sleep(2)
+ centre = _flatmap_centre(handle)
+
+ # Worked out at load, before the surface was ever flat, and right.
+ assert before["camera.flat_target"] == pytest.approx(centre, abs=1e-3)
+ after = handle._capture_view()
+ assert after["camera.flat_target"] == pytest.approx(centre, abs=1e-3)
+ assert _js_run(handle, "window.viewer.controls.setTarget", []) == \
+ pytest.approx(centre, abs=1e-3)
+ # ... and the folded target is where it was.
+ assert after["camera.target"] == pytest.approx(before["camera.target"])
+
+
+def test_flattening_leaves_the_folded_target_alone():
+ """An animation into the flat view must not drag the folded brain with it.
+
+ One camera.target used to stand for two targets, written to whichever
+ matched the unfold state at the moment: every partly unfolded frame of a
+ transition wrote the flat target's values into the folded one, sliding the
+ brain down ahead of the flattening and leaving it displaced afterwards.
+ """
+ from cortex.webgl.view import _has_flatmap
+
+ if not _has_flatmap(subj):
+ pytest.skip("%s has no flat surface" % subj)
+
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.create animation.action", []])
+ time.sleep(1)
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.views._desc.lateral_left.action",
+ []])
+ time.sleep(2)
+ folded = handle._capture_view()["camera.target"]
+ _js_run(handle, "window.viewer._animPanel.addKeyframe", [])
+
+ _js_run(handle, "window.viewer._animPanel.setFrame", [30])
+ time.sleep(1)
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.views._desc.flat.action", []])
+ time.sleep(3)
+ _js_run(handle, "window.viewer._animPanel.addKeyframe", [])
+ centre = _flatmap_centre(handle)
+
+ for frame in (5, 10, 15, 20, 25, 30):
+ _js_run(handle, "window.viewer._animPanel.setFrame", [frame])
+ time.sleep(0.5)
+ view = handle._capture_view()
+ assert view["camera.target"] == pytest.approx(folded, abs=1e-6), frame
+ assert view["camera.flat_target"] == pytest.approx(centre, abs=1e-3), frame
+
+ # Unfolding by hand returns the brain to exactly where it started.
+ handle._set_view(**{"surface.{subject}.unfold": 0})
+ time.sleep(2)
+ assert _js_run(handle, "window.viewer.controls.setTarget", []) == \
+ pytest.approx(folded, abs=1e-6)
+
+ pageerrors = [e for e in handle._pw_thread.browser_errors
+ if "[pageerror]" in e]
+ assert len(pageerrors) == 0, f"JS errors: {pageerrors}"
+
+
+def test_a_flat_view_saved_before_flat_target_still_loads():
+ """A flat view that stores only camera.target means a flat target.
+
+ That is where camera.target went once the surface was flat, so every flat
+ view saved before camera.flat_target existed stores it that way -- and it
+ is what save_3d_views passes with its flatmap, which keeps that function's
+ output exactly as it was.
+ """
+ from cortex.webgl.view import _has_flatmap
+
+ if not _has_flatmap(subj):
+ pytest.skip("%s has no flat surface" % subj)
+
+ legacy = {"surface.{subject}.unfold": 1, "camera.target": [5.0, -40.0, 0.0],
+ "camera.radius": 300.0}
+ viewdir = os.path.join(cortex.db.filestore, subj, "views")
+ os.makedirs(viewdir, exist_ok=True)
+ name = "_pytest_legacy_flat"
+ viewfile = os.path.join(viewdir, name + ".json")
+ with open(viewfile, "w") as fp:
+ json.dump(legacy, fp)
+
+ try:
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ folded = handle._capture_view()["camera.target"]
+
+ # From python ...
+ handle._set_view(**legacy)
+ time.sleep(2)
+ view = handle._capture_view()
+ assert view["camera.flat_target"] == pytest.approx([5, -40, 0])
+ assert view["camera.target"] == pytest.approx(folded)
+
+ # ... and clicked in the views menu, after moving it elsewhere.
+ handle._set_view(**{"camera.flat_target": [0.0, 0.0, 0.0]})
+ time.sleep(1)
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.views._desc"
+ ".%s.action" % name, []])
+ time.sleep(2)
+ view = handle._capture_view()
+ assert view["camera.flat_target"] == pytest.approx([5, -40, 0])
+ assert view["camera.target"] == pytest.approx(folded)
+ finally:
+ os.remove(viewfile)
+
+
+def test_browser_and_python_agree_with_a_flat_keyframe_in_the_middle():
+ """The two interpolators must treat a missing property the same way."""
+ from cortex.webgl.interpolation import build_channels, evaluate
+
+ keyframes = [
+ {"frame": 0, "camera.azimuth": 45.0, "camera.altitude": 70.0,
+ "surface.{subject}.unfold": 0.0, "interpolation": "Bezier"},
+ {"frame": 15, "surface.{subject}.unfold": 1.0,
+ "interpolation": "Bezier"},
+ {"frame": 30, "camera.azimuth": 270.0, "camera.altitude": 40.0,
+ "surface.{subject}.unfold": 0.0, "interpolation": "Bezier"},
+ ]
+ frames = [i * 1.5 for i in range(21)]
+
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ from_js = _js_run(handle, "window.jsplot.viewtools.viewsAt",
+ [keyframes, frames])
+ assert len(from_js) == len(frames)
+
+ channels = build_channels(keyframes, time_key="frame")
+ for frame, js_view in zip(frames, from_js):
+ _assert_views_match(evaluate(channels, frame), js_view)
+
+ # The flat keyframe is not a knot on the angle's curve: it sweeps
+ # across the whole animation rather than pausing in the middle.
+ mid = from_js[len(from_js) // 2]["camera.azimuth"]
+ assert mid != pytest.approx(45.0) and mid != pytest.approx(270.0)
+
+
+# ---------------------------------------------------------------------------
+# The animation panel's "match quickflat size" option
+# ---------------------------------------------------------------------------
+
+
+def test_the_panel_leaves_the_render_size_alone_until_asked():
+ """The quickflat size is offered, not imposed."""
+ from cortex.webgl.view import _has_flatmap, _quickflat_size
+
+ if not _has_flatmap(subj):
+ pytest.skip("%s has no flat surface" % subj)
+
+ size = _quickflat_size(subj)
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+ with cortex.export.headless_viewer(vol, viewer_params={}) as handle:
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.create animation.action", []])
+ time.sleep(1)
+
+ # Lay down a flat keyframe, the case the option is about.
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.views._desc.flat.action", []])
+ time.sleep(3)
+ handle.send(method="run",
+ params=["window.viewer._animPanel.addKeyframe", []])
+ time.sleep(1)
+
+ assert _js_run(handle, "window.viewer._animPanel.matchesFlat", []) is False
+ default_size = _js_run(handle, "window.viewer._animPanel.renderSize", [])
+ assert default_size != size
+
+ # Ticking the box takes over the size and re-frames the keyframe that
+ # is already down, which was framed for the untouched default.
+ before = _js_run(handle, "window.viewer._anim.keyframes.slice", [])[0]
+ assert _js_run(handle, "window.viewer._animPanel.setMatchFlat",
+ [True]) is True
+ time.sleep(2)
+
+ assert _js_run(handle, "window.viewer._animPanel.renderSize", []) == size
+ after = _js_run(handle, "window.viewer._anim.keyframes.slice", [])[0]
+ assert after["camera.radius"] != pytest.approx(before["camera.radius"])
+
+ framing = _js_run(handle, "window.viewer.flatFraming",
+ [size[0] / size[1]])
+ assert after["camera.radius"] == pytest.approx(framing["radius"],
+ rel=1e-3)
+
+ pageerrors = [e for e in handle._pw_thread.browser_errors
+ if "[pageerror]" in e]
+ assert len(pageerrors) == 0, f"JS errors: {pageerrors}"
+
+
+def test_rendering_an_animation_reproduces_the_quickflat_png(tmp_path):
+ """End to end: with the box ticked, a flat keyframe renders as the flatmap.
+
+ The panel picks the render size, the framing follows it, and the frame the
+ browser downloads has to be the png make_png writes -- that is what lets a
+ flat frame of an animation be cut against a flatmap made in python.
+ """
+ from cortex.webgl.view import _has_flatmap, _quickflat_size
+
+ if not _has_flatmap(subj):
+ pytest.skip("%s has no flat surface" % subj)
+
+ size = _quickflat_size(subj)
+ vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname)
+
+ quickflat = str(tmp_path / "quickflat.png")
+ cortex.quickflat.make_png(quickflat, vol, with_rois=False,
+ with_labels=False, with_colorbar=False)
+ drawn = _alpha_mask(quickflat)
+
+ with cortex.export.headless_viewer(
+ vol, viewer_params={}, download_dir=str(tmp_path)) as handle:
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.create animation.action", []])
+ time.sleep(1)
+
+ # Tick "match quickflat size" before laying the keyframe down.
+ handle.send(method="run",
+ params=["window.viewer._animPanel.setMatchFlat", [True]])
+ time.sleep(1)
+
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.views._desc.flat.action", []])
+ time.sleep(3)
+ handle.send(method="run",
+ params=["window.viewer._animPanel.addKeyframe", []])
+ time.sleep(1)
+
+ assert _js_run(handle, "window.viewer._animPanel.renderSize", []) == size
+
+ # One frame is enough, and 31 of them at this size are not free.
+ handle.send(method="set", params=["window.viewer._anim.last", 0])
+ handle.send(method="run",
+ params=["window.viewer._animPanel.render", []])
+
+ movie = handle._pw_thread.wait_for_download(timeout=120)
+
+ pageerrors = [e for e in handle._pw_thread.browser_errors
+ if "[pageerror]" in e]
+ assert len(pageerrors) == 0, f"JS errors: {pageerrors}"
+
+ frame = str(tmp_path / "flat_frame.png")
+ with zipfile.ZipFile(movie) as archive:
+ with open(frame, "wb") as fp:
+ fp.write(archive.read("brainmovie/brainmovie_00000.png"))
+ shot = _alpha_mask(frame)
+ assert list(shot.shape[::-1]) == size
+ overlap = (shot & drawn).sum() / (shot | drawn).sum()
+ assert overlap > 0.97, "masks overlap by only %.3f" % overlap
+
+
+# ---------------------------------------------------------------------------
+# Rendering movies to a download
+# ---------------------------------------------------------------------------
+
+
+def _render_setup(handle, last=2, size=(320, 240)):
+ """An animation of `last` + 1 frames, turning the brain, at a small size."""
+ handle.send(method="run", params=[
+ "window.viewer.ui._desc.camera._desc.create animation.action", []])
+ time.sleep(1)
+ handle._set_view(**{"camera.azimuth": 45})
+ time.sleep(1)
+ _js_run(handle, "window.viewer._animPanel.addKeyframe", [])
+ _js_run(handle, "window.viewer._animPanel.setFrame", [last])
+ handle._set_view(**{"camera.azimuth": 135})
+ time.sleep(1)
+ _js_run(handle, "window.viewer._animPanel.addKeyframe", [])
+ handle.send(method="set", params=["window.viewer._anim.last", last])
+ assert _js_run(handle, "window.viewer._animPanel.setRenderSize",
+ list(size)) == list(size)
+
+
+def _panel_status(handle):
+ """What the animation panel's status line says."""
+ return _js_run(handle, "window.viewer._animPanel._statusText", [])
+
+
+def _mp4_boxes(data, start=0, end=None):
+ """The box tree of an MP4, as a list of (type, payload) with containers expanded."""
+ containers = {b"moov", b"trak", b"mdia", b"minf", b"stbl", b"dinf"}
+ end = len(data) if end is None else end
+ boxes = []
+ while start < end:
+ size, kind = struct.unpack(">I4s", data[start:start + 8])
+ assert size >= 8, "bad box size %d at %d" % (size, start)
+ payload = data[start + 8:start + size]
+ if kind in containers:
+ boxes.append((kind, _mp4_boxes(data, start + 8, start + size)))
+ else:
+ boxes.append((kind, payload))
+ start += size
+ assert start == end, "boxes overrun their parent"
+ return boxes
+
+
+def _find_box(boxes, *path):
+ for kind, payload in boxes:
+ if kind == path[0]:
+ return payload if len(path) == 1 else _find_box(payload, *path[1:])
+ raise KeyError(b"/".join(path))
+
+
+_PLAYBACK_PAGE = """
+"""
+
+
+def _play_mp4(path):
+ """Load an MP4 into a