From 4325928bddea273592c5e315721c1cd179746e31 Mon Sep 17 00:00:00 2001 From: Damien Mouratille Date: Wed, 7 Aug 2024 16:37:46 +0200 Subject: Add Tobii Pro G3 + edit name contexts --- src/argaze/utils/contexts/PupilLabs.py | 139 -------------------- src/argaze/utils/contexts/PupilLabsInvisible.py | 140 +++++++++++++++++++++ src/argaze/utils/contexts/TobiiProGlasses3.py | 128 +++++++++++++++++++ src/argaze/utils/demo/aruco_markers_pipeline.json | 24 +--- src/argaze/utils/demo/gaze_analysis_pipeline.json | 2 +- .../pupillabs_invisible_live_stream_context.json | 6 + .../utils/demo/pupillabs_live_stream_context.json | 6 - .../utils/demo/tobii_g2_live_stream_context.json | 18 +++ .../utils/demo/tobii_g3_live_stream_context.json | 6 + .../utils/demo/tobii_live_stream_context.json | 18 --- 10 files changed, 302 insertions(+), 185 deletions(-) delete mode 100644 src/argaze/utils/contexts/PupilLabs.py create mode 100644 src/argaze/utils/contexts/PupilLabsInvisible.py create mode 100644 src/argaze/utils/contexts/TobiiProGlasses3.py create mode 100644 src/argaze/utils/demo/pupillabs_invisible_live_stream_context.json delete mode 100644 src/argaze/utils/demo/pupillabs_live_stream_context.json create mode 100644 src/argaze/utils/demo/tobii_g2_live_stream_context.json create mode 100644 src/argaze/utils/demo/tobii_g3_live_stream_context.json delete mode 100644 src/argaze/utils/demo/tobii_live_stream_context.json diff --git a/src/argaze/utils/contexts/PupilLabs.py b/src/argaze/utils/contexts/PupilLabs.py deleted file mode 100644 index 1bfb658..0000000 --- a/src/argaze/utils/contexts/PupilLabs.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Handle network connection to Pupil Labs devices. Tested with Pupil Invisible. - Based on Pupil Labs' Realtime Python API.""" - -""" -This program is free software: you can redistribute it and/or modify it under -the terms of the GNU General Public License as published by the Free Software -Foundation, either version 3 of the License, or (at your option) any later -version. -This program is distributed in the hope that it will be useful, but WITHOUT -ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. -You should have received a copy of the GNU General Public License along with -this program. If not, see . -""" - -__author__ = "Damien Mouratille" -__credits__ = [] -__copyright__ = "Copyright 2024, Ecole Nationale de l'Aviation Civile (ENAC)" -__license__ = "GPLv3" - -import sys -import logging -import time -import threading -from dataclasses import dataclass - -from argaze import ArFeatures, DataFeatures, GazeFeatures -from argaze.utils import UtilsFeatures - -import numpy -import cv2 - -from pupil_labs.realtime_api.simple import discover_one_device - - -class LiveStream(ArFeatures.DataCaptureContext): - - @DataFeatures.PipelineStepInit - def __init__(self, **kwargs): - - # Init DataCaptureContext class - super().__init__() - - def __enter__(self): - - logging.info('Pupil-Labs Device connexion starts...') - - # Init timestamp - self.__start_time = time.time() - - # Look for devices. Returns as soon as it has found the first device. - self.__device = discover_one_device(max_search_duration_seconds=10) - - if self.__device is None: - logging.info('No device found. Exit!') - raise SystemExit(-1) - else: - logging.info('Device found. Stream loading.') - - # Open gaze stream - self.__gaze_thread = threading.Thread(target=self.__stream_gaze) - - logging.debug('> starting gaze thread...') - - self.__gaze_thread.start() - - # Open video stream - self.__video_thread = threading.Thread(target=self.__stream_video) - - logging.debug('> starting video thread...') - - self.__video_thread.start() - - return self - - def __stream_gaze(self): - """Stream gaze.""" - - logging.debug('Stream gaze from Pupil Device') - - while self.is_running(): - - try: - while True: - gaze = self.__device.receive_gaze_datum() - - gaze_timestamp = int((gaze.timestamp_unix_seconds - self.__start_time) * 1e3) - - logging.debug('Gaze received at %i timestamp', gaze_timestamp) - - # When gaze position is valid - if gaze.worn is True: - - self._process_gaze_position( - timestamp=gaze_timestamp, - x=int(gaze.x), - y=int(gaze.y)) - else: - # Process empty gaze position - logging.debug('Not worn at %i timestamp', gaze_timestamp) - - self._process_gaze_position(timestamp=gaze_timestamp) - - except KeyboardInterrupt: - pass - - def __stream_video(self): - """Stream video.""" - - logging.debug('Stream video from Pupil Device') - - while self.is_running(): - - try: - while True: - scene_frame, frame_datetime = self.__device.receive_scene_video_frame() - - scene_timestamp = int((frame_datetime - self.__start_time) * 1e3) - - logging.debug('Video received at %i timestamp', scene_timestamp) - - self._process_camera_image( - timestamp=scene_timestamp, - image=scene_frame) - - except KeyboardInterrupt: - pass - - @DataFeatures.PipelineStepExit - def __exit__(self, exception_type, exception_value, exception_traceback): - - logging.debug('Pupil-Labs context stops...') - - # Close data stream - self.stop() - - # Stop streaming - threading.Thread.join(self.__gaze_thread) - threading.Thread.join(self.__video_thread) diff --git a/src/argaze/utils/contexts/PupilLabsInvisible.py b/src/argaze/utils/contexts/PupilLabsInvisible.py new file mode 100644 index 0000000..5c9a138 --- /dev/null +++ b/src/argaze/utils/contexts/PupilLabsInvisible.py @@ -0,0 +1,140 @@ +"""Handle network connection to Pupil Labs devices. Tested with Pupil Invisible. + Based on Pupil Labs' Realtime Python API.""" + +""" +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later +version. +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +__author__ = "Damien Mouratille" +__credits__ = [] +__copyright__ = "Copyright 2024, Ecole Nationale de l'Aviation Civile (ENAC)" +__license__ = "GPLv3" + +import sys +import logging +import time + +import threading +from dataclasses import dataclass + +from argaze import ArFeatures, DataFeatures, GazeFeatures +from argaze.utils import UtilsFeatures + +import numpy +import cv2 + +from pupil_labs.realtime_api.simple import discover_one_device + + +class LiveStream(ArFeatures.DataCaptureContext): + + @DataFeatures.PipelineStepInit + def __init__(self, **kwargs): + + # Init DataCaptureContext class + super().__init__() + + def __enter__(self): + + logging.info('Pupil-Labs Invisible connexion starts...') + + # Init timestamp + self.__start_time = time.time() + + # Look for devices. Returns as soon as it has found the first device. + self.__device = discover_one_device(max_search_duration_seconds=10) + + if self.__device is None: + logging.info('No device found. Exit!') + raise SystemExit(-1) + else: + logging.info('Device found. Stream loading.') + + # Open gaze stream + self.__gaze_thread = threading.Thread(target=self.__stream_gaze) + + logging.debug('> starting gaze thread...') + + self.__gaze_thread.start() + + # Open video stream + self.__video_thread = threading.Thread(target=self.__stream_video) + + logging.debug('> starting video thread...') + + self.__video_thread.start() + + return self + + def __stream_gaze(self): + """Stream gaze.""" + + logging.debug('Stream gaze from Pupil Invisible') + + while self.is_running(): + + try: + while True: + gaze = self.__device.receive_gaze_datum() + + gaze_timestamp = int((gaze.timestamp_unix_seconds - self.__start_time) * 1e3) + + logging.debug('Gaze received at %i timestamp', gaze_timestamp) + + # When gaze position is valid + if gaze.worn is True: + + self._process_gaze_position( + timestamp=gaze_timestamp, + x=int(gaze.x), + y=int(gaze.y)) + else: + # Process empty gaze position + logging.debug('Not worn at %i timestamp', gaze_timestamp) + + self._process_gaze_position(timestamp=gaze_timestamp) + + except KeyboardInterrupt: + pass + + def __stream_video(self): + """Stream video.""" + + logging.debug('Stream video from Pupil Invisible') + + while self.is_running(): + + try: + while True: + scene_frame, frame_datetime = self.__device.receive_scene_video_frame() + + scene_timestamp = int((frame_datetime - self.__start_time) * 1e3) + + logging.debug('Video received at %i timestamp', scene_timestamp) + + self._process_camera_image( + timestamp=scene_timestamp, + image=scene_frame) + + except KeyboardInterrupt: + pass + + @DataFeatures.PipelineStepExit + def __exit__(self, exception_type, exception_value, exception_traceback): + + logging.debug('Pupil-Labs context stops...') + + # Close data stream + self.stop() + + # Stop streaming + threading.Thread.join(self.__gaze_thread) + threading.Thread.join(self.__video_thread) diff --git a/src/argaze/utils/contexts/TobiiProGlasses3.py b/src/argaze/utils/contexts/TobiiProGlasses3.py new file mode 100644 index 0000000..a53c095 --- /dev/null +++ b/src/argaze/utils/contexts/TobiiProGlasses3.py @@ -0,0 +1,128 @@ +"""Handle network connection to Tobii Pro G3 devices. + Based on Tobii Realtime Python API. + g3pylib must be installed. +""" + +""" +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later +version. +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +You should have received a copy of the GNU General Public License along with +this program. If not, see . + +""" + +__author__ = "Damien Mouratille" +__credits__ = [] +__copyright__ = "Copyright 2024, Ecole Nationale de l'Aviation Civile (ENAC)" +__license__ = "GPLv3" + +import sys +import logging +import time +import dill +import threading +from dataclasses import dataclass +import numpy +import cv2 +import asyncio +import os + +from argaze import ArFeatures, DataFeatures, GazeFeatures +from argaze.utils import UtilsFeatures + + +from g3pylib import connect_to_glasses + + +class LiveStream(ArFeatures.DataCaptureContext): + + @DataFeatures.PipelineStepInit + def __init__(self, **kwargs): + + # Init DataCaptureContext class + super().__init__() + + def __enter__(self): + + logging.info('Tobii Pro G3 connexion starts...') + + # Init timestamp + self.__start_time = time.time() + + self.__loop = asyncio.new_event_loop() + self.__loop.run_until_complete(self.__stream_rtsp()) + + return self + + async def __stream_rtsp(self): + """Stream video and gaze.""" + + logging.info('Stream gaze from Tobii Pro G3') + + while self.is_running(): + + try: + async with connect_to_glasses.with_zeroconf(True,10000) as g3: + async with g3.stream_rtsp(scene_camera=True, gaze=True) as streams: + async with streams.gaze.decode() as gaze_stream, streams.scene_camera.decode() as scene_stream: + while True: + frame, frame_timestamp = await scene_stream.get() + gaze, gaze_timestamp = await gaze_stream.get() + while gaze_timestamp is None or frame_timestamp is None: + if frame_timestamp is None: + frame, frame_timestamp = await scene_stream.get() + if gaze_timestamp is None: + gaze, gaze_timestamp = await gaze_stream.get() + while gaze_timestamp < frame_timestamp: + gaze, gaze_timestamp = await gaze_stream.get() + while gaze_timestamp is None: + gaze, gaze_timestamp = await gaze_stream.get() + + scene_frame = frame.to_ndarray(format="bgr24") + + gaze_timestamp = int((gaze_timestamp - self.__start_time) * 1e3) + + logging.debug('Gaze received at %i timestamp', gaze_timestamp) + + # If given gaze data + if "gaze2d" in gaze: + gaze2d = gaze["gaze2d"] + # Convert rational (x,y) to pixel location (x,y) + h, w = scene_frame.shape[:2] + gaze_scene = (int(gaze2d[0] * w), int(gaze2d[1] * h)) + + + self._process_gaze_position( + timestamp=gaze_timestamp, + x=gaze_scene[0], + y=gaze_scene[1]) + else: + # Process empty gaze position + logging.debug('Not worn at %i timestamp', gaze_timestamp) + + scene_timestamp = int((frame_timestamp - self.__start_time) * 1e3) + + logging.debug('Video received at %i timestamp', scene_timestamp) + + self._process_camera_image( + timestamp=scene_timestamp, + image=scene_frame) + + except KeyboardInterrupt: + pass + + + + @DataFeatures.PipelineStepExit + def __exit__(self, exception_type, exception_value, exception_traceback): + + logging.debug('Tobii Pro G3 context stops...') + + # Close data stream + self.stop() + diff --git a/src/argaze/utils/demo/aruco_markers_pipeline.json b/src/argaze/utils/demo/aruco_markers_pipeline.json index 0681bc3..8221cec 100644 --- a/src/argaze/utils/demo/aruco_markers_pipeline.json +++ b/src/argaze/utils/demo/aruco_markers_pipeline.json @@ -1,7 +1,7 @@ { "argaze.ArUcoMarker.ArUcoCamera.ArUcoCamera": { "name": "Head-mounted camera", - "size": [1920, 1080], + "size": [1088, 1080], "copy_background_into_scenes_frames": true, "aruco_detector": { "dictionary": "DICT_APRILTAG_16h5", @@ -56,7 +56,7 @@ }, "frames": { "GrayRectangle": { - "size": [1920, 1149], + "size": [1088, 1080], "background": "frame_background.jpg", "gaze_movement_identifier": { "argaze.GazeAnalysis.DispersionThresholdIdentification.GazeMovementIdentifier": { @@ -71,17 +71,12 @@ "argaze.GazeAnalysis.Basic.ScanPathAnalyzer": {}, "argaze.GazeAnalysis.KCoefficient.ScanPathAnalyzer": {}, "argaze.GazeAnalysis.NearestNeighborIndex.ScanPathAnalyzer": { - "size": [1920, 1149] + "size": [1088, 1080] }, "argaze.GazeAnalysis.ExploreExploitRatio.ScanPathAnalyzer": { "short_fixation_duration_threshold": 0 } }, - "observers": { - "recorders.ScanPathAnalysisRecorder": { - "path": "_export/records/scan_path_metrics.csv" - } - }, "layers": { "demo_layer": { "aoi_scene": "aoi_2d_scene.json", @@ -101,11 +96,6 @@ "n_max": 3 }, "argaze.GazeAnalysis.Entropy.AOIScanPathAnalyzer":{} - }, - "observers": { - "recorders.AOIScanPathAnalysisRecorder": { - "path": "_export/records/aoi_scan_path_metrics.csv" - } } } }, @@ -152,14 +142,6 @@ } } } - }, - "observers": { - "argaze.utils.UtilsFeatures.LookPerformanceRecorder": { - "path": "_export/records/look_performance.csv" - }, - "argaze.utils.UtilsFeatures.WatchPerformanceRecorder": { - "path": "_export/records/watch_performance.csv" - } } } } \ No newline at end of file diff --git a/src/argaze/utils/demo/gaze_analysis_pipeline.json b/src/argaze/utils/demo/gaze_analysis_pipeline.json index 8b8212e..6e23321 100644 --- a/src/argaze/utils/demo/gaze_analysis_pipeline.json +++ b/src/argaze/utils/demo/gaze_analysis_pipeline.json @@ -1,7 +1,7 @@ { "argaze.ArFeatures.ArFrame": { "name": "GrayRectangle", - "size": [1920, 1149], + "size": [1088, 1080], "background": "frame_background.jpg", "gaze_movement_identifier": { "argaze.GazeAnalysis.DispersionThresholdIdentification.GazeMovementIdentifier": { diff --git a/src/argaze/utils/demo/pupillabs_invisible_live_stream_context.json b/src/argaze/utils/demo/pupillabs_invisible_live_stream_context.json new file mode 100644 index 0000000..3418de6 --- /dev/null +++ b/src/argaze/utils/demo/pupillabs_invisible_live_stream_context.json @@ -0,0 +1,6 @@ +{ + "argaze.utils.contexts.PupilLabsInvisible.LiveStream" : { + "name": "PupilLabs Invisible", + "pipeline": "aruco_markers_pipeline.json" + } +} \ No newline at end of file diff --git a/src/argaze/utils/demo/pupillabs_live_stream_context.json b/src/argaze/utils/demo/pupillabs_live_stream_context.json deleted file mode 100644 index bcb7263..0000000 --- a/src/argaze/utils/demo/pupillabs_live_stream_context.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "argaze.utils.contexts.PupilLabs.LiveStream" : { - "name": "PupilLabs", - "pipeline": "aruco_markers_pipeline.json" - } -} \ No newline at end of file diff --git a/src/argaze/utils/demo/tobii_g2_live_stream_context.json b/src/argaze/utils/demo/tobii_g2_live_stream_context.json new file mode 100644 index 0000000..6950617 --- /dev/null +++ b/src/argaze/utils/demo/tobii_g2_live_stream_context.json @@ -0,0 +1,18 @@ +{ + "argaze.utils.contexts.TobiiProGlasses2.LiveStream" : { + "name": "Tobii Pro Glasses 2 live stream", + "address": "10.34.0.17", + "project": "MyProject", + "participant": "NewParticipant", + "configuration": { + "sys_ec_preset": "Indoor", + "sys_sc_width": 1920, + "sys_sc_height": 1080, + "sys_sc_fps": 25, + "sys_sc_preset": "Auto", + "sys_et_freq": 50, + "sys_mems_freq": 100 + }, + "pipeline": "aruco_markers_pipeline.json" + } +} \ No newline at end of file diff --git a/src/argaze/utils/demo/tobii_g3_live_stream_context.json b/src/argaze/utils/demo/tobii_g3_live_stream_context.json new file mode 100644 index 0000000..20f6ab1 --- /dev/null +++ b/src/argaze/utils/demo/tobii_g3_live_stream_context.json @@ -0,0 +1,6 @@ +{ + "argaze.utils.contexts.TobiiProGlasses3.LiveStream" : { + "name": "Tobii Pro Glasses 3 live stream", + "pipeline": "aruco_markers_pipeline.json" + } +} \ No newline at end of file diff --git a/src/argaze/utils/demo/tobii_live_stream_context.json b/src/argaze/utils/demo/tobii_live_stream_context.json deleted file mode 100644 index 6950617..0000000 --- a/src/argaze/utils/demo/tobii_live_stream_context.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "argaze.utils.contexts.TobiiProGlasses2.LiveStream" : { - "name": "Tobii Pro Glasses 2 live stream", - "address": "10.34.0.17", - "project": "MyProject", - "participant": "NewParticipant", - "configuration": { - "sys_ec_preset": "Indoor", - "sys_sc_width": 1920, - "sys_sc_height": 1080, - "sys_sc_fps": 25, - "sys_sc_preset": "Auto", - "sys_et_freq": 50, - "sys_mems_freq": 100 - }, - "pipeline": "aruco_markers_pipeline.json" - } -} \ No newline at end of file -- cgit v1.1 From e7cace52a6c1e0af88b715fce99c46fd48aa8bcc Mon Sep 17 00:00:00 2001 From: Damien Mouratille Date: Fri, 23 Aug 2024 15:35:37 +0200 Subject: Add Pupil Labs Neon device --- .../context_modules/pupil_labs.md | 32 ----- .../context_modules/pupil_labs_invisible.md | 32 +++++ .../context_modules/pupil_labs_neon.md | 32 +++++ .../context_modules/tobii_pro_glasses_3.md | 32 +++++ mkdocs.yml | 4 +- src/argaze/utils/contexts/PupilLabsNeon.py | 140 +++++++++++++++++++++ src/argaze/utils/demo/aruco_markers_pipeline.json | 6 +- src/argaze/utils/demo/gaze_analysis_pipeline.json | 6 +- .../demo/pupillabs_neon_live_stream_context.json | 6 + 9 files changed, 251 insertions(+), 39 deletions(-) delete mode 100644 docs/user_guide/eye_tracking_context/context_modules/pupil_labs.md create mode 100644 docs/user_guide/eye_tracking_context/context_modules/pupil_labs_invisible.md create mode 100644 docs/user_guide/eye_tracking_context/context_modules/pupil_labs_neon.md create mode 100644 docs/user_guide/eye_tracking_context/context_modules/tobii_pro_glasses_3.md create mode 100644 src/argaze/utils/contexts/PupilLabsNeon.py create mode 100644 src/argaze/utils/demo/pupillabs_neon_live_stream_context.json diff --git a/docs/user_guide/eye_tracking_context/context_modules/pupil_labs.md b/docs/user_guide/eye_tracking_context/context_modules/pupil_labs.md deleted file mode 100644 index d2ec336..0000000 --- a/docs/user_guide/eye_tracking_context/context_modules/pupil_labs.md +++ /dev/null @@ -1,32 +0,0 @@ -Pupil Labs -========== - -ArGaze provides a ready-made context to work with Pupil Labs devices. - -To select a desired context, the JSON samples have to be edited and saved inside an [ArContext configuration](../configuration_and_execution.md) file. -Notice that the *pipeline* entry is mandatory. - -```json -{ - JSON sample - "pipeline": ... -} -``` - -Read more about [ArContext base class in code reference](../../../argaze.md/#argaze.ArFeatures.ArContext). - -## Live Stream - -::: argaze.utils.contexts.PupilLabs.LiveStream - -### JSON sample - -```json -{ - "argaze.utils.contexts.PupilLabs.LiveStream": { - "name": "Pupil Labs live stream", - "project": "my_experiment", - "pipeline": ... - } -} -``` diff --git a/docs/user_guide/eye_tracking_context/context_modules/pupil_labs_invisible.md b/docs/user_guide/eye_tracking_context/context_modules/pupil_labs_invisible.md new file mode 100644 index 0000000..1f4a94f --- /dev/null +++ b/docs/user_guide/eye_tracking_context/context_modules/pupil_labs_invisible.md @@ -0,0 +1,32 @@ +Pupil Labs Invisible +========== + +ArGaze provides a ready-made context to work with Pupil Labs Invisible device. + +To select a desired context, the JSON samples have to be edited and saved inside an [ArContext configuration](../configuration_and_execution.md) file. +Notice that the *pipeline* entry is mandatory. + +```json +{ + JSON sample + "pipeline": ... +} +``` + +Read more about [ArContext base class in code reference](../../../argaze.md/#argaze.ArFeatures.ArContext). + +## Live Stream + +::: argaze.utils.contexts.PupilLabsInvisible.LiveStream + +### JSON sample + +```json +{ + "argaze.utils.contexts.PupilLabsInvisible.LiveStream": { + "name": "Pupil Labs Invisible live stream", + "project": "my_experiment", + "pipeline": ... + } +} +``` diff --git a/docs/user_guide/eye_tracking_context/context_modules/pupil_labs_neon.md b/docs/user_guide/eye_tracking_context/context_modules/pupil_labs_neon.md new file mode 100644 index 0000000..535f5d5 --- /dev/null +++ b/docs/user_guide/eye_tracking_context/context_modules/pupil_labs_neon.md @@ -0,0 +1,32 @@ +Pupil Labs Neon +========== + +ArGaze provides a ready-made context to work with Pupil Labs Neon device. + +To select a desired context, the JSON samples have to be edited and saved inside an [ArContext configuration](../configuration_and_execution.md) file. +Notice that the *pipeline* entry is mandatory. + +```json +{ + JSON sample + "pipeline": ... +} +``` + +Read more about [ArContext base class in code reference](../../../argaze.md/#argaze.ArFeatures.ArContext). + +## Live Stream + +::: argaze.utils.contexts.PupilLabsNeon.LiveStream + +### JSON sample + +```json +{ + "argaze.utils.contexts.PupilLabsNeon.LiveStream": { + "name": "Pupil Labs Neon live stream", + "project": "my_experiment", + "pipeline": ... + } +} +``` diff --git a/docs/user_guide/eye_tracking_context/context_modules/tobii_pro_glasses_3.md b/docs/user_guide/eye_tracking_context/context_modules/tobii_pro_glasses_3.md new file mode 100644 index 0000000..3d37fcc --- /dev/null +++ b/docs/user_guide/eye_tracking_context/context_modules/tobii_pro_glasses_3.md @@ -0,0 +1,32 @@ +Tobii Pro Glasses 3 +=================== + +ArGaze provides a ready-made context to work with Tobii Pro Glasses 3 devices. + +To select a desired context, the JSON samples have to be edited and saved inside an [ArContext configuration](../configuration_and_execution.md) file. +Notice that the *pipeline* entry is mandatory. + +```json +{ + JSON sample + "pipeline": ... +} +``` + +Read more about [ArContext base class in code reference](../../../argaze.md/#argaze.ArFeatures.ArContext). + +## Live Stream + +::: argaze.utils.contexts.TobiiProGlasses3.LiveStream + +### JSON sample + +```json +{ + "argaze.utils.contexts.TobiiProGlasses3.LiveStream": { + "name": "Tobii Pro Glasses 3 live stream", + "pipeline": ... + } +} +``` + diff --git a/mkdocs.yml b/mkdocs.yml index 8aadb7d..c35df15 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -9,7 +9,9 @@ nav: - user_guide/eye_tracking_context/configuration_and_execution.md - Context Modules: - user_guide/eye_tracking_context/context_modules/tobii_pro_glasses_2.md - - user_guide/eye_tracking_context/context_modules/pupil_labs.md + - user_guide/eye_tracking_context/context_modules/tobii_pro_glasses_3.md + - user_guide/eye_tracking_context/context_modules/pupil_lab_invisible.md + - user_guide/eye_tracking_context/context_modules/pupil_lab_neon.md - user_guide/eye_tracking_context/context_modules/opencv.md - user_guide/eye_tracking_context/context_modules/random.md - Advanced Topics: diff --git a/src/argaze/utils/contexts/PupilLabsNeon.py b/src/argaze/utils/contexts/PupilLabsNeon.py new file mode 100644 index 0000000..e7d1f47 --- /dev/null +++ b/src/argaze/utils/contexts/PupilLabsNeon.py @@ -0,0 +1,140 @@ +"""Handle network connection to Pupil Labs devices. Tested with Pupil Neon. + Based on Pupil Labs' Realtime Python API.""" + +""" +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later +version. +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +__author__ = "Damien Mouratille" +__credits__ = [] +__copyright__ = "Copyright 2024, Ecole Nationale de l'Aviation Civile (ENAC)" +__license__ = "GPLv3" + +import sys +import logging +import time + +import threading +from dataclasses import dataclass + +from argaze import ArFeatures, DataFeatures, GazeFeatures +from argaze.utils import UtilsFeatures + +import numpy +import cv2 + +from pupil_labs.realtime_api.simple import discover_one_device + + +class LiveStream(ArFeatures.DataCaptureContext): + + @DataFeatures.PipelineStepInit + def __init__(self, **kwargs): + + # Init DataCaptureContext class + super().__init__() + + def __enter__(self): + + logging.info('Pupil-Labs Neon connexion starts...') + + # Init timestamp + self.__start_time = time.time() + + # Look for devices. Returns as soon as it has found the first device. + self.__device = discover_one_device(max_search_duration_seconds=10) + + if self.__device is None: + logging.info('No device found. Exit!') + raise SystemExit(-1) + else: + logging.info('Device found. Stream loading.') + + # Open gaze stream + self.__gaze_thread = threading.Thread(target=self.__stream_gaze) + + logging.debug('> starting gaze thread...') + + self.__gaze_thread.start() + + # Open video stream + self.__video_thread = threading.Thread(target=self.__stream_video) + + logging.debug('> starting video thread...') + + self.__video_thread.start() + + return self + + def __stream_gaze(self): + """Stream gaze.""" + + logging.debug('Stream gaze from Pupil Neon') + + while self.is_running(): + + try: + while True: + gaze = self.__device.receive_gaze_datum() + + gaze_timestamp = int((gaze.timestamp_unix_seconds - self.__start_time) * 1e3) + + logging.debug('Gaze received at %i timestamp', gaze_timestamp) + + # When gaze position is valid + if gaze.worn is True: + + self._process_gaze_position( + timestamp=gaze_timestamp, + x=int(gaze.x), + y=int(gaze.y)) + else: + # Process empty gaze position + logging.debug('Not worn at %i timestamp', gaze_timestamp) + + self._process_gaze_position(timestamp=gaze_timestamp) + + except KeyboardInterrupt: + pass + + def __stream_video(self): + """Stream video.""" + + logging.debug('Stream video from Pupil Neon') + + while self.is_running(): + + try: + while True: + scene_frame, frame_datetime = self.__device.receive_scene_video_frame() + + scene_timestamp = int((frame_datetime - self.__start_time) * 1e3) + + logging.debug('Video received at %i timestamp', scene_timestamp) + + self._process_camera_image( + timestamp=scene_timestamp, + image=scene_frame) + + except KeyboardInterrupt: + pass + + @DataFeatures.PipelineStepExit + def __exit__(self, exception_type, exception_value, exception_traceback): + + logging.debug('Pupil-Labs context stops...') + + # Close data stream + self.stop() + + # Stop streaming + threading.Thread.join(self.__gaze_thread) + threading.Thread.join(self.__video_thread) diff --git a/src/argaze/utils/demo/aruco_markers_pipeline.json b/src/argaze/utils/demo/aruco_markers_pipeline.json index 8221cec..9dc8327 100644 --- a/src/argaze/utils/demo/aruco_markers_pipeline.json +++ b/src/argaze/utils/demo/aruco_markers_pipeline.json @@ -1,7 +1,7 @@ { "argaze.ArUcoMarker.ArUcoCamera.ArUcoCamera": { "name": "Head-mounted camera", - "size": [1088, 1080], + "size": [1600, 1200], "copy_background_into_scenes_frames": true, "aruco_detector": { "dictionary": "DICT_APRILTAG_16h5", @@ -56,7 +56,7 @@ }, "frames": { "GrayRectangle": { - "size": [1088, 1080], + "size": [1600, 1200], "background": "frame_background.jpg", "gaze_movement_identifier": { "argaze.GazeAnalysis.DispersionThresholdIdentification.GazeMovementIdentifier": { @@ -71,7 +71,7 @@ "argaze.GazeAnalysis.Basic.ScanPathAnalyzer": {}, "argaze.GazeAnalysis.KCoefficient.ScanPathAnalyzer": {}, "argaze.GazeAnalysis.NearestNeighborIndex.ScanPathAnalyzer": { - "size": [1088, 1080] + "size": [1600, 1200] }, "argaze.GazeAnalysis.ExploreExploitRatio.ScanPathAnalyzer": { "short_fixation_duration_threshold": 0 diff --git a/src/argaze/utils/demo/gaze_analysis_pipeline.json b/src/argaze/utils/demo/gaze_analysis_pipeline.json index 6e23321..cc182ce 100644 --- a/src/argaze/utils/demo/gaze_analysis_pipeline.json +++ b/src/argaze/utils/demo/gaze_analysis_pipeline.json @@ -1,7 +1,7 @@ { "argaze.ArFeatures.ArFrame": { "name": "GrayRectangle", - "size": [1088, 1080], + "size": [1600, 1200], "background": "frame_background.jpg", "gaze_movement_identifier": { "argaze.GazeAnalysis.DispersionThresholdIdentification.GazeMovementIdentifier": { @@ -126,8 +126,8 @@ }, "recorders.FrameImageRecorder": { "path": "_export/records/video.mp4", - "width": 1920, - "height": 1080, + "width": 1600, + "height": 1200, "fps": 15 } } diff --git a/src/argaze/utils/demo/pupillabs_neon_live_stream_context.json b/src/argaze/utils/demo/pupillabs_neon_live_stream_context.json new file mode 100644 index 0000000..a87c30e --- /dev/null +++ b/src/argaze/utils/demo/pupillabs_neon_live_stream_context.json @@ -0,0 +1,6 @@ +{ + "argaze.utils.contexts.PupilLabsNeon.LiveStream" : { + "name": "PupilLabs Neon", + "pipeline": "aruco_markers_pipeline.json" + } +} \ No newline at end of file -- cgit v1.1 From 84fd53edaa66cda11e4e47abce3fbe7cc7ace657 Mon Sep 17 00:00:00 2001 From: Damien Mouratille Date: Fri, 23 Aug 2024 17:04:14 +0200 Subject: update docs --- mkdocs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/mkdocs.yml b/mkdocs.yml index c35df15..6384ae0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -10,8 +10,8 @@ nav: - Context Modules: - user_guide/eye_tracking_context/context_modules/tobii_pro_glasses_2.md - user_guide/eye_tracking_context/context_modules/tobii_pro_glasses_3.md - - user_guide/eye_tracking_context/context_modules/pupil_lab_invisible.md - - user_guide/eye_tracking_context/context_modules/pupil_lab_neon.md + - user_guide/eye_tracking_context/context_modules/pupil_labs_invisible.md + - user_guide/eye_tracking_context/context_modules/pupil_labs_neon.md - user_guide/eye_tracking_context/context_modules/opencv.md - user_guide/eye_tracking_context/context_modules/random.md - Advanced Topics: -- cgit v1.1 From 1499b232aa7ecda8789da3eab33a47f4675307d3 Mon Sep 17 00:00:00 2001 From: Théo de la Hogue Date: Tue, 3 Sep 2024 09:55:49 +0200 Subject: Fixing configuration and execution context documentation. --- docs/user_guide/eye_tracking_context/configuration_and_execution.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/user_guide/eye_tracking_context/configuration_and_execution.md b/docs/user_guide/eye_tracking_context/configuration_and_execution.md index e1123fb..100ab5e 100644 --- a/docs/user_guide/eye_tracking_context/configuration_and_execution.md +++ b/docs/user_guide/eye_tracking_context/configuration_and_execution.md @@ -4,7 +4,9 @@ Edit and execute context The [utils.contexts module](../../argaze.md/#argaze.utils.contexts) provides ready-made contexts like: * [Tobii Pro Glasses 2](context_modules/tobii_pro_glasses_2.md) data capture and data playback contexts, -* [Pupil Labs](context_modules/pupil_labs.md) data capture context, +* [Tobii Pro Glasses 3](context_modules/tobii_pro_glasses_3.md) data capture context, +* [Pupil Labs Invisible](context_modules/pupil_labs_invisible.md) data capture context, +* [Pupil Labs Neon](context_modules/pupil_labs_neon.md) data capture context, * [OpenCV](context_modules/opencv.md) window cursor position capture and movie playback, * [Random](context_modules/random.md) gaze position generator. -- cgit v1.1 From 3a127db0fd11634c792e8ca545453fc22bf8eb9e Mon Sep 17 00:00:00 2001 From: Théo de la Hogue Date: Tue, 3 Sep 2024 11:13:33 +0200 Subject: Setting aruco_markers_pipeline.json size to 1920, 1080. Changing GrayRectangle size to 1200, 720 and updating aoi. --- src/argaze/utils/demo/aoi_2d_scene.json | 16 ++++++++-------- src/argaze/utils/demo/aruco_markers_pipeline.json | 14 +++----------- src/argaze/utils/demo/gaze_analysis_pipeline.json | 4 ++-- src/argaze/utils/demo/random_context.json | 2 +- 4 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/argaze/utils/demo/aoi_2d_scene.json b/src/argaze/utils/demo/aoi_2d_scene.json index ac58b63..76ff1bc 100644 --- a/src/argaze/utils/demo/aoi_2d_scene.json +++ b/src/argaze/utils/demo/aoi_2d_scene.json @@ -1,18 +1,18 @@ { - "BlueTriangle":[[960, 664], [1113, 971], [806, 971]], + "BlueTriangle":[[602, 415], [697, 607], [506, 607]], "RedSquare": { "Rectangle": { - "x": 268, - "y": 203, - "width": 308, - "height": 308 + "x": 170, + "y": 127, + "width": 191, + "height": 191 } }, "GreenCircle": { "Circle": { - "cx": 1497, - "cy": 356, - "radius": 153 + "cx": 937, + "cy": 223, + "radius": 95 } } } \ No newline at end of file diff --git a/src/argaze/utils/demo/aruco_markers_pipeline.json b/src/argaze/utils/demo/aruco_markers_pipeline.json index e8d85eb..11db858 100644 --- a/src/argaze/utils/demo/aruco_markers_pipeline.json +++ b/src/argaze/utils/demo/aruco_markers_pipeline.json @@ -1,7 +1,7 @@ { "argaze.ArUcoMarker.ArUcoCamera.ArUcoCamera": { "name": "Head-mounted camera", - "size": [1088, 1080], + "size": [1920, 1080], "copy_background_into_scenes_frames": true, "aruco_detector": { "dictionary": "DICT_APRILTAG_16h5", @@ -56,11 +56,7 @@ }, "frames": { "GrayRectangle": { -<<<<<<< HEAD - "size": [1088, 1080], -======= - "size": [1600, 1200], ->>>>>>> dev/G3andNeon + "size": [1200, 720], "background": "frame_background.jpg", "gaze_movement_identifier": { "argaze.GazeAnalysis.DispersionThresholdIdentification.GazeMovementIdentifier": { @@ -75,11 +71,7 @@ "argaze.GazeAnalysis.Basic.ScanPathAnalyzer": {}, "argaze.GazeAnalysis.KCoefficient.ScanPathAnalyzer": {}, "argaze.GazeAnalysis.NearestNeighborIndex.ScanPathAnalyzer": { -<<<<<<< HEAD - "size": [1088, 1080] -======= - "size": [1600, 1200] ->>>>>>> dev/G3andNeon + "size": [1200, 720] }, "argaze.GazeAnalysis.ExploreExploitRatio.ScanPathAnalyzer": { "short_fixation_duration_threshold": 0 diff --git a/src/argaze/utils/demo/gaze_analysis_pipeline.json b/src/argaze/utils/demo/gaze_analysis_pipeline.json index e0f99a8..8d42747 100644 --- a/src/argaze/utils/demo/gaze_analysis_pipeline.json +++ b/src/argaze/utils/demo/gaze_analysis_pipeline.json @@ -1,7 +1,7 @@ { "argaze.ArFeatures.ArFrame": { "name": "GrayRectangle", - "size": [1088, 1080], + "size": [1200, 720], "background": "frame_background.jpg", "gaze_movement_identifier": { "argaze.GazeAnalysis.DispersionThresholdIdentification.GazeMovementIdentifier": { @@ -17,7 +17,7 @@ "argaze.GazeAnalysis.Basic.ScanPathAnalyzer": {}, "argaze.GazeAnalysis.KCoefficient.ScanPathAnalyzer": {}, "argaze.GazeAnalysis.NearestNeighborIndex.ScanPathAnalyzer": { - "size": [1920, 1149] + "size": [1200, 720] }, "argaze.GazeAnalysis.ExploreExploitRatio.ScanPathAnalyzer": { "short_fixation_duration_threshold": 0 diff --git a/src/argaze/utils/demo/random_context.json b/src/argaze/utils/demo/random_context.json index 7f33579..62abb91 100644 --- a/src/argaze/utils/demo/random_context.json +++ b/src/argaze/utils/demo/random_context.json @@ -1,7 +1,7 @@ { "argaze.utils.contexts.Random.GazePositionGenerator" : { "name": "Random gaze position generator", - "range": [1920, 1149], + "range": [1200, 720], "pipeline": "gaze_analysis_pipeline.json" } } \ No newline at end of file -- cgit v1.1 From 2abf3e98b3c89b68d594d172567119a341d5923c Mon Sep 17 00:00:00 2001 From: Théo de la Hogue Date: Tue, 3 Sep 2024 12:26:07 +0200 Subject: Renaming context file for tobii g2 segment playback demo. --- src/argaze/utils/demo/tobii_g2_segment_playback_context.json | 7 +++++++ src/argaze/utils/demo/tobii_segment_playback_context.json | 7 ------- 2 files changed, 7 insertions(+), 7 deletions(-) create mode 100644 src/argaze/utils/demo/tobii_g2_segment_playback_context.json delete mode 100644 src/argaze/utils/demo/tobii_segment_playback_context.json diff --git a/src/argaze/utils/demo/tobii_g2_segment_playback_context.json b/src/argaze/utils/demo/tobii_g2_segment_playback_context.json new file mode 100644 index 0000000..d481b23 --- /dev/null +++ b/src/argaze/utils/demo/tobii_g2_segment_playback_context.json @@ -0,0 +1,7 @@ +{ + "argaze.utils.contexts.TobiiProGlasses2.SegmentPlayback" : { + "name": "Tobii Pro Glasses 2 segment playback", + "segment": "./src/argaze/utils/demo/tobii_record/segments/1", + "pipeline": "aruco_markers_pipeline.json" + } +} \ No newline at end of file diff --git a/src/argaze/utils/demo/tobii_segment_playback_context.json b/src/argaze/utils/demo/tobii_segment_playback_context.json deleted file mode 100644 index d481b23..0000000 --- a/src/argaze/utils/demo/tobii_segment_playback_context.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "argaze.utils.contexts.TobiiProGlasses2.SegmentPlayback" : { - "name": "Tobii Pro Glasses 2 segment playback", - "segment": "./src/argaze/utils/demo/tobii_record/segments/1", - "pipeline": "aruco_markers_pipeline.json" - } -} \ No newline at end of file -- cgit v1.1 From a3eb35c5a3725ba589663c6a8992b48b6743eb11 Mon Sep 17 00:00:00 2001 From: Théo de la Hogue Date: Tue, 3 Sep 2024 12:27:18 +0200 Subject: Explaining how to setup aruco_markers_pipeline.json file depending on the context. --- docs/user_guide/utils/demonstrations_scripts.md | 55 +++++++++++++++++++++---- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/docs/user_guide/utils/demonstrations_scripts.md b/docs/user_guide/utils/demonstrations_scripts.md index 59df85b..277f380 100644 --- a/docs/user_guide/utils/demonstrations_scripts.md +++ b/docs/user_guide/utils/demonstrations_scripts.md @@ -40,7 +40,7 @@ python -m argaze load ./src/argaze/utils/demo/opencv_movie_context.json ### Camera context -Edit **aruco_markers_pipeline.json** file as to adapt the *size* to the camera resolution and to reduce the value of the *sides_mask*. +Edit **aruco_markers_pipeline.json** file as to adapt the *size* to the camera resolution and to set a consistent *sides_mask* value. Edit **opencv_camera_context.json** file as to select camera device identifier (default is 0). @@ -57,7 +57,9 @@ python -m argaze load ./src/argaze/utils/demo/opencv_camera_context.json !!! note This demonstration requires to print **A3_demo.pdf** file located in *./src/argaze/utils/demo/* folder on A3 paper sheet. -Edit **tobii_live_stream_context.json** file as to select exisiting IP *address*, *project* or *participant* names and setup Tobii *configuration* parameters: +Edit **aruco_markers_pipeline.json** file as to adapt the *size* to the camera resolution ([1920, 1080]) and to set *sides_mask* value to 420. + +Edit **tobii_g2_live_stream_context.json** file as to select exisiting IP *address*, *project* or *participant* names and setup Tobii *configuration* parameters: ```json { @@ -80,15 +82,17 @@ Edit **tobii_live_stream_context.json** file as to select exisiting IP *address* } ``` -Then, load **tobii_live_stream_context.json** file to find ArUco marker into camera image and, project gaze positions into AOI: +Then, load **tobii_g2_live_stream_context.json** file to find ArUco marker into camera image and, project gaze positions into AOI: ```shell -python -m argaze load ./src/argaze/utils/demo/tobii_live_stream_context.json +python -m argaze load ./src/argaze/utils/demo/tobii_g2_live_stream_context.json ``` ### Segment playback context -Edit **tobii_segment_playback_context.json** file to select an existing Tobii *segment* folder: +Edit **aruco_markers_pipeline.json** file as to adapt the *size* to the camera resolution ([1920, 1080]) and to set *sides_mask* value to 420. + +Edit **tobii_g2_segment_playback_context.json** file to select an existing Tobii *segment* folder: ```json { @@ -100,12 +104,28 @@ Edit **tobii_segment_playback_context.json** file to select an existing Tobii *s } ``` -Then, load **tobii_segment_playback_context.json** file to find ArUco marker into camera image and, project gaze positions into AOI: +Then, load **tobii_g2_segment_playback_context.json** file to find ArUco marker into camera image and, project gaze positions into AOI: + +```shell +python -m argaze load ./src/argaze/utils/demo/tobii_g2_segment_playback_context.json +``` + +## Tobii Pro Glasses 3 + +### Live stream context + +!!! note + This demonstration requires to print **A3_demo.pdf** file located in *./src/argaze/utils/demo/* folder on A3 paper sheet. + +Edit **aruco_markers_pipeline.json** file as to adapt the *size* to the camera resolution ([1920, 1080]) and to set *sides_mask* value to 420. + +Load **tobii_g3_live_stream_context.json** file to find ArUco marker into camera image and, project gaze positions into AOI: ```shell -python -m argaze load ./src/argaze/utils/demo/tobii_segment_playback_context.json +python -m argaze load ./src/argaze/utils/demo/tobii_g3_live_stream_context.json ``` + ## Pupil Invisible ### Live stream context @@ -113,8 +133,25 @@ python -m argaze load ./src/argaze/utils/demo/tobii_segment_playback_context.jso !!! note This demonstration requires to print **A3_demo.pdf** file located in *./src/argaze/utils/demo/* folder on A3 paper sheet. -Load **pupillabs_live_stream_context.json** file to find ArUco marker into camera image and, project gaze positions into AOI: +Edit **aruco_markers_pipeline.json** file as to adapt the *size* to the camera resolution ([1088, 1080]) and to set *sides_mask* value to 4. + +Load **pupillabs_invisible_live_stream_context.json** file to find ArUco marker into camera image and, project gaze positions into AOI: + +```shell +python -m argaze load ./src/argaze/utils/demo/pupillabs_invisible_live_stream_context.json +``` + +## Pupil Neon + +### Live stream context + +!!! note + This demonstration requires to print **A3_demo.pdf** file located in *./src/argaze/utils/demo/* folder on A3 paper sheet. + +Edit **aruco_markers_pipeline.json** file as to adapt the *size* to the camera resolution ([1600, 1200]) and to set *sides_mask* value to 200. + +Load **pupillabs_neon_live_stream_context.json** file to find ArUco marker into camera image and, project gaze positions into AOI: ```shell -python -m argaze load ./src/argaze/utils/demo/pupillabs_live_stream_context.json +python -m argaze load ./src/argaze/utils/demo/pupillabs_neon_live_stream_context.json ``` -- cgit v1.1 From 3aa00e234e122f3cedffdc21c00a430fee7984a8 Mon Sep 17 00:00:00 2001 From: Théo de la Hogue Date: Tue, 3 Sep 2024 14:44:45 +0200 Subject: Adding new CSV file context. --- docs/user_guide/utils/demonstrations_scripts.md | 8 + src/argaze/utils/contexts/File.py | 213 +++++ src/argaze/utils/demo/csv_file_context.json | 10 + src/argaze/utils/demo/gaze_positions.csv | 1052 +++++++++++++++++++++++ 4 files changed, 1283 insertions(+) create mode 100644 src/argaze/utils/contexts/File.py create mode 100644 src/argaze/utils/demo/csv_file_context.json create mode 100644 src/argaze/utils/demo/gaze_positions.csv diff --git a/docs/user_guide/utils/demonstrations_scripts.md b/docs/user_guide/utils/demonstrations_scripts.md index 277f380..e55e547 100644 --- a/docs/user_guide/utils/demonstrations_scripts.md +++ b/docs/user_guide/utils/demonstrations_scripts.md @@ -20,6 +20,14 @@ Load **random_context.json** file to generate random gaze positions: python -m argaze load ./src/argaze/utils/demo/random_context.json ``` +## CSV file context + +Load **csv_file_context.json** file to analyze gaze positions from a CSV file: + +```shell +python -m argaze load ./src/argaze/utils/demo/csv_file_context.json +``` + ## OpenCV ### Cursor context diff --git a/src/argaze/utils/contexts/File.py b/src/argaze/utils/contexts/File.py new file mode 100644 index 0000000..91c64e2 --- /dev/null +++ b/src/argaze/utils/contexts/File.py @@ -0,0 +1,213 @@ +"""Define eye tracking data file context""" + +""" +This program is free software: you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation, either version 3 of the License, or (at your option) any later +version. +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +__author__ = "Théo de la Hogue" +__credits__ = [] +__copyright__ = "Copyright 2023, Ecole Nationale de l'Aviation Civile (ENAC)" +__license__ = "GPLv3" + +import logging +import time +import math +import threading + +import pandas +import numpy + +from argaze import ArFeatures, DataFeatures, GazeFeatures + + +class CSV(ArFeatures.DataPlaybackContext): + + @DataFeatures.PipelineStepInit + def __init__(self, **kwargs): + + # Init ArContext class + super().__init__() + + # Init private attributes + self.__path = None + self.__separator = ',' + self.__timestamp_column = None + self.__x_column = None + self.__y_column = None + self.__start = math.nan + self.__end = math.nan + self.__duration = 0. + self.__progression = 0. + + @property + def path(self) -> str: + """Path to data file.""" + return self.__path + + @path.setter + def path(self, path: str): + + self.__path = path + + @property + def separator(self) -> str: + """Value delimiter character""" + return self.__separator + + @separator.setter + def separator(self, separator: str): + + self.__separator = separator + + @property + def timestamp_column(self) -> str: + """Select timestamp column.""" + return self.__timestamp_column + + @timestamp_column.setter + def timestamp_column(self, timestamp_column: str): + + self.__timestamp_column = timestamp_column + + @property + def x_column(self) -> str: + """Select x column.""" + return self.__x_column + + @x_column.setter + def x_column(self, x_column: str): + + self.__x_column = x_column + + @property + def y_column(self) -> str: + """Select y column.""" + return self.__y_column + + @y_column.setter + def y_column(self, y_column: str): + + self.__y_column = y_column + @property + def start(self) -> int|float: + """Start reading timestamp.""" + return self.__start + + @start.setter + def start(self, start: int|float): + + self.__start = start + + @property + def end(self) -> int|float: + """End reading timestamp.""" + return self.__end + + @end.setter + def end(self, end: int|float): + + self.__end = end + + @property + def duration(self) -> int|float: + """Get data duration.""" + + return self.__duration + + @property + def progression(self) -> float: + """Get data processing progression between 0 and 1.""" + + return self.__progression + + @DataFeatures.PipelineStepEnter + def __enter__(self): + + logging.info('CSV file context starts...') + + # Load gaze positions from a CSV file into Panda Dataframe + data_selector = { + self.__timestamp_column: numpy.float64, + self.__x_column: numpy.float64, + self.__y_column: numpy.float64 + } + + dataframe = pandas.read_csv(self.__path, delimiter = self.__separator, low_memory = False, usecols=data_selector.keys(), dtype=data_selector) + + # Rename columns with generic names + dataframe.rename(columns={ + self.__timestamp_column: 'timestamp', + self.__x_column: 'x', + self.__y_column: 'y' + }, inplace=True) + + # Optionnaly select a time range + if not math.isnan(self.start): + + dataframe = dataframe.loc[(dataframe['timestamp'] >= self.start)] + + if not math.isnan(self.end): + + dataframe = dataframe.loc[(dataframe['timestamp'] <= self.end)] + + # Filter unvalid gaze positions + len_before = len(dataframe) + dataframe = dataframe[(dataframe['x'].notna() & dataframe['y']).notna()] + + if len(dataframe) != len_before: + logging.info('%i unvalid gaze positions have been removed', len_before - len(dataframe)) + + # Open reading thread + self.__reading_thread = threading.Thread(target=self.__read, kwargs={'dataframe': dataframe}) + + logging.debug('> starting reading thread...') + self.__reading_thread.start() + + @DataFeatures.PipelineStepExit + def __exit__(self, exception_type, exception_value, exception_traceback): + + logging.info('CSV file context stops...') + + # Close data stream + self.stop() + + # Stop reading thread + threading.Thread.join(self.__reading_thread) + + def __read(self, dataframe: pandas.DataFrame): + """Read and process gaze positions from dataframe.""" + + start_ts = dataframe.iloc[0].timestamp + end_ts = dataframe.iloc[-1].timestamp + + self.__duration = end_ts - start_ts + self.__progression = 0. + + logging.info('Reading %i gaze positions from %s to %s...', len(dataframe), f'{start_ts}', f'{end_ts}') + + for index, row in dataframe.iterrows(): + + # Stop reading + if not self.is_running(): + + break + + # Pause reading + while self.is_paused() and self.is_running(): + + time.sleep(0.1) + + # Process gaze position + self._process_gaze_position(x = row['x'], y = row['y'], timestamp = row['timestamp']) + + # Update progression + self.__progression = row['timestamp'] / self.__duration + diff --git a/src/argaze/utils/demo/csv_file_context.json b/src/argaze/utils/demo/csv_file_context.json new file mode 100644 index 0000000..a09914f --- /dev/null +++ b/src/argaze/utils/demo/csv_file_context.json @@ -0,0 +1,10 @@ +{ + "argaze.utils.contexts.File.CSV" : { + "name": "CSV file data playback", + "path": "./src/argaze/utils/demo/gaze_positions.csv", + "timestamp_column": "Timestamp (ms)", + "x_column": "Gaze Position X (px)", + "y_column": "Gaze Position Y (px)", + "pipeline": "gaze_analysis_pipeline.json" + } +} \ No newline at end of file diff --git a/src/argaze/utils/demo/gaze_positions.csv b/src/argaze/utils/demo/gaze_positions.csv new file mode 100644 index 0000000..558c995 --- /dev/null +++ b/src/argaze/utils/demo/gaze_positions.csv @@ -0,0 +1,1052 @@ +"Timestamp (ms)","Gaze Position X (px)","Gaze Position Y (px)" +6016,400,443 +6056,400,443 +6106,401,443 +6128,401,443 +6168,401,442 +6367,401,442 +6409,401,442 +6486,401,442 +7310,408,433 +7334,415,427 +7375,435,412 +7397,441,409 +7438,445,405 +7472,446,405 +7509,446,404 +7532,446,404 +7570,449,392 +7593,450,384 +7631,450,378 +7653,450,377 +7705,432,371 +7730,415,365 +7766,390,353 +7788,376,345 +7828,334,318 +7847,318,306 +7890,296,288 +7912,286,277 +7950,278,263 +7978,275,256 +8018,268,246 +8039,266,245 +8074,265,244 +8092,264,243 +8128,262,243 +8185,263,242 +8205,264,241 +8241,265,231 +8261,265,228 +8305,267,227 +8337,267,226 +8392,267,228 +8427,269,232 +8445,270,232 +8481,272,231 +8501,273,228 +8537,273,219 +8558,272,217 +8592,272,217 +8613,271,218 +8650,271,222 +8671,271,225 +8703,272,227 +8724,272,227 +8755,274,227 +8776,274,226 +8813,275,224 +8834,275,223 +8866,275,223 +8886,279,225 +8922,292,228 +8944,312,227 +8981,351,222 +9000,382,218 +9033,424,214 +9053,447,213 +9087,510,216 +9104,538,216 +9137,591,218 +9156,620,220 +9192,691,219 +9212,719,216 +9246,788,214 +9267,812,215 +9300,855,216 +9319,878,215 +9352,900,215 +9371,907,216 +9403,915,217 +9420,915,217 +9464,915,217 +9484,915,218 +9519,913,219 +9538,912,219 +9573,904,222 +9592,893,224 +9626,875,225 +9645,871,226 +9677,869,226 +9738,869,227 +9760,870,229 +9798,872,231 +9818,872,231 +9851,875,232 +9871,879,232 +9904,883,230 +9925,884,229 +9963,886,228 +9984,886,227 +10073,887,229 +10125,892,234 +10146,894,234 +10181,895,233 +10202,896,231 +10232,896,229 +10252,896,229 +10341,896,229 +10363,896,232 +10399,894,247 +10420,894,254 +10454,890,274 +10472,887,284 +10519,866,316 +10540,858,328 +10573,842,352 +10590,830,370 +10621,816,386 +10641,801,399 +10671,780,415 +10690,770,422 +10723,749,435 +10742,734,442 +10772,717,449 +10790,699,458 +10823,673,468 +10839,659,471 +10868,639,477 +10885,625,481 +10921,597,492 +10941,587,496 +10975,566,508 +10991,558,513 +11025,545,523 +11044,542,527 +11078,537,536 +11095,536,540 +11126,535,548 +11142,535,551 +11177,538,556 +11196,538,557 +11230,541,559 +11247,542,559 +11288,542,559 +11331,542,559 +11349,541,559 +11383,540,558 +11441,542,558 +11462,544,559 +11497,552,562 +11518,558,566 +11549,561,569 +11571,562,569 +11605,562,570 +11657,565,564 +11676,565,561 +11711,564,557 +11766,564,558 +11787,565,560 +11822,569,569 +11841,569,570 +11877,571,570 +11898,572,568 +11933,573,564 +11954,574,561 +11988,575,559 +12009,575,559 +12078,575,560 +12115,578,564 +12137,583,565 +12171,599,560 +12192,607,549 +12229,612,537 +12254,613,535 +12288,613,536 +12305,613,537 +12334,613,539 +12354,613,539 +12388,617,541 +12406,624,540 +12438,638,534 +12458,647,530 +12495,685,512 +12515,706,502 +12547,752,474 +12567,779,449 +12602,811,405 +12622,830,380 +12653,848,359 +12671,864,341 +12706,881,316 +12726,887,308 +12760,890,305 +12776,892,305 +12812,896,301 +12831,898,300 +12868,901,299 +12888,911,296 +12926,928,284 +12947,936,280 +12983,957,266 +13000,963,262 +13034,969,257 +13053,969,257 +13087,969,258 +13108,969,260 +13142,970,266 +13159,971,267 +13189,971,267 +13216,972,266 +13249,972,261 +13271,970,256 +13310,964,252 +13331,963,252 +13367,962,253 +13388,962,255 +13422,962,257 +13443,963,257 +13480,964,256 +13500,964,255 +13536,964,253 +13556,964,253 +13590,964,254 +13611,964,255 +13647,965,257 +13668,965,257 +13705,966,256 +13725,967,253 +13762,959,243 +13782,944,238 +13816,914,234 +13837,893,233 +13870,830,223 +13890,806,216 +13922,751,204 +13941,718,201 +13974,655,192 +13993,629,188 +14030,565,184 +14051,539,184 +14082,491,186 +14100,467,186 +14132,420,189 +14150,393,189 +14182,363,190 +14198,349,189 +14232,324,188 +14252,315,188 +14286,306,190 +14305,303,190 +14338,295,191 +14358,295,191 +14394,295,190 +14414,296,190 +14448,299,188 +14468,302,186 +14501,311,178 +14521,317,174 +14556,319,174 +14614,318,177 +14634,318,181 +14666,318,182 +14686,319,182 +14721,320,181 +14741,320,180 +14776,319,179 +14795,318,179 +14835,313,181 +14856,312,185 +14896,312,186 +14917,312,186 +14950,315,185 +14971,316,184 +15007,316,182 +15027,315,182 +15069,309,182 +15094,302,187 +15127,285,199 +15144,276,205 +15178,261,219 +15204,256,223 +15238,250,229 +15256,243,239 +15290,235,263 +15309,229,274 +15344,217,294 +15363,212,302 +15400,205,314 +15419,204,314 +15453,204,314 +15482,203,313 +15517,204,307 +15537,204,305 +15569,203,304 +15586,203,303 +15621,202,303 +15641,203,302 +15673,208,296 +15694,209,294 +15732,210,291 +15753,210,291 +15790,210,290 +15808,210,291 +15842,211,292 +15861,211,292 +15899,212,292 +15924,212,291 +15955,209,290 +15973,209,290 +16010,208,291 +16032,208,291 +16070,208,287 +16091,210,279 +16122,210,258 +16140,208,244 +16174,205,213 +16193,204,203 +16223,203,195 +16239,203,192 +16273,203,190 +16291,203,190 +16325,203,192 +16345,205,202 +16377,210,210 +16397,211,210 +16433,213,208 +16454,213,205 +16488,211,202 +16507,211,202 +16543,211,202 +16565,211,203 +16600,212,205 +16621,213,206 +16654,214,206 +16674,214,206 +16713,214,203 +16734,213,203 +16768,212,203 +16788,213,206 +16823,223,224 +16843,227,229 +16876,241,236 +16894,252,242 +16930,263,252 +16950,269,258 +16987,273,262 +17006,278,267 +17037,284,272 +17054,288,275 +17089,290,275 +17106,291,276 +17139,299,282 +17162,303,285 +17197,305,286 +17217,306,287 +17256,307,287 +17277,307,285 +17315,302,279 +17337,302,279 +17373,304,281 +17394,306,282 +17427,308,281 +17448,308,281 +17484,308,280 +17505,307,280 +17544,308,281 +17567,309,282 +17603,316,285 +17624,326,291 +17662,350,319 +17683,361,336 +17719,389,378 +17738,403,396 +17768,435,430 +17788,451,444 +17822,486,473 +17842,499,482 +17875,523,500 +17894,538,510 +17928,552,519 +17947,557,522 +17981,565,528 +17998,566,529 +18033,568,527 +18051,569,527 +18085,572,527 +18102,573,526 +18136,574,522 +18153,575,520 +18185,578,518 +18206,579,517 +18242,580,517 +18300,581,517 +18321,582,517 +18359,585,513 +18379,586,509 +18411,590,498 +18432,592,491 +18467,593,487 +18489,594,487 +18527,594,486 +18582,595,490 +18601,596,490 +18638,597,491 +18660,598,490 +18696,599,483 +18717,599,481 +18750,599,480 +18771,600,483 +18810,603,496 +18830,604,500 +18866,609,508 +18888,613,513 +18924,614,513 +18945,615,514 +18983,621,530 +19001,622,538 +19035,624,548 +19060,625,554 +19093,626,567 +19110,626,570 +19145,626,571 +19164,626,572 +19199,628,576 +19218,628,576 +19249,631,576 +19266,632,575 +19304,633,573 +19325,633,573 +19405,633,574 +19428,637,576 +19467,642,578 +19488,642,578 +19546,643,578 +19583,644,577 +19604,645,576 +19636,649,572 +19654,651,569 +19686,654,567 +19707,657,565 +19745,666,559 +19764,672,555 +19801,691,535 +19822,704,514 +19853,720,489 +19872,740,462 +19907,771,419 +19926,791,382 +19960,814,335 +19977,825,315 +20010,846,289 +20030,859,275 +20063,877,258 +20080,885,249 +20115,897,240 +20134,901,238 +20170,902,239 +20190,903,246 +20224,905,252 +20242,906,253 +20277,908,258 +20296,910,266 +20330,911,275 +20350,910,276 +20384,910,279 +20404,909,282 +20443,908,289 +20464,908,290 +20560,907,290 +20597,907,290 +20630,907,289 +20665,907,286 +20686,907,285 +20745,907,286 +20777,908,288 +20798,911,288 +20835,923,283 +20854,937,272 +20891,955,247 +20913,964,231 +20947,973,211 +20968,980,196 +21004,986,175 +21021,989,166 +21054,990,142 +21071,989,135 +21100,988,129 +21120,987,129 +21156,983,131 +21177,980,134 +21213,978,137 +21232,977,142 +21270,974,158 +21292,974,162 +21326,969,169 +21348,969,172 +21381,967,177 +21402,965,180 +21436,965,181 +21494,965,179 +21516,964,179 +21554,959,178 +21572,957,178 +21607,949,181 +21629,948,182 +21666,947,183 +21686,947,184 +21723,942,185 +21745,936,185 +21778,920,183 +21797,910,181 +21833,892,180 +21854,890,181 +21889,889,181 +21918,889,181 +21954,895,186 +21973,903,189 +22008,922,198 +22029,928,200 +22069,930,200 +22091,928,200 +22128,909,192 +22149,902,191 +22182,895,191 +22205,894,191 +22251,893,191 +22278,894,193 +22317,894,194 +22338,894,194 +22375,895,194 +22396,896,193 +22433,900,188 +22454,902,187 +22492,915,187 +22513,920,188 +22550,924,190 +22571,930,195 +22609,949,212 +22631,962,219 +22667,979,225 +22686,991,226 +22717,1000,227 +22734,1004,228 +22770,1005,228 +22790,1005,230 +22821,1005,231 +22850,1005,231 +22886,1003,231 +22906,1001,231 +22943,985,232 +22964,975,231 +23003,959,230 +23025,956,231 +23063,954,233 +23084,954,233 +23116,954,235 +23135,956,236 +23174,956,239 +23195,956,241 +23231,954,244 +23250,953,244 +23288,948,242 +23309,941,239 +23347,918,228 +23366,906,226 +23404,898,224 +23424,894,223 +23460,892,221 +23480,890,221 +23513,890,222 +23530,891,224 +23568,893,229 +23589,894,232 +23625,905,245 +23646,912,248 +23682,930,248 +23704,932,248 +23742,931,247 +23760,930,247 +23793,928,248 +23814,927,249 +23854,926,251 +23876,926,252 +23908,926,252 +23927,926,252 +23967,926,254 +23989,926,255 +24027,928,258 +24049,930,259 +24081,932,258 +24102,933,252 +24141,929,240 +24163,929,239 +24213,929,239 +24235,930,239 +24269,940,247 +24290,951,258 +24327,958,264 +24345,958,264 +24385,958,265 +24404,959,266 +24440,959,268 +24463,959,272 +24504,962,283 +24525,963,285 +24559,961,285 +24579,961,285 +24616,955,282 +24638,953,282 +24673,948,279 +24691,944,276 +24724,939,272 +24744,938,272 +24781,937,272 +24802,937,272 +24837,940,272 +24858,943,270 +24898,944,260 +24920,941,257 +24956,933,256 +24978,931,257 +25011,929,262 +25030,929,263 +25072,931,266 +25093,931,266 +25129,924,267 +25150,909,266 +25188,879,261 +25210,847,260 +25245,797,265 +25263,759,274 +25298,682,286 +25318,629,288 +25353,472,278 +25373,412,270 +25407,273,250 +25427,239,243 +25461,187,227 +25481,158,210 +25518,147,198 +25538,147,198 +25568,147,198 +25589,149,198 +25629,174,201 +25649,194,206 +25686,235,222 +25704,252,229 +25738,281,236 +25758,291,237 +25794,296,238 +25824,296,238 +25860,296,242 +25880,296,243 +25934,296,243 +25972,297,243 +25991,297,243 +26026,297,244 +26046,298,247 +26078,301,249 +26098,302,249 +26137,303,246 +26158,303,244 +26195,302,243 +26213,302,243 +26251,300,250 +26271,300,251 +26302,300,251 +26321,303,248 +26358,305,243 +26376,303,240 +26409,302,239 +26431,301,239 +26470,295,240 +26492,293,241 +26529,291,242 +26550,291,242 +26588,287,238 +26609,284,237 +26642,279,235 +26663,277,237 +26702,272,246 +26723,267,254 +26761,262,268 +26782,259,272 +26821,258,273 +26840,257,272 +26877,246,268 +26902,242,268 +26937,242,268 +26955,245,266 +26993,267,263 +27013,274,262 +27046,278,258 +27066,280,251 +27102,279,246 +27149,279,247 +27171,280,250 +27205,289,252 +27224,294,249 +27259,298,239 +27280,298,223 +27315,296,208 +27334,297,202 +27370,303,192 +27391,304,190 +27428,304,189 +27459,304,190 +27494,303,197 +27513,303,197 +27550,302,198 +27567,301,198 +27603,294,200 +27624,284,207 +27662,272,224 +27684,268,233 +27719,270,241 +27740,273,244 +27774,275,248 +27795,274,250 +27831,273,251 +27853,273,251 +27888,284,255 +27907,292,255 +27945,304,256 +27966,305,257 +28003,308,265 +28022,311,272 +28056,327,294 +28077,337,304 +28117,374,334 +28138,400,351 +28173,432,372 +28193,456,389 +28224,480,408 +28241,488,416 +28276,505,439 +28295,514,455 +28329,519,469 +28349,521,475 +28385,528,491 +28405,529,495 +28438,530,497 +28455,533,501 +28492,548,516 +28510,554,522 +28543,566,534 +28563,577,545 +28599,604,575 +28619,610,581 +28654,612,585 +28674,613,588 +28707,615,593 +28727,615,594 +28774,613,589 +28795,608,584 +28828,602,578 +28845,602,578 +28880,599,578 +28900,598,579 +28931,595,579 +28951,593,579 +28982,593,580 +29040,592,579 +29059,587,577 +29094,575,577 +29115,567,578 +29147,558,579 +29167,555,579 +29207,555,579 +29251,555,578 +29271,555,577 +29309,554,575 +29330,550,573 +29369,549,573 +29420,552,574 +29446,571,577 +29494,615,569 +29517,627,561 +29554,635,547 +29578,635,545 +29634,635,545 +29672,629,540 +29692,623,535 +29728,608,523 +29746,606,522 +29778,600,518 +29799,597,514 +29840,597,514 +29859,598,516 +29896,600,524 +29917,600,527 +29953,600,529 +29974,600,529 +30011,605,517 +30033,606,509 +30068,606,508 +30089,606,509 +30121,605,517 +30142,604,522 +30181,603,527 +30200,604,529 +30244,606,530 +30269,608,529 +30310,609,528 +30331,609,528 +30368,605,533 +30389,602,535 +30428,591,536 +30447,580,532 +30484,558,515 +30505,540,498 +30540,513,471 +30559,491,451 +30591,460,424 +30609,442,408 +30647,402,372 +30667,387,359 +30702,348,332 +30722,333,321 +30758,306,303 +30779,289,292 +30812,273,282 +30829,267,278 +30864,254,275 +30888,243,274 +30933,224,272 +30953,217,270 +30984,214,269 +31002,214,269 +31047,215,269 +31078,216,267 +31096,216,267 +31143,215,267 +31162,214,268 +31196,213,271 +31213,213,273 +31250,216,274 +31272,217,272 +31311,219,261 +31332,218,257 +31366,217,256 +31398,218,257 +31436,220,263 +31457,221,264 +31492,224,262 +31513,229,258 +31552,230,247 +31574,229,242 +31612,228,237 +31633,227,232 +31670,230,229 +31689,237,224 +31729,254,212 +31756,267,207 +31791,289,208 +31812,292,209 +31845,291,211 +31865,286,221 +31902,280,236 +31924,279,244 +31962,280,248 +31984,289,248 +32021,305,242 +32043,312,237 +32083,313,236 +32139,313,238 +32158,312,238 +32192,312,239 +32224,312,239 +32264,316,235 +32285,317,232 +32323,317,224 +32344,317,223 +32392,317,225 +32430,318,235 +32451,318,238 +32586,317,239 +32609,313,244 +32644,301,254 +32665,295,257 +32707,290,257 +32728,288,256 +32764,285,255 +32784,284,255 +32823,287,261 +32845,290,264 +32883,314,264 +32904,338,257 +32940,372,245 +32961,398,238 +32999,454,238 +33020,478,240 +33054,524,239 +33074,558,236 +33112,620,231 +33132,645,229 +33168,707,224 +33189,733,225 +33223,784,222 +33243,800,221 +33279,840,223 +33296,851,225 +33332,867,227 +33353,873,226 +33394,891,226 +33416,898,227 +33452,899,227 +33480,899,228 +33512,900,233 +33530,901,236 +33569,901,237 +33629,900,237 +33647,898,237 +33680,896,238 +33697,896,239 +33737,894,240 +33758,893,240 +33798,890,243 +33819,887,247 +33856,882,254 +33877,882,256 +33911,882,257 +33929,882,257 +33969,883,257 +33990,883,258 +34027,884,261 +34049,884,263 +34089,884,265 +34111,886,265 +34147,893,263 +34169,902,258 +34207,923,233 +34229,929,223 +34265,944,203 +34290,951,189 +34325,955,170 +34346,955,159 +34382,955,155 +34413,954,155 +34449,952,165 +34471,951,175 +34506,949,189 +34527,950,195 +34564,951,197 +34586,952,198 +34627,954,199 +34649,955,202 +34685,955,214 +34704,955,221 +34737,956,223 +34758,958,223 +34798,959,223 +34817,959,222 +34854,957,222 +34874,955,223 +34914,953,228 +34938,954,231 +34973,959,238 +34994,964,244 +35030,968,249 +35049,967,250 +35091,965,252 +35113,962,258 +35149,958,267 +35171,956,270 +35209,956,271 +35265,956,271 +35299,949,266 +35319,943,264 +35353,940,263 +35373,937,263 +35414,932,263 +35438,928,263 +35469,921,260 +35490,914,248 +35530,905,223 +35552,904,207 +35585,908,198 +35604,910,196 +35638,915,192 +35658,918,191 +35696,920,190 +35750,920,193 +35768,921,195 +35802,925,201 +35821,928,202 +35864,941,208 +35886,945,211 +35924,946,219 +35945,944,228 +35983,942,244 +36005,942,248 +36044,942,250 +36062,942,250 +36100,939,248 +36122,926,243 +36163,914,243 +36185,907,243 +36223,893,243 +36243,891,243 +36282,890,243 +36304,889,244 +36341,887,246 +36360,886,248 +36403,885,253 +36425,882,259 +36459,878,268 +36481,874,272 +36515,854,275 +36535,818,275 +36569,756,275 +36589,728,279 +36625,660,296 +36643,635,302 +36673,589,309 +36690,573,311 +36731,555,313 +36757,554,313 +36843,555,313 +36863,559,316 +36897,566,322 +36917,573,325 +36957,597,329 +36981,615,326 +37030,633,316 +37054,633,314 +37095,634,314 +37117,634,315 +37153,633,316 +37174,633,317 +37212,635,319 +37233,637,319 +37274,639,318 +37295,639,315 +37334,633,311 +37355,628,310 +37389,623,311 +37407,621,313 +37443,620,315 +37461,620,316 +37497,621,317 +37515,623,318 +37555,626,317 +37576,626,317 +37610,627,320 +37630,628,324 +37667,629,325 +37687,630,326 +37722,633,326 +37742,634,325 +37779,634,324 +37801,634,323 -- cgit v1.1