Source code for psychopy.visual.helpers

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

"""Helper functions shared by the visual classes
"""

# Part of the PsychoPy library
# Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2024 Open Science Tools Ltd.
# Distributed under the terms of the GNU General Public License (GPL).

import os
import copy
from packaging.version import Version
from pathlib import Path
from psychopy import logging, colors, prefs

# tools must only be imported *after* event or MovieStim breaks on win32
# (JWP has no idea why!)
from psychopy.alerts import alert
from psychopy.tools import filetools as ft
from psychopy.tools.arraytools import val2array
from psychopy.tools.attributetools import setAttribute
from psychopy.tools.filetools import pathToString

import numpy as np


reportNImageResizes = 5  # stop raising warning after this
# global _nImageResizes
_nImageResizes = 0

try:
    import matplotlib
    if Version(matplotlib.__version__) > Version('1.2'):
        from matplotlib.path import Path as mplPath
    else:
        from matplotlib import nxutils
    haveMatplotlib = True
except Exception:
    haveMatplotlib = False


[docs]def pointInPolygon(x, y, poly): """Determine if a point is inside a polygon; returns True if inside. (`x`, `y`) is the point to test. `poly` is a list of 3 or more vertices as (x,y) pairs. If given an object, such as a `ShapeStim`, will try to use its vertices and position as the polygon. Same as the `.contains()` method elsewhere. """ try: # do this using try:...except rather than hasattr() for speed poly = poly.verticesPix # we want to access this only once except Exception: pass nVert = len(poly) if nVert < 3: msg = 'pointInPolygon expects a polygon with 3 or more vertices' logging.warning(msg) return False # faster if have matplotlib tools: if haveMatplotlib: if Version(matplotlib.__version__) > Version('1.2'): return mplPath(poly).contains_point([x, y]) else: try: return bool(nxutils.pnpoly(x, y, poly)) except Exception: pass # fall through to pure python: # adapted from http://local.wasp.uwa.edu.au/~pbourke/geometry/insidepoly/ # via http://www.ariel.com.au/a/python-point-int-poly.html inside = False # trace (horizontal?) rays, flip inside status if cross an edge: p1x, p1y = poly[-1] for p2x, p2y in poly: if y > min(p1y, p2y) and y <= max(p1y, p2y) and x <= max(p1x, p2x): if p1y != p2y: xints = (y - p1y) * (p2x - p1x) / (p2y - p1y) + p1x if p1x == p2x or x <= xints: inside = not inside p1x, p1y = p2x, p2y return inside
[docs]def polygonsOverlap(poly1, poly2): """Determine if two polygons intersect; can fail for very pointy polygons. Accepts two polygons, as lists of vertices (x,y) pairs. If given an object with with (vertices + pos), will try to use that as the polygon. Checks if any vertex of one polygon is inside the other polygon. Same as the `.overlaps()` method elsewhere. :Notes: We implement special handling for the `Line` stimulus as it is not a proper polygon. We do not check for class instances because this would require importing of `visual.Line`, creating a circular import. Instead, we assume that a "polygon" with only two vertices is meant to specify a line. Pixels between the endpoints get interpolated before testing for overlap. """ try: # do this using try:...except rather than hasattr() for speed if poly1.verticesPix.shape == (2, 2): # Line # Interpolate pixels. x = np.arange(poly1.verticesPix[0, 0], poly1.verticesPix[1, 0] + 1) y = np.arange(poly1.verticesPix[0, 1], poly1.verticesPix[1, 1] + 1) poly1_vert_pix = np.column_stack((x,y)) else: poly1_vert_pix = poly1.verticesPix except AttributeError: poly1_vert_pix = poly1 try: # do this using try:...except rather than hasattr() for speed if poly2.verticesPix.shape == (2, 2): # Line # Interpolate pixels. x = np.arange(poly2.verticesPix[0, 0], poly2.verticesPix[1, 0] + 1) y = np.arange(poly2.verticesPix[0, 1], poly2.verticesPix[1, 1] + 1) poly2_vert_pix = np.column_stack((x,y)) else: poly2_vert_pix = poly2.verticesPix except AttributeError: poly2_vert_pix = poly2 # faster if have matplotlib tools: if haveMatplotlib: if Version(matplotlib.__version__) > Version('1.2'): if any(mplPath(poly1_vert_pix).contains_points(poly2_vert_pix)): return True return any(mplPath(poly2_vert_pix).contains_points(poly1_vert_pix)) else: try: # deprecated in matplotlib 1.2 if any(nxutils.points_inside_poly(poly1_vert_pix, poly2_vert_pix)): return True return any(nxutils.points_inside_poly(poly2_vert_pix, poly1_vert_pix)) except Exception: pass # fall through to pure python: for p1 in poly1_vert_pix: if pointInPolygon(p1[0], p1[1], poly2_vert_pix): return True for p2 in poly2_vert_pix: if pointInPolygon(p2[0], p2[1], poly1_vert_pix): return True return False
def setTexIfNoShaders(obj): """Useful decorator for classes that need to update Texture after other properties. This doesn't actually perform the update, but sets a flag so the update occurs at draw time (in case multiple changes all need updates only do it once). """ if hasattr(obj, 'useShaders') and not obj.useShaders: # we aren't using shaders if hasattr(obj, '_needTextureUpdate'): obj._needTextureUpdate = True def setColor(obj, color, colorSpace=None, operation='', colorAttrib='color', # or 'fillColor' etc # legacy colorSpaceAttrib=None, rgbAttrib=None, log=True): """ Sets the given color attribute of an object. Obsolete as of 2021.1.0, as colors are now handled by Color objects, all of the necessary operations are called when setting directly via obj.color, obj.fillColor or obj.borderColor. obj : psychopy.visual object The object whose color you are changing color : color The color to use - can be a valid color value (e.g. (1,1,1), '#ffffff', 'white') or a psychopy.colors.Color object colorSpace : str The color space of the color value. Can be None for hex or named colors, otherwise must be specified. operation : str Can be '=', '+' or '-', or left blank for '='. '=' will set the color, '+' will add the color and '-' will subtract it. colorAttrib : str Name of the color attribute you are setting, e.g. 'color', 'fillColor', 'borderColor' log : bool Whether to write an update to the log about this change Legacy --- colorSpaceAttrib : str PsychoPy used to have a color space for each attribute, but color spaces are now handled by Color objects, so this input is no longer used. rgbAttrib : str PsychoPy used to handle color by converting to RGB and storing in an rgb attribute, now this conversion is done within Color objects so this input is no longer used. """ if colorSpaceAttrib is not None: alert(8105, strFields={'colorSpaceAttrib': colorSpaceAttrib}) if rgbAttrib is not None: alert(8110, strFields={'rgbAttrib': rgbAttrib}) # Make a Color object using supplied values raw = color color = colors.Color(raw, colorSpace) assert color.valid, f"Could not create valid Color object from value {raw} in space {colorSpace}" # Apply new value if operation in ('=', '', None): # If no operation, just set color from object setAttribute(obj, colorAttrib, color, log=log) elif operation == '+': # If +, add to old color setAttribute(obj, colorAttrib, getattr(obj, "_" + colorAttrib) + color, log=log) elif operation == '-': # If -, subtract from old color setAttribute(obj, colorAttrib, getattr(obj, "_" + colorAttrib) - color, log=log) else: # Any other operation is not supported msg = ('Unsupported value "%s" for operation when ' 'setting %s in %s') vals = (operation, colorAttrib, obj.__class__.__name__) raise ValueError(msg % vals) # set for groupFlipVert: immutables = {int, float, str, tuple, int, bool, np.float32, np.float64} def findImageFile(filename, checkResources=False): """Tests whether the filename is an image file. If not will try some common alternatives (e.g. extensions .jpg .tif...) as well as looking in the `psychopy/app/Resources` folder """ # if user supplied correct path then return quickly filename = pathToString(filename) isfile = os.path.isfile if isfile(filename): return filename # alias default names (so it always points to default.png) if filename in ft.defaultStim: filename = ft.defaultStim[filename] # store original orig = copy.copy(filename) # search for file using additional extensions extensions = ('.jpg', '.png', '.tif', '.bmp', '.gif', '.jpeg', '.tiff') # not supported: 'svg', 'eps' def logCorrected(orig, actual): logging.warn("Requested image {!r} not found but similar filename " "{!r} exists. This will be used instead but changing the " "filename is advised.".format(orig, actual)) # it already has one but maybe it's wrong? Remove it if filename.endswith(extensions): filename = os.path.splitext(orig)[0] if isfile(filename): # had an extension but didn't need one (mac?) logCorrected(orig, filename) return filename # try adding the standard set of extensions for ext in extensions: if isfile(filename+ext): filename += ext logCorrected(orig, filename) return filename # try doing the same in the Resources folder if checkResources: return findImageFile(Path(prefs.paths['assets']) / orig, checkResources=False)
[docs]def groupFlipVert(flipList, yReflect=0): """Reverses the vertical mirroring of all items in list ``flipList``. Reverses the .flipVert status, vertical (y) positions, and angular rotation (.ori). Flipping preserves the relations among the group's visual elements. The parameter ``yReflect`` is the y-value of an imaginary horizontal line around which to reflect the items; default = 0 (screen center). Typical usage is to call once prior to any display; call again to un-flip. Can be called with a list of all stim to be presented in a given routine. Will flip a) all psychopy.visual.xyzStim that have a setFlipVert method, b) the y values of .vertices, and c) items in n x 2 lists that are mutable (i.e., list, np.array, no tuples): [[x1, y1], [x2, y2], ...] """ if type(flipList) != list: flipList = [flipList] for item in flipList: if type(item) in (list, np.ndarray): if type(item[0]) in (list, np.ndarray) and len(item[0]) == 2: for i in range(len(item)): item[i][1] = 2 * yReflect - item[i][1] else: msg = 'Cannot vert-flip elements in "{}", type={}' raise ValueError(msg.format(item, type(item[0]))) elif type(item) in immutables: raise ValueError('Cannot change immutable item "{}"'.format(item)) if hasattr(item, 'setPos'): item.setPos([1, -1], '*') item.setPos([0, 2 * yReflect], '+') elif hasattr(item, 'pos'): # only if no setPos available item.pos[1] *= -1 item.pos[1] += 2 * yReflect if hasattr(item, 'setFlipVert'): # eg TextStim, custom marker item.setFlipVert(not item.flipVert) elif hasattr(item, 'vertices'): # and lacks a setFlipVert method try: v = item.vertices * [1, -1] # np.array except Exception: v = [[item.vertices[i][0], -1 * item.vertices[i][1]] for i in range(len(item.vertices))] item.setVertices(v) if hasattr(item, 'setOri') and item.ori: # non-zero orientation angle item.setOri(-1, '*') item._needVertexUpdate = True

Back to top