|
4 | 4 | TODO Add custom task list to Session class |
5 | 5 | """ |
6 | 6 | import time |
| 7 | +import shutil |
7 | 8 | from pathlib import Path |
8 | 9 | from collections import defaultdict |
| 10 | +from functools import partial |
9 | 11 | import logging |
10 | 12 | import warnings |
11 | 13 |
|
|
15 | 17 | import iblrig.misc |
16 | 18 | from iblrig.base_tasks import BpodMixin |
17 | 19 |
|
18 | | -_logger = logging.getLogger(__name__) |
| 20 | +_logger = logging.getLogger(f'iblrig.{__name__}') |
19 | 21 |
|
20 | 22 | # this allows the CI and automated tests to import the file and make sure it is valid without having vlc |
21 | 23 | try: |
|
27 | 29 | 'git+https://github.com/int-brain-lab/project_extraction.git"', RuntimeWarning) |
28 | 30 |
|
29 | 31 |
|
| 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 | + |
30 | 44 | class Player: |
31 | 45 | """A VLC player.""" |
32 | | - def __init__(self, rate=1, logger=None): |
| 46 | + def __init__(self, rate=1): |
33 | 47 | self._instance = vlc.Instance(['--video-on-top']) |
34 | 48 | self._player = self._instance.media_player_new() |
35 | 49 | self._player.set_fullscreen(True) |
36 | 50 | self._player.set_rate(rate) |
37 | 51 | self._media = None |
| 52 | + self._media_stats = MediaStats() |
| 53 | + self._stats = [] |
38 | 54 | self.events = defaultdict(list) |
39 | | - self.logger = logger or _logger |
40 | 55 | em = self._player.event_manager() |
41 | 56 | for event in (vlc.EventType.MediaPlayerPlaying, vlc.EventType.MediaPlayerEndReached): |
42 | 57 | em.event_attach(event, self._record_event) |
43 | 58 |
|
44 | 59 | def _record_event(self, event): |
45 | 60 | """VLC event callback.""" |
46 | | - self.logger.debug('%s', event.type) |
| 61 | + _logger.debug('%s', event.type) |
47 | 62 | # Have to convert to str as object pointer may change |
48 | 63 | self.events[str(event.type).split('.')[-1]].append(time.time()) |
49 | 64 |
|
| 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 | + |
50 | 86 | def play(self, path): |
51 | 87 | """Play a video. |
52 | 88 |
|
@@ -102,69 +138,102 @@ def get_ended_time(self, repeat=-1): |
102 | 138 | elif repeat == -1 or len(ends) > repeat: |
103 | 139 | return ends[repeat] |
104 | 140 |
|
| 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 | + |
105 | 156 |
|
106 | 157 | class Session(BpodMixin): |
107 | 158 | """Play a single video.""" |
108 | 159 |
|
109 | 160 | protocol_name = '_sp_passiveVideo' |
| 161 | + extractor_tasks = ['PassiveVideoTimeline'] |
110 | 162 |
|
111 | 163 | def __init__(self, **kwargs): |
112 | 164 | super().__init__(**kwargs) |
113 | 165 | if self.hardware_settings.get('MAIN_SYNC', False): |
114 | 166 | raise NotImplementedError('Recording frame2ttl on Bpod not yet implemented') |
115 | 167 | 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') |
116 | 169 | self.video = None |
117 | 170 | self.trial_num = -1 |
| 171 | + # For py3.11 use logging.getLevelNamesMapping instead |
| 172 | + self._log_level = logging.getLevelName(kwargs.get('log_level', 'INFO')) |
118 | 173 | columns = ['intervals_0', 'intervals_1'] |
119 | 174 | self.data = pd.DataFrame(pd.NA, index=range(self.task_params.NREPEATS), columns=columns) |
120 | 175 |
|
121 | 176 | def save(self): |
122 | | - self.logger.info('Saving data') |
| 177 | + _logger.info('Saving data') |
123 | 178 | if self.video: |
124 | 179 | data = pd.concat([self.data, pd.DataFrame.from_dict(self.video.events)], axis=1) |
125 | 180 | 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() |
126 | 192 | self.paths.SESSION_FOLDER.joinpath('transfer_me.flag').touch() |
127 | 193 |
|
128 | 194 | 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() |
131 | 197 |
|
132 | 198 | def next_trial(self): |
133 | 199 | """Start the next trial.""" |
134 | 200 | self.trial_num += 1 |
135 | 201 | self.data.at[self.trial_num, 'intervals_0'] = time.time() |
136 | 202 | 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) |
138 | 204 | self.video.play(self.task_params.VIDEO) |
139 | 205 | else: |
140 | | - self.logger.debug('Trial #%i: Replaying video', self.trial_num + 1) |
| 206 | + _logger.debug('Trial #%i: Replaying video', self.trial_num + 1) |
141 | 207 | assert self.video |
142 | 208 | self.video.replay() |
143 | 209 |
|
144 | 210 | def _set_bpod_out(self, val): |
145 | 211 | """Set Bpod BNC1 output state.""" |
| 212 | + BNC_HIGH = 255 |
| 213 | + BNC_LOW = 0 |
146 | 214 | if isinstance(val, bool): |
147 | | - val = 255 if val else 128 |
| 215 | + val = BNC_HIGH if val else BNC_LOW |
148 | 216 | self.bpod.manual_override(Bpod.ChannelTypes.OUTPUT, Bpod.ChannelNames.BNC, channel_number=1, value=val) |
149 | 217 |
|
150 | 218 | def _run(self): |
151 | 219 | """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() |
154 | 220 | for rep in range(self.task_params.NREPEATS): # Main loop |
155 | 221 | self.next_trial() |
156 | 222 | self._set_bpod_out(True) |
157 | 223 | # TODO c.f. MediaListPlayerPlayed event |
158 | 224 | while not self.video.is_started: |
159 | 225 | ... # takes time to actually start playback |
160 | 226 | 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() |
161 | 229 | time.sleep(0.05) |
162 | 230 | # trial finishes when playback finishes |
163 | 231 | self._set_bpod_out(False) |
164 | 232 | self.session_info.NTRIALS += 1 |
165 | 233 | self.data.at[self.trial_num, 'intervals_1'] = time.time() |
| 234 | + self.data.at[self.trial_num, 'video_runtime'] = self.video.get_media_length() |
166 | 235 | dt = self.task_params.ITI_DELAY_SECS - (time.time() - end_time) |
167 | | - self.logger.debug(f'dt = {dt}') |
| 236 | + _logger.debug(f'dt = {dt}') |
168 | 237 | # wait to achieve the desired ITI duration |
169 | 238 | if dt > 0: |
170 | 239 | time.sleep(dt) |
|
0 commit comments