simple_ffmpeg_batch_io.VideoIO
Read/write video images or batches of images from video file using FFmpeg backend.
This module defines the main VideoIO class used to open videos,
read images or batches of frames, and write processed outputs.
Authors
Dominique Vaufreydaz (From original C++ code: https://github.com/Vaufreyd/ReadWriteVideosWithOpenCV)
1""" 2Read/write video images or batches of images from video file using FFmpeg backend. 3 4This module defines the main `VideoIO` class used to open videos, 5read images or batches of frames, and write processed outputs. 6 7Authors 8------- 9Dominique Vaufreydaz (From original C++ code: https://github.com/Vaufreyd/ReadWriteVideosWithOpenCV) 10 11""" 12 13__authors__ = ("Dominique Vaufreydaz") 14 15import sys 16import subprocess as sp 17import re 18from enum import Enum 19from typing import Union 20 21import numpy as np 22 23from .FrameCounter import FrameCounter 24from .FrameContainer import FrameContainer 25from .PipeMode import PipeMode 26 27# init static_ffmpeg at import time, first time it will download ffmpeg executables 28import static_ffmpeg 29static_ffmpeg.add_paths() 30 31class VideoIO: 32 # "static" variables to ffmpeg, ffprobe executables 33 videoProgram, paramProgram = static_ffmpeg.run.get_or_fetch_platform_executables_else_raise() 34 35 class VideoIOException(Exception): 36 """ 37 Dedicated exception class for VideoIO class. 38 """ 39 def __init__(self, message="Error while reading/writing video occurs"): 40 self.message = message 41 super().__init__(self.message) 42 43 class PixelFormat(Enum): 44 """ 45 Enum class for supported input video type: GBR 24 bits or RGB 24 bis. 46 """ 47 GBR24 = 'bgr24' # default format 48 RGB24 = 'rgb24' 49 50 @classmethod 51 def reader(cls, filename, *, loglevel = 16, debug = False, **kwargs): 52 """ 53 Create and open a VideoIO object in reader mode (read a video file) 54 55 See `VideoIO.open` for the full list 56 of accepted parameters. 57 """ 58 reader = cls(logLevel=loglevel, debug=debug) 59 reader.open(filename,**kwargs) 60 return reader 61 62 @classmethod 63 def writer(cls, filename, width, height, fps, *, loglevel = 16, debug = False, **kwargs): 64 """ 65 Create and open a VideoIO object in writer mode (write a video file) 66 67 See `VideoIO.create` for the full list 68 of accepted parameters. 69 """ 70 writer = cls(logLevel=loglevel, debug=debug) 71 writer.create(filename, width, height, fps, **kwargs) 72 return writer 73 74 # standard method 75 def get_corresponding_writer(self, filename, **kwargs): 76 """ 77 Method to get writer for a video file with same width, height, fps as the current one VideoIO object 78 79 See `VideoIO.create` for the full list 80 of accepted parameters. 81 """ 82 return VideoIO.writer(filename, self.width, self.height, self.fps, **kwargs) 83 84 # To use with context manager "with VideoIO.reader(...) as f:' for instance 85 def __enter__(self): 86 """ 87 Method call at initialisation of a context manager like "with VideoIO.reader(...) as f:' for instance 88 """ 89 # simply return myself 90 return self 91 92 def __exit__(self, exc_type, exc_val, exc_tb): 93 """ 94 Method call when existing of a context manager like "with VideoIO.reader(...) as f:' for instance 95 """ 96 # close VideoIO 97 self.close() 98 return False 99 100 @staticmethod 101 def get_time_in_sec(filename, *, debug=False, logLevel=16): 102 """ 103 Static method to get length of a video file in seconds including milliseconds as decimal part. 104 105 Parameters 106 ---------- 107 filename : str or path 108 Video file name. 109 110 debug : bool (default False) 111 Show debug info. 112 113 log_level: int (default 16) 114 Log level to pass to the underlying ffmpeg/ffprobe command. 115 116 Returns 117 ---------- 118 float 119 Length in seconds of video file (including milliseconds as decimal part) 120 """ 121 122 cmd = [VideoIO.paramProgram, # ffprobe 123 '-hide_banner', 124 '-loglevel', str(logLevel), 125 '-show_entries', 'format=duration', 126 '-of', 'default=noprint_wrappers=1:nokey=1', 127 str(filename) 128 ] 129 130 if debug == True: 131 print(' '.join(cmd)) 132 133 # call ffprobe and get params in one single line 134 lpipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 135 output = lpipe.stdout.readlines() 136 lpipe.terminate() 137 # transform Bytes output to one single string 138 output = ''.join( [element.decode('utf-8') for element in output]) 139 140 try: 141 return float(output) 142 except (ValueError, TypeError): 143 return None 144 145 @staticmethod 146 def get_params(filename, *, debug=False, logLevel=16): 147 """ 148 Static method to get params (width, height, fps) from a video file. 149 150 Parameters 151 ---------- 152 filename: str or path 153 Video filename. 154 155 debug: bool (default (False) 156 Show debug info. 157 158 log_level: int (default 16) 159 Log level to pass to the underlying ffmpeg/ffprobe command. 160 161 Returns 162 ---------- 163 tuple 164 Tuple containing (width, height, fps) of the video 165 """ 166 cmd = [VideoIO.paramProgram, # ffprobe 167 '-hide_banner', 168 '-loglevel', str(logLevel), 169 '-show_entries', 'stream=width,height,r_frame_rate', 170 str(filename) 171 ] 172 173 if debug == True: 174 print(' '.join(cmd)) 175 176 # call ffprobe and get params in one single line 177 lpipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 178 output = lpipe.stdout.readlines() 179 lpipe.terminate() 180 # transform Bytes output to one single string 181 output = ''.join( [element.decode('utf-8') for element in output]) 182 183 pattern_width = r'width=(\d+)' 184 pattern_height = r'height=(\d+)' 185 pattern_fps = r'r_frame_rate=(\d+)/(\d+)' 186 187 # Search for values in the ffprobe output 188 match_width = re.search(pattern_width, output, flags=re.MULTILINE) 189 match_height = re.search(pattern_height, output, flags=re.MULTILINE) 190 match_fps = re.search(pattern_fps, output, flags=re.MULTILINE) 191 192 # Extraction des valeurs 193 if match_width: 194 width = int(match_width.group(1)) 195 else: 196 raise VideoIO.VideoIOException("Unable to get geometry of '" + filename + "'") 197 198 if match_height: 199 height = int(match_height.group(1)) 200 else: 201 raise VideoIO.VideoIOException("Unable to get geometry of '" + filename + "'") 202 203 if match_fps: 204 numerator = float(match_fps.group(1)) 205 denominator = float(match_fps.group(2)) 206 fps = numerator / denominator 207 else: 208 raise VideoIO.VideoIOException("Unable to get frame rate (fps) of '" + filename + "'") 209 210 return (width, height, fps) 211 212 # Attributes 213 mode: PipeMode 214 """ Pipemode of the current object (default PipeMode.UNK_MODE)""" 215 216 loglevel: int 217 """ loglevel of the underlying ffmpeg backend for this object (default 16)""" 218 219 debug: bool 220 """ debug flag for this object (print debut info, default False)""" 221 222 width: int 223 """ width of images (default -1) """ 224 225 height: int 226 """ height of images (default -1) """ 227 228 fps: float 229 """ fps of video (default -1.0) """ 230 231 pipe: sp.Popen 232 """ pipe object to ffmpeg/ffprobe (default None)""" 233 234 shape: tuple 235 """ Shape of images (default (None, None, None))""" 236 237 imageSize: int 238 """ Weight in bytes of one image (default -1)""" 239 240 filename: str 241 """ Filename of the video file (default None)""" 242 243 frame_counter: FrameCounter 244 """ `Framecounter` object to count ellapsed time (default None)""" 245 246 def __init__(self, *, logLevel = 16, debug = False): 247 """ 248 Create a VideoIO object giving ffmpeg/ffrobe loglevel and defining debug mode 249 250 Parameters 251 ---------- 252 log_level: int (default 16) 253 Log level to pass to the underlying ffmpeg/ffprobe command. 254 255 debug: bool (default (False) 256 Show debug info. while processing video 257 """ 258 259 self.mode = PipeMode.UNK_MODE 260 self.logLevel = logLevel 261 self.debug = debug 262 263 # Call init() method 264 self.init() 265 266 def init(self): 267 """ 268 Init or reinit a VideoIO object. 269 """ 270 self.width = -1 271 self.height = -1 272 self.fps = -1.0 273 self.pipe = None 274 self.shape = (None, None, None) 275 self.imageSize = -1 276 self.filename = None 277 self.frame_counter = None 278 279 _repr_exclude = {"pipe"} 280 """ List of excluded attribute for string conversion. """ 281 282 # converting the object to a string representation 283 def __repr__(self): 284 """ 285 Convert object (excluding attributes in _repr_exclude) to string representation. 286 """ 287 attrs = ", ".join( 288 f"{k}={v!r}" 289 for k, v in self.__dict__.items() 290 if k not in self._repr_exclude 291 ) 292 return f"{self.__class__.__name__}({attrs})" 293 294 __str__ = __repr__ 295 """ String representation """ 296 297 def get_elapsed_time_as_str(self) -> str: 298 """ 299 Method to get elapsed time (float value) as str from `frame_counter` attribute. 300 301 Returns 302 ---------- 303 str or None 304 Elapsed time (float value) as str, "15.500" for instance for 15 secondes and 500 milliseconds 305 None if no frame counter are available. 306 """ 307 if self.frame_counter is None: 308 return None 309 return self.frame_counter.get_elapsed_time_as_str() 310 311 def get_formated_elapsed_time_as_str(self,show_ms=True) -> str: 312 """ 313 Method to get elapsed time (hour format) as str from `frame_counter` attribute. 314 315 Returns 316 ---------- 317 str or None 318 Elapsed time (float value) as str, "00:00:15.500" for instance for 15 secondes and 500 milliseconds 319 None if no frame counter are available. 320 """ 321 if self.frame_counter is None: 322 return None 323 return self.frame_counter.get_formated_elapsed_time_as_str() 324 325 def get_elapsed_time(self) -> float: 326 """ 327 Method to get elapsed time as float value rounded to 3 decimals (millisecond) from `frame_counter` attribute. 328 329 Returns 330 ---------- 331 float or None 332 Elapsed time (float value) as str, 15.500 for instance for 15 secondes and 500 milliseconds 333 None if no frame counter are available. 334 """ 335 if self.frame_counter is None: 336 return None 337 return self.frame_counter.get_elapsed_time() 338 339 def is_opened(self) -> bool: 340 """ 341 Method to get status of the underlying pipe to ffmpeg. 342 343 Returns 344 ---------- 345 bool 346 True if pipe is opened (reading or writing mode), False if not. 347 """ 348 # is the pipe opened? 349 if self.pipe is not None and self.pipe.poll() is None: 350 return True 351 352 return False 353 354 def close(self) -> None: 355 """ 356 Method to close current pipe to ffmpeg (if any). Ffmpeg/ffprobe will be terminated. Object can be reused using open or create methods. 357 """ 358 if self.pipe is not None: 359 if self.mode == PipeMode.WRITE_MODE: 360 # killing will make ffmpeg not finish properly the job, close the pipe 361 # to let it know that no more data are comming 362 self.pipe.stdin.close() 363 else: # self.mode == PipeMode.READ_MODE 364 # in read mode, no need to be nice, send SIGTERM on Linux,/Kill it on windows 365 self.pipe.kill() 366 367 # wait for subprocess to end 368 self.pipe.wait() 369 370 # reinit object for later use 371 self.init() 372 373 def create(self, filename, width, height, fps, *, writeOverExistingFile = False, 374 inputEncoding = PixelFormat.GBR24, encodingParams = None ) -> bool: 375 """ 376 Method to create a video using parametrized access through ffmpeg. Importante note: calling create 377 on a VideoIO will close any former open video. 378 379 Parameters 380 ---------- 381 filename: str or path 382 filename of path to the file (mp4, avi, ...) 383 384 width: int 385 If defined as a positive value, width of output images will be set to this value. 386 387 height: int 388 If defined as a positive value, height of output images will be set to this value. 389 390 fps: 391 If defined as a positive value, fps of output video will be set to this value. 392 393 inputEncoding: PixelFormat optional (default PixelFormat.BGR24) 394 Define order of channels for channels. Possible values are PixelFormat.BGR24 or PixelFormat.RGB24. 395 396 encodingParams: str optional (default None) 397 Parameter to pass to ffmpeg to encode video like video filters. 398 399 Returns 400 ---------- 401 bool 402 Was the creation successfull 403 """ 404 405 # Close if already opened 406 self.close() 407 408 # Set geometry/fps of the video stream from params 409 self.width = int(width) 410 self.height = int(height) 411 self.fps = float(fps) 412 413 # Check params 414 if self.width <= 0 or self.height <= 0 or self.fps <= 0.0: 415 raise self.VideoIOException("Bad parameters: width={}, height={}, fps={:3f}".format(self.width,self.height,self.fps)) 416 417 # Params are ok, set shape and image size 418 self.shape = (self.height,self.width,3) 419 self.imageSize = self.height * self.width * 3 420 421 # Video params are set, open the video 422 cmd = [self.videoProgram] # ffmpeg 423 424 if writeOverExistingFile == True: 425 cmd.extend(['-y']) 426 427 cmd.extend(['-hide_banner', 428 '-nostats', 429 '-loglevel', str(self.logLevel), 430 '-f', 'rawvideo', '-vcodec', 'rawvideo', '-pix_fmt', inputEncoding.value, 431 '-video_size', f"{self.width}x{self.height}", 432 '-r', "{:.3f}".format(self.fps), 433 '-i', '-']) 434 435 if encodingParams is not None: 436 cmd.extend(encodingParams.split()) 437 438 # Video filename converted to str (for Path values) 439 cmd.extend( ['-an', str(filename) ] ) 440 441 if self.debug == True: 442 print( ' '.join(cmd), file=sys.stderr ) 443 444 # store filename and set mode 445 self.filename = str(filename) 446 self.mode = PipeMode.WRITE_MODE 447 448 # Call ffmpeg in write mode 449 try: 450 self.pipe = sp.Popen(cmd, stdin=sp.PIPE) 451 self.frame_counter = FrameCounter(self.fps) 452 except Exception as e: 453 # if pipe failed, reinit object and raise exception 454 self.init() 455 raise 456 457 return True 458 459 def open( self, filename, *, width = -1, height = -1, fps = -1.0, outputEncoding = PixelFormat.GBR24, 460 decodingParams = None, start_time = 0.0 ) -> bool: 461 """ 462 Method to read video using parametrized access through ffmpeg. Importante note: calling open 463 on a VideoIO will close any former open video. 464 465 Parameters 466 ---------- 467 filename: str or path 468 filename of path to the file (mp4, avi, ...) 469 470 width: int optional (default -1) 471 If defined as a positive value, width of input images will be converted to this value. 472 473 height: int optional (default -1) 474 If defined as a positive value, height of input images will be converted to this value. 475 476 fps: float optional (default -1.0) 477 If defined as a positive value, fps of input video will be converted to this value. 478 479 outputEncoding: PixelFormat optional (default PixelFormat.BGR24) 480 Define order of channels for channels. Possible values are PixelFormat.BGR24 or PixelFormat.RGB24. 481 482 decodingParams: str optional (default None) 483 Parameter to pass to ffmpeg to decode video like filters. 484 485 start_time: float optional (default 0.0) 486 Define the reading start time. If not set, reading at beginning of the video. 487 488 Returns 489 ---------- 490 bool 491 Was the opening successfull 492 """ 493 494 # Close if already opened 495 self.close() 496 497 # Force conversion of parameters 498 width = int(width) 499 height = int(height) 500 fps = float(fps) 501 502 # get parameters from video 503 self.width, self.height, self.fps = self.getVideoParams(str(filename)) 504 505 # check if parameters ask to overide video parameters 506 # TODO: add support for negative value (automatic preservation of aspect ratio) 507 if width > 0: 508 self.width = width 509 if height > 0: 510 self.height = height 511 if fps > 0.0: 512 self.fps = fps 513 514 # Params are ok, set shape and image size 515 self.shape = (self.height,self.width,3) 516 self.imageSize = self.height * self.width * 3 517 518 # Video params are set, open the video 519 cmd = [self.videoProgram, # ffmpeg 520 '-hide_banner', 521 '-nostats', 522 '-loglevel', str(self.logLevel)] 523 524 if start_time < 0.0: 525 pass 526 elif start_time > 0.0: 527 cmd.extend(["-ss", f"{start_time}"]) # set start time if any 528 529 cmd.extend( ['-i', str(filename)] ) 530 531 video_filters = '' # empty 532 if decodingParams is not None: 533 decodingParams = decodingParams.split() 534 # walk over decodingParams for specific params 535 i = 0 536 while i < len(decodingParams): 537 if decodingParams[i] == '-vf': 538 decodingParams.pop(i) # remove '-vf' 539 if i < len(decodingParams): 540 video_filters += ','+decodingParams.pop(i) # remove parameters from list too 541 # to do : add support to other option like -y 542 else: 543 i += 1 544 else: 545 decodingParams = [] 546 547 cmd.extend( ['-vf', f'scale={self.width}:{self.height}{video_filters}', # rescale (or not if shape is original one), add specific video filters 548 *(decodingParams), 549 '-f', 'rawvideo', '-vcodec', 'rawvideo', '-pix_fmt', outputEncoding.value, # input expected coding 550 '-an', # no audio 551 '-r', f"{self.fps}", 552 '-' # output to stdout 553 ] ) 554 555 if self.debug == True: 556 print( ' '.join(cmd) ) 557 558 # store filename and set mode to READ_MODE 559 self.filename = str(filename) 560 self.mode = PipeMode.READ_MODE 561 562 # call ffmpeg in read mode 563 try: 564 self.pipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 565 self.frame_counter = FrameCounter(self.fps) 566 if start_time > 0.0: 567 self.frame_counter += start_time # adding with float means adding time 568 except Exception as e: 569 # if pipe failed, reinit object and raise exception 570 self.init() 571 raise 572 573 return True 574 575 def read_frame(self, with_timestamps = False): 576 """ 577 Read next frame from the video 578 579 Parameters 580 ---------- 581 with_timestamps: bool optional (default False) 582 If set to True, the method returns a FrameContainer with the image and an array containing the associated timestamp(s) 583 584 Returns 585 ---------- 586 nparray or FrameContainer 587 An image of shape (3,width,height). if with_timestamps is True, the return object is a FrameContainer with the image in ``FrameContainer.data`` and 588 the associated timestamp in ``FrameContainer.timestamps`` as an array (one element for one frame). 589 """ 590 591 if self.pipe is None: 592 raise self.VideoIOException("No pipe opened to {}. Call open(...) before reading a frame.".format(self.videoProgram)) 593 # - pipe is in write mode 594 if self.mode != PipeMode.READ_MODE: 595 raise self.VideoIOException("Pipe to {} for '{}' not opened in read mode.".format(self.videoProgram, self.filename)) 596 597 if with_timestamps: 598 # get elapsed time in video, it is time of next frame(s) 599 current_elapsed_time = self.get_elapsed_time() 600 601 # read rgb image from pipe 602 buffer = self.pipe.stdout.read(self.imageSize) 603 if len(buffer) != self.imageSize: 604 # Incomplete image, ffmpeg have been stopped or killed, do not return an incomplete frame 605 # not considered as an error, no more frame, no exception 606 return None 607 608 # get numpy UINT8 array from buffer 609 rgbImage = np.frombuffer(buffer, dtype = np.uint8).reshape(self.shape) 610 611 # increase frame_counter 612 self.frame_counter.frame_count += 1 613 614 # say to gc that this buffer is no longer needed 615 del buffer 616 617 if with_timestamps: 618 return FrameContainer(1,rgbImage,self.fps,current_elapsed_time) 619 620 return rgbImage 621 622 def read_batch(self, number_of_frames, with_timestamps = False) -> Union[np.array, FrameContainer]: 623 """ 624 Read next batch of images from the video 625 626 Parameters 627 ---------- 628 number_of_frames: int 629 Number of desired images within the batch. The last batch of the video may have less images. 630 631 with_timestamps: bool optional (default False) 632 If set to True, the method returns a FrameContainer with the batch and the an array containing the associated timestamps to frames 633 634 Returns 635 ---------- 636 nparray or FrameContainer 637 A batch of images of shape (n,3,width,height). if with_timestamps is True, the return object is a FrameContainer with the batch in ``FrameContainer.data`` and 638 the associated timestamps in ``FrameContainer.timestamps`` as an array (one element for each frame). 639 """ 640 641 if self.pipe is None: 642 raise self.VideoIOException("No pipe opened to {}. Call open(...) before reading frames.".format(self.videoProgram)) 643 # - pipe is in write mode 644 if self.mode != PipeMode.READ_MODE: 645 raise self.VideoIOException("Pipe to {} for '{}' not opened in read mode.".format(self.videoProgram, self.filename)) 646 647 if with_timestamps: 648 # get elapsed time in video, it is time of next frame(s) 649 current_elapsed_time = self.get_elapsed_time() 650 651 # try to read complete batch 652 buffer = self.pipe.stdout.read(self.imageSize*number_of_frames) 653 654 # check if we have at least 1 image 655 if len(buffer) < self.imageSize: 656 # ffmpag backend have been stopped or killed 657 # not considered as an error, no more frame, no exception 658 return None 659 660 # compute actual number of Frames 661 actualNbFrames = len(buffer)//self.imageSize 662 663 # get and reshape batch from buffer 664 batch = np.frombuffer(buffer, dtype = np.uint8).reshape((actualNbFrames, self.height, self.width, 3)) 665 666 # increase frame_counter 667 self.frame_counter.frame_count += actualNbFrames 668 669 # say to gc that this buffer is no longer needed 670 del buffer 671 672 if with_timestamps: 673 return FrameContainer(actualNbFrames, batch, self.fps, current_elapsed_time) 674 675 return batch 676 677 def write_frame(self, image) -> bool: 678 """ 679 Write an image to the video 680 681 Parameters 682 ---------- 683 image: nparray 684 The image of shape (3, width, height) to write to the video file in the PixelFormat provided when create was called. 685 686 Returns 687 ---------- 688 bool 689 Writing was successful or not. 690 """ 691 692 # Check params 693 # - pipe exists 694 if self.pipe is None: 695 raise self.VideoIOException("No pipe opened to {}. Call create(...) before writing frames.".format(self.videoProgram)) 696 # - pipe is in write mode 697 if self.mode != PipeMode.WRITE_MODE: 698 raise self.VideoIOException("Pipe to {} for '{}' not opened in write mode.".format(self.videoProgram, self.filename)) 699 # - shape of image is fine, thus we have pixels for a full compatible frame 700 if image.shape != self.shape: 701 raise self.VideoIOException("Wong image shape: {} expected {}.".format(image.shape,self.shape)) 702 # - type of data is UINT8 703 if image.dtype != np.uint8: 704 raise self.VideoIOException("Wong pixel type: {} expected np.uint8.".format(image.dtype)) 705 706 # write frame 707 buffer = image.tobytes() 708 if self.pipe.stdin.write( buffer ) < self.imageSize: 709 print( "Error writing frame to" ) 710 return False 711 712 # increase frame_counter 713 self.frame_counter.frame_count += 1 714 715 # say to gc that this buffer is no longer needed 716 del buffer 717 718 return True 719 720 def write_batch(self, batch) -> bool: 721 """ 722 Write a batch of images to the video 723 724 Parameters 725 ---------- 726 batch: nparray 727 A batch of images to write to the video file in the PixelFormat provided when create was called. 728 729 Returns 730 ---------- 731 bool 732 Writing was successful or not. 733 """ 734 735 # Check params 736 # - pipe exists 737 if self.pipe is None: 738 raise self.VideoIOException("No pipe opened to {}. Call create(...) before writing frames.".format(self.videoProgram)) 739 # - pipe is in write mode 740 if self.mode != PipeMode.WRITE_MODE: 741 raise self.VideoIOException("Pipe to {} for '{}' not opened in write mode.".format(self.videoProgram, self.filename)) 742 # - shape of images in batch is fine 743 if batch.shape[-3:] != self.shape: 744 raise self.VideoIOException("Wrong image shape in batch: {} expected {}.".format(batch.shape[-3:], self.shape)) 745 # - we have the right amount of pixels for the full batch 746 if batch.size != (batch.shape[0]*self.imageSize): 747 raise self.VideoIOException("Wrong number of pixels in batch: {} expected {}.".format(batch.shape[-3:], self.imageSize)) 748 749 # write frame 750 buffer = batch.tobytes() 751 if self.pipe.stdin.write( buffer ) < batch.size: 752 # say to gc that this buffer is no longer needed 753 del buffer 754 raise self.VideoIOException("Error writing batch to '{}'.".format(self.filename)) 755 756 # increase frame_counter 757 self.frame_counter.frame_count += batch.shape[0] 758 759 # say to gc that this buffer is no longer needed 760 del buffer 761 762 return True 763 764 def iter_frames(self, with_timestamps = False): 765 """ 766 Method to iterate on video frames using VideoIO obj. 767 for frame in obj.iter_frames(): 768 .... 769 770 Parameters 771 ---------- 772 with_timestamps: bool optional (default False) 773 If set to True, the method returns a FrameContainer with the batch and an array containing the associated timestamps to frames 774 """ 775 776 try: 777 if self.mode == PipeMode.READ_MODE: 778 while self.isOpened(): 779 frame = self.readFrame(with_timestamps) 780 if frame is not None: 781 yield frame 782 finally: 783 self.close() 784 785 def iter_batches(self, batch_size : int, with_timestamps = False): 786 """ 787 Method to iterate on batch of frames using VideoIO obj. 788 for image_batch in obj.iter_batches(): 789 .... 790 791 Parameters 792 ---------- 793 with_timestamps: bool optional (default False) 794 If set to True, the method returns a FrameContainer with the batch and the an array containing the associated timestamps to frames 795 """ 796 try: 797 if self.mode == PipeMode.READ_MODE: 798 while self.isOpened(): 799 batch = self.readBatch(batch_size, with_timestamps) 800 if batch is not None: 801 yield batch 802 finally: 803 self.close() 804 805 # function aliases to be compliant with original C++ version 806 getVideoTimeInSec = get_time_in_sec 807 getVideoParams = get_params 808 get_video_time_in_sec = get_time_in_sec 809 get_video_params = get_params 810 isOpened = is_opened 811 readFrame = read_frame 812 readBatch = read_batch 813 writeFrame = write_frame 814 writeBatch = write_batch
32class VideoIO: 33 # "static" variables to ffmpeg, ffprobe executables 34 videoProgram, paramProgram = static_ffmpeg.run.get_or_fetch_platform_executables_else_raise() 35 36 class VideoIOException(Exception): 37 """ 38 Dedicated exception class for VideoIO class. 39 """ 40 def __init__(self, message="Error while reading/writing video occurs"): 41 self.message = message 42 super().__init__(self.message) 43 44 class PixelFormat(Enum): 45 """ 46 Enum class for supported input video type: GBR 24 bits or RGB 24 bis. 47 """ 48 GBR24 = 'bgr24' # default format 49 RGB24 = 'rgb24' 50 51 @classmethod 52 def reader(cls, filename, *, loglevel = 16, debug = False, **kwargs): 53 """ 54 Create and open a VideoIO object in reader mode (read a video file) 55 56 See `VideoIO.open` for the full list 57 of accepted parameters. 58 """ 59 reader = cls(logLevel=loglevel, debug=debug) 60 reader.open(filename,**kwargs) 61 return reader 62 63 @classmethod 64 def writer(cls, filename, width, height, fps, *, loglevel = 16, debug = False, **kwargs): 65 """ 66 Create and open a VideoIO object in writer mode (write a video file) 67 68 See `VideoIO.create` for the full list 69 of accepted parameters. 70 """ 71 writer = cls(logLevel=loglevel, debug=debug) 72 writer.create(filename, width, height, fps, **kwargs) 73 return writer 74 75 # standard method 76 def get_corresponding_writer(self, filename, **kwargs): 77 """ 78 Method to get writer for a video file with same width, height, fps as the current one VideoIO object 79 80 See `VideoIO.create` for the full list 81 of accepted parameters. 82 """ 83 return VideoIO.writer(filename, self.width, self.height, self.fps, **kwargs) 84 85 # To use with context manager "with VideoIO.reader(...) as f:' for instance 86 def __enter__(self): 87 """ 88 Method call at initialisation of a context manager like "with VideoIO.reader(...) as f:' for instance 89 """ 90 # simply return myself 91 return self 92 93 def __exit__(self, exc_type, exc_val, exc_tb): 94 """ 95 Method call when existing of a context manager like "with VideoIO.reader(...) as f:' for instance 96 """ 97 # close VideoIO 98 self.close() 99 return False 100 101 @staticmethod 102 def get_time_in_sec(filename, *, debug=False, logLevel=16): 103 """ 104 Static method to get length of a video file in seconds including milliseconds as decimal part. 105 106 Parameters 107 ---------- 108 filename : str or path 109 Video file name. 110 111 debug : bool (default False) 112 Show debug info. 113 114 log_level: int (default 16) 115 Log level to pass to the underlying ffmpeg/ffprobe command. 116 117 Returns 118 ---------- 119 float 120 Length in seconds of video file (including milliseconds as decimal part) 121 """ 122 123 cmd = [VideoIO.paramProgram, # ffprobe 124 '-hide_banner', 125 '-loglevel', str(logLevel), 126 '-show_entries', 'format=duration', 127 '-of', 'default=noprint_wrappers=1:nokey=1', 128 str(filename) 129 ] 130 131 if debug == True: 132 print(' '.join(cmd)) 133 134 # call ffprobe and get params in one single line 135 lpipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 136 output = lpipe.stdout.readlines() 137 lpipe.terminate() 138 # transform Bytes output to one single string 139 output = ''.join( [element.decode('utf-8') for element in output]) 140 141 try: 142 return float(output) 143 except (ValueError, TypeError): 144 return None 145 146 @staticmethod 147 def get_params(filename, *, debug=False, logLevel=16): 148 """ 149 Static method to get params (width, height, fps) from a video file. 150 151 Parameters 152 ---------- 153 filename: str or path 154 Video filename. 155 156 debug: bool (default (False) 157 Show debug info. 158 159 log_level: int (default 16) 160 Log level to pass to the underlying ffmpeg/ffprobe command. 161 162 Returns 163 ---------- 164 tuple 165 Tuple containing (width, height, fps) of the video 166 """ 167 cmd = [VideoIO.paramProgram, # ffprobe 168 '-hide_banner', 169 '-loglevel', str(logLevel), 170 '-show_entries', 'stream=width,height,r_frame_rate', 171 str(filename) 172 ] 173 174 if debug == True: 175 print(' '.join(cmd)) 176 177 # call ffprobe and get params in one single line 178 lpipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 179 output = lpipe.stdout.readlines() 180 lpipe.terminate() 181 # transform Bytes output to one single string 182 output = ''.join( [element.decode('utf-8') for element in output]) 183 184 pattern_width = r'width=(\d+)' 185 pattern_height = r'height=(\d+)' 186 pattern_fps = r'r_frame_rate=(\d+)/(\d+)' 187 188 # Search for values in the ffprobe output 189 match_width = re.search(pattern_width, output, flags=re.MULTILINE) 190 match_height = re.search(pattern_height, output, flags=re.MULTILINE) 191 match_fps = re.search(pattern_fps, output, flags=re.MULTILINE) 192 193 # Extraction des valeurs 194 if match_width: 195 width = int(match_width.group(1)) 196 else: 197 raise VideoIO.VideoIOException("Unable to get geometry of '" + filename + "'") 198 199 if match_height: 200 height = int(match_height.group(1)) 201 else: 202 raise VideoIO.VideoIOException("Unable to get geometry of '" + filename + "'") 203 204 if match_fps: 205 numerator = float(match_fps.group(1)) 206 denominator = float(match_fps.group(2)) 207 fps = numerator / denominator 208 else: 209 raise VideoIO.VideoIOException("Unable to get frame rate (fps) of '" + filename + "'") 210 211 return (width, height, fps) 212 213 # Attributes 214 mode: PipeMode 215 """ Pipemode of the current object (default PipeMode.UNK_MODE)""" 216 217 loglevel: int 218 """ loglevel of the underlying ffmpeg backend for this object (default 16)""" 219 220 debug: bool 221 """ debug flag for this object (print debut info, default False)""" 222 223 width: int 224 """ width of images (default -1) """ 225 226 height: int 227 """ height of images (default -1) """ 228 229 fps: float 230 """ fps of video (default -1.0) """ 231 232 pipe: sp.Popen 233 """ pipe object to ffmpeg/ffprobe (default None)""" 234 235 shape: tuple 236 """ Shape of images (default (None, None, None))""" 237 238 imageSize: int 239 """ Weight in bytes of one image (default -1)""" 240 241 filename: str 242 """ Filename of the video file (default None)""" 243 244 frame_counter: FrameCounter 245 """ `Framecounter` object to count ellapsed time (default None)""" 246 247 def __init__(self, *, logLevel = 16, debug = False): 248 """ 249 Create a VideoIO object giving ffmpeg/ffrobe loglevel and defining debug mode 250 251 Parameters 252 ---------- 253 log_level: int (default 16) 254 Log level to pass to the underlying ffmpeg/ffprobe command. 255 256 debug: bool (default (False) 257 Show debug info. while processing video 258 """ 259 260 self.mode = PipeMode.UNK_MODE 261 self.logLevel = logLevel 262 self.debug = debug 263 264 # Call init() method 265 self.init() 266 267 def init(self): 268 """ 269 Init or reinit a VideoIO object. 270 """ 271 self.width = -1 272 self.height = -1 273 self.fps = -1.0 274 self.pipe = None 275 self.shape = (None, None, None) 276 self.imageSize = -1 277 self.filename = None 278 self.frame_counter = None 279 280 _repr_exclude = {"pipe"} 281 """ List of excluded attribute for string conversion. """ 282 283 # converting the object to a string representation 284 def __repr__(self): 285 """ 286 Convert object (excluding attributes in _repr_exclude) to string representation. 287 """ 288 attrs = ", ".join( 289 f"{k}={v!r}" 290 for k, v in self.__dict__.items() 291 if k not in self._repr_exclude 292 ) 293 return f"{self.__class__.__name__}({attrs})" 294 295 __str__ = __repr__ 296 """ String representation """ 297 298 def get_elapsed_time_as_str(self) -> str: 299 """ 300 Method to get elapsed time (float value) as str from `frame_counter` attribute. 301 302 Returns 303 ---------- 304 str or None 305 Elapsed time (float value) as str, "15.500" for instance for 15 secondes and 500 milliseconds 306 None if no frame counter are available. 307 """ 308 if self.frame_counter is None: 309 return None 310 return self.frame_counter.get_elapsed_time_as_str() 311 312 def get_formated_elapsed_time_as_str(self,show_ms=True) -> str: 313 """ 314 Method to get elapsed time (hour format) as str from `frame_counter` attribute. 315 316 Returns 317 ---------- 318 str or None 319 Elapsed time (float value) as str, "00:00:15.500" for instance for 15 secondes and 500 milliseconds 320 None if no frame counter are available. 321 """ 322 if self.frame_counter is None: 323 return None 324 return self.frame_counter.get_formated_elapsed_time_as_str() 325 326 def get_elapsed_time(self) -> float: 327 """ 328 Method to get elapsed time as float value rounded to 3 decimals (millisecond) from `frame_counter` attribute. 329 330 Returns 331 ---------- 332 float or None 333 Elapsed time (float value) as str, 15.500 for instance for 15 secondes and 500 milliseconds 334 None if no frame counter are available. 335 """ 336 if self.frame_counter is None: 337 return None 338 return self.frame_counter.get_elapsed_time() 339 340 def is_opened(self) -> bool: 341 """ 342 Method to get status of the underlying pipe to ffmpeg. 343 344 Returns 345 ---------- 346 bool 347 True if pipe is opened (reading or writing mode), False if not. 348 """ 349 # is the pipe opened? 350 if self.pipe is not None and self.pipe.poll() is None: 351 return True 352 353 return False 354 355 def close(self) -> None: 356 """ 357 Method to close current pipe to ffmpeg (if any). Ffmpeg/ffprobe will be terminated. Object can be reused using open or create methods. 358 """ 359 if self.pipe is not None: 360 if self.mode == PipeMode.WRITE_MODE: 361 # killing will make ffmpeg not finish properly the job, close the pipe 362 # to let it know that no more data are comming 363 self.pipe.stdin.close() 364 else: # self.mode == PipeMode.READ_MODE 365 # in read mode, no need to be nice, send SIGTERM on Linux,/Kill it on windows 366 self.pipe.kill() 367 368 # wait for subprocess to end 369 self.pipe.wait() 370 371 # reinit object for later use 372 self.init() 373 374 def create(self, filename, width, height, fps, *, writeOverExistingFile = False, 375 inputEncoding = PixelFormat.GBR24, encodingParams = None ) -> bool: 376 """ 377 Method to create a video using parametrized access through ffmpeg. Importante note: calling create 378 on a VideoIO will close any former open video. 379 380 Parameters 381 ---------- 382 filename: str or path 383 filename of path to the file (mp4, avi, ...) 384 385 width: int 386 If defined as a positive value, width of output images will be set to this value. 387 388 height: int 389 If defined as a positive value, height of output images will be set to this value. 390 391 fps: 392 If defined as a positive value, fps of output video will be set to this value. 393 394 inputEncoding: PixelFormat optional (default PixelFormat.BGR24) 395 Define order of channels for channels. Possible values are PixelFormat.BGR24 or PixelFormat.RGB24. 396 397 encodingParams: str optional (default None) 398 Parameter to pass to ffmpeg to encode video like video filters. 399 400 Returns 401 ---------- 402 bool 403 Was the creation successfull 404 """ 405 406 # Close if already opened 407 self.close() 408 409 # Set geometry/fps of the video stream from params 410 self.width = int(width) 411 self.height = int(height) 412 self.fps = float(fps) 413 414 # Check params 415 if self.width <= 0 or self.height <= 0 or self.fps <= 0.0: 416 raise self.VideoIOException("Bad parameters: width={}, height={}, fps={:3f}".format(self.width,self.height,self.fps)) 417 418 # Params are ok, set shape and image size 419 self.shape = (self.height,self.width,3) 420 self.imageSize = self.height * self.width * 3 421 422 # Video params are set, open the video 423 cmd = [self.videoProgram] # ffmpeg 424 425 if writeOverExistingFile == True: 426 cmd.extend(['-y']) 427 428 cmd.extend(['-hide_banner', 429 '-nostats', 430 '-loglevel', str(self.logLevel), 431 '-f', 'rawvideo', '-vcodec', 'rawvideo', '-pix_fmt', inputEncoding.value, 432 '-video_size', f"{self.width}x{self.height}", 433 '-r', "{:.3f}".format(self.fps), 434 '-i', '-']) 435 436 if encodingParams is not None: 437 cmd.extend(encodingParams.split()) 438 439 # Video filename converted to str (for Path values) 440 cmd.extend( ['-an', str(filename) ] ) 441 442 if self.debug == True: 443 print( ' '.join(cmd), file=sys.stderr ) 444 445 # store filename and set mode 446 self.filename = str(filename) 447 self.mode = PipeMode.WRITE_MODE 448 449 # Call ffmpeg in write mode 450 try: 451 self.pipe = sp.Popen(cmd, stdin=sp.PIPE) 452 self.frame_counter = FrameCounter(self.fps) 453 except Exception as e: 454 # if pipe failed, reinit object and raise exception 455 self.init() 456 raise 457 458 return True 459 460 def open( self, filename, *, width = -1, height = -1, fps = -1.0, outputEncoding = PixelFormat.GBR24, 461 decodingParams = None, start_time = 0.0 ) -> bool: 462 """ 463 Method to read video using parametrized access through ffmpeg. Importante note: calling open 464 on a VideoIO will close any former open video. 465 466 Parameters 467 ---------- 468 filename: str or path 469 filename of path to the file (mp4, avi, ...) 470 471 width: int optional (default -1) 472 If defined as a positive value, width of input images will be converted to this value. 473 474 height: int optional (default -1) 475 If defined as a positive value, height of input images will be converted to this value. 476 477 fps: float optional (default -1.0) 478 If defined as a positive value, fps of input video will be converted to this value. 479 480 outputEncoding: PixelFormat optional (default PixelFormat.BGR24) 481 Define order of channels for channels. Possible values are PixelFormat.BGR24 or PixelFormat.RGB24. 482 483 decodingParams: str optional (default None) 484 Parameter to pass to ffmpeg to decode video like filters. 485 486 start_time: float optional (default 0.0) 487 Define the reading start time. If not set, reading at beginning of the video. 488 489 Returns 490 ---------- 491 bool 492 Was the opening successfull 493 """ 494 495 # Close if already opened 496 self.close() 497 498 # Force conversion of parameters 499 width = int(width) 500 height = int(height) 501 fps = float(fps) 502 503 # get parameters from video 504 self.width, self.height, self.fps = self.getVideoParams(str(filename)) 505 506 # check if parameters ask to overide video parameters 507 # TODO: add support for negative value (automatic preservation of aspect ratio) 508 if width > 0: 509 self.width = width 510 if height > 0: 511 self.height = height 512 if fps > 0.0: 513 self.fps = fps 514 515 # Params are ok, set shape and image size 516 self.shape = (self.height,self.width,3) 517 self.imageSize = self.height * self.width * 3 518 519 # Video params are set, open the video 520 cmd = [self.videoProgram, # ffmpeg 521 '-hide_banner', 522 '-nostats', 523 '-loglevel', str(self.logLevel)] 524 525 if start_time < 0.0: 526 pass 527 elif start_time > 0.0: 528 cmd.extend(["-ss", f"{start_time}"]) # set start time if any 529 530 cmd.extend( ['-i', str(filename)] ) 531 532 video_filters = '' # empty 533 if decodingParams is not None: 534 decodingParams = decodingParams.split() 535 # walk over decodingParams for specific params 536 i = 0 537 while i < len(decodingParams): 538 if decodingParams[i] == '-vf': 539 decodingParams.pop(i) # remove '-vf' 540 if i < len(decodingParams): 541 video_filters += ','+decodingParams.pop(i) # remove parameters from list too 542 # to do : add support to other option like -y 543 else: 544 i += 1 545 else: 546 decodingParams = [] 547 548 cmd.extend( ['-vf', f'scale={self.width}:{self.height}{video_filters}', # rescale (or not if shape is original one), add specific video filters 549 *(decodingParams), 550 '-f', 'rawvideo', '-vcodec', 'rawvideo', '-pix_fmt', outputEncoding.value, # input expected coding 551 '-an', # no audio 552 '-r', f"{self.fps}", 553 '-' # output to stdout 554 ] ) 555 556 if self.debug == True: 557 print( ' '.join(cmd) ) 558 559 # store filename and set mode to READ_MODE 560 self.filename = str(filename) 561 self.mode = PipeMode.READ_MODE 562 563 # call ffmpeg in read mode 564 try: 565 self.pipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 566 self.frame_counter = FrameCounter(self.fps) 567 if start_time > 0.0: 568 self.frame_counter += start_time # adding with float means adding time 569 except Exception as e: 570 # if pipe failed, reinit object and raise exception 571 self.init() 572 raise 573 574 return True 575 576 def read_frame(self, with_timestamps = False): 577 """ 578 Read next frame from the video 579 580 Parameters 581 ---------- 582 with_timestamps: bool optional (default False) 583 If set to True, the method returns a FrameContainer with the image and an array containing the associated timestamp(s) 584 585 Returns 586 ---------- 587 nparray or FrameContainer 588 An image of shape (3,width,height). if with_timestamps is True, the return object is a FrameContainer with the image in ``FrameContainer.data`` and 589 the associated timestamp in ``FrameContainer.timestamps`` as an array (one element for one frame). 590 """ 591 592 if self.pipe is None: 593 raise self.VideoIOException("No pipe opened to {}. Call open(...) before reading a frame.".format(self.videoProgram)) 594 # - pipe is in write mode 595 if self.mode != PipeMode.READ_MODE: 596 raise self.VideoIOException("Pipe to {} for '{}' not opened in read mode.".format(self.videoProgram, self.filename)) 597 598 if with_timestamps: 599 # get elapsed time in video, it is time of next frame(s) 600 current_elapsed_time = self.get_elapsed_time() 601 602 # read rgb image from pipe 603 buffer = self.pipe.stdout.read(self.imageSize) 604 if len(buffer) != self.imageSize: 605 # Incomplete image, ffmpeg have been stopped or killed, do not return an incomplete frame 606 # not considered as an error, no more frame, no exception 607 return None 608 609 # get numpy UINT8 array from buffer 610 rgbImage = np.frombuffer(buffer, dtype = np.uint8).reshape(self.shape) 611 612 # increase frame_counter 613 self.frame_counter.frame_count += 1 614 615 # say to gc that this buffer is no longer needed 616 del buffer 617 618 if with_timestamps: 619 return FrameContainer(1,rgbImage,self.fps,current_elapsed_time) 620 621 return rgbImage 622 623 def read_batch(self, number_of_frames, with_timestamps = False) -> Union[np.array, FrameContainer]: 624 """ 625 Read next batch of images from the video 626 627 Parameters 628 ---------- 629 number_of_frames: int 630 Number of desired images within the batch. The last batch of the video may have less images. 631 632 with_timestamps: bool optional (default False) 633 If set to True, the method returns a FrameContainer with the batch and the an array containing the associated timestamps to frames 634 635 Returns 636 ---------- 637 nparray or FrameContainer 638 A batch of images of shape (n,3,width,height). if with_timestamps is True, the return object is a FrameContainer with the batch in ``FrameContainer.data`` and 639 the associated timestamps in ``FrameContainer.timestamps`` as an array (one element for each frame). 640 """ 641 642 if self.pipe is None: 643 raise self.VideoIOException("No pipe opened to {}. Call open(...) before reading frames.".format(self.videoProgram)) 644 # - pipe is in write mode 645 if self.mode != PipeMode.READ_MODE: 646 raise self.VideoIOException("Pipe to {} for '{}' not opened in read mode.".format(self.videoProgram, self.filename)) 647 648 if with_timestamps: 649 # get elapsed time in video, it is time of next frame(s) 650 current_elapsed_time = self.get_elapsed_time() 651 652 # try to read complete batch 653 buffer = self.pipe.stdout.read(self.imageSize*number_of_frames) 654 655 # check if we have at least 1 image 656 if len(buffer) < self.imageSize: 657 # ffmpag backend have been stopped or killed 658 # not considered as an error, no more frame, no exception 659 return None 660 661 # compute actual number of Frames 662 actualNbFrames = len(buffer)//self.imageSize 663 664 # get and reshape batch from buffer 665 batch = np.frombuffer(buffer, dtype = np.uint8).reshape((actualNbFrames, self.height, self.width, 3)) 666 667 # increase frame_counter 668 self.frame_counter.frame_count += actualNbFrames 669 670 # say to gc that this buffer is no longer needed 671 del buffer 672 673 if with_timestamps: 674 return FrameContainer(actualNbFrames, batch, self.fps, current_elapsed_time) 675 676 return batch 677 678 def write_frame(self, image) -> bool: 679 """ 680 Write an image to the video 681 682 Parameters 683 ---------- 684 image: nparray 685 The image of shape (3, width, height) to write to the video file in the PixelFormat provided when create was called. 686 687 Returns 688 ---------- 689 bool 690 Writing was successful or not. 691 """ 692 693 # Check params 694 # - pipe exists 695 if self.pipe is None: 696 raise self.VideoIOException("No pipe opened to {}. Call create(...) before writing frames.".format(self.videoProgram)) 697 # - pipe is in write mode 698 if self.mode != PipeMode.WRITE_MODE: 699 raise self.VideoIOException("Pipe to {} for '{}' not opened in write mode.".format(self.videoProgram, self.filename)) 700 # - shape of image is fine, thus we have pixels for a full compatible frame 701 if image.shape != self.shape: 702 raise self.VideoIOException("Wong image shape: {} expected {}.".format(image.shape,self.shape)) 703 # - type of data is UINT8 704 if image.dtype != np.uint8: 705 raise self.VideoIOException("Wong pixel type: {} expected np.uint8.".format(image.dtype)) 706 707 # write frame 708 buffer = image.tobytes() 709 if self.pipe.stdin.write( buffer ) < self.imageSize: 710 print( "Error writing frame to" ) 711 return False 712 713 # increase frame_counter 714 self.frame_counter.frame_count += 1 715 716 # say to gc that this buffer is no longer needed 717 del buffer 718 719 return True 720 721 def write_batch(self, batch) -> bool: 722 """ 723 Write a batch of images to the video 724 725 Parameters 726 ---------- 727 batch: nparray 728 A batch of images to write to the video file in the PixelFormat provided when create was called. 729 730 Returns 731 ---------- 732 bool 733 Writing was successful or not. 734 """ 735 736 # Check params 737 # - pipe exists 738 if self.pipe is None: 739 raise self.VideoIOException("No pipe opened to {}. Call create(...) before writing frames.".format(self.videoProgram)) 740 # - pipe is in write mode 741 if self.mode != PipeMode.WRITE_MODE: 742 raise self.VideoIOException("Pipe to {} for '{}' not opened in write mode.".format(self.videoProgram, self.filename)) 743 # - shape of images in batch is fine 744 if batch.shape[-3:] != self.shape: 745 raise self.VideoIOException("Wrong image shape in batch: {} expected {}.".format(batch.shape[-3:], self.shape)) 746 # - we have the right amount of pixels for the full batch 747 if batch.size != (batch.shape[0]*self.imageSize): 748 raise self.VideoIOException("Wrong number of pixels in batch: {} expected {}.".format(batch.shape[-3:], self.imageSize)) 749 750 # write frame 751 buffer = batch.tobytes() 752 if self.pipe.stdin.write( buffer ) < batch.size: 753 # say to gc that this buffer is no longer needed 754 del buffer 755 raise self.VideoIOException("Error writing batch to '{}'.".format(self.filename)) 756 757 # increase frame_counter 758 self.frame_counter.frame_count += batch.shape[0] 759 760 # say to gc that this buffer is no longer needed 761 del buffer 762 763 return True 764 765 def iter_frames(self, with_timestamps = False): 766 """ 767 Method to iterate on video frames using VideoIO obj. 768 for frame in obj.iter_frames(): 769 .... 770 771 Parameters 772 ---------- 773 with_timestamps: bool optional (default False) 774 If set to True, the method returns a FrameContainer with the batch and an array containing the associated timestamps to frames 775 """ 776 777 try: 778 if self.mode == PipeMode.READ_MODE: 779 while self.isOpened(): 780 frame = self.readFrame(with_timestamps) 781 if frame is not None: 782 yield frame 783 finally: 784 self.close() 785 786 def iter_batches(self, batch_size : int, with_timestamps = False): 787 """ 788 Method to iterate on batch of frames using VideoIO obj. 789 for image_batch in obj.iter_batches(): 790 .... 791 792 Parameters 793 ---------- 794 with_timestamps: bool optional (default False) 795 If set to True, the method returns a FrameContainer with the batch and the an array containing the associated timestamps to frames 796 """ 797 try: 798 if self.mode == PipeMode.READ_MODE: 799 while self.isOpened(): 800 batch = self.readBatch(batch_size, with_timestamps) 801 if batch is not None: 802 yield batch 803 finally: 804 self.close() 805 806 # function aliases to be compliant with original C++ version 807 getVideoTimeInSec = get_time_in_sec 808 getVideoParams = get_params 809 get_video_time_in_sec = get_time_in_sec 810 get_video_params = get_params 811 isOpened = is_opened 812 readFrame = read_frame 813 readBatch = read_batch 814 writeFrame = write_frame 815 writeBatch = write_batch
247 def __init__(self, *, logLevel = 16, debug = False): 248 """ 249 Create a VideoIO object giving ffmpeg/ffrobe loglevel and defining debug mode 250 251 Parameters 252 ---------- 253 log_level: int (default 16) 254 Log level to pass to the underlying ffmpeg/ffprobe command. 255 256 debug: bool (default (False) 257 Show debug info. while processing video 258 """ 259 260 self.mode = PipeMode.UNK_MODE 261 self.logLevel = logLevel 262 self.debug = debug 263 264 # Call init() method 265 self.init()
Create a VideoIO object giving ffmpeg/ffrobe loglevel and defining debug mode
Parameters
log_level: int (default 16) Log level to pass to the underlying ffmpeg/ffprobe command.
debug: bool (default (False) Show debug info. while processing video
51 @classmethod 52 def reader(cls, filename, *, loglevel = 16, debug = False, **kwargs): 53 """ 54 Create and open a VideoIO object in reader mode (read a video file) 55 56 See `VideoIO.open` for the full list 57 of accepted parameters. 58 """ 59 reader = cls(logLevel=loglevel, debug=debug) 60 reader.open(filename,**kwargs) 61 return reader
Create and open a VideoIO object in reader mode (read a video file)
See VideoIO.open for the full list
of accepted parameters.
63 @classmethod 64 def writer(cls, filename, width, height, fps, *, loglevel = 16, debug = False, **kwargs): 65 """ 66 Create and open a VideoIO object in writer mode (write a video file) 67 68 See `VideoIO.create` for the full list 69 of accepted parameters. 70 """ 71 writer = cls(logLevel=loglevel, debug=debug) 72 writer.create(filename, width, height, fps, **kwargs) 73 return writer
Create and open a VideoIO object in writer mode (write a video file)
See VideoIO.create for the full list
of accepted parameters.
76 def get_corresponding_writer(self, filename, **kwargs): 77 """ 78 Method to get writer for a video file with same width, height, fps as the current one VideoIO object 79 80 See `VideoIO.create` for the full list 81 of accepted parameters. 82 """ 83 return VideoIO.writer(filename, self.width, self.height, self.fps, **kwargs)
Method to get writer for a video file with same width, height, fps as the current one VideoIO object
See VideoIO.create for the full list
of accepted parameters.
101 @staticmethod 102 def get_time_in_sec(filename, *, debug=False, logLevel=16): 103 """ 104 Static method to get length of a video file in seconds including milliseconds as decimal part. 105 106 Parameters 107 ---------- 108 filename : str or path 109 Video file name. 110 111 debug : bool (default False) 112 Show debug info. 113 114 log_level: int (default 16) 115 Log level to pass to the underlying ffmpeg/ffprobe command. 116 117 Returns 118 ---------- 119 float 120 Length in seconds of video file (including milliseconds as decimal part) 121 """ 122 123 cmd = [VideoIO.paramProgram, # ffprobe 124 '-hide_banner', 125 '-loglevel', str(logLevel), 126 '-show_entries', 'format=duration', 127 '-of', 'default=noprint_wrappers=1:nokey=1', 128 str(filename) 129 ] 130 131 if debug == True: 132 print(' '.join(cmd)) 133 134 # call ffprobe and get params in one single line 135 lpipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 136 output = lpipe.stdout.readlines() 137 lpipe.terminate() 138 # transform Bytes output to one single string 139 output = ''.join( [element.decode('utf-8') for element in output]) 140 141 try: 142 return float(output) 143 except (ValueError, TypeError): 144 return None
Static method to get length of a video file in seconds including milliseconds as decimal part.
Parameters
filename : str or path Video file name.
debug : bool (default False) Show debug info.
log_level: int (default 16) Log level to pass to the underlying ffmpeg/ffprobe command.
Returns
float Length in seconds of video file (including milliseconds as decimal part)
146 @staticmethod 147 def get_params(filename, *, debug=False, logLevel=16): 148 """ 149 Static method to get params (width, height, fps) from a video file. 150 151 Parameters 152 ---------- 153 filename: str or path 154 Video filename. 155 156 debug: bool (default (False) 157 Show debug info. 158 159 log_level: int (default 16) 160 Log level to pass to the underlying ffmpeg/ffprobe command. 161 162 Returns 163 ---------- 164 tuple 165 Tuple containing (width, height, fps) of the video 166 """ 167 cmd = [VideoIO.paramProgram, # ffprobe 168 '-hide_banner', 169 '-loglevel', str(logLevel), 170 '-show_entries', 'stream=width,height,r_frame_rate', 171 str(filename) 172 ] 173 174 if debug == True: 175 print(' '.join(cmd)) 176 177 # call ffprobe and get params in one single line 178 lpipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 179 output = lpipe.stdout.readlines() 180 lpipe.terminate() 181 # transform Bytes output to one single string 182 output = ''.join( [element.decode('utf-8') for element in output]) 183 184 pattern_width = r'width=(\d+)' 185 pattern_height = r'height=(\d+)' 186 pattern_fps = r'r_frame_rate=(\d+)/(\d+)' 187 188 # Search for values in the ffprobe output 189 match_width = re.search(pattern_width, output, flags=re.MULTILINE) 190 match_height = re.search(pattern_height, output, flags=re.MULTILINE) 191 match_fps = re.search(pattern_fps, output, flags=re.MULTILINE) 192 193 # Extraction des valeurs 194 if match_width: 195 width = int(match_width.group(1)) 196 else: 197 raise VideoIO.VideoIOException("Unable to get geometry of '" + filename + "'") 198 199 if match_height: 200 height = int(match_height.group(1)) 201 else: 202 raise VideoIO.VideoIOException("Unable to get geometry of '" + filename + "'") 203 204 if match_fps: 205 numerator = float(match_fps.group(1)) 206 denominator = float(match_fps.group(2)) 207 fps = numerator / denominator 208 else: 209 raise VideoIO.VideoIOException("Unable to get frame rate (fps) of '" + filename + "'") 210 211 return (width, height, fps)
Static method to get params (width, height, fps) from a video file.
Parameters
filename: str or path Video filename.
debug: bool (default (False) Show debug info.
log_level: int (default 16) Log level to pass to the underlying ffmpeg/ffprobe command.
Returns
tuple Tuple containing (width, height, fps) of the video
Framecounter object to count ellapsed time (default None)
267 def init(self): 268 """ 269 Init or reinit a VideoIO object. 270 """ 271 self.width = -1 272 self.height = -1 273 self.fps = -1.0 274 self.pipe = None 275 self.shape = (None, None, None) 276 self.imageSize = -1 277 self.filename = None 278 self.frame_counter = None
Init or reinit a VideoIO object.
298 def get_elapsed_time_as_str(self) -> str: 299 """ 300 Method to get elapsed time (float value) as str from `frame_counter` attribute. 301 302 Returns 303 ---------- 304 str or None 305 Elapsed time (float value) as str, "15.500" for instance for 15 secondes and 500 milliseconds 306 None if no frame counter are available. 307 """ 308 if self.frame_counter is None: 309 return None 310 return self.frame_counter.get_elapsed_time_as_str()
Method to get elapsed time (float value) as str from frame_counter attribute.
Returns
str or None Elapsed time (float value) as str, "15.500" for instance for 15 secondes and 500 milliseconds None if no frame counter are available.
312 def get_formated_elapsed_time_as_str(self,show_ms=True) -> str: 313 """ 314 Method to get elapsed time (hour format) as str from `frame_counter` attribute. 315 316 Returns 317 ---------- 318 str or None 319 Elapsed time (float value) as str, "00:00:15.500" for instance for 15 secondes and 500 milliseconds 320 None if no frame counter are available. 321 """ 322 if self.frame_counter is None: 323 return None 324 return self.frame_counter.get_formated_elapsed_time_as_str()
Method to get elapsed time (hour format) as str from frame_counter attribute.
Returns
str or None Elapsed time (float value) as str, "00:00:15.500" for instance for 15 secondes and 500 milliseconds None if no frame counter are available.
326 def get_elapsed_time(self) -> float: 327 """ 328 Method to get elapsed time as float value rounded to 3 decimals (millisecond) from `frame_counter` attribute. 329 330 Returns 331 ---------- 332 float or None 333 Elapsed time (float value) as str, 15.500 for instance for 15 secondes and 500 milliseconds 334 None if no frame counter are available. 335 """ 336 if self.frame_counter is None: 337 return None 338 return self.frame_counter.get_elapsed_time()
Method to get elapsed time as float value rounded to 3 decimals (millisecond) from frame_counter attribute.
Returns
float or None Elapsed time (float value) as str, 15.500 for instance for 15 secondes and 500 milliseconds None if no frame counter are available.
340 def is_opened(self) -> bool: 341 """ 342 Method to get status of the underlying pipe to ffmpeg. 343 344 Returns 345 ---------- 346 bool 347 True if pipe is opened (reading or writing mode), False if not. 348 """ 349 # is the pipe opened? 350 if self.pipe is not None and self.pipe.poll() is None: 351 return True 352 353 return False
Method to get status of the underlying pipe to ffmpeg.
Returns
bool True if pipe is opened (reading or writing mode), False if not.
355 def close(self) -> None: 356 """ 357 Method to close current pipe to ffmpeg (if any). Ffmpeg/ffprobe will be terminated. Object can be reused using open or create methods. 358 """ 359 if self.pipe is not None: 360 if self.mode == PipeMode.WRITE_MODE: 361 # killing will make ffmpeg not finish properly the job, close the pipe 362 # to let it know that no more data are comming 363 self.pipe.stdin.close() 364 else: # self.mode == PipeMode.READ_MODE 365 # in read mode, no need to be nice, send SIGTERM on Linux,/Kill it on windows 366 self.pipe.kill() 367 368 # wait for subprocess to end 369 self.pipe.wait() 370 371 # reinit object for later use 372 self.init()
Method to close current pipe to ffmpeg (if any). Ffmpeg/ffprobe will be terminated. Object can be reused using open or create methods.
374 def create(self, filename, width, height, fps, *, writeOverExistingFile = False, 375 inputEncoding = PixelFormat.GBR24, encodingParams = None ) -> bool: 376 """ 377 Method to create a video using parametrized access through ffmpeg. Importante note: calling create 378 on a VideoIO will close any former open video. 379 380 Parameters 381 ---------- 382 filename: str or path 383 filename of path to the file (mp4, avi, ...) 384 385 width: int 386 If defined as a positive value, width of output images will be set to this value. 387 388 height: int 389 If defined as a positive value, height of output images will be set to this value. 390 391 fps: 392 If defined as a positive value, fps of output video will be set to this value. 393 394 inputEncoding: PixelFormat optional (default PixelFormat.BGR24) 395 Define order of channels for channels. Possible values are PixelFormat.BGR24 or PixelFormat.RGB24. 396 397 encodingParams: str optional (default None) 398 Parameter to pass to ffmpeg to encode video like video filters. 399 400 Returns 401 ---------- 402 bool 403 Was the creation successfull 404 """ 405 406 # Close if already opened 407 self.close() 408 409 # Set geometry/fps of the video stream from params 410 self.width = int(width) 411 self.height = int(height) 412 self.fps = float(fps) 413 414 # Check params 415 if self.width <= 0 or self.height <= 0 or self.fps <= 0.0: 416 raise self.VideoIOException("Bad parameters: width={}, height={}, fps={:3f}".format(self.width,self.height,self.fps)) 417 418 # Params are ok, set shape and image size 419 self.shape = (self.height,self.width,3) 420 self.imageSize = self.height * self.width * 3 421 422 # Video params are set, open the video 423 cmd = [self.videoProgram] # ffmpeg 424 425 if writeOverExistingFile == True: 426 cmd.extend(['-y']) 427 428 cmd.extend(['-hide_banner', 429 '-nostats', 430 '-loglevel', str(self.logLevel), 431 '-f', 'rawvideo', '-vcodec', 'rawvideo', '-pix_fmt', inputEncoding.value, 432 '-video_size', f"{self.width}x{self.height}", 433 '-r', "{:.3f}".format(self.fps), 434 '-i', '-']) 435 436 if encodingParams is not None: 437 cmd.extend(encodingParams.split()) 438 439 # Video filename converted to str (for Path values) 440 cmd.extend( ['-an', str(filename) ] ) 441 442 if self.debug == True: 443 print( ' '.join(cmd), file=sys.stderr ) 444 445 # store filename and set mode 446 self.filename = str(filename) 447 self.mode = PipeMode.WRITE_MODE 448 449 # Call ffmpeg in write mode 450 try: 451 self.pipe = sp.Popen(cmd, stdin=sp.PIPE) 452 self.frame_counter = FrameCounter(self.fps) 453 except Exception as e: 454 # if pipe failed, reinit object and raise exception 455 self.init() 456 raise 457 458 return True
Method to create a video using parametrized access through ffmpeg. Importante note: calling create on a VideoIO will close any former open video.
Parameters
filename: str or path filename of path to the file (mp4, avi, ...)
width: int If defined as a positive value, width of output images will be set to this value.
height: int If defined as a positive value, height of output images will be set to this value.
fps: If defined as a positive value, fps of output video will be set to this value.
inputEncoding: PixelFormat optional (default PixelFormat.BGR24) Define order of channels for channels. Possible values are PixelFormat.BGR24 or PixelFormat.RGB24.
encodingParams: str optional (default None) Parameter to pass to ffmpeg to encode video like video filters.
Returns
bool Was the creation successfull
460 def open( self, filename, *, width = -1, height = -1, fps = -1.0, outputEncoding = PixelFormat.GBR24, 461 decodingParams = None, start_time = 0.0 ) -> bool: 462 """ 463 Method to read video using parametrized access through ffmpeg. Importante note: calling open 464 on a VideoIO will close any former open video. 465 466 Parameters 467 ---------- 468 filename: str or path 469 filename of path to the file (mp4, avi, ...) 470 471 width: int optional (default -1) 472 If defined as a positive value, width of input images will be converted to this value. 473 474 height: int optional (default -1) 475 If defined as a positive value, height of input images will be converted to this value. 476 477 fps: float optional (default -1.0) 478 If defined as a positive value, fps of input video will be converted to this value. 479 480 outputEncoding: PixelFormat optional (default PixelFormat.BGR24) 481 Define order of channels for channels. Possible values are PixelFormat.BGR24 or PixelFormat.RGB24. 482 483 decodingParams: str optional (default None) 484 Parameter to pass to ffmpeg to decode video like filters. 485 486 start_time: float optional (default 0.0) 487 Define the reading start time. If not set, reading at beginning of the video. 488 489 Returns 490 ---------- 491 bool 492 Was the opening successfull 493 """ 494 495 # Close if already opened 496 self.close() 497 498 # Force conversion of parameters 499 width = int(width) 500 height = int(height) 501 fps = float(fps) 502 503 # get parameters from video 504 self.width, self.height, self.fps = self.getVideoParams(str(filename)) 505 506 # check if parameters ask to overide video parameters 507 # TODO: add support for negative value (automatic preservation of aspect ratio) 508 if width > 0: 509 self.width = width 510 if height > 0: 511 self.height = height 512 if fps > 0.0: 513 self.fps = fps 514 515 # Params are ok, set shape and image size 516 self.shape = (self.height,self.width,3) 517 self.imageSize = self.height * self.width * 3 518 519 # Video params are set, open the video 520 cmd = [self.videoProgram, # ffmpeg 521 '-hide_banner', 522 '-nostats', 523 '-loglevel', str(self.logLevel)] 524 525 if start_time < 0.0: 526 pass 527 elif start_time > 0.0: 528 cmd.extend(["-ss", f"{start_time}"]) # set start time if any 529 530 cmd.extend( ['-i', str(filename)] ) 531 532 video_filters = '' # empty 533 if decodingParams is not None: 534 decodingParams = decodingParams.split() 535 # walk over decodingParams for specific params 536 i = 0 537 while i < len(decodingParams): 538 if decodingParams[i] == '-vf': 539 decodingParams.pop(i) # remove '-vf' 540 if i < len(decodingParams): 541 video_filters += ','+decodingParams.pop(i) # remove parameters from list too 542 # to do : add support to other option like -y 543 else: 544 i += 1 545 else: 546 decodingParams = [] 547 548 cmd.extend( ['-vf', f'scale={self.width}:{self.height}{video_filters}', # rescale (or not if shape is original one), add specific video filters 549 *(decodingParams), 550 '-f', 'rawvideo', '-vcodec', 'rawvideo', '-pix_fmt', outputEncoding.value, # input expected coding 551 '-an', # no audio 552 '-r', f"{self.fps}", 553 '-' # output to stdout 554 ] ) 555 556 if self.debug == True: 557 print( ' '.join(cmd) ) 558 559 # store filename and set mode to READ_MODE 560 self.filename = str(filename) 561 self.mode = PipeMode.READ_MODE 562 563 # call ffmpeg in read mode 564 try: 565 self.pipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 566 self.frame_counter = FrameCounter(self.fps) 567 if start_time > 0.0: 568 self.frame_counter += start_time # adding with float means adding time 569 except Exception as e: 570 # if pipe failed, reinit object and raise exception 571 self.init() 572 raise 573 574 return True
Method to read video using parametrized access through ffmpeg. Importante note: calling open on a VideoIO will close any former open video.
Parameters
filename: str or path filename of path to the file (mp4, avi, ...)
width: int optional (default -1) If defined as a positive value, width of input images will be converted to this value.
height: int optional (default -1) If defined as a positive value, height of input images will be converted to this value.
fps: float optional (default -1.0) If defined as a positive value, fps of input video will be converted to this value.
outputEncoding: PixelFormat optional (default PixelFormat.BGR24) Define order of channels for channels. Possible values are PixelFormat.BGR24 or PixelFormat.RGB24.
decodingParams: str optional (default None) Parameter to pass to ffmpeg to decode video like filters.
start_time: float optional (default 0.0) Define the reading start time. If not set, reading at beginning of the video.
Returns
bool Was the opening successfull
576 def read_frame(self, with_timestamps = False): 577 """ 578 Read next frame from the video 579 580 Parameters 581 ---------- 582 with_timestamps: bool optional (default False) 583 If set to True, the method returns a FrameContainer with the image and an array containing the associated timestamp(s) 584 585 Returns 586 ---------- 587 nparray or FrameContainer 588 An image of shape (3,width,height). if with_timestamps is True, the return object is a FrameContainer with the image in ``FrameContainer.data`` and 589 the associated timestamp in ``FrameContainer.timestamps`` as an array (one element for one frame). 590 """ 591 592 if self.pipe is None: 593 raise self.VideoIOException("No pipe opened to {}. Call open(...) before reading a frame.".format(self.videoProgram)) 594 # - pipe is in write mode 595 if self.mode != PipeMode.READ_MODE: 596 raise self.VideoIOException("Pipe to {} for '{}' not opened in read mode.".format(self.videoProgram, self.filename)) 597 598 if with_timestamps: 599 # get elapsed time in video, it is time of next frame(s) 600 current_elapsed_time = self.get_elapsed_time() 601 602 # read rgb image from pipe 603 buffer = self.pipe.stdout.read(self.imageSize) 604 if len(buffer) != self.imageSize: 605 # Incomplete image, ffmpeg have been stopped or killed, do not return an incomplete frame 606 # not considered as an error, no more frame, no exception 607 return None 608 609 # get numpy UINT8 array from buffer 610 rgbImage = np.frombuffer(buffer, dtype = np.uint8).reshape(self.shape) 611 612 # increase frame_counter 613 self.frame_counter.frame_count += 1 614 615 # say to gc that this buffer is no longer needed 616 del buffer 617 618 if with_timestamps: 619 return FrameContainer(1,rgbImage,self.fps,current_elapsed_time) 620 621 return rgbImage
Read next frame from the video
Parameters
with_timestamps: bool optional (default False) If set to True, the method returns a FrameContainer with the image and an array containing the associated timestamp(s)
Returns
nparray or FrameContainer
An image of shape (3,width,height). if with_timestamps is True, the return object is a FrameContainer with the image in FrameContainer.data and
the associated timestamp in FrameContainer.timestamps as an array (one element for one frame).
623 def read_batch(self, number_of_frames, with_timestamps = False) -> Union[np.array, FrameContainer]: 624 """ 625 Read next batch of images from the video 626 627 Parameters 628 ---------- 629 number_of_frames: int 630 Number of desired images within the batch. The last batch of the video may have less images. 631 632 with_timestamps: bool optional (default False) 633 If set to True, the method returns a FrameContainer with the batch and the an array containing the associated timestamps to frames 634 635 Returns 636 ---------- 637 nparray or FrameContainer 638 A batch of images of shape (n,3,width,height). if with_timestamps is True, the return object is a FrameContainer with the batch in ``FrameContainer.data`` and 639 the associated timestamps in ``FrameContainer.timestamps`` as an array (one element for each frame). 640 """ 641 642 if self.pipe is None: 643 raise self.VideoIOException("No pipe opened to {}. Call open(...) before reading frames.".format(self.videoProgram)) 644 # - pipe is in write mode 645 if self.mode != PipeMode.READ_MODE: 646 raise self.VideoIOException("Pipe to {} for '{}' not opened in read mode.".format(self.videoProgram, self.filename)) 647 648 if with_timestamps: 649 # get elapsed time in video, it is time of next frame(s) 650 current_elapsed_time = self.get_elapsed_time() 651 652 # try to read complete batch 653 buffer = self.pipe.stdout.read(self.imageSize*number_of_frames) 654 655 # check if we have at least 1 image 656 if len(buffer) < self.imageSize: 657 # ffmpag backend have been stopped or killed 658 # not considered as an error, no more frame, no exception 659 return None 660 661 # compute actual number of Frames 662 actualNbFrames = len(buffer)//self.imageSize 663 664 # get and reshape batch from buffer 665 batch = np.frombuffer(buffer, dtype = np.uint8).reshape((actualNbFrames, self.height, self.width, 3)) 666 667 # increase frame_counter 668 self.frame_counter.frame_count += actualNbFrames 669 670 # say to gc that this buffer is no longer needed 671 del buffer 672 673 if with_timestamps: 674 return FrameContainer(actualNbFrames, batch, self.fps, current_elapsed_time) 675 676 return batch
Read next batch of images from the video
Parameters
number_of_frames: int Number of desired images within the batch. The last batch of the video may have less images.
with_timestamps: bool optional (default False) If set to True, the method returns a FrameContainer with the batch and the an array containing the associated timestamps to frames
Returns
nparray or FrameContainer
A batch of images of shape (n,3,width,height). if with_timestamps is True, the return object is a FrameContainer with the batch in FrameContainer.data and
the associated timestamps in FrameContainer.timestamps as an array (one element for each frame).
678 def write_frame(self, image) -> bool: 679 """ 680 Write an image to the video 681 682 Parameters 683 ---------- 684 image: nparray 685 The image of shape (3, width, height) to write to the video file in the PixelFormat provided when create was called. 686 687 Returns 688 ---------- 689 bool 690 Writing was successful or not. 691 """ 692 693 # Check params 694 # - pipe exists 695 if self.pipe is None: 696 raise self.VideoIOException("No pipe opened to {}. Call create(...) before writing frames.".format(self.videoProgram)) 697 # - pipe is in write mode 698 if self.mode != PipeMode.WRITE_MODE: 699 raise self.VideoIOException("Pipe to {} for '{}' not opened in write mode.".format(self.videoProgram, self.filename)) 700 # - shape of image is fine, thus we have pixels for a full compatible frame 701 if image.shape != self.shape: 702 raise self.VideoIOException("Wong image shape: {} expected {}.".format(image.shape,self.shape)) 703 # - type of data is UINT8 704 if image.dtype != np.uint8: 705 raise self.VideoIOException("Wong pixel type: {} expected np.uint8.".format(image.dtype)) 706 707 # write frame 708 buffer = image.tobytes() 709 if self.pipe.stdin.write( buffer ) < self.imageSize: 710 print( "Error writing frame to" ) 711 return False 712 713 # increase frame_counter 714 self.frame_counter.frame_count += 1 715 716 # say to gc that this buffer is no longer needed 717 del buffer 718 719 return True
Write an image to the video
Parameters
image: nparray The image of shape (3, width, height) to write to the video file in the PixelFormat provided when create was called.
Returns
bool Writing was successful or not.
721 def write_batch(self, batch) -> bool: 722 """ 723 Write a batch of images to the video 724 725 Parameters 726 ---------- 727 batch: nparray 728 A batch of images to write to the video file in the PixelFormat provided when create was called. 729 730 Returns 731 ---------- 732 bool 733 Writing was successful or not. 734 """ 735 736 # Check params 737 # - pipe exists 738 if self.pipe is None: 739 raise self.VideoIOException("No pipe opened to {}. Call create(...) before writing frames.".format(self.videoProgram)) 740 # - pipe is in write mode 741 if self.mode != PipeMode.WRITE_MODE: 742 raise self.VideoIOException("Pipe to {} for '{}' not opened in write mode.".format(self.videoProgram, self.filename)) 743 # - shape of images in batch is fine 744 if batch.shape[-3:] != self.shape: 745 raise self.VideoIOException("Wrong image shape in batch: {} expected {}.".format(batch.shape[-3:], self.shape)) 746 # - we have the right amount of pixels for the full batch 747 if batch.size != (batch.shape[0]*self.imageSize): 748 raise self.VideoIOException("Wrong number of pixels in batch: {} expected {}.".format(batch.shape[-3:], self.imageSize)) 749 750 # write frame 751 buffer = batch.tobytes() 752 if self.pipe.stdin.write( buffer ) < batch.size: 753 # say to gc that this buffer is no longer needed 754 del buffer 755 raise self.VideoIOException("Error writing batch to '{}'.".format(self.filename)) 756 757 # increase frame_counter 758 self.frame_counter.frame_count += batch.shape[0] 759 760 # say to gc that this buffer is no longer needed 761 del buffer 762 763 return True
Write a batch of images to the video
Parameters
batch: nparray A batch of images to write to the video file in the PixelFormat provided when create was called.
Returns
bool Writing was successful or not.
765 def iter_frames(self, with_timestamps = False): 766 """ 767 Method to iterate on video frames using VideoIO obj. 768 for frame in obj.iter_frames(): 769 .... 770 771 Parameters 772 ---------- 773 with_timestamps: bool optional (default False) 774 If set to True, the method returns a FrameContainer with the batch and an array containing the associated timestamps to frames 775 """ 776 777 try: 778 if self.mode == PipeMode.READ_MODE: 779 while self.isOpened(): 780 frame = self.readFrame(with_timestamps) 781 if frame is not None: 782 yield frame 783 finally: 784 self.close()
Method to iterate on video frames using VideoIO obj. for frame in obj.iter_frames(): ....
Parameters
with_timestamps: bool optional (default False) If set to True, the method returns a FrameContainer with the batch and an array containing the associated timestamps to frames
786 def iter_batches(self, batch_size : int, with_timestamps = False): 787 """ 788 Method to iterate on batch of frames using VideoIO obj. 789 for image_batch in obj.iter_batches(): 790 .... 791 792 Parameters 793 ---------- 794 with_timestamps: bool optional (default False) 795 If set to True, the method returns a FrameContainer with the batch and the an array containing the associated timestamps to frames 796 """ 797 try: 798 if self.mode == PipeMode.READ_MODE: 799 while self.isOpened(): 800 batch = self.readBatch(batch_size, with_timestamps) 801 if batch is not None: 802 yield batch 803 finally: 804 self.close()
Method to iterate on batch of frames using VideoIO obj. for image_batch in obj.iter_batches(): ....
Parameters
with_timestamps: bool optional (default False) If set to True, the method returns a FrameContainer with the batch and the an array containing the associated timestamps to frames
101 @staticmethod 102 def get_time_in_sec(filename, *, debug=False, logLevel=16): 103 """ 104 Static method to get length of a video file in seconds including milliseconds as decimal part. 105 106 Parameters 107 ---------- 108 filename : str or path 109 Video file name. 110 111 debug : bool (default False) 112 Show debug info. 113 114 log_level: int (default 16) 115 Log level to pass to the underlying ffmpeg/ffprobe command. 116 117 Returns 118 ---------- 119 float 120 Length in seconds of video file (including milliseconds as decimal part) 121 """ 122 123 cmd = [VideoIO.paramProgram, # ffprobe 124 '-hide_banner', 125 '-loglevel', str(logLevel), 126 '-show_entries', 'format=duration', 127 '-of', 'default=noprint_wrappers=1:nokey=1', 128 str(filename) 129 ] 130 131 if debug == True: 132 print(' '.join(cmd)) 133 134 # call ffprobe and get params in one single line 135 lpipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 136 output = lpipe.stdout.readlines() 137 lpipe.terminate() 138 # transform Bytes output to one single string 139 output = ''.join( [element.decode('utf-8') for element in output]) 140 141 try: 142 return float(output) 143 except (ValueError, TypeError): 144 return None
Static method to get length of a video file in seconds including milliseconds as decimal part.
Parameters
filename : str or path Video file name.
debug : bool (default False) Show debug info.
log_level: int (default 16) Log level to pass to the underlying ffmpeg/ffprobe command.
Returns
float Length in seconds of video file (including milliseconds as decimal part)
146 @staticmethod 147 def get_params(filename, *, debug=False, logLevel=16): 148 """ 149 Static method to get params (width, height, fps) from a video file. 150 151 Parameters 152 ---------- 153 filename: str or path 154 Video filename. 155 156 debug: bool (default (False) 157 Show debug info. 158 159 log_level: int (default 16) 160 Log level to pass to the underlying ffmpeg/ffprobe command. 161 162 Returns 163 ---------- 164 tuple 165 Tuple containing (width, height, fps) of the video 166 """ 167 cmd = [VideoIO.paramProgram, # ffprobe 168 '-hide_banner', 169 '-loglevel', str(logLevel), 170 '-show_entries', 'stream=width,height,r_frame_rate', 171 str(filename) 172 ] 173 174 if debug == True: 175 print(' '.join(cmd)) 176 177 # call ffprobe and get params in one single line 178 lpipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 179 output = lpipe.stdout.readlines() 180 lpipe.terminate() 181 # transform Bytes output to one single string 182 output = ''.join( [element.decode('utf-8') for element in output]) 183 184 pattern_width = r'width=(\d+)' 185 pattern_height = r'height=(\d+)' 186 pattern_fps = r'r_frame_rate=(\d+)/(\d+)' 187 188 # Search for values in the ffprobe output 189 match_width = re.search(pattern_width, output, flags=re.MULTILINE) 190 match_height = re.search(pattern_height, output, flags=re.MULTILINE) 191 match_fps = re.search(pattern_fps, output, flags=re.MULTILINE) 192 193 # Extraction des valeurs 194 if match_width: 195 width = int(match_width.group(1)) 196 else: 197 raise VideoIO.VideoIOException("Unable to get geometry of '" + filename + "'") 198 199 if match_height: 200 height = int(match_height.group(1)) 201 else: 202 raise VideoIO.VideoIOException("Unable to get geometry of '" + filename + "'") 203 204 if match_fps: 205 numerator = float(match_fps.group(1)) 206 denominator = float(match_fps.group(2)) 207 fps = numerator / denominator 208 else: 209 raise VideoIO.VideoIOException("Unable to get frame rate (fps) of '" + filename + "'") 210 211 return (width, height, fps)
Static method to get params (width, height, fps) from a video file.
Parameters
filename: str or path Video filename.
debug: bool (default (False) Show debug info.
log_level: int (default 16) Log level to pass to the underlying ffmpeg/ffprobe command.
Returns
tuple Tuple containing (width, height, fps) of the video
101 @staticmethod 102 def get_time_in_sec(filename, *, debug=False, logLevel=16): 103 """ 104 Static method to get length of a video file in seconds including milliseconds as decimal part. 105 106 Parameters 107 ---------- 108 filename : str or path 109 Video file name. 110 111 debug : bool (default False) 112 Show debug info. 113 114 log_level: int (default 16) 115 Log level to pass to the underlying ffmpeg/ffprobe command. 116 117 Returns 118 ---------- 119 float 120 Length in seconds of video file (including milliseconds as decimal part) 121 """ 122 123 cmd = [VideoIO.paramProgram, # ffprobe 124 '-hide_banner', 125 '-loglevel', str(logLevel), 126 '-show_entries', 'format=duration', 127 '-of', 'default=noprint_wrappers=1:nokey=1', 128 str(filename) 129 ] 130 131 if debug == True: 132 print(' '.join(cmd)) 133 134 # call ffprobe and get params in one single line 135 lpipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 136 output = lpipe.stdout.readlines() 137 lpipe.terminate() 138 # transform Bytes output to one single string 139 output = ''.join( [element.decode('utf-8') for element in output]) 140 141 try: 142 return float(output) 143 except (ValueError, TypeError): 144 return None
Static method to get length of a video file in seconds including milliseconds as decimal part.
Parameters
filename : str or path Video file name.
debug : bool (default False) Show debug info.
log_level: int (default 16) Log level to pass to the underlying ffmpeg/ffprobe command.
Returns
float Length in seconds of video file (including milliseconds as decimal part)
146 @staticmethod 147 def get_params(filename, *, debug=False, logLevel=16): 148 """ 149 Static method to get params (width, height, fps) from a video file. 150 151 Parameters 152 ---------- 153 filename: str or path 154 Video filename. 155 156 debug: bool (default (False) 157 Show debug info. 158 159 log_level: int (default 16) 160 Log level to pass to the underlying ffmpeg/ffprobe command. 161 162 Returns 163 ---------- 164 tuple 165 Tuple containing (width, height, fps) of the video 166 """ 167 cmd = [VideoIO.paramProgram, # ffprobe 168 '-hide_banner', 169 '-loglevel', str(logLevel), 170 '-show_entries', 'stream=width,height,r_frame_rate', 171 str(filename) 172 ] 173 174 if debug == True: 175 print(' '.join(cmd)) 176 177 # call ffprobe and get params in one single line 178 lpipe = sp.Popen(cmd, stdout=sp.PIPE, stdin=sp.PIPE) # stdin=sp.PIPE to prevent manipulation of shell echo mode by ffmpeg/ffprobe 179 output = lpipe.stdout.readlines() 180 lpipe.terminate() 181 # transform Bytes output to one single string 182 output = ''.join( [element.decode('utf-8') for element in output]) 183 184 pattern_width = r'width=(\d+)' 185 pattern_height = r'height=(\d+)' 186 pattern_fps = r'r_frame_rate=(\d+)/(\d+)' 187 188 # Search for values in the ffprobe output 189 match_width = re.search(pattern_width, output, flags=re.MULTILINE) 190 match_height = re.search(pattern_height, output, flags=re.MULTILINE) 191 match_fps = re.search(pattern_fps, output, flags=re.MULTILINE) 192 193 # Extraction des valeurs 194 if match_width: 195 width = int(match_width.group(1)) 196 else: 197 raise VideoIO.VideoIOException("Unable to get geometry of '" + filename + "'") 198 199 if match_height: 200 height = int(match_height.group(1)) 201 else: 202 raise VideoIO.VideoIOException("Unable to get geometry of '" + filename + "'") 203 204 if match_fps: 205 numerator = float(match_fps.group(1)) 206 denominator = float(match_fps.group(2)) 207 fps = numerator / denominator 208 else: 209 raise VideoIO.VideoIOException("Unable to get frame rate (fps) of '" + filename + "'") 210 211 return (width, height, fps)
Static method to get params (width, height, fps) from a video file.
Parameters
filename: str or path Video filename.
debug: bool (default (False) Show debug info.
log_level: int (default 16) Log level to pass to the underlying ffmpeg/ffprobe command.
Returns
tuple Tuple containing (width, height, fps) of the video
340 def is_opened(self) -> bool: 341 """ 342 Method to get status of the underlying pipe to ffmpeg. 343 344 Returns 345 ---------- 346 bool 347 True if pipe is opened (reading or writing mode), False if not. 348 """ 349 # is the pipe opened? 350 if self.pipe is not None and self.pipe.poll() is None: 351 return True 352 353 return False
Method to get status of the underlying pipe to ffmpeg.
Returns
bool True if pipe is opened (reading or writing mode), False if not.
576 def read_frame(self, with_timestamps = False): 577 """ 578 Read next frame from the video 579 580 Parameters 581 ---------- 582 with_timestamps: bool optional (default False) 583 If set to True, the method returns a FrameContainer with the image and an array containing the associated timestamp(s) 584 585 Returns 586 ---------- 587 nparray or FrameContainer 588 An image of shape (3,width,height). if with_timestamps is True, the return object is a FrameContainer with the image in ``FrameContainer.data`` and 589 the associated timestamp in ``FrameContainer.timestamps`` as an array (one element for one frame). 590 """ 591 592 if self.pipe is None: 593 raise self.VideoIOException("No pipe opened to {}. Call open(...) before reading a frame.".format(self.videoProgram)) 594 # - pipe is in write mode 595 if self.mode != PipeMode.READ_MODE: 596 raise self.VideoIOException("Pipe to {} for '{}' not opened in read mode.".format(self.videoProgram, self.filename)) 597 598 if with_timestamps: 599 # get elapsed time in video, it is time of next frame(s) 600 current_elapsed_time = self.get_elapsed_time() 601 602 # read rgb image from pipe 603 buffer = self.pipe.stdout.read(self.imageSize) 604 if len(buffer) != self.imageSize: 605 # Incomplete image, ffmpeg have been stopped or killed, do not return an incomplete frame 606 # not considered as an error, no more frame, no exception 607 return None 608 609 # get numpy UINT8 array from buffer 610 rgbImage = np.frombuffer(buffer, dtype = np.uint8).reshape(self.shape) 611 612 # increase frame_counter 613 self.frame_counter.frame_count += 1 614 615 # say to gc that this buffer is no longer needed 616 del buffer 617 618 if with_timestamps: 619 return FrameContainer(1,rgbImage,self.fps,current_elapsed_time) 620 621 return rgbImage
Read next frame from the video
Parameters
with_timestamps: bool optional (default False) If set to True, the method returns a FrameContainer with the image and an array containing the associated timestamp(s)
Returns
nparray or FrameContainer
An image of shape (3,width,height). if with_timestamps is True, the return object is a FrameContainer with the image in FrameContainer.data and
the associated timestamp in FrameContainer.timestamps as an array (one element for one frame).
623 def read_batch(self, number_of_frames, with_timestamps = False) -> Union[np.array, FrameContainer]: 624 """ 625 Read next batch of images from the video 626 627 Parameters 628 ---------- 629 number_of_frames: int 630 Number of desired images within the batch. The last batch of the video may have less images. 631 632 with_timestamps: bool optional (default False) 633 If set to True, the method returns a FrameContainer with the batch and the an array containing the associated timestamps to frames 634 635 Returns 636 ---------- 637 nparray or FrameContainer 638 A batch of images of shape (n,3,width,height). if with_timestamps is True, the return object is a FrameContainer with the batch in ``FrameContainer.data`` and 639 the associated timestamps in ``FrameContainer.timestamps`` as an array (one element for each frame). 640 """ 641 642 if self.pipe is None: 643 raise self.VideoIOException("No pipe opened to {}. Call open(...) before reading frames.".format(self.videoProgram)) 644 # - pipe is in write mode 645 if self.mode != PipeMode.READ_MODE: 646 raise self.VideoIOException("Pipe to {} for '{}' not opened in read mode.".format(self.videoProgram, self.filename)) 647 648 if with_timestamps: 649 # get elapsed time in video, it is time of next frame(s) 650 current_elapsed_time = self.get_elapsed_time() 651 652 # try to read complete batch 653 buffer = self.pipe.stdout.read(self.imageSize*number_of_frames) 654 655 # check if we have at least 1 image 656 if len(buffer) < self.imageSize: 657 # ffmpag backend have been stopped or killed 658 # not considered as an error, no more frame, no exception 659 return None 660 661 # compute actual number of Frames 662 actualNbFrames = len(buffer)//self.imageSize 663 664 # get and reshape batch from buffer 665 batch = np.frombuffer(buffer, dtype = np.uint8).reshape((actualNbFrames, self.height, self.width, 3)) 666 667 # increase frame_counter 668 self.frame_counter.frame_count += actualNbFrames 669 670 # say to gc that this buffer is no longer needed 671 del buffer 672 673 if with_timestamps: 674 return FrameContainer(actualNbFrames, batch, self.fps, current_elapsed_time) 675 676 return batch
Read next batch of images from the video
Parameters
number_of_frames: int Number of desired images within the batch. The last batch of the video may have less images.
with_timestamps: bool optional (default False) If set to True, the method returns a FrameContainer with the batch and the an array containing the associated timestamps to frames
Returns
nparray or FrameContainer
A batch of images of shape (n,3,width,height). if with_timestamps is True, the return object is a FrameContainer with the batch in FrameContainer.data and
the associated timestamps in FrameContainer.timestamps as an array (one element for each frame).
678 def write_frame(self, image) -> bool: 679 """ 680 Write an image to the video 681 682 Parameters 683 ---------- 684 image: nparray 685 The image of shape (3, width, height) to write to the video file in the PixelFormat provided when create was called. 686 687 Returns 688 ---------- 689 bool 690 Writing was successful or not. 691 """ 692 693 # Check params 694 # - pipe exists 695 if self.pipe is None: 696 raise self.VideoIOException("No pipe opened to {}. Call create(...) before writing frames.".format(self.videoProgram)) 697 # - pipe is in write mode 698 if self.mode != PipeMode.WRITE_MODE: 699 raise self.VideoIOException("Pipe to {} for '{}' not opened in write mode.".format(self.videoProgram, self.filename)) 700 # - shape of image is fine, thus we have pixels for a full compatible frame 701 if image.shape != self.shape: 702 raise self.VideoIOException("Wong image shape: {} expected {}.".format(image.shape,self.shape)) 703 # - type of data is UINT8 704 if image.dtype != np.uint8: 705 raise self.VideoIOException("Wong pixel type: {} expected np.uint8.".format(image.dtype)) 706 707 # write frame 708 buffer = image.tobytes() 709 if self.pipe.stdin.write( buffer ) < self.imageSize: 710 print( "Error writing frame to" ) 711 return False 712 713 # increase frame_counter 714 self.frame_counter.frame_count += 1 715 716 # say to gc that this buffer is no longer needed 717 del buffer 718 719 return True
Write an image to the video
Parameters
image: nparray The image of shape (3, width, height) to write to the video file in the PixelFormat provided when create was called.
Returns
bool Writing was successful or not.
721 def write_batch(self, batch) -> bool: 722 """ 723 Write a batch of images to the video 724 725 Parameters 726 ---------- 727 batch: nparray 728 A batch of images to write to the video file in the PixelFormat provided when create was called. 729 730 Returns 731 ---------- 732 bool 733 Writing was successful or not. 734 """ 735 736 # Check params 737 # - pipe exists 738 if self.pipe is None: 739 raise self.VideoIOException("No pipe opened to {}. Call create(...) before writing frames.".format(self.videoProgram)) 740 # - pipe is in write mode 741 if self.mode != PipeMode.WRITE_MODE: 742 raise self.VideoIOException("Pipe to {} for '{}' not opened in write mode.".format(self.videoProgram, self.filename)) 743 # - shape of images in batch is fine 744 if batch.shape[-3:] != self.shape: 745 raise self.VideoIOException("Wrong image shape in batch: {} expected {}.".format(batch.shape[-3:], self.shape)) 746 # - we have the right amount of pixels for the full batch 747 if batch.size != (batch.shape[0]*self.imageSize): 748 raise self.VideoIOException("Wrong number of pixels in batch: {} expected {}.".format(batch.shape[-3:], self.imageSize)) 749 750 # write frame 751 buffer = batch.tobytes() 752 if self.pipe.stdin.write( buffer ) < batch.size: 753 # say to gc that this buffer is no longer needed 754 del buffer 755 raise self.VideoIOException("Error writing batch to '{}'.".format(self.filename)) 756 757 # increase frame_counter 758 self.frame_counter.frame_count += batch.shape[0] 759 760 # say to gc that this buffer is no longer needed 761 del buffer 762 763 return True
Write a batch of images to the video
Parameters
batch: nparray A batch of images to write to the video file in the PixelFormat provided when create was called.
Returns
bool Writing was successful or not.
36 class VideoIOException(Exception): 37 """ 38 Dedicated exception class for VideoIO class. 39 """ 40 def __init__(self, message="Error while reading/writing video occurs"): 41 self.message = message 42 super().__init__(self.message)
Dedicated exception class for VideoIO class.
44 class PixelFormat(Enum): 45 """ 46 Enum class for supported input video type: GBR 24 bits or RGB 24 bis. 47 """ 48 GBR24 = 'bgr24' # default format 49 RGB24 = 'rgb24'
Enum class for supported input video type: GBR 24 bits or RGB 24 bis.