| unscore - Script for converting score videos into playable PDFs.
git clone https://benconnors.ca/git-repos/unscore |
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])