class MultiVolumeVisual(Visual): """ Displays multiple 3D volumes simultaneously. Parameters ---------- volumes : list of tuples The volumes to show. Each tuple should contain three elements: the data array, the clim values, and the colormap to use. The clim values should be either a 2-element tuple, or None. relative_step_size : float The relative step size to step through the volume. Default 0.8. Increase to e.g. 1.5 to increase performance, at the cost of quality. emulate_texture : bool Use 2D textures to emulate a 3D texture. OpenGL ES 2.0 compatible, but has lower performance on desktop platforms. n_volume_max : int Absolute maximum number of volumes that can be shown. """ def __init__(self, volumes, clim=None, threshold=None, relative_step_size=0.8, cmap1='grays', cmap2='grays', emulate_texture=False, n_volume_max=10): # Choose texture class tex_cls = TextureEmulated3D if emulate_texture else Texture3D # We store the data and colormaps in a CallbackList which can warn us # when it is modified. self.volumes = CallbackList() self.volumes.on_size_change = self._update_all_volumes self.volumes.on_item_change = self._update_volume self._vol_shape = None self._need_vertex_update = True # Create OpenGL program vert_shader, frag_shader = get_shaders(n_volume_max) super(MultiVolumeVisual, self).__init__(vcode=vert_shader, fcode=frag_shader) # Create gloo objects self._vertices = VertexBuffer() self._texcoord = VertexBuffer( np.array([ [0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1], ], dtype=np.float32)) # Set up textures self.textures = [] for i in range(n_volume_max): self.textures.append(tex_cls((10, 10, 10), interpolation='linear', wrapping='clamp_to_edge')) self.shared_program['u_volumetex{0}'.format(i)] = self.textures[i] self.shared_program.frag['cmap{0:d}'.format(i)] = Function(get_colormap('grays').glsl_map) self.shared_program['a_position'] = self._vertices self.shared_program['a_texcoord'] = self._texcoord self._draw_mode = 'triangle_strip' self._index_buffer = IndexBuffer() self.shared_program.frag['sampler_type'] = self.textures[0].glsl_sampler_type self.shared_program.frag['sample'] = self.textures[0].glsl_sample # Only show back faces of cuboid. This is required because if we are # inside the volume, then the front faces are outside of the clipping # box and will not be drawn. self.set_gl_state('translucent', cull_face=False) self.relative_step_size = relative_step_size self.freeze() # Add supplied volumes self.volumes.extend(volumes) def _update_all_volumes(self, volumes): """ Update the number of simultaneous textures. Parameters ---------- n_textures : int The number of textures to use """ if len(self.volumes) > len(self.textures): raise ValueError("Number of volumes ({0}) exceeds number of textures ({1})".format(len(self.volumes), len(self.textures))) for index in range(len(self.volumes)): self._update_volume(volumes, index) def _update_volume(self, volumes, index): data, clim, cmap = volumes[index] cmap = get_colormap(cmap) if clim is None: clim = data.min(), data.max() data = data.astype(np.float32) if clim[1] == clim[0]: if clim[0] != 0.: data *= 1.0 / clim[0] else: data -= clim[0] data /= clim[1] - clim[0] self.shared_program['u_volumetex{0:d}'.format(index)].set_data(data) self.shared_program.frag['cmap{0:d}'.format(index)] = Function(cmap.glsl_map) print(self.shared_program.frag) if self._vol_shape is None: self.shared_program['u_shape'] = data.shape[::-1] self._vol_shape = data.shape elif data.shape != self._vol_shape: raise ValueError("Shape of arrays should be {0} instead of {1}".format(self._vol_shape, data.shape)) self.shared_program['u_n_tex'] = len(self.volumes) @property def relative_step_size(self): """ The relative step size used during raycasting. Larger values yield higher performance at reduced quality. If set > 2.0 the ray skips entire voxels. Recommended values are between 0.5 and 1.5. The amount of quality degredation depends on the render method. """ return self._relative_step_size @relative_step_size.setter def relative_step_size(self, value): value = float(value) if value < 0.1: raise ValueError('relative_step_size cannot be smaller than 0.1') self._relative_step_size = value self.shared_program['u_relative_step_size'] = value def _create_vertex_data(self): """ Create and set positions and texture coords from the given shape We have six faces with 1 quad (2 triangles) each, resulting in 6*2*3 = 36 vertices in total. """ shape = self._vol_shape # Get corner coordinates. The -0.5 offset is to center # pixels/voxels. This works correctly for anisotropic data. x0, x1 = -0.5, shape[2] - 0.5 y0, y1 = -0.5, shape[1] - 0.5 z0, z1 = -0.5, shape[0] - 0.5 pos = np.array([ [x0, y0, z0], [x1, y0, z0], [x0, y1, z0], [x1, y1, z0], [x0, y0, z1], [x1, y0, z1], [x0, y1, z1], [x1, y1, z1], ], dtype=np.float32) """ 6-------7 /| /| 4-------5 | | | | | | 2-----|-3 |/ |/ 0-------1 """ # Order is chosen such that normals face outward; front faces will be # culled. indices = np.array([2, 6, 0, 4, 5, 6, 7, 2, 3, 0, 1, 5, 3, 7], dtype=np.uint32) # Apply self._vertices.set_data(pos) self._index_buffer.set_data(indices) def _compute_bounds(self, axis, view): return 0, self._vol_shape[axis] def _prepare_transforms(self, view): trs = view.transforms view.view_program.vert['transform'] = trs.get_transform() view_tr_f = trs.get_transform('visual', 'document') view_tr_i = view_tr_f.inverse view.view_program.vert['viewtransformf'] = view_tr_f view.view_program.vert['viewtransformi'] = view_tr_i def _prepare_draw(self, view): if self._need_vertex_update: self._create_vertex_data() self._need_vertex_update = False
class OpenGLRenderer(ABC): def __init__(self, src_fbuffer, src_default): self.default_prog = None self.fbuffer_prog = None self.fbuffer = None self.fbuffer_tex_front = None self.fbuffer_tex_back = None self.vertex_buffer = None self.index_buffer = None # Renderer Globals: STYLE/MATERIAL PROPERTIES # self.style = Style() # Renderer Globals: Curves self.stroke_weight = 1 self.stroke_cap = ROUND self.stroke_join = MITER # Renderer Globals # VIEW MATRICES, ETC # self.viewport = None self.texture_viewport = None self.transform_matrix = np.identity(4) self.projection_matrix = np.identity(4) # Renderer Globals: RENDERING self.draw_queue = [] # Shaders self.fbuffer_prog = Program(src_fbuffer.vert, src_fbuffer.frag) self.default_prog = Program(src_default.vert, src_default.frag) def initialize_renderer(self): self.fbuffer = FrameBuffer() vertices = np.array( [[-1.0, -1.0], [+1.0, -1.0], [-1.0, +1.0], [+1.0, +1.0]], np.float32) texcoords = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], dtype=np.float32) self.fbuf_vertices = VertexBuffer(data=vertices) self.fbuf_texcoords = VertexBuffer(data=texcoords) self.fbuffer_prog['texcoord'] = self.fbuf_texcoords self.fbuffer_prog['position'] = self.fbuf_vertices self.vertex_buffer = VertexBuffer() self.index_buffer = IndexBuffer() def render_default(self, draw_type, draw_queue): # 1. Get the maximum number of vertices persent in the shapes # in the draw queue. # if len(draw_queue) == 0: return num_vertices = 0 for vertices, _, _ in draw_queue: num_vertices = num_vertices + len(vertices) # 2. Create empty buffers based on the number of vertices. # data = np.zeros(num_vertices, dtype=[('position', np.float32, 3), ('color', np.float32, 4)]) # 3. Loop through all the shapes in the geometry queue adding # it's information to the buffer. # sidx = 0 draw_indices = [] for vertices, idx, color in draw_queue: num_shape_verts = len(vertices) data['position'][sidx:(sidx + num_shape_verts), ] = np.array(vertices) color_array = np.array([color] * num_shape_verts) data['color'][sidx:sidx + num_shape_verts, :] = color_array draw_indices.append(sidx + idx) sidx += num_shape_verts self.vertex_buffer.set_data(data) self.index_buffer.set_data(np.hstack(draw_indices)) # 4. Bind the buffer to the shader. # self.default_prog.bind(self.vertex_buffer) # 5. Draw the shape using the proper shape type and get rid of # the buffers. # self.default_prog.draw(draw_type, indices=self.index_buffer) def cleanup(self): """Run the clean-up routine for the renderer. This method is called when all drawing has been completed and the program is about to exit. """ self.default_prog.delete() self.fbuffer_prog.delete() self.fbuffer.delete() def _transform_vertices(self, vertices, local_matrix, global_matrix): return np.dot(np.dot(vertices, local_matrix.T), global_matrix.T)[:, :3]
class Renderer2D: def __init__(self): self.default_prog = None self.fbuffer_prog = None self.texture_prog = None self.line_prog = None self.fbuffer = None self.fbuffer_tex_front = None self.fbuffer_tex_back = None self.vertex_buffer = None self.index_buffer = None ## Renderer Globals: USEFUL CONSTANTS self.COLOR_WHITE = (1, 1, 1, 1) self.COLOR_BLACK = (0, 0, 0, 1) self.COLOR_DEFAULT_BG = (0.8, 0.8, 0.8, 1.0) ## Renderer Globals: STYLE/MATERIAL PROPERTIES ## self.background_color = self.COLOR_DEFAULT_BG self.fill_color = self.COLOR_WHITE self.fill_enabled = True self.stroke_color = self.COLOR_BLACK self.stroke_enabled = True self.tint_color = self.COLOR_BLACK self.tint_enabled = False ## Renderer Globals: Curves self.stroke_weight = 1 self.stroke_cap = 2 self.stroke_join = 0 ## Renderer Globals ## VIEW MATRICES, ETC ## self.viewport = None self.texture_viewport = None self.transform_matrix = np.identity(4) self.modelview_matrix = np.identity(4) self.projection_matrix = np.identity(4) ## Renderer Globals: RENDERING self.draw_queue = [] def initialize_renderer(self): self.fbuffer = FrameBuffer() vertices = np.array( [[-1.0, -1.0], [+1.0, -1.0], [-1.0, +1.0], [+1.0, +1.0]], np.float32) texcoords = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], dtype=np.float32) self.fbuf_vertices = VertexBuffer(data=vertices) self.fbuf_texcoords = VertexBuffer(data=texcoords) self.fbuffer_prog = Program(src_fbuffer.vert, src_fbuffer.frag) self.fbuffer_prog['texcoord'] = self.fbuf_texcoords self.fbuffer_prog['position'] = self.fbuf_vertices self.vertex_buffer = VertexBuffer() self.index_buffer = IndexBuffer() self.default_prog = Program(src_default.vert, src_default.frag) self.texture_prog = Program(src_texture.vert, src_texture.frag) self.texture_prog['texcoord'] = self.fbuf_texcoords self.reset_view() def reset_view(self): self.viewport = ( 0, 0, int(builtins.width * builtins.pixel_x_density), int(builtins.height * builtins.pixel_y_density), ) self.texture_viewport = ( 0, 0, builtins.width, builtins.height, ) gloo.set_viewport(*self.viewport) cz = (builtins.height / 2) / math.tan(math.radians(30)) self.projection_matrix = matrix.perspective_matrix( math.radians(60), builtins.width / builtins.height, 0.1 * cz, 10 * cz) self.modelview_matrix = matrix.translation_matrix(-builtins.width / 2, \ builtins.height / 2, \ -cz) self.modelview_matrix = self.modelview_matrix.dot( matrix.scale_transform(1, -1, 1)) self.transform_matrix = np.identity(4) self.default_prog['modelview'] = self.modelview_matrix.T.flatten() self.default_prog['projection'] = self.projection_matrix.T.flatten() self.texture_prog['modelview'] = self.modelview_matrix.T.flatten() self.texture_prog['projection'] = self.projection_matrix.T.flatten() self.line_prog = Program(src_line.vert, src_line.frag) self.line_prog['modelview'] = self.modelview_matrix.T.flatten() self.line_prog['projection'] = self.projection_matrix.T.flatten() self.line_prog["height"] = builtins.height self.fbuffer_tex_front = Texture2D( (builtins.height, builtins.width, 3)) self.fbuffer_tex_back = Texture2D((builtins.height, builtins.width, 3)) for buf in [self.fbuffer_tex_front, self.fbuffer_tex_back]: self.fbuffer.color_buffer = buf with self.fbuffer: self.clear() def clear(self, color=True, depth=True): """Clear the renderer background.""" gloo.set_state(clear_color=self.background_color) gloo.clear(color=color, depth=depth) def _comm_toggles(self, state=True): gloo.set_state(blend=state) gloo.set_state(depth_test=state) if state: gloo.set_state(blend_func=('src_alpha', 'one_minus_src_alpha')) gloo.set_state(depth_func='lequal') @contextmanager def draw_loop(self): """The main draw loop context manager. """ self.transform_matrix = np.identity(4) self.default_prog['modelview'] = self.modelview_matrix.T.flatten() self.default_prog['projection'] = self.projection_matrix.T.flatten() self.fbuffer.color_buffer = self.fbuffer_tex_back with self.fbuffer: gloo.set_viewport(*self.texture_viewport) self._comm_toggles() self.fbuffer_prog['texture'] = self.fbuffer_tex_front self.fbuffer_prog.draw('triangle_strip') yield self.flush_geometry() self.transform_matrix = np.identity(4) gloo.set_viewport(*self.viewport) self._comm_toggles(False) self.clear() self.fbuffer_prog['texture'] = self.fbuffer_tex_back self.fbuffer_prog.draw('triangle_strip') self.fbuffer_tex_front, self.fbuffer_tex_back = self.fbuffer_tex_back, self.fbuffer_tex_front def _transform_vertices(self, vertices, local_matrix, global_matrix): return np.dot(np.dot(vertices, local_matrix.T), global_matrix.T)[:, :3] def _add_to_draw_queue_simple(self, stype, vertices, idx, fill, stroke, stroke_weight, stroke_cap, stroke_join): """Adds shape of stype to draw queue """ if stype == 'lines': self.draw_queue.append( (stype, (vertices, idx, stroke, stroke_weight, stroke_cap, stroke_join))) else: self.draw_queue.append((stype, (vertices, idx, fill))) def render(self, shape): fill = shape.fill.normalized if shape.fill else None stroke = shape.stroke.normalized if shape.stroke else None stroke_weight = shape.stroke_weight stroke_cap = shape.stroke_cap stroke_join = shape.stroke_join obj_list = get_render_primitives(shape) for obj in obj_list: stype, vertices, idx = obj # Transform vertices vertices = self._transform_vertices( np.hstack([vertices, np.ones((len(vertices), 1))]), shape._matrix, self.transform_matrix) # Add to draw queue self._add_to_draw_queue_simple(stype, vertices, idx, fill, stroke, stroke_weight, stroke_cap, stroke_join) def flush_geometry(self): """Flush all the shape geometry from the draw queue to the GPU. """ current_queue = [] for index, shape in enumerate(self.draw_queue): current_shape = self.draw_queue[index][0] current_queue.append(self.draw_queue[index][1]) if current_shape == "lines": self.render_line(current_queue) else: self.render_default(current_shape, current_queue) current_queue = [] self.draw_queue = [] def render_default(self, draw_type, draw_queue): # 1. Get the maximum number of vertices persent in the shapes # in the draw queue. # if len(draw_queue) == 0: return num_vertices = 0 for vertices, _, _ in draw_queue: num_vertices = num_vertices + len(vertices) # 2. Create empty buffers based on the number of vertices. # data = np.zeros(num_vertices, dtype=[('position', np.float32, 3), ('color', np.float32, 4)]) # 3. Loop through all the shapes in the geometry queue adding # it's information to the buffer. # sidx = 0 draw_indices = [] for vertices, idx, color in draw_queue: num_shape_verts = len(vertices) data['position'][sidx:(sidx + num_shape_verts), ] = vertices color_array = np.array([color] * num_shape_verts) data['color'][sidx:sidx + num_shape_verts, :] = color_array draw_indices.append(sidx + idx) sidx += num_shape_verts self.vertex_buffer.set_data(data) self.index_buffer.set_data(np.hstack(draw_indices)) # 4. Bind the buffer to the shader. # self.default_prog.bind(self.vertex_buffer) # 5. Draw the shape using the proper shape type and get rid of # the buffers. # self.default_prog.draw(draw_type, indices=self.index_buffer) def render_line(self, queue): ''' This rendering algorithm works by tesselating the line into multiple triangles. Reference: https://blog.mapbox.com/drawing-antialiased-lines-with-opengl-8766f34192dc ''' if len(queue) == 0: return pos = [] posPrev = [] posCurr = [] posNext = [] markers = [] side = [] linewidth = [] join_type = [] cap_type = [] color = [] for line in queue: if len(line[1]) == 0: continue for segment in line[1]: for i in range( len(segment) - 1): # the data is sent to renderer in line segments for j in [0, 0, 1, 0, 1, 1]: # all the vertices of triangles if i + j - 1 >= 0: posPrev.append(line[0][segment[i + j - 1]]) else: posPrev.append(line[0][segment[i + j]]) if i + j + 1 < len(segment): posNext.append(line[0][segment[i + j + 1]]) else: posNext.append(line[0][segment[i + j]]) posCurr.append(line[0][segment[i + j]]) markers.extend( [1.0, -1.0, -1.0, -1.0, 1.0, -1.0]) # Is the vertex up/below the line segment side.extend([1.0, 1.0, -1.0, 1.0, -1.0, -1.0]) # Left or right side of the segment pos.extend([line[0][segment[i]]] * 6) # Left vertex of each segment linewidth.extend([line[3]] * 6) join_type.extend([line[5]] * 6) cap_type.extend([line[4]] * 6) color.extend([line[2]] * 6) if len(pos) == 0: return posPrev = np.array(posPrev, np.float32) posCurr = np.array(posCurr, np.float32) posNext = np.array(posNext, np.float32) markers = np.array(markers, np.float32) side = np.array(side, np.float32) pos = np.array(pos, np.float32) linewidth = np.array(linewidth, np.float32) join_type = np.array(join_type, np.float32) cap_type = np.array(cap_type, np.float32) color = np.array(color, np.float32) self.line_prog['pos'] = gloo.VertexBuffer(pos) self.line_prog['posPrev'] = gloo.VertexBuffer(posPrev) self.line_prog['posCurr'] = gloo.VertexBuffer(posCurr) self.line_prog['posNext'] = gloo.VertexBuffer(posNext) self.line_prog['marker'] = gloo.VertexBuffer(markers) self.line_prog['side'] = gloo.VertexBuffer(side) self.line_prog['linewidth'] = gloo.VertexBuffer(linewidth) self.line_prog['join_type'] = gloo.VertexBuffer(join_type) self.line_prog['cap_type'] = gloo.VertexBuffer(cap_type) self.line_prog["color"] = gloo.VertexBuffer(color) self.line_prog.draw('triangles') def render_image(self, image, location, size): """Render the image. :param image: image to be rendered :type image: builtins.Image :param location: top-left corner of the image :type location: tuple | list | builtins.Vector :param size: target size of the image to draw. :type size: tuple | list | builtins.Vector """ self.flush_geometry() self.texture_prog[ 'fill_color'] = self.tint_color if self.tint_enabled else self.COLOR_WHITE self.texture_prog['transform'] = self.transform_matrix.T.flatten() x, y = location sx, sy = size imx, imy = image.size data = np.zeros(4, dtype=[('position', np.float32, 2), ('texcoord', np.float32, 2)]) data['texcoord'] = np.array( [[0.0, 1.0], [1.0, 1.0], [0.0, 0.0], [1.0, 0.0]], dtype=np.float32) data['position'] = np.array( [[x, y + sy], [x + sx, y + sy], [x, y], [x + sx, y]], dtype=np.float32) self.texture_prog['texture'] = image._texture self.texture_prog.bind(VertexBuffer(data)) self.texture_prog.draw('triangle_strip') def cleanup(self): """Run the clean-up routine for the renderer. This method is called when all drawing has been completed and the program is about to exit. """ self.default_prog.delete() self.fbuffer_prog.delete() self.line_prog.delete() self.fbuffer.delete()
class NapariVolumeVisual(Visual): """ Displays a 3D Volume Parameters ---------- vol : ndarray The volume to display. Must be ndim==3. clim : tuple of two floats | None The contrast limits. The values in the volume are mapped to black and white corresponding to these values. Default maps between min and max. method : {'mip', 'translucent', 'additive', 'iso'} The render method to use. See corresponding docs for details. Default 'mip'. threshold : float The threshold to use for the isosurface render method. By default the mean of the given volume is used. relative_step_size : float The relative step size to step through the volume. Default 0.8. Increase to e.g. 1.5 to increase performance, at the cost of quality. cmap : str Colormap to use. emulate_texture : bool Use 2D textures to emulate a 3D texture. OpenGL ES 2.0 compatible, but has lower performance on desktop platforms. """ def __init__(self, vol, clim=None, method='mip', threshold=None, relative_step_size=0.8, cmap='grays', emulate_texture=False): tex_cls = TextureEmulated3D if emulate_texture else Texture3D # Storage of information of volume self._vol_shape = () self._clim = None self._need_vertex_update = True # Set the colormap self._cmap = get_colormap(cmap) # Create gloo objects self._vertices = VertexBuffer() self._texcoord = VertexBuffer( np.array([ [0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1], ], dtype=np.float32)) self._tex = tex_cls((10, 10, 10), interpolation='linear', wrapping='clamp_to_edge') # Create program Visual.__init__(self, vcode=VERT_SHADER, fcode="") self.shared_program['u_volumetex'] = self._tex self.shared_program['a_position'] = self._vertices self.shared_program['a_texcoord'] = self._texcoord self._draw_mode = 'triangle_strip' self._index_buffer = IndexBuffer() # Only show back faces of cuboid. This is required because if we are # inside the volume, then the front faces are outside of the clipping # box and will not be drawn. self.set_gl_state('translucent', cull_face=False) # Set data self.set_data(vol, clim) # Set params self.method = method self.relative_step_size = relative_step_size self.threshold = threshold if (threshold is not None) else vol.mean() self.freeze() def set_data(self, vol, clim=None): """ Set the volume data. Parameters ---------- vol : ndarray The 3D volume. clim : tuple | None Colormap limits to use. None will use the min and max values. """ # Check volume if not isinstance(vol, np.ndarray): raise ValueError('Volume visual needs a numpy array.') if not ((vol.ndim == 3) or (vol.ndim == 4 and vol.shape[-1] <= 4)): raise ValueError('Volume visual needs a 3D image.') # Handle clim if clim is not None: clim = np.array(clim, float) if not (clim.ndim == 1 and clim.size == 2): raise ValueError('clim must be a 2-element array-like') self._clim = tuple(clim) if self._clim is None: self._clim = vol.min(), vol.max() # Apply clim vol = np.array(vol, dtype='float32', copy=False) if self._clim[1] == self._clim[0]: if self._clim[0] != 0.: vol *= 1.0 / self._clim[0] else: vol -= self._clim[0] vol /= self._clim[1] - self._clim[0] # Apply to texture self._tex.set_data(vol) # will be efficient if vol is same shape self.shared_program['u_shape'] = (vol.shape[2], vol.shape[1], vol.shape[0]) shape = vol.shape[:3] if self._vol_shape != shape: self._vol_shape = shape self._need_vertex_update = True self._vol_shape = shape # Get some stats self._kb_for_texture = np.prod(self._vol_shape) / 1024 @property def clim(self): """ The contrast limits that were applied to the volume data. Settable via set_data(). """ return self._clim @property def cmap(self): return self._cmap @cmap.setter def cmap(self, cmap): self._cmap = get_colormap(cmap) self.shared_program.frag['cmap'] = Function(self._cmap.glsl_map) self.update() @property def method(self): """The render method to use Current options are: * translucent: voxel colors are blended along the view ray until the result is opaque. * mip: maxiumum intensity projection. Cast a ray and display the maximum value that was encountered. * additive: voxel colors are added along the view ray until the result is saturated. * iso: isosurface. Cast a ray until a certain threshold is encountered. At that location, lighning calculations are performed to give the visual appearance of a surface. """ return self._method @method.setter def method(self, method): # Check and save known_methods = list(frag_dict.keys()) if method not in known_methods: raise ValueError('Volume render method should be in %r, not %r' % (known_methods, method)) self._method = method # Get rid of specific variables - they may become invalid if 'u_threshold' in self.shared_program: self.shared_program['u_threshold'] = None self.shared_program.frag = frag_dict[method] self.shared_program.frag['sampler_type'] = self._tex.glsl_sampler_type self.shared_program.frag['sample'] = self._tex.glsl_sample self.shared_program.frag['cmap'] = Function(self._cmap.glsl_map) self.update() @property def threshold(self): """ The threshold value to apply for the isosurface render method. """ return self._threshold @threshold.setter def threshold(self, value): self._threshold = float(value) if 'u_threshold' in self.shared_program: self.shared_program['u_threshold'] = self._threshold self.update() @property def relative_step_size(self): """ The relative step size used during raycasting. Larger values yield higher performance at reduced quality. If set > 2.0 the ray skips entire voxels. Recommended values are between 0.5 and 1.5. The amount of quality degredation depends on the render method. """ return self._relative_step_size @relative_step_size.setter def relative_step_size(self, value): value = float(value) if value < 0.1: raise ValueError('relative_step_size cannot be smaller than 0.1') self._relative_step_size = value self.shared_program['u_relative_step_size'] = value def _create_vertex_data(self): """ Create and set positions and texture coords from the given shape We have six faces with 1 quad (2 triangles) each, resulting in 6*2*3 = 36 vertices in total. """ shape = self._vol_shape # Get corner coordinates. The -0.5 offset is to center # pixels/voxels. This works correctly for anisotropic data. x0, x1 = -0.5, shape[2] - 0.5 y0, y1 = -0.5, shape[1] - 0.5 z0, z1 = -0.5, shape[0] - 0.5 pos = np.array([ [x0, y0, z0], [x1, y0, z0], [x0, y1, z0], [x1, y1, z0], [x0, y0, z1], [x1, y0, z1], [x0, y1, z1], [x1, y1, z1], ], dtype=np.float32) """ 6-------7 /| /| 4-------5 | | | | | | 2-----|-3 |/ |/ 0-------1 """ # Order is chosen such that normals face outward; front faces will be # culled. indices = np.array([2, 6, 0, 4, 5, 6, 7, 2, 3, 0, 1, 5, 3, 7], dtype=np.uint32) # Apply self._vertices.set_data(pos) self._index_buffer.set_data(indices) def _compute_bounds(self, axis, view): return 0, self._vol_shape[axis] def _prepare_transforms(self, view): trs = view.transforms view.view_program.vert['transform'] = trs.get_transform() view_tr_f = trs.get_transform('visual', 'document') view_tr_i = view_tr_f.inverse view.view_program.vert['viewtransformf'] = view_tr_f view.view_program.vert['viewtransformi'] = view_tr_i def _prepare_draw(self, view): if self._need_vertex_update: self._create_vertex_data()
class Renderer3D: def __init__(self): self.default_prog = None self.fbuffer = None self.fbuffer_tex_front = None self.fbuffer_tex_back = None self.vertex_buffer = None self.index_buffer = None ## Renderer Globals: USEFUL CONSTANTS self.COLOR_WHITE = (1, 1, 1, 1) self.COLOR_BLACK = (0, 0, 0, 1) self.COLOR_DEFAULT_BG = (0.8, 0.8, 0.8, 1.0) ## Renderer Globals: STYLE/MATERIAL PROPERTIES ## self.background_color = self.COLOR_DEFAULT_BG self.fill_color = self.COLOR_WHITE self.fill_enabled = True self.stroke_color = self.COLOR_BLACK self.stroke_enabled = True self.tint_color = self.COLOR_BLACK self.tint_enabled = False ## Renderer Globals: Curves self.stroke_weight = 1 self.stroke_cap = 2 self.stroke_join = 0 ## Renderer Globals ## VIEW MATRICES, ETC ## self.viewport = None self.texture_viewport = None self.transform_matrix = np.identity(4) self.projection_matrix = np.identity(4) self.lookat_matrix = np.identity(4) ## Renderer Globals: RENDERING self.draw_queue = [] def initialize_renderer(self): self.fbuffer = FrameBuffer() vertices = np.array( [[-1.0, -1.0], [+1.0, -1.0], [-1.0, +1.0], [+1.0, +1.0]], np.float32) texcoords = np.array([[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [1.0, 1.0]], dtype=np.float32) self.fbuf_vertices = VertexBuffer(data=vertices) self.fbuf_texcoords = VertexBuffer(data=texcoords) self.fbuffer_prog = Program(src_fbuffer.vert, src_fbuffer.frag) self.fbuffer_prog['texcoord'] = self.fbuf_texcoords self.fbuffer_prog['position'] = self.fbuf_vertices self.vertex_buffer = VertexBuffer() self.index_buffer = IndexBuffer() self.default_prog = Program(src_default.vert, src_default.frag) self.reset_view() def reset_view(self): self.viewport = ( 0, 0, int(builtins.width * builtins.pixel_x_density), int(builtins.height * builtins.pixel_y_density), ) self.texture_viewport = ( 0, 0, builtins.width, builtins.height, ) gloo.set_viewport(*self.viewport) cz = (builtins.height / 2) / math.tan(math.radians(30)) self.projection_matrix = matrix.perspective_matrix( math.radians(60), builtins.width / builtins.height, 0.1 * cz, 10 * cz) self.transform_matrix = np.identity(4) self.default_prog['projection'] = self.projection_matrix.T.flatten() self.default_prog['perspective_matrix'] = self.lookat_matrix.T.flatten( ) self.fbuffer_tex_front = Texture2D( (builtins.height, builtins.width, 3)) self.fbuffer_tex_back = Texture2D((builtins.height, builtins.width, 3)) for buf in [self.fbuffer_tex_front, self.fbuffer_tex_back]: self.fbuffer.color_buffer = buf with self.fbuffer: self.clear() self.fbuffer.depth_buffer = gloo.RenderBuffer( (builtins.height, builtins.width)) def clear(self, color=True, depth=True): """Clear the renderer background.""" gloo.set_state(clear_color=self.background_color) gloo.clear(color=color, depth=depth) def _comm_toggles(self, state=True): gloo.set_state(blend=state) gloo.set_state(depth_test=state) if state: gloo.set_state(blend_func=('src_alpha', 'one_minus_src_alpha')) gloo.set_state(depth_func='lequal') @contextmanager def draw_loop(self): """The main draw loop context manager. """ self.transform_matrix = np.identity(4) self.default_prog['projection'] = self.projection_matrix.T.flatten() self.default_prog['perspective_matrix'] = self.lookat_matrix.T.flatten( ) self.fbuffer.color_buffer = self.fbuffer_tex_back with self.fbuffer: gloo.set_viewport(*self.texture_viewport) self._comm_toggles() self.fbuffer_prog['texture'] = self.fbuffer_tex_front self.fbuffer_prog.draw('triangle_strip') yield self.flush_geometry() self.transform_matrix = np.identity(4) gloo.set_viewport(*self.viewport) self._comm_toggles(False) self.clear() self.fbuffer_prog['texture'] = self.fbuffer_tex_back self.fbuffer_prog.draw('triangle_strip') self.fbuffer_tex_front, self.fbuffer_tex_back = self.fbuffer_tex_back, self.fbuffer_tex_front def _transform_vertices(self, vertices, local_matrix, global_matrix): return np.dot(np.dot(vertices, local_matrix.T), global_matrix.T)[:, :3] def render(self, shape): if isinstance(shape, Geometry): n = len(shape.vertices) tverts = self._transform_vertices( np.hstack([shape.vertices, np.ones((n, 1))]), shape.matrix, self.transform_matrix) edges = shape.edges faces = shape.faces self.add_to_draw_queue('poly', tverts, edges, faces, self.fill_color, self.stroke_color) elif isinstance(shape, PShape): vertices = shape._draw_vertices n, _ = vertices.shape tverts = self._transform_vertices( np.hstack([vertices, np.zeros((n, 1)), np.ones((n, 1))]), shape._matrix, self.transform_matrix) fill = shape.fill.normalized if shape.fill else None stroke = shape.stroke.normalized if shape.stroke else None edges = shape._draw_edges faces = shape._draw_faces if edges is None: print(vertices) print("whale") exit() if 'open' in shape.attribs: overtices = shape._draw_outline_vertices no, _ = overtices.shape toverts = self._transform_vertices( np.hstack([overtices, np.zeros((no, 1)), np.ones((no, 1))]), shape._matrix, self.transform_matrix) self.add_to_draw_queue('poly', tverts, edges, faces, fill, None) self.add_to_draw_queue('path', toverts, edges[:-1], None, None, stroke) else: self.add_to_draw_queue(shape.kind, tverts, edges, faces, fill, stroke) def add_to_draw_queue(self, stype, vertices, edges, faces, fill=None, stroke=None): """Add the given vertex data to the draw queue. :param stype: type of shape to be added. Should be one of {'poly', 'path', 'point'} :type stype: str :param vertices: (N, 3) array containing the vertices to be drawn. :type vertices: np.ndarray :param edges: (N, 2) array containing edges as tuples of indices into the vertex array. This can be None when not appropriate (eg. for points) :type edges: None | np.ndarray :param faces: (N, 3) array containing faces as tuples of indices into the vertex array. For 'point' and 'path' shapes, this can be None :type faces: np.ndarray :param fill: Fill color of the shape as a normalized RGBA tuple. When set to `None` the shape doesn't get a fill (default: None) :type fill: None | tuple :param stroke: Stroke color of the shape as a normalized RGBA tuple. When set to `None` the shape doesn't get stroke (default: None) :type stroke: None | tuple """ fill_shape = self.fill_enabled and not (fill is None) stroke_shape = self.stroke_enabled and not (stroke is None) if fill_shape and stype not in ['point', 'path']: idx = np.array(faces, dtype=np.uint32).ravel() self.draw_queue.append(["triangles", (vertices, idx, fill)]) if stroke_shape: if stype == 'point': idx = np.arange(0, len(vertices), dtype=np.uint32) self.draw_queue.append(["points", (vertices, idx, stroke)]) else: idx = np.array(edges, dtype=np.uint32).ravel() self.draw_queue.append(["lines", (vertices, idx, stroke)]) def flush_geometry(self): """Flush all the shape geometry from the draw queue to the GPU. """ current_queue = [] for index, shape in enumerate(self.draw_queue): current_shape, current_obj = self.draw_queue[index][ 0], self.draw_queue[index][1] # If current_shape is lines, bring it to the front by epsilon # to resolve z-fighting if current_shape == 'lines': # line_transform is used whenever we render lines to break ties in depth # We transform the points to camera space, move them by Z_EPSILON, and them move them back to world space line_transform = inv(self.lookat_matrix).dot( translation_matrix(0, 0, Z_EPSILON).dot(self.lookat_matrix)) vertices = current_obj[0] current_obj = (np.hstack( [vertices, np.ones( (vertices.shape[0], 1))]).dot(line_transform.T)[:, :3], current_obj[1], current_obj[2]) current_queue.append(current_obj) if index < len(self.draw_queue) - 1: if self.draw_queue[index][0] == self.draw_queue[index + 1][0]: continue self.render_default(current_shape, current_queue) current_queue = [] self.draw_queue = [] def render_default(self, draw_type, draw_queue): # 1. Get the maximum number of vertices persent in the shapes # in the draw queue. # if len(draw_queue) == 0: return num_vertices = 0 for vertices, _, _ in draw_queue: num_vertices = num_vertices + len(vertices) # 2. Create empty buffers based on the number of vertices. # data = np.zeros(num_vertices, dtype=[('position', np.float32, 3), ('color', np.float32, 4)]) # 3. Loop through all the shapes in the geometry queue adding # it's information to the buffer. # sidx = 0 draw_indices = [] for vertices, idx, color in draw_queue: num_shape_verts = len(vertices) data['position'][sidx:(sidx + num_shape_verts), ] = np.array(vertices) color_array = np.array([color] * num_shape_verts) data['color'][sidx:sidx + num_shape_verts, :] = color_array draw_indices.append(sidx + idx) sidx += num_shape_verts self.vertex_buffer.set_data(data) self.index_buffer.set_data(np.hstack(draw_indices)) # 4. Bind the buffer to the shader. # self.default_prog.bind(self.vertex_buffer) # 5. Draw the shape using the proper shape type and get rid of # the buffers. # self.default_prog.draw(draw_type, indices=self.index_buffer) def cleanup(self): """Run the clean-up routine for the renderer. This method is called when all drawing has been completed and the program is about to exit. """ self.default_prog.delete() self.fbuffer_prog.delete() self.fbuffer.delete()
class VolumeVisual(Visual): """ Displays a 3D Volume Parameters ---------- vol : ndarray The volume to display. Must be ndim==3. clim : tuple of two floats | None The contrast limits. The values in the volume are mapped to black and white corresponding to these values. Default maps between min and max. method : {'mip', 'translucent', 'additive', 'iso'} The render method to use. See corresponding docs for details. Default 'mip'. threshold : float The threshold to use for the isosurface render method. By default the mean of the given volume is used. relative_step_size : float The relative step size to step through the volume. Default 0.8. Increase to e.g. 1.5 to increase performance, at the cost of quality. cmap : str Colormap to use. gamma : float Gamma to use during colormap lookup. Final color will be cmap(val**gamma). by default: 1. clim_range_threshold : float When changing the clims, if the new clim range is smaller than this fraction of the last-used texture data range, then it will trigger a rescaling of the texture data. For instance: if the texture data was last scaled from 0-1, and the clims are set to 0.4-0.5, then a texture rescale will be triggered if ``clim_range_threshold < 0.1``. To prevent rescaling, set this value to 0. To *always* rescale, set the value to >= 1. By default, 0.2 emulate_texture : bool Use 2D textures to emulate a 3D texture. OpenGL ES 2.0 compatible, but has lower performance on desktop platforms. interpolation : {'linear', 'nearest'} Selects method of image interpolation. """ _interpolation_names = ['linear', 'nearest'] def __init__( self, vol, clim=None, method='mip', threshold=None, relative_step_size=0.8, cmap='grays', gamma=1.0, clim_range_threshold=0.2, emulate_texture=False, interpolation='linear', ): tex_cls = TextureEmulated3D if emulate_texture else Texture3D # Storage of information of volume self._vol_shape = () self._clim = None self._texture_limits = None self._gamma = gamma self._need_vertex_update = True self._clim_range_threshold = clim_range_threshold # Set the colormap self._cmap = get_colormap(cmap) # Create gloo objects self._vertices = VertexBuffer() self._texcoord = VertexBuffer( np.array( [ [0, 0, 0], [1, 0, 0], [0, 1, 0], [1, 1, 0], [0, 0, 1], [1, 0, 1], [0, 1, 1], [1, 1, 1], ], dtype=np.float32, )) self._interpolation = interpolation self._tex = tex_cls( (10, 10, 10), interpolation=self._interpolation, wrapping='clamp_to_edge', ) # Create program Visual.__init__(self, vcode=VERT_SHADER, fcode="") self.shared_program['u_volumetex'] = self._tex self.shared_program['a_position'] = self._vertices self.shared_program['a_texcoord'] = self._texcoord self.shared_program['gamma'] = self._gamma self._draw_mode = 'triangle_strip' self._index_buffer = IndexBuffer() # Only show back faces of cuboid. This is required because if we are # inside the volume, then the front faces are outside of the clipping # box and will not be drawn. self.set_gl_state('translucent', cull_face=False) # Set data self.set_data(vol, clim) # Set params self.method = method self.relative_step_size = relative_step_size self.threshold = threshold if (threshold is not None) else vol.mean() self.freeze() def set_data(self, vol, clim=None, copy=True): """ Set the volume data. Parameters ---------- vol : ndarray The 3D volume. clim : tuple | None Colormap limits to use. None will use the min and max values. copy : bool | True Whether to copy the input volume prior to applying clim normalization. """ # Check volume if not isinstance(vol, np.ndarray): raise ValueError('Volume visual needs a numpy array.') if not ((vol.ndim == 3) or (vol.ndim == 4 and vol.shape[-1] <= 4)): raise ValueError('Volume visual needs a 3D image.') # Handle clim if clim is not None: clim = np.array(clim, float) if not (clim.ndim == 1 and clim.size == 2): raise ValueError('clim must be a 2-element array-like') self._clim = tuple(clim) if self._clim is None: self._clim = vol.min(), vol.max() # store clims used to normalize _tex data for use in clim_normalized self._texture_limits = self._clim # store volume in case it needs to be renormalized by clim.setter self._last_data = vol self.shared_program['clim'] = self.clim_normalized # Apply clim (copy data by default... see issue #1727) vol = np.array(vol, dtype='float32', copy=copy) if self._clim[1] == self._clim[0]: if self._clim[0] != 0.0: vol *= 1.0 / self._clim[0] elif self._clim[0] > self._clim[1]: vol *= -1 vol += self._clim[1] vol /= self._clim[1] - self._clim[0] else: vol -= self._clim[0] vol /= self._clim[1] - self._clim[0] # Apply to texture self._tex.set_data(vol) # will be efficient if vol is same shape self.shared_program['u_shape'] = ( vol.shape[2], vol.shape[1], vol.shape[0], ) shape = vol.shape[:3] if self._vol_shape != shape: self._vol_shape = shape self._need_vertex_update = True self._vol_shape = shape # Get some stats self._kb_for_texture = np.prod(self._vol_shape) / 1024 def rescale_data(self): """Force rescaling of data to the current contrast limits and texture upload. Because Textures are currently 8-bits, and contrast adjustment is done during rendering by scaling the values retrieved from the texture on the GPU (provided that the new contrast limits settings are within the range of the clims used when the last texture was uploaded), posterization may become visible if the contrast limits become *too* small of a fraction of the clims used to normalize the texture. This function is a convenience to "force" rescaling of the Texture data to the current contrast limits range. """ self.set_data(self._last_data, clim=self._clim) self.update() @property def clim(self): """The contrast limits that were applied to the volume data. Volume display is mapped from black to white with these values. Settable via set_data() as well as @clim.setter. """ return self._clim @property def texture_is_inverted(self): if self._texture_limits is not None: return self._texture_limits[1] < self._texture_limits[0] @clim.setter def clim(self, value): """Set contrast limits used when rendering the image. ``value`` should be a 2-tuple of floats (min_clim, max_clim), where each value is within the range set by self.clim. If the new value is outside of the (min, max) range of the clims previously used to normalize the texture data, then data will be renormalized using set_data. """ clim = np.array(value, float) if not (clim.ndim == 1 and clim.size == 2): raise ValueError('clim must be a 2-element array-like') self._clim = tuple(clim) if self.texture_is_inverted: if (clim[0] > self._texture_limits[0]) or ( clim[1] < self._texture_limits[1]): self.rescale_data() return else: if (clim[0] < self._texture_limits[0]) or ( clim[1] > self._texture_limits[1]): self.rescale_data() return # if the clim range is too small of a percentage of the last-used texture range, # posterization may be visible, so downscale the texture range. range_ratio = np.subtract(*clim) / abs( np.subtract(*self._texture_limits)) if np.abs(range_ratio) < self._clim_range_threshold: self.rescale_data() else: # new clims are within reasonable range of the texture data, just call shader self.shared_program['clim'] = self.clim_normalized self.update() @property def clim_normalized(self): """Normalize current clims between 0-1 based on last-used texture data range. In set_data(), the data is normalized (on the CPU) to 0-1 using ``clim``. During rendering, the frag shader will apply the final contrast adjustment based on the current ``clim``. """ range_min, range_max = self._texture_limits clim0, clim1 = self.clim if self.texture_is_inverted: tex_range = range_min - range_max clim0 = (clim0 - range_max) / tex_range clim1 = (clim1 - range_max) / tex_range else: tex_range = range_max - range_min clim0 = (clim0 - range_min) / tex_range clim1 = (clim1 - range_min) / tex_range return clim0, clim1 @property def gamma(self): """The gamma used when rendering the image.""" return self._gamma @gamma.setter def gamma(self, value): """Set gamma used when rendering the image.""" if value <= 0: raise ValueError("gamma must be > 0") self._gamma = float(value) self.shared_program['gamma'] = self._gamma self.update() @property def cmap(self): return self._cmap @cmap.setter def cmap(self, cmap): self._cmap = get_colormap(cmap) self.shared_program.frag['cmap'] = Function(self._cmap.glsl_map) self.update() @property def interpolation(self): """The interpolation method to use Current options are: * linear: this method is appropriate for most volumes as it creates nice looking visuals. * nearest: this method is appropriate for volumes with discrete data where additional interpolation does not make sense. """ return self._interpolation @interpolation.setter def interpolation(self, interp): if interp not in self._interpolation_names: raise ValueError("interpolation must be one of %s" % ', '.join(self._interpolation_names)) if self._interpolation != interp: self._interpolation = interp self._tex.interpolation = self._interpolation self.update() @property def method(self): """The render method to use Current options are: * translucent: voxel colors are blended along the view ray until the result is opaque. * mip: maxiumum intensity projection. Cast a ray and display the maximum value that was encountered. * additive: voxel colors are added along the view ray until the result is saturated. * iso: isosurface. Cast a ray until a certain threshold is encountered. At that location, lighning calculations are performed to give the visual appearance of a surface. """ return self._method @method.setter def method(self, method): # Check and save known_methods = list(frag_dict.keys()) if method not in known_methods: raise ValueError('Volume render method should be in %r, not %r' % (known_methods, method)) self._method = method # Get rid of specific variables - they may become invalid if 'u_threshold' in self.shared_program: self.shared_program['u_threshold'] = None self.shared_program.frag = frag_dict[method] self.shared_program.frag['sampler_type'] = self._tex.glsl_sampler_type self.shared_program.frag['sample'] = self._tex.glsl_sample self.shared_program.frag['cmap'] = Function(self._cmap.glsl_map) self.shared_program['texture2D_LUT'] = (self.cmap.texture_lut() if ( hasattr(self.cmap, 'texture_lut')) else None) self.update() @property def threshold(self): """ The threshold value to apply for the isosurface render method. """ return self._threshold @threshold.setter def threshold(self, value): self._threshold = float(value) if 'u_threshold' in self.shared_program: self.shared_program['u_threshold'] = self._threshold self.update() @property def relative_step_size(self): """ The relative step size used during raycasting. Larger values yield higher performance at reduced quality. If set > 2.0 the ray skips entire voxels. Recommended values are between 0.5 and 1.5. The amount of quality degredation depends on the render method. """ return self._relative_step_size @relative_step_size.setter def relative_step_size(self, value): value = float(value) if value < 0.1: raise ValueError('relative_step_size cannot be smaller than 0.1') self._relative_step_size = value self.shared_program['u_relative_step_size'] = value def _create_vertex_data(self): """ Create and set positions and texture coords from the given shape We have six faces with 1 quad (2 triangles) each, resulting in 6*2*3 = 36 vertices in total. """ shape = self._vol_shape # Get corner coordinates. The -0.5 offset is to center # pixels/voxels. This works correctly for anisotropic data. x0, x1 = -0.5, shape[2] - 0.5 y0, y1 = -0.5, shape[1] - 0.5 z0, z1 = -0.5, shape[0] - 0.5 pos = np.array( [ [x0, y0, z0], [x1, y0, z0], [x0, y1, z0], [x1, y1, z0], [x0, y0, z1], [x1, y0, z1], [x0, y1, z1], [x1, y1, z1], ], dtype=np.float32, ) """ 6-------7 /| /| 4-------5 | | | | | | 2-----|-3 |/ |/ 0-------1 """ # Order is chosen such that normals face outward; front faces will be # culled. indices = np.array([2, 6, 0, 4, 5, 6, 7, 2, 3, 0, 1, 5, 3, 7], dtype=np.uint32) # Apply self._vertices.set_data(pos) self._index_buffer.set_data(indices) def _compute_bounds(self, axis, view): return 0, self._vol_shape[axis] def _prepare_transforms(self, view): trs = view.transforms view.view_program.vert['transform'] = trs.get_transform() view_tr_f = trs.get_transform('visual', 'document') view_tr_i = view_tr_f.inverse view.view_program.vert['viewtransformf'] = view_tr_f view.view_program.vert['viewtransformi'] = view_tr_i def _prepare_draw(self, view): if self._need_vertex_update: self._create_vertex_data()