Pure-python surf2surf matrix (direct nnfr construction)#2
Conversation
…on output (gallantlab#624) * Initial plan * Fix Inkscape version detection to ignore diagnostic output before version line Agent-Logs-Url: https://github.com/gallantlab/pycortex/sessions/88b00075-2675-408a-9b57-c8fb6090cd91 Co-authored-by: mvdoc <6150554+mvdoc@users.noreply.github.com> * Add unit tests for inkscape_version() in testing_utils Agent-Logs-Url: https://github.com/gallantlab/pycortex/sessions/7b567817-a423-4884-b853-6877f364168d Co-authored-by: mvdoc <6150554+mvdoc@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mvdoc <6150554+mvdoc@users.noreply.github.com>
…ntlab#626) (gallantlab#627) * FIX Vertex objects without NaNs rendering as fully transparent in webgl viewer PR gallantlab#612 added a `nanmask` vertex attribute that the surface_vertex shader unconditionally checks: `if (nanmask < 0.5) vColor = vec4(0.)`. The mask was only built and dispatched when the data actually contained NaNs, so NaN-free Vertex objects fell back to the empty-default attribute (all 0s), causing every vertex to be treated as NaN and rendered fully transparent. Always build the mask (filled with 1s when no NaNs are present). This also fixes a latent index-mismatch bug for movies where some frames had NaNs and others didn't — `nanmasks[fframe]` is now aligned with `verts[fframe]`. Fixes gallantlab#626. * TST add headless regression tests for Vertex NaN-mask rendering (gallantlab#612, gallantlab#626) Render NaN-free and partially-NaN Vertex data with cmap='Reds' against the grayscale curvature underlay, then count red-dominant pixels in the output PNG. The NaN-free case verifies the gallantlab#626 fix (would catch fully-transparent fall-through). The half-NaN case verifies the gallantlab#612 behavior is preserved (NaN positions still render transparent, non-NaN positions still render). Verified locally: the new test fails without the dataset.js fix (0 red pixels) and passes with it. * PERF combine vertex remap and NaN-mask construction into single pass Per Gemini code review on gallantlab#627: my prior refactor split the work into a remap pass plus a separate mask-building pass. Combine them so each vertex is touched once. * FIX Vertex2D NaN-mask clobber between dimensions Codex review on gallantlab#627 surfaced that the surface_vertex shader had a single nanmask attribute shared by both dims of a 2D vertex view. With the always-build-mask change, dim 1's all-ones mask would overwrite dim 0's NaN mask whenever dim 1 was NaN-free, leaving NaN vertices visible (rendered with the JS-side 0 fallback rather than discarded). In practice this also broke fully-valid Vertex2D rendering — the dispatch ordering left the surface fully transparent. Give each dimension its own attribute: dim 0 -> nanmask, dim 1 -> nanmask2. The shader discards a vertex if either mask is below 0.5. Includes a headless regression test that compares NaN-in-dim-0-only, NaN-in-both-dims, and fully-valid renders. Verified locally that the test fails without this fix and passes with it. * TST simplify Vertex2D NaN regression test for CI stability The third assertion (n_d0_nan ≈ n_both_nan) was too strict: on CI's WebGL driver, n_d0_nan came out 0 instead of ~half, while n_both_nan was ~half. Both values discriminate the bug but the equivalence check between them was driver-dependent. Drop the third assertion and the both-NaN render entirely. Also retry getImage until rendering stabilizes — Vertex2D's first render returned 0 colored pixels in some local runs even though the data was fully populated. The retry doesn't mask the actual bug (without the fix, dim-0-NaN-only renders ~n_full colored pixels rather than ~half, which the second assertion still catches). * TST drop flaky Vertex2D NaN regression test The test exposed a real timing race condition in headless rendering: the per-dim attribute dispatches don't always land before the first render, leading to nanmask2 reading as 0 (default) and discarding all vertices. The test passed locally on initial runs but failed inconsistently across Python versions in CI (3.10 and 3.12 fail, others pass) — and even with retry+redraw logic, local runs reproduce the same flakiness. The Codex-flagged fix (per-dim nanmask2 attribute) is logically correct and stays in place. The 1D Vertex regression tests still guard the always-build-mask change. A future, more robust harness that synchronizes on attribute-dispatch completion would be the right place to land a Vertex2D regression test. * FIX Vertex2D NaN-mask via combined mask, not per-dim attributes The previous attempt used a separate nanmask2 attribute for dim 1, but the Three.js attribute binding for that second mask was unreliable — some renders showed all vertices as NaN/transparent because nanmask2 read as 0 (the empty placeholder default). Combine the masks in JavaScript instead: DataView.setFrame walks both dims' nanmasks for the current frame, ANDs them per-vertex, and dispatches a single shared nanmask attribute. The shader stays at one attribute, which Three.js handles reliably (same path that already works for 1D Vertex). Verified locally with the user's reproducer: vtx1 = cortex.Vertex.random("S1") vtx2 = cortex.Vertex.random("S1") cortex.webgl.show(cortex.Vertex2D(vtx1, vtx2)) renders with data (was previously fully transparent). * Remove .claude/ files mistakenly included in previous commit * FIX DataView.loaded never resolves for multi-data dataviews with single frame The progress handler in DataView's constructor used the same variable name `i` for both the outer and inner for loops. The inner `var i = 0; ...` shadowed the outer one, leaving outer `i` at `allready.length` after the inner loop, so the outer for loop exited after a single iteration. For Vertex2D (data.length === 2) with a single frame: - d0.loaded fires its first .notify(1) - $.when forwards to .progress; outer i=0; inner loop sets test from allready[0] alone (since allready[1] is still false from the first iteration only setting allready[0]) - inner loop terminates with i = allready.length = 2 - outer loop exits because outer i is now 2 >= data.length - allready[1] is never set; this.loaded.resolve() never fires - active.set() never runs, so data attributes stay empty and the brain renders fully transparent Rename the inner loop counter so the outer index isn't clobbered. Verified with .claude/launch_viewer.py + Claude Preview: the brain now renders the 2D colormap data instead of being fully transparent for the user's reproducer: cortex.webgl.show(cortex.Vertex2D( cortex.Vertex.random("S1"), cortex.Vertex.random("S1"))) * Remove .claude/ helper files from tracking
…ab#630) * FIX dataset dropdown auto-resizes to fit long dataset names (gallantlab#628) Replace the hardcoded 400px max-width on #dataopts and ul#datasets with width: max-content (capped at 90vw) and add white-space: nowrap to each list item. Long dataset names now display without ugly wrapping or truncation, while short names still render compactly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * FIX shrink #dataname font when long names overflow the panel (gallantlab#628) Even with the dropdown panel widening to fit long dataset names, the 24pt heading text could still exceed the 90vw panel cap and get clipped. Add fitDataname() to scale the heading font-size down (24pt → min 14px) when the rendered width exceeds 90vw, and call it after every #dataname update plus on window resize. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add issue gallantlab#628 screenshot for PR description (transient) * Remove transient screenshot (referenced via raw URL in PR) * FIX remove #dataopts transition so panel resizes instantaneously (gallantlab#628) The 1s transition-delay + .3s all-property transition on #dataopts caused the dropdown panel to lag noticeably when its width changed (e.g. when selecting a dataset with a different name length). Removing the rule makes the panel snap to its new size immediately. The dataset list slide toggle is unaffected — that animation is driven by jQuery, not CSS. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address Gemini review: explicit overflow-x and ellipsis fallback (gallantlab#628) - ul#datasets: add explicit overflow-x: hidden so the implicit auto promotion from overflow-y: auto cannot produce a horizontal scrollbar if the parent ever has to cap the list width. - ul#datasets li: add overflow: hidden and text-overflow: ellipsis so pathologically long names (e.g. on very narrow viewports) degrade gracefully with an ellipsis instead of overflowing the row. Verified at 800px viewport (all items fit, no ellipsis) and 350px viewport (longest two names show ellipsis as expected). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ab#633) * CI: cancel stale PR runs and improve Playwright cache reuse Add a concurrency group to all four workflows so that pushing a new commit to a PR cancels the in-progress run for the previous commit, avoiding wasted CI minutes. Pushes to main are unaffected (cancel-in-progress is gated on pull_request events) so every merged commit still gets a run. Also add restore-keys to the Playwright browser cache so that a change to pyproject.toml no longer forces a full Chromium re-download — the prior cache is reused as a starting point. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: empty commit to verify CI concurrency cancels prior run Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…#632) * FIX VertexRGB/VolumeRGB alpha attenuation in WebGL viewer (gallantlab#631) The WebGL fragment shader composites with a premultiplied-alpha "over" formula (gl_FragColor = vColor + (1-α)·bg), but VertexRGB.vertices / VolumeRGB.volume ship raw, non-premultiplied RGB bytes. With α<1 this caused vertices to render brighter / clipped toward white instead of blending toward the curvature underlay. Premultiply RGB by α only at the WebGL serialization step (Package.__init__), so the bytes shipped to the browser satisfy the shader's invariant. The .vertices/.volume properties stay non-premultiplied so the matplotlib (quickshow) path keeps using matplotlib's straight-alpha imshow compositor unchanged -- both viewers now produce the same composite α·rgb + (1-α)·bg. Also deprecate BrainData.blend_curvature: it was a hack to mimic transparency for Vertex objects, no longer needed now that per-vertex α works directly in both viewers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * FIX restrict alpha premultiplication to VertexRGB only VolumeRGB ships through the PNG texture path: dataset.js:335-338 sets `tex.premultiplyAlpha = true` for raw volume textures, which makes WebGL apply UNPACK_PREMULTIPLY_ALPHA_WEBGL on upload. Premultiplying again on the Python side double-attenuates partial-alpha VolumeRGB, rendering it too dark. VertexRGB has no equivalent texture-upload step (data is shipped as a vertex attribute via BufferAttribute) so it still needs Python-side premultiplication. Add a headless playwright regression test (uniform red, α=0.5) that discriminates correct single-premultiplication (~150 median R) from double-premultiplication (~100 median R), and update the unit test to assert VolumeRGB Package output stays straight-alpha. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Update blend_curvature deprecation message to point at Vertex2D/Volume2D The pycortex-canonical way to attach a per-vertex/voxel transparency map to scalar data is Vertex2D/Volume2D with a 2D colormap whose second axis encodes alpha (e.g. 'fire_alpha', 'PU_RdBu_covar_alpha'). Pointing deprecated callers there is the right migration; VertexRGB/VolumeRGB with alpha= is still mentioned as the route for users who already have raw RGB data. Includes a side-by-side example in the docstring showing the new Vertex2D call so the migration path is obvious. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Revert formatter-only churn in braindata.py from prior commit The previous commit (04a57f6, "Update blend_curvature deprecation message to point at Vertex2D/Volume2D") inadvertently reformatted the entire braindata.py file via a PostToolUse hook, producing 283 lines of mostly whitespace/quote-style churn around a ~30-line message change. This commit restores the surrounding code to its pre-noise formatting while keeping the intended deprecation-message update intact, so the PR diff focuses on the actual change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add Datasets gallery example: Plot Data with Alpha Values Demonstrates the two pycortex idioms for plotting a primary map attenuated by a secondary map (e.g. tuning maps masked by per-voxel prediction accuracy): 1. Scalar data: Volume2D / Vertex2D with a 2D alpha-encoding colormap (RdBu_r_alpha, fire_alpha, etc.). Recommended for the common "scalar value with confidence" case -- keeps cmap/vmin/vmax editable on the resulting object. 2. RGB data: VolumeRGB / VertexRGB with the alpha= constructor argument. Recommended when the underlying data is already three independent channels. Both patterns yield the same composite formula at the pixel level: out = alpha * data + (1 - alpha) * curvature_underlay. The example includes a synthetic Gaussian-bump "accuracy" mask so the alpha attenuation is visually obvious in the rendered flatmaps. This complements the deprecation of blend_curvature -- the Vertex2D route in this example is the recommended replacement. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Pass with_curvature=True in alpha-blending example so the underlay is visible quickshow defaults with_curvature=False, which makes the alpha attenuation in the example fade to the matplotlib canvas background (white) instead of to the curvature gray. That defeats the purpose of the example -- the whole point is showing how low-alpha regions blend toward the curvature underlay (matching what the WebGL viewer does unconditionally). Pass with_curvature=True to all four quickshow calls so the rendered panels show the gyri/sulci pattern under low-alpha cortex. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Use sphinx-gallery cell separators in alpha-blending example Replace the decorative "# --- / # Pattern Xx ... / # ---" header bars with sphinx-gallery's canonical "# %%" cell separators (followed by a proper rST section heading). This makes each Pattern its own gallery cell, so each plt.show() figure renders as a full-width sphx-glr-single-img instead of being grouped 2x2 as sphx-glr-multi-img. The rendered page now shows one flatmap per row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Use spatial-coordinate RGB in VertexRGB alpha example Per-vertex random RGB renders as salt-and-pepper noise on the flatmap because vertex indices are not arranged by spatial neighborhood, making the panel uninterpretable. Encode each channel as a normalized anatomical coordinate (R=x, G=y, B=z) so the three channels vary smoothly across cortex and the result is a meaningful position map attenuated by the per-vertex 'accuracy' alpha. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Use spatial-coordinate data in Vertex2D alpha example Panel 2 (Vertex2D) used `np.linspace(-1, 1, num_verts) + noise` as its scalar data, which is a ramp over vertex *index* -- and vertex indices on a cortical surface are not arranged by spatial neighborhood, so the result rendered as visual noise. Replace with a smooth anterior-posterior spatial gradient (anatomical y-coordinate, centred at zero) so the diverging RdBu_r colormap reads naturally. Consolidate the `pts`/`xyz_norm` computation in Pattern 1b and reuse it in Pattern 2b instead of recomputing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Reorganize alpha example: data setup in one cell, plotting in others Refactor the example so the first cell defines all synthetic inputs (volumetric + surface data, volumetric + surface alpha maps, RGB channels for each) once, and each subsequent Pattern cell shows only the plotting call -- a Volume2D / Vertex2D / VolumeRGB / VertexRGB constructor plus a quickshow(). This keeps the four pattern cells terse and focused on the API differences, instead of repeating data setup in each one. The rendered gallery page still shows four full-width flatmaps, one per row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Tighten prose in alpha-blending example Minor wording polish in the module docstring and a small trim in the Pattern 1a comment block. No behavioral changes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * FIX premultiply alpha on WebGL colormap textures (companion to gallantlab#631) The 2D dataview path in WebGL ships dim1 and dim2 as separate scalar maps and does the colormap LUT lookup on the GPU at fragment time (`texture2D(colormap, vec2(dim1_norm, dim2_norm))`), so it never goes through the VertexRGB/VolumeRGB premultiplication branch in `Package.__init__`. The colormap texture itself was loaded with `premultiplyAlpha = false` (mriview.js:24), but the surface fragment shader (shaderlib.js:851) composites with the premultiplied-over formula `gl_FragColor = vColor + (1 - vColor.a) * cColor`. So for any alpha-bearing colormap (RdBu_r_alpha, fire_alpha, PU_RdBu_covar_alpha, plasma_alpha, autumn_alpha, ...), the shader sampled straight-alpha RGBA from the LUT and applied a premultiplied composite, producing `R + (1-α)·bg` instead of `α·R + (1-α)·bg`. At α<1 this clipped toward white -- the same class of bug as gallantlab#631 but on the colormap texture path instead of the data texture path. Setting `tex.premultiplyAlpha = true` on the colormap textures makes WebGL's UNPACK_PREMULTIPLY_ALPHA_WEBGL hook premultiply the LUT once on upload, which is exactly the invariant the shader's composite formula expects. Non-alpha colormaps have α=255 everywhere so this is a no-op for them. This is the missing piece for the Vertex2D / Volume2D path to render correctly in WebGL (the new gallery example `examples/datasets/plot_data_with_alpha.py` recommends Vertex2D as the replacement for the deprecated `blend_curvature`, but without this fix that path renders incorrectly in WebGL). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * TST add headless regression for Vertex2D alpha cmap-texture composite Pairs with 0eafd8c (mriview.js cmap premultiplyAlpha = true). The 2D dataview path bypasses Package.__init__'s premultiplication branch because Vertex2D.uniques() yields scalar dim1/dim2 maps and the LUT lookup happens on the GPU in the vertex shader; the cmap *texture* itself has to be uploaded premultiplied for the shader's premultiplied- over composite to produce α·rgb + (1-α)·bg correctly. The test renders Vertex2D(data=+1, alpha=0.5, cmap=RdBu_r_alpha) on S1's inflated lateral_pivot view and reads the median R intensity of the red-dominant brain region: - correct (premultiplied LUT): median R ≈ 93 - buggy (un-premultiplied LUT): median R ≈ 129 Threshold at 115 sits between the distributions. α=0 doesn't catch this bug because most alpha colormaps store (0,0,0,0) at the transparent end of the LUT, so neither path produces foreground there. α=0.5 maximizes the divergence in the brain region. The cmap <img> elements decode asynchronously in headless Chromium; if the renderer's first draw fires before the image has decoded, the LUT texture stays at the 1×1 default and the data layer renders as gray (channels collapse to R==G==B). The test retries _set_view + getImage up to 6 times waiting for a colored frame, and skips with a specific message if the cmap never binds — so a Chrome timing race produces a skip rather than a false regression. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Address review feedback on alpha premultiplication code - cortex/webgl/data.py: drop the redundant `encdata.copy()` (the prior `astype(np.uint8)` already returns a fresh array) and the redundant `.astype(np.uint8)` cast on the rounded result (assignment to a uint8 slice handles the cast). Net effect identical; one fewer full copy of the RGBA array per VertexRGB serialization. - cortex/tests/test_webgl_data.py: switch the volume.mosaic spy from a hand-rolled try/finally rebind to `unittest.mock.patch.object` with `side_effect=spy_mosaic`. mock.patch handles restoration cleanly even if the patched call raises, and matches the more idiomatic pytest pattern. Also flatten `captured` from a dict-of-list to a plain list now that there's only one bucket. Per gemini-code-assist review on PR gallantlab#632. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * TST add manual quickshow-vs-webgl visual comparison across all dataviews Skipped by default (run with RUN_VISUAL_COMPARISON=1). Renders all six dataview types (Volume, Vertex, Volume2D, Vertex2D, VolumeRGB, VertexRGB) through both quickshow and the headless WebGL flatmap path and assembles a 6x2 comparison grid for manual A/B review. Volume2D / Vertex2D exercise the 2D-alpha cmap path; VolumeRGB / VertexRGB exercise the alpha= kwarg; plain Volume / Vertex serve as the no-alpha baseline. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lab#634) (gallantlab#635) * FIX WebGL viewer hover/click value readout for Vertex2D / Volume2D (gallantlab#634) The hover and click handlers bailed out for any dataview with data.length != 1, hiding both indicators for 2D views even though both channels arrive intact (Dataview2D.uniques yields dim1 and dim2 as separate Vertex/Volume entries). Extend the handlers to read both channels and format as "(v1, v2)". Volume2D picking was additionally broken: dataview.xfm for 2D views is [xfm_dim1, xfm_dim2], so PickPosition.apply spread it as two positional args to Matrix4.set, leaving the picker matrix full of NaN. Mirror the volxfm length check from VolumeData.init and use dim1's transform (both share xfmname server-side). Vertex views set the picker matrix to NaN explicitly so a stale volume xfm can't bleed through and draw voxel-axis markers in the wrong frame (vertex picks aren't voxel picks). For client-side dataset.makeFrom (mriview.js:283), which can pair two arbitrary volume dataviews with divergent shape/mosaic, add a geometry guard to volume hover/click: hide the indicators rather than show a wrong-but-plausible value pulled from data[0]'s coordinate system. Finally, refresh the indicators on dataset switch: cache the last mouse position and pick coords, then re-fire after the new dataview loads (and after surfs[i].apply has updated the picker xfm). Add a dataBuffersReady guard so the handlers stay safe in the brief window between dataview.loaded resolving and child VertexData/VolumeData buffers populating. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * MNT address PR review: safe guard order and explicit let-declarations - Reorder hover/click guards so the array length is checked before data[0].raw, preventing a TypeError when this.active.data is empty (gemini-code-assist high-priority finding on PR gallantlab#635). - Replace implicit globals (coords, hemiIdx, vertex, subject, indexMap, mouse_index) in both handlers with block-scoped let declarations. This was a pre-existing leak, surfaced by the review on the touched blocks. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * FIX volumeGeomsMatch also compares per-dim transforms (codex review gallantlab#635) dataset.makeFrom can pair two 1D volume dataviews that share shape and mosaic but have different transforms (different xfmnames whose target volumes happen to match in size). The hover/click readout reads data[i].textures[0].image.data[mouse_index] for every i, where mouse_index is computed from data[0]'s xfm via the picker. With divergent transforms, dim2's value comes from the wrong voxel and looks plausible — the failure mode the geometry guard exists to prevent. Extend volumeGeomsMatch to also compare element-wise the per-dim transforms when dataview.xfm is the 2D form ([xfm_dim0, xfm_dim1, ...]). For Python-side Volume2D the transforms are equal by construction (matching xfmname), so this is a no-op. For makeFrom with divergent xfms, the indicators now hide instead of misleading. Caller signature changes from (data) to (dataview) since the xfm lives on the DataView, not the per-dim VolumeData. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* TST harden headless tests against CI timeouts The 20-minute job wall in run_tests.yml was getting close to the ceiling (19m58s on a recent run) because every headless test waited a fixed 10s for the WebGL viewer to be "loaded" before driving it. Across ~26 browser sessions in the matrix, that adds up. Replace the ad-hoc sleeps with an adaptive wait inside ``headless_viewer``: poll ``window.viewer.loaded.state()`` (a jQuery Deferred whose resolution marks "CTM mesh downloaded + parsed + setData done") and only yield the handle once it reports "resolved". Tests that previously did ``time.sleep(10)`` right after entering the context manager can drop the sleep entirely; ``save_views.save_3d_views`` skips its own fixed sleep on the headless path for the same reason. Add ``pytest-timeout`` with a 240s default so a single hung browser session fails its own test instead of draining the whole job, and bump the CI ``timeout-minutes`` to 25 to give some headroom for matrix stragglers. https://claude.ai/code/session_01SwRcFj8xoAY3Fv2TG9DXNs * TST capture last response value in _wait_for_viewer_loaded errors Address review feedback on gallantlab#639: when the viewer's .loaded deferred fails to resolve within the timeout, the surfaced RuntimeError previously only showed errors that came back as ``{"error": ...}`` dicts. Pending-state strings, raw nulls, and other unexpected values were dropped on the floor, leaving the operator without a diagnostic when the wait actually does time out. Now the value seen on every poll is recorded in ``last_err`` (with the dict-error special case preserved), so the timeout message reflects what the browser was actually reporting on the way out. https://claude.ai/code/session_01SwRcFj8xoAY3Fv2TG9DXNs --------- Co-authored-by: Claude <noreply@anthropic.com>
…allantlab#637) (gallantlab#638) * FIX VertexData.loaded race causing blank canvas in make_static viewers (gallantlab#637) VertexData.loaded.resolve() previously fired as soon as the .npy download completed -- but the actual push to this.verts happens inside a nested subjects[this.subject].loaded.done(...) callback that waits for the CTM mesh to be parsed. When textures finished downloading before the mesh was ready, the order was: 1. array.loaded.done -> VertexData.loaded.resolve() (this.verts == []) 2. DataView.loaded resolves -> setData's active.loaded.done fires 3. this.active.set() (added in gallantlab#635) -> VertexData.set dispatches {value: this.verts[0]} == {value: undefined} 4. SurfDelegate.setAttribute crashes on event.value[0] The race already existed but was harmless before gallantlab#635, when setData never called this.active.set() on the initial load. Tighten VertexData.loaded so it only resolves once *both* the .npy download finishes AND subjects[this.subject].loaded has resolved (i.e. this.verts has been populated by the progress-queued callbacks above). jQuery dispatches .done callbacks in registration order, so the progress-queued verts.push handlers always fire before the new resolve handler. VolumeData is unaffected -- its loaded.resolve() at dataset.js:360 is in the same img.onload callback as textures.push(tex), so textures are guaranteed populated before resolution. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * FIX DataView.loaded race in multi-data dataviews (gallantlab#637, follow-up) The previous attempt at fixing gallantlab#637 (wrap VertexData.loaded.resolve in subjects.loaded.done) was a no-op for the case that actually hits users: make_static saves Vertex data as 2D arrays of shape (1, nverts), and NParray.update only notifies array.loaded for multi-D arrays -- it never resolves it. So the .done handler at line 484 never fires and adding another wait inside it changes nothing. The real bug is in the DataView.loaded aggregation. Pre-fix: deferred = $.when(this.data[0].loaded, this.data[1].loaded); deferred.progress(function (available) { for (var i = 0; i < this.data.length; i++) { if (available > this.delay && !allready[i]) { allready[i] = true; // <-- marks EVERY i ... if (test) this.loaded.resolve(); } } }) $.when's combined progress callback doesn't tell us which source fired, so the loop flips every allready[i] to true on a single child's notify. That makes DataView.loaded resolve as soon as the FIRST child's verts push lands -- the sibling VertexData's verts is still []. setData's this.active.set() then iterates data[i].set() for both children, and data[1].set() dispatches { value: this.verts[0] } == { value: undefined }, crashing SurfDelegate.setAttribute on event.value[0]. Track per-source by registering on each this.data[idx].loaded individually. allready[idx] is now only set when data[idx] notifies (or resolves, for the 1D path). DataView.loaded only resolves once every child has notified, by which time every child has pushed verts. Revert the VertexData.loaded wrapping introduced in the previous commit since it had no effect for the multi-D path that's actually broken. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Resolve DataView.loaded when this.data is empty (gemini review gallantlab#638) Pre-refactor, $.when() with no deferreds resolved immediately, so an empty this.data would propagate through to this.loaded.resolve(). After the per-source refactor the registration loop skips, allready stays empty, and markReady is never called -- so this.loaded would hang. In practice this.data can't be empty (earlier lines in the constructor, e.g. this.frames = this.data[0].frames, would already crash) but the explicit checkResolve() call after the loop matches the prior contract and removes the footgun. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* TST increase timeout for download_subject test * Reduce timeout to 5 min * TST mock download_subject to avoid network in tests Replaces the real fsaverage download with a fake in-memory tarball and an isolated filestore, so tests no longer race on download bandwidth or time out under concurrent CI runs. Adds coverage for the skip-when-present and download_again=True branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* replace np.in1d with np.isin * remove redundant `reshape` calls `isin` preserves shapes of original array Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
…allantlab#643) The SVG overlay (ROI boundaries, sulci, labels) is re-baked into a GPU texture asynchronously in SVGOverlay.update(): svg.toDataURL() -> Image -> onload -> new THREE.Texture -> dispatch "update", which the viewer commits to uniforms.overlay.value. There was no sequencing guard, so toggling layers in quick succession started several bakes concurrently that could finish out of order. The last bake to *resolve* (not the last one *requested*) won, leaving a stale overlay texture that disagreed with the checkbox state — the intermittent "switches don't take effect / buffering" bug seen across the make_static viewers. Tag each bake with a per-overlay generation id and discard any bake whose generation is no longer current, so only the most recent toggle's texture is ever committed. Also drops a stray console.log that fired on every update. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…allantlab#644) Toggling an overlay layer (ROIs / sulci / labels) re-bakes the overlay into a GPU texture asynchronously; SVGOverlay dispatches "update" with the new texture, and the surface swaps it into uniforms.overlay.value and re-dispatches "update" on itself (mriview_surface.js). But addSurf never wired the surface's "update" to the viewer's redraw, so the swap landed with no schedule() call. The menu schedules a redraw immediately on toggle — which races (and usually beats) the async bake — so the new overlay was not drawn until the next interaction. The switch appeared not to work. Wire surf "update" -> viewer.schedule() in addSurf so the overlay redraws as soon as the rebaked texture is committed. Complements the generation guard in gallantlab#643 (which fixed out-of-order bakes); together a single toggle now deterministically lands on the current switch state. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
… bake (gallantlab#644)" (gallantlab#645) This reverts commit ab156df.
… bake (gallantlab#643)" (gallantlab#646) This reverts commit 2c64b0b.
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7. - [Release notes](https://github.com/actions/checkout/releases) - [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md) - [Commits](actions/checkout@v6...v7) --- updated-dependencies: - dependency-name: actions/checkout dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 6 to 7. - [Release notes](https://github.com/codecov/codecov-action/releases) - [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md) - [Commits](codecov/codecov-action@v6...v7) --- updated-dependencies: - dependency-name: codecov/codecov-action dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
…enter, more sensible for biphasic data (gallantlab#649)
get_mri_surf2surf_matrix previously reverse-engineered freesurfer's mri_surf2surf by warping the coordinate maps, then probing the binary with 40 random test images and solving a per-vertex least-squares problem to recover the sparse transform. This required freesurfer to be installed and called several times, and was nondeterministic in spirit. The mapping freesurfer computes is actually just its nnfr (nearest -neighbor, forward-and-reverse) rule on the spherical registration: each target vertex takes its nearest source vertex, and any source vertex no target selected is folded into its nearest target so no data is lost. We now build that sparse matrix directly from ?h.sphere.reg with two KDTree queries -- no subprocess, no random probing, deterministic. Verified bit-exact (corr=1.0) against mri_surf2surf for individual-subject<->fsaverage and subject<->subject mappings on both hemispheres. surface_type is now vestigial (the correspondence depends only on the spherical registration, identical across a subject's surfaces) but kept for signature compatibility; legacy regression kwargs are accepted with a DeprecationWarning and ignored. The one documented inexact case is the regular icosahedral targets (fsaverage6/5/4/3): many fine vertices sit exactly equidistant between two coarse vertices and freesurfer breaks those ties by internal vertex ordering we do not reproduce (corr ~0.997-0.9999). The difference is numerically tiny and not worth chasing. Adds unit tests (identity, row-normalization, constant preservation, no-source-dropped invariant, a known averaging example, legacy/unknown kwarg handling) plus two freesurfer-gated integration tests that compare against the real mri_surf2surf binary using only the standard fsaverage templates (fsaverage identity, fsaverage->fsaverage6 downsample) -- no individual subject IDs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MwDmHunXqZs1tY5QVGMtgj
|
Reopening against gallantlab/pycortex upstream. |
There was a problem hiding this comment.
Code Review
This pull request introduces several enhancements and bug fixes to pycortex, notably refactoring the surface-to-surface resampling matrix calculation to be geometry-based rather than regression-based, and fixing alpha-blending/compositing issues in the WebGL viewer by premultiplying alpha for VertexRGB and colormap textures. It also improves headless viewer loading synchronization, replaces deprecated np.in1d calls with np.isin, and adds comprehensive regression tests and a new example for plotting data with alpha. One review comment was kept, which suggests defensively checking the split parts of the Inkscape version output to prevent a potential IndexError.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| for line in combined.splitlines(): | ||
| if line.strip().startswith('Inkscape'): | ||
| version = line.split()[1] | ||
| return version |
There was a problem hiding this comment.
If the output of inkscape --version contains a line starting with 'Inkscape' but has no version number or other words (e.g., just the word 'Inkscape'), calling line.split()[1] will raise an IndexError. We should defensively check the length of the split parts before accessing the second element.
| for line in combined.splitlines(): | |
| if line.strip().startswith('Inkscape'): | |
| version = line.split()[1] | |
| return version | |
| for line in combined.splitlines(): | |
| if line.strip().startswith('Inkscape'): | |
| parts = line.split() | |
| if len(parts) > 1: | |
| return parts[1] |
Summary
get_mri_surf2surf_matrixpreviously reverse-engineered FreeSurfer'smri_surf2surf: it warped the coordinate maps through the binary, probed it with 40 random test images, and solved a per-vertex least-squares problem to recover the sparse transform. That required FreeSurfer installed and called several times.The mapping FreeSurfer actually computes is just its nnfr (nearest-neighbor, forward-and-reverse) rule on the spherical registration:
?h.sphere.reg.This PR builds that sparse matrix directly from
?h.sphere.regwith two KDTree queries — no subprocess, no random probing, deterministic.Correctness
Verified against the real
mri_surf2surfbinary:fsaverageand subject ↔ subject, both hemispheres: bit-exact (corr = 1.0, maxdiff ~2e-7).fsaverage6/5/4/3): many fine vertices sit exactly equidistant between two coarse vertices, and FreeSurfer breaks those ties by an internal vertex ordering this implementation does not reproduce (corr ~0.997–0.9999). The numerical difference is tiny (the alternative neighbor is the same distance away) and is documented inline rather than chased.API / compatibility
(n_target, n_source)sparse matrix, applied asmatrix.dot(source_data));database.get_mri_surf2surf_matrixcaching andVertex.to_other_subject()are unaffected.surface_typeis now vestigial (the correspondence depends only on the spherical registration, identical across a subject's surfaces) but kept for signature compatibility.n_neighbors,n_test_images, ...) are accepted with aDeprecationWarningand ignored; unknown kwargs raiseTypeError.scipy.linalg.lstsqimport.Tests
fsaveragetemplates (no individual subject IDs):fsaverage→fsaverageidentity (bit-exact vs binary) andfsaverage→fsaverage6downsample (corr check). They skip whenmri_surf2surfis unavailable.🤖 Generated with Claude Code