From 64e4665ba25d1333953dfd400ef3d7ab49a1d3b9 Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Mon, 31 Aug 2026 23:45:26 -0700 Subject: [PATCH 01/14] Add a saved-views menu and animation GUI to the webgl viewer Saving views and building animations previously required driving the viewer from an interactive python session: _capture_view, _set_view and make_movie_views all live on the JSMixer handle. The views/ folder that every subject has in the filestore was invisible from the browser, since db.get_view needs a live viewer handle just to read a JSON file. This moves the whole loop into the viewer, with python needed only to pull results back out and to write PNGs to disk. Saved views show() and make_static() read the views/*.json of the subject(s) the viewer displays -- `subjects` is the list already used to build the CTM packs, so the filestore is never scanned wholesale -- and ship them to the browser inside the existing viewopts dict, which needs no template plumbing and works in static exports too. Each becomes a button under camera > views. save view Captures the current view in javascript, into viewer._new_views. That stays in the browser, separate from the views loaded at startup, until the new JSMixer.retrieve_new_views() asks for it -- so saving a view in the GUI never touches the filestore. The javascript capture mirrors _capture_view, discovering properties from the live menu tree and keeping the literal {subject} placeholder in the keys, so views interchange with the existing python API and with saved view files. create animation A panel with frame/first/last/fps fields, a frame slider marked with a yellow dot per keyframe, and add/clear keyframe, play and render. Scrubbing, playback and rendering all run through one setFrame -> interpolate -> apply path, reusing the viewer's own _animInterp so camera.azimuth still takes the short way around. Values that cannot be blended (booleans, strings, and the discrete `layers`, which recompiles the shaders on assignment) hold their starting value, the same rule _get_anim_seq uses. Rendering posts frames one at a time -- chained, never parallel, since both the webgl readback and the upload are async -- to a new /movie endpoint. It is deliberately separate from MixerHandler.post, which pairs uploads with filenames by queue order that a browser-driven loop cannot keep in step. The server binds all interfaces and serves the page unauthenticated, so the per-session token only keeps unrelated local processes out. The real containment is the new movie_dir argument to show(): the browser sends a path relative to a root python chose (cwd by default) and the handler refuses anything resolving outside it. setup.py needs no change; its resources/js/*.js and *.css globs already cover the new files, and htmlembed inlines them generically. Co-Authored-By: Claude Opus 5 --- cortex/tests/test_webgl_headless.py | 379 +++++++++++++ cortex/webgl/resources/css/mriview.css | 135 ++++- cortex/webgl/resources/js/mriview.js | 5 + cortex/webgl/resources/js/viewtools.js | 739 +++++++++++++++++++++++++ cortex/webgl/template.html | 1 + cortex/webgl/view.py | 164 ++++++ docs/database.rst | 28 +- 7 files changed, 1449 insertions(+), 2 deletions(-) create mode 100644 cortex/webgl/resources/js/viewtools.js diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py index 37cde0f1c..86ae72f23 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -894,3 +894,382 @@ def test_addData_vertex_data(tmp_path): _assert_no_browser_failures(handle) +<<<<<<< HEAD +======= + +# --------------------------------------------------------------------------- +# 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 10: 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_value(handle, path): + """Read one scalar javascript property, e.g. viewopts.movie_post.token.""" + 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_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 + + +# 1x1 transparent png, as the browser would send it +_TINY_PNG = ("data:image/png;base64," + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mP8" + "z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==") + + +def test_movie_handler_rejects_bad_requests(): + """The frame-render endpoint refuses bad tokens, names, and escaping paths.""" + 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" + token = _js_value(handle, "window.viewopts.movie_post.token") + assert isinstance(token, str) and len(token) > 0 + + assert _post(url, token="wrong", name="f", frame=0, png=_TINY_PNG) == 403 + assert _post(url, name="f", frame=0, png=_TINY_PNG) == 403 + assert _post(url, token=token, dir="../..", name="f", frame=0, + png=_TINY_PNG) == 403 + assert _post(url, token=token, dir="/etc", name="f", frame=0, + png=_TINY_PNG) == 403 + assert _post(url, token=token, name="../evil", frame=0, + png=_TINY_PNG) == 400 + assert _post(url, token=token, name="f", frame="nope", + png=_TINY_PNG) == 400 + assert _post(url, token=token, name="f", frame=0, png="garbage") == 400 + + +def test_movie_handler_writes_frames(tmp_path): + """A well-formed request lands as //_.png.""" + vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + with cortex.export.headless_viewer( + vol, viewer_params=dict(movie_dir=str(tmp_path))) as handle: + url = f"http://localhost:{handle.server.port}/movie" + token = _js_value(handle, "window.viewopts.movie_post.token") + assert _js_value(handle, "window.viewopts.movie_post.root") == str( + os.path.realpath(tmp_path)) + + assert _post(url, token=token, dir="frames", name="brainmovie", + frame=7, png=_TINY_PNG) == 200 + + out = tmp_path / "frames" / "brainmovie_00007.png" + assert out.exists() + assert out.stat().st_size > 0 + + +def test_static_viewer_has_views_but_no_render_target(tmp_path): + """A static export carries saved views, but nowhere to write frames.""" + 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 + # No python behind a static viewer, so the animation panel must not offer + # to render frames to disk. + assert "movie_post" not in html diff --git a/cortex/webgl/resources/css/mriview.css b/cortex/webgl/resources/css/mriview.css index 2a08c683a..cc1163f9a 100644 --- a/cortex/webgl/resources/css/mriview.css +++ b/cortex/webgl/resources/css/mriview.css @@ -587,4 +587,137 @@ button#twodbutton { button#twodbutton:disabled, button#twodbutton[disabled] { color: #888; opacity: 0.5; -} \ No newline at end of file +} + +/* Floating panels for saved views and animation (resources/js/viewtools.js). + Chrome deliberately echoes dat.GUI so they sit next to the controls. */ +.pycortex-panel { + position: absolute; + top: 60px; + left: 20px; + width: 290px; + z-index: 300; + background-color: #1a1a1a; + border: 1px solid #2c2c2c; + border-radius: 3px; + color: #eee; + font: 11px 'Lucida Grande', sans-serif; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); +} + +.pycortex-panel-title { + background-color: #000; + padding: 6px 8px; + cursor: move; + font-weight: bold; + border-bottom: 1px solid #2c2c2c; +} + +.pycortex-panel-close { + float: right; + cursor: pointer; + padding: 0 3px; + color: #888; +} + +.pycortex-panel-close:hover { + color: #fff; +} + +.pycortex-panel-body { + padding: 8px; +} + +.pycortex-row { + margin-bottom: 6px; + line-height: 20px; +} + +.pycortex-row label { + display: inline-block; + width: 44px; + color: #999; +} + +.pycortex-row input[type=number], +.pycortex-row input[type=text] { + background-color: #303030; + border: 1px solid #3c3c3c; + border-radius: 2px; + color: #2fa1d6; + padding: 2px 4px; + width: 60px; +} + +.pycortex-row input[type=text] { + width: 190px; +} + +.pycortex-buttons { + text-align: center; +} + +.pycortex-buttons button { + background-color: #303030; + border: 1px solid #3c3c3c; + border-radius: 2px; + color: #eee; + cursor: pointer; + margin: 0 2px; + padding: 4px 8px; +} + +.pycortex-buttons button:hover:enabled { + background-color: #3c3c3c; +} + +.pycortex-buttons button:disabled { + color: #666; + cursor: default; +} + +.pycortex-hint { + color: #777; + font-size: 10px; + margin-bottom: 6px; + word-wrap: break-word; +} + +.pycortex-status { + color: #999; + font-size: 10px; + min-height: 12px; + margin-top: 4px; +} + +/* Frame slider, with a dot marking every frame that has a keyframe. */ +.keyframe-track { + position: relative; + margin: 4px 0 10px 0; + padding-bottom: 8px; +} + +.keyframe-track input[type=range] { + width: 100%; + margin: 0; +} + +.keyframe-ticks { + position: absolute; + /* Inset by half a slider thumb, since the thumb centre does not reach the + ends of the track -- otherwise the first and last dots sit off the mark. */ + left: 7px; + right: 7px; + bottom: 0; + height: 8px; + pointer-events: none; /* dots must not swallow drags on the slider */ +} + +.keyframe-dot { + position: absolute; + width: 6px; + height: 6px; + margin-left: -3px; + border-radius: 50%; + background-color: #ffd700; +} diff --git a/cortex/webgl/resources/js/mriview.js b/cortex/webgl/resources/js/mriview.js index 790bb4135..aaa1cc8f3 100644 --- a/cortex/webgl/resources/js/mriview.js +++ b/cortex/webgl/resources/js/mriview.js @@ -1231,6 +1231,11 @@ var mriview = (function(module) { "Height": {action:[this, 'imageHeight', 500, 4000]} }); + // Saved views and the keyframe animation panel (resources/js/viewtools.js). + // These go in sub-folders/buttons rather than into cam_ui.add directly, so + // they do not show up in JSMixer.view_props as capturable properties. + jsplot.viewtools.installCameraUI(this, cam_ui); + // keyboard shortcut menu var _show_help = false; var helpmenu = function() { diff --git a/cortex/webgl/resources/js/viewtools.js b/cortex/webgl/resources/js/viewtools.js new file mode 100644 index 000000000..ed181ca73 --- /dev/null +++ b/cortex/webgl/resources/js/viewtools.js @@ -0,0 +1,739 @@ +// Saved views and keyframe animation for the webgl viewer. +// +// Adds three things to the "camera" folder of the viewer controls: +// * a "views" sub-folder holding one button per view saved in the subject's +// filestore views/ directory (shipped to the browser in viewopts.saved_views) +// * a "save view" button, which captures the current view into viewer._new_views. +// Those stay in the browser until python asks for them with +// JSMixer.retrieve_new_views(), so that saving a view here does not touch +// the filestore. +// * a "create animation" button, which opens a panel for laying down keyframes, +// playing them back, and rendering one png per frame. +// +// The view dicts produced here use the same keys as JSMixer._capture_view in +// cortex/webgl/view.py -- including the literal "{subject}" placeholder -- so +// they interchange with views saved from python. + +var jsplot = (function (module) { + module.viewtools = (function (vt) { + + // Lighting used to sit directly in the surface menu. Mirrors + // JSMixer._legacy_props, so view files written before the move still load. + var LEGACY_PROPS = { + 'surface.{subject}.specularity': + 'surface.{subject}.lighting.specularity', + 'surface.{subject}.uniform_illumination': + 'surface.{subject}.lighting.uniform_illumination', + }; + + var CURVATURE_PROPS = ['brightness', 'contrast', 'smoothness']; + var LIGHTING_PROPS = ['topleft_lighting', 'uniform_illumination', 'specularity']; + + // Properties that are numeric but discrete: interpolating them produces + // values the setter cannot use. `layers` recompiles the shaders on every + // assignment, so a fractional layer count is both wrong and very slow. + var STEP_PROPS = {'layers': true}; + + var SUBJ = '{subject}'; + + function subst(prop, subject) { + return prop.indexOf(SUBJ) >= 0 ? prop.replace(SUBJ, subject) : prop; + } + + // ------------------------------------------------------------------ + // Reading the menu tree + // ------------------------------------------------------------------ + + // Subjects present in this viewer, as named in the surface menu. + vt.subjects = function(viewer) { + var surface = viewer.ui._desc.surface; + if (surface === undefined || surface._desc === undefined) + return []; + return Object.keys(surface._desc); + }; + + // The settable view properties, discovered from the live menu tree the same + // way JSMixer.view_props does it. + // + // Python walks `_controls`, which also contains the plain buttons (fold, + // reset, inflate, "pial surface", ...); _capture_view then throws on each of + // them and swallows the error. Keeping only array-form actions -- the ones + // backed by a property or a getter/setter -- gives exactly the subset python + // captures successfully, so the two stay interchangeable. + vt.viewProps = function(viewer) { + var props = [], name; + + var camera = viewer.ui._desc.camera; + if (camera !== undefined && camera._desc !== undefined) { + for (name in camera._desc) + if (camera._desc[name].action instanceof Array) + props.push('camera.' + name); + } + + var subjects = vt.subjects(viewer); + if (subjects.length > 0) { + var smenu = viewer.ui._desc.surface._desc[subjects[0]]; + for (name in smenu._desc) + if (smenu._desc[name].action instanceof Array) + props.push('surface.' + SUBJ + '.' + name); + for (var i = 0; i < CURVATURE_PROPS.length; i++) + props.push('surface.' + SUBJ + '.curvature.' + CURVATURE_PROPS[i]); + for (var j = 0; j < LIGHTING_PROPS.length; j++) + props.push('surface.' + SUBJ + '.lighting.' + LIGHTING_PROPS[j]); + } + return props; + }; + + // ------------------------------------------------------------------ + // Capturing and applying views + // ------------------------------------------------------------------ + + // The javascript counterpart of JSMixer._capture_view. With several + // subjects only the first is read, matching python. + vt.captureView = function(viewer) { + var view = {}; + var subjects = vt.subjects(viewer); + var subject = subjects.length > 0 ? subjects[0] : null; + var props = vt.viewProps(viewer); + + for (var i = 0; i < props.length; i++) { + var path = subst(props[i], subject); + try { + var value = viewer.ui.get(path); + if (value !== undefined) + view[props[i]] = value; + } catch (e) { + console.warn("Could not capture " + path + ": " + e.message); + } + } + return view; + }; + + function sameValue(a, b) { + if (a instanceof Array && b instanceof Array) { + if (a.length != b.length) + return false; + for (var i = 0; i < a.length; i++) + if (a[i] !== b[i]) + return false; + return true; + } + return a === b; + } + + // The javascript counterpart of JSMixer._set_view. + // + // `previous`, when given, is the view that is currently applied; properties + // that have not changed are skipped. That matters while animating, because + // some setters (layers, dither, sampler) rebuild the shaders on every call. + vt.applyView = function(viewer, view, previous) { + var prop, key; + + // Copy so the caller's dict is not mutated by the legacy renaming. + var params = {}; + for (prop in view) + params[prop] = view[prop]; + for (key in LEGACY_PROPS) { + if (key in params && !(LEGACY_PROPS[key] in params)) { + params[LEGACY_PROPS[key]] = params[key]; + delete params[key]; + } + } + delete params['frame']; // animation bookkeeping, not a menu path + delete params['time']; // written by _capture_view(frame_time=...) + + var subjects = vt.subjects(viewer); + var unfold = 'surface.' + SUBJ + '.unfold'; + + for (var i = 0; i < subjects.length; i++) { + var subject = subjects[i]; + // Unfolding interacts with the other surface parameters, so it goes + // first -- the same reason _set_view does it first. + if (unfold in params && + (previous === undefined || !sameValue(params[unfold], previous[unfold]))) + vt.setProp(viewer, subst(unfold, subject), params[unfold]); + + for (prop in params) { + if (prop === unfold) + continue; + if (previous !== undefined && prop in previous && + sameValue(params[prop], previous[prop])) + continue; + vt.setProp(viewer, subst(prop, subject), params[prop]); + } + } + }; + + vt.setProp = function(viewer, path, value) { + try { + viewer.ui.set(path, value); + } catch (e) { + console.warn("Could not set " + path + ": " + e.message); + } + }; + + // Interpolate between two views. `a` and `b` are view dicts (optionally + // carrying a "frame" key); `t` runs from 0 at `a` to 1 at `b`. + // + // Values that cannot be blended -- null, booleans, strings, discrete + // numbers, and anything missing from `b` -- hold their starting value, the + // same rule JSMixer._get_anim_seq uses. Numbers go through the viewer's own + // _animInterp so that camera.azimuth still takes the short way around. + vt.interpolate = function(viewer, a, b, t) { + var subjects = vt.subjects(viewer); + var subject = subjects.length > 0 ? subjects[0] : null; + var out = {}; + + for (var prop in a) { + if (prop === 'frame') + continue; + var av = a[prop], bv = b[prop]; + var leaf = prop.split('.').pop(); + + if (bv === undefined || av === null || STEP_PROPS[leaf] || + typeof av === 'boolean' || typeof av === 'string') { + out[prop] = av; + continue; + } + + var state = subst(prop, subject); + if (av instanceof Array) { + if (!(bv instanceof Array) || bv.length !== av.length) { + out[prop] = av; + continue; + } + var vals = []; + for (var i = 0; i < av.length; i++) + vals.push(viewer._animInterp(state, av[i], bv[i], t)); + out[prop] = vals; + } else if (typeof av === 'number' && typeof bv === 'number') { + out[prop] = viewer._animInterp(state, av, bv, t); + } else { + out[prop] = av; + } + } + return out; + }; + + // ------------------------------------------------------------------ + // Small floating panels + // ------------------------------------------------------------------ + + function makePanel(id, title, bodyHTML, onClose) { + var panel = $("
" + + "
" + title + + "×
" + + "
" + bodyHTML + "
" + + "
"); + panel.find(".pycortex-panel-close").click(function() { + panel.hide(); + if (onClose !== undefined) + onClose(); + }); + $("body").append(panel); + if ($.fn.draggable !== undefined) + panel.draggable({handle: ".pycortex-panel-title"}); + return panel; + } + + // ------------------------------------------------------------------ + // The animation panel + // ------------------------------------------------------------------ + + // Every input carries an id as well as a class. The viewer's global keyboard + // shortcuts (see jsplot.Menu._add in menu.js) only step aside for an INPUT + // with a non-empty id -- without one, typing "brainmovie" into a field would + // fold, inflate and re-layer the brain as it went. + var ANIM_HTML = [ + "
", + " ", + " ", + " ", + " ", + "
", + "
", + " ", + "
", + "
", + "
", + " ", + " ", + " ", + " ", + "
", + "
", + " ", + " ", + "
", + "
", + " ", + " ", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + "
", + " ", + " ×", + "
", + "
", + " ", + "
", + "
", + "
", + ].join("\n"); + + function AnimationPanel(viewer) { + this.viewer = viewer; + viewer._anim = {frame: 0, first: 0, last: 30, fps: 30, keyframes: []}; + this.state = viewer._anim; + this.playing = false; + this.rendering = false; + this._applied = undefined; + + this.panel = makePanel("animpanel", "Animation", ANIM_HTML, + this.close.bind(this)); + this._bind(); + this.sync(); + } + + AnimationPanel.prototype._el = function(cls) { + return this.panel.find("." + cls); + }; + + AnimationPanel.prototype._bind = function() { + var self = this, st = this.state; + + this._el("anim-frame").on("change", function() { + self.setFrame(parseFloat(this.value)); + self.sync(); + }); + this._el("anim-slider").on("input change", function() { + self.setFrame(parseFloat(this.value)); + self._el("anim-frame").val(Math.round(st.frame)); + }); + this._el("anim-fps").on("change", function() { + var v = parseInt(this.value, 10); + st.fps = (isFinite(v) && v > 0) ? v : st.fps; + self.sync(); + }); + this._el("anim-first").on("change", function() { + var v = parseInt(this.value, 10); + if (isFinite(v)) { + st.first = v; + if (st.last <= st.first) + st.last = st.first + 1; + } + self.sync(); + }); + this._el("anim-last").on("change", function() { + var v = parseInt(this.value, 10); + if (isFinite(v)) { + st.last = v; + if (st.last <= st.first) + st.first = st.last - 1; + } + self.sync(); + }); + + this._el("anim-add").click(this.addKeyframe.bind(this)); + this._el("anim-clear").click(this.clearKeyframe.bind(this)); + this._el("anim-play").click(this.playPause.bind(this)); + + var cfg = (typeof viewopts !== "undefined") ? viewopts.movie_post : undefined; + var render = this._el("anim-render"); + if (cfg === undefined) { + // No python behind this viewer (a static export), so there is + // nowhere to write frames. + render.prop("disabled", true) + .attr("title", "Rendering needs a viewer started from python"); + } else { + this._el("anim-root").text("under " + cfg.root); + render.click(function() { self._el("anim-render-form").toggle(); }); + this._el("anim-render-cancel").click(function() { + self._el("anim-render-form").hide(); + }); + this._el("anim-render-ok").click(this.render.bind(this)); + } + this._el("anim-render-form").hide(); + this._el("anim-width").val(this.viewer.imageWidth || 2400); + this._el("anim-height").val(this.viewer.imageHeight || 1200); + }; + + AnimationPanel.prototype.show = function() { + this.panel.show(); + this.sync(); + }; + + AnimationPanel.prototype.status = function(msg) { + this._el("anim-status").text(msg === undefined ? "" : msg); + }; + + // Push the internal state out to every widget, and redraw the keyframe dots. + AnimationPanel.prototype.sync = function() { + var st = this.state; + if (st.frame < st.first) st.frame = st.first; + if (st.frame > st.last) st.frame = st.last; + + this._el("anim-first").val(st.first); + this._el("anim-last").val(st.last); + this._el("anim-fps").val(st.fps); + this._el("anim-frame").val(Math.round(st.frame)).attr({min: st.first, max: st.last}); + this._el("anim-slider").attr({min: st.first, max: st.last}).val(st.frame); + this.drawTicks(); + }; + + // One yellow dot per keyframe, positioned along the slider. Redrawn from + // scratch so that changing first/last simply repositions everything. + AnimationPanel.prototype.drawTicks = function() { + var st = this.state; + var ticks = this._el("keyframe-ticks").empty(); + var span = st.last - st.first; + if (span <= 0) + return; + for (var i = 0; i < st.keyframes.length; i++) { + var frame = st.keyframes[i].frame; + if (frame < st.first || frame > st.last) + continue; + var pct = 100 * (frame - st.first) / span; + $("
") + .css("left", pct + "%") + .attr("title", "keyframe at frame " + frame) + .appendTo(ticks); + } + }; + + AnimationPanel.prototype.sorted = function() { + return this.state.keyframes.slice().sort(function(a, b) { + return a.frame - b.frame; + }); + }; + + // The interpolated view at (possibly fractional) frame `f`. + AnimationPanel.prototype.viewAt = function(f) { + var kfs = this.sorted(); + if (kfs.length === 0) + return null; + if (f <= kfs[0].frame) + return kfs[0]; + if (f >= kfs[kfs.length - 1].frame) + return kfs[kfs.length - 1]; + for (var i = 0; i < kfs.length - 1; i++) { + if (f >= kfs[i].frame && f <= kfs[i + 1].frame) { + var span = kfs[i + 1].frame - kfs[i].frame; + var t = span === 0 ? 0 : (f - kfs[i].frame) / span; + return vt.interpolate(this.viewer, kfs[i], kfs[i + 1], t); + } + } + return kfs[kfs.length - 1]; + }; + + // Scrubbing, playback and rendering all move the viewer through here, so + // they cannot drift apart. + AnimationPanel.prototype.setFrame = function(f) { + var st = this.state; + if (!isFinite(f)) + return; + st.frame = Math.min(Math.max(f, st.first), st.last); + var view = this.viewAt(st.frame); + if (view !== null) { + vt.applyView(this.viewer, view, this._applied); + this._applied = view; + } + }; + + AnimationPanel.prototype.addKeyframe = function() { + var st = this.state; + var frame = Math.round(st.frame); + var view = vt.captureView(this.viewer); + view.frame = frame; + + for (var i = 0; i < st.keyframes.length; i++) { + if (st.keyframes[i].frame === frame) { + st.keyframes[i] = view; + this._applied = undefined; + this.drawTicks(); + this.status("Replaced keyframe at frame " + frame); + return; + } + } + st.keyframes.push(view); + this._applied = undefined; + this.drawTicks(); + this.status("Added keyframe at frame " + frame + + " (" + st.keyframes.length + " total)"); + }; + + AnimationPanel.prototype.clearKeyframe = function() { + var st = this.state; + var frame = Math.round(st.frame); + for (var i = 0; i < st.keyframes.length; i++) { + if (st.keyframes[i].frame === frame) { + st.keyframes.splice(i, 1); + this._applied = undefined; + this.drawTicks(); + this.status("Removed keyframe at frame " + frame); + return; + } + } + this.status("No keyframe at frame " + frame); + }; + + AnimationPanel.prototype.stop = function() { + this.playing = false; + this._el("anim-play").text("play animation"); + }; + + // Closing the panel abandons whatever it was in the middle of. + AnimationPanel.prototype.close = function() { + this.stop(); + this.rendering = false; + }; + + AnimationPanel.prototype.playPause = function() { + if (this.playing) { + this.stop(); + this.status("Stopped"); + return; + } + var st = this.state; + if (st.keyframes.length < 2) { + this.status("Need at least two keyframes to play"); + return; + } + + // Driven by our own clock rather than viewer.animate(): animate() works + // in seconds, only builds segments for properties that change, and would + // linearly interpolate the boolean and string properties. + var self = this; + var from = st.frame >= st.last ? st.first : st.frame; + var start = new Date(); + this.playing = true; + this._el("anim-play").text("stop"); + this.status("Playing"); + + function step() { + if (!self.playing) + return; + var frame = from + ((new Date()) - start) / 1000 * st.fps; + if (frame >= st.last) { + self.setFrame(st.last); + self.sync(); + self.stop(); + self.status("Done"); + return; + } + self.setFrame(frame); + self._el("anim-slider").val(frame); + self._el("anim-frame").val(Math.round(frame)); + requestAnimationFrame(step); + } + requestAnimationFrame(step); + }; + + AnimationPanel.prototype.render = function() { + var st = this.state, self = this; + var cfg = viewopts.movie_post; + + if (this.rendering) { + this.status("Already rendering"); + return; + } + if (st.keyframes.length === 0) { + this.status("Add at least one keyframe first"); + return; + } + + var name = this._el("anim-name").val(); + var dir = this._el("anim-dir").val(); + var width = parseInt(this._el("anim-width").val(), 10); + var height = parseInt(this._el("anim-height").val(), 10); + if (!isFinite(width) || !isFinite(height) || width < 1 || height < 1) { + this.status("Bad image size"); + return; + } + + this.stop(); + this.rendering = true; + this._el("anim-render-form").hide(); + + var total = st.last - st.first + 1; + + function finish(msg) { + self.rendering = false; + self.sync(); + self.status(msg); + } + + // Strictly one frame at a time: both the webgl readback and the upload + // are asynchronous, so a plain loop would race. + function renderFrame(frame) { + if (!self.rendering) { + finish("Rendering cancelled"); + return; + } + self.setFrame(frame); + self._el("anim-slider").val(frame); + self._el("anim-frame").val(frame); + self.status("Rendering frame " + (frame - st.first + 1) + " of " + total); + + // Let the viewer redraw before grabbing the framebuffer. + requestAnimationFrame(function() { + var image; + try { + image = self.viewer.getImage(width, height); + } catch (e) { + finish("Could not render frame " + frame + ": " + e.message); + return; + } + $.post(cfg.url, {token: cfg.token, dir: dir, name: name, + frame: frame, png: image.toDataURL()}) + .done(function() { + if (frame < st.last) + renderFrame(frame + 1); + else + finish("Rendered " + total + " frames"); + }) + .fail(function(xhr) { + finish("Frame " + frame + " failed: " + + (xhr.responseText || xhr.statusText)); + }); + }); + } + + renderFrame(st.first); + }; + + // ------------------------------------------------------------------ + // The "save view" prompt + // ------------------------------------------------------------------ + + var SAVE_HTML = [ + "
", + "
", + "
", + " ", + "
", + "
Kept in the browser until python calls", + " retrieve_new_views().
", + "
", + ].join("\n"); + + function SavePrompt(viewer, onSaved) { + var self = this; + this.viewer = viewer; + this.onSaved = onSaved; + this.panel = makePanel("viewsave", "Save view", SAVE_HTML); + this.panel.find(".viewsave-cancel").click(function() { self.panel.hide(); }); + this.panel.find(".viewsave-ok").click(function() { self.save(); }); + this.panel.find(".viewsave-name").on("keypress", function(evt) { + if (evt.which == 13) + self.save(); + }); + } + + SavePrompt.prototype.show = function() { + this.panel.find(".viewsave-status").text(""); + this.panel.show(); + this.panel.find(".viewsave-name").val("").focus(); + }; + + SavePrompt.prototype.save = function() { + var name = $.trim(this.panel.find(".viewsave-name").val()); + if (name.length === 0) { + this.panel.find(".viewsave-status").text("Please give the view a name"); + return; + } + this.viewer.saveNewView(name); + this.panel.hide(); + if (this.onSaved !== undefined) + this.onSaved(name); + }; + + // ------------------------------------------------------------------ + // Wiring it into the camera menu + // ------------------------------------------------------------------ + + vt.installCameraUI = function(viewer, cam_ui) { + // Views saved through the GUI. Deliberately separate from the views + // loaded out of the filestore: python pulls these out with + // JSMixer.retrieve_new_views() and decides whether to keep them. + viewer._new_views = {}; + + viewer.getNewViews = function() { + return this._new_views; + }; + + // Capture the current view under `name`. Called by the save prompt, and + // usable directly from python or a test. + viewer.saveNewView = function(name) { + this._new_views[name] = vt.captureView(this); + return this._new_views[name]; + }; + + viewer.applyView = function(view) { + vt.applyView(this, view); + }; + + var views_ui = cam_ui.addFolder("views", true); + var saved = (typeof viewopts !== "undefined" && viewopts.saved_views) ? + viewopts.saved_views : {}; + var subjects = Object.keys(saved); + + // jsplot.Menu stores each entry as a property of the folder, so a view + // named after one of its own methods would break the menu. + var RESERVED = {get: 1, set: 1, add: 1, addFolder: 1, remove: 1, init: 1}; + + function addViewButton(label, view) { + if (RESERVED[label] !== undefined) { + console.warn("Skipping view '" + label + "': that name is " + + "reserved by the controls menu. Rename the file."); + return; + } + if (label in views_ui._desc) // never add the same row twice + return; + var desc = {}; + desc[label] = {action: function() { vt.applyView(viewer, view); }}; + views_ui.add(desc); + } + + for (var i = 0; i < subjects.length; i++) { + var subject = subjects[i]; + var names = Object.keys(saved[subject]).sort(); + for (var j = 0; j < names.length; j++) { + // Disambiguate only when it could actually be ambiguous. + var label = subjects.length > 1 ? + subject + ": " + names[j] : names[j]; + addViewButton(label, saved[subject][names[j]]); + } + } + + var prompt = null; + var panel = null; + + cam_ui.add({ + "save view": {action: function() { + if (prompt === null) + prompt = new SavePrompt(viewer, function(name) { + addViewButton(name, viewer._new_views[name]); + }); + prompt.show(); + }}, + "create animation": {action: function() { + if (panel === null) + panel = new AnimationPanel(viewer); + panel.show(); + }}, + }); + }; + + vt.AnimationPanel = AnimationPanel; + + return vt; + }(module.viewtools || {})); + + return module; +}(jsplot || {})); diff --git a/cortex/webgl/template.html b/cortex/webgl/template.html index bfabfb158..77a3da887 100644 --- a/cortex/webgl/template.html +++ b/cortex/webgl/template.html @@ -37,6 +37,7 @@ + {% if leapmotion %} diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index cb471f4be..66a3ce2f8 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -2,9 +2,12 @@ import copy import functools import glob +import hmac import json import mimetypes import os +import re +import secrets import shutil import sys import threading @@ -61,6 +64,44 @@ def _viewer_urls(port: int) -> tuple[str, str]: return local, network +def _load_saved_views(subjects: list[str]) -> dict[str, dict[str, dict[str, Any]]]: + """Read the saved views of `subjects` out of the filestore. + + `subjects` is the list of subjects the viewer is actually displaying, so a + viewer never reads (nor ships to the browser) views belonging to unrelated + subjects in the filestore. + + Returns + ------- + dict + ``{subject: {view_name: {prop: value}}}``. The keys within each view keep + the literal ``{subject}`` placeholder that ``JSMixer._capture_view`` + writes; the javascript side substitutes it per subject when the view is + applied, so one saved view still works in a multi-subject viewer. + """ + saved: dict[str, dict[str, dict[str, Any]]] = {} + for subj in subjects: + saved[subj] = {} + viewdir = os.path.join(db.filestore, subj, "views") + # Glob *.json rather than using db.get_paths()['views'], which strips any + # extension off any file in the directory (so notes.tar.gz would show up + # as a view named "notes.tar"). + for path in sorted(glob.glob(os.path.join(viewdir, "*.json"))): + name = os.path.splitext(os.path.basename(path))[0] + try: + with open(path) as fp: + view = json.load(fp) + except (ValueError, OSError) as err: + warnings.warn("Skipping unreadable view %s: %s" % (path, err)) + continue + if not isinstance(view, dict): + warnings.warn("Skipping view %s: expected a dict of view " + "parameters, got %s" % (path, type(view).__name__)) + continue + saved[subj][name] = view + return saved + + def make_static( outpath, data, @@ -282,6 +323,10 @@ def make_static( if "paths" in sec or "labels" in sec: my_viewopts[sec] = dict(options.config.items(sec)) + # Views saved in the filestore, for the "camera > views" menu. Only the + # subjects this viewer displays are read. + my_viewopts["saved_views"] = _load_saved_views(subjects) + html = tpl.generate( data=json.dumps(metadata), colormaps=colormaps, @@ -322,6 +367,7 @@ def show( title: str="Brain", layout: Optional[str]=None, display_url: bool=True, + movie_dir: Optional[str]=None, **kwargs, ): """ @@ -400,6 +446,11 @@ def show( link to access the viewer. Set to False to suppress this display message, which can be useful in contexts like Marimo notebooks or programmatic headless viewers. Default True + movie_dir : str or None, optional + Root directory that the viewer's animation panel may render frames into. + The folder typed into the panel is interpreted relative to this root, and + the server refuses to write anywhere outside it. Default None, meaning + the current working directory. **kwargs All additional keyword arguments are passed to the template renderer. @@ -488,6 +539,18 @@ def show( if 'paths' in sec or 'labels' in sec: my_viewopts[sec] = dict(options.config.items(sec)) + # Views saved in the filestore, for the "camera > views" menu. Only the + # subjects this viewer displays are read. + my_viewopts['saved_views'] = _load_saved_views(subjects) + + # Where the animation panel is allowed to write rendered frames. The browser + # sends a path relative to this root and MovieHandler refuses anything that + # resolves outside it; see MovieHandler below. + movie_root = os.path.realpath(os.getcwd() if movie_dir is None else movie_dir) + movie_token = secrets.token_urlsafe(32) + my_viewopts['movie_post'] = dict(url="movie", token=movie_token, + root=movie_root) + if pickerfun is None: pickerfun = lambda *a: None @@ -584,6 +647,69 @@ def post(self): data = png svgfile.write(data) + class MovieHandler(web.RequestHandler): + """Writes one animation frame rendered by the viewer's animation panel. + + Kept separate from MixerHandler.post, which pairs uploads with filenames + by the order they were pushed onto `post_name`: a browser-driven render + loop has no way to keep that queue in step, so each frame carries its own + destination instead. + + The destination is always resolved underneath `movie_root` (see the + `movie_dir` argument of show). The server binds all interfaces and serves + the page unauthenticated, so the token below only keeps unrelated local + processes out -- `movie_root` is what stops this from being an arbitrary + file-write primitive. + """ + def post(self): + # Compare as bytes: compare_digest rejects non-ASCII str outright, + # which would turn a hostile token into a 500 instead of a 403. + sent = self.get_argument("token", "").encode("utf-8", "replace") + if not hmac.compare_digest(sent, movie_token.encode("utf-8")): + self.set_status(403) + self.finish("Bad or missing token") + return + + name = self.get_argument("name", "frame") + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name) is None: + self.set_status(400) + self.finish("Invalid frame name: use letters, digits, '_', '-' and '.'") + return + + try: + frame = int(self.get_argument("frame")) + except (TypeError, ValueError): + self.set_status(400) + self.finish("Invalid or missing frame number") + return + + dest = os.path.realpath(os.path.join(movie_root, + self.get_argument("dir", ""))) + if dest != movie_root and not dest.startswith(movie_root + os.sep): + self.set_status(403) + self.finish("Refusing to write outside %s" % movie_root) + return + + png = self.get_argument("png", default="") + try: + data = binascii.a2b_base64(png[png.index(",") + 1:].strip()) + except (ValueError, binascii.Error): + self.set_status(400) + self.finish("Could not decode png data") + return + + try: + os.makedirs(dest, exist_ok=True) + fname = os.path.join(dest, "%s_%05d.png" % (name, frame)) + with open(fname, "wb") as fp: + fp.write(data) + except OSError as err: + self.set_status(500) + self.finish("Could not write frame: %s" % err) + return + + self.write(dict(path=fname)) + P = ParamSpec('P') class JSMixer(serve.JSProxy[P]): @@ -727,6 +853,43 @@ def get_view(self, subject, name): """ view = db.get_view(self, subject, name) + def retrieve_new_views(self) -> dict[str, dict[str, Any]]: + """Get views saved through the viewer's GUI. + + Returns the views created with the "save view" button in the viewer's + camera menu. These live only in the browser until they are retrieved, + which keeps them separate from the views that were loaded out of the + filestore when the viewer started. + + Returns + ------- + dict of str to dict + Maps the name typed into the viewer to a dict of view parameters, + in the same format as ``_capture_view``. They can be passed + straight to ``_set_view``, or written to + ``//views/.json`` to make them + permanent:: + + for name, view in handle.retrieve_new_views().items(): + path = os.path.join(cortex.db.filestore, subject, + "views", name + ".json") + json.dump(view, open(path, "w")) + + Notes + ----- + If several subjects are displayed, only the first one's viewer is + queried, mirroring the behavior of ``_capture_view``. + """ + # One round trip, rather than the three that walking the proxy + # attribute by attribute would cost (each level is a `query`). + resp = self.send(method="run", + params=["window.viewer.getNewViews", []]) + val = resp[0] if isinstance(resp, list) and len(resp) > 0 else None + if isinstance(val, dict) and "error" in val: + raise Exception(val["error"]) + # `send` returns [None] when the browser does not answer in time. + return cast(dict[str, dict[str, Any]], val) if isinstance(val, dict) else {} + def addData(self, **kwargs): """Add (or replace) dataviews in the running viewer. @@ -1054,6 +1217,7 @@ def get_local_client(self): (r'/data/(.*)', DataHandler), (r'/stim/(.*)', StimHandler), (r'/mixer.html', MixerHandler), + (r'/movie', MovieHandler), (r'/picker', PickerHandler), (r'/', MixerHandler), (r'/static/(.*)', StaticHandler)], diff --git a/docs/database.rst b/docs/database.rst index 3d2c4ab7d..2e3a1aec0 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -328,7 +328,33 @@ It is often useful to be able to store, recall, and share specific perspectives Where, ``'subject'`` is the subject identifier and ``'name'`` is a unique name for the stored view. A previously saved view can be applied to a webgl viewer using:: - viewer.get_view(viewer, subject, name) + viewer.get_view(subject, name) + +Saved views in the browser +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Every view stored for the subject(s) a viewer displays is loaded when the viewer starts, and appears as a button under **camera > views** in the browser controls. Clicking one applies it. This works in static viewers made with ``cortex.webgl.make_static`` as well. + +The **save view** button in the same menu captures the current view under a name you choose. These stay in the browser rather than being written to the filestore, so that you can experiment freely; retrieve them from python with:: + + new_views = viewer.retrieve_new_views() + +which returns a dict mapping each name to a dict of view parameters, in the same format as ``viewer._capture_view()``. To keep one permanently, write it into the subject's ``views`` directory:: + + import json, os + for name, view in viewer.retrieve_new_views().items(): + path = os.path.join(cortex.db.filestore, subject, "views", name + ".json") + with open(path, "w") as fp: + json.dump(view, fp) + +Animations +~~~~~~~~~~ + +The **create animation** button opens a panel for building an animation out of keyframes. Set the current frame with the slider (frames that already hold a keyframe are marked with a yellow dot), pose the brain, and press **add keyframe**; the values in between are interpolated. **play animation** previews the result at the chosen frame rate, and **render animation** writes one PNG per frame. + +Rendering needs a viewer started from python, since the frames are written by the server rather than by the browser. The folder named in the panel is interpreted relative to the ``movie_dir`` given to ``cortex.webgl.show`` (the current working directory by default), and the server refuses to write outside it:: + + viewer = cortex.webgl.show(volume, movie_dir="/path/to/movies") ``overlays.svg`` From aef3d1f407b1e185903e3dbc805b2cf1bfbf334a Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Tue, 1 Sep 2026 00:54:11 -0700 Subject: [PATCH 02/14] Store GUI-saved views to the filestore, and widen the views menu Two gaps in the saved-views GUI. save_new_views() retrieve_new_views() handed back a dict and then told the caller, in its docstring, to json.dump each entry into //views/ by hand -- leaving them to get the path convention and the name checking right. cortex.db.save_view cannot be reused for this: it calls vw._capture_view() and stores the *current* view, not a dict it is given. The new handle method writes each view to the same location the rest of pycortex reads views from, so they appear in the camera > views menu of every viewer opened for that subject afterwards. Names come from a text field in the browser, so each is checked against the same pattern MovieHandler uses before anything is written; validating every name and destination up front also means a clash partway through cannot leave some views stored and others not. Unlike db.save_view it creates the views directory, which is otherwise a latent FileNotFoundError for a subject imported without one. A stored view is no longer "new": promoteNewView moves it out of the browser's _new_views and into the views folder, so retrieve_new_views() means exactly "not yet on disk" and a second save is a no-op. Wider view names dat.GUI puts a controller's label in a fixed width: 40% column sized for a slider in the remaining 60%, which truncated any view name longer than about fifteen characters for no reason. The views folder now tags its
  • from javascript -- dat.GUI gives folders no distinguishing class -- so the stylesheet can widen and centre just those rows and leave fold/reset/inflate and Save image alone. Checked in a browser against the real dat.gui + menu.js: a function controller's row is already clickable in full and the div in .c is empty for these buttons, so the name can simply take the whole row. Co-Authored-By: Claude Opus 5 --- cortex/tests/test_webgl_headless.py | 72 ++++++++++++++++ cortex/webgl/resources/css/mriview.css | 20 +++++ cortex/webgl/resources/js/viewtools.js | 32 ++++++- cortex/webgl/view.py | 115 +++++++++++++++++++++++-- docs/database.rst | 10 +-- 5 files changed, 232 insertions(+), 17 deletions(-) diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py index 86ae72f23..ae9ef3d96 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -1165,6 +1165,78 @@ def test_retrieve_new_views_roundtrip(): 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 diff --git a/cortex/webgl/resources/css/mriview.css b/cortex/webgl/resources/css/mriview.css index cc1163f9a..982550cbc 100644 --- a/cortex/webgl/resources/css/mriview.css +++ b/cortex/webgl/resources/css/mriview.css @@ -721,3 +721,23 @@ button#twodbutton:disabled, button#twodbutton[disabled] { border-radius: 50%; background-color: #ffd700; } + +/* Saved-view buttons (the "views" folder, tagged by viewtools.js) carry only a + name, so let it span and centre the whole row rather than sitting in the + narrow left column dat.GUI reserves for slider labels (.dg .property-name is + width: 40%), which needlessly truncates longer view names. + + dat.GUI already makes the whole row of a function controller clickable, and + the empty div it leaves in .c is only there to hold a label these buttons do + not use, so widening the name costs nothing. */ +.pycortex-views .cr.function .property-name { + display: block; + float: none; + clear: none; + width: auto; + text-align: center; +} + +.pycortex-views .cr.function .c { + display: none; +} diff --git a/cortex/webgl/resources/js/viewtools.js b/cortex/webgl/resources/js/viewtools.js index ed181ca73..cdb2d8969 100644 --- a/cortex/webgl/resources/js/viewtools.js +++ b/cortex/webgl/resources/js/viewtools.js @@ -617,8 +617,8 @@ var jsplot = (function (module) { "
    ", " ", "
    ", - "
    Kept in the browser until python calls", - " retrieve_new_views().
    ", + "
    Kept in the browser until python stores it", + " with save_new_views().
    ", "
    ", ].join("\n"); @@ -678,7 +678,21 @@ var jsplot = (function (module) { vt.applyView(this, view); }; - var views_ui = cam_ui.addFolder("views", true); + // Built up front, rather than letting addFolder make it, so that the + // init override below is in place before dat.GUI can materialize the + // folder. + var views_ui = new module.Menu(); + var _views_init = views_ui.init; + views_ui.init = function(gui) { + _views_init.call(this, gui); + // dat.GUI wraps a folder's element in
  • ; tag that + // so the stylesheet can widen and centre the view names without + // touching the rest of the controls. + if (gui && gui.domElement && gui.domElement.parentNode) + $(gui.domElement.parentNode).addClass("pycortex-views"); + }; + cam_ui.addFolder("views", true, views_ui); + var saved = (typeof viewopts !== "undefined" && viewopts.saved_views) ? viewopts.saved_views : {}; var subjects = Object.keys(saved); @@ -700,6 +714,18 @@ var jsplot = (function (module) { views_ui.add(desc); } + // Called by JSMixer.save_new_views once a view is on disk: it is no + // longer "new", so it moves out of _new_views and joins the views + // folder. The button's closure holds the view object itself, so it + // keeps working after the entry is deleted. + viewer.promoteNewView = function(name) { + if (!(name in this._new_views)) + return false; + addViewButton(name, this._new_views[name]); + delete this._new_views[name]; + return true; + }; + for (var i = 0; i < subjects.length; i++) { var subject = subjects[i]; var names = Object.keys(saved[subject]).sort(); diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index 66a3ce2f8..d658aa04e 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -865,15 +865,13 @@ def retrieve_new_views(self) -> dict[str, dict[str, Any]]: ------- dict of str to dict Maps the name typed into the viewer to a dict of view parameters, - in the same format as ``_capture_view``. They can be passed - straight to ``_set_view``, or written to - ``//views/.json`` to make them - permanent:: + in the same format as ``_capture_view``, so they can be passed + straight to ``_set_view``. Use ``save_new_views`` to make them + permanent. - for name, view in handle.retrieve_new_views().items(): - path = os.path.join(cortex.db.filestore, subject, - "views", name + ".json") - json.dump(view, open(path, "w")) + See Also + -------- + save_new_views : write these views into the pycortex filestore. Notes ----- @@ -890,6 +888,107 @@ def retrieve_new_views(self) -> dict[str, dict[str, Any]]: # `send` returns [None] when the browser does not answer in time. return cast(dict[str, dict[str, Any]], val) if isinstance(val, dict) else {} + def save_new_views(self, subject: Optional[str]=None, + names: Optional[list[str]]=None, + is_overwrite: bool=False) -> dict[str, str]: + """Store views saved through the viewer's GUI in the filestore. + + Writes each view created with the viewer's "save view" button to + ``//views/.json``, where the rest of + pycortex looks for saved views: they show up in the camera > views + menu of every viewer opened for that subject from then on, and can + be applied with ``get_view``. + + A view that has been written is no longer "new". It moves into the + running viewer's views menu and out of ``retrieve_new_views``, so + calling this twice does not rewrite the same files. + + Parameters + ---------- + subject : str or None, optional + pycortex subject id to save the views under. Default None, + meaning the first subject the viewer is displaying. + names : list of str or None, optional + Save only these views. Default None, meaning every view + currently held in the viewer. + is_overwrite : bool, optional + Whether to replace views of the same name that are already in + the filestore (default False). + + Returns + ------- + dict of str to str + Maps each saved view's name to the file it was written to. + + Raises + ------ + KeyError + If `names` mentions a view the viewer does not have. + ValueError + If a view name cannot be used as a filename. + IOError + If a view is already stored under that name and `is_overwrite` + is False. + + See Also + -------- + retrieve_new_views : get the same views without storing them. + + Examples + -------- + >>> handle = cortex.webgl.show(volume) # doctest: +SKIP + >>> # ... position the brain and press "save view" in the viewer + >>> handle.save_new_views() # doctest: +SKIP + {'lateral': '/path/to/filestore/S1/views/lateral.json'} + """ + if subject is None: + subject = subjects[0] + + new_views = self.retrieve_new_views() + if names is None: + names = sorted(new_views) + else: + missing = [n for n in names if n not in new_views] + if len(missing) > 0: + raise KeyError( + "The viewer has no view named %s. Views already stored " + "in the filestore cannot be re-saved; the viewer holds " + "%s." % (", ".join(repr(n) for n in missing), + ", ".join(repr(n) for n in sorted(new_views)) + or "nothing")) + + viewdir = os.path.join(db.filestore, subject, "views") + # db.save_view leaves this to get_paths, which makes it a latent + # FileNotFoundError for a subject imported without a views dir. + os.makedirs(viewdir, exist_ok=True) + + # Check everything before writing anything, so that a name clash + # partway through does not leave some views stored and some not. + # The names come from a text field in the browser, so they also + # have to be prevented from escaping the views directory. + paths = {} + for name in names: + if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9 _.-]*", name) is None: + raise ValueError( + "Cannot save the view named %r: a view name must start " + "with a letter or digit and contain only letters, " + "digits, spaces, '_', '-' and '.'" % name) + path = os.path.join(viewdir, name + ".json") + if os.path.exists(path) and not is_overwrite: + raise IOError( + "Refusing to over-write the extant view %s. If you want " + "to do this, set is_overwrite=True!" % path) + paths[name] = path + + for name in names: + with open(paths[name], "w") as fp: + json.dump(new_views[name], fp) + # Now that it is on disk it belongs with the loaded views. + self.send(method="run", + params=["window.viewer.promoteNewView", [name]]) + + return paths + def addData(self, **kwargs): """Add (or replace) dataviews in the running viewer. diff --git a/docs/database.rst b/docs/database.rst index 2e3a1aec0..e0f9d20e3 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -339,13 +339,11 @@ The **save view** button in the same menu captures the current view under a name new_views = viewer.retrieve_new_views() -which returns a dict mapping each name to a dict of view parameters, in the same format as ``viewer._capture_view()``. To keep one permanently, write it into the subject's ``views`` directory:: +which returns a dict mapping each name to a dict of view parameters, in the same format as ``viewer._capture_view()``. To keep them, write them into the subject's ``views`` directory:: - import json, os - for name, view in viewer.retrieve_new_views().items(): - path = os.path.join(cortex.db.filestore, subject, "views", name + ".json") - with open(path, "w") as fp: - json.dump(view, fp) + viewer.save_new_views() + +This returns a dict mapping each name to the file it was written to. Pass ``subject`` to store them under a subject other than the first one displayed, ``names`` to save only some of them, and ``is_overwrite=True`` to replace views already stored under the same name. A view that has been stored is no longer "new": it moves into the **camera > views** menu of the running viewer and stops being returned by ``retrieve_new_views``, so calling ``save_new_views`` twice will not rewrite the same files. Animations ~~~~~~~~~~ From 3cdf5c89b1390372acbbb3905f7bff34866d6aaf Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Mon, 21 Sep 2026 10:44:04 -0700 Subject: [PATCH 03/14] TST: remove stray merge-conflict markers from test_webgl_headless Commit d49aaeca left an unterminated conflict marker pair at lines 905-906 of cortex/tests/test_webgl_headless.py: <<<<<<< HEAD ======= There is no closing marker. The module does not parse, so pytest cannot collect any test in it -- not only the saved-views and animation tests added on this branch, but every pre-existing alpha, opacity and addData regression test in the file as well. Remove both lines. The same merge also left two groups numbered "Group 10", so renumber the second one. Co-Authored-By: Claude Opus 5 (1M context) --- cortex/tests/test_webgl_headless.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py index ae9ef3d96..721e8041a 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -894,8 +894,6 @@ def test_addData_vertex_data(tmp_path): _assert_no_browser_failures(handle) -<<<<<<< HEAD -======= # --------------------------------------------------------------------------- # Group 10: Manual visual A/B comparison across all alpha-bearing dataviews @@ -1104,7 +1102,7 @@ def _bump(surf, seed, sigma): # --------------------------------------------------------------------------- -# Group 10: Saved views and the animation GUI +# Group 11: Saved views and the animation GUI # --------------------------------------------------------------------------- From 3230f8c492e0c5226639d5f00e238ec959f9fe2a Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Mon, 21 Sep 2026 10:44:36 -0700 Subject: [PATCH 04/14] ENH: smooth animation trajectories with per-keyframe interpolation The animation panel interpolated linearly between the two bracketing keyframes, so the brain changed direction abruptly at every keyframe. Add eight per-keyframe interpolation modes, following the way Adobe describes keyframe interpolation -- a mode names how a keyframe is entered and how it is left, so the curve between two keyframes depends on the pair of modes at its ends -- with cubic Hermite added. A "smoothing" dropdown in the panel sets the mode of the keyframe under the playhead, or the mode new keyframes will be given. Bezier is the default: it carries velocity smoothly through the interior keyframes and its automatic tangents go flat at local extrema, so the camera never swings past a pose you set. Smoothing needs the keyframes on both sides of a keyframe to compute its tangent, so a bracketing pair is no longer enough. Each property becomes its own one-dimensional channel -- one per component for the vectors -- spanning the whole keyframe list. Booleans, strings and the discrete `layers` still step; camera.azimuth is unwrapped so a spin takes the short way around. The same eight modes are available from python, so a movie rendered with make_movie_views matches the preview played in the browser. That means two implementations of the same arithmetic, in cortex/webgl/ interpolation.py and resources/js/interpolation.js; they use identical closed forms and a headless test asserts they agree numerically. _get_anim_seq keeps its existing pairwise path verbatim for the whole-animation easings ('linear', 'smoothstep', 'smootherstep'), which have no per-keyframe equivalent, and only takes the new path when a mode is named. Frame times are generated the same way in both, so frame counts do not move. Four deliberate deviations from the reference implementation, each commented at the site: * Control-point offsets are signed. The reference takes an unsigned square root for the value offset, putting the handles of a keyframe with a negative derivative off its own tangent line and breaking the C1 continuity the scheme exists to provide. * The Bezier parameter is solved for rather than assumed equal to normalized time. The reference returns early, leaving its own root-finding loop unreachable; both coordinates of a cubic Bezier are cubic in the parameter, so the two agree only when the handles happen to be evenly spaced in time. * A Linear keyframe followed by one entered smoothly produces a Hermite segment entered along the chord. The reference enumerates no segment for that combination, desynchronizing its segment and end-time lists. * A lone keyframe yields a constant interpolator instead of a segment list that is built and then discarded. One subtlety worth recording: half a turn is equally short either way, and Viewer._animInterp breaks that tie by travelling against the sign of the raw difference. A symmetric shortest-angle formula reverses one of the two cases, which would flip a 180-degree spin that used to work, so shortest_step reproduces the existing tie-break and is tested against it. Co-Authored-By: Claude Opus 5 (1M context) --- cortex/tests/test_interpolation.py | 399 ++++++++++++ cortex/tests/test_webgl_headless.py | 194 ++++++ cortex/webgl/interpolation.py | 723 +++++++++++++++++++++ cortex/webgl/resources/css/mriview.css | 21 + cortex/webgl/resources/js/interpolation.js | 438 +++++++++++++ cortex/webgl/resources/js/viewtools.js | 322 ++++++++- cortex/webgl/template.html | 1 + cortex/webgl/view.py | 125 +++- docs/database.rst | 37 ++ 9 files changed, 2243 insertions(+), 17 deletions(-) create mode 100644 cortex/tests/test_interpolation.py create mode 100644 cortex/webgl/interpolation.py create mode 100644 cortex/webgl/resources/js/interpolation.js diff --git a/cortex/tests/test_interpolation.py b/cortex/tests/test_interpolation.py new file mode 100644 index 000000000..e5323d091 --- /dev/null +++ b/cortex/tests/test_interpolation.py @@ -0,0 +1,399 @@ +"""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 trough + [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_properties_missing_from_the_first_keyframe_are_skipped(): + keyframes = [{"time": 0.0, "a": 0.0}, {"time": 1.0, "a": 1.0, "b": 2.0}] + assert set(build_channels(keyframes)) == {"a"} + + +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 721e8041a..f04d54f2e 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -1343,3 +1343,197 @@ def test_static_viewer_has_views_but_no_render_target(tmp_path): # No python behind a static viewer, so the animation panel must not offer # to render frames to disk. 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 = handle.send(method="run", params=[ + "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") diff --git a/cortex/webgl/interpolation.py b/cortex/webgl/interpolation.py new file mode 100644 index 000000000..a1129dca8 --- /dev/null +++ b/cortex/webgl/interpolation.py @@ -0,0 +1,723 @@ +"""Keyframe interpolation for viewer animations. + +A one-dimensional piecewise interpolator built from a list of keyframes, each +carrying its own :class:`Interpolation` mode. The modes follow the way Adobe +products describe keyframe interpolation -- a mode names how a keyframe is +entered and how it is left, so the shape of the curve between two keyframes is +decided by the pair of modes at its ends -- with cubic Hermite added. + +``resources/js/interpolation.js`` is the twin of this module and must stay in +step with it: the browser's animation panel interpolates in javascript while +:meth:`~cortex.webgl.view.show..JSMixer._get_anim_seq` interpolates here, +and an animation should look the same either way. Both use the same closed forms +and both carry the mode as one of the strings in :class:`Interpolation`, so a +keyframe crosses between them unchanged. + +Nothing here needs numpy or scipy, which keeps the arithmetic identical to the +javascript side rather than deferring to a library the browser does not have. + +Deviations from the reference implementation this was ported from +----------------------------------------------------------------- + +1. Control-point offsets are signed (see :func:`_tangent_step`). The reference + takes a square root for the value offset, which is always positive, so the + handles of a keyframe with a negative derivative end up off its own tangent + line -- breaking the C1 continuity the whole scheme exists to provide. +2. The Bezier parameter is solved for (see :meth:`_BezierSegment.__call__`) + rather than assumed equal to normalized time. ``x`` is cubic in the + parameter, so the two agree only when the control points happen to be evenly + spaced in time, which is exactly when the smoothing is doing nothing. +3. A ``Linear`` keyframe followed by one entered smoothly produces a Hermite + segment with a linear entry slope (see :func:`_make_segment`). The reference + enumerates no segment for that combination at all. +4. A lone keyframe yields a constant interpolator (see :meth:`Interpolator.build`). + +Values outside the keyframe range are always held at the nearest keyframe, which +is what the animation panel's frame slider does at either end of its range. +""" + +from __future__ import annotations + +import math +from enum import Enum +from typing import Iterable, List, Optional, Sequence, Tuple + +__all__ = ["Interpolation", "Keyframe", "Interpolator", "from_values", + "build_channels", "evaluate", "shortest_step", "unwrap_angles", + "wrap_angle"] + + +class Interpolation(str, Enum): + """How a keyframe is entered and left. + + A ``str`` enum so that a mode survives ``json`` serialization as its own + name, which is how keyframes travel between python and the browser. + """ + + Linear = "Linear" + """Linear in, linear out.""" + + LinearInHoldOut = "LinearInHoldOut" + """Linear in, then hold this value until the next keyframe.""" + + CubicHermite = "CubicHermite" + """Cubic Hermite with automatic derivatives; will not over/undershoot.""" + + LinearInCubicHermiteOut = "LinearInCubicHermiteOut" + """Linear in, cubic Hermite out; may over/undershoot.""" + + Bezier = "Bezier" + """Cubic Bezier with automatic control points; will not over/undershoot.""" + + BezierInHoldOut = "BezierInHoldOut" + """Bezier in, then hold this value until the next keyframe.""" + + CubicHermiteInHoldOut = "CubicHermiteInHoldOut" + """Cubic Hermite in, then hold this value until the next keyframe.""" + + LinearInBezierOut = "LinearInBezierOut" + """Linear in, Bezier out; may over/undershoot.""" + + +#: Modes that hold their value from this keyframe until the next one. +HOLD_OUT_MODES = frozenset({ + Interpolation.LinearInHoldOut, + Interpolation.BezierInHoldOut, + Interpolation.CubicHermiteInHoldOut, +}) + +#: Modes entered linearly. +LINEAR_IN_MODES = frozenset({ + Interpolation.Linear, + Interpolation.LinearInHoldOut, + Interpolation.LinearInCubicHermiteOut, + Interpolation.LinearInBezierOut, +}) + +#: Modes left along a Bezier. +BEZIER_OUT_MODES = frozenset({ + Interpolation.Bezier, + Interpolation.LinearInBezierOut, +}) + +#: Modes left along a cubic Hermite. +HERMITE_OUT_MODES = frozenset({ + Interpolation.CubicHermite, + Interpolation.LinearInCubicHermiteOut, +}) + +#: Modes whose incoming derivative is the slope of the chord from the previous +#: keyframe, which is what lets them over/undershoot. +LINEAR_SLOPE_MODES = frozenset({ + Interpolation.LinearInCubicHermiteOut, + Interpolation.LinearInBezierOut, +}) + +#: How far Bezier handles reach towards the neighbouring keyframe, as a fraction +#: of the distance to it. A third is the usual choice: it is the spacing at which +#: a cubic Bezier reproduces the cubic Hermite through the same tangents. +DEFAULT_CONTROL_EXTENT = 3.0 + + +def _tangent_step(slope: float, distance: float) -> Tuple[float, float]: + """Walk ``distance`` along a line of gradient ``slope``. + + Returns the ``(time, value)`` components of the step, both signed so that + adding them moves forward along the tangent and subtracting them moves + back. The reference implementation takes a square root for the value + component and so always returns it positive, which puts the handles of a + descending keyframe on the wrong side of it. + """ + dtime = distance / math.sqrt(1.0 + slope * slope) + if slope == 0.0: + return dtime, 0.0 + # distance / sqrt(1 + 1/slope**2) == abs(slope) * dtime, written this way to + # match the reference's algebra. + return dtime, math.copysign(distance / math.sqrt(1.0 + 1.0 / (slope * slope)), + slope) + + +class Keyframe: + """A value at a time, and how the curve enters and leaves it. + + The neighbour links, derivative and control points are filled in by + :meth:`Interpolator.build`; a keyframe on its own does not know enough to + compute them. + """ + + def __init__(self, time: float, value: float, + interpolation: Interpolation = Interpolation.Bezier, + control_extent: float = DEFAULT_CONTROL_EXTENT) -> None: + self.time = float(time) + self.value = float(value) + self.interpolation = Interpolation(interpolation) + self.control_extent = float(control_extent) + + self.previous: Optional["Keyframe"] = None + self.next: Optional["Keyframe"] = None + + #: Tangent used by the Hermite and Bezier segments touching this frame. + self.derivative: float = 0.0 + #: Bezier handle on the incoming side, ``None`` at the first keyframe. + self.control_point_1: Optional[Tuple[float, float]] = None + #: Bezier handle on the outgoing side, ``None`` at the last keyframe. + self.control_point_2: Optional[Tuple[float, float]] = None + + def __repr__(self) -> str: + return "Keyframe(time=%g, value=%g, interpolation=%s)" % ( + self.time, self.value, self.interpolation.value) + + def compute_derivative(self) -> float: + """The tangent to use at this keyframe. + + Returns zero wherever a non-zero tangent could push the curve outside + the values being interpolated: at the ends, at a local extremum, and + where this keyframe repeats a neighbour's value. That, rather than any + clamping after the fact, is what stops the smooth modes from + over/undershooting. + """ + previous, following = self.previous, self.next + if previous is None or following is None: + return 0.0 + + value, before, after = self.value, previous.value, following.value + if value > before and value > after: # local maximum + return 0.0 + if value < before and value < after: # local minimum + return 0.0 + if value == before or value == after: + return 0.0 + if self.interpolation in HOLD_OUT_MODES: + return 0.0 + if self.interpolation in LINEAR_SLOPE_MODES: + # Enter along the chord and keep going: this is the overshoot the + # LinearIn* modes exist to allow. + return (value - before) / (self.time - previous.time) + + # Otherwise take the tangent of the smooth curve that would pass + # through the neighbours if this keyframe were not there -- a Hermite + # over (previous, next) flat at both ends -- and then stretch it by how + # far this keyframe sits from that curve. Sitting below the smooth path + # (in the direction of travel) steepens the tangent, above flattens it. + span = following.time - previous.time + t = (self.time - previous.time) / span + smooth_value = before + (after - before) * (3.0 * t ** 2 - 2.0 * t ** 3) + smooth_slope = (after - before) * (6.0 * t - 6.0 * t ** 2) / span + + # abs(after - before) cannot be zero here: a keyframe between two equal + # neighbours is either a local extremum or equal to them, and both cases + # have already returned. + scale = 2.0 * (smooth_value - value) / abs(after - before) + scale *= math.copysign(1.0, after - before) + return smooth_slope * math.exp(scale) + + def compute_control_points(self) -> None: + """Place the Bezier handles along this keyframe's tangent. + + Each handle reaches ``1 / control_extent`` of the way to the + neighbour it faces, measured as a straight-line distance in the + (time, value) plane. Its time component is clamped inside the interval + so that time stays monotonic along the curve, which is what lets + :meth:`_BezierSegment.__call__` solve for the parameter. + """ + slope = self.derivative + + if self.previous is None: + self.control_point_1 = None + else: + distance = math.hypot(self.time - self.previous.time, + self.value - self.previous.value) + dtime, dvalue = _tangent_step(slope, distance / self.control_extent) + self.control_point_1 = (max(self.time - dtime, self.previous.time), + self.value - dvalue) + + if self.next is None: + self.control_point_2 = None + else: + distance = math.hypot(self.time - self.next.time, + self.value - self.next.value) + dtime, dvalue = _tangent_step(slope, distance / self.control_extent) + self.control_point_2 = (min(self.time + dtime, self.next.time), + self.value + dvalue) + + +# --------------------------------------------------------------------------- +# Segments +# --------------------------------------------------------------------------- + + +class _Segment: + """A piece of the curve, valid between two keyframes.""" + + def __call__(self, time: float) -> float: + raise NotImplementedError + + +class _ConstantSegment(_Segment): + def __init__(self, value: float) -> None: + self.value = float(value) + + def __call__(self, time: float) -> float: + return self.value + + +class _LinearSegment(_Segment): + def __init__(self, t1: float, v1: float, t2: float, v2: float) -> None: + self.t1, self.v1, self.t2, self.v2 = t1, v1, t2, v2 + + def __call__(self, time: float) -> float: + span = self.t2 - self.t1 + if span == 0: + return self.v2 + t = min(1.0, max(0.0, (time - self.t1) / span)) + return self.v1 * (1.0 - t) + self.v2 * t + + +class _HermiteSegment(_Segment): + """Cubic Hermite between two keyframes with prescribed end tangents.""" + + def __init__(self, t1: float, v1: float, d1: float, + t2: float, v2: float, d2: float) -> None: + self.t1, self.v1, self.d1 = t1, v1, d1 + self.t2, self.v2, self.d2 = t2, v2, d2 + + def __call__(self, time: float) -> float: + span = self.t2 - self.t1 + if span == 0: + return self.v2 + t = min(1.0, max(0.0, (time - self.t1) / span)) + t2 = t * t + t3 = t2 * t + return ((2.0 * t3 - 3.0 * t2 + 1.0) * self.v1 + + (t3 - 2.0 * t2 + t) * span * self.d1 + + (-2.0 * t3 + 3.0 * t2) * self.v2 + + (t3 - t2) * span * self.d2) + + +def _bezier(a: float, b: float, c: float, d: float, u: float) -> float: + m = 1.0 - u + return (m * m * m * a + 3.0 * m * m * u * b + + 3.0 * m * u * u * c + u * u * u * d) + + +def _bezier_slope(a: float, b: float, c: float, d: float, u: float) -> float: + m = 1.0 - u + return 3.0 * m * m * (b - a) + 6.0 * m * u * (c - b) + 3.0 * u * u * (d - c) + + +class _BezierSegment(_Segment): + """Cubic Bezier through two keyframes and the handles facing each other.""" + + #: Newton is seeded with normalized time and converges in a couple of steps + #: for handles this well behaved; the cap is a guard, not a budget. + _NEWTON_STEPS = 8 + _BISECTION_STEPS = 60 + _TOLERANCE = 1e-12 + + def __init__(self, first: Keyframe, second: Keyframe) -> None: + self.p1 = (first.time, first.value) + self.p2 = first.control_point_2 or (first.time, first.value) + self.p3 = second.control_point_1 or (second.time, second.value) + self.p4 = (second.time, second.value) + + def parameter_at(self, time: float) -> float: + """The curve parameter whose time component is ``time``. + + The parameter is not normalized time: both coordinates of a cubic + Bezier are cubic in it. Time is monotonic along the curve because + :meth:`Keyframe.compute_control_points` clamps the handles into the + interval, so Newton from normalized time converges, and bisection is a + safe fallback when a flat spot makes the Newton step useless. + """ + x1, x2, x3, x4 = self.p1[0], self.p2[0], self.p3[0], self.p4[0] + span = x4 - x1 + if span <= 0: + return 0.0 + + u = min(1.0, max(0.0, (time - x1) / span)) + for _ in range(self._NEWTON_STEPS): + error = _bezier(x1, x2, x3, x4, u) - time + if abs(error) < self._TOLERANCE: + return u + slope = _bezier_slope(x1, x2, x3, x4, u) + if abs(slope) < 1e-9: + break + stepped = u - error / slope + if not (0.0 <= stepped <= 1.0): + break + u = stepped + + low, high = 0.0, 1.0 + u = 0.5 + for _ in range(self._BISECTION_STEPS): + x = _bezier(x1, x2, x3, x4, u) + if abs(x - time) < self._TOLERANCE: + break + if x < time: + low = u + else: + high = u + u = 0.5 * (low + high) + return u + + def __call__(self, time: float) -> float: + u = self.parameter_at(time) + return _bezier(self.p1[1], self.p2[1], self.p3[1], self.p4[1], u) + + +def _make_segment(first: Keyframe, second: Keyframe) -> _Segment: + """The piece of curve running from ``first`` to ``second``. + + ``first``'s mode decides how the segment leaves and ``second``'s decides how + it arrives, so both are consulted. A hold overrides everything else. + """ + if first.interpolation in HOLD_OUT_MODES: + return _ConstantSegment(first.value) + + if first.interpolation == Interpolation.Linear: + if second.interpolation in LINEAR_IN_MODES: + return _LinearSegment(first.time, first.value, + second.time, second.value) + # Leaves linearly but arrives smoothly. The reference implementation + # produces no segment at all here, which desynchronizes its segment and + # end-time lists; a Hermite entered along the chord is the reading its + # mode names imply. + span = second.time - first.time + chord = 0.0 if span == 0 else (second.value - first.value) / span + return _HermiteSegment(first.time, first.value, chord, + second.time, second.value, second.derivative) + + if first.interpolation in BEZIER_OUT_MODES: + return _BezierSegment(first, second) + + if first.interpolation in HERMITE_OUT_MODES: + return _HermiteSegment(first.time, first.value, first.derivative, + second.time, second.value, second.derivative) + + raise ValueError("Unhandled interpolation mode: %r" % (first.interpolation,)) + + +# --------------------------------------------------------------------------- +# The interpolator +# --------------------------------------------------------------------------- + + +class Interpolator: + """Piecewise interpolation of one value over a list of keyframes. + + >>> interp = Interpolator([Keyframe(0, 0.0), Keyframe(1, 10.0)]) + >>> interp(0.0), interp(1.0) + (0.0, 10.0) + + Times before the first keyframe and after the last hold that keyframe's + value. + """ + + def __init__(self, keyframes: Iterable[Keyframe]) -> None: + self.keyframes: List[Keyframe] = list(keyframes) + if not self.keyframes: + raise ValueError("An interpolator needs at least one keyframe") + self.segments: List[_Segment] = [] + #: ``segments[i]`` applies up to ``segment_end_times[i]``. + self.segment_end_times: List[float] = [] + self.build() + + def build(self) -> None: + """Sort and link the keyframes, then assemble the segments.""" + # Keep the last of any keyframes sharing a time, matching the way the + # animation panel replaces a keyframe when one is added over another. + unique: "dict[float, Keyframe]" = {} + for frame in sorted(self.keyframes, key=lambda k: k.time): + unique[frame.time] = frame + frames = list(unique.values()) + self.keyframes = frames + + for index, frame in enumerate(frames): + frame.previous = frames[index - 1] if index > 0 else None + frame.next = frames[index + 1] if index + 1 < len(frames) else None + + # Derivatives first: the control points are placed along them. + for frame in frames: + frame.derivative = frame.compute_derivative() + for frame in frames: + frame.compute_control_points() + + if len(frames) == 1: + self.segments = [_ConstantSegment(frames[0].value)] + self.segment_end_times = [frames[0].time] + return + + self.segments = [] + self.segment_end_times = [] + for first, second in zip(frames[:-1], frames[1:]): + self.segments.append(_make_segment(first, second)) + self.segment_end_times.append(second.time) + + @property + def start(self) -> float: + return self.keyframes[0].time + + @property + def end(self) -> float: + return self.keyframes[-1].time + + def at(self, time: float) -> float: + """The interpolated value at ``time``, held outside the keyframe range.""" + if time <= self.keyframes[0].time: + return self.keyframes[0].value + if time >= self.keyframes[-1].time: + return self.keyframes[-1].value + # Each segment covers [start, end), so a time landing exactly on an + # interior keyframe belongs to the segment starting there. Without the + # strict comparison a hold-out segment would swallow the keyframe that + # ends it and report the held value instead of the new one. + for end_time, segment in zip(self.segment_end_times, self.segments): + if time < end_time: + return segment(time) + return self.keyframes[-1].value + + def __call__(self, time: float) -> float: + return self.at(time) + + def __len__(self) -> int: + return len(self.keyframes) + + def __getitem__(self, index: int) -> Keyframe: + return self.keyframes[index] + + +def from_values(times: Sequence[float], values: Sequence[float], + interpolation: Interpolation = Interpolation.Bezier, + modes: Optional[Sequence[Interpolation]] = None) -> Interpolator: + """Build an :class:`Interpolator` from parallel time and value sequences. + + Parameters + ---------- + times : sequence of float + Keyframe times. + values : sequence of float + Keyframe values, the same length as ``times``. + interpolation : Interpolation, optional + Mode for keyframes with no entry in ``modes``. Default + ``Interpolation.Bezier``. + modes : sequence of Interpolation or None, optional + Per-keyframe modes, the same length as ``times``. An entry of ``None`` + falls back to ``interpolation``. + + Returns + ------- + Interpolator + """ + if len(times) != len(values): + raise ValueError("times and values must be the same length") + if modes is not None and len(modes) != len(times): + raise ValueError("modes must be the same length as times") + + frames = [] + for index, (time, value) in enumerate(zip(times, values)): + mode = interpolation if modes is None or modes[index] is None \ + else modes[index] + frames.append(Keyframe(time, value, mode)) + return Interpolator(frames) + + +# --------------------------------------------------------------------------- +# Whole-view interpolation +# --------------------------------------------------------------------------- +# +# The animation keyframes the viewer produces are whole view dicts, not scalars, +# so they are taken apart into independent one-dimensional "channels" -- one per +# property, or one per component for the array-valued ones -- each with its own +# Interpolator. This is the python side of vt.buildInterpolators/vt.evaluate in +# resources/js/viewtools.js and must agree with it. + +#: Leaf property names that are numeric but discrete. Interpolating them yields +#: values their setter cannot use, and ``layers`` recompiles the shaders on every +#: assignment, so a fractional layer count is both wrong and very slow. +STEP_PROP_LEAVES = frozenset({"layers"}) + +#: Properties measured in degrees around a circle, which have to be unwrapped +#: before a spline can be fitted to them. +ANGLE_PROPS = frozenset({"camera.azimuth"}) + +#: Keys that are animation bookkeeping rather than view properties. +RESERVED_KEYS = frozenset({"time", "frame", "interpolation"}) + + +def shortest_step(start: float, end: float) -> float: + """The signed change in degrees from ``start`` to ``end``, at most half a turn. + + Reproduces ``Viewer._animInterp`` in ``resources/js/mriview.js`` exactly, + tie-break included: half a turn is equally short either way, and + ``_animInterp`` resolves it by travelling *against* the sign of the raw + difference. ``math.fmod`` rather than ``%`` because the sign of the + remainder has to match javascript's. + """ + step = math.fmod(end - start, 360.0) + if step >= 180.0: + return step - 360.0 + if step <= -180.0: + return step + 360.0 + return step + + +def unwrap_angles(values: Sequence[float]) -> List[float]: + """Re-express an angle sequence so it never jumps by a full turn.""" + out = [float(values[0])] + for index in range(1, len(values)): + out.append(out[index - 1] + shortest_step(values[index - 1], values[index])) + return out + + +def wrap_angle(value: float) -> float: + """Bring an angle back into ``[0, 360)``.""" + return value % 360.0 + + +def _is_sequence(value: object) -> bool: + if isinstance(value, (str, bytes, dict)): + return False + return isinstance(value, (list, tuple)) or hasattr(value, "__len__") + + +def _is_blendable(value: object) -> bool: + """Whether a value is a finite number. ``bool`` is excluded deliberately.""" + if isinstance(value, (bool, str, bytes)) or value is None: + return False + try: + return math.isfinite(float(value)) # type: ignore[arg-type] + except (TypeError, ValueError): + return False + + +class _StepChannel: + """Holds the value of the latest keyframe at or before the requested time.""" + + def __init__(self, frames: Sequence[dict], prop: str, time_key: str) -> None: + self._points = [(frame[time_key], frame[prop]) + for frame in frames if prop in frame] + self._fallback = frames[0].get(prop) + + def at(self, time: float) -> object: + value = self._fallback + for point_time, point_value in self._points: + if point_time > time: + break + value = point_value + return value + + +class _SmoothChannel: + def __init__(self, interpolator: Interpolator, is_angle: bool) -> None: + self._interpolator = interpolator + self._is_angle = is_angle + + def at(self, time: float) -> object: + value = self._interpolator.at(time) + return wrap_angle(value) if self._is_angle else value + + +class _ArrayChannel: + def __init__(self, components: Sequence[_SmoothChannel]) -> None: + self._components = list(components) + + def at(self, time: float) -> object: + return [component.at(time) for component in self._components] + + +def _modes_of(frames: Sequence[dict], + default_mode: Interpolation) -> List[Interpolation]: + modes = [] + for frame in frames: + mode = frame.get("interpolation", default_mode) + try: + modes.append(Interpolation(mode)) + except ValueError: + modes.append(default_mode) + return modes + + +def _smooth_channel(frames: Sequence[dict], times: Sequence[float], + values: Sequence[float], is_angle: bool, + default_mode: Interpolation) -> _SmoothChannel: + if is_angle: + values = unwrap_angles(values) + keyframes = [Keyframe(time, value, mode) for time, value, mode + in zip(times, values, _modes_of(frames, default_mode))] + return _SmoothChannel(Interpolator(keyframes), is_angle) + + +def _make_channel(frames: Sequence[dict], prop: str, time_key: str, + default_mode: Interpolation) -> object: + times = [frame[time_key] for frame in frames] + first = frames[0].get(prop) + leaf = prop.split(".")[-1] + + if first is None or leaf in STEP_PROP_LEAVES or isinstance(first, (bool, str)): + return _StepChannel(frames, prop, time_key) + + if _is_sequence(first): + length = len(first) # type: ignore[arg-type] + for frame in frames: + value = frame.get(prop) + if not _is_sequence(value) or len(value) != length: # type: ignore[arg-type] + return _StepChannel(frames, prop, time_key) + if not all(_is_blendable(item) for item in value): # type: ignore[union-attr] + return _StepChannel(frames, prop, time_key) + components = [ + _smooth_channel(frames, times, + [float(frame[prop][index]) for frame in frames], + False, default_mode) + for index in range(length) + ] + return _ArrayChannel(components) + + for frame in frames: + if not _is_blendable(frame.get(prop)): + return _StepChannel(frames, prop, time_key) + + return _smooth_channel(frames, times, + [float(frame[prop]) for frame in frames], + prop in ANGLE_PROPS, default_mode) + + +def build_channels(keyframes: Sequence[dict], time_key: str = "time", + default_mode: Interpolation = Interpolation.Bezier + ) -> "dict[str, object]": + """Split a list of keyframe view dicts into per-property interpolators. + + Parameters + ---------- + keyframes : sequence of dict + Each holds ``time_key`` plus view properties, and optionally an + ``interpolation`` key naming that keyframe's :class:`Interpolation`. + time_key : str, optional + The key holding each keyframe's position on the time axis. ``"time"`` + in python, ``"frame"`` in the browser. Default ``"time"``. + default_mode : Interpolation, optional + Mode for keyframes with no ``interpolation`` key. Default + ``Interpolation.Bezier``. + + Returns + ------- + dict + Property name to an object with an ``at(time)`` method. Properties + absent from the first keyframe are skipped, matching the way + ``_get_anim_seq`` iterates the earlier view of each pair. + """ + frames = sorted(keyframes, key=lambda frame: frame[time_key]) + if not frames: + raise ValueError("Need at least one keyframe") + + channels: "dict[str, object]" = {} + for prop in frames[0]: + if prop in RESERVED_KEYS: + continue + channels[prop] = _make_channel(frames, prop, time_key, default_mode) + return channels + + +def evaluate(channels: "dict[str, object]", time: float) -> "dict[str, object]": + """Reassemble a view dict from the channels built by :func:`build_channels`.""" + return {prop: channel.at(time) # type: ignore[attr-defined] + for prop, channel in channels.items()} diff --git a/cortex/webgl/resources/css/mriview.css b/cortex/webgl/resources/css/mriview.css index 982550cbc..40782b359 100644 --- a/cortex/webgl/resources/css/mriview.css +++ b/cortex/webgl/resources/css/mriview.css @@ -653,6 +653,27 @@ button#twodbutton:disabled, button#twodbutton[disabled] { width: 190px; } +/* The smoothing dropdown. Form controls do not inherit the panel's font, so + the size is restated here to keep the row the same height as the others. */ +.pycortex-row select { + background-color: #303030; + border: 1px solid #3c3c3c; + border-radius: 2px; + color: #2fa1d6; + font: 11px 'Lucida Grande', sans-serif; + padding: 1px 2px; + width: 196px; +} + +.pycortex-row select:disabled { + color: #666; +} + +/* "smoothing" overruns the 44px label column the paired number fields use. */ +.anim-interp-row label { + width: 64px; +} + .pycortex-buttons { text-align: center; } diff --git a/cortex/webgl/resources/js/interpolation.js b/cortex/webgl/resources/js/interpolation.js new file mode 100644 index 000000000..2a68f203f --- /dev/null +++ b/cortex/webgl/resources/js/interpolation.js @@ -0,0 +1,438 @@ +// Keyframe interpolation for viewer animations. +// +// A one-dimensional piecewise interpolator built from a list of keyframes, each +// carrying its own interpolation mode. The modes follow the way Adobe products +// describe keyframe interpolation -- a mode names how a keyframe is entered and +// how it is left, so the shape of the curve between two keyframes is decided by +// the pair of modes at its ends -- with cubic hermite added. +// +// cortex/webgl/interpolation.py is the twin of this file and must stay in step +// with it: the animation panel interpolates here while JSMixer._get_anim_seq +// interpolates in python, and an animation should look the same either way. +// Both use the same closed forms and both carry the mode as one of the strings +// in `Interpolation`, so a keyframe crosses between them unchanged. There is a +// test asserting the two agree numerically. +// +// Deviations from the reference implementation this was ported from are marked +// with "Deviation:" comments at the four sites where they occur. + +var jsplot = (function (module) { + module.interpolation = (function (ip) { + + // How a keyframe is entered and left. Strings rather than numbers so a + // keyframe survives JSON round-trips to python as its own name. + ip.Interpolation = { + Linear: "Linear", // linear in, linear out + LinearInHoldOut: "LinearInHoldOut", // linear in, then hold + CubicHermite: "CubicHermite", // auto derivatives, no over/undershoot + LinearInCubicHermiteOut: "LinearInCubicHermiteOut", // linear in, hermite out, may overshoot + Bezier: "Bezier", // auto control points, no over/undershoot + BezierInHoldOut: "BezierInHoldOut", // bezier in, then hold + CubicHermiteInHoldOut: "CubicHermiteInHoldOut", // hermite in, then hold + LinearInBezierOut: "LinearInBezierOut" // linear in, bezier out, may overshoot + }; + + // The order the animation panel lists them in: the two that cannot + // over/undershoot first, then the holds, then the ones that can. + ip.MODE_ORDER = [ + "Bezier", + "CubicHermite", + "Linear", + "BezierInHoldOut", + "CubicHermiteInHoldOut", + "LinearInHoldOut", + "LinearInBezierOut", + "LinearInCubicHermiteOut" + ]; + + // Short labels for the panel's dropdown. + ip.MODE_LABELS = { + Bezier: "bezier (smooth)", + CubicHermite: "cubic hermite (smooth)", + Linear: "linear", + BezierInHoldOut: "bezier in, hold", + CubicHermiteInHoldOut: "hermite in, hold", + LinearInHoldOut: "linear in, hold", + LinearInBezierOut: "linear in, bezier out", + LinearInCubicHermiteOut: "linear in, hermite out" + }; + + ip.DEFAULT_MODE = ip.Interpolation.Bezier; + + function has(set, mode) { + return set.indexOf(mode) >= 0; + } + + // Modes that hold their value from this keyframe until the next one. + var HOLD_OUT_MODES = ["LinearInHoldOut", "BezierInHoldOut", + "CubicHermiteInHoldOut"]; + // Modes entered linearly. + var LINEAR_IN_MODES = ["Linear", "LinearInHoldOut", + "LinearInCubicHermiteOut", "LinearInBezierOut"]; + // Modes left along a bezier. + var BEZIER_OUT_MODES = ["Bezier", "LinearInBezierOut"]; + // Modes left along a cubic hermite. + var HERMITE_OUT_MODES = ["CubicHermite", "LinearInCubicHermiteOut"]; + // Modes whose incoming derivative is the chord from the previous keyframe, + // which is what lets them over/undershoot. + var LINEAR_SLOPE_MODES = ["LinearInCubicHermiteOut", "LinearInBezierOut"]; + + ip.isValidMode = function(mode) { + return ip.Interpolation.hasOwnProperty(mode); + }; + + // How far bezier handles reach towards the neighbouring keyframe, as a + // fraction of the distance to it. A third is the usual choice: it is the + // spacing at which a cubic bezier reproduces the cubic hermite through the + // same tangents. + ip.DEFAULT_CONTROL_EXTENT = 3.0; + + function sign(x) { + return x < 0 ? -1 : 1; + } + + // Walk `distance` along a line of gradient `slope`, returning the signed + // (time, value) components of the step. + // + // Deviation: the reference takes a square root for the value component, + // which is always positive, so the handles of a keyframe with a negative + // derivative land off its own tangent line and break the C1 continuity the + // whole scheme exists to provide. The sign puts them back on it. + function tangentStep(slope, distance) { + var dtime = distance / Math.sqrt(1.0 + slope * slope); + if (slope === 0) + return [dtime, 0.0]; + return [dtime, + sign(slope) * distance / Math.sqrt(1.0 + 1.0 / (slope * slope))]; + } + + // ------------------------------------------------------------------ + // Keyframes + // ------------------------------------------------------------------ + + // A value at a time, and how the curve enters and leaves it. The neighbour + // links, derivative and control points are filled in by Interpolator1D -- + // a keyframe on its own does not know enough to compute them. + ip.Keyframe1D = function(time, value, mode, controlExtent) { + this.time = time; + this.value = value; + this.mode = ip.isValidMode(mode) ? mode : ip.DEFAULT_MODE; + this.controlExtent = controlExtent || ip.DEFAULT_CONTROL_EXTENT; + + this.previous = null; + this.next = null; + this.derivative = 0.0; + this.controlPoint1 = null; // handle on the incoming side + this.controlPoint2 = null; // handle on the outgoing side + }; + + // The tangent to use at this keyframe. + // + // Returns zero wherever a non-zero tangent could push the curve outside the + // values being interpolated: at the ends, at a local extremum, and where + // this keyframe repeats a neighbour's value. That, rather than any clamping + // after the fact, is what stops the smooth modes from over/undershooting. + ip.Keyframe1D.prototype.computeDerivative = function() { + var p = this.previous, n = this.next; + if (p === null || n === null) + return 0.0; + + var value = this.value, before = p.value, after = n.value; + if (value > before && value > after) // local maximum + return 0.0; + if (value < before && value < after) // local minimum + return 0.0; + if (value === before || value === after) + return 0.0; + if (has(HOLD_OUT_MODES, this.mode)) + return 0.0; + if (has(LINEAR_SLOPE_MODES, this.mode)) + // Enter along the chord and keep going: this is the overshoot the + // LinearIn* modes exist to allow. + return (value - before) / (this.time - p.time); + + // Otherwise take the tangent of the smooth curve that would pass + // through the neighbours if this keyframe were not there -- a hermite + // over (previous, next) flat at both ends -- and then stretch it by how + // far this keyframe sits from that curve. Sitting below the smooth path + // (in the direction of travel) steepens the tangent, above flattens it. + var span = n.time - p.time; + var t = (this.time - p.time) / span; + var smoothValue = before + (after - before) * (3.0 * t * t - 2.0 * t * t * t); + var smoothSlope = (after - before) * (6.0 * t - 6.0 * t * t) / span; + + // abs(after - before) cannot be zero here: a keyframe between two equal + // neighbours is either a local extremum or equal to them, and both + // cases have already returned. + var scale = 2.0 * (smoothValue - value) / Math.abs(after - before); + scale *= sign(after - before); + return smoothSlope * Math.exp(scale); + }; + + // Place the bezier handles along this keyframe's tangent. Each reaches + // 1/controlExtent of the way to the neighbour it faces, measured as a + // straight-line distance in the (time, value) plane. The time component is + // clamped inside the interval so that time stays monotonic along the curve, + // which is what lets BezierSegment solve for its parameter. + ip.Keyframe1D.prototype.computeControlPoints = function() { + var slope = this.derivative, step; + + if (this.previous === null) { + this.controlPoint1 = null; + } else { + step = tangentStep(slope, Math.sqrt( + Math.pow(this.time - this.previous.time, 2) + + Math.pow(this.value - this.previous.value, 2)) / this.controlExtent); + this.controlPoint1 = [Math.max(this.time - step[0], this.previous.time), + this.value - step[1]]; + } + + if (this.next === null) { + this.controlPoint2 = null; + } else { + step = tangentStep(slope, Math.sqrt( + Math.pow(this.time - this.next.time, 2) + + Math.pow(this.value - this.next.value, 2)) / this.controlExtent); + this.controlPoint2 = [Math.min(this.time + step[0], this.next.time), + this.value + step[1]]; + } + }; + + // ------------------------------------------------------------------ + // Segments + // ------------------------------------------------------------------ + + function ConstantSegment(value) { + this.value = value; + } + ConstantSegment.prototype.at = function(time) { + return this.value; + }; + + function LinearSegment(t1, v1, t2, v2) { + this.t1 = t1; this.v1 = v1; this.t2 = t2; this.v2 = v2; + } + LinearSegment.prototype.at = function(time) { + var span = this.t2 - this.t1; + if (span === 0) + return this.v2; + var t = Math.min(1.0, Math.max(0.0, (time - this.t1) / span)); + return this.v1 * (1.0 - t) + this.v2 * t; + }; + + // Cubic hermite between two keyframes with prescribed end tangents. + function HermiteSegment(t1, v1, d1, t2, v2, d2) { + this.t1 = t1; this.v1 = v1; this.d1 = d1; + this.t2 = t2; this.v2 = v2; this.d2 = d2; + } + HermiteSegment.prototype.at = function(time) { + var span = this.t2 - this.t1; + if (span === 0) + return this.v2; + var t = Math.min(1.0, Math.max(0.0, (time - this.t1) / span)); + var t2 = t * t, t3 = t2 * t; + return ((2.0 * t3 - 3.0 * t2 + 1.0) * this.v1 + + (t3 - 2.0 * t2 + t) * span * this.d1 + + (-2.0 * t3 + 3.0 * t2) * this.v2 + + (t3 - t2) * span * this.d2); + }; + + function bezier(a, b, c, d, u) { + var m = 1.0 - u; + return (m * m * m * a + 3.0 * m * m * u * b + + 3.0 * m * u * u * c + u * u * u * d); + } + + function bezierSlope(a, b, c, d, u) { + var m = 1.0 - u; + return 3.0 * m * m * (b - a) + 6.0 * m * u * (c - b) + 3.0 * u * u * (d - c); + } + + var NEWTON_STEPS = 8, BISECTION_STEPS = 60, TOLERANCE = 1e-12; + + // Cubic bezier through two keyframes and the handles facing each other. + function BezierSegment(first, second) { + this.p1 = [first.time, first.value]; + this.p2 = first.controlPoint2 || [first.time, first.value]; + this.p3 = second.controlPoint1 || [second.time, second.value]; + this.p4 = [second.time, second.value]; + } + + // The curve parameter whose time component is `time`. + // + // Deviation: the reference returns early using normalized time as the + // parameter, leaving its own root-finding loop unreachable. Both + // coordinates of a cubic bezier are cubic in the parameter, so the two + // agree only when the handles happen to be evenly spaced in time -- which + // is exactly when the smoothing is doing nothing. Time is monotonic along + // the curve because computeControlPoints clamps the handles into the + // interval, so newton from normalized time converges, and bisection is a + // safe fallback when a flat spot makes the newton step useless. + BezierSegment.prototype.parameterAt = function(time) { + var x1 = this.p1[0], x2 = this.p2[0], x3 = this.p3[0], x4 = this.p4[0]; + var span = x4 - x1; + if (span <= 0) + return 0.0; + + var u = Math.min(1.0, Math.max(0.0, (time - x1) / span)); + var i, error, slope, stepped; + for (i = 0; i < NEWTON_STEPS; i++) { + error = bezier(x1, x2, x3, x4, u) - time; + if (Math.abs(error) < TOLERANCE) + return u; + slope = bezierSlope(x1, x2, x3, x4, u); + if (Math.abs(slope) < 1e-9) + break; + stepped = u - error / slope; + if (!(stepped >= 0.0 && stepped <= 1.0)) + break; + u = stepped; + } + + var low = 0.0, high = 1.0, x; + u = 0.5; + for (i = 0; i < BISECTION_STEPS; i++) { + x = bezier(x1, x2, x3, x4, u); + if (Math.abs(x - time) < TOLERANCE) + break; + if (x < time) + low = u; + else + high = u; + u = 0.5 * (low + high); + } + return u; + }; + + BezierSegment.prototype.at = function(time) { + return bezier(this.p1[1], this.p2[1], this.p3[1], this.p4[1], + this.parameterAt(time)); + }; + + // The piece of curve running from `first` to `second`. `first`'s mode + // decides how the segment leaves and `second`'s decides how it arrives, so + // both are consulted. A hold overrides everything else. + function makeSegment(first, second) { + if (has(HOLD_OUT_MODES, first.mode)) + return new ConstantSegment(first.value); + + if (first.mode === ip.Interpolation.Linear) { + if (has(LINEAR_IN_MODES, second.mode)) + return new LinearSegment(first.time, first.value, + second.time, second.value); + // Deviation: leaves linearly but arrives smoothly. The reference + // produces no segment at all for this combination, desynchronizing + // its segment and end-time lists; a hermite entered along the chord + // is the reading its mode names imply. + var span = second.time - first.time; + var chord = span === 0 ? 0 : (second.value - first.value) / span; + return new HermiteSegment(first.time, first.value, chord, + second.time, second.value, second.derivative); + } + + if (has(BEZIER_OUT_MODES, first.mode)) + return new BezierSegment(first, second); + + if (has(HERMITE_OUT_MODES, first.mode)) + return new HermiteSegment(first.time, first.value, first.derivative, + second.time, second.value, second.derivative); + + throw new Error("Unhandled interpolation mode: " + first.mode); + } + + // ------------------------------------------------------------------ + // The interpolator + // ------------------------------------------------------------------ + + // Piecewise interpolation of one value over a list of Keyframe1D. Times + // before the first keyframe and after the last hold that keyframe's value, + // which is what the panel's frame slider does at either end of its range. + ip.Interpolator1D = function(keyframes) { + this.keyframes = keyframes.slice(); + if (this.keyframes.length === 0) + throw new Error("An interpolator needs at least one keyframe"); + this.segments = []; + this.segmentEndTimes = []; // segments[i] applies up to segmentEndTimes[i] + this.build(); + }; + + ip.Interpolator1D.prototype.build = function() { + var i, frames = this.keyframes.slice().sort(function(a, b) { + return a.time - b.time; + }); + + // Keep the last of any keyframes sharing a time, matching the way the + // animation panel replaces a keyframe when one is added over another. + var unique = []; + for (i = 0; i < frames.length; i++) { + if (unique.length > 0 && + unique[unique.length - 1].time === frames[i].time) + unique[unique.length - 1] = frames[i]; + else + unique.push(frames[i]); + } + frames = unique; + this.keyframes = frames; + + for (i = 0; i < frames.length; i++) { + frames[i].previous = i > 0 ? frames[i - 1] : null; + frames[i].next = i + 1 < frames.length ? frames[i + 1] : null; + } + + // Derivatives first: the control points are placed along them. + for (i = 0; i < frames.length; i++) + frames[i].derivative = frames[i].computeDerivative(); + for (i = 0; i < frames.length; i++) + frames[i].computeControlPoints(); + + this.segments = []; + this.segmentEndTimes = []; + + // Deviation: the reference builds a one-element segment list for a lone + // keyframe and then keeps going, immediately discarding it. + if (frames.length === 1) { + this.segments.push(new ConstantSegment(frames[0].value)); + this.segmentEndTimes.push(frames[0].time); + return; + } + + for (i = 0; i < frames.length - 1; i++) { + this.segments.push(makeSegment(frames[i], frames[i + 1])); + this.segmentEndTimes.push(frames[i + 1].time); + } + }; + + ip.Interpolator1D.prototype.at = function(time) { + var frames = this.keyframes; + if (time <= frames[0].time) + return frames[0].value; + if (time >= frames[frames.length - 1].time) + return frames[frames.length - 1].value; + + // Each segment covers [start, end), so a time landing exactly on an + // interior keyframe belongs to the segment starting there. Without the + // strict comparison a hold-out segment would swallow the keyframe that + // ends it and report the held value instead of the new one. + for (var i = 0; i < this.segments.length; i++) + if (time < this.segmentEndTimes[i]) + return this.segments[i].at(time); + return frames[frames.length - 1].value; + }; + + // Build an Interpolator1D from parallel time and value arrays. `modes` is + // optional and may hold a null per keyframe to fall back to `mode`. + ip.fromValues = function(times, values, mode, modes) { + var frames = []; + for (var i = 0; i < times.length; i++) { + var m = (modes !== undefined && modes !== null && modes[i]) ? + modes[i] : mode; + frames.push(new ip.Keyframe1D(times[i], values[i], m)); + } + return new ip.Interpolator1D(frames); + }; + + return ip; + }(module.interpolation || {})); + + return module; +}(jsplot || {})); diff --git a/cortex/webgl/resources/js/viewtools.js b/cortex/webgl/resources/js/viewtools.js index cdb2d8969..9cbc60c5a 100644 --- a/cortex/webgl/resources/js/viewtools.js +++ b/cortex/webgl/resources/js/viewtools.js @@ -139,8 +139,9 @@ var jsplot = (function (module) { delete params[key]; } } - delete params['frame']; // animation bookkeeping, not a menu path - delete params['time']; // written by _capture_view(frame_time=...) + delete params['frame']; // animation bookkeeping, not a menu path + delete params['interpolation']; // ditto: the keyframe's smoothing mode + delete params['time']; // written by _capture_view(frame_time=...) var subjects = vt.subjects(viewer); var unfold = 'surface.' + SUBJ + '.unfold'; @@ -215,6 +216,176 @@ var jsplot = (function (module) { return out; }; + // ------------------------------------------------------------------ + // Smoothed interpolation across a whole keyframe list + // ------------------------------------------------------------------ + // + // vt.interpolate above blends one pair of views, which is all linear + // interpolation ever needs. The smooth modes need more: a tangent at a + // keyframe depends on the keyframes on *both* sides of it, so the curve + // cannot be built from a bracketing pair alone. + // + // buildInterpolators therefore takes the keyframe list apart into + // independent one-dimensional "channels" -- one per property, or one per + // component for the array-valued properties -- and hands each to an + // Interpolator1D from resources/js/interpolation.js. evaluate() puts a view + // dict back together from them. + + // camera.azimuth is an angle, so a spline over it needs a sequence that + // does not jump by 360 at the wrap. Re-express each value as the previous + // one plus the shortest signed step to it; the caller wraps the result back + // into [0, 360). + // + // The step reproduces Viewer._animInterp exactly, tie-break included: half + // a turn is equally short either way, and _animInterp resolves it by going + // *against* the sign of the raw difference (it adds 360 to the end value + // when travelling backwards and subtracts it when travelling forwards). A + // symmetric "shortest angle" formula picks the other direction for one of + // the two cases, which would reverse a 180-degree spin that used to work. + function shortestStep(from, to) { + var step = (to - from) % 360; // truncating remainder: keeps the sign + if (step >= 180) + return step - 360; + if (step <= -180) + return step + 360; + return step; + } + + function unwrapAngles(values) { + var out = [values[0]]; + for (var i = 1; i < values.length; i++) + out.push(out[i - 1] + shortestStep(values[i - 1], values[i])); + return out; + } + + function wrapAngle(value) { + return ((value % 360) + 360) % 360; + } + + // Properties that cannot be blended hold the value of the most recent + // keyframe at or before the requested frame -- the same rule vt.interpolate + // applies to the earlier of its pair. + function stepChannel(frames, prop) { + return {at: function(f) { + var value = frames[0][prop]; + for (var i = 0; i < frames.length; i++) { + if (frames[i].frame > f) + break; + if (frames[i][prop] !== undefined) + value = frames[i][prop]; + } + return value; + }}; + } + + function modesOf(frames) { + var modes = []; + for (var i = 0; i < frames.length; i++) + modes.push(frames[i].interpolation); + return modes; + } + + // One Interpolator1D over `values`, wrapping the result if it is an angle. + function smoothChannel(frames, values, isAngle) { + var ip = jsplot.interpolation; + var times = [], i; + for (i = 0; i < frames.length; i++) + times.push(frames[i].frame); + + var interp = ip.fromValues(times, isAngle ? unwrapAngles(values) : values, + ip.DEFAULT_MODE, modesOf(frames)); + return {at: function(f) { + var value = interp.at(f); + return isAngle ? wrapAngle(value) : value; + }}; + } + + function isBlendable(value) { + return typeof value === 'number' && isFinite(value); + } + + // Decide how one property should be animated, and build the channel for it. + function makeChannel(frames, prop) { + var leaf = prop.split('.').pop(); + var first = frames[0][prop], i, j; + + // Discrete or non-numeric in the first keyframe: nothing to blend. + if (first === null || first === undefined || STEP_PROPS[leaf] || + typeof first === 'boolean' || typeof first === 'string') + return stepChannel(frames, prop); + + if (first instanceof Array) { + // Every keyframe must agree on the length, and every element must + // be a finite number, or the whole property steps. + for (i = 0; i < frames.length; i++) { + var value = frames[i][prop]; + if (!(value instanceof Array) || value.length !== first.length) + return stepChannel(frames, prop); + for (j = 0; j < value.length; j++) + if (!isBlendable(value[j])) + return stepChannel(frames, prop); + } + + var components = []; + for (j = 0; j < first.length; j++) { + var column = []; + for (i = 0; i < frames.length; i++) + column.push(frames[i][prop][j]); + components.push(smoothChannel(frames, column, false)); + } + return {at: function(f) { + var out = []; + for (var k = 0; k < components.length; k++) + out.push(components[k].at(f)); + return out; + }}; + } + + for (i = 0; i < frames.length; i++) + if (!isBlendable(frames[i][prop])) + return stepChannel(frames, prop); + + return smoothChannel(frames, (function() { + var column = []; + for (var k = 0; k < frames.length; k++) + column.push(frames[k][prop]); + return column; + }()), prop === 'camera.azimuth'); + } + + // Take a keyframe list apart into per-property channels. Properties absent + // from the first keyframe are ignored, matching vt.interpolate's rule of + // iterating the earlier view. + vt.buildInterpolators = function(keyframes) { + var frames = keyframes.slice().sort(function(a, b) { + return a.frame - b.frame; + }); + var channels = {}; + for (var prop in frames[0]) { + if (prop === 'frame' || prop === 'interpolation') + continue; + channels[prop] = makeChannel(frames, prop); + } + return {frames: frames, channels: channels}; + }; + + // The view dict at (possibly fractional) frame `f`. + vt.evaluate = function(built, f) { + var view = {}; + for (var prop in built.channels) + view[prop] = built.channels[prop].at(f); + return view; + }; + + // A whole animation in one call: the counterpart of JSMixer._get_anim_seq, + // useful for checking that the two implementations still agree. + vt.viewsAt = function(keyframes, frames) { + var built = vt.buildInterpolators(keyframes), views = []; + for (var i = 0; i < frames.length; i++) + views.push(vt.evaluate(built, frames[i])); + return views; + }; + // ------------------------------------------------------------------ // Small floating panels // ------------------------------------------------------------------ @@ -265,6 +436,10 @@ var jsplot = (function (module) { " ", " ", "", + "
    ", + " ", + " ", + "
    ", "
    ", " ", " ", @@ -286,13 +461,42 @@ var jsplot = (function (module) { "
    ", ].join("\n"); + // resources/js/interpolation.js is loaded from template.html, but a user + // template dir can shadow that template (see FallbackLoader), so an older + // copy may not pull it in. Everywhere the smoothing needs it we fall back + // to the previous behaviour -- linear between the bracketing pair -- rather + // than throwing on every frame. + function hasInterpolation() { + return typeof jsplot !== "undefined" && + jsplot.interpolation !== undefined; + } + + function defaultMode() { + return hasInterpolation() ? jsplot.interpolation.DEFAULT_MODE : "Linear"; + } + + function modeLabel(mode) { + // A keyframe built by hand may carry no mode; the interpolator treats + // that as the default, so label it that way too. + if (!mode) + mode = defaultMode(); + if (!hasInterpolation()) + return mode; + return jsplot.interpolation.MODE_LABELS[mode] || mode; + } + function AnimationPanel(viewer) { this.viewer = viewer; - viewer._anim = {frame: 0, first: 0, last: 30, fps: 30, keyframes: []}; + // `mode` is the smoothing applied to keyframes added from here on; each + // keyframe carries its own copy in an "interpolation" key. + viewer._anim = {frame: 0, first: 0, last: 30, fps: 30, keyframes: [], + mode: defaultMode()}; this.state = viewer._anim; this.playing = false; this.rendering = false; this._applied = undefined; + // Channel interpolators, rebuilt lazily whenever the keyframes change. + this._interp = null; this.panel = makePanel("animpanel", "Animation", ANIM_HTML, this.close.bind(this)); @@ -343,6 +547,27 @@ var jsplot = (function (module) { this._el("anim-clear").click(this.clearKeyframe.bind(this)); this._el("anim-play").click(this.playPause.bind(this)); + var select = this._el("anim-interp"); + if (hasInterpolation()) { + var order = jsplot.interpolation.MODE_ORDER; + for (var i = 0; i < order.length; i++) + $("").attr("value", order[i]) + .text(modeLabel(order[i])) + .appendTo(select); + select.val(st.mode); + select.on("change", function() { self.setMode(this.value); }); + } else { + select.prop("disabled", true) + .attr("title", "resources/js/interpolation.js is not loaded"); + } + // An id is enough to keep the viewer's single-letter shortcuts off an + // INPUT, but the guard in jsplot.Menu._add tests for INPUT only, and a + // focused SELECT still gets keypress events as the user types ahead. + // Without this, typing "b" to reach "bezier" would fold the brain. + select.on("keypress keydown keyup", function(event) { + event.stopPropagation(); + }); + var cfg = (typeof viewopts !== "undefined") ? viewopts.movie_post : undefined; var render = this._el("anim-render"); if (cfg === undefined) { @@ -383,9 +608,53 @@ var jsplot = (function (module) { this._el("anim-fps").val(st.fps); this._el("anim-frame").val(Math.round(st.frame)).attr({min: st.first, max: st.last}); this._el("anim-slider").attr({min: st.first, max: st.last}).val(st.frame); + + // The dropdown shows the mode that "add keyframe" would apply here: + // the existing keyframe's own mode when there is one, otherwise the + // mode chosen for new keyframes. + if (hasInterpolation()) { + var here = this.keyframeAt(Math.round(st.frame)); + this._el("anim-interp").val(here ? here.interpolation : st.mode); + } this.drawTicks(); }; + // The keyframe laid down at exactly `frame`, or null. + AnimationPanel.prototype.keyframeAt = function(frame) { + var kfs = this.state.keyframes; + for (var i = 0; i < kfs.length; i++) + if (kfs[i].frame === frame) + return kfs[i]; + return null; + }; + + // The keyframes changed, so the channel interpolators and the record of + // what is currently applied are both out of date. + AnimationPanel.prototype.invalidate = function() { + this._interp = null; + this._applied = undefined; + this.drawTicks(); + }; + + // Set the smoothing mode: on the keyframe under the playhead if there is + // one, and always as the mode new keyframes will be created with. + AnimationPanel.prototype.setMode = function(mode) { + if (!hasInterpolation() || !jsplot.interpolation.isValidMode(mode)) + return; + var st = this.state; + st.mode = mode; + + var here = this.keyframeAt(Math.round(st.frame)); + if (here !== null) { + here.interpolation = mode; + this.invalidate(); + this.setFrame(st.frame); + this.status("Keyframe " + here.frame + ": " + modeLabel(mode)); + } else { + this.status("New keyframes will use " + modeLabel(mode)); + } + }; + // One yellow dot per keyframe, positioned along the slider. Redrawn from // scratch so that changing first/last simply repositions everything. AnimationPanel.prototype.drawTicks = function() { @@ -401,7 +670,8 @@ var jsplot = (function (module) { var pct = 100 * (frame - st.first) / span; $("
    ") .css("left", pct + "%") - .attr("title", "keyframe at frame " + frame) + .attr("title", "keyframe at frame " + frame + " (" + + modeLabel(st.keyframes[i].interpolation) + ")") .appendTo(ticks); } }; @@ -413,10 +683,28 @@ var jsplot = (function (module) { }; // The interpolated view at (possibly fractional) frame `f`. + // + // Every property is carried by its own interpolator spanning the whole + // keyframe list, because the tangent at a keyframe depends on the ones on + // either side of it -- a bracketing pair is not enough to smooth. The + // channels are rebuilt only when the keyframes change; see invalidate(). AnimationPanel.prototype.viewAt = function(f) { - var kfs = this.sorted(); + var kfs = this.state.keyframes; if (kfs.length === 0) return null; + + if (!hasInterpolation()) + return this._viewAtLinear(f); + + if (this._interp === null) + this._interp = vt.buildInterpolators(kfs); + return vt.evaluate(this._interp, f); + }; + + // The pre-smoothing path, kept for templates that do not load + // resources/js/interpolation.js. + AnimationPanel.prototype._viewAtLinear = function(f) { + var kfs = this.sorted(); if (f <= kfs[0].frame) return kfs[0]; if (f >= kfs[kfs.length - 1].frame) @@ -450,21 +738,28 @@ var jsplot = (function (module) { var frame = Math.round(st.frame); var view = vt.captureView(this.viewer); view.frame = frame; + // The dropdown is the source of truth: sync() has already pointed it at + // the mode of any keyframe sitting here, so re-adding over one keeps + // that keyframe's smoothing instead of silently resetting it. + var chosen = this._el("anim-interp").val(); + view.interpolation = (hasInterpolation() && + jsplot.interpolation.isValidMode(chosen)) ? + chosen : st.mode; for (var i = 0; i < st.keyframes.length; i++) { if (st.keyframes[i].frame === frame) { st.keyframes[i] = view; - this._applied = undefined; - this.drawTicks(); - this.status("Replaced keyframe at frame " + frame); + this.invalidate(); + this.status("Replaced keyframe at frame " + frame + + " (" + modeLabel(view.interpolation) + ")"); return; } } st.keyframes.push(view); - this._applied = undefined; - this.drawTicks(); - this.status("Added keyframe at frame " + frame + - " (" + st.keyframes.length + " total)"); + this.invalidate(); + this.status("Added keyframe at frame " + frame + " (" + + modeLabel(view.interpolation) + ", " + + st.keyframes.length + " total)"); }; AnimationPanel.prototype.clearKeyframe = function() { @@ -473,8 +768,7 @@ var jsplot = (function (module) { for (var i = 0; i < st.keyframes.length; i++) { if (st.keyframes[i].frame === frame) { st.keyframes.splice(i, 1); - this._applied = undefined; - this.drawTicks(); + this.invalidate(); this.status("Removed keyframe at frame " + frame); return; } diff --git a/cortex/webgl/template.html b/cortex/webgl/template.html index 77a3da887..892729bdf 100644 --- a/cortex/webgl/template.html +++ b/cortex/webgl/template.html @@ -37,6 +37,7 @@ + {% if leapmotion %} diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index d658aa04e..db64f538d 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -28,6 +28,7 @@ from . import serve from .data import Package from .FallbackLoader import FallbackLoader +from .interpolation import Interpolation, build_channels, evaluate try: cmapdir = options.config.get('webgl', 'colormaps') @@ -1170,7 +1171,43 @@ def _get_anim_seq(self, keyframes, fps=30, interpolation='linear'): frames of an animation can be re-rendered, or for more control over the animation process in general. + Parameters + ---------- + keyframes : list of dicts + Each holds a 'time' in seconds plus view properties, in the form + ``_capture_view`` returns. A keyframe may also carry an + 'interpolation' key naming its own + :class:`~cortex.webgl.interpolation.Interpolation` mode, which + is how the browser's animation panel stores per-keyframe + smoothing. + fps : int, optional + Frame rate the times are quantized to. Default 30. + interpolation : str, optional + Either one of the three whole-animation easings, 'linear', + 'smoothstep' or 'smootherstep', or the name of one of the eight + per-keyframe modes in + :class:`~cortex.webgl.interpolation.Interpolation` -- in which + case it supplies the mode for keyframes that do not name one of + their own. Default 'linear'. + + Returns + ------- + list of dicts + One view dict per frame of the animation. + + Notes + ----- + The per-keyframe modes interpolate each property across the whole + keyframe list rather than between neighbouring pairs, because the + tangent at a keyframe depends on the keyframes on both sides of it. + The arithmetic is shared with the browser through + ``cortex/webgl/interpolation.py`` and its javascript twin, so an + animation built in the viewer renders the same way here. """ + if interpolation not in mixes or any( + 'interpolation' in frame for frame in keyframes): + return self._get_smoothed_anim_seq(keyframes, fps, interpolation) + # Misc. setup fr = 0 a = np.array @@ -1212,7 +1249,74 @@ def _get_anim_seq(self, keyframes, fps=30, interpolation='linear'): allframes.append(frame) return allframes - def make_movie_views(self, animation, filename="brainmovie%07d.png", + def _get_smoothed_anim_seq(self, keyframes, fps=30, + interpolation='Bezier'): + """``_get_anim_seq`` for the per-keyframe interpolation modes. + + Kept separate from the pairwise path above rather than replacing + it: 'smoothstep' and 'smootherstep' ease a whole segment and have + no per-keyframe equivalent, and leaving that code untouched is the + cheapest guarantee that existing animations still render frame for + frame as they did. + + The frame times are generated exactly as the pairwise path + generates them, so the two produce the same number of frames for + the same keyframes; only the values differ. + + One value differs in kind rather than degree: 'camera.azimuth' is + unwrapped before it is interpolated, so a spin takes the short way + around and 350 -> 10 degrees crosses zero instead of running all + the way back. That matches the viewer, whose own playback has always + done this (Viewer._animInterp in resources/js/mriview.js), and it is + what makes an animation laid out in the panel render the same way + here. The pairwise path is left alone and still runs the long way. + """ + if not keyframes: + return [] + if interpolation in mixes: + # 'linear' reaches here when a keyframe names its own mode. The + # two spellings mean the same curve, so map it across; the other + # two legacy easings have no per-keyframe form and are rejected. + if interpolation != 'linear': + raise ValueError( + "interpolation=%r eases a whole segment and cannot be " + "combined with per-keyframe modes; use one of %s" + % (interpolation, + ", ".join(m.value for m in Interpolation))) + interpolation = Interpolation.Linear + try: + default_mode = Interpolation(interpolation) + except ValueError: + raise ValueError( + "Unknown interpolation %r; expected one of %s, or one of " + "the whole-animation easings %s" + % (interpolation, + ", ".join(m.value for m in Interpolation), + ", ".join(sorted(mixes)))) from None + + # Quantize to the frame grid on copies. The pairwise path rewrites + # the caller's dicts in place; there is no reason to inherit that. + fs = 1. / fps + frames = [dict(frame) for frame in keyframes] + for frame in frames: + frame['time'] = np.round(frame['time'] / fs) * fs + frames.sort(key=lambda frame: frame['time']) + + channels = build_channels(frames, time_key='time', + default_mode=default_mode) + + allframes = [] + for start, end in zip(frames[:-1], frames[1:]): + t0, t1 = start['time'], end['time'] + use_endpoint = end is frames[-1] + nvalues = np.round((t1 - t0) / fs).astype(int) + if use_endpoint: + nvalues += 1 + for t in np.linspace(0, 1, nvalues, endpoint=use_endpoint): + allframes.append(evaluate(channels, t0 + t * (t1 - t0))) + return allframes + + def make_movie_views(self, animation, filename="brainmovie%07d.png", offset=0, fps=30, size=(1920, 1080), alpha=1, frame_sleep=0.05, frame_start=0, interpolation="linear"): """Renders movie frames for animation of mesh movement @@ -1245,8 +1349,17 @@ def make_movie_views(self, animation, filename="brainmovie%07d.png", Frame rate of resultant movie size : tuple (x, y) Size (in pixels) of resulting movie - interpolation : {"linear", "smoothstep", "smootherstep"} - Interpolation method for values between keyframes. + interpolation : str + How values between keyframes are found. Either one of the + whole-animation easings "linear", "smoothstep" or + "smootherstep", which blend each pair of neighbouring + keyframes, or one of the per-keyframe modes named by + :class:`~cortex.webgl.interpolation.Interpolation` -- "Bezier", + "CubicHermite", "Linear", "BezierInHoldOut", + "CubicHermiteInHoldOut", "LinearInHoldOut", + "LinearInBezierOut", "LinearInCubicHermiteOut" -- which fit a + curve through the whole keyframe list and so carry velocity + smoothly through the interior keyframes. Default "linear". Notes ----- @@ -1254,6 +1367,12 @@ def make_movie_views(self, animation, filename="brainmovie%07d.png", of the animation are initialized (have some starting value) in the first frame. + An individual keyframe may override `interpolation` by carrying its + own "interpolation" key, which is how animations built in the + viewer's animation panel store per-keyframe smoothing. The two + implementations share their arithmetic, so such an animation renders + here exactly as it played in the browser. + Example ------- # Called after a call of the form: js_handle = cortex.webgl.show(DataViewObject) diff --git a/docs/database.rst b/docs/database.rst index e0f9d20e3..81685cdf3 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -350,6 +350,43 @@ Animations The **create animation** button opens a panel for building an animation out of keyframes. Set the current frame with the slider (frames that already hold a keyframe are marked with a yellow dot), pose the brain, and press **add keyframe**; the values in between are interpolated. **play animation** previews the result at the chosen frame rate, and **render animation** writes one PNG per frame. +Smoothing +^^^^^^^^^ + +The **smoothing** dropdown sets how the curve through the keyframes is shaped. Each keyframe carries its own setting, which describes both how the animation arrives at it and how it leaves, so the motion between two keyframes depends on the pair at either end. The dropdown always shows the setting of the keyframe under the playhead; when there is no keyframe there it shows the one new keyframes will be given. + +Eight options are available: + +========================= ==================================================== +Option Behaviour +========================= ==================================================== +bezier (smooth) Default. A cubic Bezier with automatically placed + control points. Velocity carries smoothly through + the keyframe and the brain never swings past the + pose you set. +cubic hermite (smooth) As above, using the tangents directly rather than + control points. Very nearly the same curve. +linear Straight lines between keyframes, which is what the + panel did before smoothing was added. The motion + changes direction abruptly at each keyframe. +bezier in, hold Arrive smoothly, then freeze on this pose until the + next keyframe. +hermite in, hold As above, arriving along a Hermite tangent. +linear in, hold Arrive in a straight line, then freeze. +linear in, bezier out Arrive in a straight line and leave along it, easing + out with a Bezier. May swing past the next pose. +linear in, hermite out As above with a Hermite. May swing past the next + pose. +========================= ==================================================== + +The two "smooth" options and the three holds stay within the poses you set. The two "linear in" options carry the incoming speed out of the keyframe and so can overshoot, which is useful for a sense of momentum and unhelpful if you need the camera to stop exactly where you put it. + +The same eight modes are available when rendering from python, either for a whole animation:: + + viewer.make_movie_views(animation, interpolation="Bezier") + +or per keyframe, by giving a keyframe its own ``interpolation`` key — which is what the panel does. The browser and ``cortex.webgl.interpolation`` share their arithmetic, so a movie rendered from python matches the preview played in the viewer. The older whole-animation easings ``"linear"``, ``"smoothstep"`` and ``"smootherstep"`` still work, but ease each pair of keyframes separately and cannot be combined with per-keyframe modes. + Rendering needs a viewer started from python, since the frames are written by the server rather than by the browser. The folder named in the panel is interpreted relative to the ``movie_dir`` given to ``cortex.webgl.show`` (the current working directory by default), and the server refuses to write outside it:: viewer = cortex.webgl.show(volume, movie_dir="/path/to/movies") From b709c226cc0b437de40ec4babd4594864157e3e0 Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Mon, 21 Sep 2026 12:36:09 -0700 Subject: [PATCH 05/14] ENH: give every subject a default set of named views A freshly imported subject had nothing under camera > views until someone saved something there. Offer nine standard views for every subject instead: dorsal, ventral, lateral_left and lateral_right on the fiducial surface, the same four inflated, and flat. They are assembled from the tables save_views already keeps for save_3d_views rather than spelled out again, so the camera conventions stay in one place. dorsal/ventral/lateral_left/lateral_right are the existing top/bottom/left/right angles under anatomical names, which is worth stating precisely: the viewer puts the camera at radius * (sin(alt)cos(azi+90), sin(alt)sin(azi+90), cos(alt)) with up fixed at +z, and surfaces are in surface RAS, so azimuth 90 sits left of the brain and 270 right of it, altitude 0 above and 180 below. Because lookAt resolves the degenerate straight-up and straight-down cases through the azimuth, anterior lands at the top of the image at azimuth 180 seen from above and at azimuth 0 seen from below -- which is what "frontal lobe pointed up" needs. test_default_views.py reproduces that arithmetic and asserts each name against it, so the claim is checked rather than just documented. A view stored in the subject's filestore views/ directory under one of these names replaces that default, for that subject only, leaving the other eight in place. Saving over a default now also takes effect immediately: the views menu looks up what a button applies when it is clicked instead of capturing it, so a re-saved name no longer keeps applying the old view until the page is reloaded. Without a flat surface there is no flat view to offer, and the inflated views sit at full inflation rather than half -- the same correction save_3d_views makes. The flat view uses the viewer's established flatmap preset. It cannot match quickflat.make_figure exactly, since the viewer draws the flat surface through a 45-degree perspective camera while quickflat rasterizes it orthographically. What can be matched is the pixel size: make_png writes the flatmap image itself, whose width follows from the subject's flat surface bounding box, so _quickflat_size works that out with quickflat's own arithmetic and ships it in viewopts. The animation panel shows it in the render form once an animation actually reaches the flat surface, which is the only time matching it means anything. Co-Authored-By: Claude Opus 5 (1M context) --- cortex/export/save_views.py | 84 +++++++ cortex/tests/test_default_views.py | 316 +++++++++++++++++++++++++ cortex/tests/test_webgl_headless.py | 124 ++++++++++ cortex/webgl/resources/js/viewtools.js | 48 +++- cortex/webgl/view.py | 62 ++++- docs/database.rst | 32 +++ 6 files changed, 663 insertions(+), 3 deletions(-) create mode 100644 cortex/tests/test_default_views.py diff --git a/cortex/export/save_views.py b/cortex/export/save_views.py index 2d1f6e59f..a2c431f5c 100644 --- a/cortex/export/save_views.py +++ b/cortex/export/save_views.py @@ -330,3 +330,87 @@ 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" + + +def default_subject_views(has_flatmap: bool = True) -> 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. + + 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 is the closest the viewer comes to the layout + # quickflat.make_figure draws; the two cannot match exactly, since the + # viewer renders the flat surface through a 45-degree perspective + # camera while quickflat rasterizes it orthographically. + views[FLAT_VIEW_NAME] = build(angle_view_params["flatmap"], + unfold_view_params["flatmap"]) + return views diff --git a/cortex/tests/test_default_views.py b/cortex/tests/test_default_views.py new file mode 100644 index 000000000..0217880e8 --- /dev/null +++ b/cortex/tests/test_default_views.py @@ -0,0 +1,316 @@ +"""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_VIEW_NAME, + INFLATED_SUFFIX, + angle_view_params, + 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) + +# LandscapeControls clamps altitude into (0.0001, 179.9999) before building the +# camera position, so a view asking for 0 or 180 is rendered a hair off the +# pole. That matters: exactly at the pole the view direction is parallel to the +# up vector and the image orientation is undefined. +POLE_EPSILON = 1e-4 + + +def camera_basis(azimuth, altitude): + """The camera's axes in world space, as the viewer computes them. + + Reproduces the eye position from ``LandscapeControls.update`` in + resources/js/LandscapeControls.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, 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(SUPERIOR, back)) + up = cross(back, right) + return right, up, back + + +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_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"] diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py index f04d54f2e..5db08ce3d 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -1537,3 +1537,127 @@ def test_static_viewer_ships_the_interpolation_module(tmp_path): 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 = handle.send(method="run", params=[ + "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 diff --git a/cortex/webgl/resources/js/viewtools.js b/cortex/webgl/resources/js/viewtools.js index 9cbc60c5a..80036d397 100644 --- a/cortex/webgl/resources/js/viewtools.js +++ b/cortex/webgl/resources/js/viewtools.js @@ -454,6 +454,7 @@ var jsplot = (function (module) { " ", " ×", "
    ", + "
    ", "
    ", " ", "
    ", @@ -617,6 +618,7 @@ var jsplot = (function (module) { this._el("anim-interp").val(here ? here.interpolation : st.mode); } this.drawTicks(); + this.updateFlatHint(); }; // The keyframe laid down at exactly `frame`, or null. @@ -634,6 +636,40 @@ var jsplot = (function (module) { this._interp = null; this._applied = undefined; this.drawTicks(); + this.updateFlatHint(); + }; + + // Whether the animation passes through the flattened surface at any + // keyframe. Tested on the unfold value rather than on a view name, because + // a keyframe records the pose, not the view it was posed from. + AnimationPanel.prototype.usesFlat = function() { + var prop = 'surface.' + SUBJ + '.unfold'; + var kfs = this.state.keyframes; + for (var i = 0; i < kfs.length; i++) + if (kfs[i][prop] >= 0.999) + return true; + return false; + }; + + // Rendering the flat view at the size quickflat uses makes the frames line + // up with a flatmap drawn by quickflat.make_png, so say what that size is + // once an animation actually visits the flat surface. It is shipped from + // python in viewopts.quickflat_size, since it follows from the subject's + // flat surface rather than from anything the browser knows. + AnimationPanel.prototype.updateFlatHint = function() { + var hint = this._el("anim-flatsize"); + var sizes = (typeof viewopts !== "undefined") ? + viewopts.quickflat_size : undefined; + var subjects = vt.subjects(this.viewer); + var size = (sizes !== undefined && subjects.length > 0) ? + sizes[subjects[0]] : null; + + if (!size || !this.usesFlat()) { + hint.text(""); + return; + } + hint.text("use " + size[0] + " × " + size[1] + + " to match quickflat.make_png()"); }; // Set the smoothing mode: on the keyframe under the playhead if there is @@ -995,16 +1031,26 @@ var jsplot = (function (module) { // named after one of its own methods would break the menu. var RESERVED = {get: 1, set: 1, add: 1, addFolder: 1, remove: 1, init: 1}; + // What each button applies, looked up when it is clicked rather than + // captured, so that re-adding a name replaces the view behind an + // existing button. That happens when a view is saved over one of the + // defaults every subject gets: without this the button would go on + // applying the default until the page was reloaded. + var view_registry = {}; + function addViewButton(label, view) { if (RESERVED[label] !== undefined) { console.warn("Skipping view '" + label + "': that name is " + "reserved by the controls menu. Rename the file."); return; } + view_registry[label] = view; if (label in views_ui._desc) // never add the same row twice return; var desc = {}; - desc[label] = {action: function() { vt.applyView(viewer, view); }}; + desc[label] = {action: function() { + vt.applyView(viewer, view_registry[label]); + }}; views_ui.add(desc); } diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index db64f538d..7ed2d89a4 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -65,8 +65,57 @@ def _viewer_urls(port: int) -> tuple[str, str]: return local, network +def _has_flatmap(subject: str) -> bool: + """Whether `subject` has a flat surface, without raising if it does not.""" + try: + return hasattr(getattr(db, subject).surfaces, "flat") + except Exception: + return False + + +def _quickflat_size(subject: str, height: int = 1024) -> Optional[list[int]]: + """The pixel size ``cortex.quickflat.make_png`` writes by default. + + ``make_png`` resizes the figure to the flatmap image and saves it at `dpi`, + so the png comes out exactly as many pixels as that image. The width follows + from the flat surface's bounding box, which is the one thing here that + varies by subject. + + Reproduces the arithmetic of ``quickflat.utils._make_flatmask`` rather than + calling it, because that function rasterizes the surface outline with PIL to + build a mask this does not need -- and would cache a mask the viewer may + never use. + + Returns + ------- + list of int or None + ``[width, height]``, or None if the subject has no flat surface or it + could not be read. + """ + if not _has_flatmap(subject): + return None + try: + pts, _ = db.get_surf(subject, "flat", merge=True, nudge=True) + span = pts.max(0) - pts.min(0) + if span[1] <= 0: + return None + return [int((height / span[1]) * span[0]), int(height)] + except Exception as err: + warnings.warn("Could not work out the quickflat size for %s: %s" + % (subject, err)) + return None + + def _load_saved_views(subjects: list[str]) -> dict[str, dict[str, dict[str, Any]]]: - """Read the saved views of `subjects` out of the filestore. + """The views each of `subjects` offers, defaults overlaid with saved ones. + + Every subject gets the standard anatomical views from + ``cortex.export.save_views.default_subject_views`` -- dorsal, ventral, the + two lateral views, their inflated counterparts, and flat -- so that a + subject with an empty (or missing) views/ directory still has them. A view + stored in the filestore under one of those names replaces the default, + which is how a subject whose anatomy needs a different angle, or who wants + a different framing, overrides one. `subjects` is the list of subjects the viewer is actually displaying, so a viewer never reads (nor ships to the browser) views belonging to unrelated @@ -80,9 +129,11 @@ def _load_saved_views(subjects: list[str]) -> dict[str, dict[str, dict[str, Any] writes; the javascript side substitutes it per subject when the view is applied, so one saved view still works in a multi-subject viewer. """ + from ..export.save_views import default_subject_views + saved: dict[str, dict[str, dict[str, Any]]] = {} for subj in subjects: - saved[subj] = {} + saved[subj] = dict(default_subject_views(_has_flatmap(subj))) viewdir = os.path.join(db.filestore, subj, "views") # Glob *.json rather than using db.get_paths()['views'], which strips any # extension off any file in the directory (so notes.tar.gz would show up @@ -327,6 +378,8 @@ def make_static( # Views saved in the filestore, for the "camera > views" menu. Only the # subjects this viewer displays are read. my_viewopts["saved_views"] = _load_saved_views(subjects) + my_viewopts["quickflat_size"] = {subj: _quickflat_size(subj) + for subj in subjects} html = tpl.generate( data=json.dumps(metadata), @@ -544,6 +597,11 @@ def show( # subjects this viewer displays are read. my_viewopts['saved_views'] = _load_saved_views(subjects) + # So the animation panel can say what render size reproduces the png + # quickflat.make_png writes by default, for animations using the flat view. + my_viewopts['quickflat_size'] = {subj: _quickflat_size(subj) + for subj in subjects} + # Where the animation panel is allowed to write rendered frames. The browser # sends a path relative to this root and MovieHandler refuses anything that # resolves outside it; see MovieHandler below. diff --git a/docs/database.rst b/docs/database.rst index 81685cdf3..4347ab44c 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -330,6 +330,38 @@ Where, ``'subject'`` is the subject identifier and ``'name'`` is a unique name f viewer.get_view(subject, name) +Default views +~~~~~~~~~~~~~ + +Every subject is offered a standard set of views whether or not anything has been saved for it, so a freshly imported subject already has the usual orientations one click away under **camera > views**: + +========================= ==================================================== +View Shows +========================= ==================================================== +``dorsal`` From above, frontal lobe towards the top of the + image, the subject's right on the right. +``ventral`` From below, frontal lobe towards the top. +``lateral_left`` From the left, brain upright. +``lateral_right`` From the right, brain upright. +``*_inflated`` The same four angles on the inflated surface. +``flat`` The flattened surface. Omitted for a subject with + no flat surface, which also puts the ``_inflated`` + views at full inflation rather than half. +========================= ==================================================== + +They are built from the same tables ``cortex.export.save_views`` uses for :func:`save_3d_views`, and can be inspected from python:: + + from cortex.export.save_views import default_subject_views + default_subject_views()["dorsal"] + +**A view saved in the filestore under one of these names replaces the default.** So if a subject's anatomy wants a different angle, or you prefer a different framing, save your own view under that name and it is used instead — for that subject only, leaving every other default in place:: + + viewer.save_view(subject, "dorsal", is_overwrite=True) + +The ``flat`` view uses the viewer's standard flatmap preset. It cannot match ``quickflat.make_figure`` exactly, since the viewer draws the flat surface through a perspective camera while quickflat rasterizes it orthographically; if the framing is not what you want for a particular subject, override it as above. + +Rendering the flat view at the same pixel size quickflat uses makes the frames line up with a flatmap drawn by ``cortex.quickflat.make_png``. That size depends on the subject's flat surface, so the animation panel works it out and displays it in the render form once an animation actually reaches the flat surface. + Saved views in the browser ~~~~~~~~~~~~~~~~~~~~~~~~~~ From 5e80008179025dc9aa10741d8a0c30b289875433 Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Wed, 23 Sep 2026 15:23:06 -0700 Subject: [PATCH 06/14] ENH: frame the flat view like quickflat, and stop animating the flat angle Two things about the flat view, plus the five test failures this branch was carrying. Rendering a flat keyframe did not produce the image quickflat.make_png writes: the flatmap came out small and low in the frame. Nothing ever framed it. The camera target stayed wherever it was -- the flat surface sits some sixty units below the origin, and movement.js compensates with a hardcoded _flattarget.y of -60 that happens to land within 0.1 of S1's true centre and would be wrong for any other subject -- and nothing set the camera distance at all. quickflat has no camera: it maps the flat surface's bounding box onto the bounds of the image. The viewer can reproduce that exactly, because a plane square-on to a perspective camera projects as a uniform scaling. Surface.flatBBox works out where the flatmap actually is (the flat morph target lives in the mixSurfs1 attribute, so the geometry's own bounding boxes describe the fiducial surface and say nothing about it), and Viewer.flatFraming turns that into a target and a radius for a frame of a given shape: filling it exactly at the flatmap's own aspect ratio, which is what _quickflat_size reports, and fitting inside any other shape rather than cropping to it. Rendered at that size the frame is make_png's png, same position and same scale, to a mask overlap of 0.99. The framing is applied on request, never behind a caller's back. The views menu applies it, JSMixer.fit_flat_view applies it from python, and getImage re-frames what was framed for the image it is about to write, since that need not have the shape of the window. _set_view does not, so save_3d_views and anything else driving the viewer renders exactly what it always did -- the stored visual regression references are untouched, which is the evidence for that claim. The animation panel offers the framing behind a "match quickflat size" box, unticked, which fills in the size and frames flat keyframes for it, including any already down. The second thing is the transition into that view. The controls pin the camera square-on to a flat surface and discard whatever azimuth and altitude they are given (setAzimuth/setAltitude in movement.js), unless the surface is tilt-enabled -- but below full unfolding those same setters write the *folded* angle. So a flat view carrying azimuth 180 gave an animation something to interpolate towards on the way in: the brain spun as it flattened, fighting the blend setMix performs over the same quantity, and the folded angle it would return to was overwritten on the way out. A flat pose now carries neither property, in the default views, in angle_view_params, and in both capture paths. That leaves the interpolators needing a rule for a property only some keyframes carry. Each channel is now built from the keyframes that do carry it -- in viewtools.js, in interpolation.py, and in the legacy easings of _get_anim_seq, which raised KeyError on a missing key -- so a property every keyframe carries behaves as it always did, one carried by a single keyframe is constant, and a flat keyframe is simply not a knot on the angle's curve. Folded to flat holds the angle; folded to flat to folded sweeps smoothly across the whole span. The five failures: Two tests read handle.send()'s reply as a value rather than the one-entry-per-client list it is. _js_run unwraps it, the way _js_attrs already did for query. save_new_views refused a view name starting with an underscore, which the filestore itself accepts -- test_saved_views_are_loaded_into_the_viewer writes one straight to disk and the viewer loads it. Only a leading '.', which is what makes '..' possible, is kept out of the first position now. make_static(html_embed=False) wrote tornado's bytes to a text-mode file. Latent since 4906f5f; the two static-viewer tests added on this branch are the first callers to exercise that path. Co-Authored-By: Claude Opus 5 (1M context) --- cortex/export/save_views.py | 43 +- cortex/tests/test_default_views.py | 18 + cortex/tests/test_interpolation.py | 43 +- cortex/tests/test_webgl_headless.py | 414 ++++++++++++++++++- cortex/webgl/interpolation.py | 19 +- cortex/webgl/resources/css/mriview.css | 12 + cortex/webgl/resources/js/mriview.js | 118 ++++++ cortex/webgl/resources/js/mriview_surface.js | 59 ++- cortex/webgl/resources/js/viewtools.js | 248 +++++++++-- cortex/webgl/view.py | 85 +++- docs/database.rst | 8 +- 11 files changed, 1012 insertions(+), 55 deletions(-) diff --git a/cortex/export/save_views.py b/cortex/export/save_views.py index a2c431f5c..4f37eabaf 100644 --- a/cortex/export/save_views.py +++ b/cortex/export/save_views.py @@ -161,6 +161,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 +289,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, }, @@ -365,6 +380,14 @@ def save_3d_views( #: 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) -> dict[str, ViewParams]: """The views offered for every subject, whether or not any are saved. @@ -407,10 +430,14 @@ def build(*overrides: ViewParams) -> ViewParams: if has_flatmap: # The established flatmap preset, the one save_3d_views renders - # flatmaps with. It is the closest the viewer comes to the layout - # quickflat.make_figure draws; the two cannot match exactly, since the - # viewer renders the flat surface through a 45-degree perspective - # camera while quickflat rasterizes it orthographically. - views[FLAT_VIEW_NAME] = build(angle_view_params["flatmap"], - unfold_view_params["flatmap"]) + # 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] + views[FLAT_VIEW_NAME] = flat return views diff --git a/cortex/tests/test_default_views.py b/cortex/tests/test_default_views.py index 0217880e8..401e8c5dd 100644 --- a/cortex/tests/test_default_views.py +++ b/cortex/tests/test_default_views.py @@ -14,6 +14,7 @@ from cortex.export.save_views import ( DEFAULT_VIEW_ANGLES, + FLAT_INERT_PROPS, FLAT_VIEW_NAME, INFLATED_SUFFIX, angle_view_params, @@ -136,6 +137,23 @@ def test_the_flat_view_is_fully_unfolded_and_pivoted(): 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_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(): diff --git a/cortex/tests/test_interpolation.py b/cortex/tests/test_interpolation.py index e5323d091..326d49931 100644 --- a/cortex/tests/test_interpolation.py +++ b/cortex/tests/test_interpolation.py @@ -332,9 +332,46 @@ def test_an_unknown_mode_falls_back_to_the_default(): assert evaluate(channels, 0.5)["v"] == pytest.approx(5.0) -def test_properties_missing_from_the_first_keyframe_are_skipped(): - keyframes = [{"time": 0.0, "a": 0.0}, {"time": 1.0, "a": 1.0, "b": 2.0}] - assert set(build_channels(keyframes)) == {"a"} +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(): diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py index 5db08ce3d..0de47d598 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -1119,6 +1119,17 @@ def _js_attrs(handle, path): 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. viewopts.movie_post.token.""" parent, _, name = path.rpartition(".") @@ -1409,8 +1420,8 @@ def test_browser_and_python_interpolate_identically(): 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 = handle.send(method="run", params=[ - "window.jsplot.viewtools.viewsAt", [SMOOTHING_KEYFRAMES, frames]]) + 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") @@ -1636,8 +1647,8 @@ def test_quickflat_size_reaches_the_browser(): 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 = handle.send(method="run", params=[ - "window.viewopts.quickflat_size.%s.slice" % subj, []]) + shipped = _js_run( + handle, "window.viewopts.quickflat_size.%s.slice" % subj, []) assert shipped == expected if expected is not None: @@ -1661,3 +1672,398 @@ def test_quickflat_size_matches_a_real_quickflat_png(tmp_path): 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.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}" + + +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 that + lands in the movie directory 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) + + movie_dir = tmp_path / "movie" + movie_dir.mkdir() + with cortex.export.headless_viewer( + vol, viewer_params=dict(movie_dir=str(movie_dir))) 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", []]) + + frame = str(movie_dir / "brainmovie_00000.png") + wait_for_file(frame, timeout=120) + time.sleep(2) + + pageerrors = [e for e in handle._pw_thread.browser_errors + if "[pageerror]" in e] + assert len(pageerrors) == 0, f"JS errors: {pageerrors}" + + 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 diff --git a/cortex/webgl/interpolation.py b/cortex/webgl/interpolation.py index a1129dca8..5dbc221c5 100644 --- a/cortex/webgl/interpolation.py +++ b/cortex/webgl/interpolation.py @@ -701,19 +701,24 @@ def build_channels(keyframes: Sequence[dict], time_key: str = "time", Returns ------- dict - Property name to an object with an ``at(time)`` method. Properties - absent from the first keyframe are skipped, matching the way - ``_get_anim_seq`` iterates the earlier view of each pair. + Property name to an object with an ``at(time)`` method. Each channel is + built from the keyframes that carry its property: a property every + keyframe carries behaves as it always did, one carried by a single + keyframe is constant, and the keyframes that leave it out are simply + not on its curve. That is how a flat keyframe, which records no camera + angle, stays out of the ``camera.azimuth`` channel. """ frames = sorted(keyframes, key=lambda frame: frame[time_key]) if not frames: raise ValueError("Need at least one keyframe") channels: "dict[str, object]" = {} - for prop in frames[0]: - if prop in RESERVED_KEYS: - continue - channels[prop] = _make_channel(frames, prop, time_key, default_mode) + for frame in frames: + for prop in frame: + if prop in RESERVED_KEYS or prop in channels: + continue + carriers = [carrier for carrier in frames if prop in carrier] + channels[prop] = _make_channel(carriers, prop, time_key, default_mode) return channels diff --git a/cortex/webgl/resources/css/mriview.css b/cortex/webgl/resources/css/mriview.css index 40782b359..d809014d6 100644 --- a/cortex/webgl/resources/css/mriview.css +++ b/cortex/webgl/resources/css/mriview.css @@ -674,6 +674,18 @@ button#twodbutton:disabled, button#twodbutton[disabled] { width: 64px; } +/* "match quickflat size" is a caption, not one of the 44px labels that head a + field, so it takes the width it needs and clicking it toggles the box. */ +.anim-flatmatch-label { + width: auto; + cursor: pointer; +} + +.anim-flatmatch { + margin-right: 4px; + vertical-align: middle; +} + .pycortex-buttons { text-align: center; } diff --git a/cortex/webgl/resources/js/mriview.js b/cortex/webgl/resources/js/mriview.js index aaa1cc8f3..0b54ea674 100644 --- a/cortex/webgl/resources/js/mriview.js +++ b/cortex/webgl/resources/js/mriview.js @@ -966,6 +966,124 @@ var mriview = (function(module) { this.surfs[i].setMix(mix); } + // The world-space extent of the flattened surface, from the first surface + // that has a flatmap, or null if none does. + module.Viewer.prototype.flatBBox = function() { + for (var i = 0; i < this.surfs.length; i++) { + if (this.surfs[i].flatBBox === undefined) + continue; + var box = this.surfs[i].flatBBox(); + if (box !== null) + return box; + } + return null; + }; + + // The framing cortex.quickflat.make_png would use for a frame of the given + // shape, as {target, radius, aspect}, or null if there is no flatmap. + // Works the framing out without moving anything, which is what the + // animation panel needs to re-frame a keyframe it is not looking at. + // + // quickflat has no camera at all -- it maps the flat surface's bounding box + // onto the bounds of the image -- and the viewer can reproduce that because + // a plane square-on to a perspective camera projects as a uniform scaling. + // So the camera only has to look at the middle of that bounding box from + // the distance at which the field of view spans it. + // + // `aspect` is the width/height of the frame being framed for, defaulting to + // the one on screen. The flatmap fills the frame exactly -- and the render + // is then make_png's png -- when that is the flatmap's own aspect ratio, + // which is what viewopts.quickflat_size has. At any other shape of frame + // the flatmap is fitted inside it rather than cropped to it. + module.Viewer.prototype.flatFraming = function(aspect) { + var box = this.flatBBox(); + if (box === null) + return null; + + if (!(typeof aspect === "number" && isFinite(aspect) && aspect > 0)) + aspect = this.camera.aspect; + + var halfheight = (box.max[1] - box.min[1]) / 2; + var halfwidth = (box.max[0] - box.min[0]) / 2; + + return { + target: [(box.min[0] + box.max[0]) / 2, + (box.min[1] + box.max[1]) / 2, + 0], + radius: Math.max(halfheight, halfwidth / aspect) / + Math.tan(this.camera.fov * Math.PI / 360), + aspect: aspect, + }; + }; + + // Point the camera at that framing. + // + // Assumes the square-on camera of the flat view -- azimuth 180, altitude 0, + // which is where the controls clamp to once flattened. Returns the framing + // it applied, or null if there is no flatmap to frame. + module.Viewer.prototype.fitFlatView = function(aspect) { + var framing = this.flatFraming(aspect); + if (framing === null) + return null; + + this.controls.setTarget(framing.target); + this.controls.setRadius(framing.radius); + this._flatFitAspect = framing.aspect; // so isFlatFitted knows this framing + // Move the camera now rather than on the next animation frame: this is + // called from getImage, which renders straight away. + this.controls.update(this.camera); + this.controls.dispatchEvent({type:"change"}); // schedules a redraw + + // What setTarget and setRadius actually took, which is not what was + // asked for if a subject's flatmap is small enough to hit the zoom + // clamp (radius is held at 10 or more, and at 101 or more while flat). + return {target: this.controls.setTarget(), + radius: this.controls.setRadius()}; + }; + + // Whether the camera is currently framing the flatmap, i.e. sitting where + // fitFlatView put it -- for the frame on screen, or for the frame it was + // last framed for, which is not the same thing once something has rendered + // an image of another shape. + module.Viewer.prototype.isFlatFitted = function() { + if (this.setMix() < 0.999) + return false; + + var framing = this.flatFraming(); + if (framing === null) + return false; + + var close = function(a, b) { + return Math.abs(a - b) <= 1e-3 * Math.max(1, Math.abs(b)); + }; + var target = this.controls.setTarget(); + if (!close(target[0], framing.target[0]) || + !close(target[1], framing.target[1])) + return false; + + var radii = [framing.radius]; + if (this._flatFitAspect !== undefined) + radii.push(this.flatFraming(this._flatFitAspect).radius); + + for (var i = 0; i < radii.length; i++) + if (close(this.controls.setRadius(), radii[i])) + return true; + return false; + }; + + // Re-frame the flatmap for a frame of a different shape, but only if it is + // framed right now -- a camera someone has moved is left where they put it. + // + // What keeps the framing right when the image being rendered is not the + // shape of the window it was set up in: JSMixer.getImage calls this with + // the aspect ratio of the image it is about to write. Returns the framing + // in effect afterwards, or null if there was nothing to re-frame. + module.Viewer.prototype.refitFlatView = function(aspect) { + if (!this.isFlatFitted()) + return null; + return this.fitFlatView(aspect); + }; + module.Viewer.prototype.pick = function(evt) { // Cache last pick position so setData() can refresh the picked // indicator for the newly-active dataset at the same screen point. diff --git a/cortex/webgl/resources/js/mriview_surface.js b/cortex/webgl/resources/js/mriview_surface.js index 23c748a9b..ab456b2b4 100644 --- a/cortex/webgl/resources/js/mriview_surface.js +++ b/cortex/webgl/resources/js/mriview_surface.js @@ -225,6 +225,7 @@ var mriview = (function(module) { if (this.flatlims !== undefined) { var flats = this._makeFlat(hemi.attributes.uv.array, json.flatlims, names[name]); + hemi.flatbbox = flats.bbox; hemi.addAttribute('mixSurfs'+json.names.length, new THREE.BufferAttribute(flats.pos, 4)); hemi.addAttribute('mixNorms'+json.names.length, new THREE.BufferAttribute(flats.norms, 3)); hemi.attributes['mixSurfs'+json.names.length].needsUpdate = true; @@ -789,6 +790,11 @@ var mriview = (function(module) { var fmin = flatlims[0], fmax = flatlims[1]; var flat = new Float32Array(uv.length / 2 * 4); var norms = new Float32Array(uv.length / 2 * 3); + // The extent of the flattened surface, tracked here because this is the + // only pass over it: the uv array is normalized in place below, and the + // geometry's own bounding box describes the fiducial surface, never the + // flat morph target. flatBBox turns this into world coordinates. + var min = [0, Infinity, Infinity], max = [0, -Infinity, -Infinity]; for (var i = 0, il = uv.length / 2; i < il; i++) { if (right) { flat[i*4+1] = flatscale*uv[i*2] + this.flatoff[1]; @@ -800,11 +806,59 @@ var mriview = (function(module) { // flat[i*4+0] = flatscale*uv[i*2+1]; } flat[i*4+2] = flatscale*uv[i*2+1]; + for (var j = 1; j < 3; j++) { + if (flat[i*4+j] < min[j]) min[j] = flat[i*4+j]; + if (flat[i*4+j] > max[j]) max[j] = flat[i*4+j]; + } uv[i*2] = (uv[i*2] + fmin[0]) / fmax[0]; uv[i*2+1] = (uv[i*2+1] + fmin[1]) / fmax[1]; } - return {pos:flat, norms:norms}; + return {pos:flat, norms:norms, bbox:{min:min, max:max}}; + }; + + // The extent of the flattened surface in world coordinates, as + // ``{min:[x,y,z], max:[x,y,z]}``, or null if this surface has no flatmap + // (or has not finished loading). + // + // The flat vertices live in the mixSurfs1 attribute and only reach world + // space through the meshes' matrices, which carry the flattening rotation, + // the pivot and the shift. Those are rotations by multiples of 90 degrees + // for a flat surface, so transforming the corners of the cached extent is + // exact; a half-folded pivot would over-estimate it, which is harmless + // since the framing this feeds is only meaningful once flattened. + module.Surface.prototype.flatBBox = function() { + if (this.sheets.length == 0) + return null; + + this.object.updateMatrixWorld(true); + var min = [Infinity, Infinity, Infinity]; + var max = [-Infinity, -Infinity, -Infinity]; + var corner = new THREE.Vector3(); + var found = false; + + for (var name in this.sheets[0]) { + var mesh = this.sheets[0][name]; + var hemi = this.hemis[name]; + if (mesh === undefined || hemi === undefined || + hemi.flatbbox === undefined) + continue; + found = true; + var lo = hemi.flatbbox.min, hi = hemi.flatbbox.max; + for (var c = 0; c < 8; c++) { + corner.set(c & 1 ? hi[0] : lo[0], + c & 2 ? hi[1] : lo[1], + c & 4 ? hi[2] : lo[2]); + corner.applyMatrix4(mesh.matrixWorld); + var xyz = corner.toArray(); + for (var j = 0; j < 3; j++) { + if (xyz[j] < min[j]) min[j] = xyz[j]; + if (xyz[j] > max[j]) max[j] = xyz[j]; + } + } + } + + return found ? {min:min, max:max} : null; }; module.SurfDelegate = function(dataview) { @@ -851,6 +905,9 @@ var mriview = (function(module) { module.SurfDelegate.prototype.setMix = function(mix) { return this.surf.setMix(mix); } + module.SurfDelegate.prototype.flatBBox = function() { + return this.surf.flatBBox(); + } module.SurfDelegate.prototype.setPivot = function(pivot) { return this.surf.setPivot(pivot); } diff --git a/cortex/webgl/resources/js/viewtools.js b/cortex/webgl/resources/js/viewtools.js index 80036d397..b0f667150 100644 --- a/cortex/webgl/resources/js/viewtools.js +++ b/cortex/webgl/resources/js/viewtools.js @@ -34,6 +34,12 @@ var jsplot = (function (module) { // assignment, so a fractional layer count is both wrong and very slow. var STEP_PROPS = {'layers': true}; + // Properties a flattened surface ignores: the controls pin the camera + // square-on to the flatmap and discard whatever angle they are given + // (setAzimuth/setAltitude in resources/js/movement.js). The python side + // names the same pair in cortex/export/save_views.py. + var FLAT_INERT_PROPS = ['camera.azimuth', 'camera.altitude']; + var SUBJ = '{subject}'; function subst(prop, subject) { @@ -106,6 +112,16 @@ var jsplot = (function (module) { console.warn("Could not capture " + path + ": " + e.message); } } + + // A flat pose records no camera angle, since the flat surface ignores + // it: an animation would otherwise have something spurious to + // interpolate towards on the way in, spinning the brain as it flattens + // and leaving the folded angle overwritten on the way out. A tilted + // flat surface does use the angle, so it keeps it. + if (vt.isFlat(view) && !view['surface.' + SUBJ + '.allow_tilt']) + for (var j = 0; j < FLAT_INERT_PROPS.length; j++) + delete view[FLAT_INERT_PROPS[j]]; + return view; }; @@ -163,6 +179,29 @@ var jsplot = (function (module) { vt.setProp(viewer, subst(prop, subject), params[prop]); } } + + // Applying a flat view that says nothing about how far away the + // camera is frames the flatmap the way quickflat frames the image it + // writes (see Viewer.fitFlatView). That is how the built-in "flat" + // view is defined: it names neither an angle, which a flat surface + // ignores, nor a zoom. A view saved from the GUI, and every frame + // interpolated between keyframes, carries a camera.radius, so both are + // left exactly as they were captured. + // + // This is the interactive path -- the views menu. JSMixer._set_view + // deliberately does not do it, so that setting a flat view from python + // renders what it always rendered; fit_flat_view() asks for it there. + if (vt.isFlat(params) && !('camera.radius' in params) && + viewer.fitFlatView !== undefined) + viewer.fitFlatView(); + }; + + // Whether a view has the surface flattened. The unfold value is what + // decides it, rather than the name the view was saved under, because a + // keyframe records the pose and not where it came from. + vt.isFlat = function(view) { + var unfold = view['surface.' + SUBJ + '.unfold']; + return unfold !== undefined && unfold >= 0.999; }; vt.setProp = function(viewer, path, value) { @@ -178,19 +217,31 @@ var jsplot = (function (module) { // // Values that cannot be blended -- null, booleans, strings, discrete // numbers, and anything missing from `b` -- hold their starting value, the - // same rule JSMixer._get_anim_seq uses. Numbers go through the viewer's own - // _animInterp so that camera.azimuth still takes the short way around. + // same rule JSMixer._get_anim_seq uses. A property only `b` carries holds + // its value instead: it is the one view of the pair that constrains it. + // Numbers go through the viewer's own _animInterp so that camera.azimuth + // still takes the short way around. vt.interpolate = function(viewer, a, b, t) { var subjects = vt.subjects(viewer); var subject = subjects.length > 0 ? subjects[0] : null; var out = {}; + var props = {}; + for (var key in a) props[key] = true; + for (key in b) props[key] = true; - for (var prop in a) { + for (var prop in props) { if (prop === 'frame') continue; var av = a[prop], bv = b[prop]; var leaf = prop.split('.').pop(); + // Only the later view carries it: it is the one keyframe of the + // pair that constrains the property, so it holds throughout. + if (av === undefined) { + out[prop] = bv; + continue; + } + if (bv === undefined || av === null || STEP_PROPS[leaf] || typeof av === 'boolean' || typeof av === 'string') { out[prop] = av; @@ -353,18 +404,34 @@ var jsplot = (function (module) { }()), prop === 'camera.azimuth'); } - // Take a keyframe list apart into per-property channels. Properties absent - // from the first keyframe are ignored, matching vt.interpolate's rule of - // iterating the earlier view. + // The keyframes that carry `prop`. A keyframe that leaves a property out + // does not constrain it -- which is how a flat keyframe stays out of the + // camera.azimuth channel, since a flat pose records no angle. + function carriersOf(frames, prop) { + var out = []; + for (var i = 0; i < frames.length; i++) + if (frames[i][prop] !== undefined) + out.push(frames[i]); + return out; + } + + // Take a keyframe list apart into per-property channels. Each channel is + // built from the keyframes that carry its property, so a property every + // keyframe carries behaves as it always did, one carried by a single + // keyframe is constant, and the keyframes in between are simply not on the + // curve. vt.buildInterpolators = function(keyframes) { var frames = keyframes.slice().sort(function(a, b) { return a.frame - b.frame; }); var channels = {}; - for (var prop in frames[0]) { - if (prop === 'frame' || prop === 'interpolation') - continue; - channels[prop] = makeChannel(frames, prop); + for (var i = 0; i < frames.length; i++) { + for (var prop in frames[i]) { + if (prop === 'frame' || prop === 'interpolation' || + channels[prop] !== undefined) + continue; + channels[prop] = makeChannel(carriersOf(frames, prop), prop); + } } return {frames: frames, channels: channels}; }; @@ -454,6 +521,10 @@ var jsplot = (function (module) { " ", " ×", " ", + "
    ", + "
    ", "
    ", "
    ", " ", @@ -587,6 +658,10 @@ var jsplot = (function (module) { this._el("anim-render-form").hide(); this._el("anim-width").val(this.viewer.imageWidth || 2400); this._el("anim-height").val(this.viewer.imageHeight || 1200); + this._el("anim-flatmatch").prop("checked", false).on("change", function() { + self.matchFlatChanged(); + }); + this._el("anim-flatmatch-row").hide(); }; AnimationPanel.prototype.show = function() { @@ -643,32 +718,126 @@ var jsplot = (function (module) { // keyframe. Tested on the unfold value rather than on a view name, because // a keyframe records the pose, not the view it was posed from. AnimationPanel.prototype.usesFlat = function() { - var prop = 'surface.' + SUBJ + '.unfold'; var kfs = this.state.keyframes; for (var i = 0; i < kfs.length; i++) - if (kfs[i][prop] >= 0.999) + if (vt.isFlat(kfs[i])) return true; return false; }; - // Rendering the flat view at the size quickflat uses makes the frames line - // up with a flatmap drawn by quickflat.make_png, so say what that size is - // once an animation actually visits the flat surface. It is shipped from - // python in viewopts.quickflat_size, since it follows from the subject's - // flat surface rather than from anything the browser knows. - AnimationPanel.prototype.updateFlatHint = function() { - var hint = this._el("anim-flatsize"); + // The size quickflat.make_png would write for the subject on show, shipped + // from python in viewopts.quickflat_size because it follows from the flat + // surface rather than from anything the browser knows. Null if the subject + // has no flatmap. + AnimationPanel.prototype.flatSize = function() { var sizes = (typeof viewopts !== "undefined") ? viewopts.quickflat_size : undefined; var subjects = vt.subjects(this.viewer); - var size = (sizes !== undefined && subjects.length > 0) ? - sizes[subjects[0]] : null; + if (sizes === undefined || subjects.length == 0) + return null; + var size = sizes[subjects[0]]; + return (size === undefined || size === null) ? null : size; + }; + + // Whether the render form's "match quickflat size" box is ticked. Ticking + // it renders an animation that visits the flat surface at the size + // quickflat uses, so its flat frames come out as the png + // quickflat.make_png writes: a flat keyframe is framed to fill the frame + // (Viewer.flatFraming), which reproduces make_png only at make_png's own + // aspect ratio. It starts unticked, so nothing about an animation changes + // unless it is asked for. + AnimationPanel.prototype.matchesFlat = function() { + return this._el("anim-flatmatch").prop("checked") === true; + }; + + // Put quickflat's size in the size fields, if the box is ticked and the + // fields still hold a size the panel is free to overwrite -- the one it + // wrote last time, or the untouched default it starts out with. Typing a + // size is how a person says they want a different one, and flat keyframes + // are then framed for that size instead. Returns whether the fields ended + // up holding quickflat's size. + AnimationPanel.prototype.useFlatSize = function() { + var size = this.flatSize(); + if (size === null || !this.matchesFlat()) + return false; + + var width = this._el("anim-width"), height = this._el("anim-height"); + var have = [parseInt(width.val(), 10), parseInt(height.val(), 10)]; + if (have[0] === size[0] && have[1] === size[1]) + return true; + + var mine = this._flatsized || + [this.viewer.imageWidth || 2400, this.viewer.imageHeight || 1200]; + if (have[0] !== mine[0] || have[1] !== mine[1]) + return false; + + width.val(size[0]); + height.val(size[1]); + this._flatsized = [size[0], size[1]]; + return true; + }; + + // Frame every flat keyframe for the size the animation renders at. + // + // A flat keyframe is framed when it is laid down, so one laid down before + // the box was ticked -- or before the size was changed -- carries a framing + // for the wrong frame. Written straight into the keyframes rather than by + // posing the viewer, since these are keyframes the playhead is not on. + AnimationPanel.prototype.reframeFlatKeyframes = function() { + var size = this.renderSize(); + if (size === null || this.viewer.flatFraming === undefined) + return 0; + + var framing = this.viewer.flatFraming(size[0] / size[1]); + if (framing === null) + return 0; + + var kfs = this.state.keyframes, reframed = 0; + for (var i = 0; i < kfs.length; i++) { + if (!vt.isFlat(kfs[i])) + continue; + kfs[i]['camera.target'] = framing.target.slice(); + kfs[i]['camera.radius'] = framing.radius; + reframed++; + } + if (reframed > 0) { + this.invalidate(); + this.setFrame(this.state.frame); + } + return reframed; + }; + + // Tick or untick the box from code, as the menu click would. + AnimationPanel.prototype.setMatchFlat = function(on) { + this._el("anim-flatmatch").prop("checked", on === true); + this.matchFlatChanged(); + return this.matchesFlat(); + }; + + // The box was ticked or unticked. Ticking takes over the size and re-frames + // the flat keyframes for it; unticking leaves both alone, since the size in + // the fields is the one the keyframes are now framed for. + AnimationPanel.prototype.matchFlatChanged = function() { + if (this.matchesFlat()) { + this.useFlatSize(); + this.reframeFlatKeyframes(); + } + this.updateFlatHint(); + }; + + AnimationPanel.prototype.updateFlatHint = function() { + var hint = this._el("anim-flatsize"); + var size = this.flatSize(); if (!size || !this.usesFlat()) { + this._el("anim-flatmatch-row").hide(); hint.text(""); return; } - hint.text("use " + size[0] + " × " + size[1] + + + this._el("anim-flatmatch-row").show(); + hint.text((this.useFlatSize() ? "size set to " : "use ") + + size[0] + " \u00d7 " + size[1] + " to match quickflat.make_png()"); }; @@ -773,6 +942,22 @@ var jsplot = (function (module) { var st = this.state; var frame = Math.round(st.frame); var view = vt.captureView(this.viewer); + + // With "match quickflat size" ticked, a flat keyframe is framed for + // the size this animation will render at -- quickflat's own, unless + // someone has typed another one. Framed here rather than when the flat + // view was applied because only the panel knows that size, and the + // framing only reproduces quickflat.make_png at the aspect ratio it is + // rendered at. + if (vt.isFlat(view) && this.matchesFlat() && + this.viewer.fitFlatView !== undefined) { + this.useFlatSize(); + var size = this.renderSize(); + if (size !== null) { + this.viewer.fitFlatView(size[0] / size[1]); + view = vt.captureView(this.viewer); + } + } view.frame = frame; // The dropdown is the source of truth: sync() has already pointed it at // the mode of any keyframe sitting here, so re-adding over one keeps @@ -864,6 +1049,16 @@ var jsplot = (function (module) { requestAnimationFrame(step); }; + // The size frames are rendered at, as [width, height], or null if what is + // in the size fields is not a size. + AnimationPanel.prototype.renderSize = function() { + var width = parseInt(this._el("anim-width").val(), 10); + var height = parseInt(this._el("anim-height").val(), 10); + if (!isFinite(width) || !isFinite(height) || width < 1 || height < 1) + return null; + return [width, height]; + }; + AnimationPanel.prototype.render = function() { var st = this.state, self = this; var cfg = viewopts.movie_post; @@ -879,12 +1074,12 @@ var jsplot = (function (module) { var name = this._el("anim-name").val(); var dir = this._el("anim-dir").val(); - var width = parseInt(this._el("anim-width").val(), 10); - var height = parseInt(this._el("anim-height").val(), 10); - if (!isFinite(width) || !isFinite(height) || width < 1 || height < 1) { + var size = this.renderSize(); + if (size === null) { this.status("Bad image size"); return; } + var width = size[0], height = size[1]; this.stop(); this.rendering = true; @@ -1091,6 +1286,9 @@ var jsplot = (function (module) { "create animation": {action: function() { if (panel === null) panel = new AnimationPanel(viewer); + // Reachable from python (and from the console) the way the + // keyframe state itself is, as viewer._anim. + viewer._animPanel = panel; panel.show(); }}, }); diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index 7ed2d89a4..dfba01643 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -397,7 +397,8 @@ def make_static( if html_embed: htmlembed.embed(html, desthtml, rootdirs) else: - with open(desthtml, "w") as htmlfile: + # tpl.generate returns bytes, like everything tornado templates render. + with open(desthtml, "wb") as htmlfile: htmlfile.write(html) @@ -840,6 +841,43 @@ def _set_view(self, **kwargs): # Wait for webgl. Wait for it. .... WAAAAAIIIT. time.sleep(0.03) + def fit_flat_view(self) -> Optional[dict[str, Any]]: + """Frame the flattened surface the way ``quickflat`` frames it. + + Points the camera at the middle of the flat surface and backs it off + until the flatmap is as large as fits, which is what + ``cortex.quickflat.make_png`` does with the bounds of the image it + writes. The framing follows the shape of the frame, so it fills one + exactly at the flatmap's own aspect ratio -- the subject's quickflat + size (``cortex.webgl.view._quickflat_size``, which the viewer's + animation panel fills into its render form) -- and fits inside any + other shape rather than being cropped to it. ``getImage`` re-frames + for the image it writes, so rendering a flat view at the quickflat + size reproduces ``make_png``'s png whatever the window's shape. + + Only meaningful once the surface is flat and square-on to the + camera, which is the pose the ``flat`` view sets. + + Returns + ------- + dict or None + The framing that was applied, as ``{"target": [x, y, z], + "radius": r}``, or None if the subject has no flat surface. + + See Also + -------- + getImage : re-frames what this framed for the image it writes. + + Notes + ----- + Applied on request rather than by ``_set_view``, so that setting the + flat view from python -- as :func:`cortex.export.save_3d_views` does + -- keeps whatever framing the caller asked for. + """ + resp = self.send(method="run", + params=["window.viewer.fitFlatView", []]) + return resp[0] if isinstance(resp, list) and len(resp) > 0 else None + def _capture_view(self, frame_time=None): """Low-level command: returns a dict of current view parameters @@ -869,6 +907,19 @@ def _capture_view(self, frame_time=None): print(err) #msg = "Cannot read property 'undefined'" #if err.message[:len(msg)] != msg: # raise err + # A flat pose records no camera angle, since a flattened surface + # ignores it (see FLAT_INERT_PROPS). An animation would otherwise + # have something spurious to interpolate towards on the way in, + # spinning the brain as it flattens and leaving the folded angle + # overwritten on the way out. Mirrors vt.captureView in + # resources/js/viewtools.js, so the two still interchange. + from ..export.save_views import FLAT_INERT_PROPS + + if (view.get('surface.{subject}.unfold', 0) >= 0.999 + and not view.get('surface.{subject}.allow_tilt')): + for prop in FLAT_INERT_PROPS: + view.pop(prop, None) + if frame_time is not None: view['time'] = frame_time return view @@ -1027,10 +1078,12 @@ def save_new_views(self, subject: Optional[str]=None, # have to be prevented from escaping the views directory. paths = {} for name in names: - if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9 _.-]*", name) is None: + # A leading '.' is what makes '..' (and hidden files) possible, + # so only that is kept out of the first position. + if re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9 _.-]*", name) is None: raise ValueError( "Cannot save the view named %r: a view name must start " - "with a letter or digit and contain only letters, " + "with a letter, digit or '_' and contain only letters, " "digits, spaces, '_', '-' and '.'" % name) path = os.path.join(viewdir, name + ".json") if os.path.exists(path) and not is_overwrite: @@ -1133,7 +1186,20 @@ def getImage(self, filename: str, size: tuple[int, int]=(1920, 1080)): duh. size : tuple (x, y) size (in pixels) of image to save. + + Notes + ----- + A flatmap that ``fit_flat_view`` (or the viewer's own ``flat`` view) + has framed is re-framed for the image being written, since the + framing follows the shape of the frame and `size` need not have the + shape of the window. That is what makes a framed flat view rendered + at the subject's quickflat size come out as the png + ``cortex.quickflat.make_png`` writes. A camera that is sitting + anywhere else -- which is every camera this method has not been + asked to frame -- is left exactly where it is. """ + self.send(method="run", params=["window.viewer.refitFlatView", + [size[0] / size[1]]]) post_name.put(filename) Proxy = serve.JSProxy(self.send, "window.viewer.getImage") return Proxy(size[0], size[1], "mixer.html") @@ -1293,10 +1359,19 @@ def _get_anim_seq(self, keyframes, fps=30, interpolation='linear'): # Interpolate between values for t in fr_time: frame = {} - for prop in start.keys(): + # The union of the two: a property only one of them carries + # is the one keyframe of the pair that constrains it, so it + # holds that value across the segment. Flat keyframes carry + # no camera angle (see FLAT_INERT_PROPS), which is what this + # is for. + for prop in list(start.keys()) + [ + p for p in end.keys() if p not in start]: if prop=='time': continue - if (start[prop] is None) or (start[prop] == end[prop]) or isinstance(start[prop], (bool, str)): + if prop not in start: + frame[prop] = end[prop] + continue + if (start[prop] is None) or (prop not in end) or (start[prop] == end[prop]) or isinstance(start[prop], (bool, str)): frame[prop] = start[prop] continue val = func(a(start[prop]), a(end[prop]), t) diff --git a/docs/database.rst b/docs/database.rst index 4347ab44c..4df72a2e1 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -358,9 +358,13 @@ They are built from the same tables ``cortex.export.save_views`` uses for :func: viewer.save_view(subject, "dorsal", is_overwrite=True) -The ``flat`` view uses the viewer's standard flatmap preset. It cannot match ``quickflat.make_figure`` exactly, since the viewer draws the flat surface through a perspective camera while quickflat rasterizes it orthographically; if the framing is not what you want for a particular subject, override it as above. +The ``flat`` view uses the viewer's standard flatmap preset. It names no camera angle, because a flattened surface ignores one: the controls hold the camera square-on to the flatmap and discard whatever azimuth and altitude they are given (unless the surface's ``allow_tilt`` is on). Leaving them out is what keeps an animation from spinning the brain as it flattens, and from overwriting the folded angle it returns to when it unfolds again. A flat view or keyframe captured in the viewer leaves them out for the same reason. -Rendering the flat view at the same pixel size quickflat uses makes the frames line up with a flatmap drawn by ``cortex.quickflat.make_png``. That size depends on the subject's flat surface, so the animation panel works it out and displays it in the render form once an animation actually reaches the flat surface. +It carries no zoom of its own either. Clicking it in the browser frames the flatmap the way ``quickflat`` frames the image it writes — the camera looks at the middle of the flatmap from the distance at which the field of view spans it — so that rendered at the pixel size quickflat uses, the frame is the png ``cortex.quickflat.make_png`` writes, same position and same scale. (The perspective camera is no obstacle: a plane square-on to it projects as a uniform scaling, which is all quickflat's mapping of the flat surface onto the bounds of the image amounts to.) + +That framing is applied on request, never behind your back. Setting the flat view from python with ``_set_view`` leaves the camera where it is, so :func:`cortex.export.save_3d_views` and anything else driving the viewer render exactly as they always did; ask for it with ``handle.fit_flat_view()``, and ``getImage`` then re-frames it for the image it is about to write, whatever the shape of the window. In the animation panel, tick **match quickflat size** in the render form: the size fields are filled with the subject's quickflat size and flat keyframes are framed for it, including any already laid down. Type another size over it and flat keyframes are framed for that one instead. + +A view saved in the filestore under the name ``flat`` replaces all of this, framing included, since a saved view records the camera distance it was saved with. Saved views in the browser ~~~~~~~~~~~~~~~~~~~~~~~~~~ From 8e0d5d7223fdacd0c79dab00e8726c0c6637340f Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Wed, 23 Sep 2026 19:11:30 -0700 Subject: [PATCH 07/14] ENH: render animations to a browser download, as PNG frames or MP4 The animation panel's render button used to POST every frame to a /movie endpoint, which wrote it under show()'s movie_dir on the machine serving the viewer. That was a file-write primitive on the server -- guarded by a token and a path check, on a server that binds every interface unauthenticated -- and it put the frames on the wrong machine whenever the browser was not running where python was. Now the movie is built in the page and handed over as one download, the way the viewer's "Save image" button works: it lands wherever that browser saves downloads, and the server writes nothing. One download per frame is not an option, since browsers throttle or prompt for hundreds of them, so the render form offers two single-file formats. PNG frames (.zip): one lossless, transparent PNG per frame -- the frames to use when they must match quickflat.make_png. zipstore.js writes stored entries, since PNG is already compressed, which leaves only a CRC-32 per frame to compute; the frames stay as Blobs and the archive is a Blob of parts, so the image bytes are never copied into one buffer. Classic ZIP only, with a clear message past 65,535 frames or 4 GiB. MP4 video: H.264, encoded by the browser's own WebCodecs VideoEncoder and packed by mp4mux.js, a small muxer for one constant-frame-rate track -- ftyp, moov, then mdat, so the file can play before it has loaded. WebCodecs exists only in secure contexts (localhost, https, file://), so the option is disabled elsewhere; the encoder's size limit is checked before the first frame renders; and since H.264 has no alpha channel and needs even dimensions, frames are laid on black and an odd size gets a row or column of padding. With no server involved, rendering also works in make_static viewers. movie_dir, MovieHandler, the /movie route and viewopts.movie_post are gone; they were added on this branch and never released, so there is nothing to deprecate. Axes3D.getImage allocated a WebGLRenderTarget on every call and never freed it -- a few megabytes of GPU memory per rendered frame. It now disposes of it once the pixels are read back, which Save image and save_3d_views benefit from too; the visual regression references pass unchanged. The headless harness now captures downloads (headless_viewer's download_dir, wait_for_download), so the tests check the files a person would actually get: every CRC in the zip, the MP4's box structure and sample table, and that a
    ", "
    ", - "
    ", - "
    ", - "
    ", "
    ", "
    ", + "
    ", + "
    ", + "
    ", "
    ", " ", " ×", @@ -640,21 +643,29 @@ var jsplot = (function (module) { event.stopPropagation(); }); - var cfg = (typeof viewopts !== "undefined") ? viewopts.movie_post : undefined; - var render = this._el("anim-render"); - if (cfg === undefined) { - // No python behind this viewer (a static export), so there is - // nowhere to write frames. - render.prop("disabled", true) - .attr("title", "Rendering needs a viewer started from python"); - } else { - this._el("anim-root").text("under " + cfg.root); - render.click(function() { self._el("anim-render-form").toggle(); }); - this._el("anim-render-cancel").click(function() { - self._el("anim-render-form").hide(); - }); - this._el("anim-render-ok").click(this.render.bind(this)); - } + // Rendering builds the movie in the page and downloads it, so it needs + // no server: it works the same in a static export. + this._el("anim-render").click(function() { + self._el("anim-render-form").toggle(); + }); + this._el("anim-render-cancel").click(function() { + self._el("anim-render-form").hide(); + }); + this._el("anim-render-ok").click(this.render.bind(this)); + + var format = this._el("anim-format"); + if (!vt.canEncodeVideo()) + format.find("option[value=mp4]").prop("disabled", true).attr( + "title", "Needs a browser that can encode video (WebCodecs), " + + "on localhost, https or a local file"); + format.on("change", function() { self.updateFormatHint(); }); + // Same reason as the smoothing select above: keep typing in it from + // reaching the viewer's keyboard shortcuts. + format.on("keypress keydown keyup", function(event) { + event.stopPropagation(); + }); + this.updateFormatHint(); + this._el("anim-render-form").hide(); this._el("anim-width").val(this.viewer.imageWidth || 2400); this._el("anim-height").val(this.viewer.imageHeight || 1200); @@ -673,6 +684,12 @@ var jsplot = (function (module) { this._el("anim-status").text(msg === undefined ? "" : msg); }; + // What the status line says -- how a script (or a test) learns why a + // render stopped. + AnimationPanel.prototype._statusText = function() { + return this._el("anim-status").text(); + }; + // Push the internal state out to every widget, and redraw the keyframe dots. AnimationPanel.prototype.sync = function() { var st = this.state; @@ -1059,9 +1076,218 @@ var jsplot = (function (module) { return [width, height]; }; + // ------------------------------------------------------------------ + // Rendering to a download + // ------------------------------------------------------------------ + // + // A render is packaged in the page and handed to the browser as one + // download, the way the viewer's "Save image" button works: the frames land + // on the machine running the browser, and the server writes nothing. One + // download per frame is not an option -- browsers throttle a page that + // starts hundreds of them, or ask whether to allow it -- so the frames go + // into a single file, either a .zip of PNGs or an .mp4. + + // Whether this page can encode video. That takes WebCodecs, which browsers + // only offer in a secure context: localhost, https or a local file, but not + // plain http from another machine. + vt.canEncodeVideo = function() { + return typeof window.VideoEncoder !== "undefined" && + window.isSecureContext === true; + }; + + // Hand `blob` to the browser as a download called `filename`. + vt.download = function(blob, filename) { + var url = URL.createObjectURL(blob); + var a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // The click only starts the download. Releasing the blob straight away + // can cut it short in some browsers, so hold on to it for a while. + setTimeout(function() { URL.revokeObjectURL(url); }, 60000); + }; + + // A movie name safe to use as a file name and as the folder inside a zip: + // nothing that could climb out of that folder when it is unpacked. + function movieName(name) { + var safe = String(name || "").trim().replace(/[^A-Za-z0-9 _.-]/g, "_") + .replace(/^[.\s]+/, ""); + return safe.length > 0 ? safe : "brainmovie"; + } + + function pad5(n) { + var s = String(n); + while (s.length < 5) + s = "0" + s; + return s; + } + + function formatBytes(n) { + if (n < 1024 * 1024) + return (n / 1024).toFixed(0) + " KB"; + return (n / (1024 * 1024)).toFixed(1) + " MB"; + } + + function canvasToBlob(canvas, type) { + return new Promise(function(resolve, reject) { + canvas.toBlob(function(blob) { + if (blob) + resolve(blob); + else + reject(new Error("The browser could not encode the frame")); + }, type); + }); + } + + // One PNG per frame, in a zip. Lossless and transparent outside the brain, + // so these are the frames to use when they have to match a flatmap from + // quickflat.make_png. Entries are named after the animation's own frame + // numbers, inside a folder named after the movie. + function PngZipWriter(name) { + this.name = name; + this.zip = new jsplot.zipstore.ZipWriter(); + } + PngZipWriter.prototype.extension = "zip"; + PngZipWriter.prototype.start = function() { + return Promise.resolve(); + }; + PngZipWriter.prototype.addFrame = function(canvas, frame) { + var zip = this.zip; + var entry = this.name + "/" + this.name + "_" + pad5(frame) + ".png"; + return canvasToBlob(canvas, "image/png").then(function(blob) { + return zip.add(entry, blob); + }); + }; + PngZipWriter.prototype.finish = function() { + return Promise.resolve(this.zip.finish()); + }; + PngZipWriter.prototype.abort = function() {}; + + // An H.264 video, encoded by the browser (WebCodecs) and packed by + // jsplot.mp4mux. Lossy, and opaque: H.264 has no alpha channel, so frames + // are laid on black, which is how the viewer shows them on screen. H.264 + // also stores colour at half resolution and so needs even dimensions; an + // odd size gets one extra column or row of black rather than being + // rescaled. + function Mp4Writer(width, height, fps) { + this.width = width + (width % 2); + this.height = height + (height % 2); + this.padded = this.width !== width || this.height !== height; + this.fps = fps; + this.keyEvery = Math.max(1, Math.round(2 * fps)); // a keyframe every 2 s + this.canvas = document.createElement("canvas"); + this.canvas.width = this.width; + this.canvas.height = this.height; + this.ctx = this.canvas.getContext("2d"); + this.muxer = null; + this.encoder = null; + this.error = null; + } + Mp4Writer.prototype.extension = "mp4"; + + // Settle on an encoder configuration before the first frame is rendered, + // so a size the browser cannot encode fails straight away. + Mp4Writer.prototype.start = function() { + var self = this; + return jsplot.mp4mux.encoderConfig(this.width, this.height, this.fps) + .then(function(config) { + self.muxer = new jsplot.mp4mux.Mp4Muxer(self.width, self.height, + self.fps); + self.encoder = new VideoEncoder({ + output: function(chunk, metadata) { + try { + self.muxer.addChunk(chunk, metadata); + } catch (e) { + self.error = e; + } + }, + error: function(e) { self.error = e; }, + }); + self.encoder.configure(config); + }); + }; + + // `index` counts frames from the start of the render, so the video's + // clock starts at zero whatever the animation's first frame is. + Mp4Writer.prototype.addFrame = function(canvas, frame, index) { + if (this.error) + return Promise.reject(this.error); + + this.ctx.fillStyle = "#000"; + this.ctx.fillRect(0, 0, this.width, this.height); + this.ctx.drawImage(canvas, 0, 0); + + var video = new VideoFrame(this.canvas, { + timestamp: Math.round(index * 1e6 / this.fps), + duration: Math.round(1e6 / this.fps), + }); + this.encoder.encode(video, {keyFrame: index % this.keyEvery === 0}); + video.close(); + + // Rendering is faster than encoding at these sizes; wait for the + // encoder to catch up rather than queueing the whole movie as raw + // frames. + var encoder = this.encoder, self = this; + return new Promise(function(resolve, reject) { + (function wait() { + if (self.error) + reject(self.error); + else if (encoder.encodeQueueSize <= 2) + resolve(); + else + setTimeout(wait, 5); + }()); + }); + }; + Mp4Writer.prototype.finish = function() { + var self = this; + return this.encoder.flush().then(function() { + self.encoder.close(); + if (self.error) + throw self.error; + return self.muxer.finish(); + }); + }; + Mp4Writer.prototype.abort = function() { + if (this.encoder !== null && this.encoder.state !== "closed") + this.encoder.close(); + }; + + // Choose the render format ("png" or "mp4") from code, as the select + // would. Returns false for a format this browser cannot produce. + AnimationPanel.prototype.setRenderFormat = function(format) { + var select = this._el("anim-format"); + var option = select.find("option[value='" + format + "']"); + if (option.length === 0 || option.prop("disabled")) + return false; + select.val(format); + this.updateFormatHint(); + return true; + }; + + // Set the render size from code, as typing it would. + AnimationPanel.prototype.setRenderSize = function(width, height) { + this._el("anim-width").val(width); + this._el("anim-height").val(height); + return this.renderSize(); + }; + + AnimationPanel.prototype.updateFormatHint = function() { + var hint = this._el("anim-format-hint"); + if (this._el("anim-format").val() === "mp4") + hint.text("H.264: lossy, and without transparency. Use PNG " + + "frames to match quickflat.make_png() exactly."); + else if (!vt.canEncodeVideo()) + hint.text("MP4 needs a browser that can encode video, on " + + "localhost, https or a local file."); + else + hint.text(""); + }; + AnimationPanel.prototype.render = function() { var st = this.state, self = this; - var cfg = viewopts.movie_post; if (this.rendering) { this.status("Already rendering"); @@ -1072,14 +1298,20 @@ var jsplot = (function (module) { return; } - var name = this._el("anim-name").val(); - var dir = this._el("anim-dir").val(); + var name = movieName(this._el("anim-name").val()); + var format = this._el("anim-format").val(); var size = this.renderSize(); if (size === null) { this.status("Bad image size"); return; } + if (format === "mp4" && !vt.canEncodeVideo()) { + this.status("This browser cannot encode MP4 here; render PNG frames"); + return; + } var width = size[0], height = size[1]; + var writer = format === "mp4" ? new Mp4Writer(width, height, st.fps) : + new PngZipWriter(name); this.stop(); this.rendering = true; @@ -1093,11 +1325,17 @@ var jsplot = (function (module) { self.status(msg); } - // Strictly one frame at a time: both the webgl readback and the upload - // are asynchronous, so a plain loop would race. + function fail(msg) { + writer.abort(); + finish(msg); + } + + // Strictly one frame at a time: the webgl readback, the PNG or video + // encoding and the packing are all asynchronous, so a plain loop would + // race. function renderFrame(frame) { if (!self.rendering) { - finish("Rendering cancelled"); + fail("Rendering cancelled"); return; } self.setFrame(frame); @@ -1111,25 +1349,40 @@ var jsplot = (function (module) { try { image = self.viewer.getImage(width, height); } catch (e) { - finish("Could not render frame " + frame + ": " + e.message); + fail("Could not render frame " + frame + ": " + e.message); return; } - $.post(cfg.url, {token: cfg.token, dir: dir, name: name, - frame: frame, png: image.toDataURL()}) - .done(function() { - if (frame < st.last) - renderFrame(frame + 1); - else - finish("Rendered " + total + " frames"); - }) - .fail(function(xhr) { - finish("Frame " + frame + " failed: " + - (xhr.responseText || xhr.statusText)); - }); + writer.addFrame(image, frame, frame - st.first).then(function() { + if (frame < st.last) + renderFrame(frame + 1); + else + deliver(); + }, function(e) { + fail("Frame " + frame + " failed: " + e.message); + }); }); } - renderFrame(st.first); + function deliver() { + self.status("Packing " + total + " frames"); + writer.finish().then(function(blob) { + var filename = name + "." + writer.extension; + vt.download(blob, filename); + finish("Saved " + filename + " (" + total + " frames, " + + formatBytes(blob.size) + + (writer.padded ? ", padded to " + writer.width + " × " + + writer.height : "") + ")"); + }, function(e) { + fail("Could not finish " + name + "." + writer.extension + + ": " + e.message); + }); + } + + writer.start().then(function() { + renderFrame(st.first); + }, function(e) { + fail(e.message); + }); }; // ------------------------------------------------------------------ diff --git a/cortex/webgl/resources/js/zipstore.js b/cortex/webgl/resources/js/zipstore.js new file mode 100644 index 000000000..051a156c9 --- /dev/null +++ b/cortex/webgl/resources/js/zipstore.js @@ -0,0 +1,143 @@ +// A ZIP writer for files the browser has already compressed. +// +// The animation panel renders a movie as one PNG per frame, and a page cannot +// hand the person hundreds of separate downloads: browsers throttle them, or +// ask whether to allow them. So the frames are packed into a single .zip in the +// page, and that is downloaded instead. +// +// PNG is compressed already, so every entry is *stored* (compression method 0) +// rather than deflated. That keeps this small: a stored entry is its bytes +// behind a fixed header, and the only computation is a CRC-32 of each one. +// The archive is assembled as a Blob of parts -- headers interleaved with the +// frames' own Blobs -- so the image bytes are never copied into one buffer, +// and a browser that backs large Blobs with disk (as Chrome does) need not +// hold the whole movie in memory. +// +// Classic ZIP only: at most 65535 entries and 4 GiB. Past either, add() throws +// rather than writing ZIP64. + +var jsplot = (function (module) { + module.zipstore = (function (zs) { + + var MAX_ENTRIES = 0xFFFF; + var MAX_OFFSET = 0xFFFFFFFF; + + var CRC_TABLE = (function() { + var table = new Uint32Array(256); + for (var n = 0; n < 256; n++) { + var c = n; + for (var k = 0; k < 8; k++) + c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); + table[n] = c >>> 0; + } + return table; + }()); + + // CRC-32 (the one ZIP, gzip and PNG use) of a Uint8Array. + zs.crc32 = function(bytes) { + var crc = 0xFFFFFFFF; + for (var i = 0; i < bytes.length; i++) + crc = CRC_TABLE[(crc ^ bytes[i]) & 0xFF] ^ (crc >>> 8); + return (crc ^ 0xFFFFFFFF) >>> 0; + }; + + // The MS-DOS time and date fields ZIP stores, for `when`. + function dosDateTime(when) { + return { + time: (when.getHours() << 11) | (when.getMinutes() << 5) | + (when.getSeconds() >> 1), + date: ((when.getFullYear() - 1980) << 9) | + ((when.getMonth() + 1) << 5) | when.getDate(), + }; + } + + // Collects stored entries and turns them into one application/zip Blob. + zs.ZipWriter = function() { + this._parts = []; // local headers and file data, in order + this._central = []; // one central directory record per entry + this._offset = 0; // where the next local header starts + this._stamp = dosDateTime(new Date()); + this._encoder = new TextEncoder(); + }; + + // Add `blob` under `name`. Returns a promise, since reading the blob to + // work out its CRC is asynchronous; add entries one at a time. + zs.ZipWriter.prototype.add = function(name, blob) { + var self = this; + if (this.count() >= MAX_ENTRIES) + return Promise.reject(new Error( + "A zip holds at most " + MAX_ENTRIES + " files; render a " + + "shorter range of frames")); + + return blob.arrayBuffer().then(function(buffer) { + var data = new Uint8Array(buffer); + var crc = zs.crc32(data); + var nameBytes = self._encoder.encode(name); + + if (self._offset + 30 + nameBytes.length + data.length > MAX_OFFSET) + throw new Error("A zip holds at most 4 GiB; render a shorter " + + "range of frames, or a smaller size"); + + // Bit 11 of the flags: the name is UTF-8. + var local = new DataView(new ArrayBuffer(30)); + local.setUint32(0, 0x04034b50, true); // local file header + local.setUint16(4, 20, true); // version needed: 2.0 + local.setUint16(6, 0x0800, true); // flags + local.setUint16(8, 0, true); // method: stored + local.setUint16(10, self._stamp.time, true); + local.setUint16(12, self._stamp.date, true); + local.setUint32(14, crc, true); + local.setUint32(18, data.length, true); // compressed size + local.setUint32(22, data.length, true); // uncompressed size + local.setUint16(26, nameBytes.length, true); + local.setUint16(28, 0, true); // extra field length + + var central = new DataView(new ArrayBuffer(46)); + central.setUint32(0, 0x02014b50, true); // central directory header + central.setUint16(4, 20, true); // version made by + central.setUint16(6, 20, true); // version needed + central.setUint16(8, 0x0800, true); + central.setUint16(10, 0, true); + central.setUint16(12, self._stamp.time, true); + central.setUint16(14, self._stamp.date, true); + central.setUint32(16, crc, true); + central.setUint32(20, data.length, true); + central.setUint32(24, data.length, true); + central.setUint16(28, nameBytes.length, true); + // extra, comment, disk, internal and external attributes: all 0 + central.setUint32(42, self._offset, true); + + self._parts.push(local.buffer, nameBytes, blob); + self._central.push(central.buffer, nameBytes); + self._offset += 30 + nameBytes.length + data.length; + }); + }; + + // The finished archive. + zs.ZipWriter.prototype.finish = function() { + var size = 0; + for (var i = 0; i < this._central.length; i++) + size += this._central[i].byteLength; + + var end = new DataView(new ArrayBuffer(22)); + var count = this._central.length / 2; + end.setUint32(0, 0x06054b50, true); // end of central directory + end.setUint16(8, count, true); // entries on this disk + end.setUint16(10, count, true); // entries in total + end.setUint32(12, size, true); + end.setUint32(16, this._offset, true); // where the directory starts + + return new Blob(this._parts.concat(this._central, [end.buffer]), + {type: "application/zip"}); + }; + + // How many files have been added. + zs.ZipWriter.prototype.count = function() { + return this._central.length / 2; + }; + + return zs; + }(module.zipstore || {})); + + return module; +}(jsplot || {})); diff --git a/cortex/webgl/template.html b/cortex/webgl/template.html index 892729bdf..a97328365 100644 --- a/cortex/webgl/template.html +++ b/cortex/webgl/template.html @@ -38,6 +38,8 @@ + + {% if leapmotion %} diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index dfba01643..98fa2efd7 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -2,12 +2,10 @@ import copy import functools import glob -import hmac import json import mimetypes import os import re -import secrets import shutil import sys import threading @@ -422,7 +420,6 @@ def show( title: str="Brain", layout: Optional[str]=None, display_url: bool=True, - movie_dir: Optional[str]=None, **kwargs, ): """ @@ -501,11 +498,6 @@ def show( link to access the viewer. Set to False to suppress this display message, which can be useful in contexts like Marimo notebooks or programmatic headless viewers. Default True - movie_dir : str or None, optional - Root directory that the viewer's animation panel may render frames into. - The folder typed into the panel is interpreted relative to this root, and - the server refuses to write anywhere outside it. Default None, meaning - the current working directory. **kwargs All additional keyword arguments are passed to the template renderer. @@ -603,14 +595,6 @@ def show( my_viewopts['quickflat_size'] = {subj: _quickflat_size(subj) for subj in subjects} - # Where the animation panel is allowed to write rendered frames. The browser - # sends a path relative to this root and MovieHandler refuses anything that - # resolves outside it; see MovieHandler below. - movie_root = os.path.realpath(os.getcwd() if movie_dir is None else movie_dir) - movie_token = secrets.token_urlsafe(32) - my_viewopts['movie_post'] = dict(url="movie", token=movie_token, - root=movie_root) - if pickerfun is None: pickerfun = lambda *a: None @@ -707,69 +691,6 @@ def post(self): data = png svgfile.write(data) - class MovieHandler(web.RequestHandler): - """Writes one animation frame rendered by the viewer's animation panel. - - Kept separate from MixerHandler.post, which pairs uploads with filenames - by the order they were pushed onto `post_name`: a browser-driven render - loop has no way to keep that queue in step, so each frame carries its own - destination instead. - - The destination is always resolved underneath `movie_root` (see the - `movie_dir` argument of show). The server binds all interfaces and serves - the page unauthenticated, so the token below only keeps unrelated local - processes out -- `movie_root` is what stops this from being an arbitrary - file-write primitive. - """ - def post(self): - # Compare as bytes: compare_digest rejects non-ASCII str outright, - # which would turn a hostile token into a 500 instead of a 403. - sent = self.get_argument("token", "").encode("utf-8", "replace") - if not hmac.compare_digest(sent, movie_token.encode("utf-8")): - self.set_status(403) - self.finish("Bad or missing token") - return - - name = self.get_argument("name", "frame") - if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", name) is None: - self.set_status(400) - self.finish("Invalid frame name: use letters, digits, '_', '-' and '.'") - return - - try: - frame = int(self.get_argument("frame")) - except (TypeError, ValueError): - self.set_status(400) - self.finish("Invalid or missing frame number") - return - - dest = os.path.realpath(os.path.join(movie_root, - self.get_argument("dir", ""))) - if dest != movie_root and not dest.startswith(movie_root + os.sep): - self.set_status(403) - self.finish("Refusing to write outside %s" % movie_root) - return - - png = self.get_argument("png", default="") - try: - data = binascii.a2b_base64(png[png.index(",") + 1:].strip()) - except (ValueError, binascii.Error): - self.set_status(400) - self.finish("Could not decode png data") - return - - try: - os.makedirs(dest, exist_ok=True) - fname = os.path.join(dest, "%s_%05d.png" % (name, frame)) - with open(fname, "wb") as fp: - fp.write(data) - except OSError as err: - self.set_status(500) - self.finish("Could not write frame: %s" % err) - return - - self.write(dict(path=fname)) - P = ParamSpec('P') class JSMixer(serve.JSProxy[P]): @@ -1568,7 +1489,6 @@ def get_local_client(self): (r'/data/(.*)', DataHandler), (r'/stim/(.*)', StimHandler), (r'/mixer.html', MixerHandler), - (r'/movie', MovieHandler), (r'/picker', PickerHandler), (r'/', MixerHandler), (r'/static/(.*)', StaticHandler)], diff --git a/docs/database.rst b/docs/database.rst index 4df72a2e1..19057e49d 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -423,9 +423,10 @@ The same eight modes are available when rendering from python, either for a whol or per keyframe, by giving a keyframe its own ``interpolation`` key — which is what the panel does. The browser and ``cortex.webgl.interpolation`` share their arithmetic, so a movie rendered from python matches the preview played in the viewer. The older whole-animation easings ``"linear"``, ``"smoothstep"`` and ``"smootherstep"`` still work, but ease each pair of keyframes separately and cannot be combined with per-keyframe modes. -Rendering needs a viewer started from python, since the frames are written by the server rather than by the browser. The folder named in the panel is interpreted relative to the ``movie_dir`` given to ``cortex.webgl.show`` (the current working directory by default), and the server refuses to write outside it:: +**render animation** builds the movie in the browser and downloads it as one file, the way **Save image** does — so it lands on the computer running the browser, wherever that browser saves downloads, and the server writes nothing. It works in static viewers made with :func:`cortex.webgl.make_static` too. Choose the format in the render form: - viewer = cortex.webgl.show(volume, movie_dir="/path/to/movies") +* **PNG frames (.zip)** — one lossless PNG per frame, transparent outside the brain, named after the animation's frame numbers inside a folder named after the movie (``brainmovie/brainmovie_00000.png``, …). These are the frames to use when they must match a flatmap from ``cortex.quickflat.make_png``. A zip holds at most 65,535 frames and 4 GiB; render a shorter range of frames if a movie is larger than that. +* **MP4 video** — H.264, encoded by the browser itself (WebCodecs). It is lossy and has no transparency, so frames are laid on black, as the viewer shows them. Browsers only offer video encoding in a secure context — a viewer opened on ``localhost``, over ``https``, or from a local file — so the option is unavailable when a viewer is reached over plain http from another machine. The largest size depends on the browser's encoder, and a size it cannot encode is refused before rendering starts; an odd width or height gets one extra row or column of black, since H.264 needs even dimensions. ``overlays.svg`` From d202147bc85687cf3931a5950a75a42468ad9c4d Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Wed, 23 Sep 2026 19:11:30 -0700 Subject: [PATCH 08/14] MNT: ignore uv.lock Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 1 + 1 file changed, 1 insertion(+) 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 From 5de6b49a80aa180359e54b4ecb08e579c15cfdd9 Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Wed, 23 Sep 2026 22:32:50 -0700 Subject: [PATCH 09/14] FIX: keep the folded and flat camera targets apart camera.target -- the point the camera orbits and looks at, a hidden menu entry -- stood for two things. LandscapeControls keeps a folded target and a flat target, and shows their blend by how flat the surface is; a write to camera.target went to whichever matched the unfold state at the moment it was made. That was harmless for a view applied all at once, but not for an animation. Between a folded keyframe and a flat one every frame is partly unfolded, so each interpolated target was written into the folded copy: the brain slid down ahead of the flattening -- 88% of the way at frame 20 of 30 -- and after unfolding again it sat some 53 units below where it started. The same class of bug as the camera angle, fixed earlier on this branch, for position. camera.target now always means the folded target, and a new hidden camera.flat_target always means the flat one (setFoldedTarget and setFlatTarget in movement.js). Views and keyframes carry both, each is interpolated on its own and written only to its own copy, and the transition is simply the blend setMix already performs. Panning still goes through setTarget, so dragging behaves as it did. A flat view saved before camera.flat_target existed stores its flat target as camera.target, since that is where it went once the surface was flat, so a flat view carrying camera.target and no camera.flat_target is read that way -- in vt.applyView and in JSMixer._set_view alike. save_3d_views depends on that reading for the target it passes with its flatmap: regenerating every visual regression reference in place leaves all 28 byte-identical. The flat target also no longer starts at a hardcoded y = -60, a guess within 0.1 of S1's flatmap centre and wrong for any other subject. It starts at the flatmap's real centre, measured when the viewer loads. The surface is folded then, and flatBBox measures through the current transforms, so Surface.flatViewBBox poses the pivot groups the way the flat view would -- flat, pivoted 180, unshifted -- measures, and puts them back before anything is drawn. It agrees with the fitted flat view to the digits shown. The built-in flat view accordingly names no target, and setting it from python lands the flatmap centred rather than sixty units low. Co-Authored-By: Claude Opus 5.5 (1M context) --- cortex/export/save_views.py | 7 + cortex/tests/test_default_views.py | 12 ++ cortex/tests/test_webgl_headless.py | 146 ++++++++++++++++++- cortex/webgl/resources/js/movement.js | 33 ++++- cortex/webgl/resources/js/mriview.js | 39 ++++- cortex/webgl/resources/js/mriview_surface.js | 34 +++++ cortex/webgl/resources/js/viewtools.js | 10 ++ cortex/webgl/view.py | 10 ++ docs/database.rst | 2 + 9 files changed, 284 insertions(+), 9 deletions(-) diff --git a/cortex/export/save_views.py b/cortex/export/save_views.py index 4f37eabaf..31ca9f173 100644 --- a/cortex/export/save_views.py +++ b/cortex/export/save_views.py @@ -439,5 +439,12 @@ def build(*overrides: ViewParams) -> ViewParams: 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 return views diff --git a/cortex/tests/test_default_views.py b/cortex/tests/test_default_views.py index 401e8c5dd..27ce4d943 100644 --- a/cortex/tests/test_default_views.py +++ b/cortex/tests/test_default_views.py @@ -154,6 +154,18 @@ def test_the_flat_view_carries_no_camera_angle(): 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(): diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py index cdac63c34..3c4ec83b7 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -1698,7 +1698,7 @@ def test_fit_flat_view_frames_the_flat_surface(): fov = _js_value(handle, "window.viewer.camera.fov") aspect = _js_value(handle, "window.viewer.camera.aspect") view = handle._capture_view() - assert view["camera.target"] == pytest.approx( + 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, @@ -1906,6 +1906,150 @@ def test_flattening_leaves_the_camera_angle_alone(): 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 diff --git a/cortex/webgl/resources/js/movement.js b/cortex/webgl/resources/js/movement.js index b006612d9..7d838b566 100644 --- a/cortex/webgl/resources/js/movement.js +++ b/cortex/webgl/resources/js/movement.js @@ -134,14 +134,21 @@ var jsplot = (function (module) { this.setRadius(this.radius * Math.exp(this.pinchZoomSpeed * delta)); } + // The visible target: the folded and flat targets blended by how flat the + // surface is, which is how the camera travels between them as it unfolds. + module.LandscapeControls.prototype._blendTarget = function() { + var mix = this.mix; + this.target.set(this._foldedtarget.x * (1-mix) + mix*this._flattarget.x, + this._foldedtarget.y * (1-mix) + mix*this._flattarget.y, + this._foldedtarget.z * (1-mix) + mix*this._flattarget.z); + } + module.LandscapeControls.prototype.setMix = function(mix) { this.mix = mix; if (mix > 0 && mix < 1 || true) { // hacky, I'm leaving this for now.. this.azimuth = (1 - this.mix) * this._foldedazimuth + this.mix * this._flatazimuth; this.altitude = (1 - this.mix) * this._foldedaltitude + this.mix * this._flataltitude; - this.target.set(this._foldedtarget.x * (1-mix) + mix*this._flattarget.x, - this._foldedtarget.y * (1-mix) + mix*this._flattarget.y, - this._foldedtarget.z * (1-mix) + mix*this._flattarget.z); + this._blendTarget(); } else { this.setAzimuth(this.azimuth); this.setAltitude(this.altitude); @@ -208,6 +215,26 @@ var jsplot = (function (module) { } } + // The two targets setTarget chooses between, each on its own. Views and + // keyframes store these (as camera.target and camera.flat_target) rather + // than going through setTarget, which writes whichever one matches the + // surface at that moment: an animation stepping through partly unfolded + // poses would otherwise write values meant for the flat target into the + // folded one, and leave the folded brain displaced once it unfolds. + module.LandscapeControls.prototype.setFoldedTarget = function(xyz) { + if (!(xyz instanceof Array)) + return this._foldedtarget.toArray(); + this._foldedtarget.set(xyz[0], xyz[1], xyz[2]); + this._blendTarget(); + } + + module.LandscapeControls.prototype.setFlatTarget = function(xyz) { + if (!(xyz instanceof Array)) + return this._flattarget.toArray(); + this._flattarget.set(xyz[0], xyz[1], 0); // the flatmap lies in z = 0 + this._blendTarget(); + } + module.LandscapeControls.prototype.update2Dbutton = function() { if ( this.mix == 1.0 ){ if ( this.altitude > 0.1 || this.azimuth != 180 ) diff --git a/cortex/webgl/resources/js/mriview.js b/cortex/webgl/resources/js/mriview.js index 0b54ea674..29622f5c4 100644 --- a/cortex/webgl/resources/js/mriview.js +++ b/cortex/webgl/resources/js/mriview.js @@ -109,6 +109,15 @@ var mriview = (function(module) { this.loaded = $.Deferred().done(function() { //this.schedule(); this.resize(); + // Start the flat target at the middle of the flatmap, where + // quickflat centres it, rather than at the controls' built-in + // guess (y = -60: close for S1, wrong for other subjects). Kept as + // the fallback when there is no flatmap to measure. + // Measured in the flat view's pose, since the surface is not flat + // yet. + var framing = this.flatFraming(undefined, this.flatViewBBox()); + if (framing !== null) + this.controls.setFlatTarget(framing.target); $(this.object).find("#ctmload").hide(); this.canvas.css("opacity", 1); this.object.appendChild(this.controls.twodbutton[0]); @@ -979,6 +988,19 @@ var mriview = (function(module) { return null; }; + // flatBBox as it will be in the flat view's pose, whatever the pose now + // (see Surface.flatViewBBox). + module.Viewer.prototype.flatViewBBox = function() { + for (var i = 0; i < this.surfs.length; i++) { + if (this.surfs[i].flatViewBBox === undefined) + continue; + var box = this.surfs[i].flatViewBBox(); + if (box !== null) + return box; + } + return null; + }; + // The framing cortex.quickflat.make_png would use for a frame of the given // shape, as {target, radius, aspect}, or null if there is no flatmap. // Works the framing out without moving anything, which is what the @@ -995,8 +1017,11 @@ var mriview = (function(module) { // is then make_png's png -- when that is the flatmap's own aspect ratio, // which is what viewopts.quickflat_size has. At any other shape of frame // the flatmap is fitted inside it rather than cropped to it. - module.Viewer.prototype.flatFraming = function(aspect) { - var box = this.flatBBox(); + // + // `box` is the extent to frame, defaulting to the flatmap as it is now. + module.Viewer.prototype.flatFraming = function(aspect, box) { + if (box === undefined) + box = this.flatBBox(); if (box === null) return null; @@ -1026,7 +1051,7 @@ var mriview = (function(module) { if (framing === null) return null; - this.controls.setTarget(framing.target); + this.controls.setFlatTarget(framing.target); this.controls.setRadius(framing.radius); this._flatFitAspect = framing.aspect; // so isFlatFitted knows this framing // Move the camera now rather than on the next animation frame: this is @@ -1037,7 +1062,7 @@ var mriview = (function(module) { // What setTarget and setRadius actually took, which is not what was // asked for if a subject's flatmap is small enough to hit the zoom // clamp (radius is held at 10 or more, and at 101 or more while flat). - return {target: this.controls.setTarget(), + return {target: this.controls.setFlatTarget(), radius: this.controls.setRadius()}; }; @@ -1295,7 +1320,11 @@ var mriview = (function(module) { azimuth: {action:[this.controls, 'setAzimuth', 0, 360]}, altitude: {action:[this.controls, 'setAltitude', 0, 180]}, radius: {action:[this.controls, 'setRadius', 10, 1000]}, - target: {action:[this.controls, 'setTarget'], hidden:true}, + // The folded and the flat target, stored apart so that neither + // absorbs values meant for the other (see setFoldedTarget in + // movement.js). + target: {action:[this.controls, 'setFoldedTarget'], hidden:true}, + flat_target: {action:[this.controls, 'setFlatTarget'], hidden:true}, }); var fold_brain = function() { diff --git a/cortex/webgl/resources/js/mriview_surface.js b/cortex/webgl/resources/js/mriview_surface.js index ab456b2b4..a7602dcfb 100644 --- a/cortex/webgl/resources/js/mriview_surface.js +++ b/cortex/webgl/resources/js/mriview_surface.js @@ -861,6 +861,37 @@ var mriview = (function(module) { return found ? {min:min, max:max} : null; }; + // flatBBox for the pose the flat view puts the surface in -- flattened, + // pivoted 180 and not shifted -- whatever pose it is in now. + // + // flatBBox measures through the meshes' current matrices, which describe + // the flatmap only while the surface is flat. This poses the pivot groups + // the way setMix (fully flat), setPivot(180) and setShift(0) would, + // measures, and puts them back, all before anything is drawn: so the + // viewer can know where the flatmap will be before it is first flattened. + // Those three setters are the only things that move the pivot groups. + module.Surface.prototype.flatViewBBox = function() { + var sides = {left: 1, right: -1}, saved = {}, name; + for (name in this.pivots) { + var p = this.pivots[name]; + saved[name] = {front: p.front.rotation.clone(), + back: p.back.rotation.clone(), + shift: p.front.position.clone()}; + p.back.rotation.x = -Math.PI / 2; // setMix, flat + p.front.rotation.z = 0; // setPivot(180) + p.back.rotation.z = Math.PI * sides[name] / 2; + p.front.position.x = 0; // setShift(0) + } + var box = this.flatBBox(); + for (name in saved) { + this.pivots[name].front.rotation.copy(saved[name].front); + this.pivots[name].back.rotation.copy(saved[name].back); + this.pivots[name].front.position.copy(saved[name].shift); + } + this.object.updateMatrixWorld(true); + return box; + }; + module.SurfDelegate = function(dataview) { this.object = new THREE.Group(); this.object.name = "SurfDelegate"; @@ -908,6 +939,9 @@ var mriview = (function(module) { module.SurfDelegate.prototype.flatBBox = function() { return this.surf.flatBBox(); } + module.SurfDelegate.prototype.flatViewBBox = function() { + return this.surf.flatViewBBox(); + } module.SurfDelegate.prototype.setPivot = function(pivot) { return this.surf.setPivot(pivot); } diff --git a/cortex/webgl/resources/js/viewtools.js b/cortex/webgl/resources/js/viewtools.js index 2c8080279..5b7520723 100644 --- a/cortex/webgl/resources/js/viewtools.js +++ b/cortex/webgl/resources/js/viewtools.js @@ -155,6 +155,16 @@ var jsplot = (function (module) { delete params[key]; } } + // A flat view saved before camera.flat_target existed stores its flat + // target as camera.target -- that is where setTarget put it once the + // surface was flat -- so read it as one. save_3d_views relies on the + // same reading for the target it passes along with its flatmap. + // JSMixer._set_view applies the same rule. + if (vt.isFlat(params) && ('camera.target' in params) && + !('camera.flat_target' in params)) { + params['camera.flat_target'] = params['camera.target']; + delete params['camera.target']; + } delete params['frame']; // animation bookkeeping, not a menu path delete params['interpolation']; // ditto: the keyframe's smoothing mode delete params['time']; // written by _capture_view(frame_time=...) diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index 98fa2efd7..dc6cf7478 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -749,6 +749,16 @@ def _set_view(self, **kwargs): for old_key, new_key in self._legacy_props.items(): if old_key in kwargs and new_key not in kwargs: kwargs[new_key] = kwargs.pop(old_key) + # A flat view saved before camera.flat_target existed stores its + # flat target as camera.target -- that is where it went once the + # surface was flat -- so read it as one. Mirrors vt.applyView in + # resources/js/viewtools.js; save_3d_views relies on it for the + # target it passes along with its flatmap, which is what keeps + # that function's flatmaps exactly as they were. + if (kwargs.get('surface.{subject}.unfold', 0) >= 0.999 + and 'camera.target' in kwargs + and 'camera.flat_target' not in kwargs): + kwargs['camera.flat_target'] = kwargs.pop('camera.target') for subject in subject_list: if 'surface.{subject}.unfold' in kwargs: unfold = kwargs.pop('surface.{subject}.unfold') diff --git a/docs/database.rst b/docs/database.rst index 19057e49d..e95ded19c 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -366,6 +366,8 @@ That framing is applied on request, never behind your back. Setting the flat vie A view saved in the filestore under the name ``flat`` replaces all of this, framing included, since a saved view records the camera distance it was saved with. +The camera keeps two targets — the point it orbits and looks at — one for the folded brain and one for the flatmap, and moves between them as the surface unfolds. Views store them separately, as ``camera.target`` (folded) and ``camera.flat_target``, so an animation from a folded pose into the flat view leaves the folded target where it was, and unfolding again returns the brain exactly to its starting place. The flat target starts at the middle of the flatmap, so flattening lands centred without the flat view naming one. A flat view saved before ``camera.flat_target`` existed stores its flat target as ``camera.target``, and is still read that way: a flat view that carries ``camera.target`` but no ``camera.flat_target`` sets the flat target. + Saved views in the browser ~~~~~~~~~~~~~~~~~~~~~~~~~~ From 4d82a3458fbeb117efe0de36185a6d0464c07a0f Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Wed, 23 Sep 2026 23:45:44 -0700 Subject: [PATCH 10/14] ENH: frame the default views for each subject, zoom included The default views named camera angles and an unfold amount, but no camera radius, so clicking one kept whatever zoom the viewer happened to have -- a view could not reliably return the same scene, and so could not be relied on to render the same images twice. They also all aimed at the origin, while a brain need not be centred there: S1's is 22 mm anterior and 11 mm superior of it, which left it off-centre in every view. Every default view but flat now carries a camera target and radius. The target is the middle of the surface the view shows; the radius is the smallest distance at which every vertex of that surface falls inside 85% of a 4:3 frame, worked out through the viewer's own perspective camera (camera_basis, moved here from the tests so the fit and the anatomical name checks share one model of it). A wider window just leaves more room either side. flat keeps the framing it fits to the window itself. No constant could do this for every subject -- brains differ in size, pycortex serves macaque data as well as human, and an inflated surface is a different shape again -- so the framing is fitted to each subject's own surfaces, as the viewer lays them out. That is not the surface files as they stand: brainctm packs a subject on its pial surface, with the white matter alongside and the folded brain drawn at their midpoint, and rescales the inflated surface one hemisphere at a time into the pial bounding box. Fitting the raw inflated file left the inflated views at about half size; _viewer_points reproduces the packing, and a test checks it vertex for vertex against brainctm's own output. Reading the surfaces takes about half a second, so the fit is cached as default_view_framing.json in the subject's cache directory, and refitted when a surface file or a framing constant changes. A filestore that cannot be written only loses the caching, and surfaces that cannot be read leave the views as they were rather than breaking a viewer. default_subject_views gains an optional subject and is otherwise unchanged; save_3d_views, which builds its views from the shared tables, is untouched, and the visual regression references pass as they are. Co-Authored-By: Claude Opus 5.5 (1M context) --- cortex/export/save_views.py | 256 +++++++++++++++++++++++++++- cortex/tests/test_default_views.py | 228 ++++++++++++++++++++----- cortex/tests/test_webgl_headless.py | 57 +++++++ cortex/webgl/view.py | 7 +- docs/database.rst | 4 + 5 files changed, 505 insertions(+), 47 deletions(-) diff --git a/cortex/export/save_views.py b/cortex/export/save_views.py index 31ca9f173..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, @@ -389,7 +395,8 @@ def save_3d_views( FLAT_INERT_PROPS = ("camera.azimuth", "camera.altitude") -def default_subject_views(has_flatmap: bool = True) -> dict[str, ViewParams]: +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 @@ -404,6 +411,11 @@ def default_subject_views(has_flatmap: bool = True) -> dict[str, ViewParams]: 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 ------- @@ -447,4 +459,244 @@ def build(*overrides: ViewParams) -> ViewParams: # 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 index 27ce4d943..311a649e7 100644 --- a/cortex/tests/test_default_views.py +++ b/cortex/tests/test_default_views.py @@ -18,6 +18,7 @@ FLAT_VIEW_NAME, INFLATED_SUFFIX, angle_view_params, + camera_basis, default_subject_views, ) @@ -26,49 +27,6 @@ ANTERIOR = (0.0, 1.0, 0.0) SUPERIOR = (0.0, 0.0, 1.0) -# LandscapeControls clamps altitude into (0.0001, 179.9999) before building the -# camera position, so a view asking for 0 or 180 is rendered a hair off the -# pole. That matters: exactly at the pole the view direction is parallel to the -# up vector and the image orientation is undefined. -POLE_EPSILON = 1e-4 - - -def camera_basis(azimuth, altitude): - """The camera's axes in world space, as the viewer computes them. - - Reproduces the eye position from ``LandscapeControls.update`` in - resources/js/LandscapeControls.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, 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(SUPERIOR, back)) - up = cross(back, right) - return right, up, back - - 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)) @@ -344,3 +302,187 @@ def test_defaults_are_per_subject(monkeypatch, tmp_path): 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_webgl_headless.py b/cortex/tests/test_webgl_headless.py index 3c4ec83b7..e8a71533d 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -2423,3 +2423,60 @@ def textures(): _js_run(handle, "window.viewer.getImage", [64, 48]) assert textures() == before + + +# --------------------------------------------------------------------------- +# Default views carry the whole camera +# --------------------------------------------------------------------------- + + +def test_default_views_set_the_fitted_target_and_radius(): + """Clicking a default view returns the same scene, zoom included.""" + from cortex.export.save_views import default_view_framing + + framing = default_view_framing(subj) + vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + with cortex.export.headless_viewer(vol, viewer_params={}) as handle: + for name in ("dorsal", "lateral_left_inflated"): + # Zoomed and aimed somewhere else first. + handle._set_view(**{"camera.radius": 520, "camera.target": [30, -20, 5]}) + 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.radius"] == pytest.approx( + framing[name]["camera.radius"], rel=1e-6), name + assert view["camera.target"] == pytest.approx( + framing[name]["camera.target"], abs=1e-6), name + + +@pytest.mark.parametrize("name", ["dorsal", "lateral_left_inflated"]) +def test_a_default_view_fills_a_4_by_3_frame(tmp_path, name): + """The framing holds in the real viewer, not just in python's model of it. + + Fitted so the brain's farthest point from the middle reaches + FRAMING_FILL of the half-frame: rendered 4:3, the tightest margin is + (1 - FRAMING_FILL) / 2 of the frame, and nothing is clipped. Dorsal is + limited by its height, the lateral view by its width, and the inflated + view checks that the fit sees the inflated surface the way the viewer's + surface packs lay it out. + """ + from cortex.export.save_views import FRAMING_FILL + + width, height = 800, 600 + vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + shot = str(tmp_path / (name + ".png")) + with cortex.export.headless_viewer(vol, viewer_params={}) as handle: + handle.send(method="run", params=[ + "window.viewer.ui._desc.camera._desc.views._desc.%s.action" % name, []]) + time.sleep(3) + handle.getImage(shot, size=(width, height)) + wait_for_file(shot, timeout=60) + time.sleep(1) + + x0, y0, x1, y1 = _mask_bbox(_alpha_mask(shot)) + margins = [x0 / width, (width - x1) / width, y0 / height, (height - y1) / height] + assert min(margins) > 0, margins # nothing clipped + assert min(margins) == pytest.approx((1 - FRAMING_FILL) / 2, abs=0.015), margins diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index dc6cf7478..8d23a80ca 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -110,7 +110,10 @@ def _load_saved_views(subjects: list[str]) -> dict[str, dict[str, dict[str, Any] Every subject gets the standard anatomical views from ``cortex.export.save_views.default_subject_views`` -- dorsal, ventral, the two lateral views, their inflated counterparts, and flat -- so that a - subject with an empty (or missing) views/ directory still has them. A view + subject with an empty (or missing) views/ directory still has them. All but + flat are framed for that subject's brain: aimed at its middle, from a + distance that fills most of the frame (``default_view_framing``), so a view + always returns the same scene. A view stored in the filestore under one of those names replaces the default, which is how a subject whose anatomy needs a different angle, or who wants a different framing, overrides one. @@ -131,7 +134,7 @@ def _load_saved_views(subjects: list[str]) -> dict[str, dict[str, dict[str, Any] saved: dict[str, dict[str, dict[str, Any]]] = {} for subj in subjects: - saved[subj] = dict(default_subject_views(_has_flatmap(subj))) + saved[subj] = dict(default_subject_views(_has_flatmap(subj), subj)) viewdir = os.path.join(db.filestore, subj, "views") # Glob *.json rather than using db.get_paths()['views'], which strips any # extension off any file in the directory (so notes.tar.gz would show up diff --git a/docs/database.rst b/docs/database.rst index e95ded19c..56f70df6c 100644 --- a/docs/database.rst +++ b/docs/database.rst @@ -354,6 +354,10 @@ They are built from the same tables ``cortex.export.save_views`` uses for :func: from cortex.export.save_views import default_subject_views default_subject_views()["dorsal"] +Each view but ``flat`` also fixes the camera's aim and distance, so clicking it returns exactly the same scene every time, ready to render the same images again. The camera aims at the middle of the surface the view shows, from the distance at which that brain fills 85% of a 4:3 frame (``FRAMING_FILL`` and ``FRAMING_ASPECT`` in ``cortex.export.save_views``); in a wider window there is simply more room either side. Brains differ in size, and an inflated surface in shape, so this is fitted to each subject's own surfaces — as the viewer lays them out — the first time a viewer opens that subject, and cached as ``default_view_framing.json`` in its cache directory; the cache is refitted when a surface file changes. Pass the subject to see the framed views:: + + default_subject_views(subject="S1")["dorsal"] + **A view saved in the filestore under one of these names replaces the default.** So if a subject's anatomy wants a different angle, or you prefer a different framing, save your own view under that name and it is used instead — for that subject only, leaving every other default in place:: viewer.save_view(subject, "dorsal", is_overwrite=True) From ea11af6677b6bf68a0342753c9b71e3ef5b20f9d Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Mon, 28 Sep 2026 17:06:11 -0700 Subject: [PATCH 11/14] FIX: line the keyframe dots up with the slider, and make them clickable The animation panel's keyframe dots did not sit under the slider's thumb when the playhead was on their frame. Measured in headless Chrome, with keyframes at frames 0, 10 and 30 of 30, they sat 4 px left of the thumb at the start and 3 px right of it at the end -- more than a dot's width. The dots were inset by a hardcoded 7 px, a guess at half the browser's own thumb. A thumb's centre travels from half a thumb in at the first frame to half a thumb short of the end at the last, but the native thumb differs in size and inner padding from one browser and platform to the next (about 20 px here), so no fixed inset suits every browser. The slider now draws its own 12 px thumb and track, and a single --anim-thumb custom property sizes both the thumb and the dots' inset, so the two cannot drift apart. Restyling the slider also exposed w2ui's rule for every input -- a border and padding the native slider ignored, and whose 1 px border would have moved the thumb in from the ends -- so that is undone for it. The offsets are now 0 px at every keyframe. The dots are also clickable: a click stops playback and puts the playhead on that keyframe through the same path as typing a frame number (AnimationPanel.goToFrame, which both now use), so the slider, the frame field and the smoothing dropdown all follow. Each dot takes clicks in a 12 px area around its 6 px mark, laid out in the band below the slider so it never covers it; the dots' container still passes everything else through. The tests find the thumb and the dots by colour in a screenshot of the slider, click the dots, and drag the slider over them. That needs the page itself, which sync Playwright only lets its own thread touch, so the headless harness gains _PlaywrightThread.run_on_page, which runs a function on the worker thread and returns what it returns. Co-Authored-By: Claude Opus 5.5 (1M context) --- cortex/export/headless.py | 44 ++++++++- cortex/tests/test_webgl_headless.py | 120 +++++++++++++++++++++++++ cortex/webgl/resources/css/mriview.css | 75 ++++++++++++++-- cortex/webgl/resources/js/viewtools.js | 22 ++++- 4 files changed, 250 insertions(+), 11 deletions(-) diff --git a/cortex/export/headless.py b/cortex/export/headless.py index 005cdf9db..3b1ccc563 100644 --- a/cortex/export/headless.py +++ b/cortex/export/headless.py @@ -42,11 +42,14 @@ 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 @@ -172,6 +175,8 @@ def __init__(self, download_dir: Optional[str] = None) -> None: 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 @@ -254,6 +259,28 @@ def wait_for_download(self, timeout: float = 60.0, count: int = 1) -> str: 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() @@ -311,6 +338,7 @@ def _worker(self) -> None: # 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: @@ -357,6 +385,20 @@ def _save_downloads(self) -> None: 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"): diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py index e8a71533d..4514822c3 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -2480,3 +2480,123 @@ def test_a_default_view_fills_a_4_by_3_frame(tmp_path, name): margins = [x0 / width, (width - x1) / width, y0 / height, (height - y1) / height] assert min(margins) > 0, margins # nothing clipped assert min(margins) == pytest.approx((1 - FRAMING_FILL) / 2, abs=0.015), margins + + +# --------------------------------------------------------------------------- +# The keyframe dots under the animation panel's slider +# --------------------------------------------------------------------------- + +_THUMB_RGB = (0x2f, 0xa1, 0xd6) # .keyframe-track's slider thumb (mriview.css) +_DOT_RGB = (0xff, 0xd7, 0x00) # .keyframe-dot + + +def _colour_centres(png, rgb, rows=None, tol=60): + """The x centres of each run of columns where `rgb` appears in a png.""" + import io + + from PIL import Image + + pixels = np.asarray(Image.open(io.BytesIO(png)).convert("RGB")).astype(int) + if rows is not None: + pixels = pixels[rows] + columns = np.where((np.abs(pixels - np.array(rgb)).sum(-1) < tol).any(0))[0] + runs, run = [], [] + for x in columns: + if run and x - run[-1] > 1: + runs.append(run) + run = [] + run.append(x) + if run: + runs.append(run) + return [(r[0] + r[-1]) / 2 for r in runs] + + +def _panel_with_keyframes(handle, frames, azimuths=None): + """Open the animation panel and lay down a keyframe at each of `frames`.""" + handle.send(method="run", params=[ + "window.viewer.ui._desc.camera._desc.create animation.action", []]) + time.sleep(1) + for i, frame in enumerate(frames): + _js_run(handle, "window.viewer._animPanel.goToFrame", [frame]) + if azimuths is not None: + handle._set_view(**{"camera.azimuth": azimuths[i]}) + time.sleep(0.5) + _js_run(handle, "window.viewer._animPanel.addKeyframe", []) + time.sleep(0.5) + + +def test_keyframe_dots_line_up_with_the_slider(): + """A keyframe's dot sits under the slider's thumb when the playhead is on it. + + It used to drift by several pixels towards either end: the dots were inset + by a guess at half the browser's own thumb, whose size varies by browser. + """ + frames = [0, 7, 15, 23, 30] + vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + with cortex.export.headless_viewer(vol, viewer_params={}) as handle: + _panel_with_keyframes(handle, frames) + + def track(page): + return page.locator(".keyframe-track").screenshot() + + dots = _colour_centres(handle._pw_thread.run_on_page(track), _DOT_RGB) + assert len(dots) == len(frames), dots + + for frame, dot in zip(frames, dots): + _js_run(handle, "window.viewer._animPanel.goToFrame", [frame]) + time.sleep(0.3) + png = handle._pw_thread.run_on_page(track) + thumbs = _colour_centres(png, _THUMB_RGB) + assert len(thumbs) == 1, thumbs + assert abs(thumbs[0] - dot) <= 1, (frame, thumbs[0], dot) + + +def test_clicking_a_keyframe_dot_goes_to_its_frame(): + """Clicking a dot puts the playhead, the slider and the view on that keyframe.""" + frames, azimuths = [0, 12, 30], [45, 120, 200] + vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + with cortex.export.headless_viewer(vol, viewer_params={}) as handle: + _panel_with_keyframes(handle, frames, azimuths) + _js_run(handle, "window.viewer._animPanel.goToFrame", [5]) + time.sleep(0.5) + + for frame, azimuth in zip(frames, azimuths): + def click(page, frame=frame): + page.locator('.keyframe-dot[data-frame="%d"]' % frame).click() + return (page.locator(".anim-slider").input_value(), + page.locator(".anim-frame").input_value()) + + slider, field = handle._pw_thread.run_on_page(click) + time.sleep(0.5) + assert _js_value(handle, "window.viewer._anim.frame") == frame + assert (int(float(slider)), int(float(field))) == (frame, frame) + assert handle._capture_view()["camera.azimuth"] == pytest.approx( + azimuth, abs=0.5) + + pageerrors = [e for e in handle._pw_thread.browser_errors + if "[pageerror]" in e] + assert len(pageerrors) == 0, f"JS errors: {pageerrors}" + + +def test_the_slider_still_drags_over_the_dots(): + """The dots take clicks without covering any part of the slider.""" + vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + with cortex.export.headless_viewer(vol, viewer_params={}) as handle: + _panel_with_keyframes(handle, [0, 10, 20, 30]) + _js_run(handle, "window.viewer._animPanel.goToFrame", [0]) + time.sleep(0.5) + + def drag(page): + slider = page.locator(".anim-slider") + slider.scroll_into_view_if_needed() + box = slider.bounding_box() + y = box["y"] + box["height"] / 2 + page.mouse.move(box["x"] + 6, y) + page.mouse.down() + page.mouse.move(box["x"] + box["width"] / 2, y, steps=10) + page.mouse.up() + + handle._pw_thread.run_on_page(drag) + time.sleep(0.5) + assert _js_value(handle, "window.viewer._anim.frame") == pytest.approx(15, abs=1) + diff --git a/cortex/webgl/resources/css/mriview.css b/cortex/webgl/resources/css/mriview.css index d809014d6..2a58d6f8c 100644 --- a/cortex/webgl/resources/css/mriview.css +++ b/cortex/webgl/resources/css/mriview.css @@ -724,35 +724,98 @@ button#twodbutton:disabled, button#twodbutton[disabled] { } /* Frame slider, with a dot marking every frame that has a keyframe. */ +/* The frame slider and its keyframe dots. + + The dots only line up with the slider if they are inset by exactly half the + thumb: the thumb's centre travels from half a thumb in at the first frame to + half a thumb short of the end at the last. A browser's own thumb differs in + size (and inner padding) from one browser and platform to the next, so the + slider draws its own, and --anim-thumb sizes both it and the inset. */ .keyframe-track { + --anim-thumb: 12px; position: relative; margin: 4px 0 10px 0; padding-bottom: 8px; } .keyframe-track input[type=range] { + -webkit-appearance: none; + appearance: none; width: 100%; + height: var(--anim-thumb); margin: 0; + /* Undo w2ui's rule for every input (a border and padding), which the + browser's own slider ignored and a restyled one does not. A border would + also move the thumb in from the ends the dots are measured from. */ + padding: 0; + border: none; + border-radius: 0; + box-shadow: none; + background: transparent; + cursor: pointer; +} + +.keyframe-track input[type=range]:focus { + outline: none; +} + +.keyframe-track input[type=range]::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; + background: #3c3c3c; +} + +.keyframe-track input[type=range]::-moz-range-track { + height: 4px; + border-radius: 2px; + background: #3c3c3c; +} + +.keyframe-track input[type=range]::-webkit-slider-thumb { + -webkit-appearance: none; + width: var(--anim-thumb); + height: var(--anim-thumb); + margin-top: calc((4px - var(--anim-thumb)) / 2); /* centred on the track */ + border: none; + border-radius: 50%; + background: #2fa1d6; +} + +.keyframe-track input[type=range]::-moz-range-thumb { + width: var(--anim-thumb); + height: var(--anim-thumb); + border: none; + border-radius: 50%; + background: #2fa1d6; } .keyframe-ticks { position: absolute; - /* Inset by half a slider thumb, since the thumb centre does not reach the - ends of the track -- otherwise the first and last dots sit off the mark. */ - left: 7px; - right: 7px; + left: calc(var(--anim-thumb) / 2); + right: calc(var(--anim-thumb) / 2); bottom: 0; height: 8px; - pointer-events: none; /* dots must not swallow drags on the slider */ + pointer-events: none; /* only the dots take clicks, never the slider's area */ } +/* 6 px across, inside a 12 px area that takes the click. That area starts at + the top of the band below the slider, so it never covers the slider itself. */ .keyframe-dot { position: absolute; + top: 0; width: 6px; height: 6px; - margin-left: -3px; + padding: 3px; + margin-left: -6px; border-radius: 50%; background-color: #ffd700; + background-clip: content-box; + cursor: pointer; + pointer-events: auto; +} + +.keyframe-dot:hover { + background-color: #fff066; } /* Saved-view buttons (the "views" folder, tagged by viewtools.js) carry only a diff --git a/cortex/webgl/resources/js/viewtools.js b/cortex/webgl/resources/js/viewtools.js index 5b7520723..383107dcd 100644 --- a/cortex/webgl/resources/js/viewtools.js +++ b/cortex/webgl/resources/js/viewtools.js @@ -597,8 +597,7 @@ var jsplot = (function (module) { var self = this, st = this.state; this._el("anim-frame").on("change", function() { - self.setFrame(parseFloat(this.value)); - self.sync(); + self.goToFrame(parseFloat(this.value)); }); this._el("anim-slider").on("input change", function() { self.setFrame(parseFloat(this.value)); @@ -887,10 +886,19 @@ var jsplot = (function (module) { } }; + // Put the playhead on `frame`, the way typing it into the frame field does: + // playback stops, and the slider, the frame field and the smoothing + // dropdown all follow. What clicking a keyframe's dot does. + AnimationPanel.prototype.goToFrame = function(frame) { + this.stop(); + this.setFrame(frame); + this.sync(); + }; + // One yellow dot per keyframe, positioned along the slider. Redrawn from // scratch so that changing first/last simply repositions everything. AnimationPanel.prototype.drawTicks = function() { - var st = this.state; + var st = this.state, self = this; var ticks = this._el("keyframe-ticks").empty(); var span = st.last - st.first; if (span <= 0) @@ -902,8 +910,14 @@ var jsplot = (function (module) { var pct = 100 * (frame - st.first) / span; $("
    ") .css("left", pct + "%") + .attr("data-frame", frame) .attr("title", "keyframe at frame " + frame + " (" + - modeLabel(st.keyframes[i].interpolation) + ")") + modeLabel(st.keyframes[i].interpolation) + + ") \u2014 click to go there") + .on("click", function(event) { + event.stopPropagation(); + self.goToFrame(parseInt($(this).attr("data-frame"), 10)); + }) .appendTo(ticks); } }; From 4bfe68dc7e3ff9b3e86cd099119221207feb193d Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Mon, 28 Sep 2026 17:06:25 -0700 Subject: [PATCH 12/14] FIX: anonymized static exports, legacy templates, and render-size changes Three issues from review of this branch. Anonymized exports leaked the real subject IDs. make_static(anonymize=True) renames subjects S0, S1, ... in the surface files and the dataset metadata, but the saved views and the quickflat size hint added on this branch were still keyed by the real IDs -- putting them back into the page, and leaving the viewer unable to find either entry, since it looks them up under the anonymized surface names (so an anonymized export's views menu came out empty). They are now keyed by the page's own names. Doing that exposed an older inconsistency: make_static numbered the anonymized names once in the order of a set, which changes from one process to the next, and once in sorted order, so a multi-subject export could give one subject two names. It now builds one sorted mapping and uses it for the files, the metadata and the viewer options alike; a single-subject export's names are unchanged. A custom template.html written before viewtools.js existed stopped the viewer from opening at all: mriview.js called jsplot.viewtools.installCameraUI unconditionally. The call is now guarded, so such a viewer opens without the views menu and the animation panel, and rendering reports a missing zipstore.js or mp4mux.js in the panel's status line instead of throwing. With "match quickflat size" ticked, changing the render size did not re-frame the flat keyframes already laid down, so a render at a size typed after ticking the box used framing meant for the previous shape of frame. Typed sizes now re-frame them, as does setRenderSize, which sets the fields from code without firing their change events; and render() re-frames first, so a size that reached the fields any other way is still honoured. Each fix has a test that fails without it: an anonymized export whose subjects and viewer options must name the same subjects, a shadowing template with the new script tags removed, and the size set from code, typed, and changed with no event at all. Co-Authored-By: Claude Opus 5.5 (1M context) --- cortex/tests/test_webgl_headless.py | 131 +++++++++++++++++++++++++ cortex/webgl/resources/js/mriview.js | 5 +- cortex/webgl/resources/js/viewtools.js | 33 ++++++- cortex/webgl/view.py | 23 +++-- 4 files changed, 184 insertions(+), 8 deletions(-) diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py index 4514822c3..97c2cba79 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -2600,3 +2600,134 @@ def drag(page): time.sleep(0.5) assert _js_value(handle, "window.viewer._anim.frame") == pytest.approx(15, abs=1) + +# --------------------------------------------------------------------------- +# Review fixes: anonymized exports, legacy templates, render-size changes +# --------------------------------------------------------------------------- + + +def _static_page_json(html, name): + """The JSON static.html assigns to `name` (``name = {...};`` on one line).""" + import re + + match = re.search(r"^\s*%s = (\{.*\});\s*$" % name, html, re.M) + assert match, "no %s in the page" % name + return json.loads(match.group(1)) + + +@pytest.mark.parametrize("anonymize", [False, True]) +def test_static_export_names_subjects_consistently(tmp_path, anonymize): + """Views and the quickflat hint are keyed by the name the page knows. + + In an anonymized export that is the anonymized name: keyed by the real one, + the page would carry the real subject ID the export exists to hide, and the + viewer could not find either entry. + """ + vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + outpath = str(tmp_path / "static") + # An anonymized export ships its own renamed surface files (make_static + # rewrites the names inside them), so it needs them copied. + cortex.webgl.make_static(outpath, vol, html_embed=False, + copy_ctmfiles=anonymize, anonymize=anonymize) + with open(os.path.join(outpath, "index.html")) as fp: + html = fp.read() + + surfaces = set(_static_page_json(html, "subjects")) + viewopts = _static_page_json(html, "viewopts") + assert set(viewopts["saved_views"]) == surfaces + assert set(viewopts["quickflat_size"]) == surfaces + if anonymize: + assert surfaces == {"S0"} + assert subj not in viewopts["saved_views"] + assert subj not in viewopts["quickflat_size"] + else: + assert surfaces == {subj} + + +def test_a_template_without_the_new_scripts_still_opens(tmp_path): + """A custom template.html from before viewtools.js leaves the viewer usable. + + Template directories can shadow template.html, and one written before this + viewer gained its views menu loads none of viewtools.js, interpolation.js, + zipstore.js or mp4mux.js. The viewer must open without them -- just without + the views menu and the animation panel. + """ + webgl = os.path.dirname(cortex.webgl.view.__file__) + with open(os.path.join(webgl, "template.html")) as fp: + legacy = [line for line in fp if not any( + script in line for script in ("viewtools.js", "interpolation.js", + "zipstore.js", "mp4mux.js"))] + (tmp_path / "template.html").write_text("".join(legacy)) + with open(os.path.join(webgl, "mixer.html")) as fp: + (tmp_path / "mixer.html").write_text(fp.read()) + + vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname) + with cortex.export.headless_viewer( + vol, viewer_params=dict(template=str(tmp_path / "mixer.html"))) as handle: + assert _js_attrs(handle, "window.jsplot").get("viewtools") is None + camera = _js_attrs(handle, "window.viewer.ui._desc.camera._desc") + assert "views" not in camera and "create animation" not in camera + + handle._set_view(**{"camera.azimuth": 123}) + time.sleep(1) + assert handle._capture_view()["camera.azimuth"] == pytest.approx(123, abs=1) + + pageerrors = [e for e in handle._pw_thread.browser_errors + if "[pageerror]" in e] + assert len(pageerrors) == 0, f"JS errors: {pageerrors}" + + +def test_changing_the_render_size_reframes_flat_keyframes(): + """With "match quickflat size" ticked, flat keyframes follow the size fields. + + Typed into the fields, set from code, or changed without either -- a flat + keyframe is framed for the size the animation renders at. + """ + 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) + _js_run(handle, "window.viewer._animPanel.setMatchFlat", [True]) + 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", []) + time.sleep(0.5) + + def flat_radius(): + return _js_run(handle, "window.viewer._anim.keyframes.slice", + [])[0]["camera.radius"] + + def fitted(width, height): + return _js_run(handle, "window.viewer.flatFraming", + [width / height])["radius"] + + # Set from code. + _js_run(handle, "window.viewer._animPanel.setRenderSize", [600, 1000]) + assert flat_radius() == pytest.approx(fitted(600, 1000), rel=1e-6) + + # Typed into the fields. + def type_size(page): + page.locator(".anim-render").click() # show the render form + for field, value in ((".anim-width", "1600"), (".anim-height", "400")): + page.locator(field).fill(value) + page.locator(field).press("Tab") # a change event, as typing gives + + handle._pw_thread.run_on_page(type_size) + time.sleep(0.5) + assert flat_radius() == pytest.approx(fitted(1600, 400), rel=1e-6) + + # Changed with no event at all: the render itself re-frames first. + handle._pw_thread.run_on_page(lambda page: page.evaluate( + "() => { document.querySelector('.anim-width').value = 700;" + " document.querySelector('.anim-height').value = 700; }")) + handle.send(method="set", params=["window.viewer._anim.last", 0]) + _js_run(handle, "window.viewer._animPanel.render", []) + assert flat_radius() == pytest.approx(fitted(700, 700), rel=1e-6) + handle._pw_thread.wait_for_download(timeout=120) diff --git a/cortex/webgl/resources/js/mriview.js b/cortex/webgl/resources/js/mriview.js index 29622f5c4..14ab1869b 100644 --- a/cortex/webgl/resources/js/mriview.js +++ b/cortex/webgl/resources/js/mriview.js @@ -1381,7 +1381,10 @@ var mriview = (function(module) { // Saved views and the keyframe animation panel (resources/js/viewtools.js). // These go in sub-folders/buttons rather than into cam_ui.add directly, so // they do not show up in JSMixer.view_props as capturable properties. - jsplot.viewtools.installCameraUI(this, cam_ui); + // A template that shadows template.html from before viewtools.js existed + // does not load it; the viewer has to open without it. + if (jsplot.viewtools !== undefined) + jsplot.viewtools.installCameraUI(this, cam_ui); // keyboard shortcut menu var _show_help = false; diff --git a/cortex/webgl/resources/js/viewtools.js b/cortex/webgl/resources/js/viewtools.js index 383107dcd..ac0821986 100644 --- a/cortex/webgl/resources/js/viewtools.js +++ b/cortex/webgl/resources/js/viewtools.js @@ -681,6 +681,9 @@ var jsplot = (function (module) { this._el("anim-flatmatch").prop("checked", false).on("change", function() { self.matchFlatChanged(); }); + this._el("anim-width").add(this._el("anim-height")).on("change", function() { + self.renderSizeChanged(); + }); this._el("anim-flatmatch-row").hide(); }; @@ -1116,9 +1119,17 @@ var jsplot = (function (module) { // plain http from another machine. vt.canEncodeVideo = function() { return typeof window.VideoEncoder !== "undefined" && - window.isSecureContext === true; + window.isSecureContext === true && + jsplot.mp4mux !== undefined; }; + // Whether the page loaded the zip writer. A template that shadows + // template.html may predate zipstore.js (and mp4mux.js); the panel then + // says so rather than failing partway through a render. + function canWriteZip() { + return jsplot.zipstore !== undefined; + } + // Hand `blob` to the browser as a download called `filename`. vt.download = function(blob, filename) { var url = URL.createObjectURL(blob); @@ -1295,9 +1306,20 @@ var jsplot = (function (module) { AnimationPanel.prototype.setRenderSize = function(width, height) { this._el("anim-width").val(width); this._el("anim-height").val(height); + this.renderSizeChanged(); return this.renderSize(); }; + // The render size changed, typed or set from code. With "match quickflat + // size" ticked, flat keyframes are framed for whatever size is in the + // fields, so they follow it -- otherwise a render at a size typed after + // ticking the box would use framing for the previous shape of frame. + AnimationPanel.prototype.renderSizeChanged = function() { + if (this.matchesFlat()) + this.reframeFlatKeyframes(); + this.updateFlatHint(); + }; + AnimationPanel.prototype.updateFormatHint = function() { var hint = this._el("anim-format-hint"); if (this._el("anim-format").val() === "mp4") @@ -1333,7 +1355,16 @@ var jsplot = (function (module) { this.status("This browser cannot encode MP4 here; render PNG frames"); return; } + if (format === "png" && !canWriteZip()) { + this.status("Rendering needs resources/js/zipstore.js, which this " + + "page's template does not load"); + return; + } var width = size[0], height = size[1]; + // However the size got into the fields, flat keyframes are framed for + // it by the time anything is rendered. + if (this.matchesFlat()) + this.reframeFlatKeyframes(); var writer = format === "mp4" ? new Mp4Writer(width, height, st.fps) : new PngZipWriter(name); diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index 8d23a80ca..77bedb796 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -280,12 +280,18 @@ def make_static( db.auxfile = None ## Rename files to anonymize + # One anonymized name per subject, used for the surface files, the dataset + # metadata and the viewer options alike. Numbered in sorted order: the + # subjects come from a set, whose order changes from one process to the + # next, and numbering the files by that order while renaming `ctms` by the + # sorted one could give a subject two different names in the same export. + anonymized = {subj: "S%d" % i for i, subj in enumerate(sorted(ctms))} submap = dict() - for i, (subj, ctmfile) in enumerate(ctms.items()): + for subj, ctmfile in ctms.items(): oldpath, fname = os.path.split(ctmfile) fname, ext = os.path.splitext(fname) if anonymize: - newfname = "S%d" % i + newfname = anonymized[subj] submap[subj] = newfname else: newfname = fname @@ -310,8 +316,7 @@ def make_static( ofh.write(jsoncontents.replace(fname, newfname)) ofh.close() if anonymize: - old_subjects = sorted(list(ctms.keys())) - ctms = dict(("S%d" % i, ctms[k]) for i, k in enumerate(old_subjects)) + ctms = dict((anonymized[subj], ctms[subj]) for subj in sorted(ctms)) if len(submap) == 0: submap = None @@ -378,8 +383,14 @@ def make_static( # Views saved in the filestore, for the "camera > views" menu. Only the # subjects this viewer displays are read. - my_viewopts["saved_views"] = _load_saved_views(subjects) - my_viewopts["quickflat_size"] = {subj: _quickflat_size(subj) + # Keyed by the names the browser knows the subjects by, which in an + # anonymized export are not their real ones -- anything else would put the + # real IDs back into the page, and leave the viewer unable to find them. + subject_names = submap or {subj: subj for subj in subjects} + my_viewopts["saved_views"] = { + subject_names[subj]: views + for subj, views in _load_saved_views(subjects).items()} + my_viewopts["quickflat_size"] = {subject_names[subj]: _quickflat_size(subj) for subj in subjects} html = tpl.generate( From 994db7c7ec51359dc635f47cac83d0429c9b397e Mon Sep 17 00:00:00 2001 From: Mark Lescroart Date: Thu, 1 Oct 2026 11:59:23 -0700 Subject: [PATCH 13/14] TST: reword a comment codespell reads as a typo "a peak and a trough" describes the test curve correctly, but codespell flags "trough" as a misspelling of "through" and fails the spelling check. Reworded to "a peak and a dip" rather than adding "trough" to the repo-wide ignore list, which would stop codespell catching the far more common case: "trough" written for "through" in prose. Co-Authored-By: Claude Opus 5.5 (1M context) --- cortex/tests/test_interpolation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortex/tests/test_interpolation.py b/cortex/tests/test_interpolation.py index 326d49931..0946c1609 100644 --- a/cortex/tests/test_interpolation.py +++ b/cortex/tests/test_interpolation.py @@ -78,7 +78,7 @@ def test_hold_out_modes_are_constant_until_the_next_keyframe(mode): @pytest.mark.parametrize("mode", [Interpolation.CubicHermite, Interpolation.Bezier]) @pytest.mark.parametrize("values", [ - [0.0, 10.0, 4.0, 7.0], # a peak and a trough + [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 From a5c81f72f4f2864e304b74274254aa259a8c8bb8 Mon Sep 17 00:00:00 2001 From: Aditya Vaidya Date: Thu, 1 Oct 2026 13:45:34 -0700 Subject: [PATCH 14/14] Increase test timeout (many new browser tests) --- .github/workflows/run_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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