def _gen_promotional_banner(total_width, params=None): """Generate promotion banner. This is the promotional banner for the storyboard package. Parameters ---------- total_width : int Total width of the metadata sheet. Usually determined by the width of the bare storyboard. params : dict, optional Optional parameters enclosed in a dict. Default is ``None``. See the "Other Parameters" section for understood key/value pairs. Returns ------- banner : PIL.Image.Image Other Parameters ---------------- text_font : Font, optional Default is the font constructed by ``Font()`` without arguments. text_color: color, optional Default is 'black'. background_color: color, optional Default is 'white'. """ if params is None: params = {} text_font = _read_param(params, 'text_font', Font()) text_color = _read_param(params, 'text_color', 'black') background_color = _read_param(params, 'background_color', 'white') text = ("Generated by storyboard version %s. " "Fork me on GitHub: git.io/storyboard" % version.__version__) text_width, total_height = draw_text_block(None, (0, 0), text, params={ 'font': text_font, 'dry_run': True, }) banner = Image.new('RGBA', (total_width, total_height), background_color) # center the text -- calculate the x coordinate of its topleft # corner text_x = int((total_width - text_width) / 2) draw_text_block(banner, (text_x, 0), text, params={ 'font': text_font, 'color': text_color, }) return banner
def __init__(self, video, params=None): """Initialize the StoryBoard class. See the module docstring for parameters and exceptions. """ if params is None: params = {} if 'bins' in params and params['bins'] is not None: bins = params['bins'] assert isinstance(bins, tuple) and len(bins) == 2 else: bins = fflocate.guess_bins() frame_codec = _read_param(params, 'frame_codec', 'png') video_duration = _read_param(params, 'video_duration', None) print_progress = _read_param(params, 'print_progress', False) fflocate.check_bins(bins) # seek frame by frame if video duration is specially given # (indicating that normal input seeking may not work) self._seek_frame_by_frame = video_duration is not None self._bins = bins if isinstance(video, metadata.Video): self.video = video elif isinstance(video, str): self.video = metadata.Video(video, params={ 'ffprobe_bin': bins[1], 'video_duration': video_duration, 'print_progress': print_progress, }) else: raise ValueError("expected str or storyboard.metadata.Video " "for the video argument, got %s" % type(video).__name__) self.frames = [] self._frame_codec = frame_codec
def compute_sha1sum(self, params=None): """Computes the SHA-1 digest of the video file. Parameters ---------- params : dict, optional Optional parameters enclosed in a dict. Default is ``None``. See the "Other Parameters" section for understood key/value pairs. Returns ------- sha1sum : str The SHA-1 hex digest of the video file (40 character hexadecimal string). Other Parameters ---------------- print_progress : bool, optional Whether to print progress information (to stderr). Default is False. Notes ----- Since computing SHA-1 digest is an expensive operation, the digest is only calculated and set upon request, either through this method or `format_metadata` with the ``include_sha1sum`` optional parameter set to ``True``. Further requests load the calculated value rather than repeat the computation. """ self.__dp("entered StoryBoard.compute_sha1sum") if params is None: params = {} print_progress = _read_param(params, 'print_progress', False) self.__dp("left StoryBoard.compute_sha1sum") return self._get_sha1sum(print_progress=print_progress)
def format_metadata(self, params=None): """Return video metadata in one formatted string. Parameters ---------- params : dict, optional Optional parameters enclosed in a dict. Default is ``None``. See the "Other Parameters" section for understood key/value pairs. Returns ------- str A formatted string loaded with video and per-stream metadata, which can be printed directly. See the "Examples" section for a printed example. Other Parameters ---------------- include_sha1sum : bool, optional Whether to include the SHA-1 hex digest. Default is False. Keep in mind that computing SHA-1 digest is an expensive operation, and hence is only performed upon request. print_progress : bool, optional Whether to print progress information (to stderr). Default is False. Examples -------- >>> import os >>> import tempfile >>> import requests >>> video_uri = 'https://dl.bintray.com/zmwangx/pypi/sample-h264-aac-srt.mkv' >>> tempdir = tempfile.mkdtemp() >>> video_file = os.path.join(tempdir, 'sample-h264-aac-srt.mkv') >>> r = requests.get(video_uri, stream=True) >>> with open(video_file, 'wb') as fd: ... for chunk in r.iter_content(65536): ... bytes_written = fd.write(chunk) >>> print(Video(video_file).format_metadata({'include_sha1sum': True})) Title: Example video: H.264 + AAC + SRT in Matroska container Filename: sample-h264-aac-srt.mkv File size: 4842 (4.73KiB) SHA-1 digest: 95e7d9f9359d8d7ba4ec441bc8cb3830a58ee102 Container format: Matroska Duration: 00:00:02.08 Pixel dimensions: 128x72 Display aspect ratio: 16:9 Scan type: Progressive scan Frame rate: 25 fps Streams: #0: Video, H.264 (High Profile level 1.0), 128x72 (DAR 16:9), 25 fps #1: Audio (und), AAC (Low-Complexity) #2: Subtitle, SubRip >>> os.remove(video_file) >>> os.rmdir(tempdir) """ self.__dp("entered StoryBoard.format_metadata") if params is None: params = {} include_sha1sum = _read_param(params, 'include_sha1sum', False) print_progress = _read_param(params, 'print_progress', False) lines = [] # holds the lines that will be joined in the end # title if self.title: lines.append("Title: %s" % self.title) # filename lines.append("Filename: %s" % self.filename) # size lines.append("File size: %d (%s)" % (self.size, self.size_text)) # sha1sum if include_sha1sum: self._get_sha1sum(print_progress) lines.append("SHA-1 digest: %s" % self.sha1sum) # container format lines.append("Container format: %s" % self.format) # duration if self.duration_text: lines.append("Duration: %s" % self.duration_text) else: lines.append("Duration: Not available") # dimension if self.dimension_text: lines.append("Pixel dimensions: %s" % self.dimension_text) # aspect ratio if self.dar_text: lines.append("Display aspect ratio: %s" % self.dar_text) # scanning type if self.scan_type: lines.append("Scan type: %s" % self.scan_type) # frame rate if self.frame_rate: lines.append("Frame rate: %s" % self.frame_rate_text) # streams lines.append("Streams:") for stream in self.streams: lines.append(" #%d: %s" % (stream.index, stream.info_string)) self.__dp("left StoryBoard.format_metadata") return '\n'.join(lines).strip()
def __init__(self, video, params=None): """Initialize the Video class. See class docstring for parameters of the constructor. """ if params is None: params = {} if 'debug' in params and params['debug']: self.__debug = True self.__dp("entered StoryBoard.__init__") if 'ffprobe_bin' in params: ffprobe_bin = params['ffprobe_bin'] else: _, ffprobe_bin = fflocate.guess_bins() video_duration = _read_param(params, 'video_duration', None) print_progress = _read_param(params, 'print_progress', False) self.path = os.path.abspath(video) if not os.path.exists(self.path): raise OSError("'" + video + "' does not exist") self.filename = os.path.basename(self.path) if hasattr(self.filename, 'decode'): # python2 str, need to be decoded to unicode for proper # printing self.filename = self.filename.decode('utf-8') if print_progress: sys.stderr.write("Processing %s\n" % self.filename) sys.stderr.write("Crunching metadata...\n") self._call_ffprobe(ffprobe_bin) self.title = self._get_title() self.format = self._get_format() self.size, self.size_text = self._get_size() if video_duration is None: self.duration, self.duration_text = self._get_duration() else: self.duration = video_duration self.duration_text = util.humantime(video_duration) self.sha1sum = None # SHA-1 digest is generated upon request # the remaining attributes will be dynamically set when parsing # streams self.dimension = None self.dimension_text = None self.frame_rate = None self.frame_rate_text = None self.dar = None self.dar_text = None self._process_streams() # detect if the file contains any video streams at all and try # to extract scan type only if it does for stream in self.streams: if stream.type == 'video': break else: # no video stream self.scan_type = None self.__dp("left StoryBoard.__init__") return self.scan_type = self._get_scan_type(ffprobe_bin, print_progress) self.__dp("left StoryBoard.__init__")
def _gen_metadata_sheet(self, total_width, params=None): """Generate metadata sheet. Parameters ---------- total_width : int Total width of the metadata sheet. Usually determined by the width of the bare storyboard. params : dict, optional Optional parameters enclosed in a dict. Default is ``None``. See the "Other Parameters" section for understood key/value pairs. Returns ------- metadata_sheet : PIL.Image.Image Other Parameters ---------------- text_font : Font, optional Default is the font constructed by ``Font()`` without arguments. text_color: color, optional Default is 'black'. line_spacing : float, optional Line spacing as a float. Default is 1.2. background_color: color, optional Default is 'white'. include_sha1sum: bool, optional See the `include_sha1sum` option of `storyboard.metadata.Video.format_metadata`. Beware that computing SHA-1 digest is an expensive operation. print_progress : bool, optional Whether to print progress information (to stderr). Default is False. """ if params is None: params = {} text_font = _read_param(params, 'text_font', Font()) text_color = _read_param(params, 'text_color', 'black') line_spacing = _read_param(params, 'line_spacing', 1.2) background_color = _read_param(params, 'background_color', 'white') include_sha1sum = _read_param(params, 'include_sha1sum', False) print_progress = _read_param(params, 'print_progress', False) text = self.video.format_metadata(params={ 'include_sha1sum': include_sha1sum, 'print_progress': print_progress, }) _, total_height = draw_text_block(None, (0, 0), text, params={ 'font': text_font, 'spacing': line_spacing, 'dry_run': True, }) metadata_sheet = Image.new('RGBA', (total_width, total_height), background_color) draw_text_block(metadata_sheet, (0, 0), text, params={ 'font': text_font, 'color': text_color, 'spacing': line_spacing, }) return metadata_sheet
def _gen_bare_storyboard(self, tile, thumbnail_width, params=None): """Generate bare storyboard (thumbnails only). Parameters ---------- tile : tuple A tuple ``(cols, rows)`` specifying the number of columns and rows for the array of thumbnails. thumbnail_width : int Width of each thumbnail. params : dict, optional Optional parameters enclosed in a dict. Default is ``None``. See the "Other Parameters" section for understood key/value pairs. Returns ------- base_storyboard : PIL.Image.Image Other Parameters ---------------- tile_spacing : tuple, optional See the `tile_spacing` parameter of the `tile_images` function. Default is ``(0, 0)``. background_color : color, optional See the `canvas_color` paramter of the `tile_images` function. Default is ``'white'``. thumbnail_aspect_ratio : float, optional Aspect ratio of generated thumbnails. If ``None``, first try to use the display aspect ratio of the video (``self.video.dar``), then the aspect ratio of the frames if ``self.video.dar`` is not available. Default is ``None``. draw_timestamp : bool, optional See the `draw_timestamp` parameter of the `create_thumbnail` function. Default is ``False``. timestamp_font : Font, optional See the `timestamp_font` parameter of the `create_thumbnail` function. Default is the font constructed by ``Font()`` without arguments. timestamp_align : {'right', 'center', 'left'}, optional See the `timestamp_align` parameter of the `create_thumbnail` function. Default is ``'right'``. print_progress : bool, optional Whether to print progress information (to stderr). Default is False. """ if params is None: params = {} tile_spacing = _read_param(params, 'tile_spacing', (0, 0)) background_color = _read_param(params, 'background_color', 'white') if (('thumbnail_aspect_ratio' in params and params['thumbnail_aspect_ratio'] is not None)): thumbnail_aspect_ratio = params['thumbnail_aspect_ratio'] elif self.video.dar is not None: thumbnail_aspect_ratio = self.video.dar else: # defer calculation to after generating frames thumbnail_aspect_ratio = None draw_timestamp = _read_param(params, 'draw_timestamp', False) if draw_timestamp: timestamp_font = _read_param(params, 'timestamp_font', Font()) timestamp_align = _read_param(params, 'timestamp_align', 'right') print_progress = _read_param(params, 'print_progress', False) cols, rows = tile if (not (isinstance(cols, int) and isinstance(rows, int) and cols > 0 and rows > 0)): raise ValueError('tile is not a tuple of positive integers') thumbnail_count = cols * rows self.gen_frames(cols * rows, params={ 'print_progress': print_progress, }) if thumbnail_aspect_ratio is None: frame_size = self.frames[0].image.size thumbnail_aspect_ratio = frame_size[0] / frame_size[1] thumbnails = [] counter = 0 for frame in self.frames: counter += 1 if print_progress: sys.stderr.write("\rGenerating thumbnail %d/%d..." % (counter, thumbnail_count)) thumbnails.append( create_thumbnail(frame, thumbnail_width, params={ 'aspect_ratio': thumbnail_aspect_ratio, 'draw_timestamp': draw_timestamp, 'timestamp_font': timestamp_font, 'timestamp_align': timestamp_align, })) if print_progress: sys.stderr.write("\n") if print_progress: sys.stderr.write("Tiling thumbnails...\n") return tile_images(thumbnails, tile, params={ 'tile_spacing': tile_spacing, 'canvas_color': background_color, 'close_separate_images': True, })
def gen_frames(self, count, params=None): """Extract equally spaced frames from the video. When tasked with extracting N frames, this method extracts them at positions 1/2N, 3/2N, 5/2N, ... , (2N-1)/2N of the video. The extracted frames are stored in the `frames` attribute. Note that new frames are extracted only if the number of existing frames in the `frames` attribute doesn't match the specified `count` (0 at instantiation), in which case new frames are extracted to match the specification, and the `frames` attribute is overwritten. Parameters ---------- count : int Number of (equally-spaced) frames to generate. params : dict, optional Optional parameters enclosed in a dict. Default is ``None``. See the "Other Parameters" section for understood key/value pairs. Returns ------- None Access the generated frames through the `frames` attribute. RAISES ------ OSError If frame extraction with FFmpeg fails. Other Parameters ---------------- print_progress : bool, optional Whether to print progress information (to stderr). Default is False. """ if params is None: params = {} print_progress = _read_param(params, 'print_progress', False) if len(self.frames) == count: return duration = self.video.duration interval = duration / count timestamps = [interval * (i + 1 / 2) for i in range(0, count)] counter = 0 for timestamp in timestamps: counter += 1 if print_progress: sys.stderr.write("\rExtracting frame %d/%d..." % (counter, count)) try: frame = _extract_frame(self.video.path, timestamp, params={ 'ffmpeg_bin': self._bins[0], 'codec': self._frame_codec, 'frame_by_frame': self._seek_frame_by_frame, }) self.frames.append(frame) except: # \rExtracting frame %d/%d... isn't terminated by # newline yet if print_progress: sys.stderr.write("\n") raise if print_progress: sys.stderr.write("\n")
def gen_storyboard(self, params=None): """Generate full storyboard. A full storyboard has three sections, arranged vertically in the order listed: a metadata sheet, a bare storyboard, and a promotional banner. The metadata sheet consists of formatted metadata generated by ``storyboard.metadata.Video.format_metadata``; you may choose whether to include the SHA-1 hex digest of the video file (see `include_sha1sum` in "Other Parameters"). The bare storyboard is an array (usually 4x4) of thumbnails, generated from equally spaced frames from the video. The promotional banner briefly promotes this package (storyboard). The metadata sheet and promotional banner are optional and can be turned off individually. See `include_metadata_sheet` and `include_promotional_banner` in "Other Parameters". `Here <https://i.imgur.com/9T2zM8R.jpg>`_ is a basic example of a full storyboard. Parameters ---------- params : dict, optional Optional parameters enclosed in a dict. Default is ``None``. See the "Other Parameters" section for understood key/value pairs. Returns ------- full_storyboard : PIL.Image.Image Other Parameters ---------------- include_metadata_sheet: bool, optional Whether to include a video metadata sheet in the storyboard. Default is ``True``. include_promotional_banner: bool, optional Whether to include a short promotional banner about this package (storyboard) at the bottom of the storyboard. Default is ``True``. background_color : color, optional Background color of the storyboard, in any color format recognized by Pillow. Default is ``'white'``. section_spacing : int, optional Vertical spacing between adjacent sections (metadata sheet and bare storyboard, bare storyboard and promotional banner). If ``None``, use the vertical tile spacing (see `tile_spacing`). Default is ``None``. margins : tuple, optional A tuple ``(hor, ver)`` specifying the horizontal and vertical margins (padding) around the entire storyboard (all sections included). Default is ``(10, 10)``. tile : tuple, optional A tuple ``(cols, rows)`` specifying the number of columns and rows for the array of thumbnails in the storyboard. Default is ``(4, 4)``. tile_spacing : tuple, optional A tuple ``(hor, ver)`` specifying the horizontal and vertical spacing between adjacent thumbnails. Default is ``(8, 6)``. thumbnail_width : int, optional Width of each thumbnail. Default is 480 (as in 480x270 for a 16:9 video). thumbnail_aspect_ratio : float, optional Aspect ratio of generated thumbnails. If ``None``, first try to use the display aspect ratio of the video (``self.video.dar``), then the aspect ratio of the frames if ``self.video.dar`` is not available. Default is ``None``. draw_timestamp : bool, optional Whether to draw frame timestamps on top of thumbnails (as an overlay). Default is ``True``. timestamp_font : Font, optional Font used for timestamps, if `draw_timestamp` is ``True``. Default is the font constructed by ``Font()`` without arguments. timestamp_align : {'right', 'center', 'left'}, optional Horizontal alignment of timestamps over the thumbnails, if `draw_timestamp` is ``True``. Default is ``'right'``. Note that timestamps are always vertically aligned to the bottom. text_font: Font, optional Font used for metadata sheet and promotional banner. Default is the font constructed by ``Font()`` without arguments. text_color: color, optional Color of metadata and promotional text, in any format recognized by Pillow. Default is ``'black'``. line_spacing: float, optional Line spacing of metadata text as a float, e.g., 1.0 for single spacing, 1.5 for one-and-a-half spacing, and 2.0 for double spacing. Default is 1.2. include_sha1sum: bool, optional Whether to include the SHA-1 hex digest of the video file in the metadata fields. Default is ``False``. Be aware that computing SHA-1 digest is an expensive operation. print_progress : bool, optional Whether to print progress information (to stderr). Default is ``False``. """ # process parameters -- a ton of them if params is None: params = {} include_metadata_sheet = _read_param(params, 'include_metadata_sheet', True) include_promotional_banner = _read_param(params, 'include_promotional_banner', True) background_color = _read_param(params, 'background_color', 'white') margins = _read_param(params, 'margins', (10, 10)) tile = _read_param(params, 'tile', (4, 4)) tile_spacing = _read_param(params, 'tile_spacing', (8, 6)) if (('section_spacing' in params and params['section_spacing'] is not None)): section_spacing = params['section_spacing'] else: section_spacing = tile_spacing[1] thumbnail_width = _read_param(params, 'thumbnail_width', 480) thumbnail_aspect_ratio = _read_param(params, 'thumbnail_aspect_ratio', None) draw_timestamp = _read_param(params, 'draw_timestamp', True) timestamp_font = _read_param(params, 'timestamp_font', Font()) timestamp_align = _read_param(params, 'timestamp_align', 'right') text_font = _read_param(params, 'text_font', Font()) text_color = _read_param(params, 'text_color', 'black') line_spacing = _read_param(params, 'line_spacing', 1.2) include_sha1sum = _read_param(params, 'include_sha1sum', False) print_progress = _read_param(params, 'print_progress', False) # draw bare storyboard, metadata sheet, and promotional banner if print_progress: sys.stderr.write("Generating main storyboard...\n") bare_storyboard = self._gen_bare_storyboard( tile, thumbnail_width, params={ 'tile_spacing': tile_spacing, 'background_color': background_color, 'thumbnail_aspect_ratio': thumbnail_aspect_ratio, 'draw_timestamp': draw_timestamp, 'timestamp_font': timestamp_font, 'timestamp_align': timestamp_align, 'print_progress': print_progress, }) total_width, _ = bare_storyboard.size if include_metadata_sheet: if print_progress: sys.stderr.write("Generating metadata sheet...\n") metadata_sheet = self._gen_metadata_sheet(total_width, params={ 'text_font': text_font, 'text_color': text_color, 'line_spacing': line_spacing, 'background_color': background_color, 'include_sha1sum': include_sha1sum, 'print_progress': print_progress, }) if include_promotional_banner: if print_progress: sys.stderr.write("Generating promotional banner...\n") banner = self._gen_promotional_banner(total_width, params={ 'text_font': text_font, 'text_color': text_color, 'background_color': background_color, }) # combine different sections if print_progress: sys.stderr.write("Assembling pieces...\n") sections = [] if include_metadata_sheet: sections.append(metadata_sheet) sections.append(bare_storyboard) if include_promotional_banner: sections.append(banner) storyboard = tile_images(sections, (1, len(sections)), params={ 'tile_spacing': (0, section_spacing), 'margins': margins, 'canvas_color': background_color, 'close_separate_images': True, }) return storyboard
def tile_images(images, tile, params=None): """ Combine images into a composite image through 2D tiling. For example, 16 thumbnails can be combined into an 4x4 array. As another example, three images of the same width (think of the metadata sheet, the bare storyboard, and the promotional banner) can be combined into a 1x3 array, i.e., assembled vertically. The image list is processed column-first. Note that except if you use the `tile_size` option (see "Other Parameters"), you should make sure that images passed into this function satisfy the condition that the widths of all images in each column and the heights of all images in each row match perfectly; otherwise, this function will give up and raise a ValueError. Parameters ---------- images : list A list of PIL.Image.Image objects, satisfying the necessary height and width conditions (see explanation above). tile : tuple A tuple ``(m, n)`` indicating `m` columns and `n` rows. The product of `m` and `n` should be the length of `images`, or a `ValueError` will arise. params : dict, optional Optional parameters enclosed in a dict. Default is ``None``. See the "Other Parameters" section for understood key/value pairs. Returns ------- PIL.Image.Image The composite image. Raises ------ ValueError If the length of `images` does not match the product of columns and rows (as defined in `tile`), or the widths and heights of the images do not satisfy the necessary consistency conditions. Other Parameters ---------------- tile_size : tuple, optional A tuple ``(width, height)``. If this parameter is specified, width and height consistency conditions won't be checked, and every image will be resized to (width, height) when combined. Default is ``None``. tile_spacing : tuple, optional A tuple ``(hor, ver)`` specifying the horizontal and vertical spacing between adjacent tiles. Default is ``(0, 0)``. margins : tuple, optional A tuple ``(hor, ver)`` specifying the horizontal and vertical margins (padding) around the combined image. Default is ``(0, 0)``. canvas_color : color, optional A color in any format recognized by Pillow. This is only relevant if you have nonzero tile spacing or margins, when the background shines through the spacing or margins. Default is ``'white'``. close_separate_images : bool Whether to close the separate after combining. Closing the images will release the corresponding resources. Default is ``False``. """ # pylint: disable=too-many-branches if params is None: params = {} cols, rows = tile if len(images) != cols * rows: msg = "{} images cannot fit into a {}x{}={} array".format( len(images), cols, rows, cols * rows) raise ValueError(msg) hor_spacing, ver_spacing = _read_param(params, 'tile_spacing', (0, 0)) hor_margin, ver_margin = _read_param(params, 'margins', (0, 0)) canvas_color = _read_param(params, 'canvas_color', 'white') close_separate_images = _read_param(params, 'close_separate_images', False) if 'tile_size' in params and params['tile_size'] is not None: tile_size = params['tile_size'] tile_width, tile_height = tile_size canvas_width = (tile_width * cols + hor_spacing * (cols - 1) + hor_margin * 2) canvas_height = (tile_height * rows + ver_spacing * (rows - 1) + ver_margin * 2) resize = True else: # check column width consistency, bark if not # calculate total width along the way canvas_width = hor_spacing * (cols - 1) + hor_margin * 2 for col in range(0, cols): # reference width set by the first image in the column ref_index = 0 * cols + col ref_width, ref_height = images[ref_index].size canvas_width += ref_width for row in range(1, rows): index = row * cols + col width, height = images[index].size if width != ref_width: msg = ("the width of image #{} " "(row #{}, col #{}, {}x{}) " "does not agree with that of image #{} " "(row #{}, col #{}, {}x{})".format( index, row, col, width, height, ref_index, 0, col, ref_width, ref_height, )) raise ValueError(msg) # check row height consistency, bark if not # calculate total height along the way canvas_height = ver_spacing * (rows - 1) + ver_margin * 2 for row in range(0, rows): # reference width set by the first image in the column ref_index = row * cols + 0 ref_width, ref_height = images[ref_index].size canvas_height += ref_height for col in range(1, cols): index = row * cols + col width, height = images[index].size if height != ref_height: msg = ("the height of image #{} " "(row #{}, col #{}, {}x{}) " "does not agree with that of image #{} " "(row #{}, col #{}, {}x{})".format( index, row, col, width, height, ref_index, 0, col, ref_width, ref_height, )) raise ValueError(msg) # passed tests, will assemble as is resize = False # start assembling images canvas = Image.new('RGB', (canvas_width, canvas_height), canvas_color) y = ver_margin for row in range(0, rows): x = hor_margin for col in range(0, cols): image = images[row * cols + col] if resize: canvas.paste(image.resize(tile_size, Image.LANCZOS), (x, y)) else: canvas.paste(image, (x, y)) # accumulate width of this column, as well as horizontal spacing x += image.size[0] + hor_spacing # accumulate height of this row, as well as vertical spacing y += images[row * cols + 0].size[1] + ver_spacing if close_separate_images: for image in images: image.close() return canvas
def create_thumbnail(frame, width, params=None): """Create thumbnail of a video frame. The timestamp of the frame can be overlayed to the thumbnail. See "Other Parameters" to how to enable this feature and relevant options. Parameters ---------- frame : storyboard.frame.Frame The video frame to be thumbnailed. width : int Width of the thumbnail. params : dict, optional Optional parameters enclosed in a dict. Default is ``None``. See the "Other Parameters" section for understood key/value pairs. Returns ------- thumbnail : PIL.Image.Image Other Parameters ---------------- aspect_ratio : float, optional Aspect ratio of the thumbnail. If ``None``, use the aspect ratio (only considering the pixel dimensions) of the frame. Default is ``None``. draw_timestamp : bool, optional Whether to draw frame timestamp over the timestamp. Default is ``False``. timestamp_font : Font, optional Font for the timestamp, if `draw_timestamp` is ``True``. Default is the font constructed by ``Font()`` without arguments. timestamp_align : {'right', 'center', 'left'}, optional Horizontal alignment of the timestamp over the thumbnail, if `draw_timestamp` is ``True``. Default is ``'right'``. Note that the timestamp is always vertically aligned towards the bottom of the thumbnail. """ if params is None: params = {} if 'aspect_ratio' in params: aspect_ratio = params['aspect_ratio'] else: image_width, image_height = frame.image.size aspect_ratio = image_width / image_height height = int(round(width / aspect_ratio)) size = (width, height) draw_timestamp = _read_param(params, 'draw_timestamp', False) if draw_timestamp: timestamp_font = _read_param(params, 'timestamp_font', Font()) timestamp_align = _read_param(params, 'timestamp_align', 'right') thumbnail = frame.image.resize(size, Image.LANCZOS) if draw_timestamp: draw = ImageDraw.Draw(thumbnail) timestamp_text = util.humantime(frame.timestamp, ndigits=0) timestamp_width, timestamp_height = \ draw.textsize(timestamp_text, timestamp_font.obj) # calculate upperleft corner of the timestamp overlay # we hard code a margin of 5 pixels timestamp_y = height - 5 - timestamp_height if timestamp_align == 'right': timestamp_x = width - 5 - timestamp_width elif timestamp_align == 'left': timestamp_x = 5 elif timestamp_align == 'center': timestamp_x = int((width - timestamp_width) / 2) else: raise ValueError("timestamp alignment option '%s' not recognized" % timestamp_align) # draw white timestamp with 1px thick black border for x_offset in range(-1, 2): for y_offset in range(-1, 2): draw.text((timestamp_x + x_offset, timestamp_y + y_offset), timestamp_text, fill='black', font=timestamp_font.obj) draw.text((timestamp_x, timestamp_y), timestamp_text, fill='white', font=timestamp_font.obj) return thumbnail
def draw_text_block(canvas, xy, text, params=None): """Draw a block of text. You need to specify a canvas to draw upon. If you are not sure about the size of the canvas, there is a `dry_run` option (see "Other Parameters" that help determine the size of the text block before creating the canvas. Parameters ---------- canvas : PIL.ImageDraw.Image The canvas to draw the text block upon. If the `dry_run` option is on (see "Other Parameters"), `canvas` can be ``None``. xy : tuple Tuple ``(x, y)`` consisting of x and y coordinates of the topleft corner of the text block. text : str Text to be drawn, can be multiline. params : dict, optional Optional parameters enclosed in a dict. Default is ``None``. See the "Other Parameters" section for understood key/value pairs. Returns ------- (width, height) Size of text block. Other Parameters ---------------- font : Font, optional Default is the font constructed by ``Font()`` without arguments. color : color, optional Color of text; can be in any color format accepted by Pillow (used for the ``fill`` argument of ``PIL.ImageDraw.Draw.text``). Default is ``'black'``. spacing : float, optional Line spacing as a float. Default is 1.2. dry_run : bool, optional If ``True``, do not draw anything, only return the size of the text block. Default is ``False``. """ if params is None: params = {} x, y = xy font = _read_param(params, 'font', Font()) color = _read_param(params, 'color', 'black') spacing = _read_param(params, 'spacing', 1.2) dry_run = _read_param(params, 'dry_run', False) if not dry_run: draw = ImageDraw.Draw(canvas) line_height = int(round(font.size * spacing)) width = 0 height = 0 for line in text.splitlines(): w, _ = font.obj.getsize(line) if not dry_run: draw.text((x, y), line, fill=color, font=font.obj) if w > width: width = w # update width to that of the current widest line height += line_height y += line_height return (width, height)
def extract_frame(video_path, timestamp, params=None): """Extract a video frame from a given timestamp. Use FFmpeg to seek to the specified timestamp and decode the corresponding frame. Parameters ---------- video_path : str Path to the video file. timestamp : float Timestamp in seconds (as a nonnegative float). params : dict, optional Optional parameters enclosed in a dict. Default is ``None``. See the "Other Parameters" section for understood key/value pairs. Returns ------- frame : Frame Raises ------ OSError If video file doesn't exist, ffmpeg binary doesn't exist or fails to run, or ffmpeg runs but generates no output (possibly due to an out of range timestamp). Other Parameters ---------------- ffmpeg_bin : str, optional Name or path of FFmpeg binary. If ``None``, make educated guess using ``storyboard.fflocate.guess_bins``. Default is ``None``. codec : str, optional Image codec used by FFmpeg when outputing the frame. Default is ``'png'``. There is no need to touch this option unless your FFmpeg cannot encode PNG, which is very unlikely. frame_by_frame : bool, optional Whether to seek frame by frame, i.e., whether to use output seeking (see https://trac.ffmpeg.org/wiki/Seeking). Default is ``False``. Note that seeking frame by frame is *extremely* slow, but accurate. Only use this when the container metadata is wrong or missing, so that input seeking produces wrong image. """ if params is None: params = {} if 'ffmpeg_bin' in params and params['ffmpeg_bin'] is not None: ffmpeg_bin = params['ffmpeg_bin'] else: ffmpeg_bin, _ = fflocate.guess_bins() codec = _read_param(params, 'codec', 'png') frame_by_frame = (params['frame_by_frame'] if 'frame_by_frame' in params else False) if not os.path.exists(video_path): raise OSError("video file '%s' does not exist" % video_path) ffmpeg_args = [ffmpeg_bin] if frame_by_frame: # output seeking ffmpeg_args += [ '-i', video_path, '-ss', str(timestamp), ] else: # input seeking ffmpeg_args += [ '-ss', str(timestamp), '-i', video_path, ] ffmpeg_args += [ '-f', 'image2', '-vcodec', codec, '-vframes', '1', '-hide_banner', '-', ] proc = subprocess.Popen(ffmpeg_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) frame_bytes, ffmpeg_err = proc.communicate() if proc.returncode != 0: msg = (("ffmpeg failed to extract frame at time %.2f\n" "ffmpeg error message:\n%s") % (timestamp, ffmpeg_err.strip().decode('utf-8'))) raise OSError(msg) if not frame_bytes: # empty output, no frame generated msg = ("ffmpeg generated no output " "(timestamp %.2f might be out of range)" "ffmpeg error message:\n%s" % (timestamp, ffmpeg_err.strip().decode('utf-8'))) raise OSError(msg) try: frame_image = Image.open(io.BytesIO(frame_bytes)) except IOError: raise OSError("failed to open frame with PIL.Image.open") return Frame(timestamp, frame_image)