Source code for psychopy.visual.windowwarp

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
Copyright (C) 2014 Allen Institute for Brain Science

This program is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License Version 3
as published by the Free Software Foundation on 29 June 2007.
This program is distributed WITHOUT WARRANTY OF MERCHANTABILITY OR FITNESS
FOR A PARTICULAR PURPOSE OR ANY OTHER WARRANTY, EXPRESSED OR IMPLIED.
See the GNU General Public License Version 3 for more details.
You should have received a copy of the GNU General Public License along
with this program. If not, see http://www.gnu.org/licenses/
"""

import ctypes
import numpy as np
from psychopy import logging
import pyglet
GL = pyglet.gl


[docs]class Warper: """Class to perform warps. Supports spherical, cylindrical, warpfile, or None (disabled) warps """ def __init__(self, win, warp=None, warpfile=None, warpGridsize=300, eyepoint=(0.5, 0.5), flipHorizontal=False, flipVertical=False): """Warping is a final operation which can be optionally performed on each frame just before transmission to the display. It is useful for perspective correction when the eye to monitor distance is small (say, under 50 cm), or when projecting to domes or other non-planar surfaces. These attributes define the projection and can be altered dynamically using the changeProjection() method. :Parameters: win : Handle to the window. warp : 'spherical', 'cylindrical, 'warpfile' or *None* This table gives the main properties of each projection +-----------+---------------+-----------+------------+--------------------+ | Warp | eyepoint | verticals | horizontals| perspective correct| | | modifies warp | parallel | parallel | | +===========+===============+===========+============+====================+ |spherical | y | n | n | y | +-----------+---------------+-----------+------------+--------------------+ |cylindrical| y | y | n | n | +-----------+---------------+-----------+------------+--------------------+ | warpfile | n | ? | ? | ? | +-----------+---------------+-----------+------------+--------------------+ | None | n | y | y | n | +-----------+---------------+-----------+------------+--------------------+ warpfile : *None* or filename containing Blender and Paul Bourke compatible warp definition. (see http://paulbourke.net/dome/warpingfisheye/) warpGridsize : 300 Defines the resolution of the warp in both X and Y when not using a warpfile. Typical values would be 64-300 trading off tolerance for jaggies for speed. eyepoint : [0.5, 0.5] center of the screen Position of the eye in X and Y as a fraction of the normalized screen width and height. [0,0] is the bottom left of the screen. [1,1] is the top right of the screen. flipHorizontal: True or *False* Flip the entire output horizontally. Useful for back projection scenarious. flipVertical: True or *False* Flip the entire output vertically. useful if projector is flipped upside down. :notes: 1) The eye distance from the screen is initialized from the monitor definition. 2) The eye distance can be altered dynamically by changing 'warper.dist_cm' and then calling changeProjection(). Example usage to create a spherical projection:: from psychopy.visual.windowwarp import Warper win = Window(monitor='testMonitor', screen=1, fullscr=True, useFBO = True) warper = Warper(win, warp='spherical', warpfile = "", warpGridsize = 128, eyepoint = [0.5, 0.5], flipHorizontal = False, flipVertical = False) """ super(Warper, self).__init__() self.win = win # monkey patch the warp method win._renderFBO = self.drawWarp self.warp = warp self.warpfile = warpfile self.warpGridsize = warpGridsize self.eyepoint = eyepoint self.flipHorizontal = flipHorizontal self.flipVertical = flipVertical self.initDefaultWarpSize() # get the eye distance from the monitor object, # but the pixel dimensions from the actual window object w, h = win.size self.aspect = w/h self.dist_cm = win.monitor.getDistance() if self.dist_cm is None: # create a fake monitor if one isn't defined self.dist_cm = 30.0 self.mon_width_cm = 50.0 logging.warning('Monitor is not calibrated') else: self.mon_width_cm = win.monitor.getWidth() self.mon_height_cm = self.mon_width_cm / self.aspect self.mon_width_pix = w self.mon_height_pix = h self.changeProjection(self.warp, self.warpfile, self.eyepoint) def drawWarp(self): """Warp the output, using the vertex, texture, and optionally an opacity array. """ GL.glUseProgram(0) GL.glColorMask(True, True, True, True) # point to color (opacity) if self.gl_color is not None: GL.glEnableClientState(GL.GL_COLOR_ARRAY) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.gl_color) GL.glColorPointer(4, GL.GL_FLOAT, 0, None) GL.glEnable(GL.GL_BLEND) GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ZERO) # point to vertex data GL.glEnableClientState(GL.GL_VERTEX_ARRAY) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.gl_vb) GL.glVertexPointer(2, GL.GL_FLOAT, 0, None) # point to texture GL.glEnableClientState(GL.GL_TEXTURE_COORD_ARRAY) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.gl_tb) GL.glTexCoordPointer(2, GL.GL_FLOAT, 0, None) # draw quads GL.glDrawArrays(GL.GL_QUADS, 0, self.nverts) # cleanup GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) GL.glDisableClientState(GL.GL_VERTEX_ARRAY) GL.glDisableClientState(GL.GL_TEXTURE_COORD_ARRAY) if self.gl_color is not None: GL.glBlendFunc(GL.GL_SRC_ALPHA, GL.GL_ONE_MINUS_SRC_ALPHA) GL.glDisableClientState(GL.GL_COLOR_ARRAY) def initDefaultWarpSize(self): self.xgrid = self.warpGridsize self.ygrid = self.warpGridsize
[docs] def changeProjection(self, warp, warpfile=None, eyepoint=(0.5, 0.5), flipHorizontal=False, flipVertical=False): """Allows changing the warp method on the fly. Uses the same parameter definitions as constructor. """ self.warp = warp self.warpfile = warpfile self.eyepoint = list(eyepoint) self.flipHorizontal = flipHorizontal self.flipVertical = flipVertical # warpfile might have changed the size... self.initDefaultWarpSize() if self.warp is None: self.projectionNone() elif self.warp == 'spherical': self.projectionSphericalOrCylindrical(False) elif self.warp == 'cylindrical': self.projectionSphericalOrCylindrical(True) elif self.warp == 'warpfile': self.projectionWarpfile() else: raise ValueError('Unknown warp specification: %s' % self.warp)
def projectionNone(self): """No warp, same projection as original PsychoPy """ # Vertex data v0 = (-1.0, -1.0) v1 = (-1.0, 1.0) v2 = (1.0, 1.0) v3 = (1.0, -1.0) # Texture coordinates t0 = (0.0, 0.0) t1 = (0.0, 1.0) t2 = (1.0, 1.0) t3 = (1.0, 0.0) vertices = np.array([v0, v1, v2, v3], 'float32') tcoords = np.array([t0, t1, t2, t3], 'float32') # draw four quads during rendering loop self.nverts = 4 self.createVertexAndTextureBuffers(vertices, tcoords) def projectionSphericalOrCylindrical(self, isCylindrical=False): """Correct perspective on flat screen using either a spherical or cylindrical projection. """ self.nverts = (self.xgrid - 1) * (self.ygrid - 1) * 4 # eye position in cm xEye = self.eyepoint[0] * self.mon_width_cm yEye = self.eyepoint[1] * self.mon_height_cm # create vertex grid array, and texture coords # times 4 for quads vertices = np.zeros( ((self.xgrid - 1) * (self.ygrid - 1) * 4, 2), dtype='float32') tcoords = np.zeros( ((self.xgrid - 1) * (self.ygrid - 1) * 4, 2), dtype='float32') equalDistanceX = np.linspace(0, self.mon_width_cm, self.xgrid) equalDistanceY = np.linspace(0, self.mon_height_cm, self.ygrid) # vertex coordinates x_c = np.linspace(-1.0, 1.0, self.xgrid) y_c = np.linspace(-1.0, 1.0, self.ygrid) x_coords, y_coords = np.meshgrid(x_c, y_c) x = np.zeros(((self.xgrid), (self.ygrid)), dtype='float32') y = np.zeros(((self.xgrid), (self.ygrid)), dtype='float32') x[:, :] = equalDistanceX - xEye y[:, :] = equalDistanceY - yEye y = np.transpose(y) r = np.sqrt(np.square(x) + np.square(y) + np.square(self.dist_cm)) azimuth = np.arctan(x / self.dist_cm) altitude = np.arcsin(y / r) # calculate the texture coordinates if isCylindrical: tx = self.dist_cm * np.sin(azimuth) ty = self.dist_cm * np.sin(altitude) else: tx = self.dist_cm * (1 + x/r) - self.dist_cm ty = self.dist_cm * (1 + y/r) - self.dist_cm # prevent div0 azimuth[azimuth == 0] = np.finfo(np.float32).eps altitude[altitude == 0] = np.finfo(np.float32).eps # the texture coordinates (which are now lying on the sphere) # need to be remapped back onto the plane of the display. # This effectively stretches the coordinates away from the eyepoint. if isCylindrical: tx = tx * azimuth / np.sin(azimuth) ty = ty * altitude / np.sin(altitude) else: centralAngle = np.arccos( np.cos(altitude) * np.cos(np.abs(azimuth))) # distance from eyepoint to texture vertex arcLength = centralAngle * self.dist_cm # remap the texture coordinate theta = np.arctan2(ty, tx) tx = arcLength * np.cos(theta) ty = arcLength * np.sin(theta) u_coords = tx / self.mon_width_cm + 0.5 v_coords = ty / self.mon_height_cm + 0.5 # loop to create quads vdex = 0 for y in range(0, self.ygrid - 1): for x in range(0, self.xgrid - 1): index = y * (self.xgrid) + x vertices[vdex + 0, 0] = x_coords[y, x] vertices[vdex + 0, 1] = y_coords[y, x] vertices[vdex + 1, 0] = x_coords[y, x + 1] vertices[vdex + 1, 1] = y_coords[y, x + 1] vertices[vdex + 2, 0] = x_coords[y + 1, x + 1] vertices[vdex + 2, 1] = y_coords[y + 1, x + 1] vertices[vdex + 3, 0] = x_coords[y + 1, x] vertices[vdex + 3, 1] = y_coords[y + 1, x] tcoords[vdex + 0, 0] = u_coords[y, x] tcoords[vdex + 0, 1] = v_coords[y, x] tcoords[vdex + 1, 0] = u_coords[y, x + 1] tcoords[vdex + 1, 1] = v_coords[y, x + 1] tcoords[vdex + 2, 0] = u_coords[y + 1, x + 1] tcoords[vdex + 2, 1] = v_coords[y + 1, x + 1] tcoords[vdex + 3, 0] = u_coords[y + 1, x] tcoords[vdex + 3, 1] = v_coords[y + 1, x] vdex += 4 self.createVertexAndTextureBuffers(vertices, tcoords) def projectionWarpfile(self): """Use a warp definition file to create the projection. See: http://paulbourke.net/dome/warpingfisheye/ """ try: fh = open(self.warpfile) lines = fh.readlines() fh.close() filetype = int(lines[0]) rc = list(map(int, lines[1].split())) cols, rows = rc[0], rc[1] warpdata = np.loadtxt(self.warpfile, skiprows=2) except Exception: error = 'Unable to read warpfile: ' + self.warpfile logging.warning(error) print(error) return if (cols * rows != warpdata.shape[0] or warpdata.shape[1] != 5 or filetype != 2): error = 'warpfile data incorrect: ' + self.warpfile logging.warning(error) print(error) return self.xgrid = cols self.ygrid = rows self.nverts = (self.xgrid - 1) * (self.ygrid - 1) * 4 # create vertex grid array, and texture coords times 4 for quads vertices = np.zeros( ((self.xgrid - 1) * (self.ygrid - 1) * 4, 2), dtype='float32') tcoords = np.zeros( ((self.xgrid - 1) * (self.ygrid - 1) * 4, 2), dtype='float32') # opacity is RGBA opacity = np.ones( ((self.xgrid - 1) * (self.ygrid - 1) * 4, 4), dtype='float32') # loop to create quads vdex = 0 for y in range(0, self.ygrid - 1): for x in range(0, self.xgrid - 1): index = y * (self.xgrid) + x vertices[vdex + 0, 0] = warpdata[index, 0] # x_coords[y,x] vertices[vdex + 0, 1] = warpdata[index, 1] # y_coords[y,x] # x_coords[y,x+1] vertices[vdex + 1, 0] = warpdata[index + 1, 0] # y_coords[y,x+1] vertices[vdex + 1, 1] = warpdata[index + 1, 1] # x_coords[y+1,x+1] vertices[vdex + 2, 0] = warpdata[index + cols + 1, 0] # y_coords[y+1,x+1] vertices[vdex + 2, 1] = warpdata[index + cols + 1, 1] # x_coords[y+1,x] vertices[vdex + 3, 0] = warpdata[index + cols, 0] # y_coords[y+1,x] vertices[vdex + 3, 1] = warpdata[index + cols, 1] # u_coords[y,x] tcoords[vdex + 0, 0] = warpdata[index, 2] # v_coords[y,x] tcoords[vdex + 0, 1] = warpdata[index, 3] # u_coords[y,x+1]: tcoords[vdex + 1, 0] = warpdata[index + 1, 2] # v_coords[y,x+1]: tcoords[vdex + 1, 1] = warpdata[index + 1, 3] # u_coords[y+1,x+1]: tcoords[vdex + 2, 0] = warpdata[index + cols + 1, 2] # v_coords[y+1,x+1]: tcoords[vdex + 2, 1] = warpdata[index + cols + 1, 3] # u_coords[y+1,x] tcoords[vdex + 3, 0] = warpdata[index + cols, 2] # v_coords[y+1,x]: tcoords[vdex + 3, 1] = warpdata[index + cols, 3] opacity[vdex, 3] = warpdata[index, 4] opacity[vdex + 1, 3] = warpdata[index + 1, 4] opacity[vdex + 2, 3] = warpdata[index + cols + 1, 4] opacity[vdex + 3, 3] = warpdata[index + cols, 4] vdex += 4 self.createVertexAndTextureBuffers(vertices, tcoords, opacity) def createVertexAndTextureBuffers(self, vertices, tcoords, opacity=None): """Allocate hardware buffers for vertices, texture coordinates, and optionally opacity. """ if self.flipHorizontal: vertices[:, 0] = -vertices[:, 0] if self.flipVertical: vertices[:, 1] = -vertices[:, 1] GL.glEnableClientState(GL.GL_VERTEX_ARRAY) # type and size for arrays arrType = ctypes.c_float ptrType = ctypes.POINTER(arrType) nbytes = ctypes.sizeof(arrType) # vertex buffer in hardware self.gl_vb = GL.GLuint() GL.glGenBuffers(1, self.gl_vb) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.gl_vb) GL.glBufferData( GL.GL_ARRAY_BUFFER, vertices.size * nbytes, vertices.ctypes.data_as(ptrType), GL.GL_STATIC_DRAW) # vertex buffer texture data in hardware self.gl_tb = GL.GLuint() GL.glGenBuffers(1, self.gl_tb) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.gl_tb) GL.glBufferData( GL.GL_ARRAY_BUFFER, tcoords.size * nbytes, tcoords.ctypes.data_as(ptrType), GL.GL_STATIC_DRAW) # opacity buffer in hardware (only for warp files) if opacity is not None: self.gl_color = GL.GLuint() GL.glGenBuffers(1, self.gl_color) GL.glBindBuffer(GL.GL_ARRAY_BUFFER, self.gl_color) # convert opacity to RGBA, one point for each corner of the quad GL.glBufferData( GL.GL_ARRAY_BUFFER, opacity.size * nbytes, opacity.ctypes.data_as(ptrType), GL.GL_STATIC_DRAW) else: self.gl_color = None GL.glBindBuffer(GL.GL_ARRAY_BUFFER, 0) GL.glDisableClientState(GL.GL_VERTEX_ARRAY)

Back to top