|
12 | 12 |
|
13 | 13 |
|
14 | 14 | class _UnsetSamplerBufferFilter(logging.Filter): |
15 | | - # RasterHistoryVisual / VoltageHistoryVisual deliberately never set their |
16 | | - # samplerBuffer uniforms (`u_spikes`, `u_volts`): gloo has no samplerBuffer |
17 | | - # support, so each defaults to texture unit 0, which we bind by hand. VisPy's |
18 | | - # one-time program validation logs that as an "unset variable" -- drop just |
19 | | - # those messages so they don't look like errors. |
| 15 | + # RasterHistoryVisual / VoltageHistoryVisual / NeuronCloudVisual deliberately |
| 16 | + # never set their samplerBuffer uniforms (`u_spikes`, `u_volts`, `u_fire`): gloo |
| 17 | + # has no samplerBuffer support, so each defaults to texture unit 0, which we bind |
| 18 | + # by hand. VisPy's one-time program validation logs that as an "unset variable" |
| 19 | + # -- drop just those messages so they don't look like errors. |
| 20 | + _UNSET = ('u_spikes', 'u_volts', 'u_fire') |
| 21 | + |
20 | 22 | def filter(self, record): |
21 | 23 | msg = record.getMessage() |
22 | | - return 'u_spikes' not in msg and 'u_volts' not in msg |
| 24 | + return not any(name in msg for name in self._UNSET) |
23 | 25 |
|
24 | 26 |
|
25 | 27 | logging.getLogger('vispy').addFilter(_UnsetSamplerBufferFilter()) |
@@ -384,3 +386,326 @@ def __del__(self): |
384 | 386 |
|
385 | 387 |
|
386 | 388 | FeatureMatrix = create_visual_node(FeatureMatrixVisual) |
| 389 | + |
| 390 | + |
| 391 | +# Neurons-as-circles, with firing read straight from the spike-history GL buffer. |
| 392 | +# Each neuron is one GL_POINTS vertex placed at a static layout position (a_pos). |
| 393 | +# Firing is pulled zero-copy from the SAME (T, batch, n) R8UI spike-history buffer |
| 394 | +# RasterHistoryVisual reads (see GUINetwork.enable_spike_history): the vertex shader |
| 395 | +# texelFetches this neuron's spike at the current timestep (and a few preceding ones) |
| 396 | +# to compute a fading "glow" intensity, and the fragment shader draws a filled disc |
| 397 | +# coloured between the layer's base colour and the fire colour. No per-frame copy: |
| 398 | +# the owning widget just updates the u_t uniform each draw. |
| 399 | +# |
| 400 | +# #version 140: gives texelFetch + usamplerBuffer while still allowing gl_FragColor |
| 401 | +# and the attribute/varying qualifiers vispy's transform Function emits (vispy |
| 402 | +# rewrites these to in/out to match the version automatically). |
| 403 | +_NEURON_VERT = """ |
| 404 | +#version 140 |
| 405 | +attribute vec2 a_pos; // neuron position in DATA coords (static layout) |
| 406 | +attribute float a_index; // neuron id within the layer (row index into u_fire) |
| 407 | +uniform usamplerBuffer u_fire; // R8UI spike history; left UNSET -> texture unit 0 |
| 408 | +uniform int u_t; // current timestep |
| 409 | +uniform int u_T; // total timesteps in the buffer |
| 410 | +uniform int u_stride; // elements per timestep row (= batch*n) |
| 411 | +uniform int u_glow; // afterglow window (timesteps); >=1 |
| 412 | +uniform float u_pointsize; // on-screen disc diameter in pixels |
| 413 | +varying float v_intensity; // 0..1 firing glow -> fragment |
| 414 | +void main() { |
| 415 | + gl_Position = $transform(vec4(a_pos, 0.0, 1.0)); |
| 416 | + gl_PointSize = u_pointsize; |
| 417 | +
|
| 418 | + // Max spike over [t-u_glow+1, t] with linear falloff, so a spike stays visible |
| 419 | + // for a few frames even when draws are throttled (batch 0; time-major buffer). |
| 420 | + int idx = int(a_index); |
| 421 | + float inten = 0.0; |
| 422 | + for (int k = 0; k < u_glow; k++) { |
| 423 | + int tt = u_t - k; |
| 424 | + if (tt < 0 || tt >= u_T) continue; |
| 425 | + uint s = texelFetch(u_fire, tt * u_stride + idx).r; |
| 426 | + if (s != 0u) inten = max(inten, 1.0 - float(k) / float(u_glow)); |
| 427 | + } |
| 428 | + v_intensity = inten; |
| 429 | +} |
| 430 | +""" |
| 431 | + |
| 432 | +_NEURON_FRAG = """ |
| 433 | +#version 140 |
| 434 | +varying float v_intensity; |
| 435 | +uniform vec4 u_base; // resting colour |
| 436 | +uniform vec4 u_fire_color; // colour at full firing intensity |
| 437 | +void main() { |
| 438 | + // Round the square point sprite into a disc. |
| 439 | + vec2 d = gl_PointCoord - vec2(0.5); |
| 440 | + if (dot(d, d) > 0.25) discard; |
| 441 | + gl_FragColor = mix(u_base, u_fire_color, v_intensity); |
| 442 | +} |
| 443 | +""" |
| 444 | + |
| 445 | + |
| 446 | +class NeuronCloudVisual(Visual): |
| 447 | + def __init__(self, positions, indices, total_timesteps, row_stride, gl_buffer_id, |
| 448 | + base_color=(0.25, 0.25, 0.30, 1.0), fire_color=(1.0, 0.9, 0.2, 1.0), |
| 449 | + point_size=9.0, glow=8): |
| 450 | + self.n = int(len(positions)) |
| 451 | + self.T = int(total_timesteps) |
| 452 | + self.stride = int(row_stride) # = batch * n; idx = time*stride + neuron |
| 453 | + self._gl_buffer_id = int(gl_buffer_id) # raw GL buffer with the spike history |
| 454 | + self._tbo_tex = None # GL texture viewing that buffer (lazy) |
| 455 | + Visual.__init__(self, vcode=_NEURON_VERT, fcode=_NEURON_FRAG) |
| 456 | + |
| 457 | + self._pos = np.asarray(positions, dtype=np.float32) |
| 458 | + self._pos_vbo = gloo.VertexBuffer(self._pos) |
| 459 | + self._index_vbo = gloo.VertexBuffer(np.asarray(indices, dtype=np.float32)) |
| 460 | + self.shared_program['a_pos'] = self._pos_vbo |
| 461 | + self.shared_program['a_index'] = self._index_vbo |
| 462 | + self.shared_program['u_t'] = 0 |
| 463 | + self.shared_program['u_T'] = self.T |
| 464 | + self.shared_program['u_stride'] = self.stride |
| 465 | + self.shared_program['u_glow'] = max(1, int(glow)) |
| 466 | + self.shared_program['u_pointsize'] = float(point_size) |
| 467 | + self.shared_program['u_base'] = base_color |
| 468 | + self.shared_program['u_fire_color'] = fire_color |
| 469 | + # NOTE: u_fire is deliberately never set (gloo has no samplerBuffer support); it |
| 470 | + # defaults to texture unit 0, which we bind by hand in _prepare_draw. |
| 471 | + self._draw_mode = 'points' |
| 472 | + self.set_gl_state('translucent', depth_test=False) |
| 473 | + |
| 474 | + def set_time(self, t): |
| 475 | + self.shared_program['u_t'] = int(t) |
| 476 | + |
| 477 | + def _create_tbo(self): |
| 478 | + # 1-D R8UI view of the spike-history buffer; glTexBuffer references it (no copy). |
| 479 | + tex = gl.glGenTextures(1) |
| 480 | + gl.glBindTexture(gl.GL_TEXTURE_BUFFER, tex) |
| 481 | + gl.glTexBuffer(gl.GL_TEXTURE_BUFFER, gl.GL_R8UI, self._gl_buffer_id) |
| 482 | + gl.glBindTexture(gl.GL_TEXTURE_BUFFER, 0) |
| 483 | + self._tbo_tex = tex |
| 484 | + |
| 485 | + def _prepare_draw(self, view): |
| 486 | + if self._tbo_tex is None: |
| 487 | + self._create_tbo() |
| 488 | + # Round point sprites need program-controlled point size. Enable it here (raw |
| 489 | + # GL) so gl_PointSize from the vertex shader takes effect; harmless if already on. |
| 490 | + gl.glEnable(gl.GL_PROGRAM_POINT_SIZE) |
| 491 | + # Bind our buffer-texture to unit 0 (u_fire samples unit 0 by default). Same |
| 492 | + # immediate-bind trick RasterHistoryVisual uses; per-program flush keeps it live |
| 493 | + # through this visual's draw. |
| 494 | + gl.glActiveTexture(gl.GL_TEXTURE0) |
| 495 | + gl.glBindTexture(gl.GL_TEXTURE_BUFFER, self._tbo_tex) |
| 496 | + |
| 497 | + def _prepare_transforms(self, view): |
| 498 | + view.view_program.vert['transform'] = view.transforms.get_transform() |
| 499 | + |
| 500 | + def _compute_bounds(self, axis, view): |
| 501 | + if axis in (0, 1) and self.n: |
| 502 | + return (float(self._pos[:, axis].min()), float(self._pos[:, axis].max())) |
| 503 | + return None |
| 504 | + |
| 505 | + # No __del__: the buffer texture is freed when the GL context is destroyed (see |
| 506 | + # the note on RasterHistoryVisual). |
| 507 | + |
| 508 | + |
| 509 | +NeuronCloud = create_visual_node(NeuronCloudVisual) |
| 510 | + |
| 511 | + |
| 512 | +# Synapses-as-lines. A single GL_LINES draw covers every selected synapse across all |
| 513 | +# connections: each segment is two vertices in `positions`, coloured per-vertex from |
| 514 | +# the synapse weight (`colors`). Forward edges are one straight segment; recurrent / |
| 515 | +# back edges are pre-tessellated into several short segments along a bowed curve by |
| 516 | +# the owning widget, so they still live in this one flat lines buffer. The colour |
| 517 | +# buffer is rebuildable via set_colors -- the hook for later weight-change rendering. |
| 518 | +_SYNAPSE_VERT = """ |
| 519 | +#version 140 |
| 520 | +attribute vec2 a_pos; |
| 521 | +attribute vec4 a_color; |
| 522 | +varying vec4 v_color; |
| 523 | +void main() { |
| 524 | + gl_Position = $transform(vec4(a_pos, 0.0, 1.0)); |
| 525 | + v_color = a_color; |
| 526 | +} |
| 527 | +""" |
| 528 | + |
| 529 | +_SYNAPSE_FRAG = """ |
| 530 | +#version 140 |
| 531 | +varying vec4 v_color; |
| 532 | +void main() { gl_FragColor = v_color; } |
| 533 | +""" |
| 534 | + |
| 535 | + |
| 536 | +class SynapseLinesVisual(Visual): |
| 537 | + def __init__(self, positions, colors): |
| 538 | + Visual.__init__(self, vcode=_SYNAPSE_VERT, fcode=_SYNAPSE_FRAG) |
| 539 | + self._pos = np.asarray(positions, dtype=np.float32) |
| 540 | + self._pos_vbo = gloo.VertexBuffer(self._pos) |
| 541 | + self._color_vbo = gloo.VertexBuffer(np.asarray(colors, dtype=np.float32)) |
| 542 | + self.shared_program['a_pos'] = self._pos_vbo |
| 543 | + self.shared_program['a_color'] = self._color_vbo |
| 544 | + self._draw_mode = 'lines' |
| 545 | + self.set_gl_state('translucent', depth_test=False) |
| 546 | + |
| 547 | + def set_colors(self, colors): |
| 548 | + # Goal-3 hook: re-drive per-vertex colour from refreshed weights. `colors` must |
| 549 | + # match the vertex count established at construction (2 verts per segment). |
| 550 | + self._color_vbo.set_data(np.asarray(colors, dtype=np.float32)) |
| 551 | + self.update() |
| 552 | + |
| 553 | + def _prepare_draw(self, view): |
| 554 | + pass |
| 555 | + |
| 556 | + def _prepare_transforms(self, view): |
| 557 | + view.view_program.vert['transform'] = view.transforms.get_transform() |
| 558 | + |
| 559 | + def _compute_bounds(self, axis, view): |
| 560 | + if axis in (0, 1) and len(self._pos): |
| 561 | + return (float(self._pos[:, axis].min()), float(self._pos[:, axis].max())) |
| 562 | + return None |
| 563 | + |
| 564 | + |
| 565 | +SynapseLines = create_visual_node(SynapseLinesVisual) |
| 566 | + |
| 567 | + |
| 568 | +# Cached synapse lines: the synapse geometry is STATIC, but a single SceneCanvas |
| 569 | +# clears and redraws every visual each frame, so plain SynapseLines re-pays its |
| 570 | +# (vertex-bound) line-draw cost on every frame -- ~24 ms with another plot on the |
| 571 | +# canvas (profiled). This visual draws the lines ONCE into an offscreen texture (an |
| 572 | +# FBO covering the network's data-space bounding box) and then, every frame, draws a |
| 573 | +# single camera-transformed textured quad over that bbox. Because the quad lives in |
| 574 | +# DATA coordinates, the scene camera pans/zooms it exactly like the lines would move, |
| 575 | +# so the expensive line pass NEVER re-runs on camera changes -- only when the colours |
| 576 | +# change (`set_colors`, the goal-3 weight-change hook). Trade-off: the cache is a |
| 577 | +# raster snapshot, so zooming far in shows texture pixelation (raise `max_side` or |
| 578 | +# call `refresh()` for a re-render at the current detail if that matters). |
| 579 | +_CACHED_LINE_VERT = """ |
| 580 | +#version 120 |
| 581 | +attribute vec2 a_pos; |
| 582 | +attribute vec4 a_color; |
| 583 | +uniform vec2 u_scale; // 2/(x1-x0), 2/(y1-y0): bbox -> clip, offscreen pass |
| 584 | +uniform vec2 u_offset; // (x0, y0) |
| 585 | +varying vec4 v_color; |
| 586 | +void main() { |
| 587 | + // Map the data-space bbox to clip space [-1, 1] directly (no matrix-convention |
| 588 | + // ambiguity): x0 -> -1, x1 -> +1, likewise y. The FBO viewport then puts x0,y0 at |
| 589 | + // texel (0, 0), matching the display quad's (0, 0) texcoord at corner (x0, y0). |
| 590 | + vec2 ndc = (a_pos - u_offset) * u_scale - 1.0; |
| 591 | + gl_Position = vec4(ndc, 0.0, 1.0); |
| 592 | + v_color = a_color; |
| 593 | +} |
| 594 | +""" |
| 595 | + |
| 596 | +_CACHED_LINE_FRAG = """ |
| 597 | +#version 120 |
| 598 | +varying vec4 v_color; |
| 599 | +void main() { gl_FragColor = v_color; } |
| 600 | +""" |
| 601 | + |
| 602 | +# Display quad: a textured rectangle spanning the bbox in data coords, positioned by |
| 603 | +# the scene transform (camera). vispy's default GLSL handles attribute/varying and the |
| 604 | +# $transform Function injection. |
| 605 | +_CACHED_QUAD_VERT = """ |
| 606 | +attribute vec2 a_pos; |
| 607 | +attribute vec2 a_tex; |
| 608 | +varying vec2 v_tex; |
| 609 | +void main() { |
| 610 | + gl_Position = $transform(vec4(a_pos, 0.0, 1.0)); |
| 611 | + v_tex = a_tex; |
| 612 | +} |
| 613 | +""" |
| 614 | + |
| 615 | +_CACHED_QUAD_FRAG = """ |
| 616 | +uniform sampler2D u_tex; |
| 617 | +varying vec2 v_tex; |
| 618 | +void main() { gl_FragColor = texture2D(u_tex, v_tex); } |
| 619 | +""" |
| 620 | + |
| 621 | + |
| 622 | +class CachedSynapseLinesVisual(Visual): |
| 623 | + def __init__(self, positions, colors, bbox, max_side=2048): |
| 624 | + Visual.__init__(self, vcode=_CACHED_QUAD_VERT, fcode=_CACHED_QUAD_FRAG) |
| 625 | + x0, y0, x1, y1 = (float(v) for v in bbox) |
| 626 | + # Guard against a degenerate (zero-area) bbox. |
| 627 | + if x1 <= x0: |
| 628 | + x1 = x0 + 1.0 |
| 629 | + if y1 <= y0: |
| 630 | + y1 = y0 + 1.0 |
| 631 | + self._bbox = (x0, y0, x1, y1) |
| 632 | + |
| 633 | + # Offscreen resolution: longest side = max_side, other side by aspect. |
| 634 | + aspect = (x1 - x0) / (y1 - y0) |
| 635 | + if aspect >= 1.0: |
| 636 | + W, H = int(max_side), max(16, int(round(max_side / aspect))) |
| 637 | + else: |
| 638 | + W, H = max(16, int(round(max_side * aspect)), ), int(max_side) |
| 639 | + self._W, self._H = int(W), int(H) |
| 640 | + |
| 641 | + # Offscreen line program (raw gloo; rendered into the FBO in refresh()). |
| 642 | + self._line_prog = gloo.Program(_CACHED_LINE_VERT, _CACHED_LINE_FRAG) |
| 643 | + self._line_prog['a_pos'] = gloo.VertexBuffer(np.asarray(positions, dtype=np.float32)) |
| 644 | + self._line_color = gloo.VertexBuffer(np.asarray(colors, dtype=np.float32)) |
| 645 | + self._line_prog['a_color'] = self._line_color |
| 646 | + self._line_prog['u_scale'] = (2.0 / (x1 - x0), 2.0 / (y1 - y0)) |
| 647 | + self._line_prog['u_offset'] = (x0, y0) |
| 648 | + self._n_verts = int(len(positions)) |
| 649 | + |
| 650 | + self._tex = None # FBO colour texture (lazy; needs a GL context) |
| 651 | + self._fbo = None |
| 652 | + self.dirty = True # needs an offscreen render before the quad is meaningful |
| 653 | + |
| 654 | + # Display quad over the bbox (data coords) with matching texcoords. |
| 655 | + corners = np.array([[x0, y0], [x1, y0], [x0, y1], [x1, y1]], dtype=np.float32) |
| 656 | + texco = np.array([[0, 0], [1, 0], [0, 1], [1, 1]], dtype=np.float32) |
| 657 | + self.shared_program['a_pos'] = gloo.VertexBuffer(corners) |
| 658 | + self.shared_program['a_tex'] = gloo.VertexBuffer(texco) |
| 659 | + self._draw_mode = 'triangle_strip' |
| 660 | + self.set_gl_state('translucent', depth_test=False) |
| 661 | + |
| 662 | + def refresh(self): |
| 663 | + # Render the (static) lines into the offscreen texture. Cheap to call when clean |
| 664 | + # via `if visual.dirty: visual.refresh()`; the owning widget gates it. Runs OUTSIDE |
| 665 | + # the scene draw (from the widget's render()), so the nested FBO pass doesn't |
| 666 | + # interleave with vispy's per-visual GLIR flush. |
| 667 | + if self._tex is None: |
| 668 | + self._tex = gloo.Texture2D( |
| 669 | + shape=(self._H, self._W, 4), format='rgba', interpolation='linear') |
| 670 | + self._fbo = gloo.FrameBuffer(color=self._tex) |
| 671 | + self.shared_program['u_tex'] = self._tex |
| 672 | + |
| 673 | + with self._fbo: |
| 674 | + gloo.set_viewport(0, 0, self._W, self._H) |
| 675 | + gloo.set_state(blend=True, depth_test=False, |
| 676 | + blend_func=('src_alpha', 'one_minus_src_alpha')) |
| 677 | + gloo.clear(color=(0.0, 0.0, 0.0, 0.0)) |
| 678 | + self._line_prog.draw('lines') |
| 679 | + # FrameBuffer.__exit__ rebinds the default FBO but does NOT restore the viewport, |
| 680 | + # so reset it to the full canvas; otherwise the next on-screen draw is squashed |
| 681 | + # into the FBO's (W, H) rect. (Belt-and-suspenders: vispy also resets it per draw.) |
| 682 | + canvas = getattr(self, 'canvas', None) |
| 683 | + if canvas is not None: |
| 684 | + w, h = canvas.physical_size |
| 685 | + gloo.set_viewport(0, 0, int(w), int(h)) |
| 686 | + self.dirty = False |
| 687 | + |
| 688 | + def set_colors(self, colors): |
| 689 | + # Goal-3 hook: re-drive per-vertex colour from refreshed weights, then mark the |
| 690 | + # cache stale so the next render() re-bakes the texture. |
| 691 | + self._line_color.set_data(np.asarray(colors, dtype=np.float32)) |
| 692 | + self.dirty = True |
| 693 | + |
| 694 | + def _prepare_draw(self, view): |
| 695 | + # If the texture isn't baked yet (first frame before the widget called refresh), |
| 696 | + # bake it now so the quad has something to sample. |
| 697 | + if self._tex is None: |
| 698 | + self.refresh() |
| 699 | + |
| 700 | + def _prepare_transforms(self, view): |
| 701 | + view.view_program.vert['transform'] = view.transforms.get_transform() |
| 702 | + |
| 703 | + def _compute_bounds(self, axis, view): |
| 704 | + if axis == 0: |
| 705 | + return (self._bbox[0], self._bbox[2]) |
| 706 | + if axis == 1: |
| 707 | + return (self._bbox[1], self._bbox[3]) |
| 708 | + return None |
| 709 | + |
| 710 | + |
| 711 | +CachedSynapseLines = create_visual_node(CachedSynapseLinesVisual) |
0 commit comments