From c157bd0ecef3557c3d096631b225a887ae5676fb Mon Sep 17 00:00:00 2001 From: Matteo Visconti di Oleggio Castello Date: Sat, 19 Sep 2026 17:27:38 -0700 Subject: [PATCH 1/5] ENH render Tractogram streamlines in the WebGL viewer Streamlines bypass the mosaic and CTM machinery entirely. Package collects each Tractogram into four little-endian buffers -- points, offsets, colors and group membership -- served by a new TractHandler under /tract/, or written to tracts/*.bin by make_static. Colors arrive finished, since the colormapping happens in Python, so the browser side stays simple. resources/js/tractogram.js builds one THREE.Line in LinePieces mode per tractogram, with a Uint16 or Uint32 index depending on size and a duplicated-vertex fallback where OES_element_index_uint is missing. The controls live in their own #tracts panel under the dataset box rather than in dat.gui, because what a tractogram shows is data: a visibility checkbox and an opacity slider per tractogram, plus a checkbox per bundle when the file has groups. A streamline draws while it belongs to at least one checked group. Three things about Three.js r69 that this had to work around, each of which looked like a rendering bug first: - svgoverlay.js renders a depth pass with scene.overrideMaterial, which the line geometry cannot satisfy; tract objects opt out through userData.skipOverrideMaterial or r69 throws on the missing attributes. - r69 walks the transparent list backwards, so a translucent tractogram needs a large renderDepth to stay behind the surface rather than on top of it. - The material keeps writing depth at every opacity. Without it the streamlines have nothing to depth-test against each other and blend in buffer order, so the bundle last in the geometry paints over those in front and the apparent depth order changes as the opacity slider moves. Streamlines hide as soon as the surface starts to inflate or flatten, since their coordinates only mean anything against the folded surface, and a tractogram alone cannot open a viewer -- Package says so rather than failing obscurely later. Co-Authored-By: Claude Opus 5 --- cortex/tests/test_webgl_tractogram.py | 239 ++++++++++ cortex/webgl/data.py | 138 +++++- cortex/webgl/mixer.html | 6 +- cortex/webgl/resources/css/mriview.css | 95 ++++ cortex/webgl/resources/js/mriview.js | 97 +++- cortex/webgl/resources/js/svgoverlay.js | 13 + cortex/webgl/resources/js/tractogram.js | 598 ++++++++++++++++++++++++ cortex/webgl/static.html | 6 +- cortex/webgl/template.html | 2 + cortex/webgl/view.py | 70 ++- docs/dataset.rst | 2 + 11 files changed, 1248 insertions(+), 18 deletions(-) create mode 100644 cortex/tests/test_webgl_tractogram.py create mode 100644 cortex/webgl/resources/js/tractogram.js diff --git a/cortex/tests/test_webgl_tractogram.py b/cortex/tests/test_webgl_tractogram.py new file mode 100644 index 000000000..466f3962a --- /dev/null +++ b/cortex/tests/test_webgl_tractogram.py @@ -0,0 +1,239 @@ +"""Tests for shipping a `cortex.Tractogram` to the WebGL viewer. + +Covers the python transport (`cortex.webgl.data.Package`, `make_static`) and, +when playwright + Chromium are available, the javascript side +(`resources/js/tractogram.js`). +""" + +import os + +import numpy as np +import pytest + +import cortex +from cortex.webgl.data import Package + +from .testing_utils import has_playwright +from .test_tractogram import _make_tractogram, _overlapping_groups + +subj = "S1" + + +def _vertex(): + """A small Vertex dataview for S1, to accompany the tractogram.""" + pts, _ = cortex.db.get_surf(subj, "fiducial", merge=True) + return cortex.Vertex(np.zeros(pts.shape[0], dtype=np.float32), subj) + + +def _dataset(): + # Overlapping groups + an ungrouped streamline, to exercise the viewer's + # per-group visibility toggles (a streamline is visible iff it is a + # member of >=1 visible group; ungrouped streamlines follow a pseudo + # "(ungrouped)" group). + tract = _make_tractogram( + n_streamlines=8, n_points=15, groups=_overlapping_groups(8) + ) + return cortex.Dataset(overlay=_vertex(), af=tract), tract + + +# --------------------------------------------------------------------------- +# Package: wire format +# --------------------------------------------------------------------------- + + +def test_package_tract_metadata_and_buffers(): + ds, tract = _dataset() + pkg = Package(ds) + + meta = pkg.metadata() + assert "af" in meta["tracts"] + tmeta = meta["tracts"]["af"] + + assert tmeta["subject"] == subj + assert tmeta["n_points"] == tract.n_points + assert tmeta["n_streamlines"] == tract.n_streamlines + assert tmeta["alpha"] == tract.alpha + assert tmeta["linewidth"] == tract.linewidth + assert tmeta["visible"] is True + assert set(tmeta["urls"]) == {"points", "offsets", "colors", "groups"} + assert tmeta["urls"]["points"] == "/tract/af/points/" + + bufs = pkg.tracts["af"] + n, m = tract.n_points, tract.n_streamlines + n_group_entries = sum(len(idx) for idx in tract.groups.values()) + assert len(bufs["points"]) == 12 * n + assert len(bufs["offsets"]) == 4 * (m + 1) + assert len(bufs["colors"]) == 3 * n + assert len(bufs["groups"]) == 4 * n_group_entries + + # The buffers must round-trip as little-endian arrays of the right dtype. + points = np.frombuffer(bufs["points"], dtype=" fewer segments; on the + # indexed path only the index shrinks, so count segments rather + # than vertices), and showAllGroups() must restore it. JSProxy calls + # return the per-client response list, hence the [0]. + full_n_segments = handle.tracts.af.n_segments + assert full_n_segments == n - m + assert set(handle.tracts.af.groupNames()[0]) == { + "first_half", + "second_half", + "(ungrouped)", + } + handle.tracts.af.setGroupVisible("first_half", False) + shrunk_n_segments = handle.tracts.af.n_segments + assert 0 < shrunk_n_segments < full_n_segments + + handle.tracts.af.showAllGroups() + assert handle.tracts.af.n_segments == full_n_segments + + # Translucent streamlines must keep writing depth. Without it they + # have nothing to depth-test against each other and blend in buffer + # order, so whichever bundle sits last in the geometry paints over + # the ones in front of it and the picture reorders itself the moment + # opacity leaves 1. + assert handle.tracts.af.material.depthWrite is True + handle.tracts.af.setOpacity(0.5) + assert handle.tracts.af.material.transparent is True + assert handle.tracts.af.material.depthWrite is True + # Called with no argument setOpacity is the getter; as with + # groupNames() above, a JSProxy call answers with the per-client + # response list. + assert handle.tracts.af.setOpacity()[0] == 0.5 + # Out-of-range and unparsable input is clamped / ignored rather than + # reaching the material (the panel's number box accepts typing). + handle.tracts.af.setOpacity(5) + assert handle.tracts.af.setOpacity()[0] == 1 + handle.tracts.af.setOpacity("") + assert handle.tracts.af.setOpacity()[0] == 1 diff --git a/cortex/webgl/data.py b/cortex/webgl/data.py index 0dfed7d4c..66e70412b 100644 --- a/cortex/webgl/data.py +++ b/cortex/webgl/data.py @@ -5,7 +5,20 @@ views = [ dict(name="proper name", cmap=cmap, vmin=vmin, vmax=vmax, data=["__braindata_name"]) ], data = dict(__braindata_name=dict(subject=subject, min=min, max=max)), images=(__braindata_name=["img1.png", "img2.png"]), + tracts = dict(name=dict(subject=subject, n_points=N, n_streamlines=M, ..., + urls=dict(points=url, offsets=url, colors=url))), ) + +Tractograms (`cortex.Tractogram`) are handled separately from the BrainData +based dataviews: they are not colormapped in the browser and carry no +volume/vertex arrays, so they never appear in ``views``/``data``/``images``. +Instead they contribute four little-endian binary buffers each -- ``points`` +(float32, N x 3), ``offsets`` (uint32, M + 1), ``colors`` (uint8, N x 3) and +``groups`` (uint32, concatenation of every group's streamline indices, in +the order they appear in ``tract_meta[name]["groups"]``, whose values are +``[start, stop]`` slice bounds into this buffer rather than counts) -- which +the viewer fetches and turns into a THREE.js line geometry +(``resources/js/tractogram.js``). """ import os @@ -19,12 +32,68 @@ # TODO: How to package multiviews? class Package(object): - """Package the data into a form usable by javascript""" + """Package the data into a form usable by javascript + + Parameters + ---------- + data : Dataset or Dataview + The data to package. + require_brains : bool + If True (the default), packaging a dataset that holds only + tractograms raises `ValueError`: the viewer cannot boot without a + Volume/Vertex dataview to build its surfaces from. Set to False when + pushing data into an already-running viewer. + """ - def __init__(self, data): + def __init__(self, data, require_brains=True): self.dataset = dataset.normalize(data) - self.uniques = list(data.uniques(collapse=True)) - self.subjects = set() + self._require_brains = require_brains + + # `normalize` returns a bare Dataview untouched (it does not wrap it + # into a Dataset), so handle both shapes here. + if isinstance(self.dataset, dataset.Dataset): + items = list(self.dataset) + else: + items = [(None, self.dataset)] + + # Tractograms are not BrainData: they are pulled out first, both + # because Dataset.uniques() would choke on them (only BrainData + # implements .uniques()) and because every existing javascript path + # expects views/data/images to hold colormapped brain data only. + self.tracts = dict() + self.tract_meta = dict() + brain_views = [] + for name, view in items: + if isinstance(view, dataset.Tractogram): + tname = name or view.description or view.name + if view.n_points > np.iinfo(np.uint32).max: + raise ValueError( + "Tractogram %r has %d points, more than the uint32 " + "offsets sent to the viewer can address; use " + "Tractogram.subsample() first." % (tname, view.n_points) + ) + group_indices, _ = view.groups_wire() + self.tracts[tname] = dict( + points=view.points.astype(" 0) + this.setData(data[0].name); }; module.Viewer.prototype.fitDataname = function() { @@ -709,6 +727,7 @@ var mriview = (function(module) { $("#dataopts").show(); } this.fitDataname(); + this._updateTractsPanel(); this.schedule(); this.loaded.resolve(); @@ -745,6 +764,70 @@ var mriview = (function(module) { $(this).remove(); }) }; + module.Viewer.prototype.addTracts = function(meta) { + //`meta` is the `tracts` dict of the metadata package built by + //cortex/webgl/data.py: {name: {..., urls:{points, offsets, colors}}}. + if (meta === undefined || meta === null) + return; + + for (var name in meta) { + if (this.tracts[name] !== undefined) + this.rmTracts(name); + + var tract = new module.Tractogram(name, meta[name], this.renderer); + this.tracts[name] = tract; + this.root.add(tract.object); + //Tracts load asynchronously and the viewer's own `loaded` Deferred + //deliberately does not wait for them, so redraw when they land. + tract.loaded.done(function(tract) { + $(this.object).find("#tracts").append(tract.element); + this._updateTractsPanel(); + this.schedule(); + }.bind(this)); + //Keep a freshly added tract in step with the current morph state. + if (tract.setMix !== undefined && this.surfs.length > 0) + tract.setMix(this.setMix()); + } + this.schedule(); + }; + + module.Viewer.prototype.rmTracts = function(name) { + var tract = this.tracts[name]; + if (tract === undefined) + return; + this.root.remove(tract.object); + if (tract.element !== null) + tract.element.remove(); + tract.dispose(); + delete this.tracts[name]; + this._updateTractsPanel(); + this.schedule(); + }; + + //Show/hide the #tracts panel depending on whether there is anything to + //show, and keep it positioned directly under the dataset box (#dataopts + //can change height -- a long description, or being hidden entirely -- + //so this is recomputed rather than fixed in CSS). + module.Viewer.prototype._updateTractsPanel = function() { + var panel = $(this.object).find("#tracts"); + if (panel.length === 0) + return; + var hasTracts = Object.keys(this.tracts).length > 0; + panel.toggle(hasTracts); + if (!hasTracts) + return; + + var dataopts = $(this.object).find("#dataopts"); + if (dataopts.length && dataopts.is(":visible")) { + panel.css({ + left: dataopts.position().left, + top: dataopts.position().top + dataopts.outerHeight() + 10, + }); + } else { + panel.css({left: "", top: ""}); + } + }; + module.Viewer.prototype.addSurf = function(surftype, opts) { //Sets the slicing surface used to visualize the data var surf = new surftype(this.active, opts); @@ -964,6 +1047,7 @@ var mriview = (function(module) { for (var i = 0; i < this.surfs.length; i++) if (this.surfs[i].setMix !== undefined) this.surfs[i].setMix(mix); + //Tracts follow through the surfaces' "mix" event (see this._mix). } module.Viewer.prototype.pick = function(evt) { @@ -1169,7 +1253,7 @@ var mriview = (function(module) { var _bound = false; module.Viewer.prototype._bindUI = function() { $(window).scrollTop(0); - $(window).resize(function() { this.resize(); this.fitDataname(); }.bind(this)); + $(window).resize(function() { this.resize(); this.fitDataname(); this._updateTractsPanel(); }.bind(this)); this.canvas.resize(function() { this.resize(); }.bind(this)); var cam_ui = this.ui.addFolder("camera", true); @@ -1431,8 +1515,11 @@ var mriview = (function(module) { var dataset_cat = $(dataopts).find('#dataset_category'); dataset_cat.hide(); $(dataopts).find('#dataname').click(function(e) { - dataset_cat.slideToggle(); - }); + dataset_cat.slideToggle({ + step: function() { this._updateTractsPanel(); }.bind(this), + complete: function() { this._updateTractsPanel(); }.bind(this), + }); + }.bind(this)); var setdat = function(event, ui) { var names = []; diff --git a/cortex/webgl/resources/js/svgoverlay.js b/cortex/webgl/resources/js/svgoverlay.js index c9ccaca68..a9f756ca7 100644 --- a/cortex/webgl/resources/js/svgoverlay.js +++ b/cortex/webgl/resources/js/svgoverlay.js @@ -157,9 +157,22 @@ var svgoverlay = (function(module) { var clearAlpha = renderer.getClearAlpha(); var clearColor = renderer.getClearColor().clone(); renderer.setClearColor(0xffffff, 1); + //The depth shader only knows the surface's vertex attributes, so + //objects that opt out (e.g. tractography lines, see tractogram.js) + //are hidden for this pass -- rendering them with the override + //material would crash on the missing attributes. + var hidden = []; + scene.traverse(function(obj) { + if (obj.visible && obj.userData && obj.userData.skipOverrideMaterial) { + obj.visible = false; + hidden.push(obj); + } + }); scene.overrideMaterial = this.depthshade; renderer.render(scene, camera, this.depth); scene.overrideMaterial = null; + for (var i = 0; i < hidden.length; i++) + hidden[i].visible = true; renderer.setClearColor(clearColor, clearAlpha); this.labels.left.visible = true; this.labels.right.visible = true; diff --git a/cortex/webgl/resources/js/tractogram.js b/cortex/webgl/resources/js/tractogram.js new file mode 100644 index 000000000..032eb2fe2 --- /dev/null +++ b/cortex/webgl/resources/js/tractogram.js @@ -0,0 +1,598 @@ +var mriview = (function(module) { + + //Fetch a binary payload (one of the tractogram's buffers) as an + //ArrayBuffer. Same XMLHttpRequest style as surfload.js / CTMLoader.js. + function loadBuffer(url, callback, errback) { + var xhr = new XMLHttpRequest(); + xhr.onreadystatechange = function() { + if (xhr.readyState == 4) { + if (xhr.status == 200 || xhr.status == 206 || xhr.status == 0) { + callback(xhr.response); + } else { + console.error("mriview.Tractogram: couldn't load " + url + + " (" + xhr.status + ")"); + if (errback !== undefined) + errback(xhr.status); + } + } + }; + xhr.open("GET", url, true); + xhr.responseType = "arraybuffer"; + xhr.send(null); + } + + //Three.js r69 only enables 32-bit element indices when the + //OES_element_index_uint extension is present (see the THREE.Line branch of + //renderBufferDirect, three.js:20451, which picks UNSIGNED_INT purely from + //the array type -- an unsupported extension means a GL error rather than a + //fallback). Without it we duplicate the vertices instead, which also avoids + //needing r69's `geometry.offsets` drawcall chunking for >65535 vertices. + module.supportsUint32Index = function(renderer) { + var gl = null; + if (renderer !== undefined && renderer !== null) + gl = renderer.context; + else if (window.viewer !== undefined && window.viewer.renderer !== undefined) + gl = window.viewer.renderer.context; + + if (gl === null || gl === undefined) + return false; + try { + return !!gl.getExtension("OES_element_index_uint"); + } catch (e) { + return false; + } + }; + + //A bundle of streamlines, rendered as GL_LINES. + // + //`meta` is one entry of the `tracts` dict of the metadata package built by + //cortex/webgl/data.py: {subject, n_points, n_streamlines, alpha, linewidth, + //visible, color, groups: {name: [start, stop]}, description, + //urls:{points, offsets, colors, groups}}. + module.Tractogram = function(name, meta, renderer) { + this.name = name; + this.meta = meta; + this.renderer = renderer; + + this._visible = (meta.visible === undefined) ? true : !!meta.visible; + this._opacity = (meta.alpha === undefined) ? 1 : meta.alpha; + this._mix = 0; + + this.n_points = 0; + this.n_streamlines = 0; + //Number of vertices actually uploaded to the GPU: equals n_points + //for the indexed geometry, 2 * (number of segments) for the + //duplicated-vertex fallback. Plain numbers are also what the python + //JSProxy can read back (typed-array lengths are not enumerable). + this.n_vertices = 0; + this.geometry = null; + this.material = null; + this.line = null; + + //Per-group visibility (name -> bool), including a synthetic + //"(ungrouped)" entry when at least one streamline belongs to no + //named group. Populated once the "groups" buffer has loaded (see + //_build); empty until then, and left empty forever when the + //tractogram has no groups at all (n.b. distinct from "has groups + //but is entirely covered by them", which does not create the + //pseudo-group). this._hasGroups records whether group filtering + //applies at all, so setVisible-only tractograms skip it entirely. + this._hasGroups = false; + this._groupVisible = {}; + //Uint32Array: concatenation of every real group's streamline + //indices (server "groups" buffer) followed by the synthetic + //"(ungrouped)" group's indices, if any -- see _build. + this._groupIndices = null; + //name -> [start, stop] slice into _groupIndices. + this._groupSlices = {}; + //Uint8Array of length n_streamlines, recomputed by + //_updateStreamlineVisibility whenever _groupVisible changes: 1 if + //the streamline is a member of >=1 visible group (or, with no + //groups at all, always 1). + this._streamlineVisible = null; + //Raw (unfiltered) buffers, kept around so _rebuildGeometry can + //recompute the segment index / duplicated arrays without re-fetching. + this._rawPoints = null; + this._rawOffsets = null; + this._rawColors = null; + + //The viewer's own `loaded` Deferred must NOT wait on this one: tracts + //are an overlay on top of a viewer that is usable without them. + this.loaded = $.Deferred(); + + this.object = new THREE.Group(); + this.object.name = "Tractogram:" + name; + this.object.visible = this._visible; + //Passes that render the scene with a surface-specific override + //material (the SVG label depth pass in svgoverlay.js) must skip us: + //those shaders expect surface attributes this geometry doesn't have. + this.object.userData.skipOverrideMaterial = true; + + //DOM element for this tractogram's controls, built once _build has + //run (see _buildElement); appended under #tracts by + //Viewer.addTracts. References to the individual inputs are kept so + //_syncControls can update them after a programmatic state change + //(e.g. a Python call through the JSProxy). + this.element = null; + this._visibleCheckbox = null; + this._opacitySlider = null; + this._opacityBox = null; + this._groupCheckboxes = {}; + + var buffers = {}, names = ["points", "offsets", "colors", "groups"]; + var pending = names.length; + var failed = false; + var ondone = function(bufname) { + return function(data) { + buffers[bufname] = data; + if (--pending === 0 && !failed) + this._build(buffers); + }.bind(this); + }.bind(this); + var onfail = function(status) { + if (!failed) { + failed = true; + this.loaded.reject(status); + } + }.bind(this); + + for (var i = 0; i < names.length; i++) + loadBuffer(meta.urls[names[i]], ondone(names[i]), onfail); + }; + + //Turn the four raw buffers into a THREE.Line of segments, and set up + //per-group visibility (this._groupVisible / this._groupSlices / + //this._groupIndices) plus the "groups" dat.gui sub-folder, if any. + module.Tractogram.prototype._build = function(buffers) { + var points = new Float32Array(buffers.points); + var offsets = new Uint32Array(buffers.offsets); + var rawcolors = new Uint8Array(buffers.colors); + + var npts = points.length / 3; + this.n_points = npts; + //The offsets array carries a trailing sentinel equal to npts, so there + //is one streamline per pair of consecutive entries. + var nstream = Math.max(offsets.length - 1, 0); + this.n_streamlines = nstream; + + //r69 always uploads vertex attributes as gl.FLOAT (three.js:20276 + //hardcodes it in setupVertexAttributes), so the uint8 colors must be + //expanded to normalized floats -- normalized uint8 attributes are not + //an option in this version. + var colors = new Float32Array(rawcolors.length); + for (var i = 0; i < rawcolors.length; i++) + colors[i] = rawcolors[i] / 255; + + this._rawPoints = points; + this._rawOffsets = offsets; + this._rawColors = colors; + + this._setupGroups(buffers.groups, nstream); + this._updateStreamlineVisibility(); + + var alpha = this._opacity; + this.material = new THREE.LineBasicMaterial({ + vertexColors: THREE.VertexColors, + transparent: alpha < 1, + opacity: alpha, + //Always write depth, at every opacity -- see setOpacity for why. + depthWrite: true, + linewidth: (this.meta.linewidth === undefined) ? 1 : this.meta.linewidth, + }); + + this._buildLine(this._computeGeometryArrays()); + + this._buildElement(); + + this.loaded.resolve(this); + }; + + //Parse the "groups" buffer (uint32 streamline indices, the + //concatenation of every group in metadata order -- see + //Tractogram.groups_wire in cortex/dataset/tractogram.py) plus + //`meta.groups` ({name: [start, stop]}, same order) into + //this._groupSlices / this._groupIndices, and append a synthetic + //"(ungrouped)" group covering any streamline that is not a member of a + //real group. Initializes every group (including the pseudo one) to + //visible. A tractogram with no groups at all leaves this._hasGroups + //false and group filtering out of the picture entirely. + module.Tractogram.prototype._setupGroups = function(groupsBuffer, nstream) { + var groupIndices = new Uint32Array(groupsBuffer); + var groupSlices = {}; + //Object key order for non-integer-like string keys follows + //insertion order in all JS engines we target, and JSON.parse + //preserves the order the metadata was serialized in -- but a group + //named e.g. "2" would be reordered numerically by the JS engine; + //not handled here (group names are expected to be bundle names). + for (var gname in this.meta.groups) + groupSlices[gname] = this.meta.groups[gname]; + var hasGroups = Object.keys(groupSlices).length > 0; + + if (hasGroups) { + var inGroup = new Uint8Array(nstream); + for (var gi = 0; gi < groupIndices.length; gi++) + inGroup[groupIndices[gi]] = 1; + var ungrouped = []; + for (var s = 0; s < nstream; s++) + if (!inGroup[s]) + ungrouped.push(s); + if (ungrouped.length > 0) { + var combined = new Uint32Array(groupIndices.length + ungrouped.length); + combined.set(groupIndices, 0); + combined.set(ungrouped, groupIndices.length); + groupSlices["(ungrouped)"] = [groupIndices.length, combined.length]; + groupIndices = combined; + } + } + + this._hasGroups = hasGroups; + this._groupIndices = groupIndices; + this._groupSlices = groupSlices; + this._groupVisible = {}; + for (var name in groupSlices) + this._groupVisible[name] = true; + }; + + //Recompute this._streamlineVisible (Uint8Array, one entry per + //streamline) from this._groupVisible. A streamline is visible iff it is + //a member of >=1 visible group (ungrouped streamlines follow the + //"(ungrouped)" pseudo-group); with no groups at all, every streamline + //is visible. + module.Tractogram.prototype._updateStreamlineVisibility = function() { + var nstream = this.n_streamlines; + var vis = new Uint8Array(nstream); + if (!this._hasGroups) { + vis.fill(1); + } else { + for (var name in this._groupSlices) { + if (!this._groupVisible[name]) + continue; + var se = this._groupSlices[name]; + for (var k = se[0]; k < se[1]; k++) + vis[this._groupIndices[k]] = 1; + } + } + this._streamlineVisible = vis; + }; + + //Build the position/color/index arrays for the currently-visible + //streamlines only, in the shape _buildLine expects. Shared by the first + //_build and every subsequent _rebuildGeometry. + module.Tractogram.prototype._computeGeometryArrays = function() { + var points = this._rawPoints, offsets = this._rawOffsets, colors = this._rawColors; + var nstream = this.n_streamlines; + var visible = this._streamlineVisible; + var npts = points.length / 3; + + //Number of segments among visible streamlines: every streamline of + //L points yields L-1 segments. + var nseg = 0; + for (var s = 0; s < nstream; s++) { + if (visible[s]) + nseg += Math.max(offsets[s+1] - offsets[s] - 1, 0); + } + + if (module.supportsUint32Index(this.renderer) || npts <= 65535) { + //Uint16 is enough (and universally supported) for small tractograms. + var index = (npts <= 65535) ? new Uint16Array(2 * nseg) : new Uint32Array(2 * nseg); + var k = 0; + for (var s = 0; s < nstream; s++) { + if (!visible[s]) + continue; + for (var j = offsets[s]; j + 1 < offsets[s+1]; j++) { + index[k++] = j; + index[k++] = j + 1; + } + } + //Position/color stay the full (unfiltered) buffers -- only the + //index changes with visibility. + return {indexed: true, position: points, color: colors, index: index}; + } else { + //Fallback: duplicate the endpoints of every segment so no element + //index buffer is needed at all. + var pos = new Float32Array(6 * nseg); + var col = new Float32Array(6 * nseg); + var k = 0; + for (var s = 0; s < nstream; s++) { + if (!visible[s]) + continue; + for (var j = offsets[s]; j + 1 < offsets[s+1]; j++) { + for (var d = 0; d < 3; d++) { + pos[6*k + d] = points[3*j + d]; + pos[6*k + 3 + d] = points[3*(j+1) + d]; + col[6*k + d] = colors[3*j + d]; + col[6*k + 3 + d] = colors[3*(j+1) + d]; + } + k++; + } + } + return {indexed: false, position: pos, color: col}; + } + }; + + //Replace this.geometry/this.line with a fresh geometry built from + //`arrays` (as returned by _computeGeometryArrays), disposing the old + //ones. this.material is reused across rebuilds (only the geometry + //changes), so opacity/depthWrite state carries over unchanged. + module.Tractogram.prototype._buildLine = function(arrays) { + var geometry = new THREE.BufferGeometry(); + geometry.addAttribute("position", new THREE.BufferAttribute(arrays.position, 3)); + geometry.addAttribute("color", new THREE.BufferAttribute(arrays.color, 3)); + if (arrays.indexed) + geometry.addAttribute("index", new THREE.BufferAttribute(arrays.index, 1)); + geometry.computeBoundingSphere(); + + if (this.line !== null) + this.object.remove(this.line); + if (this.geometry !== null) + this.geometry.dispose(); + + this.geometry = geometry; + this.line = new THREE.Line(geometry, this.material, THREE.LinePieces); + this.line.name = "Tractogram:" + this.name + ":lines"; + this.n_vertices = geometry.attributes.position.array.length / 3; + //Segments actually drawn: shrinks when groups are hidden on both + //the indexed path (index halves) and the fallback path (vertices + //are duplicated per segment). n_vertices only shrinks on the latter. + this.n_segments = arrays.indexed + ? arrays.index.length / 2 + : this.n_vertices / 2; + this._updateRenderOrder(); + this.object.add(this.line); + }; + + //Rebuild the rendered geometry from the current this._streamlineVisible + //(called after any change to group visibility) and ask the viewer to + //redraw. A no-op before the buffers have loaded. + module.Tractogram.prototype._rebuildGeometry = function() { + if (this._rawPoints === null) + return; + this._buildLine(this._computeGeometryArrays()); + this._syncControls(); + if (window.viewer !== undefined && window.viewer.schedule !== undefined) + window.viewer.schedule(); + }; + + //Build this.element: the DOM controls for this tractogram, appended + //under #tracts by Viewer.addTracts once this.loaded resolves. Header + //row (visibility checkbox, name, collapse toggle) always present; body + //(opacity slider, and a per-group "bundles" section when the + //tractogram has groups) can be collapsed. Collapsed by default when + //there are more than 8 groups, to keep the panel from taking over the + //screen for tractograms with many bundles. + module.Tractogram.prototype._buildElement = function() { + var names = this.groupNames(); + var collapsed = this._hasGroups && names.length > 8; + + var el = $("
"); + var header = $("
"); + var toggle = $("").text(collapsed ? "▸" : "▾"); + var visibleCheckbox = $(""); + var nameLabel = $("").text(this.name); + header.append(visibleCheckbox, toggle, nameLabel); + + var body = $("
"); + if (collapsed) + body.hide(); + + var opacityRow = $("
"); + var opacitySlider = $(""); + var opacityBox = $(""); + opacityRow.append($(""), opacitySlider, opacityBox); + body.append(opacityRow); + + this._groupCheckboxes = {}; + if (this._hasGroups) { + var groupsSection = $("
"); + var actions = $("
"); + var allLink = $("all"); + var noneLink = $("none"); + actions.append(allLink, noneLink); + groupsSection.append(actions); + + for (var i = 0; i < names.length; i++) { + (function(tract, gname) { + var slice = tract._groupSlices[gname]; + var count = slice[1] - slice[0]; + var row = $("
"); + var cb = $(""); + var label = $("") + .text(gname + " ") + .append($("").text("(" + count + ")")); + row.append(cb, label); + groupsSection.append(row); + tract._groupCheckboxes[gname] = cb; + + cb.on("change", function() { + tract.setGroupVisible(gname, cb.prop("checked")); + }); + }(this, names[i])); + } + + allLink.on("click", function() { this.showAllGroups(); }.bind(this)); + noneLink.on("click", function() { this.hideAllGroups(); }.bind(this)); + + body.append(groupsSection); + } + + el.append(header, body); + + //Clicks/drags inside the panel must not reach the WebGL canvas + //handlers underneath (camera rotation, picking, etc). + el.on("mousedown click", function(e) { e.stopPropagation(); }); + + visibleCheckbox.on("change", function() { + this.setVisible(visibleCheckbox.prop("checked")); + }.bind(this)); + opacitySlider.on("input change", function() { + this.setOpacity(parseFloat(opacitySlider.val())); + }.bind(this)); + //The box takes typed values, so it only commits on change/Enter -- + //reacting to "input" would fight the user mid-keystroke ("0.0" while + //they are on their way to "0.05"). A value outside 0-1 or an empty + //box is clamped or ignored by setOpacity, and _syncControls then puts + //the accepted value back in the box. + opacityBox.on("change", function() { + this.setOpacity(opacityBox.val()); + this._syncControls(); + }.bind(this)); + opacityBox.on("keydown", function(e) { + if (e.which === 13) + opacityBox.trigger("change"); + }); + toggle.on("click", function() { + collapsed = !collapsed; + toggle.text(collapsed ? "▸" : "▾"); + body.toggle(!collapsed); + }); + + this.element = el; + this._visibleCheckbox = visibleCheckbox; + this._opacitySlider = opacitySlider; + this._opacityBox = opacityBox; + + this._syncControls(); + }; + + //Keep the DOM controls in step with this tractogram's state, whether it + //changed via a click in the panel or programmatically (showAllGroups / + //hideAllGroups / setGroupVisible / setVisible / setOpacity called + //directly, e.g. from Python through the JSProxy). Called at the end of + //every setter. + module.Tractogram.prototype._syncControls = function() { + if (this._visibleCheckbox !== null) + this._visibleCheckbox.prop("checked", this._visible); + if (this._opacitySlider !== null) + this._opacitySlider.val(this._opacity); + if (this._opacityBox !== null) + this._opacityBox.val(this._opacity); + for (var name in this._groupCheckboxes) + this._groupCheckboxes[name].prop("checked", !!this._groupVisible[name]); + }; + + //Getter/setter pair, in the shape jsplot.Menu expects (called with no + //argument it returns the current value, so dat.gui can initialize itself). + module.Tractogram.prototype.setVisible = function(value) { + if (value === undefined) + return this._visible; + this._visible = !!value; + this._updateVisible(); + this._syncControls(); + this._requestRedraw(); + }; + + module.Tractogram.prototype.setOpacity = function(value) { + if (value === undefined) + return this._opacity; + value = parseFloat(value); + if (isNaN(value)) + return; + value = Math.min(1, Math.max(0, value)); + this._opacity = value; + if (this.material !== null) { + this.material.opacity = value; + this.material.transparent = value < 1; + //depthWrite stays on at every opacity. Turning it off (the + //obvious thing to do for a transparent material) leaves the + //streamlines with nothing to depth-test against each other, so + //they blend in buffer order instead of depth order: the bundle + //that happens to sit last in the geometry paints over the ones + //in front of it, and which bundle looks nearest changes the + //moment opacity drops below 1. Writing depth keeps the + //occlusion identical at every opacity, at the cost of not + //seeing one translucent streamline through another -- the + //alternative is re-sorting every segment back-to-front on each + //camera move, which r69 will not do for us. + this.material.depthWrite = true; + this.material.needsUpdate = true; + this._updateRenderOrder(); + } + this._syncControls(); + this._requestRedraw(); + }; + + //The panel's inputs are plain DOM controls (not dat.gui, whose menu used + //to dispatch an "update" the viewer redraws on), so every state change + //has to ask the viewer for a frame itself. + module.Tractogram.prototype._requestRedraw = function() { + if (window.viewer !== undefined && window.viewer.schedule !== undefined) + window.viewer.schedule(); + }; + + //Getter/setter pair for one group's visibility, in the shape jsplot.Menu + //expects. Rebuilds the rendered geometry (see _rebuildGeometry). + module.Tractogram.prototype.setGroupVisible = function(name, value) { + if (value === undefined) + return !!this._groupVisible[name]; + this._groupVisible[name] = !!value; + this._updateStreamlineVisibility(); + this._rebuildGeometry(); + }; + + //Make every group (including "(ungrouped)") visible. + module.Tractogram.prototype.showAllGroups = function() { + for (var name in this._groupVisible) + this._groupVisible[name] = true; + this._updateStreamlineVisibility(); + this._rebuildGeometry(); + }; + + //Hide every group (including "(ungrouped)"): no streamlines render. + module.Tractogram.prototype.hideAllGroups = function() { + for (var name in this._groupVisible) + this._groupVisible[name] = false; + this._updateStreamlineVisibility(); + this._rebuildGeometry(); + }; + + //Names of every group this tractogram knows about, in metadata order, + //including the synthetic "(ungrouped)" entry if present. Empty when the + //tractogram has no groups. + module.Tractogram.prototype.groupNames = function() { + return Object.keys(this._groupSlices || {}); + }; + + //Three.js r69 draws opaque objects first, then transparent ones sorted by + //their (projected) center depth. Opaque tracts are therefore always + //covered by a translucent surface, but as soon as the tracts themselves + //become translucent the depth sort can put them *after* the surface -- + //drawn on top of it, undimmed, so lowering tract opacity from 1 to 0.9 + //made them brighter. Pinning renderDepth keeps translucent tracts first: + //r69 sorts the transparent list ascending by z and then walks it from the + //END (renderObjects iterates backwards), so the largest renderDepth is + //drawn first, i.e. always under the surface, and the surface's own + //opacity attenuates the tracts consistently. + module.Tractogram.prototype._updateRenderOrder = function() { + if (this.line === null) + return; + this.line.renderDepth = (this._opacity < 1) ? 1e6 : null; + }; + + //Streamlines are defined in the fiducial (unmorphed) space, so they only + //make sense while the surface is not inflating/flattening. + module.Tractogram.prototype.setMix = function(mix) { + if (mix === undefined) + return this._mix; + this._mix = mix; + this._updateVisible(); + }; + + module.Tractogram.prototype._updateVisible = function() { + this.object.visible = this._visible && this._mix === 0; + }; + + module.Tractogram.prototype.dispose = function() { + if (this.line !== null) + this.object.remove(this.line); + if (this.geometry !== null) + this.geometry.dispose(); + if (this.material !== null) + this.material.dispose(); + this.geometry = null; + this.material = null; + this.line = null; + }; + + return module; +}(mriview || {})); diff --git a/cortex/webgl/static.html b/cortex/webgl/static.html index aed6d5c37..80e08be49 100644 --- a/cortex/webgl/static.html +++ b/cortex/webgl/static.html @@ -1,7 +1,7 @@ {% autoescape None %} {% extends template.html %} {% block jsinit %} - var viewer, subjects, datasets, figure, sock, viewopts; + var viewer, subjects, datasets, figure, sock, viewopts, metadata; {% end %} {% block onload %} viewopts = {{viewopts}}; @@ -13,6 +13,8 @@ figure = new jsplot.W2Figure(); viewer = figure.add(mriview.Viewer, "main", true); - dataviews = dataset.fromJSON({{data}}); + metadata = {{data}}; + dataviews = dataset.fromJSON(metadata); viewer.addData(dataviews); + viewer.addTracts(metadata.tracts); {% end %} diff --git a/cortex/webgl/template.html b/cortex/webgl/template.html index bfabfb158..be0a543b5 100644 --- a/cortex/webgl/template.html +++ b/cortex/webgl/template.html @@ -36,6 +36,7 @@ + {% if leapmotion %} @@ -62,6 +63,7 @@ +
0.0000
diff --git a/cortex/webgl/view.py b/cortex/webgl/view.py index cb471f4be..24a515a19 100644 --- a/cortex/webgl/view.py +++ b/cortex/webgl/view.py @@ -222,7 +222,11 @@ def make_static( submap = None # Process the data - metadata = package.metadata(fmt="data/{name}_{frame}.png", submap=submap) + metadata = package.metadata( + fmt="data/{name}_{frame}.png", + tract_fmt="tracts/{name}_{buf}.bin", + submap=submap, + ) images = package.images # Write out the PNGs for name, imgs in images.items(): @@ -231,6 +235,16 @@ def make_static( with open(impath.format(name=name, frame=i), "wb") as binfile: binfile.write(img) + # Write out the raw streamline buffers of any tractogram + if package.tracts: + tractpath = os.path.join(outpath, "tracts") + os.makedirs(tractpath, exist_ok=True) + for name, bufs in package.tracts.items(): + for buf, contents in bufs.items(): + fname = os.path.join(tractpath, "%s_%s.bin" % (name, buf)) + with open(fname, "wb") as binfile: + binfile.write(contents) + # Copy any stimulus files stimpath = os.path.join(outpath, "stim") for name, view in data: @@ -438,6 +452,8 @@ def show( # is serialized to JSON on demand, when the mixer page is generated. metadata = package.metadata() images = package.images + # Raw streamline buffers, served by TractHandler below. + tracts = package.tracts subjects = list(package.subjects) ctmargs = dict(method='mg2', level=9, recache=recache, @@ -537,6 +553,47 @@ def get(self, path: str): self.set_status(404) self.write_error(404) + class TractHandler(web.RequestHandler): + """Serve the raw streamline buffers of a Tractogram. + + Mirrors DataHandler, for the "/tract/{name}/{buf}/" urls generated by + `cortex.webgl.data.Package.tract_names`. `buf` is one of "points" + (float32 N x 3), "offsets" (uint32 M + 1), "colors" (uint8 N x 3) or + "groups" (uint32, concatenated group streamline indices). + """ + def get(self, path: str): + path = path.strip("/") + try: + tractname, buf = path.rsplit('/', 1) + except ValueError: + self.set_status(404) + self.write_error(404) + return + + if tractname in tracts and buf in tracts[tractname]: + contents = tracts[tractname][buf] + self.set_header("Content-Type", "application/octet-stream") + + if 'Range' in self.request.headers: + self.set_status(206) + rangestr = self.request.headers['Range'].split('=')[1] + start, end = [ int(i) if len(i) > 0 else None for i in rangestr.split('-') ] + # "bytes=100-" means "from 100 to the end" (inclusive end) + if start is None: + start = 0 + if end is None or end >= len(contents): + end = len(contents) - 1 + + clenheader = 'bytes %s-%s/%s' % (start, end, len(contents)) + self.set_header('Content-Range', clenheader) + self.set_header('Content-Length', end-start+1) + self.write(contents[start:end+1]) + else: + self.write(contents) + else: + self.set_status(404) + self.write_error(404) + class StimHandler(web.StaticFileHandler): def initialize(self): pass @@ -757,7 +814,10 @@ def addData(self, **kwargs): Proxy = serve.JSProxy(self.send, "window.viewer.addData") new_data = dataset.Dataset(**kwargs) - new_package = Package(new_data) + # require_brains=False: the viewer is already up, so pushing only + # a Tractogram is fine (the javascript side just adds it to the + # scene and keeps the currently displayed dataview). + new_package = Package(new_data, require_brains=False) unknown = set(new_package.subjects) - set(subjects) if len(unknown) > 0: raise ValueError( @@ -782,6 +842,11 @@ def addData(self, **kwargs): metadata["data"].update(new_metadata["data"]) metadata["images"].update(new_metadata["images"]) + # Serve the streamline buffers of any newly added tractogram, and + # remember them so that a page reload still shows them. + tracts.update(new_package.tracts) + metadata["tracts"].update(new_metadata["tracts"]) + # Forget the brains that no dataview refers to anymore, so that # repeatedly refreshing the same dataview does not pile up unused # image buffers in the server. @@ -1052,6 +1117,7 @@ def get_local_client(self): server = WebApp([(r'/ctm/(.*)', CTMHandler), (r'/data/(.*)', DataHandler), + (r'/tract/(.*)', TractHandler), (r'/stim/(.*)', StimHandler), (r'/mixer.html', MixerHandler), (r'/picker', PickerHandler), diff --git a/docs/dataset.rst b/docs/dataset.rst index 64f6a30d1..0e9482149 100644 --- a/docs/dataset.rst +++ b/docs/dataset.rst @@ -125,6 +125,8 @@ or from a plain list of ``(L_i, 3)`` streamline arrays:: ``groups`` holds named subsets of streamlines (as arrays of streamline indices, e.g. bundle names); groups may overlap, and a streamline may belong to no group. :meth:`Tractogram.get_group` and :meth:`Tractogram.select` return new tractograms restricted to a subset, and :meth:`Tractogram.subsample` decimates a large tractogram for faster interactive display. +In the WebGL viewer, tractogram controls live in their own panel under the dataset box, with one entry per tractogram (a visibility checkbox, name, and collapse toggle). A tractogram with at least one group gets, inside its (optionally collapsed) entry, an opacity slider and a "bundles" section with "all"/"none" links plus one checkbox per group, in the order groups were given, each labeled with its streamline count; any streamline in no group is covered by an additional ``(ungrouped)`` checkbox. A streamline renders while it belongs to at least one checked group. From Python, toggle a group directly on the live viewer handle, e.g. ``handle.tracts.af.setGroupVisible("CST_L", False)``. + .. note:: `points` must be expressed in the same mm space as the subject's fiducial surfaces (FreeSurfer scanner RAS mm). Tractography output is often already in this space when the diffusion data was registered to the same T1 used to generate the surfaces; otherwise, pass an `xfm` (4x4 affine) to :meth:`Tractogram.from_trx` to align the streamline positions onto the fiducial surface. From 51a3ccc1986da9614f18a75c994533031eb3a072 Mon Sep 17 00:00:00 2001 From: Matteo Visconti di Oleggio Castello Date: Sat, 19 Sep 2026 18:41:56 -0700 Subject: [PATCH 2/5] FIX hide a zero-opacity tractogram, and list bundles alphabetically Dragging tract opacity to 0 did not hide the streamlines: it turned them into silhouettes cut out of the translucent surface. Writing depth at every opacity is what keeps the bundles from reordering themselves as the slider moves, but it also means fragments contributing no colour still hide whatever is behind them. r69's basic shader runs its alphatest discard while gl_FragColor.a is still just `opacity`, before vertex colours are folded in, so a small constant alphaTest drops exactly the fragments that would have been invisible anyway, depth write and all. Being constant, the ALPHATEST define compiles once and opacity changes stay cheap. The bundle checkboxes were in wire order, which for a real TRX is arbitrary: pyAFQ's HCP atlas lists IF0F_R, F_R, F_L, CC_ForcepsMajor and so on. They are now alphabetical, comparing digit runs as numbers so CST_2 precedes CST_10, with the synthetic "(ungrouped)" entry pinned last since it is not a bundle and its leading parenthesis would otherwise float it to the top. groupNames() returns the same order, so the panel and the API agree. Co-Authored-By: Claude Opus 5 --- cortex/tests/test_webgl_tractogram.py | 7 +++-- cortex/webgl/resources/js/tractogram.js | 36 +++++++++++++++++++++---- docs/dataset.rst | 2 +- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/cortex/tests/test_webgl_tractogram.py b/cortex/tests/test_webgl_tractogram.py index 466f3962a..b157d4e9b 100644 --- a/cortex/tests/test_webgl_tractogram.py +++ b/cortex/tests/test_webgl_tractogram.py @@ -206,11 +206,14 @@ def test_tractogram_renders_in_headless_viewer(): # return the per-client response list, hence the [0]. full_n_segments = handle.tracts.af.n_segments assert full_n_segments == n - m - assert set(handle.tracts.af.groupNames()[0]) == { + # Alphabetical, with the synthetic "(ungrouped)" entry last -- not + # the order the groups arrived in, which the fixture deliberately + # makes the reverse of alphabetical. + assert handle.tracts.af.groupNames()[0] == [ "first_half", "second_half", "(ungrouped)", - } + ] handle.tracts.af.setGroupVisible("first_half", False) shrunk_n_segments = handle.tracts.af.n_segments assert 0 < shrunk_n_segments < full_n_segments diff --git a/cortex/webgl/resources/js/tractogram.js b/cortex/webgl/resources/js/tractogram.js index 032eb2fe2..f192df6b6 100644 --- a/cortex/webgl/resources/js/tractogram.js +++ b/cortex/webgl/resources/js/tractogram.js @@ -1,5 +1,9 @@ var mriview = (function(module) { + //Name of the synthetic group covering streamlines that belong to no real + //one. Not a bundle, so it is listed after them (see sortGroupNames). + var UNGROUPED = "(ungrouped)"; + //Fetch a binary payload (one of the tractogram's buffers) as an //ArrayBuffer. Same XMLHttpRequest style as surfload.js / CTMLoader.js. function loadBuffer(url, callback, errback) { @@ -177,6 +181,16 @@ var mriview = (function(module) { opacity: alpha, //Always write depth, at every opacity -- see setOpacity for why. depthWrite: true, + //...which on its own makes a fully transparent tractogram visible + //rather than invisible: fragments that contribute no colour still + //write depth, so they hide the translucent surface behind them and + //the streamlines show up as silhouettes cut out of the brain. + //r69's basic shader runs the alphatest discard while gl_FragColor.a + //is still just `opacity` (before vertex colours are folded in), so + //a small alphaTest drops exactly the fragments that would have been + //invisible anyway, depth write and all. It is a constant, so the + //ALPHATEST define is compiled once and opacity changes stay cheap. + alphaTest: 0.01, linewidth: (this.meta.linewidth === undefined) ? 1 : this.meta.linewidth, }); @@ -220,7 +234,7 @@ var mriview = (function(module) { var combined = new Uint32Array(groupIndices.length + ungrouped.length); combined.set(groupIndices, 0); combined.set(ungrouped, groupIndices.length); - groupSlices["(ungrouped)"] = [groupIndices.length, combined.length]; + groupSlices[UNGROUPED] = [groupIndices.length, combined.length]; groupIndices = combined; } } @@ -546,11 +560,23 @@ var mriview = (function(module) { this._rebuildGeometry(); }; - //Names of every group this tractogram knows about, in metadata order, - //including the synthetic "(ungrouped)" entry if present. Empty when the - //tractogram has no groups. + //Alphabetical, case-insensitively and with digit runs compared as + //numbers, so CST_2 sorts before CST_10. The synthetic "(ungrouped)" entry + //always sorts last: it is not a bundle, and a leading parenthesis would + //otherwise float it to the top. + module.sortGroupNames = function(names) { + return names.slice().sort(function(a, b) { + if (a === UNGROUPED) return 1; + if (b === UNGROUPED) return -1; + return a.localeCompare(b, undefined, {numeric: true, sensitivity: "base"}); + }); + }; + + //Names of every group this tractogram knows about, alphabetically (see + //sortGroupNames), including the synthetic "(ungrouped)" entry if present. + //Empty when the tractogram has no groups. module.Tractogram.prototype.groupNames = function() { - return Object.keys(this._groupSlices || {}); + return module.sortGroupNames(Object.keys(this._groupSlices || {})); }; //Three.js r69 draws opaque objects first, then transparent ones sorted by diff --git a/docs/dataset.rst b/docs/dataset.rst index 0e9482149..256982403 100644 --- a/docs/dataset.rst +++ b/docs/dataset.rst @@ -125,7 +125,7 @@ or from a plain list of ``(L_i, 3)`` streamline arrays:: ``groups`` holds named subsets of streamlines (as arrays of streamline indices, e.g. bundle names); groups may overlap, and a streamline may belong to no group. :meth:`Tractogram.get_group` and :meth:`Tractogram.select` return new tractograms restricted to a subset, and :meth:`Tractogram.subsample` decimates a large tractogram for faster interactive display. -In the WebGL viewer, tractogram controls live in their own panel under the dataset box, with one entry per tractogram (a visibility checkbox, name, and collapse toggle). A tractogram with at least one group gets, inside its (optionally collapsed) entry, an opacity slider and a "bundles" section with "all"/"none" links plus one checkbox per group, in the order groups were given, each labeled with its streamline count; any streamline in no group is covered by an additional ``(ungrouped)`` checkbox. A streamline renders while it belongs to at least one checked group. From Python, toggle a group directly on the live viewer handle, e.g. ``handle.tracts.af.setGroupVisible("CST_L", False)``. +In the WebGL viewer, tractogram controls live in their own panel under the dataset box, with one entry per tractogram (a visibility checkbox, name, and collapse toggle). A tractogram with at least one group gets, inside its (optionally collapsed) entry, an opacity slider and a "bundles" section with "all"/"none" links plus one checkbox per group, listed alphabetically rather than in the order they were given, each labeled with its streamline count; any streamline in no group is covered by an additional ``(ungrouped)`` checkbox. A streamline renders while it belongs to at least one checked group. From Python, toggle a group directly on the live viewer handle, e.g. ``handle.tracts.af.setGroupVisible("CST_L", False)``. .. note:: `points` must be expressed in the same mm space as the subject's fiducial surfaces (FreeSurfer scanner RAS mm). Tractography output is often already in this space when the diffusion data was registered to the same T1 used to generate the surfaces; otherwise, pass an `xfm` (4x4 affine) to :meth:`Tractogram.from_trx` to align the streamline positions onto the fiducial surface. From 5d8625ce7583c167beef83385bae453c9c3718df Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 02:42:13 +0000 Subject: [PATCH 3/5] FIX read the served metadata from its assignment, not from fromJSON `_served_metadata` located the dataset payload by the `dataset.fromJSON(` marker and JSON-decoded from there. Adding the tract panel changed mixer.html and static.html to assign the payload first, so that `viewer.addTracts(metadata.tracts)` has something to read: metadata = {{data}}; dataviews = dataset.fromJSON(metadata); That call now takes an identifier rather than a JSON literal, so the decode landed on `metadata);` and raised, failing the four tests that read the served metadata: json.decoder.JSONDecodeError: Expecting value: line 15603 column 30 The served page was always correct -- the viewer parses it fine -- so the marker moves to the assignment. Line 4 of each template declares `metadata` without an `=`, so the assignment is the page's first `metadata = `. Verified against a real served page: a viewer started with open_browser=False reproduces the failure at exactly the line and column CI reports, and returns the payload once the marker moves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015zgY3aPBUWUhHBpisvF6Kw --- cortex/tests/test_webgl_headless.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cortex/tests/test_webgl_headless.py b/cortex/tests/test_webgl_headless.py index 37cde0f1c..d838b9bb4 100644 --- a/cortex/tests/test_webgl_headless.py +++ b/cortex/tests/test_webgl_headless.py @@ -742,11 +742,17 @@ def _served_metadata(handle): ``show()`` regenerates the page from the same ``metadata`` dict that ``addData`` merges into, so this is how we check that a reload of the viewer would show everything that has been added so far. + + The payload is the right-hand side of ``mixer.html``'s ``metadata = ...`` + assignment, not the argument of the ``dataset.fromJSON()`` below it: that + call takes the variable, so decoding from there lands on an identifier and + raises. Line 4 of the template declares ``metadata`` without an ``=``, so + the assignment is the first ``metadata = `` in the page. """ url = "http://localhost:%d/mixer.html" % handle.server.port with urllib.request.urlopen(url, timeout=30) as resp: page = resp.read().decode("utf-8") - marker = "dataset.fromJSON(" + marker = "metadata = " start = page.index(marker) + len(marker) return json.JSONDecoder().raw_decode(page, start)[0] From de3b004acdc3a9047f90e6b10cb36110ceceeab0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 02:14:33 +0000 Subject: [PATCH 4/5] FIX address the review of the tractogram WebGL path A tractogram's name is a dataset key, i.e. an arbitrary string, and it was reaching both the buffer urls and the file names of `make_static` verbatim: `Dataset(**{"../../escape": tract})` wrote outside the output directory, and any name holding a `/` failed outright. Buffers now travel under a transport id (`cortex.webgl.data._tract_id`) that is safe as a single url segment and as a file name; ordinary names pass through unchanged, so the urls stay readable, and the metadata is still keyed by the name the user gave. The viewer keys its tractograms by name in a plain object too, where a tractogram called "constructor" or "toString" looked like one that was already loaded -- addTracts would "replace" it and rmTracts would then trip over the inherited value. Every lookup goes through `Viewer.hasTract` now, and the per-group maps, whose keys are bundle names, are prototype-free. Streamlines are meaningless against another subject's brain, but in a multi-subject dataset they stayed on screen when the active dataview (and with it the surface) switched subjects. A tractogram now shows only while a dataview of its own subject is active, lines and panel entry alike; and `Package` refuses at the outset a tractogram whose subject has no dataview at all, rather than booting a viewer that silently omits it. Tractograms load outside `viewer.loaded` on purpose, so that a slow streamline download cannot hold up an otherwise usable viewer -- which left `getImage`/`save_3d_views` free to screenshot a scene whose streamlines were still in flight. `headless_viewer` waits for `viewer.tractsState()` on top of `viewer.loaded`, and reports a failed download rather than waiting it out. The panel's controls were bare inputs with no accessible name, and the collapse control was a glyph in a : unreachable by keyboard and unannounced by a screen reader. They carry their own labels now, the collapse and all/none controls are real buttons, and each bundle row is a