Repository: psychopy/psychopy

Issue:
on the scaling of `psychopy.visual.window.viewPos`
I find the use of the `viewPos`-attribute of a `window` slightly unclear. I could manipulate it real time, but the units did not follow anything I specified (`pix` or `deg`). The `flip`-method says:

https://github.com/psychopy/psychopy/blob/89cce557d6daaab4fd7136de22f457322dc96b64/psychopy/visual/window.py#L653-L656

and after some testing I figured out that `viewPos` is in "normalised" coordinates. Presumably because `glTranslate` assumes this.

Am I correct in saying that the handling of `viewPos` should be checked against the current `units` of the window?

All that said, I'm pretty stoked about the ability to manipulate this parameter! /Chris


Discussion:
I think it would be ideal if it followed the default units of the Window. I think it's just following `norm` because that is the natural unit of OpenGL. Feel free to add the conversion if possible
Thanks for the quick response. I've been looking at the implications of changing how `viewPos` is handled: it's not used by any `psychopy` methods. There may be good reasons to let the parameter remain in GL-coordinates (norm)? You are very explicit about each stimulus being able to override the window units,  and then there's this:

https://github.com/psychopy/psychopy/blob/2541564da842dc606f6e77ae60c9ba53fd119373/psychopy/visual/window.py#L1273-L1277

Are you in fact moving to `Window` only ever being in `pix`-units? I'm having trouble grokking how the window units are used currently.

Re: `viewPos`, I can see a couple of alternatives here:
* add a comment to docstring to the effect of: "viewPos : *None*, or [x,y] in normalised units", leaving the task of translation to the user
* make `viewPos` a property add a method `setViewPos` for manipulating it between the possible units to normalised (is this overkill?)
BTW: I spent considerable time today installing the dev-version of psychopy... Here's the yaml-file of the `conda` environment I managed to set up that finally worked:

https://gist.github.com/cjayb/67d8f6ada85f8fcd58ff1d4e1fc20764

In it, this works, for me (OS X 10.11.5)

```
python.app psychopyApp.py
```
(I hit a little `matplotlib vs. OS X 10.11` snag with locales on the way, which I understand is fixed in Sierra/10.12)
No, it isn't that window will only support pix in future; it's just I changed (several years ago) the way that scaling was handled. It used to be that the window had to change its scale as each stimulus was drawn. Now it is left in one of two "native" scales (pix and norm) and then the stimuli handle the transformations themselves.
Window will continue to have a units setting that will be applied to the mouse and to any stimulus that doesn't override it. I think it would be _ideal_ if this were applied to the viewPos, as you suggest, but it isn't a big deal and might be a hassle to sort out. Simply changing the documentation is fine too. Whatever you can be bothered with :-) 
Thanks for the conda recipe Chris. I don't use Conda so I don't know much about this, but maybe your yaml file is a useful update for @kastman's Conda recipe?
Let me sleep on it. Now that I know how viewPos is handled, I can use it as-is (and would have figured it out immediately from the docstring). In my application, input is coming in `pix` coordinates (from an eye tracker), so scaling to `norm` is no big deal.

At the very least I will submit a PR for a docs-enhancement to close this. Since I'm used to a slightly different workflow in other projects, I need to ask: do you really need me to PR my `master` against yours? I usually submit my feature branch against the current `master`. I'm referring to [this page](http://www.psychopy.org/developers/repository.html).
Nope, happy for you to PR from any branch. :-)
Hi @cjayb - thanks for the conda gist. I'm actually in the process of moving over to conda-forge, so this will be very helpful. I may get in touch if I have any questions?
@kastman excellent! Haven't tried anything so ambitious, so not sure I can directly help. But I'm more than happy to beta(/alpha)-test for you! Convinced conda-forge has a brilliant future, PsychoPy should be there too!

Resolving patch:
diff --git a/psychopy/visual/window.py b/psychopy/visual/window.py
--- a/psychopy/visual/window.py
+++ b/psychopy/visual/window.py
@@ -38,7 +38,7 @@
         haveAvbin = False
         # either avbin isn't installed or scipy.stats has been imported
         # (prevents avbin loading)
-    except Exception, e:
+    except Exception as e:
         # WindowsError on some systems
         # AttributeError if using avbin5 from pyglet 1.2?
         haveAvbin = False
@@ -61,6 +61,7 @@
 # (JWP has no idea why!)
 from psychopy.tools.attributetools import attributeSetter, setAttribute
 from psychopy.tools.arraytools import val2array
+from psychopy.tools.monitorunittools import convertToPix
 from .text import TextStim
 from .grating import GratingStim
 from .helpers import setColor
@@ -118,7 +119,8 @@ def remove(self, item):
 
 class Window(object):
     """Used to set up a context in which to draw objects,
-    using either `pyglet <http://www.pyglet.org>`_ or `pygame <http://www.pygame.org>`_
+    using either `pyglet <http://www.pyglet.org>`_ or
+    `pygame <http://www.pygame.org>`_
 
     The pyglet backend allows multiple windows to be created, allows the user
     to specify which screen to use (if more than one is available, duh!) and
@@ -155,7 +157,7 @@ def __init__(self,
                  name='window1',
                  checkTiming=True,
                  useFBO=False,
-                 useRetina = False,
+                 useRetina=False,
                  autoLog=True):
         """
         These attributes can only be set at initialization. See further down
@@ -192,7 +194,9 @@ def __init__(self,
                 Can be used to apply a custom scaling to the current units
                 of the :class:`~psychopy.visual.Window`.
             viewPos : *None*, or [x,y]
-                If not None, redefines the origin for the window
+                If not None, redefines the origin within the window, in the
+                ref:`units` of the window. Values outside the borders will be
+                clamped to lie on the border.
             viewOri : *0* or any numeric value
                 A single value determining the orientation of the view in degs
             waitBlanking : *None*, True or False.
@@ -236,7 +240,7 @@ def __init__(self,
             self._initParams.remove(unecess)
 
         # Check autoLog value
-        if not autoLog in (True, False):
+        if autoLog not in (True, False):
             raise ValueError(
                 'autoLog must be either True or False for visual.Window')
 
@@ -292,6 +296,8 @@ def __init__(self,
 
         # parameters for transforming the overall view
         self.viewScale = val2array(viewScale)
+        if self.viewPos is not None and self.units is None:
+            raise ValueError('You must define the window units to use viewPos')
         self.viewPos = val2array(viewPos, withScalar=False)
         self.viewOri = float(viewOri)
         if self.viewOri != 0. and self.viewPos is not None:
@@ -400,7 +406,7 @@ def __init__(self,
             logging.exp("Created %s = %s" % (self.name, str(self)))
 
     def __del__(self):
-        if self._closed == False:
+        if self._closed is False:
             self.close()
 
     def __enter__(self):
@@ -440,6 +446,35 @@ def units(self, value):
     def setUnits(self, value, log=True):
         setAttribute(self, 'units', value, log=log)
 
+    @attributeSetter
+    def viewPos(self, value):
+        """The origin of the window onto which stimulus-objects are drawn.
+
+        The value should be given in the units defined for the window. NB:
+        Never change a single component (x or y) of the origin, instead replace
+        the viewPos-attribute in one shot, e.g.:
+            win.viewPos = [new_xval, new_yval]  # This is the way to do it
+            win.viewPos[0] = new_xval  # DO NOT DO THIS! Errors will result.
+        """
+        self.__dict__['viewPos'] = value
+        if value is not None:
+            # let setter take care of normalisation
+            setattr(self, '_viewPosNorm', value)
+
+    @attributeSetter
+    def _viewPosNorm(self, value):
+        """Normalised value of viewPos, hidden from user view."""
+        # first convert to pixels, then normalise to window units
+        viewPos_pix = convertToPix([0, 0], list(value),
+                                   units=self.units, win=self)[:2]
+        viewPos_norm = viewPos_pix / (self.size / 2.0)
+        # Clip to +/- 1; should going out-of-window raise an exception?
+        viewPos_norm = numpy.clip(viewPos_norm, a_min=-1., a_max=1.)
+        self.__dict__['_viewPosNorm'] = viewPos_norm
+
+    def setViewPos(self, value, log=True):
+        setAttribute(self, 'viewPos', value, log=log)
+
     @attributeSetter
     def waitBlanking(self, value):
         """*None*, True or False.
@@ -579,7 +614,7 @@ def flip(self, clearBuffer=True):
                 stencilOn = GL.glIsEnabled(GL.GL_STENCIL_TEST)
                 GL.glDisable(GL.GL_STENCIL_TEST)
 
-                if self.bits != None:
+                if self.bits is not None:
                     self.bits._prepareFBOrender()
 
                 # before flipping need to copy the renderBuffer to the
@@ -654,8 +689,11 @@ def flip(self, clearBuffer=True):
             absScaleX, absScaleY = 1, 1
 
         if self.viewPos is not None:
-            normRfPosX = self.viewPos[0] / absScaleX
-            normRfPosY = self.viewPos[1] / absScaleY
+            # here we must use normalised units in _viewPosNorm,
+            # see the corresponding attributeSetter above
+            normRfPosX = self._viewPosNorm[0] / absScaleX
+            normRfPosY = self._viewPosNorm[1] / absScaleY
+
             GL.glTranslatef(normRfPosX, normRfPosY, 0.0)
 
         if self.viewOri:  # float
@@ -878,9 +916,11 @@ def _getFrame(self, buffer='front'):
         GL.glReadPixels(0, 0, self.size[0], self.size[1],
                         GL.GL_RGBA, GL.GL_UNSIGNED_BYTE, bufferDat)
         try:
-            im = Image.fromstring(mode='RGBA', size=tuple(self.size), data=bufferDat)
+            im = Image.fromstring(mode='RGBA', size=tuple(self.size),
+                                  data=bufferDat)
         except Exception:
-            im = Image.frombytes(mode='RGBA', size=tuple(self.size), data=bufferDat)
+            im = Image.frombytes(mode='RGBA', size=tuple(self.size),
+                                 data=bufferDat)
         im = im.transpose(Image.FLIP_TOP_BOTTOM)
         im = im.convert('RGB')
 
@@ -956,7 +996,7 @@ def saveMovieFrames(self, fileName, codec='libx264',
                 numpyFrames.append(numpy.array(frame))
             clip = ImageSequenceClip(numpyFrames, fps=fps)
             if fileExt == '.gif':
-                clip.write_gif(fileName,fps=15)
+                clip.write_gif(fileName, fps=15)
             else:
                 clip.write_videofile(fileName, codec=codec)
         elif len(self.movieFrames) == 1:
@@ -1241,7 +1281,7 @@ def gamma(self, gamma):
                    "Bits++/Bits# enabled. It was ambiguous what should "
                    "happen. Use the setGamma() function of the bits box "
                    "instead")
-            raise DeprecationError, msg
+            raise DeprecationWarning(msg)
         elif self.winType == 'pygame':
             pygame.display.set_gamma(self.gamma[0],
                                      self.gamma[1],
@@ -1369,7 +1409,7 @@ def retina_on_resize(self, width, height):
             GL.glOrtho(0, width, 0, height, -1, 1)
             GL.glMatrixMode(GL.GL_MODELVIEW)
 
-        if self.useRetina and sys.platform=='darwin':
+        if self.useRetina and sys.platform == 'darwin':
             from pyglet.gl.cocoa import CocoaContext
             CocoaContext.attach = retinaAttach
             from pyglet.window.cocoa import CocoaWindow


Test patch:
diff --git a/psychopy/tests/test_all_visual/test_winScalePos.py b/psychopy/tests/test_all_visual/test_winScalePos.py
--- a/psychopy/tests/test_all_visual/test_winScalePos.py
+++ b/psychopy/tests/test_all_visual/test_winScalePos.py
@@ -1,51 +1,77 @@
 
 from psychopy import visual
 from psychopy.tests import utils
-import os, pytest
+import os
+import pytest
 
-v = [(1,1),(1,-1),(-1,-1),(-1,1)]  # vertices to use = square
+v = [(1, 1), (1, -1), (-1, -1), (-1, 1)]  # vertices to use = square
 n = 15  # size of the base square
-pimg = ( n, n)  # position for the image
-pgrn = (-n,-n)  # position for green square
+pimg = (n, n)  # position for the image
+pgrn = (-n, -n)  # position for green square
 img_name = os.path.join(utils.TESTS_DATA_PATH, 'filltext.png')
 
+
 class Test_Win_Scale_Pos_Ori(object):
     def setup_class(self):
-        self.win = visual.Window(size=(200,200), units='pix', allowGUI=False, autoLog=False)
+        self.win = visual.Window(size=(200, 200), units='pix',
+                                 allowGUI=False, autoLog=False)
+
     def teardown_class(self):
         self.win.close()
 
     @pytest.mark.scalepos
     def test_winScalePosOri(self):
         """test window.viewScale and .viewPos simultaneous
-        negative-going scale should mirror-reverse, and position should account for that
-        visually, the green square/rect should move clockwise around the text
+        negative-going scale should mirror-reverse, and position should
+        account for that visually, the green square/rect should move clockwise
+        around the text
 
         Non-zero viewOri would not currently pass with a nonzero viewPos
         """
+        with pytest.raises(ValueError):
+            # units must be define for viewPos!
+            _, = visual.Window(size=(200, 200), viewPos=(1, 1), viewOri=1)
         with pytest.raises(NotImplementedError):
-            w = visual.Window(size=(200,200), viewPos=(1,1), viewOri=1)
+            # simultaneous viewPos and viewOri prohibited at present
+            _, = visual.Window(size=(200, 200), units='pix',
+                               viewPos=(1, 1), viewOri=1)
 
         for ori in [0, 45]:
             self.win.viewOri = ori
-            for offset in [(0,0), (-.4,0)]:
+            for offset in [(0, 0), (-0.4, 0)]:
                 if ori and (offset[0] or offset[1]):
                     continue  # this combination is NotImplemented
-                self.win.viewPos = offset
-                for scale in [[1,1],  # normal: green at lower left
-                              [1,-1],  # mirror vert only: green appears to move up, text mirrored
-                              [-1,-1],  # mirror horiz & vert: green appears to move right, text normal but upside down
-                              [-1,1],  # mirror horiz only: green appears to move down, text mirrored
-                        [2,2],[2,-2],[-2,-2],[-2,2]]:  # same, but both larger
+                else:  # NB assumes test win is in pixels!
+                    # convert from normalised offset to pixels
+                    offset_pix = (round(offs * siz / 2.) for offs, siz in
+                                  zip(offset, self.win.size))
+
+                    self.win.viewPos = offset_pix
+
+                for scale in [[1, 1],  # normal: green at lower left
+                              [1, -1],  # mirror vert only: green appears to
+                                        # move up, text mirrored
+                              [-1, -1],  # mirror horiz & vert: green appears
+                                         # to move right, text normal but
+                                         # upside down
+                              [-1, 1],  # mirror horiz only: green appears to
+                                        # move down, text mirrored
+                              [2, 2],  # same, but both larger
+                              [2, -2],
+                              [-2, -2],
+                              [-2, 2]]:
                     self.win.viewScale = scale
                     self.win.flip()
-                    grn = visual.ShapeStim(self.win, vertices=v, pos=pgrn, size=n, fillColor='darkgreen')
-                    img = visual.ImageStim(self.win, image=img_name, size=2*n, pos=pimg)
+                    grn = visual.ShapeStim(self.win, vertices=v, pos=pgrn,
+                                           size=n, fillColor='darkgreen')
+                    img = visual.ImageStim(self.win, image=img_name, size=2*n,
+                                           pos=pimg)
                     grn.draw()
                     img.draw()
 
                     oristr = str(ori)
                     scalestr = str(scale[0]) + ',' + str(scale[1])
                     posstr = str(offset[0]) + ',' + str(offset[1])
-                    filename = 'winScalePos_ori%s_scale%s_pos%s.png' % (oristr, scalestr, posstr)
+                    filename = 'winScalePos_ori%s_scale%s_pos%s.png' % \
+                        (oristr, scalestr, posstr)
                     utils.compareScreenshot(filename, self.win, crit=15)