Commit 5e67ff5b authored by Paul McCarthy's avatar Paul McCarthy 🚵
Browse files

Merge branch 'enh/streamline-ortho' into 'master'

ENH: Draw tractograms in 2D

See merge request fsl/fsleyes/fsleyes!312
parents aa8b55e2 ed4e66f2
......@@ -107,7 +107,7 @@ sleep 5
export MESA_GL_VERSION_OVERRIDE=3.3
export FSLEYES_TEST_GL=3.3
export LOCAL_TEST_FSLEYES=1
((pytest --cov-report= --cov-append -m "gl33test" && echo "0" > status) || echo "1" > status) || true
((pytest --cov-report= --cov-append -m "overlayclitest and gl33test" && echo "0" > status) || echo "1" > status) || true
status=`cat status`
failed=`echo "$status + $failed" | bc`
......
......@@ -19,8 +19,7 @@ Added
* FSLeyes is now able to visualise TrackVis ``.trk`` and Mrtrix3 ``.tck``
tractogram files, containing tractography streamlines These
tractogram files are currently only supported in the 3D view (!307).
tractogram files, containing tractography streamlines (!307, !312).
* New *Invert modulata alpha* display setting (available via the
``--inverModulateAlpha`` command-line option), which can be used to
make regions with high intensity more transparent (!311).
......
......@@ -5,9 +5,19 @@
uniform mat4 MVP;
/* Vertex coordinates. */
/* Streamline vertex coordinates. */
attribute vec3 vertex;
{% if twod %}
/*
* Current vertex coordinate on circle
* used to draw the current streamline
* point, defined in normalised device
* coordinates.
*/
attribute vec3 circleVertex;
{% endif %}
/* Unused */
uniform int resolution;
uniform bool lighting;
......@@ -63,5 +73,9 @@ void main(void) {
fragClipVertexData = clipVertexData;
{% endif %}
{% if twod %}
gl_Position = MVP * vec4(vertex, 1) + vec4(circleVertex, 0);
{% else %}
gl_Position = MVP * vec4(vertex, 1);
{% endif %}
}
......@@ -37,11 +37,12 @@ out vec3 fragVertex;
out vec3 fragNormal;
/*
* Line/cylinder width - must be defined
* in terms of normalised device coordinates
* Line width scaling factors along x/y
* axesa - must be defined in terms of
* normalised device coordinates
* (i.e. after MVP has been applied).
*/
uniform float lineWidth;
uniform vec2 lineWidth;
/* Not used */
uniform int resolution;
......@@ -73,7 +74,7 @@ void main(void) {
projstart = start - camera * dot(start, camera);
projend = end - camera * dot(end, camera);
offset = projend - projstart;
offset = normalize(cameraRotation * offset) * lineWidth / 2;
offset = normalize(cameraRotation * offset) * vec3(lineWidth, 0) / 2;
// Lighting is not currently applied
// to line geometry, but we should
......
/*
* Geometry shader for rendering GLTractogram instances.
* Turns individual streamline points into 2D polygons.
*/
#version 330
layout (points) in;
layout (triangle_strip, max_vertices=26) out;
/*
* Vertex data - passed straight through to
* fragment shader.
*/
{% if colourMode == 'orientation' %}
in vec3 geomOrient[];
out vec3 fragOrient;
{% elif colourMode == 'vertexData' %}
in float geomVertexData[];
out float fragVertexData;
{% endif %}
{% if colourMode == 'imageData' or clipMode == 'imageData' %}
in vec3 geomVertex[];
out vec3 fragVertexWorld;
{% endif %}
{% if clipMode == 'vertexData' %}
in float geomClipVertexData[];
out float fragClipVertexData;
{% endif %}
/*
* Not set.
*/
out vec3 fragVertex;
out vec3 fragNormal;
/*
* Horizontal/vertical width / scaling factors
*/
uniform float xscale;
uniform float yscale;
/* Angular resolution */
uniform int resolution;
void main(void) {
int i;
vec3 vertex;
int res = clamp(resolution, 3, 10);
vec3 origin = gl_in[0].gl_Position.xyz;
float delta = 6.283185307179586 / res;
float angle = delta;
{% if colourMode == 'orientation' %}
fragOrient = geomOrient[0];
{% elif colourMode == 'vertexData' %}
fragVertexData = geomVertexData[0];
{% endif %}
{% if colourMode == 'imageData' or clipMode == 'imageData' %}
fragVertexWorld = geomVertex[0];
{% endif %}
{% if clipMode == 'vertexData' %}
fragClipVertexData = geomClipVertexData[0];
{% endif %}
// avoid the initial calls to sin(0)/cos(0)
gl_Position = vec4(origin.x, origin.y + yscale, origin.z, 1);
EmitVertex();
gl_Position.xyz = origin;
EmitVertex();
for (i = 1; i < res; i++) {
vertex.xyz = origin.xyz;
vertex.x += sin(angle) * xscale;
vertex.y += cos(angle) * yscale;
angle = angle + delta;
gl_Position.xyz = vertex;
EmitVertex();
gl_Position.xyz = origin;
EmitVertex();
}
gl_Position.xyz = vec3(origin.x, origin.y + yscale, origin.z);
EmitVertex();
EndPrimitive();
}
......@@ -38,12 +38,14 @@ out float fragClipVertexData;
out vec3 fragVertex;
out vec3 fragNormal;
/*
* Line/cylinder width - must be defined
* in terms of normalised device coordinates
* Tube width scaling factors along x/y
* axesa - must be defined in terms of
* normalised device coordinates
* (i.e. after MVP has been applied).
*/
uniform float lineWidth;
uniform vec2 lineWidth;
/*
* Cylinder resolution - it will be drawn
......@@ -99,7 +101,7 @@ void main(void) {
// Offset from the line at the current angle,
// on the plane perpendicular to the line.
offset = normalize(((normalx * cosa) + (normaly * sina)));
scaledOffset = offset * lineWidth / 2;
scaledOffset = offset * vec3(lineWidth, 0) / 2;
{% if colourMode == 'orientation' %}
fragOrient = geomOrient[0];
......
......@@ -15,6 +15,7 @@ import os.path as op
import wx
import fsl.data.image as fslimage
import fsleyes_props as props
import fsleyes_widgets as fwidgets
import fsleyes_widgets.utils.typedict as td
......@@ -66,6 +67,7 @@ class OverlayDisplayToolBar(ctrlpanel.ControlToolBar):
_OverlayDisplayToolBar__makeTensorOptsTools
_OverlayDisplayToolBar__makeSHOptsTools
_OverlayDisplayToolBar__makeMIPOptsTools
_OverlayDisplayToolBar__makeTractogramOptsTools
"""
......@@ -615,6 +617,49 @@ class OverlayDisplayToolBar(ctrlpanel.ControlToolBar):
return tools, nav
def __makeTractogramOptsTools(self, opts):
"""Creates and returns a collection of controls for editing properties
of a :class:`.TractogramOpts` instance.
"""
widthSpec = self.__widgetSpecs[opts, 'lineWidth']
resSpec = self.__widgetSpecs[opts, 'resolution']
colourSpec = self.__widgetSpecs[opts, 'colourMode']
cmapSpec = self.__widgetSpecs[opts, 'cmap']
sliderPanel = wx.Panel(self)
colourPanel = wx.Panel(self)
widthWidget = props.buildGUI(sliderPanel, opts, widthSpec)
resWidget = props.buildGUI(sliderPanel, opts, resSpec)
colourWidget = props.buildGUI(colourPanel, opts, colourSpec)
cmapWidget = props.buildGUI(colourPanel, opts, cmapSpec)
widthLabel = wx.StaticText(sliderPanel)
resLabel = wx.StaticText(sliderPanel)
widthLabel.SetLabel(strings.properties[opts, 'lineWidth'])
resLabel .SetLabel(strings.properties[opts, 'resolution'])
sliderSizer = wx.FlexGridSizer(2, 2, 0, 0)
sliderPanel.SetSizer(sliderSizer)
sliderSizer.AddGrowableCol(1)
sliderSizer.Add(widthLabel)
sliderSizer.Add(widthWidget)
sliderSizer.Add(resLabel)
sliderSizer.Add(resWidget)
colourSizer = wx.BoxSizer(wx.VERTICAL)
colourPanel.SetSizer(colourSizer)
colourSizer.Add(colourWidget, flag=wx.EXPAND, proportion=1)
colourSizer.Add(cmapWidget, flag=wx.EXPAND, proportion=1)
tools = [sliderPanel, colourPanel]
nav = [widthWidget, resWidget, colourWidget, cmapWidget]
return tools, nav
def __generateWidgetSpecs(self):
"""Called by :meth:`__init__`. Creates specifications for the toolbar
widgets for all overlay types.
......@@ -632,6 +677,17 @@ class OverlayDisplayToolBar(ctrlpanel.ControlToolBar):
if p is None: return 'None'
else: return op.basename(p)
# TractogramOpts.colourMode
def _colourModeLabel(data):
if data == 'orientation':
return 'Orientation'
if data is None:
return 'None'
elif isinstance(data, fslimage.Image):
return self.displayCtx.getDisplay(data).name
else:
return op.basename(data)
self.__widgetSpecs = td.TypeDict({
'Display.name' : props.Widget(
......@@ -814,6 +870,27 @@ class OverlayDisplayToolBar(ctrlpanel.ControlToolBar):
'cmap',
labels=fslcm.getColourMapLabel,
tooltip=_TOOLTIPS['VolumeOpts.cmap']),
'TractogramOpts.lineWidth' : props.Widget(
'lineWidth',
showLimits=False,
slider=True,
spin=False,
tooltip=_TOOLTIPS['TractogramOpts.lineWidth']),
'TractogramOpts.resolution' : props.Widget(
'resolution',
showLimits=False,
slider=True,
spin=False,
tooltip=_TOOLTIPS['TractogramOpts.resolution']),
'TractogramOpts.colourMode' : props.Widget(
'colourMode',
labels=_colourModeLabel,
tooltip=_TOOLTIPS['TractogramOpts.colourMode']),
'TractogramOpts.cmap' : props.Widget(
'cmap',
labels=fslcm.getColourMapLabel,
tooltip=_TOOLTIPS['VolumeOpts.cmap']),
})
......@@ -869,6 +946,13 @@ _TOOLTIPS = td.TypeDict({
'SHOpts.size' : fsltooltips.properties['SHOpts.size'],
'SHOpts.radiusThreshold' : fsltooltips.properties['SHOpts.'
'radiusThreshold'],
'TractogramOpts.lineWidth' :
fsltooltips.properties['TractogramOpts.lineWidth'],
'TractogramOpts.resolution' :
fsltooltips.properties['TractogramOpts.resolution'],
'TractogramOpts.colourMode' :
fsltooltips.properties['TractogramOpts.colourMode'],
})
"""This dictionary contains tooltips for :class:`.Display` and
:class:`.DisplayOpts` properties. It is used by the
......
......@@ -341,10 +341,9 @@ def _initPropertyList_VolumeRGBOpts(threedee):
def _initPropertyList_TractogramOpts(threedee):
if not threedee:
return []
return ['lineWidth',
'resolution',
'subsample',
'custom_colourMode',
'clipMode',
'custom_cmap',
......@@ -931,9 +930,6 @@ def _initWidgetSpec_VolumeRGBOpts(displayCtx, threedee):
def _initWidgetSpec_TractogramOpts(displayCtx, threedee):
if not threedee:
return {}
def cmodeName(data):
if data == 'orientation':
return 'Orientation'
......@@ -963,6 +959,7 @@ def _initWidgetSpec_TractogramOpts(displayCtx, threedee):
'suppressMode' : props.Widget('suppressMode', **orientOpts),
'lineWidth' : props.Widget('lineWidth', **sliderOpts),
'resolution' : props.Widget('resolution', **sliderOpts),
'subsample' : props.Widget('subsample', **sliderOpts),
# We override the ColourMapOpts definitions
# for custom enabledWhen behaviour.
......
......@@ -43,6 +43,11 @@ class Tractogram:
self.name = op.basename(fname)
self.tractFile = nibstrm.load(fname)
# Bounding box is calculsted on first
# call to bounds(), then cached for
# subsequent calls.
self.__bounds = None
# Data sets associated with each
# vertex, or with each streamline.
# Per-streamline data sets are
......@@ -86,8 +91,10 @@ class Tractogram:
"""Returns the bounding box of all streamlines as a tuple of
``((xlo, ylo, zlo), (xhi, yhi, zhi))`` values.
"""
data = self.tractFile.streamlines.get_data()
return (data.min(axis=0), data.max(axis=0))
if self.__bounds is None:
data = self.tractFile.streamlines.get_data()
self.__bounds = (data.min(axis=0), data.max(axis=0))
return self.__bounds
@property
......@@ -172,6 +179,34 @@ class Tractogram:
return orients
def subset(self, indices):
"""Extract a sub-set of streamlines using the given ``indices`` into
the :meth:`offsets` / :meth:`lengths` arrays. The provided ``indices``
must be sorted.
:returns: A tuple of numpy arrays:
- New streamline vertices
- Offsets
- Lengths
- Indices into the full :meth:`vertices` array.
"""
offsets = self.offsets[indices]
lengths = self.lengths[indices]
vertIdxs = np.zeros(np.sum(lengths), dtype=np.uint32)
i = 0
for o, l in zip(offsets, lengths):
vertIdxs[i:i + l] = np.arange(o, o + l, dtype=np.uint32)
i += l
vertices = self.vertices[vertIdxs]
newOffsets = np.zeros(len(offsets), dtype=np.int32)
newOffsets[1:] = np.cumsum(lengths)[:-1]
return vertices, newOffsets, lengths, vertIdxs
def loadVertexData(self, infile, key=None):
"""Load per-vertex or per-streamline data from a separate file. The
data will be accessible via the :meth:`getVertexData` method.
......
......@@ -9,10 +9,11 @@ display properties for :class:`.Tractogram` overlays.
"""
import numpy as np
import numpy as np
import nibabel as nib
import fsl.data.image as fslimage
import fsl.data.constants as constants
import fsl.transform.affine as affine
import fsleyes.gl as fslgl
import fsleyes.strings as strings
import fsleyes_props as props
......@@ -48,24 +49,37 @@ class TractogramOpts(fsldisplay.DisplayOpts,
"""
lineWidth = props.Int(minval=1, maxval=10, default=2)
"""Width to draw the streamlines. """
lineWidth = props.Real(minval=1, maxval=10, default=2)
"""Width to draw the streamlines. When drawing in 3D, this controls the
line width / tube diameter. When drawing in 2D, this controls the point
diameter.
"""
resolution = props.Int(minval=1, maxval=10, default=1, clamped=True)
"""Only relevant when using OpenGL >= 3.3. Streamlines are drawn as tubes -
this setting defines the resolution at which the tubes are drawn. IF
resolution <= 2, the streamlines are drawn as lines.
"""When drawing in 3D as tubes, or in 2D as circles, this setting defines
the resolution at which the tubes/circles are drawn. In 3D, if
resolution <= 2, the streamlines are drawn as lines. In 2D, the
resolution is clamped to a minimum of 3, with the effect that streamline
vertices are drawn as triangles.
"""
subsample = props.Percentage(default=100)
"""Draw a random sub-sample of all streamlines. This is useful when drawing
very large tractograms.
"""
def __init__(self, *args, **kwargs):
"""Create a ``TractogramOpts``. """
def __init__(self, overlay, *args, **kwargs):
"""Create a ``TractogramOpts`` instance. """
if float(fslgl.GL_COMPATIBILITY) < 3.3:
self.getProp('resolution').disable(self)
# Default to drawing a random sub-sample
# of streamlines for large tractograms
if overlay.nstreamlines > 150000:
self.subsample = 15000000 / overlay.nstreamlines
fsldisplay.DisplayOpts .__init__(self, *args, **kwargs)
fsldisplay.DisplayOpts .__init__(self, overlay, *args, **kwargs)
cmapopts .ColourMapOpts.__init__(self)
vectoropts.VectorOpts .__init__(self)
......@@ -198,12 +212,46 @@ class TractogramOpts(fsldisplay.DisplayOpts,
else: self.clipMode = None
@property
def displayTransform(self):
"""Return an affine transformation which will transform streamline
vertex coordinates into the current display coordinate system.
"""
ref = self.displayCtx.displaySpace
if not isinstance(ref, fslimage.Image):
return np.eye(4)
opts = self.displayCtx.getOpts(ref)
return opts.getTransform('world', 'display')
def sliceWidth(self, zax):
"""Returns a width along the specified **display** coordinate system
axis, to be used for drawing a 2D slice through the tractogram on the
axis plane.
"""
# The z axis is specified in terms of
# the display coordinate system -
# identify the corresponding axis in the
# tractogram/world coordinate system.
codes = [[0, 0], [1, 1], [2, 2]]
xform = affine.invert(self.displayTransform)
zax = nib.orientations.aff2axcodes(xform, codes)[zax]
los, his = self.overlay.bounds
zlen = his[zax] - los[zax]
return zlen / 200
def __colourModeChanged(self, *_):
"""Called when :attr:`colourMode` changes. Calls
:meth:`.ColourMapOpts.updateDataRange`, to ensure that the display
and clipping ranges are up to date.
"""
self.updateDataRange(resetCR=False)
self.updateDataRange(resetCR=(self.clipMode is None))
def __clipModeChanged(self, *_):
......
......@@ -120,3 +120,4 @@ register('GL_DEPTH_ATTACHMENT', 3.0, glfbo, '_EXT')
register('glVertexAttribDivisor', 3.3, arbia, 'ARB')
register('glDrawElementsInstanced', 3.1, arbdi, 'ARB')
register('glDrawArraysInstanced', 3.1, arbdi, 'ARB')
#!/usr/bin/env python
#
# gltractogram_funcs.py -
# gltractogram_funcs.py - GL21 functions for drawing tractogram overlays.
#
# Author: Paul McCarthy <pauldmccarthy@gmail.com>
#
"""This module comtains functions for drawing tractogram overlays with OpenGL
2.1. These functions are used by :class:`.GLTractogram` instances.
"""
import itertools as it
import OpenGL.GL as gl
import numpy as np
import fsl.transform.affine as affine
import fsleyes.gl.routines as glroutines
import fsleyes.gl.shaders as shaders
import fsl.transform.affine as affine
import fsleyes.gl.routines as glroutines
import fsleyes.gl.extensions as glexts
import fsleyes.gl.shaders as shaders
def compileShaders(self):
......@@ -41,13 +46,40 @@ def compileShaders(self):
consts = {
'colourMode' : colourMode,
'clipMode' : clipMode,
'lighting' : False
'lighting' : False,
'twod' : not self.threedee,
}
shader = shaders.GLSLShader(vsrc, fsrc, constants=consts, **kwa)
shader = shaders.GLSLShader(vsrc, fsrc, constants=consts, **kwa)
self.shaders[colourMode][clipMode].append(shader)
def draw2D(self, axes, mvp):
"""Called by :class:`.GLTractogram.draw2D`. """
opts = self.opts
colourMode = opts.effectiveColourMode
clipMode = opts.effectiveClipMode
res = max((opts.resolution, 3))
shader = self.shaders[colourMode][clipMode][0]
# each vertex is drawn as a circle,
# using instanced rendering.
vertices = glroutines.unitCircle(res)
scales = self.normalisedLineWidth(mvp)
vertices[:, :2] *= scales[:2]
gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL)
with shader.loaded(), shader.loadedAtts():
shader.set( 'MVP', mvp)
shader.setAtt('circleVertex', vertices)
glexts.glDrawArraysInstanced(gl.GL_TRIANGLE_FAN,
0,
len(vertices),
len(self.vertices))
def draw3D(self, xform=None):
"""Called by :class:`.GLTractogram.draw3D`. """
canvas = self.canvas
......@@ -59,13 +91,11 @@ def draw3D(self, xform=None):
vertXform = ovl.affine
mvp = canvas.mvpMatrix
mv = canvas.viewMatrix
nstrms = ovl.nstreamlines
lineWidth = opts.lineWidth
offsets = self.offsets
counts = self.counts
nstrms = len(offsets)
shader = self.shaders[colourMode][clipMode][0]