-
-
Save Fortyseven/c975a2a26d7af49389ff32539259d51a to your computer and use it in GitHub Desktop.
| #!/usr/bin/env python3 | |
| ''' | |
| Creates a montage grid of frames from a video file with frame and | |
| time offset labels to help give a basic summary of the video file contents. | |
| ''' | |
| # ---------------------------------------------------------------------------- | |
| # REQUIRES: This script requires `ffmpeg`` (including `ffprobe``) and | |
| # imagemagick (with `convert` and `montage`) to be available on the path. | |
| # | |
| # WARNING: This is an extremely kludgy tool that makes a real mess but tries to | |
| # clean up afterward. If you stop it mid-process you might have a bunch of .png | |
| # files to clean up. This is seriously janky and will probably be unhappy with | |
| # small files with large grid sizes. | |
| # | |
| # There are ABSOLUTELY better ways to do this, but it works "good enough" | |
| # for my purposes. (You know how that goes.) | |
| # | |
| # 2022-06-19, Fortyseven (Network47.org) | |
| # LICENSE: Steal me. | |
| # ---------------------------------------------------------------------------- | |
| import os | |
| import sys | |
| from os.path import exists | |
| CMD_FFPROBE_FRAME_COUNT = "ffprobe -v error -select_streams v:0 -count_packets -show_entries stream=nb_read_packets -of csv=p=0 " | |
| CMD_FFPROBE_FRAMERATE_COUNT = "ffprobe -v error -select_streams v:0 -of default=noprint_wrappers=1:nokey=1 -show_entries stream=r_frame_rate " | |
| FFMPEG_SHARP = "unsharp=lx=5:ly=5:la=0.5" | |
| DEFAULT_COLS = 8 | |
| DEFAULT_ROWS = 8 | |
| INTERFRAME_OFFSET = 30 # used to avoid the "black frame 0" problem | |
| MONTAGE_OUTPUT_FILE = "montage.png" | |
| # too big and `montage` will run out of memory -- yes, it seems to load each frame into memory when building... 8x8x1920x1080x4 | |
| EXTRACTED_FRAME_WIDTH = 256 | |
| LABEL_FONTSIZE = 10 | |
| # We'll either take just the input and default to 8x8, or you'll need to | |
| # provide BOTH column and row counts. Otherwise: outta here. | |
| if not len(sys.argv) in [2, 4, 5]: | |
| print(f"usage: {sys.argv[0]} video_file [col_count row_count] [outfile]") | |
| sys.exit(-1) | |
| input_video_file = sys.argv[1] | |
| grid_columns = DEFAULT_COLS | |
| grid_rows = DEFAULT_ROWS | |
| if len(sys.argv) >= 4: | |
| grid_columns = int(sys.argv[2]) | |
| grid_rows = int(sys.argv[3]) | |
| if len(sys.argv) == 5: | |
| MONTAGE_OUTPUT_FILE = sys.argv[4] | |
| try: | |
| # Query ffprobe for the frame count | |
| h = os.popen(CMD_FFPROBE_FRAME_COUNT + input_video_file) | |
| vid_frames_total = int(h.read().strip()) | |
| # Query ffprobe for the framerate | |
| h2 = os.popen(CMD_FFPROBE_FRAMERATE_COUNT + input_video_file) | |
| vid_fps = round(eval(h2.read().strip()), 3) | |
| # Get an estimate of how long the video is, in minutes | |
| vid_minutes = round((vid_frames_total / vid_fps)/60, 3) | |
| grid_total_frames = grid_columns * grid_rows | |
| print( | |
| f"* There are {vid_frames_total} frames at {vid_fps}fps for ~{vid_minutes} mins.") | |
| print( | |
| f"* Grid of size {grid_columns}x{grid_rows} will have {grid_total_frames} frames.") | |
| frame_index_roster = [] # array of frame indexes into the video | |
| select_elements = [] # array for building the ffmpeg extraction call | |
| frame_data = [] # array holding the label under each frame | |
| label_fnames = [] # filenames for the resulting labeled frames | |
| # gather a list of candidate frame numbers to extract | |
| for grid_frame_index in range(grid_columns * grid_rows): | |
| frame_index_roster.append( | |
| round(vid_frames_total / grid_total_frames) * grid_frame_index + INTERFRAME_OFFSET) | |
| # builds list of frame index specifiers to pass to ffmpeg, also sets up later vars | |
| i = 1 | |
| for row in range(grid_rows): | |
| for col in range(grid_columns): | |
| frame_index = frame_index_roster[grid_columns * row + col] | |
| top = "eq(n\\," | |
| bot = top + f"{frame_index})" | |
| select_elements.append(bot) | |
| pct_done = (frame_index / vid_frames_total) | |
| min_offs = vid_minutes * pct_done | |
| label_text = f"Frame {frame_index} @ {round(min_offs,2)} min ({round(pct_done * 100,2)}%)" | |
| frame_data.append(label_text) | |
| label_fnames.append(f"montage_labeled_{i}.png") | |
| i += 1 | |
| # build the ffmpeg extraction call | |
| vfsel = f"-vf \"select='{'+'.join(select_elements)}', scale={EXTRACTED_FRAME_WIDTH}\:-1, {FFMPEG_SHARP}\"" | |
| cmd_extract = f"ffmpeg -hide_banner -loglevel error -i {input_video_file} {vfsel} " | |
| cmd_extract += f" -vsync 0 montage_frame_%d.png" | |
| print("* Extracting frames (this will take some time depending on size of grid)...") | |
| if os.system(cmd_extract): | |
| os._exit(-1) | |
| # add labels to all the exported frames | |
| print("* Building labeled versions...") | |
| for frame_id in range(grid_columns*grid_rows): | |
| cmd_label = f"convert montage_frame_{1+frame_id}.png -background black -fill white -pointsize {LABEL_FONTSIZE} label:\"{frame_data[frame_id]}\" -gravity center -append montage_labeled_{1+frame_id}.png" | |
| # print(frame_data[frame_id]) | |
| if os.system(cmd_label): | |
| os._exit(-1) | |
| print(f"* Building montage to `{MONTAGE_OUTPUT_FILE}`...") | |
| cmd_montage = f"montage -density {EXTRACTED_FRAME_WIDTH} -tile {grid_columns}x{grid_rows} -geometry +0+0 -border 0 " | |
| cmd_montage += " ".join(label_fnames) | |
| cmd_montage += f" {MONTAGE_OUTPUT_FILE}" | |
| if os.system(cmd_montage): | |
| os._exit(-1) | |
| except Exception as e: | |
| print("Some horrible thing happened: ") | |
| print(e) | |
| finally: | |
| # try to delete all our temporary files | |
| print("* Removing temporary files...") | |
| for i in range(grid_columns * grid_rows): | |
| f = f"montage_frame_{1+i}.png" | |
| if exists(f): | |
| os.remove(f) | |
| f = f"montage_labeled_{1+i}.png" | |
| if exists(f): | |
| os.remove(f) |
I believe I noticed similar issues now and then, come to think of it. I'll migrate this over to use argparse for argument handling sometime in the next day or so. Maybe I should make a proper repo while I'm at it... either way, I'll ping you with an update when that happens. 👍
Gotcha, thanks much! I can see using this script frequently to make thumbnails when I link to videos on our gaming blog.
Sweet.
I went the repo route and migrated over to argparse. Seemed to work alright while I was generating example images for the readme, so: 🤞. :)
Usage should go like extract-grid-overview.py -c 12 -r 12 input.mp4 output.png, and of course -h for a reminder. I'll probably add flags for other settings at some point (like the label styles, etc).
https://github.com/Fortyseven/extract-grid-overview
Direct link to the updated .py -> https://raw.githubusercontent.com/Fortyseven/extract-grid-overview/master/extract-grid-overview.py
I had a couple of issues running it--
First, it seems to have issues with filenames containing spaces. Even if I surround the filename with quotes it interprets it as multiple arguments, In a test run, I got the error "Argument 'tpy.mp4' provided as input filename, but 'tpy' was already specified."
Second, I then renamed the file so it didn't contain spaces, but in my run I got the error "Invalid parameter - -background" after the Building labeled versions step. I'm using Windows Powershell and the current versions of both this code and ffmpeg as of 8/15/2022.