unscore - Script for converting score videos into playable PDFs.

git clone https://benconnors.ca/git-repos/unscore

About | Log | Files | Refs

unscore.py (14999B) - raw


      1 #!/usr/bin/env python3
      2 
      3 import os
      4 import re
      5 import subprocess as subp
      6 import tempfile
      7 from typing import List
      8 
      9 import numpy as np
     10 from PIL import Image, ImageOps, ImageFilter, ImageMorph, ImageChops, ImageDraw, ImageFont
     11 try:
     12     import scipy.ndimage
     13     import scipy.signal
     14 
     15     HAVE_SCIPY = True
     16 except ModuleNotFoundError:
     17     HAVE_SCIPY = False
     18 
     19 ## BEGIN Thresholding
     20 
     21 def threshold(image: Image, level: int) -> Image:
     22     """Threshold an image at the given level."""
     23     return image.convert("L").point(lambda p: p >= level and 255, mode="1")
     24 
     25 def compute_otsu_level(image: Image) -> int:
     26     """Compute the Otsu level for thresholding."""
     27     hist = image.histogram()
     28     total = sum(hist)
     29 
     30     sum_b = 0
     31     w_b = 0
     32     mx = 0
     33     level = 0
     34     sum_1 = sum((a*b for a, b in zip(range(256), hist)))
     35     for i in range(1, 256):
     36         w_f = total - w_b
     37         if w_b > 0 and w_f > 0:
     38             m_f = (sum_1 - sum_b) / w_f
     39             val = w_b * w_f * ((sum_b / w_b) - m_f) * ((sum_b / w_b) - m_f)
     40             if val >= mx:
     41                 level = i
     42                 mx = val
     43         w_b += hist[i]
     44         sum_b += (i-1) * hist[i]
     45 
     46     return level
     47 
     48 def otsu(image: Image) -> Image:
     49     """Threshold an image using the Otsu level.
     50 
     51     Otsu thresholding typically does very well when the image is first upsampled, then    thresholded, e.g. via
     52 
     53       otsu(img.resize((img.width*2, img.height*2), resample=Image.Resampling.LANCZOS))
     54 
     55     This is useful when e.g. the original image is low-resolution; upsampling then
     56     thresholding will typically give a better result than thresholding and then
     57     upsampling.
     58     """
     59     image = image.convert("L")
     60     o = compute_otsu_level(image)
     61     return image.point(lambda i: i > o, mode="1")
     62 
     63 def local_otsu(image, window_size=100):
     64     """Threshold the image via the local Otsu method."""
     65     output = Image.new("1", image.size)
     66     for x_window in range(math.ceil(image.width / window_size)):
     67         for y_window in range(math.ceil(image.height / window_size)):
     68             a = x_window*window_size
     69             b = min(x_window*window_size + window_size, image.width)
     70             c = y_window*window_size
     71             d = min(y_window*window_size + window_size, image.height)
     72 
     73             output.paste(otsu(image.crop((a, c, b, d))), (a, c))
     74 
     75     return output
     76 
     77 ## END Thresholding
     78 
     79 ## BEGIN Image processing
     80 
     81 def difference(a: Image, b: Image, level: int = 50):
     82     """Compute a difference mask of two images.
     83 
     84     We do:
     85 
     86     1. Compute the difference between `a` and `b`
     87     2. Threshold this to remove noise using `level`
     88     3. Use grayscale dilation to expand this slightly
     89     4. Threshold
     90     """
     91     ## TODO: Is step 2 necessary? At worst we should just change some artifacting
     92     return ImageMorph.MorphOp(op_name="dilation8").apply(threshold(ImageChops.difference(a, b), level).convert("L"))[1].convert("1")
     93 
     94 def remove_line(a: Image, b: Image, c: Image, level: int = 50) -> Image:
     95     """Remove a moving line from a sequence of frames.
     96 
     97     The three images `a`, `b`, and `c` should each have the line in a different spot.
     98     We do:
     99 
    100     1. Compute the difference mask between `a` and `b`
    101     2. Paste these parts of image `c` into `a`.
    102 
    103     If the images are of varying quality, `a` should be the best.
    104     """
    105     a = a.copy()
    106     a.paste(c, None, mask=difference(a, b, level=level))
    107     
    108     return a
    109 
    110 def do_savgol(scene: Image, cutoff: float) -> Image:
    111     """Run a 2D Savitzky-Golay filter on the image, then threshold at `cutoff`.
    112 
    113     This is very good at smoothing out thresholded images and should be used as the
    114     final step; it is computationally expensive.
    115     """
    116     if not HAVE_SCIPY:
    117         raise ModuleNotFoundError("Need scipy for savgol filtering")
    118     a = np.array(scene).astype(float)
    119     b = scipy.signal.savgol_filter(
    120         scipy.signal.savgol_filter(a, 11, 4, axis=1), 
    121         11, 
    122         4, 
    123         axis=0
    124     )
    125     return Image.fromarray(b > cutoff)
    126 
    127 def split_lines(image: Image, min_dist: int = 15) -> List[Image]:
    128     """Split an image into lines, each separated by at least `min_dist`."""
    129     ## TODO: Morphological opening here to remove noise?
    130     image = otsu(image)
    131     arr = np.array(image)
    132 
    133     extents = []
    134     last = None
    135     for n, row in enumerate(arr):
    136         if False not in row:
    137             if last is None:
    138                 continue
    139             else:
    140                 extents.append((last, n))
    141                 last = None
    142         else:
    143             if last is None:
    144                 last = n
    145 
    146     if last is not None:
    147         extents.append((last, len(arr)))
    148 
    149     found = True
    150     while found:
    151         found = False
    152         for i in range(len(extents)-1):
    153             a, b = extents[i]
    154             a2, b2 = extents[i+1]
    155             if a2 - b < min_dist:
    156                 extents.pop(i)
    157                 extents.pop(i)
    158                 extents.insert(i, (a, b2))
    159                 found = True
    160                 break
    161 
    162     return extents
    163 
    164 ## END Image processing
    165 
    166 ## BEGIN Crop/pad
    167 
    168 def double_bbox(image: Image) -> [int, int, int, int]:
    169     """Get the bounding box of an image possibly surrounded by a black then white 
    170     outline.
    171     """
    172     image = threshold(image, 95)
    173     left, upper, right, lower = image.getbbox()
    174 
    175     other = image.crop((left, upper, right, lower))
    176     left2, upper2, right2, lower2 = ImageOps.invert(other).getbbox()
    177 
    178     return left2+left, upper2+upper, right2+left, lower2+upper
    179 
    180 def crop_scene(i: Image, new_width: int) -> Image:
    181     """Remove the bounding box from a scene and resize to match `new_width`."""
    182     width, height = i.width, i.height
    183     left, upper, right, lower = double_bbox(i)
    184     i = i.crop((left, upper, right, lower))
    185     
    186     new_height = int((lower - upper)*new_width/(right-left)+1)
    187     ri = i.resize((new_width, new_height))
    188 
    189     return ri
    190 
    191 def pad_scene(scene: Image, padding: int = 10) -> Image:
    192     """Add padding to a scene."""
    193     i = Image.new("L", size=(scene.width, scene.height+2*padding), color=255)
    194     i.paste(scene, (0, padding))
    195 
    196     return i
    197 
    198 ## END Crop/pad
    199 
    200 ## BEGIN FFmpeg
    201 
    202 def get_scenes(file: str, cutoff: float = 0.05) -> List[float]:
    203     """Use FFmpeg to get a list of scenes in a video file."""
    204     res = subp.check_output(["ffmpeg", "-i", file, "-filter:v", "select='gt(scene,%s)',showinfo" % str(cutoff), "-f", "null", "-"], stderr=subp.STDOUT)
    205 
    206     ## FFmpeg's output is annoying to parse...
    207     times = []
    208     for line in res.decode("utf-8").split('\n'):
    209         if not "showinfo" in line:
    210             continue
    211         val = re.search(r'pts_time:([0-9.]+)', line)
    212         if not val:
    213             continue
    214         times.append(float(val.group(1)))
    215 
    216     return [0] + times
    217 
    218 def do_screenshots(file: str, td: str, fps: int = 1) -> List[str]:
    219     """Use FFmpeg to take `fps` screenshots per second of `file`, storing them in 
    220     the folder `td`.
    221 
    222     Space-wise this is wasteful but is faster/simpler than taking only the screenshots
    223     we need.
    224     """
    225     subp.check_call(["ffmpeg", "-i", file, "-q:v", "3", "-vf", "fps=%d" % fps, os.path.join(td, "%09d.jpg")], stderr=subp.DEVNULL)
    226     snapshots = []
    227     for f in os.listdir(td):
    228         if not f.endswith(".jpg"):
    229             continue
    230         time = int(f.split('.')[0])
    231         snapshots.append((time, os.path.join(td, f)))
    232 
    233     return sorted(snapshots)
    234 
    235 ## END FFmpeg
    236 
    237 ## BEGIN Scenes
    238 
    239 def filter_scenes(scenes: List[float], cutoff: float = 1) -> List[float]:
    240     """Filter out scenes that are too close together (false positives)."""
    241     new = []
    242     last = None
    243     for s in scenes[::-1]:
    244         if last is None or last - s > cutoff:
    245             new.append(s)
    246             last = s
    247 
    248     return list(reversed(new))
    249 
    250 def find_scenes(f: str, ffmpeg_cutoff: float, line: bool = False, scene_cutoff: float = 1, fps: int = 1) -> List[Image]:
    251     """Return a list of scenes in the file `f`."""
    252     ## Find the times we need
    253     times = filter_scenes(get_scenes(f, ffmpeg_cutoff), scene_cutoff)
    254     with tempfile.TemporaryDirectory() as td:
    255         ## Take screenshots
    256         snapshots = do_screenshots(f, td, fps=fps)
    257 
    258         ## Open the screenshots we need
    259         use = []
    260         for n, (t, nt) in enumerate(zip(times, times[1:]+[None]), 1):
    261             ## Figure out which screenshots work for this scene
    262             available = [b for a, b in snapshots if t < a and (nt is None or a < nt)]
    263 
    264             if line:
    265                 ## Grab three shots to remove the line
    266                 p1 = Image.open(available[len(available)//3])
    267                 p2 = Image.open(available[len(available)//2])
    268                 p3 = Image.open(available[2*(len(available)//3)])
    269 
    270                 use.append(remove_line(p2, p1, p3))
    271             else:
    272                 ## Grab the middle, usually the compression is better
    273                 pick =  available[len(available)//2]
    274                 use.append(Image.open(pick))
    275 
    276         return use
    277 
    278 def split_scene(scene: Image, min_dist: int = 10) -> List[Image]:
    279     """Split a scene into lines."""
    280     todo = []
    281     extents = split_lines(scene, min_dist)
    282     for a, b in extents:
    283         todo.append(scene.crop((0, a, scene.width, b)))
    284     return todo
    285 
    286 def merge_lines(images: List[Image], aspect: float = 8.5/11, center: bool = True, breaks: List[int] = None, center_breaks: bool = False) -> List[Images]:
    287     """Merge lines into images of maximum aspect ratio `aspect`.
    288 
    289     The resulting images will have the same width as the widest input line.
    290     
    291     The result may have aspect ratio larger than `aspect` if an input image already
    292     does; otherwise, they will all have height `max_width / aspect`.
    293 
    294     :param breaks: A list of forced page-breaks (e.g. page-turns).
    295     :param center: Center images horizontally.
    296     :param center_breaks: Space out lines on below-height pages.
    297     """
    298     width = max(i.width for i in images)
    299 
    300     todo = []
    301     output = []
    302     broken = []
    303     ## Split input images into groups of appropriate size
    304     for n, i in enumerate(images):
    305         height = sum((j.height for j in todo))
    306         if todo and (width/(height+i.height)) < aspect or n-1 in breaks:
    307             output.append(todo)
    308             broken.append(len(output))
    309             todo = []
    310 
    311         todo.append(i)
    312 
    313     output.append(todo)
    314 
    315     ## Merge them together
    316     for n, todo in enumerate(output, 1):
    317         height = sum((j.height for j in todo))
    318         outimage = Image.new("L", (width, max(height, int(width/aspect + 1))), color=255)
    319 
    320         if center and (center_breaks or n not in broken):
    321             padding = int((width/aspect - height)/(len(todo)+1))
    322         else:
    323             padding = 0
    324 
    325         cury = padding
    326         for t in todo:
    327             outimage.paste(t, (0, cury))
    328             cury += t.height + padding
    329 
    330         yield outimage
    331 
    332 DEFAULT_FONT = ImageFont.load_default(64)
    333 
    334 def number_images(images: List[Image], start: int = 0, in_place: bool = False, font = DEFAULT_FONT) -> List[Image]:
    335     res = []
    336     for n, i in enumerate(images, start):
    337         if not in_place:
    338             i = i.copy()
    339         d = ImageDraw.Draw(i)
    340         d.text((10, 10), str(n), font=font, fill=0)
    341 
    342         res.append(i)
    343 
    344     return res
    345 
    346 ## END Scenes
    347 
    348 if __name__ == "__main__":
    349     import argparse as ap
    350 
    351     parser = ap.ArgumentParser()
    352     subparsers = parser.add_subparsers(dest="command", required=True)
    353 
    354     find = subparsers.add_parser("find", help="Find scenes in video")
    355     find.add_argument("input", help="Input video to process")
    356     find.add_argument("-c", "--cutoff", type=float, default=0.03, help="Default scene cutoff for FFmpeg")
    357     find.add_argument("-l", "--line", action="store_true", help="Remove scrolling line")
    358     find.add_argument("-t", "--scene-time", type=float, default=2, help="Length of shortest scene")
    359     find.add_argument("-f", "--format", help="Output format", default="png")
    360     find.add_argument("outdir", help="Output directory")
    361 
    362     merge = subparsers.add_parser("merge", help="Merge scenes into a PDF")
    363     merge.add_argument("input", help="Input directory")
    364     merge.add_argument("-l", "--line-dist", default=10, type=int, help="Minimum distance between lines")
    365     merge.add_argument("-a", "--aspect", help="Desired aspect ratio (width/height)", type=float, default=8.5/11)
    366     merge.add_argument("-p", "--padding", help="Padding between cropped lines", type=int, default=50)
    367     merge.add_argument("-e", "--center", action="store_true", help="Horizontally center lines")
    368     merge.add_argument("-b", "--center-breaks", action="store_true", help="Vertically center breaks on a page")
    369     merge.add_argument("-s", "--savgol", type=float, default=0, help="Savgol threshold (0 to disable)")
    370     merge.add_argument("-o", "--otsu", action="store_true", help="Run Otsu thresholding")
    371     merge.add_argument("-f", "--format", help="Output format", default="png")
    372     merge.add_argument("-d", "--dpi", type=int, default=300, help="PDF DPI")
    373     merge.add_argument("-n", "--number", action="store_true", help="Number lines in output")
    374     merge.add_argument("--no-merge", action="store_true", help="Don't merge the output, save them to the output folder instead")
    375     merge.add_argument("output", help="Output file")
    376     merge.add_argument("forced_breaks", nargs="*", type=int, help="Forced page breaks")
    377 
    378     args = parser.parse_args()
    379 
    380     if args.command == "find":
    381         os.makedirs(args.outdir, exist_ok=True)
    382 
    383         scenes = find_scenes(args.input, args.cutoff, line=args.line, scene_cutoff=args.scene_time)
    384 
    385         fmt = "out%07d."+args.format
    386         for n, s in enumerate(scenes):
    387             s.save(os.path.join(args.outdir, fmt % n))
    388     elif args.command == "merge":
    389         if args.no_merge:
    390             os.makedirs(args.output, exist_ok=True)
    391 
    392         scenes = []
    393         for f in sorted(os.listdir(args.input)):
    394             if not f.endswith('.'+args.format):
    395                 continue
    396             scenes.append(Image.open(os.path.join(args.input, f)))
    397 
    398         new = []
    399         width = max(s.width for s in scenes)
    400         for s in scenes:
    401             s = crop_scene(s, width)
    402             for r in split_scene(s, args.line_dist):
    403                 new.append(pad_scene(r, args.padding))
    404 
    405         if args.number:
    406             number_images(new, in_place=True)
    407 
    408         with tempfile.TemporaryDirectory() as outdir:
    409             outfiles = []
    410             fmt = "out%07d."+args.format
    411 
    412             for n, done in enumerate(merge_lines(new, aspect=args.aspect, center=args.center, center_breaks=args.center_breaks, breaks=args.forced_breaks), 1):
    413                 name = os.path.join(outdir if not args.no_merge else args.output, fmt % n)
    414                 outfiles.append(name)
    415                 if args.otsu:
    416                     done = otsu(done.resize((done.width*2, done.height*2), resample=Image.Resampling.LANCZOS))
    417                 if args.savgol > 0:
    418                     done = do_savgol(done, args.savgol)
    419                 done.save(name)
    420 
    421             if not args.no_merge:
    422                 subp.check_call(["magick", "-density", str(args.dpi), *outfiles, args.output])