diff --git a/.github/workflows/build_docs.yml b/.github/workflows/build_docs.yml index 004d8e542..fcf547083 100644 --- a/.github/workflows/build_docs.yml +++ b/.github/workflows/build_docs.yml @@ -11,11 +11,15 @@ on: - main workflow_dispatch: # Manual trigger for publishing docs +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: build-docs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 # Required for setuptools-scm to get version from tags @@ -48,6 +52,8 @@ jobs: with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-playwright- - name: Install Playwright Chromium run: playwright install --with-deps --only-shell chromium diff --git a/.github/workflows/codespell.yml b/.github/workflows/codespell.yml index c2416cea7..c40d06fd1 100644 --- a/.github/workflows/codespell.yml +++ b/.github/workflows/codespell.yml @@ -7,6 +7,10 @@ on: pull_request: branches: [main] +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + permissions: contents: read @@ -17,6 +21,6 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Codespell uses: codespell-project/actions-codespell@v2 diff --git a/.github/workflows/install_from_wheel.yml b/.github/workflows/install_from_wheel.yml index 8656db430..81a5596fb 100644 --- a/.github/workflows/install_from_wheel.yml +++ b/.github/workflows/install_from_wheel.yml @@ -8,6 +8,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: install-from-wheel: runs-on: ubuntu-latest @@ -17,7 +21,7 @@ jobs: max-parallel: 5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v6 with: diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8d4a410c9..13d5c183d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -12,7 +12,7 @@ jobs: contents: read # Required to checkout the repository steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 # Required for setuptools-scm to get version from tags diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index c66bef78c..6a4a8a763 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -8,6 +8,10 @@ on: branches: - main +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: run-tests: runs-on: ubuntu-latest @@ -17,7 +21,7 @@ jobs: max-parallel: 5 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Set up Python uses: actions/setup-python@v6 with: @@ -47,16 +51,18 @@ jobs: with: path: ~/.cache/ms-playwright key: ${{ runner.os }}-playwright-${{ hashFiles('**/pyproject.toml') }} + restore-keys: | + ${{ runner.os }}-playwright- - name: Install Playwright Chromium run: playwright install --with-deps --only-shell chromium - name: Test with pytest - timeout-minutes: 20 # in case webviewer tests hang + timeout-minutes: 25 # in case webviewer tests hang run: pytest --cov=./ - name: Upload coverage to Codecov - uses: codecov/codecov-action@v6 + uses: codecov/codecov-action@v7 with: env_vars: OS,PYTHON fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index b54f158ba..613aa48c5 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,7 @@ docs/colormaps.rst # Git worktrees .worktrees + +# Claude Code working directory (per-worktree scratch: launchers, verify +# scripts, plans). Not part of the project. +.claude diff --git a/cortex/dataset/braindata.py b/cortex/dataset/braindata.py index 7cbe94efb..817c024bb 100644 --- a/cortex/dataset/braindata.py +++ b/cortex/dataset/braindata.py @@ -1,4 +1,5 @@ import hashlib +import warnings from copy import deepcopy import sys from typing import Generic, Optional, TypeVar, Union, cast @@ -552,7 +553,33 @@ def right(self): def blend_curvature(self, alpha, threshold=0, brightness=0.5, contrast=0.25, smooth=20): """Blend the data with a curvature map depending on a transparency map. - + + .. deprecated:: + Per-vertex/voxel alpha is now honored directly by both the WebGL + viewer and ``cortex.quickshow``, so this curvature-blending hack + is no longer needed. The recommended replacement for scalar data + with a transparency map is :class:`Vertex2D` (or + :class:`Volume2D`) with a 2D colormap whose second axis encodes + alpha (e.g. ``"fire_alpha"``, ``"PU_RdBu_covar_alpha"``):: + + # Was: + # blended = vtx.blend_curvature(alpha) + # cortex.quickshow(blended) + # Now: + v2d = cortex.Vertex2D(vtx.data, alpha, subject, + cmap="fire_alpha", + vmin=vtx.vmin, vmax=vtx.vmax, + vmin2=0, vmax2=1) + cortex.quickshow(v2d) # or cortex.webgl.show(v2d) + + The 2D colormap path keeps colormap parameters (``cmap``, + ``vmin``, ``vmax``) editable on the resulting object, and the + curvature underlay is composited through automatically by both + the matplotlib and WebGL renderers. + + For data that is already RGB, pass ``alpha=`` to + :class:`VertexRGB` / :class:`VolumeRGB` directly instead. + Vertex objects cannot use transparency as Volume objects. This method is a hack to mimic the transparency of Volume objects, blending the Vertex data with a curvature map. This method returns a VertexRGB @@ -577,6 +604,19 @@ def blend_curvature(self, alpha, threshold=0, brightness=0.5, blended : VertexRGB object The original map blended with a curvature map. """ + warnings.warn( + "blend_curvature is deprecated and will be removed in a future " + "release. Per-vertex/voxel alpha is now honored directly by both " + "the WebGL viewer and quickshow, so this curvature-blending hack " + "is no longer needed. For scalar data with a transparency map, " + "use Vertex2D / Volume2D with a 2D colormap whose second axis " + "encodes alpha (e.g. 'fire_alpha', 'PU_RdBu_covar_alpha'), e.g. " + "`Vertex2D(data, alpha, subject, cmap='fire_alpha', vmin=..., " + "vmax=..., vmin2=0, vmax2=1)`. For data that is already RGB, " + "pass `alpha=` to VertexRGB / VolumeRGB directly.", + DeprecationWarning, + stacklevel=2, + ) from .views import Vertex from .viewRGB import VertexRGB # prepare curvature map diff --git a/cortex/export/headless.py b/cortex/export/headless.py index 9145e2c20..d3e8f2337 100644 --- a/cortex/export/headless.py +++ b/cortex/export/headless.py @@ -42,6 +42,7 @@ import contextlib import logging import threading +import time from typing import Any, Mapping, Optional import cortex @@ -50,6 +51,49 @@ logger = logging.getLogger(__name__) +def _wait_for_viewer_loaded(handle, timeout: float = 60.0) -> None: + """Block until ``window.viewer.loaded`` resolves in the browser. + + The WebSocket "connect" message fires as soon as the page DOM is ready, + but the WebGL viewer's CTM mesh download, parse, and initial render + typically take a few more seconds. ``viewer.loaded`` is a jQuery + Deferred that resolves at the end of ``setData`` (mriview.js), so polling + its ``.state()`` is a faithful "the viewer is ready to be driven" + signal that replaces brittle fixed sleeps in callers and tests. + + We bypass the JSProxy attribute machinery and call ``send`` directly so + each poll is exactly one WebSocket roundtrip (the JSProxy ``__getattr__`` + path would issue an extra ``query`` call per poll). + """ + deadline = time.monotonic() + timeout + poll_interval = 0.1 + last_err: Optional[str] = None + while time.monotonic() < deadline: + try: + result = handle.send( + method="run", params=["window.viewer.loaded.state", []] + ) + except Exception as exc: + last_err = repr(exc) + result = None + # WebApp.send always wraps its single per-client response in a list; + # unpack the leaf so the "resolved"/"pending"/error-dict check below + # is straightforward and last_err carries the actual value seen. + val = result[0] if isinstance(result, list) and result else result + if val == "resolved": + return + if val is not None: + last_err = ( + str(val.get("error", val)) if isinstance(val, dict) else str(val) + ) + time.sleep(poll_interval) + raise RuntimeError( + f"Viewer's .loaded deferred did not resolve within {timeout:.0f}s " + f"(last response: {last_err!r}). The CTM mesh may have failed to " + "download or parse, or mriview.js failed to initialise." + ) + + # --------------------------------------------------------------------------- # # Helper: run Playwright in a dedicated thread to avoid asyncio conflicts # # --------------------------------------------------------------------------- # @@ -337,6 +381,12 @@ def headless_viewer( # any point during the session (each call returns a fresh snapshot). handle._pw_thread = pw_thread + # Block until the WebGL viewer has finished initialising (CTM mesh + # download + parse + first setData). Replaces ad-hoc time.sleep(10) + # calls in tests and callers, and shortens the wait when the + # browser is faster than the worst-case timeout. + _wait_for_viewer_loaded(handle, timeout=timeout) + yield handle finally: diff --git a/cortex/export/save_views.py b/cortex/export/save_views.py index 2ea757510..c9a1e7710 100644 --- a/cortex/export/save_views.py +++ b/cortex/export/save_views.py @@ -114,8 +114,12 @@ def save_3d_views( cm = contextlib.nullcontext(cortex.webshow(volume, **viewer_params)) with cm as handle: - # Wait for the viewer to be loaded - time.sleep(sleep) + # Wait for the viewer to be loaded. The headless context manager + # already blocks on ``viewer.loaded`` before yielding, so we only + # need this fixed sleep for the interactive (real-browser) path + # where the user is opening the page manually. + if not headless: + time.sleep(sleep) # Add interpolation and layers params only if we have a volume if isinstance(volume, (cortex.Volume, cortex.Volume2D, cortex.VolumeRGB)): diff --git a/cortex/freesurfer.py b/cortex/freesurfer.py index f30c81c9f..b90d7546b 100644 --- a/cortex/freesurfer.py +++ b/cortex/freesurfer.py @@ -16,8 +16,7 @@ import nibabel import numpy as np from nibabel import gifti -from scipy.linalg import lstsq -from scipy.sparse import coo_matrix +from scipy.sparse import coo_matrix, diags from scipy.spatial import KDTree from . import anat, database @@ -705,128 +704,168 @@ def mri_surf2surf(data, source_subj, target_subj, hemi, subjects_dir=None): return output_data -def get_mri_surf2surf_matrix(source_subj, hemi, surface_type, - target_subj='fsaverage', subjects_dir=None, - n_neighbors=20, random_state=0, - n_test_images=40, coef_threshold=None, - renormalize=True): +def _read_sphere_reg(subject, hemi, subjects_dir=None): + """Read the registered sphere (``?h.sphere.reg``) vertex coordinates. + + These are the coordinates on which freesurfer's spherical registration + defines the cross-subject vertex correspondence used by ``mri_surf2surf``. + """ + surf_file = get_paths(subject, hemi, 'surf', + freesurfer_subject_dir=subjects_dir).format( + name='sphere.reg') + pts, _ = parse_surf(surf_file) + return pts + + +def _surf2surf_nnfr_matrix(src_sphere, trg_sphere): + """Build the freesurfer ``nnfr`` surf2surf matrix from sphere coordinates. + + Implements freesurfer's nearest-neighbor, forward-and-reverse (``nnfr``) + mapping directly from the registered-sphere geometry: + + * **Forward**: every target vertex draws its value from its single nearest + source vertex on the sphere. This guarantees every target gets a value. + * **Reverse**: any source vertex that was *not* selected by some target in + the forward pass (an "orphan") is folded into its nearest target vertex, + so that no source vertex's data is silently dropped. This is the + "forward and reverse" part of ``nnfr`` and is what makes downsampling + (e.g. to a coarser subject) an average rather than a subsampling. + + Each target row is then renormalized so that it is the *mean* of its + contributing source vertices. - """Creates a matrix implementing freesurfer mri_surf2surf command. - - A surface-to-surface transform is a linear transform between vertex spaces. - Such a transform must be highly localized in the sense that a vertex in the - target surface only draws its values from very few source vertices. - This function exploits the localization to create an inverse problem for - each vertex. - The source neighborhoods for each target vertex are found by using - mri_surf2surf to transform the three coordinate maps from the source - surface to the target surface, yielding three coordinate values for each - target vertex, for which we find the nearest neighbors in the source space. - A small number of test images is transformed from source surface to - target surface. - For each target vertex in the transformed test images, a regression is - performed using only the corresponding source image neighborhood, yielding - the entries for a sparse matrix encoding the transform. - Parameters - ========== - - source_subj: str - Freesurfer name of source subject - - hemi: str in ("lh", "rh") - Indicator for hemisphere - - surface_type: str in ("white", "pial", ...) - Indicator for surface layer - - target_subj: str, default "fsaverage" - Freesurfer name of target subject - - subjects_dir: str, default os.environ["SUBJECTS_DIR"] - The freesurfer subjects directory - - n_neighbors: int, default 20 - The size of the neighborhood to take into account when estimating - the source support of a vertex - - random_state: int, default 0 - Random number generator or seed for generating test images - - n_test_images: int, default 40 - Number of test images transformed to compute inverse problem. This - should be greater than n_neighbors or equal. - - coef_treshold: float, default 1 / (10 * n_neighbors) - Value under which to set a weight to zero in the inverse problem. - - renormalize: boolean, default True - Determines whether the rows of the output matrix should add to 1, - implementing what is sensible: a weighted averaging - + ---------- + src_sphere : ndarray, shape (n_src, 3) + Source ``sphere.reg`` vertex coordinates. + trg_sphere : ndarray, shape (n_trg, 3) + Target ``sphere.reg`` vertex coordinates. + + Returns + ------- + matrix : scipy.sparse.csr_matrix, shape (n_trg, n_src) + Linear operator mapping source vertex data to target vertex data via + ``target_data = matrix.dot(source_data)``. + Notes - ===== - It turns out that freesurfer seems to do the following: For each target - vertex, find, on the sphere, the nearest source vertices, and average their - values. Try to be as one-to-one as possible. + ----- + For ordinary subjects (and full-resolution ``fsaverage``), the resulting + matrix reproduces ``mri_surf2surf`` bit-for-bit, because the spheres are + irregular enough that nearest-neighbor assignment is unambiguous. + + The icosahedrally-subsampled targets (``fsaverage6``/``5``/``4``/``3``) + are the one exception: their meshes are perfectly regular, so a sizeable + number of fine vertices land *exactly* equidistant between two coarse + target vertices. Freesurfer breaks these exact ties using its internal + vertex ordering, which this implementation does not reproduce. The result + is that a small fraction (~0.3-2%) of coarse vertices average a different, + equidistant neighbor, giving correlations of ~0.997-0.9999 rather than an + exact match. The numerical difference is tiny (the alternative neighbor is + the same distance away) and not worth chasing freesurfer's tie-breaking + bookkeeping to eliminate. """ + src_sphere = np.asarray(src_sphere) + trg_sphere = np.asarray(trg_sphere) + n_src = len(src_sphere) + n_trg = len(trg_sphere) + + # Forward: each target vertex -> its nearest source vertex. + _, fwd_src = KDTree(src_sphere).query(trg_sphere, k=1) + # Reverse: each source vertex -> its nearest target vertex. + _, rev_trg = KDTree(trg_sphere).query(src_sphere, k=1) + + # Orphan source vertices are those not chosen by any target's forward + # mapping; fold them into their nearest target so their data is preserved. + used = np.zeros(n_src, dtype=bool) + used[fwd_src] = True + orphan = np.flatnonzero(~used) + + rows = np.concatenate([np.arange(n_trg), rev_trg[orphan]]) + cols = np.concatenate([fwd_src, orphan]) + matrix = coo_matrix((np.ones(len(rows)), (rows, cols)), + shape=(n_trg, n_src)).tocsr() + # A target/source pair can be added by both passes; collapse duplicates. + matrix.sum_duplicates() + + # Renormalize each row so the target value is the mean of its sources. + row_sums = np.asarray(matrix.sum(axis=1)).ravel() + row_sums[row_sums == 0] = 1.0 + matrix = (diags(1.0 / row_sums) @ matrix).tocsr() + return matrix - source_verts, _, _ = get_surf(source_subj, hemi, surface_type, - freesurfer_subject_dir=subjects_dir) - - transformed_coords = mri_surf2surf(source_verts.T, - source_subj, target_subj, hemi, - subjects_dir=subjects_dir) - - kdt = KDTree(source_verts) - print("Getting nearest neighbors") - distances, indices = kdt.query(transformed_coords.T, k=n_neighbors) - print("Done") - - rng = (np.random.RandomState(random_state) - if isinstance(random_state, int) else random_state) - test_images = rng.randn(n_test_images, len(source_verts)) - transformed_test_images = mri_surf2surf(test_images, source_subj, - target_subj, hemi, - subjects_dir=subjects_dir) - - # Solve linear problems to get coefficients - all_coefs = [] - residuals = [] - print("Computing coefficients") - i = 0 - for target_activation, source_inds in zip( - transformed_test_images.T, indices): - i += 1 - print("{i}".format(i=i), end="\r") - source_values = test_images[:, source_inds] - r = lstsq(source_values, target_activation, - overwrite_a=True, overwrite_b=True) - all_coefs.append(r[0]) - residuals.append(r[1]) - print("Done") - - all_coefs = np.array(all_coefs) - - if coef_threshold is None: # we know now that coefs are doing averages - coef_threshold = (1 / 10. / n_neighbors ) - all_coefs[np.abs(all_coefs) < coef_threshold] = 0 - if renormalize: - all_coefs /= np.abs(all_coefs).sum(axis=1)[:, np.newaxis] + 1e-10 - - # there seem to be like 7 vertices that don't constitute an average over - # 20 vertices or less, but all the others are such an average. - - # Let's make a matrix that does the transform: - col_indices = indices.ravel() - row_indices = (np.arange(indices.shape[0])[:, np.newaxis] * - np.ones(indices.shape[1], dtype='int')).ravel() - data = all_coefs.ravel() - shape = (transformed_coords.shape[1], source_verts.shape[0]) - - matrix = coo_matrix((data, (row_indices, col_indices)), shape=shape) - return matrix +# Legacy keyword arguments accepted by the old (regression-based) +# implementation of ``get_mri_surf2surf_matrix``; kept only so existing +# callers do not break. +_SURF2SURF_LEGACY_KWARGS = frozenset( + ('n_neighbors', 'random_state', 'n_test_images', 'coef_threshold', + 'renormalize')) + + +def get_mri_surf2surf_matrix(source_subj, hemi, surface_type=None, + target_subj='fsaverage', subjects_dir=None, + **kwargs): + """Create a sparse matrix implementing freesurfer's ``mri_surf2surf``. + + A surface-to-surface resampling is a linear, highly localized transform + between two subjects' vertex spaces. Freesurfer defines this mapping + entirely from the spherical registration (``?h.sphere.reg``): for each + target vertex it takes the value of its nearest source vertex, and it + additionally averages in any source vertex that no target selected, so + that no source data is lost (the ``nnfr`` -- nearest-neighbor, + forward-and-reverse -- method). + + This implementation builds that matrix directly from the registered-sphere + geometry with two nearest-neighbor queries (see + :func:`_surf2surf_nnfr_matrix`). It is exact for ordinary subjects and + full-resolution ``fsaverage``, requires no calls to the ``mri_surf2surf`` + binary, and is deterministic. (The previous implementation reverse + -engineered the matrix by probing ``mri_surf2surf`` with random test images + and solving a per-vertex least-squares problem.) + + Parameters + ---------- + source_subj : str + Freesurfer name of the source subject. + hemi : str in ("lh", "rh") + Hemisphere. + surface_type : str, optional + Ignored. Retained for backwards compatibility with the previous + signature. The surf2surf correspondence depends only on the spherical + registration and is therefore identical for every surface + (``white``/``pial``/``inflated``/...) of a given subject. + target_subj : str, default "fsaverage" + Freesurfer name of the target subject. + subjects_dir : str, default os.environ["SUBJECTS_DIR"] + The freesurfer subjects directory. + + Returns + ------- + matrix : scipy.sparse.csr_matrix, shape (n_target_verts, n_source_verts) + Apply with ``target_data = matrix.dot(source_data)``. + + Notes + ----- + See :func:`_surf2surf_nnfr_matrix` for the algorithm and for the one known + caveat (exact tie-breaking on the regular icosahedral targets such as + ``fsaverage6``). + """ + legacy = _SURF2SURF_LEGACY_KWARGS.intersection(kwargs) + if legacy: + warnings.warn( + "get_mri_surf2surf_matrix no longer uses the regression-based " + "parameters {}; they are ignored. The matrix is now built " + "directly from the spherical registration.".format(sorted(legacy)), + DeprecationWarning, stacklevel=2) + unexpected = set(kwargs) - _SURF2SURF_LEGACY_KWARGS + if unexpected: + raise TypeError( + "get_mri_surf2surf_matrix got unexpected keyword argument(s) " + "{}".format(sorted(unexpected))) + + src_sphere = _read_sphere_reg(source_subj, hemi, subjects_dir) + trg_sphere = _read_sphere_reg(target_subj, hemi, subjects_dir) + return _surf2surf_nnfr_matrix(src_sphere, trg_sphere) def get_curv(fs_subject, hemi, type='wm', freesurfer_subject_dir=None): diff --git a/cortex/rois.py b/cortex/rois.py index d5d9bbace..e9553c90b 100644 --- a/cortex/rois.py +++ b/cortex/rois.py @@ -117,8 +117,8 @@ def to_svg(self, open_inkscape=False, filename=None): # Find polys allbpolys = np.unique(surf.connected[inbound+exbound].indices) selbpolys = surf.polys[allbpolys] - inpolys = np.in1d(selbpolys, inbound).reshape(selbpolys.shape) - expolys = np.in1d(selbpolys, exbound).reshape(selbpolys.shape) + inpolys = np.isin(selbpolys, inbound) + expolys = np.isin(selbpolys, exbound) badpolys = np.logical_or(inpolys.all(1), expolys.all(1)) boundpolys = np.logical_and(np.logical_or(inpolys, expolys).all(1), ~badpolys) diff --git a/cortex/segment.py b/cortex/segment.py index f274b6ab6..3d5c24808 100644 --- a/cortex/segment.py +++ b/cortex/segment.py @@ -384,7 +384,7 @@ def flatten_slim( ) # Cull pts that are not in manifold pi = np.arange(len(pts)) - pii = np.in1d(pi, polys.flatten()) + pii = np.isin(pi, polys.flatten()) idx = np.nonzero(pii)[0] pts_new = pts[idx] # Match indices in polys to new index for pts diff --git a/cortex/surfinfo.py b/cortex/surfinfo.py index 798403335..f59525648 100644 --- a/cortex/surfinfo.py +++ b/cortex/surfinfo.py @@ -186,7 +186,7 @@ def make_surface_graph(tris): mwallset = set.union(*(set(g[v]) for v in fog.nodes())) & set(allbounds) #cutset = (set(g.nodes()) - mwallset) & set(allbounds) - mwallbounds = [np.in1d(b, mwallset) for b in bounds] + mwallbounds = [np.isin(b, mwallset) for b in bounds] changes = [np.nonzero(np.diff(b.astype(float))!=0)[0]+1 for b in mwallbounds] #splitbounds = [np.split(b, c) for b,c in zip(bounds, changes)] diff --git a/cortex/testing_utils.py b/cortex/testing_utils.py index 24a3221e0..85c5f2e76 100644 --- a/cortex/testing_utils.py +++ b/cortex/testing_utils.py @@ -11,12 +11,19 @@ def inkscape_version(): if not has_installed('inkscape'): return None cmd = 'inkscape --version' - output = sp.check_output(cmd.split(), stderr=sp.PIPE) - # b'Inkscape 1.0 (4035a4f, 2020-05-01)\n' - version = output.split()[1] - if isinstance(version, bytes): - version = version.decode('utf-8') - return version + result = sp.run(cmd.split(), stdout=sp.PIPE, stderr=sp.PIPE, check=True) + # Combine stdout and stderr; some systems print diagnostic messages + # (e.g. "Setting _INKSCAPE_GC=disable …") before the version line. + combined = result.stdout + result.stderr + if isinstance(combined, bytes): + combined = combined.decode('utf-8') + # Find the line that starts with 'Inkscape' to get the real version, + # e.g. 'Inkscape 1.2.2 (b0a8486, 2022-12-01)' + for line in combined.splitlines(): + if line.strip().startswith('Inkscape'): + version = line.split()[1] + return version + return None INKSCAPE_VERSION = inkscape_version() diff --git a/cortex/tests/test_dataset.py b/cortex/tests/test_dataset.py index e45ee082d..d3224bd4f 100644 --- a/cortex/tests/test_dataset.py +++ b/cortex/tests/test_dataset.py @@ -312,23 +312,27 @@ def test_blend_curvature(): view = cortex.Vertex.empty(subj) alpha = np.linspace(0, 1, view.data.size).reshape(view.data.shape) - # test alpha with float - view_rgb = view.blend_curvature(alpha) - # test alpha with bool - view_rgb = view.blend_curvature(alpha > 0.3) + # blend_curvature is deprecated; the warning should fire on every call. + with pytest.warns(DeprecationWarning, match="blend_curvature is deprecated"): + view_rgb = view.blend_curvature(alpha) + with pytest.warns(DeprecationWarning): + view_rgb = view.blend_curvature(alpha > 0.3) # test that it returns a VertexRGB assert isinstance(view_rgb, cortex.VertexRGB) # test on Vertex2D view_2d = cortex.Vertex2D(view_rgb.red.data, view_rgb.green.data, subj) - view_rgb = view_2d.blend_curvature(alpha) + with pytest.warns(DeprecationWarning): + view_rgb = view_2d.blend_curvature(alpha) # test on VertexRGB - view_rgb_new = view_rgb.blend_curvature(alpha) + with pytest.warns(DeprecationWarning): + view_rgb_new = view_rgb.blend_curvature(alpha) # test that it returns a different VertexRGB assert not np.allclose(view_rgb.red.data, view_rgb_new.red.data) # test that it returns a VertexRGB with same values when alpha is ones - view_rgb_new = view_rgb.blend_curvature(np.ones_like(alpha)) + with pytest.warns(DeprecationWarning): + view_rgb_new = view_rgb.blend_curvature(np.ones_like(alpha)) assert np.allclose(view_rgb.red.data, view_rgb_new.red.data) diff --git a/cortex/tests/test_freesurfer.py b/cortex/tests/test_freesurfer.py index e7668fdfb..2a16e3ade 100644 --- a/cortex/tests/test_freesurfer.py +++ b/cortex/tests/test_freesurfer.py @@ -1,6 +1,15 @@ +import os +import shutil + import numpy as np +import pytest -from cortex.freesurfer import _remove_disconnected_polys +import cortex.freesurfer as fs +from cortex.freesurfer import ( + _remove_disconnected_polys, + _surf2surf_nnfr_matrix, + get_mri_surf2surf_matrix, +) def test_remove_disconnected_polys_examples(): @@ -27,3 +36,145 @@ def test_remove_disconnected_polys_idempotence(): # make sure calling the function does not change anything polys_2 = _remove_disconnected_polys(polys_1) np.testing.assert_array_equal(polys_1, polys_2) + + +# --------------------------------------------------------------------------- +# surf2surf (nnfr) matrix construction +# --------------------------------------------------------------------------- + +def _random_sphere(n, seed): + """n points on the unit sphere (so KDTree distances behave like the + real ?h.sphere.reg geometry).""" + rng = np.random.RandomState(seed) + pts = rng.randn(n, 3) + return pts / np.linalg.norm(pts, axis=1, keepdims=True) + + +def test_surf2surf_identity_when_source_equals_target(): + # If source and target spheres are identical, every target maps to the + # co-located source vertex and there are no orphans -> identity matrix. + sphere = _random_sphere(60, seed=0) + m = _surf2surf_nnfr_matrix(sphere, sphere) + assert m.shape == (60, 60) + np.testing.assert_allclose(m.toarray(), np.eye(60)) + + +def test_surf2surf_rows_sum_to_one(): + src = _random_sphere(80, seed=1) + trg = _random_sphere(50, seed=2) + m = _surf2surf_nnfr_matrix(src, trg) + assert m.shape == (50, 80) + row_sums = np.asarray(m.sum(axis=1)).ravel() + np.testing.assert_allclose(row_sums, np.ones(50)) + + +def test_surf2surf_preserves_constants(): + # A row-normalized averaging matrix maps a constant map to itself. + src = _random_sphere(80, seed=3) + trg = _random_sphere(50, seed=4) + m = _surf2surf_nnfr_matrix(src, trg) + const = np.full(80, 7.0) + np.testing.assert_allclose(m.dot(const), np.full(50, 7.0)) + + +def test_surf2surf_no_source_vertex_is_dropped(): + # The reverse pass guarantees every source vertex contributes to at least + # one target vertex (this is the whole point of "forward and reverse"). + src = _random_sphere(120, seed=5) + trg = _random_sphere(40, seed=6) # heavy downsampling + m = _surf2surf_nnfr_matrix(src, trg) + col_nnz = m.getnnz(axis=0) + assert (col_nnz > 0).all() + + +def test_surf2surf_known_averaging_example(): + # Four collinear source vertices, two target vertices placed so that each + # target's nearest source is an endpoint, leaving the two middle source + # vertices as orphans that get folded into their nearest target. + src = np.array([[0., 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]]) + trg = np.array([[0.4, 0, 0], [2.6, 0, 0]]) + m = _surf2surf_nnfr_matrix(src, trg) + + expected = np.array([[0.5, 0.5, 0.0, 0.0], # mean of src 0 and 1 + [0.0, 0.0, 0.5, 0.5]]) # mean of src 2 and 3 + np.testing.assert_allclose(m.toarray(), expected) + + data = np.array([10., 20., 30., 40.]) + np.testing.assert_allclose(m.dot(data), [15., 35.]) + + +def test_get_mri_surf2surf_matrix_ignores_legacy_kwargs(monkeypatch): + # Legacy regression-based kwargs are accepted but ignored, with a warning. + src = _random_sphere(20, seed=7) + trg = _random_sphere(15, seed=8) + monkeypatch.setattr( + fs, "_read_sphere_reg", + lambda subj, hemi, subjects_dir=None: src if subj == "A" else trg) + + with pytest.warns(DeprecationWarning): + m = get_mri_surf2surf_matrix("A", "lh", target_subj="B", + n_neighbors=20, n_test_images=40) + assert m.shape == (15, 20) + + +def test_get_mri_surf2surf_matrix_rejects_unknown_kwargs(): + with pytest.raises(TypeError): + get_mri_surf2surf_matrix("A", "lh", target_subj="B", bogus_kwarg=1) + + +def _have_template(subjects_dir, name, hemi="lh"): + return bool(subjects_dir) and os.path.exists( + os.path.join(subjects_dir, name, "surf", hemi + ".sphere.reg")) + + +@pytest.mark.skipif(shutil.which("mri_surf2surf") is None, + reason="freesurfer mri_surf2surf not available") +def test_surf2surf_matches_freesurfer_identity(): + """fsaverage -> fsaverage must be the identity, matching mri_surf2surf. + + Uses only the standard fsaverage template (no individual subjects), so it + is reproducible anywhere freesurfer is installed and skipped otherwise. + """ + subjects_dir = os.environ.get("SUBJECTS_DIR") + hemi = "lh" + if not _have_template(subjects_dir, "fsaverage", hemi): + pytest.skip("fsaverage template with sphere.reg not found in SUBJECTS_DIR") + + m = get_mri_surf2surf_matrix("fsaverage", hemi, target_subj="fsaverage", + subjects_dir=subjects_dir) + rng = np.random.RandomState(0) + data = rng.randn(4, m.shape[1]).astype(np.float32) + reference = fs.mri_surf2surf(data, "fsaverage", "fsaverage", hemi, + subjects_dir=subjects_dir) + got = np.stack([m.dot(data[i]) for i in range(data.shape[0])]) + + np.testing.assert_allclose(got, data, atol=1e-4) # identity + np.testing.assert_allclose(got, reference, atol=1e-4) # matches freesurfer + + +@pytest.mark.skipif(shutil.which("mri_surf2surf") is None, + reason="freesurfer mri_surf2surf not available") +def test_surf2surf_matches_freesurfer_downsample(): + """fsaverage -> fsaverage6 (icosahedral downsample) against mri_surf2surf. + + Uses only standard fsaverage templates. This is the documented inexact + case: freesurfer's tie-breaking on exactly-equidistant vertices of the + regular mesh differs, so we only require a high correlation rather than a + bit-exact match (see _surf2surf_nnfr_matrix notes). + """ + subjects_dir = os.environ.get("SUBJECTS_DIR") + hemi = "lh" + if not (_have_template(subjects_dir, "fsaverage", hemi) + and _have_template(subjects_dir, "fsaverage6", hemi)): + pytest.skip("fsaverage/fsaverage6 templates not found in SUBJECTS_DIR") + + m = get_mri_surf2surf_matrix("fsaverage", hemi, target_subj="fsaverage6", + subjects_dir=subjects_dir) + rng = np.random.RandomState(0) + data = rng.randn(4, m.shape[1]).astype(np.float32) + reference = fs.mri_surf2surf(data, "fsaverage", "fsaverage6", hemi, + subjects_dir=subjects_dir) + got = np.stack([m.dot(data[i]) for i in range(data.shape[0])]) + + corr = np.corrcoef(reference.ravel(), got.ravel())[0, 1] + assert corr > 0.99 diff --git a/cortex/tests/test_quickflat.py b/cortex/tests/test_quickflat.py index 11782f1cc..e0ec115bf 100644 --- a/cortex/tests/test_quickflat.py +++ b/cortex/tests/test_quickflat.py @@ -3,7 +3,9 @@ import tempfile import pytest +from cortex import dataset from cortex.testing_utils import has_installed +from cortex.webgl.data import Package no_inkscape = not has_installed('inkscape') @@ -40,3 +42,80 @@ def test_make_flatmap_image_nanmean(type_, nanmean): vol, nanmean=nanmean) # assert that the nanmean only returns NaNs and 1s assert np.nanmin(img) == 1 + + +def test_quickshow_webgl_alpha_equivalence(): + """quickshow (matplotlib) and WebGL must render the same VertexRGB+α identically. + + Issue #631: the WebGL shader uses a premultiplied "over" composite, while + matplotlib's imshow layering uses straight alpha. The fix premultiplies α + into RGB at the WebGL serialization step only, so both paths converge on + the same composite formula out = α·rgb + (1-α)·bg for any background. + This test asserts that equivalence at the per-vertex level for an + arbitrary curvature gray. + """ + subj = "S1" + nverts = cortex.db.get_surf(subj, "fiducial", merge=True)[0].shape[0] + rng = np.random.default_rng(631) + r = rng.uniform(0, 1, nverts).astype(np.float32) + g = rng.uniform(0, 1, nverts).astype(np.float32) + b = rng.uniform(0, 1, nverts).astype(np.float32) + alpha = rng.uniform(0, 1, nverts).astype(np.float32) + + vrgb = cortex.VertexRGB( + r, g, b, subj, + alpha=cortex.Vertex(alpha, subj, vmin=0, vmax=1), + ) + + raw = vrgb.vertices # what quickshow/matplotlib will composite (non-premult) + pkg = Package(dataset.Dataset(view=vrgb)) + packaged = pkg.images[vrgb.name][0] # what the shader will composite (premult) + + # Sanity: alpha is shared between the two paths. + assert np.array_equal(raw[..., 3], packaged[..., 3]) + + # Composite both against an arbitrary curvature gray. matplotlib's + # imshow with two layered images uses straight alpha; the GLSL shader at + # shaderlib.js line 851 uses gl_FragColor = vColor + (1-α)·bg. + a_norm = raw[..., 3:4].astype(np.float32) / 255.0 + rgb_raw = raw[..., :3].astype(np.float32) / 255.0 + rgb_pkg = packaged[..., :3].astype(np.float32) / 255.0 + for curv in (0.0, 0.25, 0.5, 0.75, 1.0): + bg = np.full_like(rgb_raw, curv) + matplotlib_out = a_norm * rgb_raw + (1.0 - a_norm) * bg + webgl_out = rgb_pkg + (1.0 - a_norm) * bg + # 1 LSB of uint8 rounding on each side -> 2/255 worst case. + np.testing.assert_allclose(matplotlib_out, webgl_out, atol=2.0 / 255.0) + + +def test_make_flatmap_image_vertexrgb_alpha_unchanged(): + """The matplotlib path must keep using NON-premultiplied RGBA bytes. + + Premultiplying inside .vertices would silently double-attenuate the + quickshow output. Pin that .vertices stays straight-alpha by checking + a uniform bright-red, half-transparent VertexRGB survives + make_flatmap_image without losing red intensity. + """ + subj = "S1" + nverts = cortex.db.get_surf(subj, "fiducial", merge=True)[0].shape[0] + # Uniform bright red, half transparent everywhere. Pass explicit Vertex + # objects with vmin/vmax to avoid auto-range degeneracy on the flat + # green/blue channels. + r = cortex.Vertex(np.ones(nverts, dtype=np.float32), subj, vmin=0, vmax=1) + g = cortex.Vertex(np.zeros(nverts, dtype=np.float32), subj, vmin=0, vmax=1) + b = cortex.Vertex(np.zeros(nverts, dtype=np.float32), subj, vmin=0, vmax=1) + alpha = cortex.Vertex(np.full(nverts, 0.5, dtype=np.float32), subj, + vmin=0, vmax=1) + vrgb = cortex.VertexRGB(r, g, b, subj, alpha=alpha) + img, _ = cortex.quickflat.utils.make_flatmap_image(vrgb) + # img is the rasterized RGBA flatmap. The data layer's red channel (where + # mask is filled and pixmap is non-degenerate) must be ~255, not ~127 -- + # if we ever start premultiplying inside .vertices, this drops to ~127. + rgba_in_mask = img[img[..., 3] > 0] + assert rgba_in_mask.size > 0 + # Filled pixels should have red close to 255 (bright red, with alpha=128). + bright_red_pixels = rgba_in_mask[rgba_in_mask[..., 0] > 200] + assert bright_red_pixels.size > 0, ( + "VertexRGB.vertices appears to be premultiplied -- the matplotlib " + "path will double-attenuate. The fix should live in webgl/data.py." + ) diff --git a/cortex/tests/test_testing_utils.py b/cortex/tests/test_testing_utils.py new file mode 100644 index 000000000..3abe4be00 --- /dev/null +++ b/cortex/tests/test_testing_utils.py @@ -0,0 +1,88 @@ +"""Tests for cortex/testing_utils.py""" +import subprocess +from unittest import mock + +import pytest + +from cortex.testing_utils import inkscape_version + + +def _make_run_result(stdout=b'', stderr=b'', returncode=0): + """Helper to build a mock subprocess.CompletedProcess.""" + result = mock.MagicMock() + result.stdout = stdout + result.stderr = stderr + result.returncode = returncode + return result + + +@mock.patch('cortex.testing_utils.has_installed', return_value=False) +def test_inkscape_version_not_installed(mock_has): + """Returns None when inkscape is not on PATH.""" + assert inkscape_version() is None + + +@mock.patch('cortex.testing_utils.has_installed', return_value=True) +@mock.patch('cortex.testing_utils.sp.run') +def test_inkscape_version_clean_stdout(mock_run, mock_has): + """Parses version correctly from clean stdout output.""" + mock_run.return_value = _make_run_result( + stdout=b'Inkscape 1.0 (4035a4f, 2020-05-01)\n' + ) + assert inkscape_version() == '1.0' + + +@mock.patch('cortex.testing_utils.has_installed', return_value=True) +@mock.patch('cortex.testing_utils.sp.run') +def test_inkscape_version_newer(mock_run, mock_has): + """Parses a newer version number correctly.""" + mock_run.return_value = _make_run_result( + stdout=b'Inkscape 1.2.2 (b0a8486, 2022-12-01)\n' + ) + assert inkscape_version() == '1.2.2' + + +@mock.patch('cortex.testing_utils.has_installed', return_value=True) +@mock.patch('cortex.testing_utils.sp.run') +def test_inkscape_version_with_diagnostic_noise(mock_run, mock_has): + """Returns correct version even when a diagnostic message precedes it. + + This is the regression test for the bug where systems that print + "Setting _INKSCAPE_GC=disable as a workaround for broken libgc" + caused INKSCAPE_VERSION to be set to '_INKSCAPE_GC=disable'. + """ + mock_run.return_value = _make_run_result( + stdout=( + b'Setting _INKSCAPE_GC=disable as a workaround for broken libgc\n' + b'Inkscape 1.2.2 (b0a8486, 2022-12-01)\n' + ) + ) + assert inkscape_version() == '1.2.2' + + +@mock.patch('cortex.testing_utils.has_installed', return_value=True) +@mock.patch('cortex.testing_utils.sp.run') +def test_inkscape_version_noise_in_stderr(mock_run, mock_has): + """Returns correct version when the diagnostic message is on stderr.""" + mock_run.return_value = _make_run_result( + stderr=b'Setting _INKSCAPE_GC=disable as a workaround for broken libgc\n', + stdout=b'Inkscape 1.2.2 (b0a8486, 2022-12-01)\n', + ) + assert inkscape_version() == '1.2.2' + + +@mock.patch('cortex.testing_utils.has_installed', return_value=True) +@mock.patch('cortex.testing_utils.sp.run') +def test_inkscape_version_no_inkscape_line(mock_run, mock_has): + """Returns None when no 'Inkscape …' line is found in output.""" + mock_run.return_value = _make_run_result(stdout=b'some unexpected output\n') + assert inkscape_version() is None + + +@mock.patch('cortex.testing_utils.has_installed', return_value=True) +@mock.patch('cortex.testing_utils.sp.run') +def test_inkscape_version_subprocess_error(mock_run, mock_has): + """Propagates CalledProcessError when inkscape exits non-zero.""" + mock_run.side_effect = subprocess.CalledProcessError(1, 'inkscape') + with pytest.raises(subprocess.CalledProcessError): + inkscape_version() diff --git a/cortex/tests/test_utils.py b/cortex/tests/test_utils.py index 65c1c813c..22ee0c937 100644 --- a/cortex/tests/test_utils.py +++ b/cortex/tests/test_utils.py @@ -1,14 +1,76 @@ +import shutil +import tarfile +from unittest import mock + +import pytest + import cortex -def test_download_subject(): - # Test that newly downloaded subjects are added to the current database. - # remove fsaverage from the list of available subjects if present. - if "fsaverage" in cortex.db.subjects: - cortex.db._subjects.pop("fsaverage") +@pytest.fixture +def fake_fsaverage_tarball(tmp_path): + """Build a minimal fsaverage.tar.gz that pycortex will recognize as a subject.""" + subj_src = tmp_path / "src" / "fsaverage" + subj_src.mkdir(parents=True) + (subj_src / "marker").write_text("ok") + tarball = tmp_path / "fsaverage.tar.gz" + with tarfile.open(tarball, "w:gz") as tar: + tar.add(subj_src, arcname="fsaverage") + return tarball + + +@pytest.fixture +def isolated_filestore(tmp_path, monkeypatch): + """Redirect cortex.db.filestore to an empty tmp dir and reset the subject cache.""" + store = tmp_path / "store" + store.mkdir() + monkeypatch.setattr(cortex.db, "filestore", str(store)) + original_subjects = cortex.db._subjects + cortex.db._subjects = None + yield store + cortex.db._subjects = original_subjects + + +def test_download_subject(isolated_filestore, fake_fsaverage_tarball, monkeypatch): + # Newly downloaded subjects are added to the current database. + def fake_retrieve(url, dest): + shutil.copy(fake_fsaverage_tarball, dest) + return dest, None + + mock_retrieve = mock.Mock(side_effect=fake_retrieve) + monkeypatch.setattr(cortex.utils.urllib.request, "urlretrieve", mock_retrieve) assert "fsaverage" not in cortex.db.subjects cortex.utils.download_subject(subject_id='fsaverage') assert "fsaverage" in cortex.db.subjects - # test that downloading it again works + assert mock_retrieve.call_count == 1 + + +def test_download_subject_skips_when_present(isolated_filestore, monkeypatch): + # If the subject is already in the database and download_again is False, + # download_subject warns and returns without touching the network. + cortex.db._subjects = {"fsaverage": mock.MagicMock()} + + mock_retrieve = mock.Mock() + monkeypatch.setattr(cortex.utils.urllib.request, "urlretrieve", mock_retrieve) + + with pytest.warns(UserWarning, match="already present"): + cortex.utils.download_subject(subject_id='fsaverage') + mock_retrieve.assert_not_called() + + +def test_download_subject_download_again( + isolated_filestore, fake_fsaverage_tarball, monkeypatch +): + # download_again=True re-downloads even when the subject is already present. + def fake_retrieve(url, dest): + shutil.copy(fake_fsaverage_tarball, dest) + return dest, None + + mock_retrieve = mock.Mock(side_effect=fake_retrieve) + monkeypatch.setattr(cortex.utils.urllib.request, "urlretrieve", mock_retrieve) + + cortex.utils.download_subject(subject_id='fsaverage') + assert "fsaverage" in cortex.db.subjects cortex.utils.download_subject(subject_id='fsaverage', download_again=True) + assert mock_retrieve.call_count == 2 diff --git a/cortex/tests/test_webgl_data.py b/cortex/tests/test_webgl_data.py new file mode 100644 index 000000000..35302e1af --- /dev/null +++ b/cortex/tests/test_webgl_data.py @@ -0,0 +1,194 @@ +"""Tests for the WebGL serialization layer in cortex.webgl.data.""" + +import numpy as np +import pytest + +import cortex +from cortex import dataset +from cortex.webgl.data import Package + + +subj, xfmname, nverts, volshape = "S1", "fullhead", 304380, (31, 100, 100) + + +def _packaged_rgba(brain): + """Run a dataview through Package and recover the pre-mosaic uint8 RGBA bytes.""" + pkg = Package(dataset.Dataset(view=brain)) + images = pkg.images[brain.name] + # Vertex* paths store the raw uint8 array directly; Volume* paths mosaic+PNG. + if isinstance(brain, dataset.VertexRGB): + return images[0] + raise NotImplementedError("Use _packaged_rgba_volume for VolumeRGB") + + +def _expected_premultiplied(raw_uint8): + """Compute alpha-premultiplied RGB bytes the same way Package should.""" + a = raw_uint8[..., 3:4].astype(np.float32) / 255.0 + out = raw_uint8.copy() + out[..., :3] = np.round(raw_uint8[..., :3].astype(np.float32) * a).astype(np.uint8) + return out + + +def test_vertexrgb_alpha_is_premultiplied_in_package(): + """WebGL Package should ship alpha-premultiplied RGB bytes (issue #631). + + The shader formula is gl_FragColor = vColor + (1-α)·bg, which only yields + correct "over" compositing when vColor is premultiplied. The fix lives in + Package.__init__; this test pins the contract. + """ + rng = np.random.default_rng(0) + r = rng.uniform(0, 1, nverts).astype(np.float32) + g = rng.uniform(0, 1, nverts).astype(np.float32) + b = rng.uniform(0, 1, nverts).astype(np.float32) + alpha = rng.uniform(0, 1, nverts).astype(np.float32) + + vrgb = cortex.VertexRGB( + r, + g, + b, + subj, + alpha=cortex.Vertex(alpha, subj, vmin=0, vmax=1), + ) + + # Sanity: the .vertices property itself stays NON-premultiplied so the + # quickshow (matplotlib) path keeps working. + raw = vrgb.vertices + assert raw.dtype == np.uint8 + assert raw.shape == (1, nverts, 4) + # Raw alpha must round-trip the input alpha to within 1 LSB. + expected_a = (np.clip(alpha, 0, 1) * 255).astype(np.uint8) + assert np.allclose(raw[0, :, 3].astype(int), expected_a.astype(int), atol=1) + # Raw RGB bytes must NOT already be premultiplied -- if they were, the + # fix is in the wrong layer and quickshow would double-attenuate. + nontrivial = expected_a < 200 # avoid α≈1 where premult ≈ raw + naive_premult = np.round( + raw[0, :, 0].astype(np.float32) * raw[0, :, 3].astype(np.float32) / 255.0 + ).astype(np.uint8) + assert ( + np.mean( + np.abs( + raw[0, nontrivial, 0].astype(int) + - naive_premult[nontrivial].astype(int) + ) + ) + > 5 + ), "vertices property looks already-premultiplied; quickshow would break" + + # Now check what Package serializes for the WebGL viewer. + packaged = _packaged_rgba(vrgb) + expected = _expected_premultiplied(raw) + assert packaged.shape == raw.shape + assert packaged.dtype == np.uint8 + # Alpha channel must be passed through unchanged (shader needs it for 1-α). + assert np.array_equal(packaged[..., 3], expected[..., 3]) + # RGB channels must be alpha-premultiplied. + assert np.array_equal(packaged[..., :3], expected[..., :3]) + + # And the un-packaged property must NOT have been mutated by Package + # (Package should defensive-copy). + raw_after = vrgb.vertices + assert np.array_equal(raw_after, raw) + + +def test_vertexrgb_alpha_one_is_passthrough(): + """When α=1 everywhere, premultiplication is a no-op (bug was invisible at α=1).""" + rng = np.random.default_rng(1) + r = rng.uniform(0, 1, nverts).astype(np.float32) + g = rng.uniform(0, 1, nverts).astype(np.float32) + b = rng.uniform(0, 1, nverts).astype(np.float32) + + vrgb = cortex.VertexRGB(r, g, b, subj) # default alpha = 1 + raw = vrgb.vertices + packaged = _packaged_rgba(vrgb) + assert np.array_equal(packaged[..., 3], raw[..., 3]) # alpha all 255 + # RGB unchanged because α=255 → premultiply by 1. + assert np.array_equal(packaged[..., :3], raw[..., :3]) + + +def test_vertexrgb_alpha_zero_zeros_rgb(): + """α=0 must drive packaged RGB to 0 -- the shader then renders pure curvature.""" + rng = np.random.default_rng(2) + r = rng.uniform(0, 1, nverts).astype(np.float32) + g = rng.uniform(0, 1, nverts).astype(np.float32) + b = rng.uniform(0, 1, nverts).astype(np.float32) + alpha = np.zeros(nverts, dtype=np.float32) + + vrgb = cortex.VertexRGB( + r, + g, + b, + subj, + alpha=cortex.Vertex(alpha, subj, vmin=0, vmax=1), + ) + packaged = _packaged_rgba(vrgb) + assert np.array_equal(packaged[..., 3], np.zeros_like(packaged[..., 3])) + assert np.array_equal(packaged[..., :3], np.zeros_like(packaged[..., :3])) + + +def test_volumergb_alpha_is_NOT_premultiplied_in_package(): + """VolumeRGB must ship straight-alpha bytes -- Three.js premultiplies on upload. + + Three.js sets ``tex.premultiplyAlpha = true`` for raw VolumeRGB textures + (cortex/webgl/resources/js/dataset.js:335-338), which makes WebGL apply + UNPACK_PREMULTIPLY_ALPHA_WEBGL on texture upload. So the shader sees + premultiplied RGB by the time vColor is sampled, but ONLY because the + texture-upload pipeline does it for us. If Package also premultiplied + here, partial-alpha VolumeRGB would render double-attenuated (too dark). + """ + rng = np.random.default_rng(3) + shape = volshape + r = rng.uniform(0, 1, shape).astype(np.float32) + g = rng.uniform(0, 1, shape).astype(np.float32) + b = rng.uniform(0, 1, shape).astype(np.float32) + alpha = rng.uniform(0, 1, shape).astype(np.float32) + + vrgb = cortex.VolumeRGB( + r, + g, + b, + subj, + xfmname, + alpha=cortex.Volume(alpha, subj, xfmname, vmin=0, vmax=1), + ) + + raw = vrgb.volume + assert raw.dtype == np.uint8 + + # Spy on the Package internals: wrap volume.mosaic to capture the array + # Package actually ships before it gets PNG-encoded. mock.patch handles + # restoration even if the patched call raises before reaching the + # assertions below. + from unittest import mock + + from cortex.webgl import data as webgl_data + + captured = [] + real_mosaic = webgl_data.volume.mosaic + + def spy_mosaic(arr, show=False): + captured.append(arr.copy()) + return real_mosaic(arr, show=show) + + with mock.patch.object(webgl_data.volume, "mosaic", side_effect=spy_mosaic): + Package(dataset.Dataset(view=vrgb)) + + assert len(captured) == 1 + packaged_frame = captured[0] + + # Bytes shipped to the browser must equal the raw .volume bytes verbatim + # (NOT premultiplied) -- Three.js will premultiply once on texture upload. + assert packaged_frame.shape == raw[0].shape + assert np.array_equal(packaged_frame, raw[0]) + + # And specifically, RGB should NOT have been premultiplied by alpha. + naive_premult = _expected_premultiplied(raw[0]) + nontrivial = (raw[0, ..., 3] < 200) & (raw[0, ..., 0] > 50) + assert ( + np.mean( + np.abs( + packaged_frame[nontrivial][..., 0].astype(int) + - naive_premult[nontrivial][..., 0].astype(int) + ) + ) + > 5 + ), "VolumeRGB Package output looks premultiplied; Three.js will then double-attenuate" diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py index f998ba73c..908084124 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -83,7 +83,6 @@ def test_datatype_renders(dtype_name, tmp_path): """Each data type should render in the headless viewer without errors.""" vol = make_dataview(dtype_name) with cortex.export.headless_viewer(vol, viewer_params={}) as handle: - time.sleep(10) outfile = str(tmp_path / "test.png") handle.getImage(outfile, (512, 384)) _wait_for_file(outfile) @@ -111,7 +110,6 @@ def _setup_viewer(self, tmp_path_factory): cls = type(self) cls.tmp_dir = tmp_path_factory.mktemp("angles") with cortex.export.headless_viewer(vol, viewer_params={}) as handle: - time.sleep(10) cls.handle = handle yield @@ -149,7 +147,6 @@ def _setup_viewer(self, tmp_path_factory): cls = type(self) cls.tmp_dir = tmp_path_factory.mktemp("surfaces") with cortex.export.headless_viewer(vol, viewer_params={}) as handle: - time.sleep(10) cls.handle = handle yield @@ -208,7 +205,6 @@ def test_capture_view_roundtrip(): """Setting view parameters and capturing them back should match.""" vol = cortex.Volume(np.random.randn(*volshape), subj, xfmname) with cortex.export.headless_viewer(vol, viewer_params={}) as handle: - time.sleep(10) target_params = { "camera.azimuth": 90, "camera.altitude": 90, @@ -243,7 +239,6 @@ def test_overlay_visibility_changes_image(tmp_path): with cortex.export.headless_viewer( vol, viewer_params=dict(overlays_visible=["rois"]) ) as handle: - time.sleep(10) handle._set_view(**view) time.sleep(1) handle.getImage(f1, (512, 384)) @@ -254,7 +249,6 @@ def test_overlay_visibility_changes_image(tmp_path): with cortex.export.headless_viewer( vol, viewer_params=dict(overlays_visible=[]) ) as handle: - time.sleep(10) handle._set_view(**view) time.sleep(1) handle.getImage(f2, (512, 384)) @@ -266,7 +260,343 @@ def test_overlay_visibility_changes_image(tmp_path): # --------------------------------------------------------------------------- -# Group 7: addData dataset switching +# Group 7: Vertex NaN-mask regression tests (#612, #626) +# --------------------------------------------------------------------------- + + +def _count_red_pixels(png_path): + """Count strongly red-dominant pixels (R - max(G, B) > 50).""" + from PIL import Image + + rgb = np.array(Image.open(png_path))[..., :3].astype(int) + return int((rgb[..., 0] - np.maximum(rgb[..., 1], rgb[..., 2]) > 50).sum()) + + +def test_vertex_no_nan_renders_data(tmp_path): + """A NaN-free Vertex must render visibly, not fall through to transparent. + + Regression test for #626: prior to the fix, the surface_vertex shader's + nanmask attribute defaulted to zeros when the Python data had no NaNs, + causing every vertex to be discarded and the brain to render with only + the grayscale curvature underlay. + """ + np.random.seed(0) + # Constant high values + chromatic colormap so colored pixels are + # easily distinguishable from the grayscale curvature underlay. + data = np.full(nverts, 5.0) + vtx = cortex.Vertex(data, subj, vmin=0, vmax=1, cmap="Reds") + + view = { + **default_view_params, + **angle_view_params["lateral_pivot"], + **unfold_view_params["inflated"], + } + + with cortex.export.headless_viewer(vtx, viewer_params={}) as handle: + handle._set_view(**view) + time.sleep(1) + outfile = str(tmp_path / "vtx.png") + handle.getImage(outfile, (512, 384)) + _wait_for_file(outfile) + + n_red = _count_red_pixels(outfile) + assert n_red > 1000, ( + f"Vertex data does not appear to be rendering " + f"(only {n_red} red-dominant pixels). " + "Surface may be falling through to grayscale curvature (#626)." + ) + + +def test_vertex_with_nan_renders_partial(tmp_path): + """A Vertex with some NaN values still renders the non-NaN portion (#612). + + Sanity check that the per-vertex NaN mask path keeps working: half-NaN + data should render strictly fewer red pixels than fully-valid data, but + still meaningfully more than zero. + """ + np.random.seed(0) + + full = np.full(nverts, 5.0) + half_nan = full.copy() + half_nan[: nverts // 2] = np.nan + + view = { + **default_view_params, + **angle_view_params["lateral_pivot"], + **unfold_view_params["inflated"], + } + + def render(data, name): + vtx = cortex.Vertex(data, subj, vmin=0, vmax=1, cmap="Reds") + with cortex.export.headless_viewer(vtx, viewer_params={}) as handle: + handle._set_view(**view) + time.sleep(1) + outfile = str(tmp_path / f"{name}.png") + handle.getImage(outfile, (512, 384)) + _wait_for_file(outfile) + return _count_red_pixels(outfile) + + n_full = render(full, "full") + n_half = render(half_nan, "half_nan") + + assert n_full > 1000, "Fully-valid Vertex should render visibly" + assert ( + n_half > 100 + ), "Half-NaN Vertex should still render the non-NaN half (#612 regression)" + assert n_half < n_full, ( + f"Expected half-NaN render ({n_half} red px) to have fewer red " + f"pixels than fully-valid render ({n_full} red px)" + ) + + +# --------------------------------------------------------------------------- +# Group 8: VertexRGB alpha attenuation regression test (#631) +# --------------------------------------------------------------------------- + + +def test_vertexrgb_alpha_zero_renders_curvature_only(tmp_path): + """VertexRGB with α=0 must render the curvature underlay, not bright color. + + Regression test for #631: prior to the fix, the WebGL fragment shader's + premultiplied-alpha composite formula (gl_FragColor = vColor + (1-α)·bg) + consumed un-premultiplied RGB bytes from VertexRGB.vertices, so α=0 left + the foreground color fully opaque and clipped toward white instead of + falling through to the gray curvature. + + With the fix, RGB is premultiplied at the WebGL serialization step + (cortex/webgl/data.py), so packaged vColor.rgb=0 when α=0, and the + shader produces pure curvature gray. + """ + from PIL import Image + + rng = np.random.default_rng(631) + # Bright, saturated colors -- if the bug returns these will leak through + # as red/green/blue pixels. With the fix and α=0, only neutral (curvature) + # gray pixels should remain in the brain region. + r = rng.uniform(0.7, 1.0, nverts).astype(np.float32) + g = rng.uniform(0.0, 0.3, nverts).astype(np.float32) + b = rng.uniform(0.0, 0.3, nverts).astype(np.float32) + alpha = np.zeros(nverts, dtype=np.float32) + + vrgb = cortex.VertexRGB( + r, + g, + b, + subj, + alpha=cortex.Vertex(alpha, subj, vmin=0, vmax=1), + ) + + view = { + **default_view_params, + **angle_view_params["lateral_pivot"], + **unfold_view_params["inflated"], + } + with cortex.export.headless_viewer(vrgb, viewer_params={}) as handle: + handle._set_view(**view) + time.sleep(1) + outfile = str(tmp_path / "alpha_zero.png") + handle.getImage(outfile, (512, 384)) + _wait_for_file(outfile) + + rgb = np.array(Image.open(outfile))[..., :3].astype(int) + # Count strongly red-dominant pixels: with the bug, α=0 lets the + # bright reds through and we'd see thousands of them. With the fix, + # the brain renders curvature gray (R≈G≈B) and red-dominant pixels + # fall to near zero (a handful from anti-aliased ROI overlays). + n_red = int((rgb[..., 0] - np.maximum(rgb[..., 1], rgb[..., 2]) > 50).sum()) + assert n_red < 500, ( + f"VertexRGB with α=0 produced {n_red} red-dominant pixels; " + "expected near-zero. The shader composite is consuming " + "un-premultiplied RGB (issue #631)." + ) + + +def test_volumergb_alpha_half_renders_correct_blend(tmp_path): + """VolumeRGB with α=0.5 must blend halfway, not double-attenuate. + + Companion regression to test_vertexrgb_alpha_zero_renders_curvature_only + (#631). VolumeRGB ships through the PNG texture path: Three.js sets + ``tex.premultiplyAlpha = true`` on upload, so the texture is premultiplied + once by WebGL itself. Package therefore must NOT premultiply on the + Python side -- if it does, the shader sees double-attenuated RGB. + + α=0 won't catch that bug (0·anything = 0), so we use α=0.5 with bright + uniform red. With curvature contribution included, observed shader + output for the brain region is: + - correct (single premult by JS): median R ≈ 145-160 + - bug (double premult: Py + JS): median R ≈ 90-105 + Threshold at 125 sits in the middle of the gap and tolerates 20+ LSB + of boundary/interpolation noise on either side. + """ + from PIL import Image + + # Uniform saturated red over the whole volume, half transparent. Wrap in + # explicit Volume(vmin=0, vmax=1) so the .volume property doesn't + # auto-normalize a constant array to NaN. + r = cortex.Volume( + np.full(volshape, 1.0, dtype=np.float32), subj, xfmname, vmin=0, vmax=1 + ) + g = cortex.Volume( + np.full(volshape, 0.0, dtype=np.float32), subj, xfmname, vmin=0, vmax=1 + ) + b = cortex.Volume( + np.full(volshape, 0.0, dtype=np.float32), subj, xfmname, vmin=0, vmax=1 + ) + alpha = cortex.Volume( + np.full(volshape, 0.5, dtype=np.float32), subj, xfmname, vmin=0, vmax=1 + ) + vrgb = cortex.VolumeRGB(r, g, b, subj, xfmname, alpha=alpha) + + view = { + **default_view_params, + **angle_view_params["lateral_pivot"], + **unfold_view_params["inflated"], + } + with cortex.export.headless_viewer(vrgb, viewer_params={}) as handle: + handle._set_view(**view) + time.sleep(1) + outfile = str(tmp_path / "volumergb_alpha_half.png") + handle.getImage(outfile, (512, 384)) + _wait_for_file(outfile) + + rgb = np.array(Image.open(outfile))[..., :3].astype(int) + # Brain-region pixels are red-dominant under both correct and buggy + # paths, but their R intensity differs. Pick the strongly red + # pixels (R clearly > G,B) and check median R. + red_mask = rgb[..., 0] - np.maximum(rgb[..., 1], rgb[..., 2]) > 30 + assert red_mask.sum() > 1000, ( + "Expected a large red-dominant region for half-transparent red " + f"VolumeRGB; got only {red_mask.sum()} pixels. Did the brain render?" + ) + median_r = float(np.median(rgb[red_mask, 0])) + # Discriminator: correct path produces ~145-160, double-premult + # produces ~90-105. Threshold at 125 sits in the middle. + assert median_r > 125, ( + f"VolumeRGB α=0.5 brain pixels have median R={median_r:.0f}; " + "expected ~150. R<125 indicates Package is double-premultiplying " + "VolumeRGB (issue #631 regression)." + ) + + +def test_vertex2d_alpha_half_renders_correct_blend(tmp_path): + """Vertex2D with α=0.5 must blend halfway, not over-attenuate the bg. + + Companion regression to the issue #631 fix on the colormap-texture + path. The 2D dataview ships dim1 / dim2 as separate scalar maps and + the LUT lookup happens on the GPU via + ``texture2D(colormap, vec2(dim1_norm, dim2_norm))``. The shader's + composite (shaderlib.js:851) uses the premultiplied-over formula + ``vColor + (1-α)·bg``, so the colormap texture itself must be + premultiplied on upload (``tex.premultiplyAlpha = true`` in + mriview.js). Without that, alpha-bearing colormaps like + ``RdBu_r_alpha`` produce ``R + (1-α)·bg`` -- where the foreground + is added on top of a partially-attenuated curvature -- instead of + the correct ``α·R + (1-α)·bg``. + + α=0 doesn't catch this bug because most alpha colormaps store + ``(0, 0, 0, 0)`` at the transparent end of the LUT (so neither the + buggy nor the correct shader produces foreground there). At α=0.5 + the LUT stores its full RGB with α=127, and the difference between + buggy and correct composites is maximal in the brain region. + + Empirical pixel stats for RdBu_r_alpha at data=+1, alpha=0.5, + inflated lateral_pivot view, default viewer params (S1): + + - correct (premultiplied): red_dom median R ≈ 93 (25/50/75 = 80/93/110) + - buggy (un-premultiplied): red_dom median R ≈ 129 (25/50/75 = 105/129/149) + + Threshold at 115 sits between the two distributions. + """ + from PIL import Image + + # data=+1 puts every vertex at the deep red end of RdBu_r_alpha, + # alpha=0.5 puts every vertex at mid-α (LUT row ~128). + data = np.full(nverts, 1.0, dtype=np.float32) + alpha = np.full(nverts, 0.5, dtype=np.float32) + + vtx2d = cortex.Vertex2D( + data, alpha, subj, + cmap="RdBu_r_alpha", + vmin=-1, vmax=1, + vmin2=0, vmax2=1, + ) + + view = { + **default_view_params, + **angle_view_params["lateral_pivot"], + **unfold_view_params["inflated"], + } + # The cmap elements decode asynchronously in Chromium. If the first + # render frame happens before the LUT image has decoded, three.js skips + # the texImage2D upload and the data layer renders against a 1×1 black + # texture (R==G==B everywhere -- looks like the curvature underlay). + # Retry _set_view + getImage until we observe a colored data layer + # (some pixels with R clearly > G or B, or vice-versa). We then run the + # premultiplication discriminator on that frame. + with cortex.export.headless_viewer(vtx2d, viewer_params={}) as handle: + # viewer.loaded already resolved by the context manager; a short + # extra pause covers the gap before the cmap decodes. The + # retry loop below is the real guard for slow decodes. + time.sleep(2) + rgb = None + outfile = None + for attempt in range(6): + handle._set_view(**view) + time.sleep(3) + # Use a fresh filename each retry so we never read a partial PNG + # left over from a prior iteration (getImage writes async). + outfile = str(tmp_path / f"vertex2d_alpha_half_{attempt}.png") + handle.getImage(outfile, (512, 384)) + _wait_for_file(outfile) + # Give the PNG writer a moment to finish flushing. + time.sleep(1) + try: + rgb = np.array(Image.open(outfile))[..., :3].astype(int) + except Exception: + continue + # "Colored" = at least some pixels deviate strongly from R==G==B. + # In a curvature-only (cmap-unbound) frame all brain pixels have + # R==G==B exactly; any non-zero count of channel-divergent pixels + # means the cmap texture is bound. + channel_spread = np.abs(rgb[..., 0] - rgb[..., 1]) + np.abs( + rgb[..., 1] - rgb[..., 2] + ) + if (channel_spread > 5).sum() > 1000: + break + else: + pytest.skip( + "Cmap texture never bound in headless Chromium across 6 " + "render retries; can't discriminate fix vs bug." + ) + + # Both fix and bug produce a red-dominant brain region (the bug + # doesn't zero the foreground, just over-brightens it). The + # discriminator is the *median R intensity* of those red-dominant + # pixels: the buggy un-premultiplied path adds the full R on top of + # half the curvature, biasing R upward; the correct premultiplied + # path attenuates R by α before adding curvature. + # + # First, the brain must render as a red-dominant region (this is + # also satisfied by the bug, but if even this fails the cmap is + # unbound and we can't discriminate). + red_mask = rgb[..., 0] - np.maximum(rgb[..., 1], rgb[..., 2]) > 20 + assert red_mask.sum() > 1000, ( + f"Vertex2D α=0.5 deep-red rendered only {red_mask.sum()} " + "red-dominant pixels. Check that the cmap LUT bound and the " + "data layer rendered at all." + ) + median_r = float(np.median(rgb[red_mask, 0])) + assert median_r < 115, ( + f"Vertex2D α=0.5 brain pixels have median R={median_r:.0f}; " + "expected ≈93 (correct), saw ≥115 which is in the buggy range " + "(~129). The colormap texture is being sampled straight-alpha " + "while the shader applies a premultiplied composite -- check " + "mriview.js cmap texture premultiplyAlpha." + ) + + +# --------------------------------------------------------------------------- +# Group 9: addData dataset switching # --------------------------------------------------------------------------- @@ -280,8 +610,212 @@ def test_addData_no_crash(): vol1 = cortex.Volume(np.random.randn(*volshape), subj, xfmname) vol2 = cortex.Volume(np.random.randn(*volshape), subj, xfmname) with cortex.export.headless_viewer(vol1, viewer_params={}) as handle: - time.sleep(10) handle.addData(second=vol2) time.sleep(2) pageerrors = [e for e in handle._pw_thread.browser_errors if "[pageerror]" in e] assert len(pageerrors) == 0, f"JS errors after addData: {pageerrors}" + + +# --------------------------------------------------------------------------- +# 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 diff --git a/cortex/utils.py b/cortex/utils.py index 8cfaf201d..6ed7ab520 100644 --- a/cortex/utils.py +++ b/cortex/utils.py @@ -811,12 +811,12 @@ def get_roi_masks(subject, xfmname, roi_list=None, gm_sampler='cortical', split_ vert_in_scan = np.hstack([np.array((m>0).sum(1)).flatten() for m in mapper.masks]) vert_in_scan = vert_in_scan[roi_verts[roi]] elif use_cortex_mask: - vox_in_roi = np.in1d(vox_idx.flatten(), roi_verts[roi]).reshape(vox_idx.shape) + vox_in_roi = np.isin(vox_idx, roi_verts[roi]) roi_voxels[roi] = vox_in_roi & cortex_mask # This is not accurate... because vox_idx only contains the indices of the *nearest* # vertex to each voxel, it excludes many vertices. I can't think of a way to compute # this accurately for non-mapper gm_samplers for now... ML 2017.07.14 - vert_in_scan = np.in1d(roi_verts[roi], vox_idx[cortex_mask]) + vert_in_scan = np.isin(roi_verts[roi], vox_idx[cortex_mask]) # Compute ROI coverage pct_coverage[roi] = vert_in_scan.mean() * 100 if use_mapper: diff --git a/cortex/webgl/data.py b/cortex/webgl/data.py index dc15eccb8..0dfed7d4c 100644 --- a/cortex/webgl/data.py +++ b/cortex/webgl/data.py @@ -7,6 +7,7 @@ images=(__braindata_name=["img1.png", "img2.png"]), ) """ + import os import json from io import BytesIO @@ -16,14 +17,15 @@ from .. import volume -#TODO: How to package multiviews? +# TODO: How to package multiviews? class Package(object): """Package the data into a form usable by javascript""" + def __init__(self, data): self.dataset = dataset.normalize(data) self.uniques = list(data.uniques(collapse=True)) self.subjects = set() - + self.brains = dict() self.images = dict() for brain in self.uniques: @@ -36,19 +38,41 @@ def __init__(self, data): encdata = brain.volume if isinstance(brain, (dataset.VolumeRGB, dataset.VertexRGB)): encdata = encdata.astype(np.uint8) - self.brains[name]['raw'] = True + # The WebGL fragment shader (shaderlib.js) composites with a + # premultiplied-alpha "over" formula + # (gl_FragColor = vColor + (1-α)·bg). We only need to pre- + # multiply on the Python side for VertexRGB, where the bytes + # are uploaded as raw vertex attributes and Three.js does NOT + # premultiply (see dataset.js VertexData path). VolumeRGB ships + # through the PNG texture path (dataset.js:335-338, raw=true), + # where Three.js sets `tex.premultiplyAlpha = true` and the + # WebGL UNPACK_PREMULTIPLY_ALPHA_WEBGL hook premultiplies the + # texture once on upload -- premultiplying here would double- + # attenuate it. The .vertices/.volume properties stay + # non-premultiplied so the matplotlib (quickshow) path keeps + # using matplotlib's straight-alpha imshow compositor. + if isinstance(brain, dataset.VertexRGB): + # Note: encdata is already a fresh uint8 copy from the + # .astype(np.uint8) call above, so we can write into it + # in place. The assignment to a uint8 slice handles the + # float→uint8 cast for us. + a = encdata[..., 3:4].astype(np.float32) / 255.0 + encdata[..., :3] = np.round( + encdata[..., :3].astype(np.float32) * a + ) + self.brains[name]["raw"] = True else: encdata = encdata.astype(np.float32) - self.brains[name]['raw'] = False + self.brains[name]["raw"] = False - #VertexData requires reordering, only save normalized version for now + # VertexData requires reordering, only save normalized version for now if isinstance(brain, (dataset.Vertex, dataset.VertexRGB)): self.images[name] = [encdata] else: self.images[name] = [volume.mosaic(vol, show=False) for vol in encdata] if len(set([shape for m, shape in self.images[name]])) != 1: - raise ValueError('Internal error in mosaic') - self.brains[name]['mosaic'] = self.images[name][0][1] + raise ValueError("Internal error in mosaic") + self.brains[name]["mosaic"] = self.images[name][0][1] self.images[name] = [_pack_png(m) for m, shape in self.images[name]] @property @@ -56,22 +80,24 @@ def views(self): metadata = [] for name, view in self.dataset: meta = view.to_json(simple=False) - meta['name'] = name - if 'stim' in meta['attrs']: - meta['attrs']['stim'] = os.path.split(meta['attrs']['stim'])[1] + meta["name"] = name + if "stim" in meta["attrs"]: + meta["attrs"]["stim"] = os.path.split(meta["attrs"]["stim"])[1] metadata.append(meta) return metadata def reorder(self, subjects): - indices = dict((k, np.load(os.path.splitext(v)[0]+".npz")) for k, v in subjects.items()) + indices = dict( + (k, np.load(os.path.splitext(v)[0] + ".npz")) for k, v in subjects.items() + ) for brain in self.uniques: if isinstance(brain, (dataset.Vertex, dataset.VertexRGB)): data = np.array(self.images[brain.name])[0] npyform = BytesIO() - if self.brains[brain.name]['raw']: - data = data[..., indices[brain.subject]['index'], :] + if self.brains[brain.name]["raw"]: + data = data[..., indices[brain.subject]["index"], :] else: - data = data[..., indices[brain.subject]['index']] + data = data[..., indices[brain.subject]["index"]] np.save(npyform, np.ascontiguousarray(data)) npyform.seek(0) self.images[brain.name] = [npyform.read()] @@ -81,8 +107,10 @@ def reorder(self, subjects): def metadata(self, submap=None, **kwargs): if submap is not None: for data in self.brains.values(): - data['subject'] = submap[data['subject']] - return dict(views=self.views, data=self.brains, images=self.image_names(**kwargs)) + data["subject"] = submap[data["subject"]] + return dict( + views=self.views, data=self.brains, images=self.image_names(**kwargs) + ) def image_names(self, fmt="/data/{name}/{frame}/"): names = dict() @@ -90,14 +118,16 @@ def image_names(self, fmt="/data/{name}/{frame}/"): names[name] = [fmt.format(name=name, frame=i) for i in range(len(imgs))] return names + def _pack_png(mosaic): from PIL import Image + buf = BytesIO() if mosaic.dtype not in (np.float32, np.uint8): raise TypeError y, x = mosaic.shape[:2] - im = Image.frombuffer('RGBA', (x,y), mosaic.data, 'raw', 'RGBA', 0, 1) - im.save(buf, format='PNG') + im = Image.frombuffer("RGBA", (x, y), mosaic.data, "raw", "RGBA", 0, 1) + im.save(buf, format="PNG") buf.seek(0) return buf.read() diff --git a/cortex/webgl/resources/css/mriview.css b/cortex/webgl/resources/css/mriview.css index f365dc8bd..47962cbbc 100644 --- a/cortex/webgl/resources/css/mriview.css +++ b/cortex/webgl/resources/css/mriview.css @@ -116,25 +116,10 @@ a:visited { color:white; } margin:20px; border-radius:10px; background:rgba(255,255,255,.2); - max-width:400px; + width:max-content; + min-width:200px; + max-width:90vw; display:none; - /*width:320px; - height:58px; -*/ - transition:all .3s; - transition-timing-function:ease; - transition-delay:1s; - -moz-transition: all .3s; - -moz-transition-timing-function: ease; - -moz-transition-delay:1s; - -webkit-transition: all .3s; - -webkit-transition-timing-function: ease; - -webkit-transition-delay:1s; -} -#dataopts:hover, #dataopts:active { - transition-delay:.1s; - -moz-transition-delay:.1s; - -webkit-transition-delay:.1s; } #dataname { color:white; @@ -486,12 +471,14 @@ input.vlim { /* datasets */ ul#datasets { - max-width: 400px; list-style: none; margin: 0; padding: 0; - overflow:auto; + overflow-y: auto; + overflow-x: hidden; max-height:70vh; + width: max-content; + max-width: 100%; } ul#datasets li { background: white; @@ -501,6 +488,9 @@ ul#datasets li { border: 1px solid black; list-style: none; padding-left: 42px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } ul#datasets li .handle { background: #CCC; diff --git a/cortex/webgl/resources/js/dataset.js b/cortex/webgl/resources/js/dataset.js index 762210eb8..9a0a71643 100644 --- a/cortex/webgl/resources/js/dataset.js +++ b/cortex/webgl/resources/js/dataset.js @@ -109,34 +109,40 @@ var dataset = (function(module) { this._dispatch = this.dispatchEvent.bind(this); - //Aggregate all Volume/VertexData deferreds to determine when to resolve + // Aggregate all child Volume/VertexData deferreds to determine when + // to resolve. Each child is tracked separately by registering on its + // own .loaded — using $.when().progress and looping over data.length + // would mark every child ready on a single child's notify, since + // $.when's combined progress event doesn't say which source fired. + // That ordering bug caused setData → active.set() to dispatch + // verts/textures for a sibling that hadn't pushed yet. var allready = []; for (var i = 0; i < this.data.length; i++) { allready.push(false); } - - var deferred = this.data.length == 1 ? - $.when(this.data[0].loaded) : - $.when(this.data[0].loaded, this.data[1].loaded); - deferred - .progress(function(available) { - for (var i = 0; i < this.data.length; i++) { - //TODO: fix this load order - if (available > this.delay && !allready[i]) { - allready[i] = true; - - //Resolve this deferred if ALL the BrainData objects are loaded (for multiviews) - var test = true; - for (var i = 0; i < allready.length; i++) - test = test && allready[i]; - if (test) - this.loaded.resolve(); - } - } - }.bind(this)) - .done(function() { + var checkResolve = function() { + for (var j = 0; j < allready.length; j++) + if (!allready[j]) return; this.loaded.resolve(); - }.bind(this)); + }.bind(this); + var markReady = function(idx) { + if (!allready[idx]) { + allready[idx] = true; + checkResolve(); + } + }; + for (var i = 0; i < this.data.length; i++) { + (function(idx) { + this.data[idx].loaded + .progress(function(available) { + if (available > this.delay) markReady(idx); + }.bind(this)) + .done(function() { markReady(idx); }); + }.bind(this))(i); + } + // Handle the empty-data edge case so this.loaded still resolves + // (matches the pre-refactor $.when() semantics for no arguments). + checkResolve(); this.ui = new jsplot.Menu(); @@ -275,6 +281,31 @@ var dataset = (function(module) { for (var i = 0; i < this.data.length; i++) { this.data[i].set(this.uniforms, i, fframe, this._dispatch); } + // Combine per-dim NaN masks into the single shared nanmask + // attribute. Vertex2D dispatches each dim's data separately + // (data0/1 vs data2/3) but shares one nanmask attribute in the + // shader; if either dim's value is NaN at a vertex, that vertex + // must be discarded. + if (this.vertex && !this.data[0].raw && this.data[0].nanmasks.length > 0) { + var dim0 = this.data[0].nanmasks[fframe]; + var combined; + if (this.data.length === 1) { + combined = dim0; + } else { + combined = [0, 1].map(function(side) { + var a = dim0[side].array; + var b = this.data[1].nanmasks[fframe][side].array; + var out = new Float32Array(a.length); + for (var i = 0; i < a.length; i++) { + out[i] = (a[i] < 0.5 || b[i] < 0.5) ? 0.0 : 1.0; + } + var attr = new THREE.BufferAttribute(out, 1); + attr.needsUpdate = true; + return attr; + }.bind(this)); + } + this._dispatch({type:"attribute", name:"nanmask", value:combined}); + } } module.DataView.prototype.setFilter = function(interp) { this.filter = interp; @@ -419,31 +450,34 @@ var dataset = (function(module) { } } else { - // Remap indices and detect NaN in a single pass. - // WebGL drivers may sanitize NaN in vertex attributes, - // so we build a mask and replace NaN with 0 here. - var hasNaN = false; - for (var i = 0; i < sleft.length; i++) { - sleft[i] = left[hemis.left.reverseIndexMap[i]]; - if (isNaN(sleft[i])) hasNaN = true; - } - for (var i = 0; i < sright.length; i++) { - sright[i] = right[hemis.right.reverseIndexMap[i]]; - if (isNaN(sright[i])) hasNaN = true; - } - if (hasNaN) { - var masks = [sleft, sright].map(function(arr) { - var mask = new Float32Array(arr.length); - for (var i = 0; i < arr.length; i++) { - if (isNaN(arr[i])) { mask[i] = 0.0; arr[i] = 0.0; } - else { mask[i] = 1.0; } + // Remap indices and build the NaN mask in a single + // pass. WebGL drivers may sanitize NaN in vertex + // attributes, so we always build a mask attribute + // (1=valid, 0=NaN) and replace NaN with 0 in the + // data. The shader uses the mask to discard NaN + // vertices. We always push a mask (all 1s when + // there are no NaNs) so per-frame indexing in + // VertexData.set stays aligned with this.verts. + var masks = [ + {dest: sleft, src: left, map: hemis.left.reverseIndexMap}, + {dest: sright, src: right, map: hemis.right.reverseIndexMap} + ].map(function(h) { + var mask = new Float32Array(h.dest.length); + for (var i = 0; i < h.dest.length; i++) { + var val = h.src[h.map[i]]; + if (isNaN(val)) { + h.dest[i] = 0.0; + mask[i] = 0.0; + } else { + h.dest[i] = val; + mask[i] = 1.0; } - var attr = new THREE.BufferAttribute(mask, 1); - attr.needsUpdate = true; - return attr; - }); - this.nanmasks.push(masks); - } + } + var attr = new THREE.BufferAttribute(mask, 1); + attr.needsUpdate = true; + return attr; + }); + this.nanmasks.push(masks); } var lattr = new THREE.BufferAttribute(sleft, this.raw?4:1); var rattr = new THREE.BufferAttribute(sright, this.raw?4:1); @@ -467,9 +501,8 @@ var dataset = (function(module) { var name = dim == 0 ? "data0":"data2"; dispatch({type:"attribute", name:"data"+(2*dim), value:this.verts[fframe]}); dispatch({type:"attribute", name:"data"+(2*dim+1), value:this.verts[(fframe+1).mod(this.verts.length)]}); - if (this.nanmasks.length > 0) { - dispatch({type:"attribute", name:"nanmask", value:this.nanmasks[fframe]}); - } + // The combined nanmask is dispatched by DataView.setFrame after + // every dim's data has been set, so we don't dispatch it here. } return module; diff --git a/cortex/webgl/resources/js/facepick.js b/cortex/webgl/resources/js/facepick.js index a6cedf708..0747ff576 100644 --- a/cortex/webgl/resources/js/facepick.js +++ b/cortex/webgl/resources/js/facepick.js @@ -82,8 +82,27 @@ function PickPosition(surf, posdata) { PickPosition.prototype = { //Set the transform for any voxels apply: function(dataview) { - if (dataview) - this.xfm.set.apply(this.xfm, dataview.xfm); + if (dataview) { + if (dataview.xfm) { + // For 2D volume dataviews, dataview.xfm is [xfm_dim1, xfm_dim2]; + // both share xfmname (enforced server-side), so dim1's transform + // is correct for picker -> voxel conversion. For 1D volume, + // dataview.xfm is a flat 16-element array. Mirror the dataset.js + // volxfm length check (see VolumeData.init). + var xfm = (dataview.xfm.length === 16) ? dataview.xfm : dataview.xfm[0]; + this.xfm.set.apply(this.xfm, xfm); + } else { + // Vertex dataviews have no xfm. Setting the matrix to NaN + // suppresses voxel-axis marker rendering (Three.js culls NaN + // positions). This mirrors the implicit pre-existing + // behavior where `set.apply(xfm, undefined)` invoked + // `Matrix4.set()` with no args, leaving the matrix + // un-numerable — voxel markers don't belong on a vertex + // view (you're picking a vertex, not a voxel). + this.xfm.set(NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, + NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN); + } + } for (var i = 0; i < this.axes.length; i++) { var ax = this.axes[i]; diff --git a/cortex/webgl/resources/js/mriview.js b/cortex/webgl/resources/js/mriview.js index 8c220a604..a407b03c7 100644 --- a/cortex/webgl/resources/js/mriview.js +++ b/cortex/webgl/resources/js/mriview.js @@ -1,5 +1,61 @@ var mriview = (function(module) { var grid_shapes = [null, [1,1], [2, 1], [3, 1], [2, 2], [2, 2], [3, 2], [3, 2]]; + + // Returns true iff every VolumeData entry in `dataview.data` shares the + // same shape, mosaic, and numslices, AND every dim's transform matches + // dim0's. The hover/click value readout for 2D volume views computes + // mouse_index from dim0's voxel coordinate (via picker.xfm = dim0 xfm) + // and reuses it across all dims; that is only correct when both shape/ + // mosaic AND the transforms match. Python-side Volume2D enforces matching + // xfmname (so xfms agree); dataset.makeFrom can pair volumes with + // matching shape but different transforms — bail out in that case. + module.volumeGeomsMatch = function (dataview) { + var data = dataview && dataview.data; + if (!data || data.length < 2) return true; + var d0 = data[0]; + for (var i = 1; i < data.length; i++) { + var di = data[i]; + if (!d0.shape || !di.shape || !d0.mosaic || !di.mosaic) return false; + if (d0.shape[0] !== di.shape[0] || d0.shape[1] !== di.shape[1]) return false; + if (d0.mosaic[0] !== di.mosaic[0] || d0.mosaic[1] !== di.mosaic[1]) return false; + if (d0.numslices !== di.numslices) return false; + } + // For 2D volume dataviews, dataview.xfm is [xfm_dim0, xfm_dim1, ...]; + // each entry is a flat 16-element array. (For 1D the xfm is itself a + // flat 16-element array, no per-dim entries to compare — handled by + // the early return above.) + var xfm = dataview.xfm; + if (Array.isArray(xfm) && xfm.length === data.length && Array.isArray(xfm[0])) { + var x0 = xfm[0]; + for (var j = 1; j < xfm.length; j++) { + var xj = xfm[j]; + if (!Array.isArray(xj) || xj.length !== x0.length) return false; + for (var k = 0; k < x0.length; k++) { + if (x0[k] !== xj[k]) return false; + } + } + } + return true; + }; + + // Returns true iff every entry in `data` has the per-frame buffers the + // hover/click handlers need (verts for vertex data, textures for + // volume data). Loading is async — the dataview's `loaded` deferred + // can resolve a tick before all child VertexData/VolumeData buffers + // are populated, and the setData refire fires inside that window. + module.dataBuffersReady = function (data) { + if (!data || data.length === 0) return false; + for (var i = 0; i < data.length; i++) { + var d = data[i]; + if (d.mosaic !== undefined) { + if (!d.textures || d.textures.length === 0) return false; + } else { + if (!d.verts || d.verts.length === 0) return false; + } + } + return true; + }; + module.Viewer = function(figure) { jsplot.Axes.call(this, figure); @@ -21,7 +77,16 @@ var mriview = (function(module) { var tex = new THREE.Texture(this); tex.minFilter = THREE.LinearFilter; tex.magFilter = THREE.LinearFilter; - tex.premultiplyAlpha = false; + // Premultiply alpha on upload so the shader's premultiplied-over + // composite (gl_FragColor = vColor + (1-α)·cColor in shaderlib.js) + // produces the correct α·rgb + (1-α)·bg result when sampling + // alpha-bearing colormaps (e.g. RdBu_r_alpha, fire_alpha). For + // non-alpha cmaps every row has α=255, so premultiplication is a + // no-op. Without this, alpha-cmap-rendered Vertex2D/Volume2D + // foregrounds clip toward white instead of fading to the + // curvature underlay (companion fix to issue #631 for the LUT + // texture path). + tex.premultiplyAlpha = true; tex.flipY = true; tex.needsUpdate = true; colormaps[this.parentNode.id] = tex; @@ -229,8 +294,28 @@ var mriview = (function(module) { this.setData(data[0].name); }; + module.Viewer.prototype.fitDataname = function() { + var dn = document.getElementById("dataname"); + if (!dn || !dn.offsetWidth) return; + dn.style.fontSize = ""; + var available = Math.floor(window.innerWidth * 0.9) - 60; + var width = dn.scrollWidth; + if (width <= available) return; + var maxPx = 32, minPx = 14; + var fitted = Math.max(minPx, Math.floor(maxPx * available / width)); + dn.style.fontSize = fitted + "px"; + }; + module.Viewer.prototype.setData = function(name) { + // Hide any previously displayed hover/click values immediately. They + // refer to the OLD dataview; we will re-fire them against the NEW + // dataview once it has loaded (see this.active.loaded.done() below). + // Without this transient hide, a stale number is briefly visible + // during the (sometimes slow) load of the new dataset. + $('#mouseover_value').css('display', 'none') + $('#picked_value').css('display', 'none') + // blur any selected input elements let ids = [ ['#vmin', '#vmin-input'], @@ -291,7 +376,28 @@ var mriview = (function(module) { this.active.loaded.done(function() { this.active.set(); // Register event for dataset switching - this.dispatchEvent({type:"setData", name:this.active.name}); + this.dispatchEvent({type:"setData", name:this.active.name}); + // Push the new dataview into each surface (shaders + picker xfm) + // before re-firing hover/click. drawView normally does this on + // the next render frame, but pick() needs the picker's xfm to + // already match the new dataview — otherwise a vertex→volume + // switch picks with the prior (vertex-view, NaN) transform. + for (var i = 0; i < this.surfs.length; i++) { + if (this.surfs[i].apply !== undefined) + this.surfs[i].apply(this.active); + } + // Re-fire hover and click indicators so the displayed values + // reflect the newly-active dataview at the same screen positions + // (see _lastHoverEvent / _lastPickEvt cached by the handlers). + if (this._lastHoverEvent) { + $("#brain").trigger($.Event('mousemove', { + clientX: this._lastHoverEvent.clientX, + clientY: this._lastHoverEvent.clientY, + })); + } + if (this._lastPickEvt) { + this.pick({x: this._lastPickEvt.x, y: this._lastPickEvt.y}); + } }.bind(this)); // var surf, scene, grid = grid_shapes[this.active.data.length]; @@ -590,6 +696,7 @@ var mriview = (function(module) { $("#dataname").text(name); $("#dataopts").show(); } + this.fitDataname(); this.schedule(); this.loaded.resolve(); @@ -645,40 +752,64 @@ var mriview = (function(module) { $("#brain").on('mousemove', function (event) { - // only implemented for 1d volume datasets or vertex datasets - if (this.active.data.length != 1 || this.active.data[0].raw) { + // Cache last mouse position so setData() can recompute the + // hover indicator for the newly-active dataset at the same + // screen coordinate. + this._lastHoverEvent = event; + // Length check first so the short-circuit guards us against + // an empty data array before we read data[0]. Then skip RGB + // (no underlying scalars), then ensure all child buffers + // have populated (the setData refire can briefly precede + // buffer fill). + if ((this.active.data.length !== 1 && this.active.data.length !== 2) || + this.active.data[0].raw || + !module.dataBuffersReady(this.active.data)) { $('#mouseover_value').css('display', 'none') return } // We need to use a different logic if we have a VolumeData or a VertexData object - let value = null; + let values = null; if (this.active.vertex) { - coords = this.getCoords(event) + let coords = this.getCoords(event) if (coords !== -1) { - hemiIdx = (coords.hemi == 'left') ? 0 : 1 - // console.log('hemiIdx: ', hemiIdx) - vertex = coords.vertex - // console.log('vertex: ', vertex) - // Now we need to map back with the index map - // First figure out the subject, then get the index map - subject = this.active.data[0].subject - indexMap = subjects[subject].hemis[coords.hemi].indexMap - // console.log("vertex before: " + vertex); - vertex = indexMap[vertex] - // console.log("vertex after: " + vertex); - // Now access the data - value = this.active.data[0].verts[0][hemiIdx].array[vertex] + let hemiIdx = (coords.hemi == 'left') ? 0 : 1 + // Map the picked vertex through the subject's indexMap. + // dim1 and dim2 share subject for 2D views (server-side). + let subject = this.active.data[0].subject + let indexMap = subjects[subject].hemis[coords.hemi].indexMap + let vertex = indexMap[coords.vertex] + // Now access the data for each channel (1 for 1D, 2 for 2D) + values = this.active.data.map(function (d) { + return d.verts[0][hemiIdx].array[vertex] + }) } } else { - // Get index of the mosaic to get the value - mouse_index = this.getMouseIndex(event) + // Volume branch. For 2D views we reuse data[0]'s mouse_index + // across all dims; that is safe iff every dim shares the + // same shape/mosaic/numslices. Python-side Volume2D enforces + // matching xfmname → matching geometry. Client-side + // dataset.makeFrom (mriview.js:283) can pair arbitrary 1D + // volumes; if their geometries diverge, hide the readout + // rather than display a wrong-but-plausible value. + if (!module.volumeGeomsMatch(this.active)) { + $('#mouseover_value').css('display', 'none') + return + } + let mouse_index = this.getMouseIndex(event) if (mouse_index !== -1) { - value = this.active.data[0].textures[0].image.data[mouse_index] + values = this.active.data.map(function (d) { + return d.textures[0].image.data[mouse_index] + }) } } - // console.log("Value on mouseover: " + value); - if (value !== null) { - $('#mouseover_value').text(parseFloat(value).toPrecision(3)) + if (values !== null) { + let formatted = values.map(function (v) { + return parseFloat(v).toPrecision(3) + }).join(', ') + if (values.length > 1) { + formatted = '(' + formatted + ')' + } + $('#mouseover_value').text(formatted) $('#mouseover_value').css('display', 'block') } else { $('#mouseover_value').css('display', 'none') @@ -817,44 +948,63 @@ var mriview = (function(module) { } 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. + this._lastPickEvt = {x: evt.x, y: evt.y}; let coords for (var i = 0; i < this.surfs.length; i++) { if (this.surfs[i].pick) coords = this.surfs[i].pick(this.renderer, this.camera, evt.x, evt.y); } // set the picked value display - // only implemented for 1d volume datasets or vertex datasets - if (this.active.data.length != 1 || this.active.data[0].raw) { + // Length check first so we don't index data[0] on an empty array. + // Skip RGB, then ensure all child buffers have populated. + if ((this.active.data.length !== 1 && this.active.data.length !== 2) || + this.active.data[0].raw || + !module.dataBuffersReady(this.active.data)) { $('#picked_value').css('display', 'none') return } // We need to use a different logic if we have a VolumeData or a VertexData object - let value = null; + let values = null; if (this.active.vertex) { if (coords !== -1) { - hemiIdx = (coords.hemi == 'left') ? 0 : 1 - vertex = coords.vertex - // Now we need to map back with the index map - // First figure out the subject, then get the index map - subject = this.active.data[0].subject - indexMap = subjects[subject].hemis[coords.hemi].indexMap - vertex = indexMap[vertex] - // Now access the data - value = this.active.data[0].verts[0][hemiIdx].array[vertex] + let hemiIdx = (coords.hemi == 'left') ? 0 : 1 + // Map the picked vertex through the subject's indexMap. + // dim1 and dim2 share subject for 2D views (server-side). + let subject = this.active.data[0].subject + let indexMap = subjects[subject].hemis[coords.hemi].indexMap + let vertex = indexMap[coords.vertex] + // Now access the data for each channel (1 for 1D, 2 for 2D) + values = this.active.data.map(function (d) { + return d.verts[0][hemiIdx].array[vertex] + }) } } else { if (coords !== -1) { - // Get index of the mosaic to get the value + // Volume branch. See the matching note in the mousemove handler + // for why we hide on geometry mismatch. + if (!module.volumeGeomsMatch(this.active)) { + $('#picked_value').css('display', 'none') + return + } let mouse_index = this.xyxToI(coords.voxel.x, coords.voxel.y, coords.voxel.z) if (mouse_index !== -1) { - value = this.active.data[0].textures[0].image.data[mouse_index] + values = this.active.data.map(function (d) { + return d.textures[0].image.data[mouse_index] + }) } } } - console.log("Value on click: " + value); - if (value !== null) { - $('#picked_value').text(parseFloat(value).toPrecision(3)) + if (values !== null) { + let formatted = values.map(function (v) { + return parseFloat(v).toPrecision(3) + }).join(', ') + if (values.length > 1) { + formatted = '(' + formatted + ')' + } + $('#picked_value').text(formatted) $('#picked_value').css('display', 'block') } else { $('#picked_value').css('display', 'none') @@ -1000,7 +1150,7 @@ var mriview = (function(module) { var _bound = false; module.Viewer.prototype._bindUI = function() { $(window).scrollTop(0); - $(window).resize(function() { this.resize(); }.bind(this)); + $(window).resize(function() { this.resize(); this.fitDataname(); }.bind(this)); this.canvas.resize(function() { this.resize(); }.bind(this)); var cam_ui = this.ui.addFolder("camera", true); diff --git a/cortex/webgl/resources/js/shaderlib.js b/cortex/webgl/resources/js/shaderlib.js index 0ec91c8e5..1c40ed603 100644 --- a/cortex/webgl/resources/js/shaderlib.js +++ b/cortex/webgl/resources/js/shaderlib.js @@ -755,6 +755,8 @@ var Shaderlib = (function() { "vColor = texture2D(colormap, cuv);", // NaN mask: WebGL drivers sanitize NaN in vertex attributes, // so we detect NaN in JavaScript and pass a mask (0=NaN, 1=valid). + // For 2D vertex views the JS layer combines per-dim masks + // before dispatch, so a single shared attribute is enough. "if (nanmask < 0.5) vColor = vec4(0.);", "#endif", diff --git a/examples/datasets/plot_data_with_alpha.py b/examples/datasets/plot_data_with_alpha.py new file mode 100644 index 000000000..b4df461e0 --- /dev/null +++ b/examples/datasets/plot_data_with_alpha.py @@ -0,0 +1,195 @@ +""" +========================== +Plot Data with Alpha Values +========================== + +It is often useful to plot a primary map (the "data" you are interested in) +masked or attenuated by a secondary map (a "confidence" or "weight" +map). For example, an encoding model's tuning maps are +interpretable where the model fits well, so one could plot +tuning maps with opacity proportional to the per-voxel/per-vertex prediction +accuracy. Voxels/vertices where the model fits poorly fade into the +gray curvature underlay; voxels/vertices where the model fits well are +fully opaque. + +pycortex supports two patterns for this: + +1. **Scalar data with an alpha map** -- use :class:`Volume2D` / + :class:`Vertex2D` with a 2D colormap whose second axis encodes alpha + (the colormap LUT itself goes from transparent to opaque along + ``dim2``). No extra arithmetic is needed; the alpha channel is + composited correctly by both the matplotlib (``quickshow``) and the + WebGL renderers. + +2. **RGB data with an alpha map** -- pass ``alpha=`` directly to + :class:`VolumeRGB` / :class:`VertexRGB`. The alpha can be any + per-voxel/per-vertex array (or a :class:`Volume`/:class:`Vertex`) + in ``[0, 1]``. + +Below, we illustrate both patterns with a synthetic "model accuracy" +mask -- a 3D Gaussian bump for the volume case and a vertex-distance +falloff for the surface case -- so cortex near the bump centre stays +opaque while the periphery fades into the curvature. +""" + +import cortex +import cortex.polyutils +import numpy as np +import matplotlib.pyplot as plt + +subject = "S1" +xfm = "fullhead" + +# %% +# Synthesize the data and alpha maps +# ---------------------------------- +# +# All four patterns below reuse the same synthetic inputs, so we set +# everything up once here and only show the *plotting* call in each +# pattern's cell. In a real analysis these would come from your model +# fits (e.g. ``data`` = regression coefficients, ``accuracy`` = +# cross-validated prediction r^2). + +# --- Volumetric data + alpha ------------------------------------------------- +# Signed gradient across the brain stands in for a regression coefficient +# or tuning preference. +zz, yy, xx = np.mgrid[0:31, 0:100, 0:100] +data_vol = (xx - 50) / 50.0 # range ~ [-1, 1] + +# A 3D Gaussian bump centered in the volume stands in for a per-voxel +# model accuracy / prediction r in [0, 1]. +center = np.array([15, 50, 50]) +sigma = 25.0 +dist2 = (zz - center[0]) ** 2 + (yy - center[1]) ** 2 + (xx - center[2]) ** 2 +accuracy_vol = np.exp(-dist2 / (2 * sigma**2)) # in [0, 1] + +# RGB volumetric channels: anatomical x/y/z normalized to [0, 1]. Three +# smoothly-varying volumetric channels stand in for, e.g., three latent +# RGB tuning axes from a model. +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) data + alpha ------------------------------------------- +# Encode by *spatial coordinate*, not vertex index: vertex indices on the +# cortical surface are not arranged by spatial neighborhood, so a +# vertex-index ramp would render as visual noise. +surfs = [cortex.polyutils.Surface(*d) for d in cortex.db.get_surf(subject, "fiducial")] +num_verts = [s.pts.shape[0] for s in surfs] +total_verts = sum(num_verts) +pts = np.vstack([surfs[0].pts, surfs[1].pts]) # (total_verts, 3) + +# Scalar surface data: anterior-posterior gradient (anatomical y), +# centered at zero so a diverging colormap reads naturally. +y_centered = pts[:, 1] - pts[:, 1].mean() +data_vtx = y_centered / np.abs(y_centered).max() # in [-1, 1] + +# RGB surface channels: anatomical x/y/z normalized to [0, 1]. +xyz_norm = (pts - pts.min(axis=0)) / (pts.max(axis=0) - pts.min(axis=0)) + + +# Surface alpha: a soft bump centered at a particular vertex in each hemi. +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), + ] +) + +# %% +# Pattern 1a: scalar Volume + alpha via Volume2D + 2D alpha colormap +# ------------------------------------------------------------------ +# +# The 2D colormap ``"RdBu_r_alpha"`` maps ``(data, alpha) -> RGBA``: along +# the first axis, blue-white-red diverging; along the second axis, +# transparent-to-opaque. So passing the data as ``dim1`` and the accuracy +# as ``dim2`` yields exactly "diverging colormap, opacity = accuracy". +# +# Other 2D alpha colormaps shipped with pycortex include ``"fire_alpha"`` +# (sequential, perceptually uniform), ``"PU_RdBu_covar_alpha"`` (diverging, +# perceptually uniform), ``"plasma_alpha"``, and ``"autumn_alpha"``. +v2d = cortex.Volume2D( + data_vol, + accuracy_vol, + subject, + xfm, + cmap="RdBu_r_alpha", + vmin=-1, + vmax=1, # range for the data (dim1) + vmin2=0, + vmax2=1, # range for the alpha (dim2) +) +cortex.quickshow(v2d, with_colorbar=True, with_curvature=True) +plt.suptitle("Volume2D + RdBu_r_alpha: data masked by 'accuracy'") +plt.show() + +# %% +# Pattern 1b: scalar Vertex + alpha via Vertex2D + 2D alpha colormap +# ------------------------------------------------------------------ +# +# Same idea on the surface. +vtx2d = cortex.Vertex2D( + data_vtx, + accuracy_vtx, + subject, + cmap="RdBu_r_alpha", + vmin=-1, + vmax=1, + vmin2=0, + vmax2=1, +) +cortex.quickshow(vtx2d, with_colorbar=True, with_curvature=True) +plt.suptitle("Vertex2D + RdBu_r_alpha: data masked by 'accuracy'") +plt.show() + +# %% +# Pattern 2a: RGB Volume + alpha via VolumeRGB(alpha=...) +# ------------------------------------------------------- +# +# When the "data" is itself three independent channels, use +# :class:`VolumeRGB` and pass the accuracy as the ``alpha=`` argument. +red = cortex.Volume(red_vol, subject, xfm, vmin=0, vmax=1) +green = cortex.Volume(green_vol, subject, xfm, vmin=0, vmax=1) +blue = cortex.Volume(blue_vol, subject, xfm, vmin=0, vmax=1) +alpha_vol = cortex.Volume(accuracy_vol, subject, xfm, vmin=0, vmax=1) + +vrgb = cortex.VolumeRGB(red, green, blue, subject, alpha=alpha_vol) +cortex.quickshow(vrgb, with_colorbar=False, with_curvature=True) +plt.suptitle("VolumeRGB(alpha=accuracy): RGB tuning masked by 'accuracy'") +plt.show() + +# %% +# Pattern 2b: RGB Vertex + alpha via VertexRGB(alpha=...) +# ------------------------------------------------------- +# +# Same idea on the surface. +red_v = cortex.Vertex(xyz_norm[:, 0], subject, vmin=0, vmax=1) +green_v = cortex.Vertex(xyz_norm[:, 1], subject, vmin=0, vmax=1) +blue_v = cortex.Vertex(xyz_norm[:, 2], subject, vmin=0, vmax=1) +alpha_v = cortex.Vertex(accuracy_vtx, subject, vmin=0, vmax=1) + +vrgb_vtx = cortex.VertexRGB(red_v, green_v, blue_v, subject, alpha=alpha_v) +cortex.quickshow(vrgb_vtx, with_colorbar=False, with_curvature=True) +plt.suptitle("VertexRGB(alpha=accuracy): RGB channels masked by 'accuracy'") +plt.show() + +# %% +# Notes +# ----- +# +# * Both patterns produce the same composite formula at the pixel level: +# ``out = alpha * data + (1 - alpha) * curvature_underlay``. Choose +# based on what the "data" is: scalar (use Pattern 1) or RGB (use +# Pattern 2). +# * The same objects work in the WebGL viewer: +# ``cortex.webgl.show(v2d)`` etc.; opacity is honored identically. +# * The deprecated ``Vertex.blend_curvature(alpha)`` helper produced a +# pre-blended :class:`VertexRGB` that lost ``cmap``/``vmin``/``vmax`` +# editability. The Pattern 1 :class:`Vertex2D` route above is the +# recommended replacement: it keeps the colormap parameters editable +# on the resulting object and renders identically. diff --git a/filestore/colormaps/BuWtRd.png b/filestore/colormaps/BuWtRd.png index 676f80734..7ae596af6 100644 Binary files a/filestore/colormaps/BuWtRd.png and b/filestore/colormaps/BuWtRd.png differ diff --git a/filestore/colormaps/BuWtRd_alpha.png b/filestore/colormaps/BuWtRd_alpha.png index 08d2f8d05..2101a2db5 100644 Binary files a/filestore/colormaps/BuWtRd_alpha.png and b/filestore/colormaps/BuWtRd_alpha.png differ diff --git a/pyproject.toml b/pyproject.toml index e65c04616..6acd93aad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [build-system] # Minimum requirements for the build system to execute, according to PEP518 # specification. -requires = ["setuptools>=64", "setuptools-scm>=8", "build", "numpy", "cython", "wheel"] +requires = ["setuptools>=64", "setuptools-scm>=8", "build", "numpy>=1.13.0", "cython", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -20,6 +20,7 @@ test = [ "nbformat>=5.10.4", "pytest", "pytest-cov", + "pytest-timeout", ] [project.optional-dependencies] diff --git a/pytest.ini b/pytest.ini index 84083f96f..25191cf6d 100644 --- a/pytest.ini +++ b/pytest.ini @@ -4,3 +4,8 @@ testpaths = addopts = -r a -v +# Per-test timeout (in seconds) so a single hung headless browser session +# does not consume the entire CI budget. Individual tests can override with +# @pytest.mark.timeout(N). Requires the optional ``pytest-timeout`` +# plugin; if not installed, this key is silently ignored. +timeout = 240 diff --git a/requirements.txt b/requirements.txt index b05b6dd0b..56fa497c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,6 @@ setuptools future -numpy +numpy>=1.13.0 scipy tornado>=4.3 shapely