diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f4486a6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,208 @@ +# Created by https://www.gitignore.io/api/python,pycharm,visualstudiocode +# Edit at https://www.gitignore.io/?templates=python,pycharm,visualstudiocode + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/**/sonarlint/ + +# SonarQube Plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator/ + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history + +# End of https://www.gitignore.io/api/python,pycharm,visualstudiocode diff --git a/README.md b/README.md index d81103b..53cfba0 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,16 @@ -# Pupil LSL Relay Plugin +# App-PupilLabs -Plugin for _[Pupil Capture](https://github.com/pupil-labs/pupil/wiki/Pupil-Capture)_ that publishes realtime gaze data using the [lab streaming layer](https://github.com/sccn/labstreaminglayer) framework. +This repository contains implementations of relays that publish realtime gaze data using the [lab streaming layer](https://github.com/sccn/labstreaminglayer) framework. -## Installation +- **`pupil_capture`** contains a [Pupil Capture][pupil-capture-app] plugin that works with the [Pupil Core headset][pupil-core-headset] and the [VR/AR add-ons][vr-ar-addons]. +More information on how to install and use the plugin is available [here][pupil-core-lsl-readme] +- **`pupil_invisible_lsl_relay`** is a command line tool for publishing data from [Pupil Invisible][pupil-invisible-headset-and-app]. +More information on how to install and use the tool is available [here][pupil-invisible-lsl-readme]. - [user plugin directory](https://docs.pupil-labs.com/#plugin-guide) -1. Install `pylsl` -2. Copy or symlink `pylsl` with all its content to the _plugin directory_. -3. Copy [`pupil_lsl_relay.py`](pupil_lsl_relay.py) to the _plugin directory_. - - -## Usage - -1. Start _Pupil Capture_. -2. [Open the _Pupil LSL Relay_ plugin](https://docs.pupil-labs.com/#open-a-plugin). -3. Now the LSL outlet is ready to provide data to other inlets in the network. - -## LSL Outlet - -The plugin opens a single outlet named `pupil_capture` that follows the [Gaze Meta Data](https://github.com/sccn/xdf/wiki/Gaze-Meta-Data) format. - -See our [pupil-helpers](https://github.com/pupil-labs/pupil-helpers/tree/master/LabStreamingLayer) for examples on how to record and visualize the published data. - -The published LSL data is simply a flattened version (see `extract_*()` functions in `pupil_lsl_relay.py`) of the original Pupil gaze data stream. The stream's channels will be filled with best effort, i.e. if there is a monocular gaze datum the values for the opposite eye will be set to `NaN`. The actual pairing of pupil data to binocular gaze data happens in [Capture](https://github.com/pupil-labs/pupil/blob/master/pupil_src/shared_modules/calibration_routines/gaze_mappers.py#L95-L140) and is not a LSL specific behaviour. Therefore, it is possible to apply the same [flattening code](https://github.com/papr/App-PupilLabs/blob/master/pupil_lsl_relay.py#L226-L287) to offline calibrated gaze data and reproduce the stream published by the LSL outlet. - -## Data Format - -'confidence': Normalized (0-1) confidence. - -'norm_pos_x', 'norm_pos_y': Normalized (0-1) coordinates on the screen. - -'gaze_point_3d_x', 'gaze_point_3d_y', 'gaze_point_3d_z': World coordinates in mm - -'eye_centerright_3d_x' ... (for right/left eyes, for x/y/z): Position of eye center in world coordinates in mm. - -'gaze_normalright_x' (right/left, x/y/z): End point of vector from eye center (I think). - -'diameterright_2d' (right/left): Pupil diameter in pixels - -'diameterright_3d' (right/left): Pupil diameter in mm - -## LSL Clock Synchronization - -The `Pupil LSL Relay` plugin adjusts Capture's timebase to synchronize Capture's own clock with the `pylsl.local_clock()`. This allows the recording of native Capture timestamps and removes the necessity of manually synchronize timestamps after the effect. - -**Warning**: The time synchronization will potentially break if other time alternating actors (e.g. the `Time Sync` plugin or `hmd-eyes`) are active. +[pupil-capture-app]: https://github.com/pupil-labs/pupil/releases/latest +[pupil-core-headset]: https://pupil-labs.com/products/core +[pupil-invisible-headset-and-app]: https://pupil-labs.com/products/invisible/ +[pupil-core-lsl-readme]: https://github.com/labstreaminglayer/App-PupilLabs/blob/master/pupil_capture/README.md +[pupil-invisible-lsl-readme]: https://github.com/labstreaminglayer/App-PupilLabs/blob/master/pupil_invisible_lsl_relay/README.md +[vr-ar-addons]: https://pupil-labs.com/products/vr-ar/ diff --git a/pupil_capture/README.md b/pupil_capture/README.md new file mode 100644 index 0000000..320eb59 --- /dev/null +++ b/pupil_capture/README.md @@ -0,0 +1,48 @@ +# Pupil Capture LSL Relay Plugin + +Plugin for _[Pupil Capture](https://github.com/pupil-labs/pupil/releases/latest)_ that publishes realtime gaze data using the [lab streaming layer](https://github.com/sccn/labstreaminglayer) framework. + +## Installation + +Please see our documentation on where to find the [user plugin directory](https://docs.pupil-labs.com/developer/core/plugin-api/#adding-a-plugin). + +1. Install `pylsl` +2. Copy or symlink `pylsl` with all its content to the _plugin directory_. +3. Copy [`pupil_capture_lsl_relay.py`](pupil_capture_lsl_relay.py) to the _plugin directory_. + + +## Usage + +1. Start _Pupil Capture_. +2. [Open the _Pupil Capture LSL Relay_ plugin](https://docs.pupil-labs.com/core/software/pupil-capture/#plugins). +3. Now the LSL outlet is ready to provide data to other inlets in the network. + +## LSL Outlet + +The plugin opens a single outlet named `pupil_capture` that follows the [Gaze Meta Data](https://github.com/sccn/xdf/wiki/Gaze-Meta-Data) format. + +See our [pupil-helpers](https://github.com/pupil-labs/pupil-helpers/tree/master/LabStreamingLayer) for examples on how to record and visualize the published data. + +The published LSL data is simply a flattened version (see `extract_*()` functions in `pupil_capture_lsl_relay.py`) of the original Pupil gaze data stream. The stream's channels will be filled with best effort, i.e. if there is a monocular gaze datum the values for the opposite eye will be set to `NaN`. The actual pairing of pupil data to binocular gaze data happens in [Capture](https://github.com/pupil-labs/pupil/blob/master/pupil_src/shared_modules/calibration_routines/gaze_mappers.py#L95-L140) and is not a LSL specific behaviour. Therefore, it is possible to apply the same [flattening code](https://github.com/papr/App-PupilLabs/blob/master/pupil_lsl_relay.py#L226-L287) to offline calibrated gaze data and reproduce the stream published by the LSL outlet. + +## Data Format + +'confidence': Normalized (0-1) confidence. + +'norm_pos_x', 'norm_pos_y': Normalized (0-1) coordinates on the screen. + +'gaze_point_3d_x', 'gaze_point_3d_y', 'gaze_point_3d_z': World coordinates in mm + +'eye_centerright_3d_x' ... (for right/left eyes, for x/y/z): Position of eye center in world coordinates in mm. + +'gaze_normalright_x' (right/left, x/y/z): End point of vector from eye center (I think). + +'diameterright_2d' (right/left): Pupil diameter in pixels + +'diameterright_3d' (right/left): Pupil diameter in mm + +## LSL Clock Synchronization + +The `Pupil LSL Relay` plugin adjusts Capture's timebase to synchronize Capture's own clock with the `pylsl.local_clock()`. This allows the recording of native Capture timestamps and removes the necessity of manually synchronize timestamps after the effect. + +**Warning**: The time synchronization will potentially break if other time alternating actors (e.g. the `Time Sync` plugin or `hmd-eyes`) are active. diff --git a/pupil_lsl_relay.py b/pupil_capture/pupil_capture_lsl_relay.py similarity index 100% rename from pupil_lsl_relay.py rename to pupil_capture/pupil_capture_lsl_relay.py diff --git a/pupil_invisible_lsl_relay/README.md b/pupil_invisible_lsl_relay/README.md new file mode 100644 index 0000000..375e40c --- /dev/null +++ b/pupil_invisible_lsl_relay/README.md @@ -0,0 +1,32 @@ +# Pupil Invisible Gaze Relay + +## Installation + +```bash +git clone https://github.com/labstreaminglayer/App-PupilLabs.git + +cd App-PupilLabs/ +git checkout pupil-invisible-relay + +# Use the Python 3 installation of your choice +python -m pip install -U pip +python -m pip install -r requirements.txt +``` + +## Usage + +#### Basic mode + +The basic usage of the Pupil Invisible Gaze Relay module is to provide a device host name as an argument. The module will wait for that device to announce a gaze sensor, will connect to it and start pushing the gaze data to the LSL outlet named `pupil_invisible`. + +```bash +pupil_invisible_lsl_relay --host-name +``` + +#### Interactive mode + +In interactive mode, there is no need to provide the device name beforehand. Instead, the module monitors the network and shows a list of available devices which the user can select. + +```bash +pupil_invisible_lsl_relay +``` diff --git a/pupil_invisible_lsl_relay/__init__.py b/pupil_invisible_lsl_relay/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pupil_invisible_lsl_relay/__main__.py b/pupil_invisible_lsl_relay/__main__.py new file mode 100644 index 0000000..9ae637f --- /dev/null +++ b/pupil_invisible_lsl_relay/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + main() diff --git a/pupil_invisible_lsl_relay/cli.py b/pupil_invisible_lsl_relay/cli.py new file mode 100644 index 0000000..41b88fd --- /dev/null +++ b/pupil_invisible_lsl_relay/cli.py @@ -0,0 +1,90 @@ +import abc +import logging +import typing + +import click + +from .controllers import ConnectionController, InteractionController +from .pi_gaze_relay import PupilInvisibleGazeRelay + + +logger = logging.getLogger(__name__) + + +@click.command() +@click.option("--host-name", default=None, help="Device (host) name to connect") +@click.option( + "--timeout", + default=5.0, + help="Time limit in seconds to try to connect to the device (only works with --host-name argument)", +) +def main(host_name: str, timeout: float): + + if host_name is None: + toggle_logging(enable=False) + host_name = interactive_mode_get_host_name() + timeout = ( + None + ) # Since the user picked a device from the discovered list, ignore the timeout + + if host_name is None: + exit(0) + + toggle_logging(enable=True) + + gaze_relay = PupilInvisibleGazeRelay() + + for gaze in gaze_data_stream(host_name, connection_timeout=timeout): + gaze_relay.push_gaze_sample(gaze) + + +def interactive_mode_get_host_name(): + interaction = InteractionController() + try: + while True: + host_name = interaction.get_user_selected_host_name() + if host_name is not None: + return host_name + except KeyboardInterrupt: + return None + finally: + interaction.cleanup() + + +def gaze_data_stream(host_name, connection_timeout): + connection = ConnectionController(host_name=host_name, timeout=connection_timeout) + try: + while True: + connection.poll_events() + for gaze in connection.fetch_gaze(): + # logger.debug(gaze) + yield gaze + except KeyboardInterrupt: + pass + except ConnectionController.Timeout: + print(f"===============================================") + print(f'Failed to discover device named "{host_name}"') + print(f"Discovered devices:") + for host_name in connection.discovered_hosts: + print(f"\t{host_name}") + print(f"===============================================") + finally: + connection.cleanup() + + +def toggle_logging(enable: bool): + handlers = [] + if enable: + handlers.append(logging.StreamHandler()) + logging.basicConfig( + level=logging.DEBUG, + handlers=handlers, + style="{", + format="{asctime} [{levelname}] {message}", + datefmt="%Y-%m-%d %H:%M:%S", + ) + logging.getLogger("pyre").setLevel(logging.WARNING) + + +if __name__ == "__main__": + main() diff --git a/pupil_invisible_lsl_relay/controllers.py b/pupil_invisible_lsl_relay/controllers.py new file mode 100644 index 0000000..c58042d --- /dev/null +++ b/pupil_invisible_lsl_relay/controllers.py @@ -0,0 +1,162 @@ +import logging +import threading +import typing as T +import ndsi + + +logger = logging.getLogger(__name__) + + +class DiscoveryController: + def __init__(self): + self.discovered_hosts = set() # Set of discovered hosts with gaze sensors + self.network = ndsi.Network( + formats={ndsi.DataFormat.V4}, callbacks=(self.on_event,) + ) + self.network.start() + + def cleanup(self): + self.network.stop() + + def poll_events(self): + while self.network.has_events: + self.network.handle_event() + + def on_event(self, caller, event): + if event["subject"] == "attach" and event["sensor_type"] == "gaze": + self.on_gaze_sensor_attach( + host_name=event["host_name"], sensor_uuid=event["sensor_uuid"] + ) + if event["subject"] == "detach": + self.on_gaze_sensor_detach(host_name=event["host_name"]) + + def on_gaze_sensor_attach(self, host_name, sensor_uuid): + self.discovered_hosts.add(host_name) + + def on_gaze_sensor_detach(self, host_name): + self.discovered_hosts.remove(host_name) + + +class ConnectionController(DiscoveryController): + class Timeout(Exception): + pass + + def __init__(self, host_name, timeout=None): + self._target_host_name = host_name + self._gaze_sensor = None + super().__init__() + self._connection_did_timeout = False + if timeout is not None: + self._connection_timer = threading.Timer( + timeout, self.on_connection_timeout + ) + self._connection_timer.start() + else: + self._connection_timer = None + + def cleanup(self): + if self._connection_timer: + self._connection_timer.cancel() + self._disconnect_sensor() + super().cleanup() + + def poll_events(self): + if self._connection_did_timeout: + raise ConnectionController.Timeout + super().poll_events() + if self._gaze_sensor: + while self._gaze_sensor.has_notifications: + self._gaze_sensor.handle_notification() + + def fetch_gaze(self): + if self._connection_did_timeout: + raise ConnectionController.Timeout + if self._gaze_sensor: + yield from self._gaze_sensor.fetch_data() + + def on_gaze_sensor_attach(self, host_name, sensor_uuid): + super().on_gaze_sensor_attach(host_name, sensor_uuid) + if host_name == self._target_host_name: + self._connect_sensor(sensor_uuid) + + def on_gaze_sensor_detach(self, host_name): + super().on_gaze_sensor_detach(host_name) + if host_name == self._target_host_name: + self._disconnect_sensor() + + def on_connection_timeout(self): + self._connection_did_timeout = True + + def _connect_sensor(self, sensor_uuid): + self._disconnect_sensor() + gaze_sensor = self.network.sensor(sensor_uuid) + self._gaze_sensor = gaze_sensor + self._gaze_sensor.set_control_value("streaming", True) + self._gaze_sensor.refresh_controls() + if self._connection_timer: + self._connection_timer.cancel() + logger.debug(f"Sensor connected: {gaze_sensor}") + + def _disconnect_sensor(self): + if self._gaze_sensor: + gaze_sensor = self._gaze_sensor + self._gaze_sensor.unlink() + self._gaze_sensor = None + logger.debug(f"Sensor disconnected: {gaze_sensor}") + + +class InteractionController(DiscoveryController): + def __init__(self): + super().__init__() + self._initial_discovery_event = threading.Event() + self._network_should_stop = threading.Event() + + self._network_thread = threading.Thread( + target=self._discovery_run, name="Host discovery", args=(), daemon=False + ) + self._network_thread.start() + + def _discovery_run(self): + while not self._network_should_stop.wait(1): + self.poll_events() + super().cleanup() # NOTE: Only call super implementation, since it is the one running in the background thread. + + def cleanup(self): + self._network_should_stop.set() + self._network_thread.join() + + def on_gaze_sensor_attach(self, host_name, sensor_uuid): + super().on_gaze_sensor_attach(host_name, sensor_uuid) + self._initial_discovery_event.set() + + def on_gaze_sensor_detach(self, host_name): + super().on_gaze_sensor_detach(host_name) + + def get_user_selected_host_name(self): + if not self._initial_discovery_event.wait(1): + return None + + RELOAD_COMMAND = "R" + shown_hosts = sorted(self.discovered_hosts) + + print("\n======================================") + print("Please select a Pupil Invisible device:") + for host_index, host_name in enumerate(shown_hosts): + print(f"\t{host_index}\t{host_name}") + print(f"\t{RELOAD_COMMAND}\tReload list") + + user_input = input(">>> ").strip() + + try: + host_index = int(user_input) + except ValueError: + host_index = None + + if host_index is not None and 0 <= host_index < len(shown_hosts): + return shown_hosts[host_index] + elif user_input.upper() == RELOAD_COMMAND.upper(): + pass + else: + print(f"Invalid input: {user_input}. Please try again.") + + return None diff --git a/pupil_invisible_lsl_relay/pi_gaze_relay.py b/pupil_invisible_lsl_relay/pi_gaze_relay.py new file mode 100644 index 0000000..8d7ad8f --- /dev/null +++ b/pupil_invisible_lsl_relay/pi_gaze_relay.py @@ -0,0 +1,111 @@ +import logging +import time +import uuid +import pylsl as lsl + + +VERSION = "1.0" + + +logger = logging.getLogger(__name__) + + +class PupilInvisibleGazeRelay: + def __init__(self, outlet_uuid=None): + self._channels = pi_gaze_channels() + self._time_offset = time.time() - lsl.local_clock() + self._outlet_uuid = outlet_uuid or str(uuid.uuid4()) + self._outlet = pi_gaze_outlet(self._outlet_uuid, self._channels) + + def push_gaze_sample(self, gaze): + try: + sample = [chan.query(gaze) for chan in self._channels] + timestamp = gaze.timestamp - self._time_offset + except Exception as exc: + logger.error("Error extracting gaze sample: {}".format(exc)) + logger.debug(str(gaze)) + return + # push_chunk might be more efficient but does not + # allow to set explicit timstamps for all samples + self._outlet.push_sample(sample, timestamp) + + +def pi_gaze_outlet(outlet_uuid, channels): + stream_info = pi_streaminfo(outlet_uuid, channels) + return lsl.StreamOutlet(stream_info) + + +def pi_streaminfo(outlet_uuid, channels): + stream_info = lsl.StreamInfo( + name="pupil_invisible", + type="Gaze", + channel_count=len(channels), + channel_format=lsl.cf_double64, + source_id=outlet_uuid, + ) + stream_info.desc().append_child_value("pupil_invisible_lsl_relay_version", VERSION) + xml_channels = stream_info.desc().append_child("channels") + for chan in channels: + chan.append_to(xml_channels) + return stream_info + + +def pi_gaze_channels(): + channels = [] + + # ScreenX, ScreenY: screen coordinates of the gaze cursor + channels.extend( + [ + GazeChannel( + query=pi_extract_screen_query(i), + label="xy"[i], + eye="both", + metatype="Screen" + "XY"[i], + unit="pixels", + coordinate_system="world", + ) + for i in range(2) + ] + ) + + # PupilInvisibleTimestamp: original Pupil Invisible UNIX timestamp + channels.extend( + [ + GazeChannel( + query=pi_extract_timestamp_query(), + label="pi_timestamp", + eye="both", + metatype="PupilInvisibleTimestamp", + unit="seconds", + ) + ] + ) + + return channels + + +def pi_extract_screen_query(dim): + return lambda gaze: [gaze.x, gaze.y][dim] + + +def pi_extract_timestamp_query(): + return lambda gaze: gaze.timestamp + + +class GazeChannel: + def __init__(self, query, label, eye, metatype, unit, coordinate_system=None): + self.label = label + self.eye = eye + self.metatype = metatype + self.unit = unit + self.coordinate_system = coordinate_system + self.query = query + + def append_to(self, channels): + chan = channels.append_child("channel") + chan.append_child_value("label", self.label) + chan.append_child_value("eye", self.eye) + chan.append_child_value("type", self.metatype) + chan.append_child_value("unit", self.unit) + if self.coordinate_system: + chan.append_child_value("coordinate_system", self.coordinate_system) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d7f4936 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +-r requirements_custom.txt +-e . diff --git a/requirements_custom.txt b/requirements_custom.txt new file mode 100644 index 0000000..68e1a93 --- /dev/null +++ b/requirements_custom.txt @@ -0,0 +1,2 @@ +-e git://github.com/pupil-labs/pyndsi/@master#egg=ndsi +-e git://github.com/zeromq/pyre@master#egg=pyre diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..33dcbfa --- /dev/null +++ b/setup.py @@ -0,0 +1,23 @@ +from setuptools import setup + + +if __name__ == "__main__": + setup( + name="pupil_invisible_lsl_relay", + version="0.1", + packages=['pupil_invisible_lsl_relay'], + install_requires=[ + "ndsi>=1.0.dev0", + "pyre", + "pylsl>=1.12.2", + "click>=7.0", + ], + url="https://github.com/labstreaminglayer/App-PupilLabs", + author="Pupil Labs", + author_email="info@pupil-labs.com", + entry_points={ + 'console_scripts': [ + 'pupil_invisible_lsl_relay = pupil_invisible_lsl_relay.cli:main', + ] + } + )