Skip to content

Commit a9f4b08

Browse files
authored
Merge branch 'main' into main
2 parents 94f0010 + 1a498dd commit a9f4b08

File tree

15 files changed

+884
-404
lines changed

15 files changed

+884
-404
lines changed

README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,12 @@ pip install -e .
1414
- `projects/task_type_procedures.json` - Associate Alyx procedures to a custom task protocol
1515
- `projects/_template.py` - Example for creating a custom Bpod extractor, QC or DAQ sync task
1616
- `projects/extraction_tasks.py` - Where to import pipeline tasks so they are readable by ibllib
17+
18+
## Contributing
19+
20+
To contribute to this repository you must create a pull request into main. Before merging you must increase the version number in the [pyproject.toml](./pyproject.toml) file (see [this guide](https://packaging.python.org/en/latest/specifications/version-specifiers/#version-specifiers) for versioning scheme).
21+
A GitHub check will ensure that the repository version is valid and greater than the version on the main branch. This is essential as we currently do not publish to PyPi.
22+
The pull request may be merged only when this check passes. Bypassing this check is not permitted, nor are direct pushes to main. Once merged, a version tag is automatically generated.
23+
24+
> [!IMPORTANT]
25+
> Tests in this repository are run by both the [iblrig](https://github.com/int-brain-lab/iblrig) and [ibllib](https://github.com/int-brain-lab/ibllib) CI.

iblrig_custom_tasks/_sp_passiveVideo/__init__.py

Whitespace-only changes.

iblrig_custom_tasks/_sp_passiveVideo/task.py

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
TODO Add custom task list to Session class
55
"""
66
import time
7+
import shutil
78
from pathlib import Path
89
from collections import defaultdict
10+
from functools import partial
911
import logging
1012
import warnings
1113

@@ -15,7 +17,7 @@
1517
import iblrig.misc
1618
from iblrig.base_tasks import BpodMixin
1719

18-
_logger = logging.getLogger(__name__)
20+
_logger = logging.getLogger(f'iblrig.{__name__}')
1921

2022
# this allows the CI and automated tests to import the file and make sure it is valid without having vlc
2123
try:
@@ -27,26 +29,60 @@
2729
'git+https://github.com/int-brain-lab/project_extraction.git"', RuntimeWarning)
2830

2931

32+
class MediaStats(vlc.MediaStats):
33+
"""A class to store media stats."""
34+
35+
def fieldnames(self):
36+
"""Return the field names."""
37+
return zip(*self._fields_)[0]
38+
39+
def as_tuple(self):
40+
"""Return all attribute values as a tuple."""
41+
return tuple(map(partial(getattr, self), self.fieldnames()))
42+
43+
3044
class Player:
3145
"""A VLC player."""
32-
def __init__(self, rate=1, logger=None):
46+
def __init__(self, rate=1):
3347
self._instance = vlc.Instance(['--video-on-top'])
3448
self._player = self._instance.media_player_new()
3549
self._player.set_fullscreen(True)
3650
self._player.set_rate(rate)
3751
self._media = None
52+
self._media_stats = MediaStats()
53+
self._stats = []
3854
self.events = defaultdict(list)
39-
self.logger = logger or _logger
4055
em = self._player.event_manager()
4156
for event in (vlc.EventType.MediaPlayerPlaying, vlc.EventType.MediaPlayerEndReached):
4257
em.event_attach(event, self._record_event)
4358

4459
def _record_event(self, event):
4560
"""VLC event callback."""
46-
self.logger.debug('%s', event.type)
61+
_logger.debug('%s', event.type)
4762
# Have to convert to str as object pointer may change
4863
self.events[str(event.type).split('.')[-1]].append(time.time())
4964

65+
def update_media_stats(self):
66+
"""Update media stats.
67+
68+
Returns
69+
-------
70+
bool
71+
True if the stats have changed since the last update.
72+
"""
73+
if not vlc.libvlc_media_get_stats(self._player.get_media(), self._media_stats):
74+
return False
75+
stats = tuple((time.time(), *self._media_stats.as_tuple()))
76+
if not any(self._stats) or stats[1:] != self._stats[-1][1:]:
77+
self._stats.append(stats)
78+
return True
79+
return False
80+
81+
@property
82+
def stats(self):
83+
"""Return media stats."""
84+
return pd.DataFrame(self._stats, columns=['time', *self._media_stats.fieldnames()])
85+
5086
def play(self, path):
5187
"""Play a video.
5288
@@ -102,69 +138,102 @@ def get_ended_time(self, repeat=-1):
102138
elif repeat == -1 or len(ends) > repeat:
103139
return ends[repeat]
104140

141+
def get_media_length(self):
142+
"""
143+
Return length of the video in seconds.
144+
145+
Returns
146+
-------
147+
float, None
148+
The length of the video in seconds when played at the provided frame rate.
149+
None is returned when no video is loaded.
150+
"""
151+
if self._media:
152+
length = self._media.get_length()
153+
if length > -1:
154+
return length / 1e3
155+
105156

106157
class Session(BpodMixin):
107158
"""Play a single video."""
108159

109160
protocol_name = '_sp_passiveVideo'
161+
extractor_tasks = ['PassiveVideoTimeline']
110162

111163
def __init__(self, **kwargs):
112164
super().__init__(**kwargs)
113165
if self.hardware_settings.get('MAIN_SYNC', False):
114166
raise NotImplementedError('Recording frame2ttl on Bpod not yet implemented')
115167
self.paths.DATA_FILE_PATH = self.paths.DATA_FILE_PATH.with_name('_sp_taskData.raw.pqt')
168+
self.paths.STATS_FILE_PATH = self.paths.DATA_FILE_PATH.with_name('_sp_videoData.stats.pqt')
116169
self.video = None
117170
self.trial_num = -1
171+
# For py3.11 use logging.getLevelNamesMapping instead
172+
self._log_level = logging.getLevelName(kwargs.get('log_level', 'INFO'))
118173
columns = ['intervals_0', 'intervals_1']
119174
self.data = pd.DataFrame(pd.NA, index=range(self.task_params.NREPEATS), columns=columns)
120175

121176
def save(self):
122-
self.logger.info('Saving data')
177+
_logger.info('Saving data')
123178
if self.video:
124179
data = pd.concat([self.data, pd.DataFrame.from_dict(self.video.events)], axis=1)
125180
data.to_parquet(self.paths.DATA_FILE_PATH)
181+
if 20 > self._log_level > 0:
182+
stats = self.video.stats
183+
stats.to_parquet(self.paths.STATS_FILE_PATH)
184+
if self.video._media and self.video._media.get_mrl().endswith(str(self.task_params.VIDEO)):
185+
ext = Path(self.task_params.VIDEO).suffix
186+
video_file_path = self.paths.DATA_FILE_PATH.with_name(f'_sp_video.raw{ext}')
187+
_logger.info('Copying %s -> %s', self.task_params.VIDEO, video_file_path)
188+
shutil.copy(self.task_params.VIDEO, video_file_path)
189+
else:
190+
_logger.warning('Video not copied (video most likely was not played)')
191+
self. self.video._media.get_mrl()
126192
self.paths.SESSION_FOLDER.joinpath('transfer_me.flag').touch()
127193

128194
def start_hardware(self):
129-
self.start_mixin_bpod() # used for protocol spacer only
130-
self.video = Player(logger=self.logger)
195+
self.start_mixin_bpod()
196+
self.video = Player()
131197

132198
def next_trial(self):
133199
"""Start the next trial."""
134200
self.trial_num += 1
135201
self.data.at[self.trial_num, 'intervals_0'] = time.time()
136202
if self.trial_num == 0:
137-
self.logger.info('Starting video %s', self.task_params.VIDEO)
203+
_logger.info('Starting video %s', self.task_params.VIDEO)
138204
self.video.play(self.task_params.VIDEO)
139205
else:
140-
self.logger.debug('Trial #%i: Replaying video', self.trial_num + 1)
206+
_logger.debug('Trial #%i: Replaying video', self.trial_num + 1)
141207
assert self.video
142208
self.video.replay()
143209

144210
def _set_bpod_out(self, val):
145211
"""Set Bpod BNC1 output state."""
212+
BNC_HIGH = 255
213+
BNC_LOW = 0
146214
if isinstance(val, bool):
147-
val = 255 if val else 128
215+
val = BNC_HIGH if val else BNC_LOW
148216
self.bpod.manual_override(Bpod.ChannelTypes.OUTPUT, Bpod.ChannelNames.BNC, channel_number=1, value=val)
149217

150218
def _run(self):
151219
"""This is the method that runs the video."""
152-
# make the bpod send spacer signals to the main sync clock for protocol discovery
153-
self.send_spacers()
154220
for rep in range(self.task_params.NREPEATS): # Main loop
155221
self.next_trial()
156222
self._set_bpod_out(True)
157223
# TODO c.f. MediaListPlayerPlayed event
158224
while not self.video.is_started:
159225
... # takes time to actually start playback
160226
while self.video.is_playing or (end_time := self.video.get_ended_time(rep)) is None:
227+
if 20 > self._log_level > 0:
228+
self.video.update_media_stats()
161229
time.sleep(0.05)
162230
# trial finishes when playback finishes
163231
self._set_bpod_out(False)
164232
self.session_info.NTRIALS += 1
165233
self.data.at[self.trial_num, 'intervals_1'] = time.time()
234+
self.data.at[self.trial_num, 'video_runtime'] = self.video.get_media_length()
166235
dt = self.task_params.ITI_DELAY_SECS - (time.time() - end_time)
167-
self.logger.debug(f'dt = {dt}')
236+
_logger.debug(f'dt = {dt}')
168237
# wait to achieve the desired ITI duration
169238
if dt > 0:
170239
time.sleep(dt)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import unittest
2+
from unittest.mock import Mock
3+
from iblrig_custom_tasks._sp_passiveVideo.task import Session, Player
4+
from iblrig.test.base import TaskArgsMixin
5+
6+
7+
class TestPassiveVideo(TaskArgsMixin, unittest.TestCase):
8+
9+
def setUp(self):
10+
self.get_task_kwargs()
11+
12+
def test_next_trial(self):
13+
self.assertRaises(NotImplementedError, Session, **self.task_kwargs)
14+
self.task_kwargs['hardware_settings']['MAIN_SYNC'] = False
15+
task = Session(log_level='DEBUG', **self.task_kwargs)
16+
task.video = Mock(auto_spec=Player)
17+
task.task_params.VIDEO = r'C:\Users\Work\Downloads\ONE\perlin-xyscale2-tscale50-comb08-5min.mp4'
18+
task.task_params.VIDEO = r'C:\Users\Work\Downloads\SampleVideo_1280x720_1mb.mp4'
19+
task.next_trial()
20+
task.video.play.assert_called_once_with(task.task_params.VIDEO)
21+
task.video.replay.assert_not_called()
22+
task.video.reset_mock()
23+
task.next_trial()
24+
task.video.replay.assert_called_once()
25+
# task.bpod = MagicMock()
26+
# with patch.object(task, 'start_mixin_bpod'):
27+
# task.run()
28+
29+
30+
if __name__ == '__main__':
31+
unittest.main()

0 commit comments

Comments
 (0)