Skip to content

Commit f27bfd1

Browse files
Copilotmvdoc
andauthored
FIX NaN values in Volume/Vertex rendering as black instead of transparent (#612)
* Initial plan * Initial plan for fixing NaN transparency issue Agent-Logs-Url: https://github.com/gallantlab/pycortex/sessions/a5d0ee68-41d6-4451-8ed5-a84b060a457f Co-authored-by: mvdoc <6150554+mvdoc@users.noreply.github.com> * Fix NaN values rendering as black instead of transparent in Volume/Vertex raw When using Volume.raw or Vertex.raw, NaN values in data were rendered as opaque black because: (1) colormap conversion maps NaN to black RGB, and (2) after uint8 conversion, the NaN info is lost so the alpha getter's NaN detection fails. Fix: - In Dataview.raw: explicitly set alpha=0 for NaN positions before returning the RGBA channels - In Volume.raw/Vertex.raw: store a _nan_mask on the RGB object - In VolumeRGB/VertexRGB alpha getters: apply stored _nan_mask to enforce alpha=0 for NaN positions, even after user alpha override Agent-Logs-Url: https://github.com/gallantlab/pycortex/sessions/a5d0ee68-41d6-4451-8ed5-a84b060a457f Co-authored-by: mvdoc <6150554+mvdoc@users.noreply.github.com> * Revert unintended change to overlays.svg Agent-Logs-Url: https://github.com/gallantlab/pycortex/sessions/a5d0ee68-41d6-4451-8ed5-a84b060a457f Co-authored-by: mvdoc <6150554+mvdoc@users.noreply.github.com> * Address code review comments with improved documentation Agent-Logs-Url: https://github.com/gallantlab/pycortex/sessions/a5d0ee68-41d6-4451-8ed5-a84b060a457f Co-authored-by: mvdoc <6150554+mvdoc@users.noreply.github.com> * Restore overlays.svg to match origin/main Agent-Logs-Url: https://github.com/gallantlab/pycortex/sessions/a5d0ee68-41d6-4451-8ed5-a84b060a457f Co-authored-by: mvdoc <6150554+mvdoc@users.noreply.github.com> * Fix NaN handling in WebGL surface_vertex shader for Vertex objects (issue #455) Add NaN detection in the surface_vertex shader's colormap lookup. When vertex data contains NaN, the normalized colormap coordinates (cuv.x/cuv.y) become NaN. Use the GLSL idiom (x != x) to detect NaN and set vColor to vec4(0.) (fully transparent) so NaN vertices are discarded instead of rendered as colored. Agent-Logs-Url: https://github.com/gallantlab/pycortex/sessions/4091acc2-7dc4-4b78-bd80-52e45c73d1ea Co-authored-by: mvdoc <6150554+mvdoc@users.noreply.github.com> * FIX NaN transparency in WebGL surface_vertex shader via JS-side nanmask WebGL drivers sanitize NaN values in vertex attributes to 0, so the shader-only NaN detection (cuv.x != cuv.x) never fires. Instead, detect NaN in JavaScript when vertex data is loaded, build a nanmask attribute (1=valid, 0=NaN), replace NaN with 0 in the data, and check the mask in the vertex shader. Also refactors the Python side: - Dataview.raw returns (rgba, nan_mask) to avoid redundant np.isnan - Volume.raw / Vertex.raw reuse the mask from the parent - DataviewRGB._apply_nan_mask() consolidates duplicated logic --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mvdoc <6150554+mvdoc@users.noreply.github.com> Co-authored-by: Matteo Visconti di Oleggio Castello <mvdoc@berkeley.edu>
1 parent 5fdd3f5 commit f27bfd1

6 files changed

Lines changed: 163 additions & 16 deletions

File tree

cortex/dataset/viewRGB.py

Lines changed: 30 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,19 @@ def uniques(self, collapse=False):
112112
if self.alpha is not None:
113113
yield self.alpha
114114

115+
def _apply_nan_mask(self, alpha):
116+
"""Apply stored NaN mask to alpha, enforcing transparency for NaN
117+
positions even when the user overrides the alpha channel. uint8 RGB
118+
channels cannot hold NaN, so the mask is captured before conversion
119+
in Dataview.raw and stored as ``_nan_mask``."""
120+
nan_mask = getattr(self, "_nan_mask", None)
121+
if nan_mask is None:
122+
return
123+
if nan_mask.shape == alpha.data.shape:
124+
alpha.data[nan_mask] = alpha.vmin
125+
elif hasattr(alpha, "volume") and nan_mask.shape == alpha.volume.shape:
126+
alpha.volume[nan_mask] = alpha.vmin
127+
115128
def _write_hdf(self, h5, name="data", xfmname=None):
116129
self._cls._write_hdf(self.red, h5)
117130
self._cls._write_hdf(self.green, h5)
@@ -264,21 +277,21 @@ def color_voxels(
264277
needs_auto_min = any(v is None for v in channel_vmins)
265278
needs_auto_max = any(v is None for v in channel_vmaxs)
266279

267-
if (needs_auto_min or needs_auto_max):
268-
if autorange == 'shared':
280+
if needs_auto_min or needs_auto_max:
281+
if autorange == "shared":
269282
all_data = np.concatenate([data1.ravel(), data2.ravel(), data3.ravel()])
270283
shared_min = np.percentile(all_data, 1)
271284
shared_max = np.percentile(all_data, 99)
272285
channel_vmins = [shared_min if v is None else v for v in channel_vmins]
273286
channel_vmaxs = [shared_max if v is None else v for v in channel_vmaxs]
274-
elif autorange == 'individual':
287+
elif autorange == "individual":
275288
for i, data in enumerate([data1, data2, data3]):
276289
if channel_vmins[i] is None:
277290
channel_vmins[i] = np.percentile(data.ravel(), 1)
278291
if channel_vmaxs[i] is None:
279292
channel_vmaxs[i] = np.percentile(data.ravel(), 99)
280293
else:
281-
raise ValueError('autorange must be \'shared\' or \'individual\'')
294+
raise ValueError("autorange must be 'shared' or 'individual'")
282295

283296
normalized = []
284297
for channel, (data, channel_min, channel_max) in enumerate(
@@ -287,7 +300,9 @@ def color_voxels(
287300
channel_range = channel_max - channel_min
288301
if channel_range == 0:
289302
warnings.warn(
290-
"Channel {} has no dynamic range (vmin == vmax) and will be zeroed out".format(channel)
303+
"Channel {} has no dynamic range (vmin == vmax) and will be zeroed out".format(
304+
channel
305+
)
291306
)
292307
normalized.append(np.zeros_like(data))
293308
else:
@@ -432,7 +447,7 @@ def __init__(
432447
max_color_saturation: float = 1.0,
433448
vmin: Optional[Union[float, tuple]] = None,
434449
vmax: Optional[Union[float, tuple]] = None,
435-
autorange: str = 'individual',
450+
autorange: str = "individual",
436451
priority: int = 1,
437452
):
438453
channel1color = tuple(channel1color)
@@ -464,7 +479,7 @@ def __init__(
464479
and (channel3color == Colors.Blue)
465480
and vmin is None
466481
and vmax is None
467-
and autorange == 'individual'
482+
and autorange == "individual"
468483
):
469484
# R/G/B basis can be directly passed through
470485
self.red = channel1
@@ -506,7 +521,7 @@ def __init__(
506521
and (channel3color == Colors.Blue)
507522
and vmin is None
508523
and vmax is None
509-
and autorange == 'individual'
524+
and autorange == "individual"
510525
):
511526
# R/G/B basis can be directly passed through
512527
self.red = Volume(channel1, subject, xfmname)
@@ -567,6 +582,8 @@ def alpha(self):
567582
rgb = np.array([self.red.volume, self.green.volume, self.blue.volume])
568583
mask = np.isnan(rgb).any(axis=0)
569584
alpha.volume[mask] = alpha.vmin
585+
586+
self._apply_nan_mask(alpha)
570587
return alpha
571588

572589
@alpha.setter
@@ -728,7 +745,7 @@ def __init__(
728745
max_color_saturation=1.0,
729746
vmin=None,
730747
vmax=None,
731-
autorange='individual',
748+
autorange="individual",
732749
priority=1,
733750
):
734751
channel1color = tuple(channel1color)
@@ -750,7 +767,7 @@ def __init__(
750767
and (channel3color == Colors.Blue)
751768
and vmin is None
752769
and vmax is None
753-
and autorange == 'individual'
770+
and autorange == "individual"
754771
):
755772
# R/G/B basis can be directly passed through
756773
self.red = red
@@ -790,7 +807,7 @@ def __init__(
790807
and (channel3color == Colors.Blue)
791808
and vmin is None
792809
and vmax is None
793-
and autorange == 'individual'
810+
and autorange == "individual"
794811
):
795812
# R/G/B basis can be directly passed through
796813
self.red = Vertex(red, subject)
@@ -841,6 +858,8 @@ def alpha(self):
841858
rgb = np.array([self.red.data, self.green.data, self.blue.data])
842859
mask = np.isnan(rgb).any(axis=0)
843860
alpha.data[mask] = alpha.vmin
861+
862+
self._apply_nan_mask(alpha)
844863
return alpha
845864

846865
@alpha.setter

cortex/dataset/views.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -323,13 +323,16 @@ def raw(self):
323323
# Normalize colors according to vmin, vmax
324324
norm = colors.Normalize(self.vmin, self.vmax)
325325
cmapper = cm.ScalarMappable(norm=norm, cmap=cmap)
326+
# Capture NaN mask before uint8 conversion (NaN info is lost after)
327+
nan_mask = np.isnan(self.data)
326328
# TODO: self.data relies on BrainData. Would need common inheritance for this to work.
327329
color_data = cmapper.to_rgba(self.data.flatten()).reshape(
328330
self.data.shape + (4,)
329331
)
330332
# rollaxis puts the last color dimension first, to allow output of separate channels: r,g,b,a = dataset.raw
331333
color_data = (np.clip(color_data, 0, 1) * 255).astype(np.uint8)
332-
return np.rollaxis(color_data, -1)
334+
color_data[nan_mask, 3] = 0
335+
return np.rollaxis(color_data, -1), nan_mask
333336

334337

335338
class Multiview(Dataview):
@@ -429,8 +432,8 @@ def _write_hdf(self, h5, name="data"):
429432

430433
@property
431434
def raw(self):
432-
r, g, b, a = super(Volume, self).raw
433-
return VolumeRGB(
435+
(r, g, b, a), nan_mask = super(Volume, self).raw
436+
result = VolumeRGB(
434437
r,
435438
g,
436439
b,
@@ -441,6 +444,8 @@ def raw(self):
441444
state=self.state,
442445
priority=self.priority,
443446
)
447+
result._nan_mask = nan_mask
448+
return result
444449

445450

446451
class Vertex(VertexData, Dataview):
@@ -511,8 +516,8 @@ def _write_hdf(self, h5, name="data"):
511516

512517
@property
513518
def raw(self):
514-
r, g, b, a = super(Vertex, self).raw
515-
return VertexRGB(
519+
(r, g, b, a), nan_mask = super(Vertex, self).raw
520+
result = VertexRGB(
516521
r,
517522
g,
518523
b,
@@ -522,6 +527,8 @@ def raw(self):
522527
state=self.state,
523528
priority=self.priority,
524529
)
530+
result._nan_mask = nan_mask
531+
return result
525532

526533
def map(
527534
self,

cortex/tests/test_dataset.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,3 +356,93 @@ def test_warn_non_perceptually_uniform_2D_cmap():
356356
)
357357
with pytest.warns(UserWarning):
358358
cortex.quickshow(view)
359+
360+
361+
def test_nan_transparent_vertex_raw():
362+
"""NaN values in Vertex.raw should have alpha=0 (transparent)."""
363+
data = np.random.randn(nverts)
364+
nan_indices = [0, 10, 100, nverts - 1]
365+
data[nan_indices] = np.nan
366+
367+
vtx = dataset.Vertex(data, subj, vmin=-2, vmax=2, cmap="RdBu_r")
368+
raw = vtx.raw
369+
370+
# Default alpha: NaN positions should have alpha=0
371+
vertices = raw.vertices # (1, nverts, 4)
372+
for idx in nan_indices:
373+
assert vertices[0, idx, 3] == 0, (
374+
f"NaN vertex {idx} should have alpha=0, got {vertices[0, idx, 3]}"
375+
)
376+
377+
# Non-NaN positions should have non-zero alpha
378+
non_nan_idx = 1
379+
assert not np.isnan(data[non_nan_idx])
380+
assert vertices[0, non_nan_idx, 3] > 0
381+
382+
383+
def test_nan_transparent_vertex_raw_alpha_override():
384+
"""NaN values should remain transparent even when user overrides alpha."""
385+
data = np.random.randn(nverts)
386+
nan_indices = [0, 10, 100, nverts - 1]
387+
data[nan_indices] = np.nan
388+
389+
vtx = dataset.Vertex(data, subj, vmin=-2, vmax=2, cmap="RdBu_r")
390+
raw = vtx.raw
391+
392+
# Override alpha with all-opaque values
393+
alpha = np.ones(nverts) * 0.8
394+
raw.alpha = alpha
395+
396+
vertices = raw.vertices # (1, nverts, 4)
397+
for idx in nan_indices:
398+
assert vertices[0, idx, 3] == 0, (
399+
f"NaN vertex {idx} should have alpha=0 after override, got {vertices[0, idx, 3]}"
400+
)
401+
402+
# Non-NaN positions should reflect the user's alpha
403+
non_nan_idx = 1
404+
assert not np.isnan(data[non_nan_idx])
405+
assert vertices[0, non_nan_idx, 3] > 0
406+
407+
408+
def test_nan_transparent_volume_raw():
409+
"""NaN values in Volume.raw should have alpha=0 (transparent)."""
410+
data = np.random.randn(*volshape)
411+
data[0, 0, 0] = np.nan
412+
data[10, 50, 50] = np.nan
413+
414+
vol = dataset.Volume(data, subj, xfmname, vmin=-2, vmax=2, cmap="RdBu_r")
415+
raw = vol.raw
416+
417+
# Default alpha: NaN positions should have alpha=0
418+
volume = raw.volume # (1, z, y, x, 4)
419+
assert volume[0, 0, 0, 0, 3] == 0
420+
assert volume[0, 10, 50, 50, 3] == 0
421+
422+
# Non-NaN positions should have non-zero alpha
423+
assert not np.isnan(data[15, 50, 50])
424+
assert volume[0, 15, 50, 50, 3] > 0
425+
426+
427+
def test_nan_transparent_volume_raw_alpha_override():
428+
"""NaN values should remain transparent even when user overrides alpha."""
429+
data = np.random.randn(*volshape)
430+
data[0, 0, 0] = np.nan
431+
data[10, 50, 50] = np.nan
432+
433+
vol = dataset.Volume(data, subj, xfmname, vmin=-2, vmax=2, cmap="RdBu_r")
434+
raw = vol.raw
435+
436+
# Override alpha with all-opaque values
437+
alpha = np.ones(volshape) * 0.8
438+
raw.alpha = alpha
439+
440+
volume = raw.volume # (1, z, y, x, 4)
441+
assert volume[0, 0, 0, 0, 3] == 0, (
442+
f"NaN voxel should have alpha=0 after override, got {volume[0, 0, 0, 0, 3]}"
443+
)
444+
assert volume[0, 10, 50, 50, 3] == 0
445+
446+
# Non-NaN positions should reflect the user's alpha
447+
assert not np.isnan(data[15, 50, 50])
448+
assert volume[0, 15, 50, 50, 3] > 0

cortex/webgl/resources/js/dataset.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,7 @@ var dataset = (function(module) {
391391
this.frames = json.frames;
392392

393393
this.verts = [];
394+
this.nanmasks = [];
394395
NParray.fromURL(this.data[0], function(array) {
395396
array.loaded.progress(function(available){
396397
var data = array.view(available-1).data;
@@ -418,11 +419,30 @@ var dataset = (function(module) {
418419
}
419420

420421
} else {
422+
// Remap indices and detect NaN in a single pass.
423+
// WebGL drivers may sanitize NaN in vertex attributes,
424+
// so we build a mask and replace NaN with 0 here.
425+
var hasNaN = false;
421426
for (var i = 0; i < sleft.length; i++) {
422427
sleft[i] = left[hemis.left.reverseIndexMap[i]];
428+
if (isNaN(sleft[i])) hasNaN = true;
423429
}
424430
for (var i = 0; i < sright.length; i++) {
425431
sright[i] = right[hemis.right.reverseIndexMap[i]];
432+
if (isNaN(sright[i])) hasNaN = true;
433+
}
434+
if (hasNaN) {
435+
var masks = [sleft, sright].map(function(arr) {
436+
var mask = new Float32Array(arr.length);
437+
for (var i = 0; i < arr.length; i++) {
438+
if (isNaN(arr[i])) { mask[i] = 0.0; arr[i] = 0.0; }
439+
else { mask[i] = 1.0; }
440+
}
441+
var attr = new THREE.BufferAttribute(mask, 1);
442+
attr.needsUpdate = true;
443+
return attr;
444+
});
445+
this.nanmasks.push(masks);
426446
}
427447
}
428448
var lattr = new THREE.BufferAttribute(sleft, this.raw?4:1);
@@ -447,6 +467,9 @@ var dataset = (function(module) {
447467
var name = dim == 0 ? "data0":"data2";
448468
dispatch({type:"attribute", name:"data"+(2*dim), value:this.verts[fframe]});
449469
dispatch({type:"attribute", name:"data"+(2*dim+1), value:this.verts[(fframe+1).mod(this.verts.length)]});
470+
if (this.nanmasks.length > 0) {
471+
dispatch({type:"attribute", name:"nanmask", value:this.nanmasks[fframe]});
472+
}
450473
}
451474

452475
return module;

cortex/webgl/resources/js/mriview_surface.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ var mriview = (function(module) {
256256
hemi.addAttribute("data1", new THREE.BufferAttribute(new Float32Array(), 1));
257257
hemi.addAttribute("data2", new THREE.BufferAttribute(new Float32Array(), 1));
258258
hemi.addAttribute("data3", new THREE.BufferAttribute(new Float32Array(), 1));
259+
hemi.addAttribute("nanmask", new THREE.BufferAttribute(new Float32Array(), 1));
259260

260261
hemi.dynamic = true;
261262
var pivots = {back:new THREE.Group(), front:new THREE.Group()};

cortex/webgl/resources/js/shaderlib.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -718,6 +718,7 @@ var Shaderlib = (function() {
718718
"attribute float data1;",
719719
"attribute float data2;",
720720
"attribute float data3;",
721+
"attribute float nanmask;",
721722
"#endif",
722723

723724
"attribute vec4 wm;",
@@ -752,6 +753,9 @@ var Shaderlib = (function() {
752753
"cuv.y = (mix(data2, data3, framemix) - vmin[1]) / (vmax[1] - vmin[1]);",
753754
"#endif",
754755
"vColor = texture2D(colormap, cuv);",
756+
// NaN mask: WebGL drivers sanitize NaN in vertex attributes,
757+
// so we detect NaN in JavaScript and pass a mask (0=NaN, 1=valid).
758+
"if (nanmask < 0.5) vColor = vec4(0.);",
755759
"#endif",
756760

757761
"#ifdef CORTSHEET",
@@ -866,6 +870,9 @@ var Shaderlib = (function() {
866870
for (var i = 0; i < 4; i++)
867871
attributes['data'+i] = {type:opts.rgb ? 'v4':'f', value:null};
868872

873+
if (!opts.rgb)
874+
attributes['nanmask'] = {type:'f', value:null};
875+
869876
for (var i = 0; i < morphs-1; i++) {
870877
attributes['mixSurfs'+i] = { type:'v4', value:null };
871878
attributes['mixNorms'+i] = { type:'v3', value:null };

0 commit comments

Comments
 (0)