Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion examples/sync/sync_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@
from expyfun import ExperimentController, building_doc
from expyfun.visual import Circle, Rectangle

USE_VPIXX = False
VPIXX_COLOR = [0, 1, 2, 3, 4, 5, 6, 7] # full red
Copy link
Copy Markdown
Member

@larsoner larsoner May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typically in expyfun we try to abstract complexity like this (I would have no idea how to switch to blue for example!). Ideally here the API would just allow for set_vpixx_color("r") or set_vpixx_color("red").

If vpixx supports full-spectrum (0-255, 0-255, 0-255) colors, then we should just use matplotlib to convert to int8, then convert this to vpixx internal coding.

If vpixx only supports a subset of colors, I'd prefer:

  1. Add aliases for the most common ones (probably red, green, blue, black, white)?
  2. Allow passing this list-of-int8-of-length-8 (?) to have more granular control

Copy link
Copy Markdown
Contributor

@NeuroLaunch NeuroLaunch May 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The VPixx DataPixx video and Pixel Mode digital output use the RGB color scheme. 8-bits for each of the Red, Blue, Green. I agree with Dan (below) about abstracting the colors for Expyfun scripts, because in practice our lab would ever use only a subset of the digital output pins and we want exact control over those pins because they will be tied to digital inputs of the Megin or other acquisition system.

n_channels = 2
click_idx = [0]
with ExperimentController(
Expand All @@ -64,9 +66,12 @@
circle = Circle(ec, 1, units="deg", fill_color="k", line_color="w")
# Make a rectangle that is the standard credit card size
rect = Rectangle(ec, [0, 0, 8.56, 5.398], "cm", None, "#AA3377")
# set the vpixx trigger pixel
if USE_VPIXX:
ec.set_vpixx_color(VPIXX_COLOR)
while pressed != "8": # enable a clean quit if required
ec.set_background_color("white")
t1 = ec.start_stimulus(start_of_trial=False) # skip checks
t1 = ec.start_stimulus(start_of_trial=False, vpixx=USE_VPIXX) # skip checks
ec.set_background_color("black")
t2 = ec.flip()
diff = round(1000 * (t2 - t1), 2)
Expand Down
11 changes: 6 additions & 5 deletions examples/sync/sync_test_dualmon.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,11 @@
from expyfun import ExperimentController, building_doc
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It might be nice if this and the other sync utility has VPIXX be a user-set option, defined at the top of the script. (Since our lab and others may not always have this hardware online during an AV test.) And consider defining the Vpixx color bit specification(s) at the top as well, for users to customize the test around their physical Vpixx connections.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in cc69b92 and 25bb732

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the general way of working in expyfun is to set everything that needs to be set, then let the sys config JSON make sure that things (triggering modes, response modes, etc.) get to and from the correct places / devices. As implemented, this code deviates from this pattern by protecting some setting from happening based on a conditional at the top of the file.

What I think would be better would be this set_vpixx_color command not to be protected by an if conditional. Then somewhere else, the vpixx-display-ness is controlled more globally based on whether or not a vpixx projector is connected.

The "expyfun-standard" way to do this I think would be

with ExperimentController(..., vpixx=None):

which means "decide whether or not to display vpixx pixel codes based on system expyfun.json". And end users can set ExperimentController(..., vpixx=True) on their systems to see this pixel appear even if they don't have vpixx.

Then at the end of the day the only thing that changes in this specific test is a single line addition of ec.set_vpixx_color("red"), and we make both ExperimentController and start_stimulus smarter in terms of how they handle vpixx-ness. So concretely the API would be:

  1. Add SCREEN_VPIXX=False (or similar name) default to expyfun.json, which can be set to True on systems with vpixx
  2. Add ExperimentController(..., vpixx=None), which uses SCREEN_VPIXX when None
  3. Add start_stimulus(..., vpixx=None), which uses SCREEN_VPIXX when None

from expyfun.visual import Circle, Rectangle

print(__doc__)

SCREEN = 0
FULL = False
WIN_SIZE = (1000, 1000) # or None for full screen if size matches config
USE_VPIXX = True
VPIXX_COLOR = [0, 1, 2, 3, 4, 5, 6, 7] # full red

n_channels = 2
click_idx = [0]
Expand Down Expand Up @@ -71,9 +71,12 @@
circle = Circle(ec, 1, units="deg", fill_color="k", line_color="w")
# Make a rectangle that is the standard credit card size
rect = Rectangle(ec, [0, 0, 8.56, 5.398], "cm", None, "#AA3377")
# set the vpixx trigger pixel
if USE_VPIXX:
ec.set_vpixx_color(VPIXX_COLOR)
while pressed != "8": # enable a clean quit if required
ec.set_background_color("white")
t1 = ec.start_stimulus(start_of_trial=False) # skip checks
t1 = ec.start_stimulus(start_of_trial=False, vpixx=USE_VPIXX) # skip checks
ec.set_background_color("black")
t2 = ec.flip()
diff = round(1000 * (t2 - t1), 2)
Expand All @@ -86,5 +89,3 @@
ec.refocus()
pressed = ec.wait_one_press(0.5)[0] if not building_doc else "8"
ec.stop()

# ea.plot_screen(screenshot)
54 changes: 51 additions & 3 deletions expyfun/_experiment_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@ def __init__(
car = sum([np.sin(2 * np.pi * f * t) for f in [800, 1000, 1200]])
self._beep = None
self._beep_data = np.tile(car * np.exp(-t * 10) / 4, (2, 3))
self.vpixx_color = ()

# finish initialization
logger.info("Expyfun: Initialization complete")
Expand Down Expand Up @@ -794,7 +795,31 @@ def set_background_color(self, color="black"):
self._bgcolor = _convert_color(color)
gl.glClearColor(*[c / 255.0 for c in self._bgcolor])

def start_stimulus(self, start_of_trial=True, flip=True, when=None):
def set_vpixx_color(self, bits=()):
"""Calculate color for vpixx "pixel mode" triggering, from the desired bits.

Parameters
----------
bits: array-like of int
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As written, this approach seems to preclude setting all the pixel bits to low. A simple fix would be to allow an empty bits array. In practice though, I-LABs will for now only be monitoring one of the digital output pins, so just setting one of the other bits to high is sufficient to get the bit of interest to be low.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed in cc69b92

The bits to be set high (0-indexed). Values between 0 and 23 are valid.
Note that these are the RGB bits *not the DSUB pins*; pins 1-4 and 14-17
control the red value, pins 5-8 and 18-21 control the green value, and pins
9-12 and 22-25 control the blue value (13 is ground). See
https://docs.vpixx.com/vocal/sending-triggers-with-pixel-mode for details.
"""
if not len(bits):
self.vpixx_color = ()
return
bits = np.array(bits, dtype=int)
assert bits.min() >= 0 and bits.max() < 24, (
"Vpixx color bits must be between 0 and 23"
)
trig_out = np.exp2(bits).sum(dtype=int).item()
col = (trig_out % 256, (trig_out >> 8) % 256, (trig_out >> 16) % 256)
# this gets (unavoidably) passed to _convert_color later; must be in range [0,1]
self.vpixx_color = np.array(col) / 255

def start_stimulus(self, start_of_trial=True, flip=True, when=None, vpixx=False):
"""Play audio, (optionally) flip screen, run any "on_flip" functions.

Parameters
Expand All @@ -812,6 +837,9 @@ def start_stimulus(self, start_of_trial=True, flip=True, when=None):
flip completes (if `flip` is ``True``). As a result, in some
cases `when` should be set to a value smaller than your true
intended flip time.
vpixx : bool
Whether to display the vpixx trigger pixel on flip. See
:meth:`ExperimentController.flip` for details. Ignored if ``flip`` is False.

Returns
-------
Expand Down Expand Up @@ -844,7 +872,7 @@ def start_stimulus(self, start_of_trial=True, flip=True, when=None):
self._on_next_flip = (
[self._play] + self._ofp_critical_funs + self._on_next_flip
)
stimulus_time = self.flip(when)
stimulus_time = self.flip(when, vpixx=vpixx)
else:
if when is not None:
self.wait_until(when)
Expand Down Expand Up @@ -1150,7 +1178,7 @@ def _setup_window(self, window_size, exp_name, full_screen, screen):
% (window_size, screen, self.dpi)
)

def flip(self, when=None):
def flip(self, when=None, vpixx=False):
"""Flip screen, then run any "on-flip" functions.

Parameters
Expand All @@ -1161,6 +1189,11 @@ def flip(self, when=None):
absolute) wait time before the flip completes. As a result, in
some cases `when` should be set to a value smaller than your
true intended flip time.
vpixx : bool
Whether to display the vpixx trigger pixel on this flip. Useful only with
Vpixx projectors set in "pixel mode". Will use the value of
``ExperimentController.vpixx_color``, which can be computed from the desired
output trigger values with :meth:`~ExperimentController.set_vpixx_color`.

Returns
-------
Expand Down Expand Up @@ -1191,6 +1224,21 @@ def flip(self, when=None):
if self.safe_flipping:
# On NVIDIA Linux these calls cause a 2x delay (33ms instead of 16)
gl.glFinish()
if vpixx:
if not len(self.vpixx_color):
raise RuntimeError(
"Vpixx pixel color not set; did you forget to call "
"ec.set_vpixx_color() ?"
)
rect = Rectangle(
ec=self,
pos=(0.5, self.window_size_pix[1] - 0.5, 1, 1),
units="pix",
fill_color=self.vpixx_color,
line_color=None,
line_width=0.0,
)
rect.draw()
self._win.flip()
# this waits until everything is called, including last draw
self._clear_rect.draw()
Expand Down
Loading