Coverage for ibllib/io/video.py: 97%
115 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-07 14:26 +0100
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-07 14:26 +0100
1"""Functions for fetching video frames, meta data and file locations"""
2import sys
3import re
4from datetime import timedelta
5from pathlib import Path
7import cv2
8import numpy as np
10from iblutil.util import Bunch
11from one.api import ONE
12from one import params
14VIDEO_LABELS = ('left', 'right', 'body', 'belly')
17class VideoStreamer:
18 """
19 Provides a wrapper to stream a video from a password protected HTTP server using opencv
20 """
22 def __init__(self, url_vid):
23 """
24 TODO Allow auth as input
25 :param url_vid: full url of the video or dataset dictionary as output by alyx rest datasets
26 :returns cv2.VideoCapture object
27 """
28 # pop the data url from the dataset record if the input is a dictionary
29 if isinstance(url_vid, dict): 1seq
30 url_vid = next(fr['data_url'] for fr in url_vid['file_records'] if fr['data_url']) 1q
31 self.url = url_vid 1seq
32 self._par = params.get(silent=True) 1seq
33 self.cap = cv2.VideoCapture(self._url) 1seq
34 self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT)) 1seq
36 @property
37 def _url(self):
38 username = self._par.HTTP_DATA_SERVER_LOGIN 1seq
39 password = self._par.HTTP_DATA_SERVER_PWD 1seq
40 return re.sub(r'(^https?://)', r'\1' + f'{username}:{password}@', self.url) 1seq
42 def get_frame(self, frame_index):
43 self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_index) 1q
44 return self.cap.read() 1q
47def get_video_frame(video_path, frame_number):
48 """
49 Obtain numpy array corresponding to a particular video frame in video_path
50 :param video_path: local path to mp4 file
51 :param frame_number: video frame to be returned
52 :return: numpy array corresponding to frame of interest. Dimensions are (w, h, 3)
53 """
54 is_url = isinstance(video_path, str) and video_path.startswith('http') 1u
55 cap = VideoStreamer(video_path).cap if is_url else cv2.VideoCapture(str(video_path)) 1u
56 # 0-based index of the frame to be decoded/captured next.
57 cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number) 1u
58 ret, frame_image = cap.read() 1u
59 cap.release() 1u
60 return frame_image 1u
63def get_video_frames_preload(vid, frame_numbers=None, mask=Ellipsis, as_list=False,
64 func=lambda x: x, quiet=False):
65 """
66 Obtain numpy array corresponding to a particular video frame in video.
67 Fetching and returning a list is about 33% faster but may be less memory controlled. NB: Any
68 gain in speed will be lost if subsequently converted to array.
69 :param vid: URL or local path to mp4 file or cv2.VideoCapture instance.
70 :param frame_numbers: video frames to be returned. If None, return all frames.
71 :param mask: a logical mask or slice to apply to frames
72 :param as_list: if true the frames are returned as a list, this is faster but may be less
73 memory efficient
74 :param func: Function to be applied to each frame. Applied after masking if applicable.
75 :param quiet: if true, suppress frame loading progress output.
76 :return: numpy array corresponding to frame of interest, or list if as_list is True.
77 Default dimensions are (n, w, h, 3) where n = len(frame_numbers)
79 Example - Load first 1000 frames, keeping only the first colour channel:
80 frames = get_video_frames_preload(vid, range(1000), mask=np.s_[:, :, 0])
81 """
82 is_cap = not isinstance(vid, (str, Path)) 1abfgchdjik
83 if is_cap: 1abfgchdjik
84 cap = vid
85 else:
86 is_url = isinstance(vid, str) and vid.startswith('http') 1abfgchdjik
87 cap = VideoStreamer(vid).cap if is_url else cv2.VideoCapture(str(vid)) 1abfgchdjik
88 assert cap.isOpened(), 'Failed to open video' 1abfgchdjik
90 frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) 1abfgchdjik
91 frame_numbers = frame_numbers if frame_numbers is not None else range(frame_count) 1abfgchdjik
93 # Setting the index is extremely slow; determine where frame index must be set
94 # The first index is always explicitly set.
95 to_set = np.insert(np.diff(frame_numbers), 0, 0) != 1 1abfgchdjik
96 if as_list: 1abfgchdjik
97 frame_images = [None] * len(frame_numbers) 1i
98 else:
99 ret, frame = cap.read() 1abfgchdjik
100 frame_images = np.zeros((len(frame_numbers), *func(frame[mask or ...]).shape), np.uint8) 1abfgchdjik
101 for ii, i in enumerate(frame_numbers): 1abfgchdjik
102 if not quiet: 1abfgchdjik
103 sys.stdout.write(f'\rloading frame {ii}/{len(frame_numbers)}') 1abfgchdjik
104 sys.stdout.flush() 1abfgchdjik
105 if to_set[ii]: 1abfgchdjik
106 cap.set(cv2.CAP_PROP_POS_FRAMES, i) 1abfgchdjik
107 ret, frame = cap.read() 1abfgchdjik
108 if ret: 1abfgchdjik
109 frame_images[ii] = func(frame[mask or ...]) 1abfgchdjik
110 else:
111 print(f'failed to read frame #{i}')
112 if not is_cap: 1abfgchdjik
113 cap.release() 1abfgchdjik
114 if not quiet: 1abfgchdjik
115 sys.stdout.write('\x1b[2K\r') # Erase current line in stdout 1abfgchdjik
116 return frame_images 1abfgchdjik
119def get_video_meta(video_path, one=None):
120 """
121 Return a bunch of video information with the fields ('length', 'fps', 'width', 'height',
122 'duration', 'size')
123 :param video_path: A path to the video. May be a file path or URL.
124 :param one: An instance of ONE
125 :return: A Bunch of video mata data
126 """
127 is_url = isinstance(video_path, str) and video_path.startswith('http') 1abmnopcde
128 cap = VideoStreamer(video_path).cap if is_url else cv2.VideoCapture(str(video_path)) 1abmnopcde
129 assert cap.isOpened(), f'Failed to open video file {video_path}' 1abmnopcde
131 # Get basic properties of video
132 meta = Bunch() 1abmnopcde
133 meta.length = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) 1abmnopcde
134 meta.fps = int(cap.get(cv2.CAP_PROP_FPS)) 1abmnopcde
135 meta.width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) 1abmnopcde
136 meta.height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) 1abmnopcde
137 meta.duration = timedelta(seconds=meta.length / meta.fps) if meta.fps > 0 else 0 1abmnopcde
138 if is_url and one: 1abmnopcde
139 eid = one.path2eid(video_path) 1e
140 datasets = one.list_datasets(eid, details=True) 1e
141 label = label_from_path(video_path) 1e
142 record = datasets[datasets['rel_path'].str.contains(f'_iblrig_{label}Camera.raw')] 1e
143 assert len(record) == 1 1e
144 meta.size = record['file_size'].iloc[0] 1e
145 elif is_url and not one: 1abmnopcde
146 meta.size = None
147 else:
148 meta.size = Path(video_path).stat().st_size 1abmnopcde
149 cap.release() 1abmnopcde
150 return meta 1abmnopcde
153def url_from_eid(eid, label=None, one=None):
154 """Return the video URL(s) for a given eid.
156 Parameters
157 ----------
158 eid : UUID, str
159 The session ID.
160 label : str, tuple of str
161 The video label (e.g. 'body') or a tuple thereof.
162 one : one.api.One
163 An instance of ONE.
165 Returns
166 -------
167 str, dict of str
168 The URL string if the label is a string, otherwise a dict of urls with labels as keys.
170 Raises
171 ------
172 ValueError
173 Video label is unreckognized. See `VIDEO_LABELS` for valid labels.
174 """
175 valid_labels = VIDEO_LABELS 1lr
176 if not (label is None or np.isin(label, valid_labels).all()): 1lr
177 raise ValueError('labels must be one of ("%s")' % '", "'.join(valid_labels)) 1l
178 one = one or ONE() 1lr
179 session_path = one.eid2path(one.to_eid(eid)) 1lr
181 # Filter the video files
182 def match(dataset): 1lr
183 matched = re.match(r'(?:_iblrig_)([a-z]+)(?=Camera.raw.mp4$)', dataset.rsplit('/')[-1]) 1l
184 return matched and matched.group(1) in (label or valid_labels) 1l
186 if one.mode != 'remote': 1lr
187 datasets = one.list_datasets(eid, details=False) 1l
188 datasets = [ds for ds in datasets if match(ds)] 1l
189 urls = [one.path2url(session_path / ds) for ds in datasets] 1l
190 else:
191 datasets = one.get_details(eid, full=True)['data_dataset_session_related'] 1lr
192 urls = [ds['data_url'] for ds in datasets 1lr
193 if ds['dataset_type'] == '_iblrig_Camera.raw' and match(ds['name'])]
195 # If one label specified, return the url, otherwise return a dict
196 if isinstance(label, str): 1lr
197 return urls[0] 1l
198 urls_dict = {label_from_path(url): url for url in urls} 1lr
199 return {**dict.fromkeys(label), **urls_dict} if label else urls_dict 1lr
202def label_from_path(video_name):
203 """
204 Return the video label, e.g.. 'left', 'right' or 'body'
205 :param video_name: A file path, URL or file name for the video
206 :return: The string label or None if the video doesn't match
207 """
208 result = re.search(r'(?<=_)([a-z]+)(?=Camera)', str(video_name)) 1aKlbLvfMe
209 return result.group() if result else None 1aKlbLvfMe
212def assert_valid_label(label):
213 """
214 Raises a value error is the provided label is not supported.
215 :param label: A video label to verify
216 :return: the label in lowercase
217 """
218 if not isinstance(label, str): 1axtyzAwBbvCDfgcEhFGHIJ
219 try: 1t
220 return tuple(map(assert_valid_label, label)) 1t
221 except AttributeError: 1t
222 raise ValueError('label must be string or iterable of strings')
223 if label.lower() not in VIDEO_LABELS: 1axtyzAwBbvCDfgcEhFGHIJ
224 raise ValueError(f"camera must be one of ({', '.join(VIDEO_LABELS)})") 1tw
225 return label.lower() 1axtyzAwBbvCDfgcEhFGHIJ