Created
September 19, 2025 16:59
-
-
Save larsoner/1a9bbdc26bc3a3af1de8e5033fb99891 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """Visual stimulus design. | |
| Tools for drawing shapes and text on the screen. | |
| """ | |
| # Authors: Dan McCloy <[email protected]> | |
| # Eric Larson <[email protected]> | |
| # Ross Maddox <[email protected]> | |
| # | |
| # License: BSD (3-clause) | |
| import re | |
| import warnings | |
| from contextlib import contextmanager | |
| from ctypes import POINTER, c_char, c_float, c_int, cast, create_string_buffer, pointer | |
| import numpy as np | |
| from .._utils import check_units, logger | |
| # TODO: Should eventually follow | |
| # https://pyglet.readthedocs.io/en/latest/programming_guide/rendering.html | |
| # Should have cleaner programs, less manual GL usage, etc. | |
| # This will maybe also fix some bugs with GL state accounting that seem to be breaking | |
| # the advanced_video.py example (text flashes, i.e., draw only works once!) | |
| # Or even better, let's refactor to use native Pyglet shapes: | |
| # https://github.com/pyglet/pyglet/blob/master/examples/shapes.py | |
| def _convert_color(color, byte=True): | |
| """Convert 3- or 4-element color into OpenGL usable color.""" | |
| from matplotlib.colors import colorConverter | |
| color = (0.0, 0.0, 0.0, 0.0) if color is None else color | |
| color = 255 * np.array(colorConverter.to_rgba(color)) | |
| color = color.astype(np.uint8) | |
| if not byte: | |
| color = tuple((color / 255.0).astype(np.float32)) | |
| else: | |
| color = tuple(int(c) for c in color) | |
| return color | |
| @contextmanager | |
| def _element_array_buffer(buffer_id): | |
| from pyglet import gl | |
| old = (gl.GLint * 1)() | |
| gl.glGetIntegerv(gl.GL_ELEMENT_ARRAY_BUFFER_BINDING, old) | |
| gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, buffer_id) | |
| try: | |
| yield | |
| finally: | |
| gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, list(old)[0]) | |
| @contextmanager | |
| def _array_buffer(buffer_id): | |
| from pyglet import gl | |
| old = (gl.GLint * 1)() | |
| gl.glGetIntegerv(gl.GL_ARRAY_BUFFER_BINDING, old) | |
| gl.glBindBuffer(gl.GL_ARRAY_BUFFER, buffer_id) | |
| try: | |
| yield | |
| finally: | |
| gl.glBindBuffer(gl.GL_ARRAY_BUFFER, list(old)[0]) | |
| @contextmanager | |
| def _use_program(program): | |
| from pyglet import gl | |
| old = (gl.GLint * 1)() | |
| gl.glGetIntegerv(gl.GL_CURRENT_PROGRAM, old) | |
| gl.glUseProgram(program) | |
| try: | |
| yield | |
| finally: | |
| gl.glUseProgram(list(old)[0]) | |
| ############################################################################## | |
| # Text | |
| class Text: | |
| """A text object. | |
| Parameters | |
| ---------- | |
| ec : instance of ExperimentController | |
| Parent EC. | |
| text : str | |
| The text to display. | |
| pos : array | |
| 2-element array consisting of X- and Y-position coordinates. | |
| color : matplotlib Color | |
| Color of the text. | |
| font_name : str | |
| Font to use. | |
| font_size : float | |
| Font size (points) to use. | |
| height : float | None | |
| Height of the text region. None will automatically allocate the | |
| necessary size. | |
| width : float | None | str | |
| Width (in pixels) of the text region. `'auto'` will allocate 80% of | |
| the screen width, useful for instructions. None will automatically | |
| allocate sufficient space, but not that this disables text wrapping. | |
| anchor_x : str | |
| Horizontal text anchor (e.g., ``'center'``). | |
| anchor_y : str | |
| Vertical text anchor (e.g., ``'center'``). | |
| units : str | |
| Units to use. These will apply to all spatial aspects of the drawing. | |
| shape e.g. size, position. See ``check_units`` for options. | |
| wrap : bool | |
| Whether or not the text will wrap to fit in screen, appropriate for | |
| multiline text. Inappropriate for text requiring precise positioning. | |
| attr : bool | |
| Should the text be interpreted with pyglet's ``decode_attributed`` | |
| method? This allows inline formatting for text color, e.g., | |
| ``'This is {color (255, 0, 0, 255)}red text'``. If ``attr=True``, the | |
| values of ``font_name``, ``font_size``, and ``color`` are automatically | |
| prepended to ``text`` (though they will be overridden by any inline | |
| formatting within ``text`` itself). | |
| Returns | |
| ------- | |
| text : instance of Text | |
| The text object. | |
| """ | |
| def __init__( | |
| self, | |
| ec, | |
| text, | |
| pos=(0, 0), | |
| color="white", | |
| font_name="Arial", | |
| font_size=24, | |
| height=None, | |
| width="auto", | |
| anchor_x="center", | |
| anchor_y="center", | |
| units="norm", | |
| wrap=False, | |
| attr=True, | |
| ): | |
| import pyglet | |
| pos = np.array(pos)[:, np.newaxis] | |
| pos = ec._convert_units(pos, units, "pix")[:, 0] | |
| if width == "auto": | |
| width = float(ec.window_size_pix[0]) * 0.8 | |
| elif isinstance(width, str): | |
| raise ValueError('"width", if str, must be "auto"') | |
| self._attr = attr | |
| if wrap: | |
| text = text + "\n " # weird Pyglet bug | |
| if self._attr: | |
| preamble = ( | |
| f"{{font_name '{font_name}'}}" | |
| f"{{font_size {font_size}}}" | |
| f"{{color {_convert_color(color)}}}" | |
| ) | |
| doc = pyglet.text.decode_attributed(preamble + text) | |
| self._text = pyglet.text.layout.TextLayout( | |
| doc, width=width, height=height, multiline=wrap, dpi=int(ec.dpi) | |
| ) | |
| else: | |
| self._text = pyglet.text.Label( | |
| text, width=width, height=height, multiline=wrap, dpi=int(ec.dpi) | |
| ) | |
| self._text.color = _convert_color(color) | |
| self._text.font_name = font_name | |
| self._text.font_size = font_size | |
| self._text.x = pos[0] | |
| self._text.y = pos[1] | |
| self._text.anchor_x = anchor_x | |
| self._text.anchor_y = anchor_y | |
| def set_color(self, color): | |
| """Set the text color | |
| Parameters | |
| ---------- | |
| color : matplotlib Color | None | |
| The color. Use None for no color. | |
| """ | |
| if self._attr: | |
| self._text.document.set_style( | |
| 0, len(self._text.document.text), {"color": _convert_color(color)} | |
| ) | |
| else: | |
| self._text.color = _convert_color(color) | |
| def draw(self): | |
| """Draw the object to the display buffer""" | |
| self._text.draw() | |
| ############################################################################## | |
| # Triangulations | |
| tri_vert = """\ | |
| #version 150 core | |
| in vec2 a_position; | |
| uniform WindowBlock | |
| { | |
| mat4 projection; | |
| mat4 view; | |
| } window; | |
| void main() | |
| { | |
| gl_Position = window.projection * window.view * vec4(a_position, 0.0, 1.0); | |
| } | |
| """ | |
| tri_frag = """ | |
| #version 150 core | |
| uniform vec4 u_color; | |
| out vec4 final_color; | |
| void main() | |
| { | |
| final_color = u_color; | |
| } | |
| """ | |
| def _check_log(obj, func): | |
| log = create_string_buffer(4096) | |
| ptr = cast(pointer(log), POINTER(c_char)) | |
| func(obj, 4096, pointer(c_int()), ptr) | |
| message = log.value | |
| message = message.decode() | |
| if ( | |
| message.startswith("No errors") | |
| or re.match(".*shader was successfully compiled.*", message) | |
| or message == "Vertex shader(s) linked, fragment shader(s) linked.\n" | |
| ): | |
| pass | |
| elif message: | |
| raise RuntimeError(message) | |
| def _create_program(ec, vert, frag): | |
| from pyglet import gl | |
| program = gl.glCreateProgram() | |
| vertex = gl.glCreateShader(gl.GL_VERTEX_SHADER) | |
| buf = create_string_buffer(vert.encode("ASCII")) | |
| ptr = cast(pointer(pointer(buf)), POINTER(POINTER(c_char))) | |
| gl.glShaderSource(vertex, 1, ptr, None) | |
| gl.glCompileShader(vertex) | |
| _check_log(vertex, gl.glGetShaderInfoLog) | |
| fragment = gl.glCreateShader(gl.GL_FRAGMENT_SHADER) | |
| buf = create_string_buffer(frag.encode("ASCII")) | |
| ptr = cast(pointer(pointer(buf)), POINTER(POINTER(c_char))) | |
| gl.glShaderSource(fragment, 1, ptr, None) | |
| gl.glCompileShader(fragment) | |
| _check_log(fragment, gl.glGetShaderInfoLog) | |
| gl.glAttachShader(program, vertex) | |
| gl.glAttachShader(program, fragment) | |
| gl.glLinkProgram(program) | |
| _check_log(program, gl.glGetProgramInfoLog) | |
| gl.glDetachShader(program, vertex) | |
| gl.glDetachShader(program, fragment) | |
| # Set the view matrix | |
| with _use_program(program): | |
| loc = gl.glGetUniformLocation(program, b"u_view") | |
| view = ec.window_size_pix | |
| view = np.diag([2.0 / view[0], 2.0 / view[1], 1.0, 1.0]) | |
| view[-1, :2] = -1 | |
| view = view.astype(np.float32).ravel() | |
| gl.glUniformMatrix4fv(loc, 1, False, (c_float * 16)(*view)) | |
| return program | |
| class _Triangular: | |
| """Super class for objects that use triangulations and/or lines""" | |
| def __init__(self, ec, fill_color, line_color, line_width, line_loop): | |
| from pyglet.graphics.shader import Shader, ShaderProgram | |
| self._ec = ec | |
| self._line_width = line_width | |
| self._line_loop = line_loop # whether or not lines drawn are looped | |
| # initialize program and shaders | |
| self._programs = dict() | |
| self._points = dict() | |
| self._tris = dict() | |
| self._vlist = dict() | |
| for kind in ("line", "fill"): | |
| self._programs[kind] = ShaderProgram( | |
| Shader(tri_vert, 'vertex'), | |
| Shader(tri_frag, 'fragment'), | |
| ) | |
| self._set_points(None, kind) | |
| self.set_line_color("k") | |
| self.set_fill_color("k") | |
| self.set_fill_color(fill_color) | |
| self.set_line_color(line_color) | |
| def _set_points(self, points, kind, *, tris=None): | |
| """Set fill and line points.""" | |
| from pyglet import gl | |
| if points is None: | |
| points = np.empty((0, 2), dtype=np.float32, order="C") | |
| tris = np.empty((0, 3), dtype=np.uint32, order="C") | |
| else: | |
| points = np.asarray(points, dtype=np.float32, order="C") | |
| if kind == "fill": | |
| assert tris is not None | |
| tris = np.asarray(tris, dtype=np.uint32, order="C") | |
| assert tris.ndim == 1 and tris.size % 3 == 0 | |
| tris.shape = (-1, 3) | |
| assert (tris < len(points)).all() | |
| else: | |
| assert tris is None | |
| tris = np.empty((0, 3), dtype=np.uint32, order="C") | |
| kind = "line" | |
| assert points.ndim == 2 and points.shape[1] == 2 | |
| count = points.size // 2 | |
| self._tris[kind] = tris | |
| self._points[kind] = points | |
| a_position = ("f", points.ravel()) | |
| del points | |
| program = self._programs[kind] | |
| if kind == "fill": | |
| self._vlist[kind] = program.vertex_list_indexed( | |
| count, gl.GL_TRIANGLES, tris.ravel(), a_position=a_position, | |
| ) | |
| else: | |
| self._vlist[kind] = program.vertex_list( | |
| count, gl.GL_TRIANGLES, a_position=a_position, | |
| ) | |
| def _set_fill_points(self, points, tris): | |
| self._set_points(points, "fill", tris=tris) | |
| def _set_line_points(self, points): | |
| self._set_points(points, "line") | |
| def set_fill_color(self, fill_color): | |
| """Set the object color | |
| Parameters | |
| ---------- | |
| fill_color : matplotlib Color | None | |
| The fill color. Use None for no fill. | |
| """ | |
| self._programs["fill"]["u_color"] = _convert_color(fill_color, byte=False) | |
| def set_line_color(self, line_color): | |
| """Set the object color | |
| Parameters | |
| ---------- | |
| line_color : matplotlib Color | None | |
| The fill color. Use None for no fill. | |
| """ | |
| self._programs["line"]["u_color"] = _convert_color(line_color, byte=False) | |
| def set_line_width(self, line_width): | |
| """Set the line width in pixels | |
| Parameters | |
| ---------- | |
| line_width : float | |
| The line width. Must be given in pixels. Due to OpenGL | |
| limitations, it must be `0.0 <= line_width <= 10.0`. | |
| """ | |
| line_width = float(line_width) | |
| if not (0.0 <= line_width <= 10.0): | |
| raise ValueError("line_width must be between 0 and 10") | |
| self._line_width = line_width | |
| def draw(self): | |
| """Draw the object to the display buffer.""" | |
| from pyglet import gl | |
| kinds = ("fill",) | |
| if self._line_width > 0: | |
| kinds = kinds + ("line",) | |
| for kind in kinds: | |
| with self._programs[kind]: | |
| x = self._vlist[kind] | |
| if kind == "line": | |
| try: | |
| gl.glLineWidth(self._line_width) | |
| except gl.lib.GLException: # not supported on macOS | |
| gl.glLineWidth(1.0) | |
| if self._line_loop: | |
| mode = gl.GL_LINE_LOOP | |
| else: | |
| mode = gl.GL_LINE_STRIP | |
| else: | |
| mode = gl.GL_TRIANGLES | |
| x.draw(mode) | |
| class Line(_Triangular): | |
| """A connected set of line segments | |
| Parameters | |
| ---------- | |
| ec : instance of ExperimentController | |
| Parent EC. | |
| coords : array-like | |
| 2 x N set of X, Y coordinates. | |
| units : str | |
| Units to use. These will apply to all spatial aspects of the drawing. | |
| shape e.g. size, position. See ``check_units`` for options. | |
| line_color : matplotlib Color | |
| Color of the line. | |
| line_width : float | |
| Line width in pixels. | |
| line_loop : bool | |
| If True, the last point will be joined to the first in a loop. | |
| Returns | |
| ------- | |
| line : instance of Line | |
| The line object. | |
| """ | |
| def __init__( | |
| self, | |
| ec, | |
| coords, | |
| units="norm", | |
| line_color="white", | |
| line_width=1.0, | |
| line_loop=False, | |
| ): | |
| super().__init__( | |
| ec, | |
| fill_color=None, | |
| line_color=line_color, | |
| line_width=line_width, | |
| line_loop=line_loop, | |
| ) | |
| self.set_coords(coords, units) | |
| self.set_line_color(line_color) | |
| def set_coords(self, coords, units="norm"): | |
| """Set line coordinates | |
| Parameters | |
| ---------- | |
| coords : array-like | |
| 2 x N set of X, Y coordinates. | |
| units : str | |
| Units to use. | |
| """ | |
| check_units(units) | |
| coords = np.array(coords, dtype=float) | |
| if coords.ndim == 1: | |
| coords = coords[:, np.newaxis] | |
| if coords.ndim != 2 or coords.shape[0] != 2: | |
| raise ValueError( | |
| "coords must be a vector of length 2, or an " | |
| "array with 2 dimensions (with first dimension " | |
| "having length 2" | |
| ) | |
| self._set_line_points(self._ec._convert_units(coords, units, "pix").T) | |
| class Triangle(_Triangular): | |
| """A triangle | |
| Parameters | |
| ---------- | |
| ec : instance of ExperimentController | |
| Parent EC. | |
| coords : array-like | |
| 2 x 3 set of X, Y coordinates. | |
| units : str | |
| Units to use. These will apply to all spatial aspects of the drawing. | |
| shape e.g. size, position. See ``check_units`` for options. | |
| fill_color : matplotlib Color | |
| Color of the triangle. | |
| line_color : matplotlib Color | None | |
| Color of the border line. None is transparent. | |
| line_width : float | |
| Line width in pixels. | |
| Returns | |
| ------- | |
| line : instance of Triangle | |
| The triangle object. | |
| """ | |
| def __init__( | |
| self, | |
| ec, | |
| coords, | |
| units="norm", | |
| fill_color="white", | |
| line_color=None, | |
| line_width=1.0, | |
| ): | |
| super().__init__( | |
| ec, | |
| fill_color=fill_color, | |
| line_color=line_color, | |
| line_width=line_width, | |
| line_loop=True, | |
| ) | |
| self.set_coords(coords, units) | |
| self.set_fill_color(fill_color) | |
| def set_coords(self, coords, units="norm"): | |
| """Set triangle coordinates | |
| Parameters | |
| ---------- | |
| coords : array-like | |
| 2 x 3 set of X, Y coordinates. | |
| units : str | |
| Units to use. | |
| """ | |
| check_units(units) | |
| coords = np.array(coords, dtype=float) | |
| if coords.shape != (2, 3): | |
| raise ValueError( | |
| "coords must be an array of shape (2, 3), got %s" % (coords.shape,) | |
| ) | |
| points = self._ec._convert_units(coords, units, "pix") | |
| points = points.T | |
| self._set_fill_points(points, [0, 1, 2]) | |
| self._set_line_points(points) | |
| class Rectangle(_Triangular): | |
| """A rectangle. | |
| Parameters | |
| ---------- | |
| ec : instance of ExperimentController | |
| Parent EC. | |
| pos : array-like | |
| 4-element array-like with X, Y center and width, height where x and y | |
| are coordinates of the center. | |
| units : str | |
| Units to use. These will apply to all spatial aspects of the drawing. | |
| shape e.g. size, position. See ``check_units`` for options. | |
| fill_color : matplotlib Color | None | |
| Color to fill with. None is transparent. | |
| line_color : matplotlib Color | None | |
| Color of the border line. None is transparent. | |
| line_width : float | |
| Line width in pixels. | |
| Returns | |
| ------- | |
| line : instance of Rectangle | |
| The rectangle object. | |
| """ | |
| def __init__( | |
| self, ec, pos, units="norm", fill_color="white", line_color=None, line_width=1.0 | |
| ): | |
| super().__init__( | |
| ec, | |
| fill_color=fill_color, | |
| line_color=line_color, | |
| line_width=line_width, | |
| line_loop=True, | |
| ) | |
| self.set_pos(pos, units) | |
| def set_pos(self, pos, units="norm"): | |
| """Set the position of the rectangle | |
| Parameters | |
| ---------- | |
| pos : array-like | |
| X, Y, width, height of the rectangle. | |
| units : str | |
| Units to use. See ``check_units`` for options. | |
| """ | |
| check_units(units) | |
| # do this in normalized units, then convert | |
| pos = np.array(pos) | |
| if not (pos.ndim == 1 and pos.size == 4): | |
| raise ValueError("pos must be a 4-element array-like vector") | |
| self._pos = pos | |
| w = self._pos[2] | |
| h = self._pos[3] | |
| points = np.array( | |
| [ | |
| [-w / 2.0, -h / 2.0], | |
| [-w / 2.0, h / 2.0], | |
| [w / 2.0, h / 2.0], | |
| [w / 2.0, -h / 2.0], | |
| ] | |
| ).T | |
| points += np.array(self._pos[:2])[:, np.newaxis] | |
| points = self._ec._convert_units(points, units, "pix") | |
| points = points.T | |
| self._set_fill_points(points, [0, 1, 2, 0, 2, 3]) | |
| self._set_line_points(points) # all 4 points used for line drawing | |
| class Diamond(_Triangular): | |
| """A diamond. | |
| Parameters | |
| ---------- | |
| ec : instance of ExperimentController | |
| Parent EC. | |
| pos : array-like | |
| 4-element array-like with X, Y center and width, height where x and y | |
| are coordinates of the center. | |
| units : str | |
| Units to use. These will apply to all spatial aspects of the drawing. | |
| shape e.g. size, position. See ``check_units`` for options. | |
| fill_color : matplotlib Color | None | |
| Color to fill with. None is transparent. | |
| line_color : matplotlib Color | None | |
| Color of the border line. None is transparent. | |
| line_width : float | |
| Line width in pixels. | |
| Returns | |
| ------- | |
| line : instance of Rectangle | |
| The rectangle object. | |
| """ | |
| def __init__( | |
| self, ec, pos, units="norm", fill_color="white", line_color=None, line_width=1.0 | |
| ): | |
| super().__init__( | |
| ec, | |
| fill_color=fill_color, | |
| line_color=line_color, | |
| line_width=line_width, | |
| line_loop=True, | |
| ) | |
| self.set_pos(pos, units) | |
| def set_pos(self, pos, units="norm"): | |
| """Set the position of the rectangle | |
| Parameters | |
| ---------- | |
| pos : array-like | |
| X, Y, width, height of the rectangle. | |
| units : str | |
| Units to use. See ``check_units`` for options. | |
| """ | |
| check_units(units) | |
| # do this in normalized units, then convert | |
| pos = np.array(pos) | |
| if not (pos.ndim == 1 and pos.size == 4): | |
| raise ValueError("pos must be a 4-element array-like vector") | |
| self._pos = pos | |
| w = self._pos[2] | |
| h = self._pos[3] | |
| points = np.array( | |
| [[w / 2.0, 0.0], [0.0, h / 2.0], [-w / 2.0, 0.0], [0.0, -h / 2.0]] | |
| ).T | |
| points += np.array(self._pos[:2])[:, np.newaxis] | |
| points = self._ec._convert_units(points, units, "pix") | |
| points = points.T | |
| self._set_fill_points(points, [0, 1, 2, 0, 2, 3]) | |
| self._set_line_points(points) | |
| class Circle(_Triangular): | |
| """A circle or ellipse. | |
| Parameters | |
| ---------- | |
| ec : instance of ExperimentController | |
| Parent EC. | |
| radius : float | array-like | |
| Radius of the circle. Can be array-like with two elements to | |
| make an ellipse. | |
| pos : array-like | |
| 2-element array-like with X, Y center positions. | |
| units : str | |
| Units to use. These will apply to all spatial aspects of the drawing. | |
| shape e.g. size, position. See ``check_units`` for options. | |
| n_edges : int | |
| Number of edges to use (must be >= 4) to approximate a circle. | |
| fill_color : matplotlib Color | None | |
| Color to fill with. None is transparent. | |
| line_color : matplotlib Color | None | |
| Color of the border line. None is transparent. | |
| line_width : float | |
| Line width in pixels. | |
| Returns | |
| ------- | |
| circle : instance of Circle | |
| The circle object. | |
| """ | |
| def __init__( | |
| self, | |
| ec, | |
| radius=1, | |
| pos=(0, 0), | |
| units="norm", | |
| n_edges=200, | |
| fill_color="white", | |
| line_color=None, | |
| line_width=1.0, | |
| ): | |
| super().__init__( | |
| ec, | |
| fill_color=fill_color, | |
| line_color=line_color, | |
| line_width=line_width, | |
| line_loop=True, | |
| ) | |
| if not isinstance(n_edges, int): | |
| raise TypeError("n_edges must be an int") | |
| if n_edges < 4: | |
| raise ValueError("n_edges must be >= 4 for a reasonable circle") | |
| self._n_edges = n_edges | |
| # construct triangulation (never changes so long as n_edges is fixed) | |
| tris = [[0, ii + 1, ii + 2] for ii in range(n_edges)] | |
| tris = np.concatenate(tris) | |
| tris[-1] = 1 # fix wrap for last triangle | |
| self._orig_tris = tris | |
| # need to set a dummy value here so recalculation doesn't fail | |
| self._radius = np.array([1.0, 1.0]) | |
| self.set_pos(pos, units) | |
| self.set_radius(radius, units) | |
| def set_radius(self, radius, units="norm"): | |
| """Set the position and radius of the circle | |
| Parameters | |
| ---------- | |
| radius : array-like | float | |
| X- and Y-direction extents (radii) of the circle / ellipse. | |
| A single value (float) will be replicated for both directions. | |
| units : str | |
| Units to use. See ``check_units`` for options. | |
| """ | |
| check_units(units) | |
| radius = np.atleast_1d(radius).astype(float) | |
| if radius.ndim != 1 or radius.size > 2: | |
| raise ValueError("radius must be a 1- or 2-element array-like vector") | |
| if radius.size == 1: | |
| radius = np.r_[radius, radius] | |
| # convert to pixel (OpenGL) units | |
| self._radius = self._ec._convert_units(radius[:, np.newaxis], units, "pix")[ | |
| :, 0 | |
| ] | |
| # need to subtract center position | |
| ctr = self._ec._convert_units(np.zeros((2, 1)), units, "pix")[:, 0] | |
| self._radius -= ctr | |
| self._recalculate() | |
| def set_pos(self, pos, units="norm"): | |
| """Set the position and radius of the circle | |
| Parameters | |
| ---------- | |
| pos : array-like | |
| X, Y center of the circle. | |
| units : str | |
| Units to use. See ``check_units`` for options. | |
| """ | |
| check_units(units) | |
| pos = np.array(pos, dtype=float) | |
| if not (pos.ndim == 1 and pos.size == 2): | |
| raise ValueError("pos must be a 2-element array-like vector") | |
| # convert to pixel (OpenGL) units | |
| self._pos = self._ec._convert_units(pos[:, np.newaxis], units, "pix")[:, 0] | |
| self._recalculate() | |
| def _recalculate(self): | |
| """Helper to recalculate point coordinates""" | |
| edges = self._n_edges | |
| arg = 2 * np.pi * (np.arange(edges) / float(edges)) | |
| points = np.array( | |
| [self._radius[0] * np.cos(arg), self._radius[1] * np.sin(arg)] | |
| ) | |
| points = np.c_[np.zeros((2, 1)), points] # prepend the center | |
| points += np.array(self._pos[:2], dtype=float)[:, np.newaxis] | |
| points = points.T | |
| self._set_fill_points(points, self._orig_tris) | |
| self._set_line_points(points[1:]) # omit center point for lines | |
| class ConcentricCircles: | |
| """A set of filled concentric circles drawn without edges. | |
| Parameters | |
| ---------- | |
| ec : instance of ExperimentController | |
| Parent EC. | |
| radii : list of float | |
| Radii of the circles. Note that circles will be drawn in order, | |
| so using e.g., radii=[1., 2.] will cause the first circle to be | |
| covered by the second. | |
| pos : array-like | |
| 2-element array-like with the X, Y center position. | |
| units : str | |
| Units to use. These will apply to all spatial aspects of the drawing. | |
| See ``check_units`` for options. | |
| colors : list or tuple of matplotlib Colors | |
| Color to fill each circle with. | |
| Returns | |
| ------- | |
| circle : instance of Circle | |
| The circle object. | |
| """ | |
| def __init__( | |
| self, ec, radii=(0.2, 0.05), pos=(0, 0), units="norm", colors=("w", "k") | |
| ): | |
| radii = np.array(radii, float) | |
| if radii.ndim != 1: | |
| raise ValueError("radii must be 1D") | |
| if not isinstance(colors, (tuple, list)): | |
| raise TypeError("colors must be a tuple, list, or array") | |
| if len(colors) != len(radii): | |
| raise ValueError("colors and radii must be the same length") | |
| # need to set a dummy value here so recalculation doesn't fail | |
| self._circles = [ | |
| Circle(ec, r, pos, units, fill_color=c, line_width=0) | |
| for r, c in zip(radii, colors) | |
| ] | |
| def __len__(self): | |
| return len(self._circles) | |
| def set_pos(self, pos, units="norm"): | |
| """Set the position of the circles | |
| Parameters | |
| ---------- | |
| pos : array-like | |
| X, Y center of the circle. | |
| units : str | |
| Units to use. See ``check_units`` for options. | |
| """ | |
| for circle in self._circles: | |
| circle.set_pos(pos, units) | |
| def set_radius(self, radius, idx, units="norm"): | |
| """Set the radius of one of the circles | |
| Parameters | |
| ---------- | |
| radius : float | |
| Radius the circle. | |
| idx : int | |
| Index of the circle. | |
| units : str | |
| Units to use. See ``check_units`` for options. | |
| """ | |
| self._circles[idx].set_radius(radius, units) | |
| def set_radii(self, radii, units="norm"): | |
| """Set the color of each circle | |
| Parameters | |
| ---------- | |
| radii : array-like | |
| List of radii to assign to the circles. Must contain the same | |
| number of radii as the number of circles. | |
| units : str | |
| Units to use. See ``check_units`` for options. | |
| """ | |
| radii = np.array(radii, float) | |
| if radii.ndim != 1 or radii.size != len(self): | |
| raise ValueError(f"radii must contain exactly {len(self)} radii") | |
| for idx, radius in enumerate(radii): | |
| self.set_radius(radius, idx, units) | |
| def set_color(self, color, idx): | |
| """Set the color of one of the circles | |
| Parameters | |
| ---------- | |
| color : matplotlib Color | |
| Color of the circle. | |
| idx : int | |
| Index of the circle. | |
| """ | |
| self._circles[idx].set_fill_color(color) | |
| def set_colors(self, colors): | |
| """Set the color of each circle. | |
| Parameters | |
| ---------- | |
| colors : list or tuple of matplotlib Colors | |
| Must be of type list or tuple, and contain the same number of | |
| colors as the number of circles. | |
| """ | |
| if not isinstance(colors, (tuple, list)) or len(colors) != len(self): | |
| raise ValueError(f"colors must be a list or tuple with {len(self)} colors") | |
| for idx, color in enumerate(colors): | |
| self.set_color(color, idx) | |
| def draw(self): | |
| """Draw the fixation dot.""" | |
| for circle in self._circles: | |
| circle.draw() | |
| class FixationDot(ConcentricCircles): | |
| """A reasonable centered fixation dot. | |
| This uses concentric circles, the inner of which has a radius of one | |
| pixel, to create a fixation dot. If finer-grained control is desired, | |
| consider using ``ConcentricCircles``. | |
| Parameters | |
| ---------- | |
| ec : instance of ExperimentController | |
| Parent EC. | |
| colors : list of matplotlib Colors | |
| Color to fill the outer and inner circle with, respectively. | |
| Returns | |
| ------- | |
| fix : instance of FixationDot | |
| The fixation dot. | |
| """ | |
| def __init__(self, ec, colors=("w", "k")): | |
| if len(colors) != 2: | |
| raise ValueError("colors must have length 2") | |
| super().__init__(ec, radii=[0.2, 0.2], pos=[0, 0], units="deg", colors=colors) | |
| self.set_radius(1, 1, units="pix") | |
| class ProgressBar: | |
| """A progress bar that can be displayed between sections. | |
| This uses two rectangles, one outline, and one solid to show how much | |
| progress has been made in the experiment. | |
| Parameters | |
| ---------- | |
| ec : instance of ExperimentController | |
| Parent EC. | |
| pos : array-like | |
| 4-element array-like with X, Y center and width, height where x and y | |
| are coordinates of the box center. | |
| units : str | |
| Units to use. These will apply to all spatial aspects of the drawing. | |
| Must be either ``'norm'`` or ``'pix'``. | |
| colors : list or tuple of matplotlib Colors | |
| Colors to fill and outline the bar respectively. Defaults to green and | |
| white. | |
| """ | |
| def __init__(self, ec, pos, units="norm", colors=("g", "w")): | |
| self._ec = ec | |
| if len(colors) != 2: | |
| raise ValueError("colors must have length 2") | |
| if units not in ["norm", "pix"]: | |
| raise ValueError("units must be either 'norm' or 'pix'") | |
| pos = np.array(pos, dtype=float) | |
| self._pos = pos | |
| self._width = pos[2] | |
| self._units = units | |
| # initialize the bar with zero progress | |
| self._pos_bar = pos.copy() | |
| self._pos_bar[0] -= self._width * 0.5 | |
| self._init_x = self._pos_bar[0] | |
| self._pos_bar[2] = 0 | |
| self._rectangles = [ | |
| Rectangle(ec, self._pos_bar, units, colors[0], None), | |
| Rectangle(ec, self._pos, units, None, colors[1]), | |
| ] | |
| def update_bar(self, percent): | |
| """Update the progress of the bar. | |
| Parameters | |
| ---------- | |
| percent: float | |
| The percentage of the bar to be filled. Must be between 0 and 1. | |
| """ | |
| if percent > 100 or percent < 0: | |
| raise ValueError("percent must be a float between 0 and 100") | |
| self._pos_bar[2] = percent * self._width / 100.0 | |
| self._pos_bar[0] = self._init_x + self._pos_bar[2] * 0.5 | |
| self._rectangles[0].set_pos(self._pos_bar, self._units) | |
| def draw(self): | |
| """Draw the progress bar.""" | |
| for rectangle in self._rectangles: | |
| rectangle.draw() | |
| ############################################################################## | |
| # Image display | |
| class RawImage: | |
| """Create image from array for on-screen display. | |
| Parameters | |
| ---------- | |
| ec : instance of ExperimentController | |
| Parent EC. | |
| image_buffer : array | |
| Array, shape (N, M[, 3/4]). Color values should range between 0 and 1. | |
| pos : array-like | |
| 2-element array-like with X, Y (center) arguments. | |
| scale : float | |
| The scale factor. 1 is native size (pixel-to-pixel), 2 is twice as | |
| large, etc. | |
| units : str | |
| Units to use for the position. See ``check_units`` for options. | |
| Returns | |
| ------- | |
| img : instance of RawImage | |
| The image object. | |
| """ | |
| def __init__(self, ec, image_buffer, pos=(0, 0), scale=1.0, units="norm"): | |
| self._ec = ec | |
| self._img = None | |
| self.set_image(image_buffer) | |
| self.set_pos(pos, units) | |
| self.set_scale(scale) | |
| def set_image(self, image_buffer): | |
| """Set image buffer data | |
| Parameters | |
| ---------- | |
| image_buffer : array | |
| N x M x 3 (or 4) array. Can be type ``np.float64`` or ``np.uint8``. | |
| If ``np.float64``, color values must range between 0 and 1. | |
| ``np.uint8`` is slightly more efficient. | |
| """ | |
| from pyglet import image, sprite | |
| image_buffer = np.ascontiguousarray(image_buffer) | |
| if image_buffer.dtype not in (np.float64, np.uint8): | |
| raise TypeError("image_buffer must be np.float64 or np.uint8") | |
| if image_buffer.dtype == np.float64: | |
| if image_buffer.max() > 1 or image_buffer.min() < 0: | |
| raise ValueError("all float values must be between 0 and 1") | |
| image_buffer = (image_buffer * 255).astype("uint8") | |
| assert image_buffer.dtype == np.uint8 | |
| if image_buffer.ndim == 2: # grayscale | |
| image_buffer = np.tile(image_buffer[..., np.newaxis], (1, 1, 3)) | |
| dims = image_buffer.shape | |
| if len(dims) != 3 or dims[2] not in [3, 4]: | |
| raise ValueError(f"image_buffer incorrect shape: {image_buffer.shape}") | |
| fmt = "RGB" if dims[2] == 3 else "RGBA" | |
| self._sprite = sprite.Sprite( | |
| image.ImageData( | |
| dims[1], dims[0], fmt, image_buffer.tobytes(), -dims[1] * dims[2] | |
| ) | |
| ) | |
| def set_pos(self, pos, units="norm"): | |
| """Set image position. | |
| Parameters | |
| ---------- | |
| pos : array-like | |
| 2-element array-like with X, Y (center) arguments. | |
| units : str | |
| Units to use. See ``check_units`` for options. | |
| """ | |
| pos = np.array(pos, float) | |
| if pos.ndim != 1 or pos.size != 2: | |
| raise ValueError("pos must be a 2-element array") | |
| pos = np.reshape(pos, (2, 1)) | |
| self._pos = self._ec._convert_units(pos, units, "pix").ravel() | |
| @property | |
| def bounds(self): | |
| """Left, Right, Bottom, Top (in pixels) of the image.""" | |
| pos = np.array(self._pos, float) | |
| size = np.array([self._sprite.width, self._sprite.height], float) | |
| bounds = np.concatenate((pos - size / 2.0, pos + size / 2.0)) | |
| return bounds[[0, 2, 1, 3]] | |
| @property | |
| def scale(self): | |
| return self._scale | |
| def set_scale(self, scale): | |
| """Create image from array for on-screen display. | |
| Parameters | |
| ---------- | |
| scale : float | |
| The scale factor. 1 is native size (pixel-to-pixel), 2 is twice as | |
| large, etc. | |
| """ | |
| scale = float(scale) | |
| self._scale = scale | |
| self._sprite.scale = self._scale | |
| def draw(self): | |
| """Draw the image to the buffer""" | |
| self._sprite.scale = self._scale | |
| pos = self._pos - [self._sprite.width / 2.0, self._sprite.height / 2.0] | |
| if hasattr(self._sprite, "z"): # Pyglet 2+ | |
| self._sprite.position = (*pos, self._sprite.z) | |
| else: | |
| self._sprite.position = pos | |
| self._sprite.draw() | |
| def get_rect(self, units="norm"): | |
| """X, Y center, Width, Height of image. | |
| Parameters | |
| ---------- | |
| units : str | |
| Units to use for the position. See ``check_units`` for options. | |
| Returns | |
| ------- | |
| rect : ndarray | |
| The rect. | |
| """ | |
| # left,right,bottom,top | |
| lrbt = self._ec._convert_units(self.bounds.reshape(2, -1), fro="pix", to=units) | |
| center = self._ec._convert_units(self._pos.reshape(2, -1), fro="pix", to=units) | |
| width_height = np.diff(lrbt, axis=-1) | |
| return np.squeeze(np.concatenate([center, width_height])) | |
| tex_vert = """\ | |
| #version 150 core | |
| in vec2 a_position; | |
| in vec2 a_texcoord; | |
| uniform mat4 u_view; | |
| out vec2 v_texcoord; | |
| void main() | |
| { | |
| gl_Position = u_view * vec4(a_position, 0.0, 1.0); | |
| v_texcoord = a_texcoord; | |
| } | |
| """ | |
| tex_frag = """\ | |
| #version 150 core | |
| uniform sampler2D u_texture; | |
| in vec2 v_texcoord; | |
| out vec4 FragColor; | |
| void main() | |
| { | |
| FragColor = texture(u_texture, v_texcoord); | |
| FragColor.a = 1.0; | |
| } | |
| """ | |
| class Video: | |
| """Read video file and draw it to the screen. | |
| Parameters | |
| ---------- | |
| ec : instance of expyfun.ExperimentController | |
| file_name : str | |
| the video file path | |
| pos : array-like | |
| 2-element array-like with X, Y elements. | |
| units : str | |
| Units to use for the position. See ``check_units`` for options. | |
| scale : float | str | |
| The scale factor. 1 is native size (pixel-to-pixel), 2 is twice as | |
| large, etc. If scale is a string, it must be either ``'fill'`` | |
| (which ensures the entire ``ExperimentController`` window is | |
| covered by the video, at the expense of some parts of the video | |
| potentially being offscreen), or ``'fit'`` (which scales maximally | |
| while ensuring none of the video is offscreen, and may result in | |
| letterboxing or pillarboxing). | |
| center : bool | |
| If ``False``, the elements of ``pos`` specify the position of the lower | |
| left corner of the video frame; otherwise they position the center of | |
| the frame. | |
| visible : bool | |
| Whether to show the video when initialized. Can be toggled later using | |
| `Video.set_visible` method. | |
| Notes | |
| ----- | |
| This is a somewhat pared-down implementation of video playback. Looping is | |
| not available, and the audio stream from the video file is discarded. | |
| Timing of individual frames is relegated to the pyglet media player's | |
| internal clock. Recommended for use only in paradigms where the relative | |
| timing of audio and video are unimportant (e.g., if the video is merely | |
| entertainment for the participant during a passive auditory task). | |
| """ | |
| def __init__( | |
| self, | |
| ec, | |
| file_name, | |
| pos=(0, 0), | |
| units="norm", | |
| scale=1.0, | |
| center=True, | |
| visible=True, | |
| ): | |
| from pyglet import gl | |
| from pyglet.media import Player, load | |
| self._ec = ec | |
| # On Windows, the default is unaccelerated WMF, which is terribly slow. | |
| decoder = None | |
| try: | |
| from pyglet.media.codecs.ffmpeg import FFmpegDecoder | |
| decoder = FFmpegDecoder() | |
| except Exception as exc: | |
| warnings.warn( | |
| "FFmpeg decoder could not be instantiated, decoding " | |
| f"performance could be compromised:\n{exc}" | |
| ) | |
| self._source = load(file_name, decoder=decoder) | |
| self._player = Player() | |
| with warnings.catch_warnings(record=True): # deprecated eos_action | |
| self._player.queue(self._source) | |
| self._player._audio_player = None | |
| frame_rate = self.frame_rate | |
| if frame_rate is None: | |
| logger.warning("Frame rate could not be determined") | |
| frame_rate = 60.0 | |
| self._dt = 1.0 / frame_rate | |
| self._playing = False | |
| self._finished = False | |
| self._pos = pos | |
| self._units = units | |
| self._center = center | |
| self.set_scale(scale) # also calls set_pos | |
| self._visible = visible | |
| self._program = _create_program(ec, tex_vert, tex_frag) | |
| gl.glUseProgram(self._program) | |
| self._buffers = dict() | |
| for key in ("position", "texcoord"): | |
| self._buffers[key] = gl.GLuint(0) | |
| gl.glGenBuffers(1, pointer(self._buffers[key])) | |
| tex = np.array([(0, 1), (1, 1), (1, 0), (0, 0)], np.float32) | |
| with _array_buffer(self._buffers["texcoord"]): | |
| gl.glBufferData( | |
| gl.GL_ARRAY_BUFFER, tex.nbytes, tex.tobytes(), gl.GL_DYNAMIC_DRAW | |
| ) | |
| gl.glUseProgram(0) | |
| def play(self, auto_draw=True, audio=False): | |
| """Play video from current position. | |
| Parameters | |
| ---------- | |
| auto_draw : bool | |
| If True, add ``self.draw`` to ``ec.on_every_flip``. | |
| audio : bool | |
| Whether to play the audio stream. Only works if the | |
| :class:`~expyfun.ExperimentController` was instantiated with | |
| ``audio_controller=dict(TYPE="sound_card", SOUND_CARD_BACKEND="pyglet")``, | |
| and will raise an error if that is not the case. | |
| Returns | |
| ------- | |
| time : float | |
| The timestamp (on the parent ``ExperimentController`` timeline) at | |
| which ``play()`` was called. | |
| """ | |
| if audio: | |
| if self._ec.audio_type != "pyglet": | |
| raise ValueError( | |
| "Cannot play a video's audio stream unless the audio type is " | |
| "'sound_card' and the backend is 'pyglet'." | |
| ) | |
| self._player.volume = 1.0 | |
| else: | |
| self._player.volume = 0.0 | |
| if not self._playing: | |
| if auto_draw: | |
| self._ec.call_on_every_flip(self.draw) | |
| self._player.play() | |
| self._playing = True | |
| else: | |
| warnings.warn( | |
| "ExperimentController.video.play() called when already playing." | |
| ) | |
| return self._ec.get_time() | |
| def pause(self): | |
| """Halt video playback. | |
| Returns | |
| ------- | |
| time : float | |
| The timestamp (on the parent ``ExperimentController`` timeline) at | |
| which ``pause()`` was called. | |
| """ | |
| if self._playing: | |
| try: | |
| idx = self._ec.on_every_flip_functions.index(self.draw) | |
| except ValueError: # not auto_draw | |
| pass | |
| else: | |
| self._ec.on_every_flip_functions.pop(idx) | |
| self._player.pause() | |
| self._playing = False | |
| else: | |
| warnings.warn( | |
| "ExperimentController.video.pause() called when already paused." | |
| ) | |
| return self._ec.get_time() | |
| def _delete(self): | |
| """Halt video playback and remove player.""" | |
| if self._playing: | |
| self.pause() | |
| self._player.delete() | |
| def set_scale(self, scale=1.0): | |
| """Set video scale. | |
| Parameters | |
| ---------- | |
| scale : float | str | |
| The scale factor. 1 is native size (pixel-to-pixel), 2 is twice as | |
| large, etc. If scale is a string, it must be either ``'fill'`` | |
| (which ensures the entire ``ExperimentController`` window is | |
| covered by the video, at the expense of some parts of the video | |
| potentially being offscreen), or ``'fit'`` (which scales maximally | |
| while ensuring none of the video is offscreen, which may result in | |
| letterboxing). | |
| """ | |
| if isinstance(scale, str): | |
| _scale = self._ec.window_size_pix / np.array( | |
| (self.source_width, self.source_height), dtype=float | |
| ) | |
| if scale == "fit": | |
| scale = _scale.min() | |
| elif scale == "fill": | |
| scale = _scale.max() | |
| self._scale = float(scale) # allows [1, 1., '1']; others: ValueError | |
| if self._scale <= 0: | |
| raise ValueError("Video scale factor must be strictly positive.") | |
| self.set_pos(self._pos, self._units, self._center) | |
| def set_pos(self, pos, units="norm", center=True): | |
| """Set video position. | |
| Parameters | |
| ---------- | |
| pos : array-like | |
| 2-element array-like with X, Y elements. | |
| units : str | |
| Units to use for the position. See ``check_units`` for options. | |
| center : bool | |
| If ``False``, the elements of ``pos`` specify the position of the | |
| lower left corner of the video frame; otherwise they position the | |
| center of the frame. | |
| """ | |
| pos = np.array(pos, float) | |
| if pos.size != 2: | |
| raise ValueError("pos must be a 2-element array") | |
| pos = np.reshape(pos, (2, 1)) | |
| pix = self._ec._convert_units(pos, units, "pix").ravel() | |
| offset = np.array((self.width, self.height)) // 2 if center else 0 | |
| self._pos = pos | |
| self._actual_pos = pix - offset | |
| self._pos_unit = units | |
| self._pos_centered = center | |
| def _draw(self): | |
| from pyglet import gl | |
| tex = self._player.texture | |
| gl.glUseProgram(self._program) | |
| gl.glActiveTexture(gl.GL_TEXTURE0) | |
| gl.glBindTexture(tex.target, tex.id) | |
| gl.glBindVertexArray(0) | |
| x, y = self._actual_pos | |
| w = self.source_width * self._scale | |
| h = self.source_height * self._scale | |
| pos = np.array([(x, y), (x + w, y), (x + w, y + h), (x, y + h)], np.float32) | |
| with _array_buffer(self._buffers["position"]): | |
| gl.glBufferData( | |
| gl.GL_ARRAY_BUFFER, pos.nbytes, pos.tobytes(), gl.GL_DYNAMIC_DRAW | |
| ) | |
| loc_pos = gl.glGetAttribLocation(self._program, b"a_position") | |
| gl.glEnableVertexAttribArray(loc_pos) | |
| gl.glVertexAttribPointer(loc_pos, 2, gl.GL_FLOAT, gl.GL_FALSE, 0, 0) | |
| gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._buffers["texcoord"]) | |
| loc_tex = gl.glGetAttribLocation(self._program, b"a_texcoord") | |
| gl.glEnableVertexAttribArray(loc_tex) | |
| gl.glVertexAttribPointer(loc_tex, 2, gl.GL_FLOAT, gl.GL_FALSE, 0, 0) | |
| gl.glDrawArrays(gl.GL_QUADS, 0, 4) | |
| gl.glDisableVertexAttribArray(loc_pos) | |
| gl.glDisableVertexAttribArray(loc_tex) | |
| gl.glBindTexture(tex.target, 0) | |
| gl.glUseProgram(0) | |
| def draw(self): | |
| """Draw the video texture to the screen buffer.""" | |
| done = False | |
| if self._player.source is None: | |
| done = True | |
| if not done: | |
| self._player.update_texture() | |
| # detect end-of-stream to prevent pyglet from hanging: | |
| if self._eos: | |
| done = True | |
| elif self._visible: | |
| self._draw() | |
| if done: | |
| self._finished = True | |
| self.pause() | |
| self._ec.check_force_quit() | |
| def set_visible(self, show, flip=False): | |
| """Show/hide the video frame. | |
| Parameters | |
| ---------- | |
| show : bool | |
| Show or hide. | |
| flip : bool | |
| If True, flip after showing or hiding. | |
| """ | |
| if show: | |
| self._visible = True | |
| self._draw() | |
| else: | |
| self._visible = False | |
| self._ec.flip() | |
| if flip: | |
| self._ec.flip() | |
| # PROPERTIES | |
| @property | |
| def _eos(self): | |
| done = self._player.source is None | |
| ts = self._source.get_next_video_timestamp() | |
| dur = self._source._duration | |
| return done or ts is None or ts >= dur | |
| @property | |
| def playing(self): | |
| return self._playing | |
| @property | |
| def finished(self): | |
| return self._finished | |
| @property | |
| def position(self): | |
| return np.squeeze(self._pos) | |
| @property | |
| def scale(self): | |
| return self._scale | |
| @property | |
| def duration(self): | |
| return self._source.duration | |
| @property | |
| def frame_rate(self): | |
| return self._source.video_format.frame_rate | |
| @property | |
| def dt(self): | |
| return self._dt | |
| @property | |
| def time(self): | |
| return self._player.time | |
| @property | |
| def width(self): | |
| return self.source_width * self._scale | |
| @property | |
| def height(self): | |
| return self.source_height * self._scale | |
| @property | |
| def source_width(self): | |
| return self._source.video_format.width | |
| @property | |
| def source_height(self): | |
| return self._source.video_format.height | |
| @property | |
| def time_offset(self): | |
| return self._ec.get_time() - self._player.time |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment