Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 30 additions & 11 deletions cortex/dataset/viewRGB.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ def uniques(self, collapse=False):
if self.alpha is not None:
yield self.alpha

def _apply_nan_mask(self, alpha):
"""Apply stored NaN mask to alpha, enforcing transparency for NaN
positions even when the user overrides the alpha channel. uint8 RGB
channels cannot hold NaN, so the mask is captured before conversion
in Dataview.raw and stored as ``_nan_mask``."""
nan_mask = getattr(self, "_nan_mask", None)
if nan_mask is None:
return
if nan_mask.shape == alpha.data.shape:
alpha.data[nan_mask] = alpha.vmin
elif hasattr(alpha, "volume") and nan_mask.shape == alpha.volume.shape:
alpha.volume[nan_mask] = alpha.vmin

def _write_hdf(self, h5, name="data", xfmname=None):
self._cls._write_hdf(self.red, h5)
self._cls._write_hdf(self.green, h5)
Expand Down Expand Up @@ -264,21 +277,21 @@ def color_voxels(
needs_auto_min = any(v is None for v in channel_vmins)
needs_auto_max = any(v is None for v in channel_vmaxs)

if (needs_auto_min or needs_auto_max):
if autorange == 'shared':
if needs_auto_min or needs_auto_max:
if autorange == "shared":
all_data = np.concatenate([data1.ravel(), data2.ravel(), data3.ravel()])
shared_min = np.percentile(all_data, 1)
shared_max = np.percentile(all_data, 99)
channel_vmins = [shared_min if v is None else v for v in channel_vmins]
channel_vmaxs = [shared_max if v is None else v for v in channel_vmaxs]
elif autorange == 'individual':
elif autorange == "individual":
for i, data in enumerate([data1, data2, data3]):
if channel_vmins[i] is None:
channel_vmins[i] = np.percentile(data.ravel(), 1)
if channel_vmaxs[i] is None:
channel_vmaxs[i] = np.percentile(data.ravel(), 99)
else:
raise ValueError('autorange must be \'shared\' or \'individual\'')
raise ValueError("autorange must be 'shared' or 'individual'")

normalized = []
for channel, (data, channel_min, channel_max) in enumerate(
Expand All @@ -287,7 +300,9 @@ def color_voxels(
channel_range = channel_max - channel_min
if channel_range == 0:
warnings.warn(
"Channel {} has no dynamic range (vmin == vmax) and will be zeroed out".format(channel)
"Channel {} has no dynamic range (vmin == vmax) and will be zeroed out".format(
channel
)
)
normalized.append(np.zeros_like(data))
else:
Expand Down Expand Up @@ -432,7 +447,7 @@ def __init__(
max_color_saturation: float = 1.0,
vmin: Optional[Union[float, tuple]] = None,
vmax: Optional[Union[float, tuple]] = None,
autorange: str = 'individual',
autorange: str = "individual",
priority: int = 1,
):
channel1color = tuple(channel1color)
Expand Down Expand Up @@ -464,7 +479,7 @@ def __init__(
and (channel3color == Colors.Blue)
and vmin is None
and vmax is None
and autorange == 'individual'
and autorange == "individual"
):
# R/G/B basis can be directly passed through
self.red = channel1
Expand Down Expand Up @@ -506,7 +521,7 @@ def __init__(
and (channel3color == Colors.Blue)
and vmin is None
and vmax is None
and autorange == 'individual'
and autorange == "individual"
):
# R/G/B basis can be directly passed through
self.red = Volume(channel1, subject, xfmname)
Expand Down Expand Up @@ -567,6 +582,8 @@ def alpha(self):
rgb = np.array([self.red.volume, self.green.volume, self.blue.volume])
mask = np.isnan(rgb).any(axis=0)
alpha.volume[mask] = alpha.vmin

self._apply_nan_mask(alpha)
return alpha

@alpha.setter
Expand Down Expand Up @@ -728,7 +745,7 @@ def __init__(
max_color_saturation=1.0,
vmin=None,
vmax=None,
autorange='individual',
autorange="individual",
priority=1,
):
channel1color = tuple(channel1color)
Expand All @@ -750,7 +767,7 @@ def __init__(
and (channel3color == Colors.Blue)
and vmin is None
and vmax is None
and autorange == 'individual'
and autorange == "individual"
):
# R/G/B basis can be directly passed through
self.red = red
Expand Down Expand Up @@ -790,7 +807,7 @@ def __init__(
and (channel3color == Colors.Blue)
and vmin is None
and vmax is None
and autorange == 'individual'
and autorange == "individual"
):
# R/G/B basis can be directly passed through
self.red = Vertex(red, subject)
Expand Down Expand Up @@ -841,6 +858,8 @@ def alpha(self):
rgb = np.array([self.red.data, self.green.data, self.blue.data])
mask = np.isnan(rgb).any(axis=0)
alpha.data[mask] = alpha.vmin

self._apply_nan_mask(alpha)
return alpha

@alpha.setter
Expand Down
17 changes: 12 additions & 5 deletions cortex/dataset/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,13 +323,16 @@ def raw(self):
# Normalize colors according to vmin, vmax
norm = colors.Normalize(self.vmin, self.vmax)
cmapper = cm.ScalarMappable(norm=norm, cmap=cmap)
# Capture NaN mask before uint8 conversion (NaN info is lost after)
nan_mask = np.isnan(self.data)
# TODO: self.data relies on BrainData. Would need common inheritance for this to work.
color_data = cmapper.to_rgba(self.data.flatten()).reshape(
self.data.shape + (4,)
)
# rollaxis puts the last color dimension first, to allow output of separate channels: r,g,b,a = dataset.raw
color_data = (np.clip(color_data, 0, 1) * 255).astype(np.uint8)
return np.rollaxis(color_data, -1)
color_data[nan_mask, 3] = 0
return np.rollaxis(color_data, -1), nan_mask


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

@property
def raw(self):
r, g, b, a = super(Volume, self).raw
return VolumeRGB(
(r, g, b, a), nan_mask = super(Volume, self).raw
result = VolumeRGB(
r,
g,
b,
Expand All @@ -441,6 +444,8 @@ def raw(self):
state=self.state,
priority=self.priority,
)
result._nan_mask = nan_mask
return result


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

@property
def raw(self):
r, g, b, a = super(Vertex, self).raw
return VertexRGB(
(r, g, b, a), nan_mask = super(Vertex, self).raw
result = VertexRGB(
r,
g,
b,
Expand All @@ -522,6 +527,8 @@ def raw(self):
state=self.state,
priority=self.priority,
)
result._nan_mask = nan_mask
return result

def map(
self,
Expand Down
90 changes: 90 additions & 0 deletions cortex/tests/test_dataset.py
Original file line number Diff line number Diff line change
Expand Up @@ -356,3 +356,93 @@ def test_warn_non_perceptually_uniform_2D_cmap():
)
with pytest.warns(UserWarning):
cortex.quickshow(view)


def test_nan_transparent_vertex_raw():
"""NaN values in Vertex.raw should have alpha=0 (transparent)."""
data = np.random.randn(nverts)
nan_indices = [0, 10, 100, nverts - 1]
data[nan_indices] = np.nan

vtx = dataset.Vertex(data, subj, vmin=-2, vmax=2, cmap="RdBu_r")
raw = vtx.raw

# Default alpha: NaN positions should have alpha=0
vertices = raw.vertices # (1, nverts, 4)
for idx in nan_indices:
assert vertices[0, idx, 3] == 0, (
f"NaN vertex {idx} should have alpha=0, got {vertices[0, idx, 3]}"
)

# Non-NaN positions should have non-zero alpha
non_nan_idx = 1
assert not np.isnan(data[non_nan_idx])
assert vertices[0, non_nan_idx, 3] > 0


def test_nan_transparent_vertex_raw_alpha_override():
"""NaN values should remain transparent even when user overrides alpha."""
data = np.random.randn(nverts)
nan_indices = [0, 10, 100, nverts - 1]
data[nan_indices] = np.nan

vtx = dataset.Vertex(data, subj, vmin=-2, vmax=2, cmap="RdBu_r")
raw = vtx.raw

# Override alpha with all-opaque values
alpha = np.ones(nverts) * 0.8
raw.alpha = alpha

vertices = raw.vertices # (1, nverts, 4)
for idx in nan_indices:
assert vertices[0, idx, 3] == 0, (
f"NaN vertex {idx} should have alpha=0 after override, got {vertices[0, idx, 3]}"
)

# Non-NaN positions should reflect the user's alpha
non_nan_idx = 1
assert not np.isnan(data[non_nan_idx])
assert vertices[0, non_nan_idx, 3] > 0


def test_nan_transparent_volume_raw():
"""NaN values in Volume.raw should have alpha=0 (transparent)."""
data = np.random.randn(*volshape)
data[0, 0, 0] = np.nan
data[10, 50, 50] = np.nan

vol = dataset.Volume(data, subj, xfmname, vmin=-2, vmax=2, cmap="RdBu_r")
raw = vol.raw

# Default alpha: NaN positions should have alpha=0
volume = raw.volume # (1, z, y, x, 4)
assert volume[0, 0, 0, 0, 3] == 0
assert volume[0, 10, 50, 50, 3] == 0

# Non-NaN positions should have non-zero alpha
assert not np.isnan(data[15, 50, 50])
assert volume[0, 15, 50, 50, 3] > 0


def test_nan_transparent_volume_raw_alpha_override():
"""NaN values should remain transparent even when user overrides alpha."""
data = np.random.randn(*volshape)
data[0, 0, 0] = np.nan
data[10, 50, 50] = np.nan

vol = dataset.Volume(data, subj, xfmname, vmin=-2, vmax=2, cmap="RdBu_r")
raw = vol.raw

# Override alpha with all-opaque values
alpha = np.ones(volshape) * 0.8
raw.alpha = alpha

volume = raw.volume # (1, z, y, x, 4)
assert volume[0, 0, 0, 0, 3] == 0, (
f"NaN voxel should have alpha=0 after override, got {volume[0, 0, 0, 0, 3]}"
)
assert volume[0, 10, 50, 50, 3] == 0

# Non-NaN positions should reflect the user's alpha
assert not np.isnan(data[15, 50, 50])
assert volume[0, 15, 50, 50, 3] > 0
23 changes: 23 additions & 0 deletions cortex/webgl/resources/js/dataset.js
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,7 @@ var dataset = (function(module) {
this.frames = json.frames;

this.verts = [];
this.nanmasks = [];
NParray.fromURL(this.data[0], function(array) {
array.loaded.progress(function(available){
var data = array.view(available-1).data;
Expand Down Expand Up @@ -418,11 +419,30 @@ var dataset = (function(module) {
}

} else {
// Remap indices and detect NaN in a single pass.
// WebGL drivers may sanitize NaN in vertex attributes,
// so we build a mask and replace NaN with 0 here.
var hasNaN = false;
for (var i = 0; i < sleft.length; i++) {
sleft[i] = left[hemis.left.reverseIndexMap[i]];
if (isNaN(sleft[i])) hasNaN = true;
}
for (var i = 0; i < sright.length; i++) {
sright[i] = right[hemis.right.reverseIndexMap[i]];
if (isNaN(sright[i])) hasNaN = true;
}
if (hasNaN) {
var masks = [sleft, sright].map(function(arr) {
var mask = new Float32Array(arr.length);
for (var i = 0; i < arr.length; i++) {
if (isNaN(arr[i])) { mask[i] = 0.0; arr[i] = 0.0; }
else { mask[i] = 1.0; }
}
var attr = new THREE.BufferAttribute(mask, 1);
attr.needsUpdate = true;
return attr;
});
this.nanmasks.push(masks);
}
}
var lattr = new THREE.BufferAttribute(sleft, this.raw?4:1);
Expand All @@ -447,6 +467,9 @@ var dataset = (function(module) {
var name = dim == 0 ? "data0":"data2";
dispatch({type:"attribute", name:"data"+(2*dim), value:this.verts[fframe]});
dispatch({type:"attribute", name:"data"+(2*dim+1), value:this.verts[(fframe+1).mod(this.verts.length)]});
if (this.nanmasks.length > 0) {
dispatch({type:"attribute", name:"nanmask", value:this.nanmasks[fframe]});
}
}

return module;
Expand Down
1 change: 1 addition & 0 deletions cortex/webgl/resources/js/mriview_surface.js
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ var mriview = (function(module) {
hemi.addAttribute("data1", new THREE.BufferAttribute(new Float32Array(), 1));
hemi.addAttribute("data2", new THREE.BufferAttribute(new Float32Array(), 1));
hemi.addAttribute("data3", new THREE.BufferAttribute(new Float32Array(), 1));
hemi.addAttribute("nanmask", new THREE.BufferAttribute(new Float32Array(), 1));

hemi.dynamic = true;
var pivots = {back:new THREE.Group(), front:new THREE.Group()};
Expand Down
7 changes: 7 additions & 0 deletions cortex/webgl/resources/js/shaderlib.js
Original file line number Diff line number Diff line change
Expand Up @@ -718,6 +718,7 @@ var Shaderlib = (function() {
"attribute float data1;",
"attribute float data2;",
"attribute float data3;",
"attribute float nanmask;",
"#endif",

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

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

if (!opts.rgb)
attributes['nanmask'] = {type:'f', value:null};

for (var i = 0; i < morphs-1; i++) {
attributes['mixSurfs'+i] = { type:'v4', value:null };
attributes['mixNorms'+i] = { type:'v3', value:null };
Expand Down