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