| unscore - Script for converting score videos into playable PDFs.
git clone https://benconnors.ca/git-repos/unscore |
unscore_gui.py (11071B) - raw
1 #!/usr/bin/env python3 2 3 import argparse as ap 4 import os 5 from tkinter import * 6 from tkinter import filedialog 7 from tkinter.ttk import * 8 9 from unscore import * 10 11 class ScrollableFrame(Frame): 12 def _on_mousewheel(self, e): 13 delta = e.delta 14 if e.num == 5 or delta == -120: 15 delta = -1 16 if e.num == 4 or delta == 120: 17 delta = 1 18 self.canvas.yview_scroll(-delta, "units") 19 20 def _mouse_enter(self, *args, **kwargs): 21 for button in ("<MouseWheel>", "<Button-4>", "<Button-5>",): 22 self.bind_all(button, self._on_mousewheel) 23 24 def _mouse_leave(self, *args, **kwargs): 25 for button in ("<MouseWheel>", "<Button-4>", "<Button-5>",): 26 self.unbind_all(button) 27 28 def __init__(self, *args, **kwargs): 29 super().__init__(*args, **kwargs) 30 self.rowconfigure(0, weight=1) 31 self.columnconfigure(0, weight=1) 32 33 self.canvas = Canvas(self) 34 self.canvas.grid(row=0, sticky=N+E+S+W) 35 36 self.scrollbar = Scrollbar(self, orient="vertical", command=self.canvas.yview) 37 self.scrollbar.grid(row=0, column=1, sticky=N+E+S+W) 38 39 self.inner = Frame(self.canvas) 40 self.canvas.create_window((0, 0), window=self.inner, anchor=N+W) 41 self.canvas.configure(yscrollcommand=self.scrollbar.set) 42 self.bind("<Enter>", self._mouse_enter) 43 self.bind("<Leave>", self._mouse_leave) 44 45 self.inner.bind( 46 "<Configure>", 47 lambda _: self.canvas.configure(scrollregion=self.canvas.bbox("all")) 48 ) 49 50 folder_name = None 51 folder_name_set = False 52 def choose_folder(*args, **kwargs): 53 global folder_name, folder_name_set 54 55 fname = filedialog.askdirectory(initialdir=folder_name) 56 if not fname: 57 return 58 59 folder_name_set = True 60 folder_name = fname 61 folder_name_var.set(fname) 62 63 reload_folder() 64 65 video_fname = None 66 def choose_video(*args, **kwargs): 67 global video_fname, folder_name 68 69 fname = filedialog.askopenfilename(initialfile=video_fname) 70 if not fname: 71 return 72 73 video_fname = fname 74 video_fname_var.set(fname) 75 76 if not folder_name_set: 77 folder_name = fname + "_scenes" 78 folder_name_var.set(folder_name) 79 80 reload_video() 81 82 scenes_list = [] 83 84 ## 1. Load scenes using one of the two ways: 85 def reload_video(*aargs, **kwargs): 86 """Find scenes from a video file.""" 87 global scenes_list 88 89 os.makedirs(folder_name, exist_ok=True) 90 91 scenes_list = find_scenes( 92 video_fname, 93 ffmpeg_cutoff_scale.get(), 94 line=bool(remove_line.get()), 95 scene_cutoff=cutoff_scale.get() 96 ) 97 for n, s in enumerate(scenes_list, 1): 98 s.save(os.path.join(folder_name, "out%07d.%s" % (n, "png"))) 99 100 update_splits() 101 102 def reload_folder(*aargs, **kwargs): 103 """Load scenes from a folder.""" 104 global scenes_list 105 106 print_status("Reading scenes from folder...") 107 scenes_list = [] 108 for f in sorted(os.listdir(folder_name)): 109 if not f.endswith('.' + "png"): 110 continue 111 scenes_list.append(Image.open(os.path.join(folder_name, f))) 112 113 update_splits() 114 115 scenes = [] 116 old_split = None 117 old_number = None 118 119 def print_status(s): 120 print(s) 121 status.set(s) 122 root.update() 123 124 def update_splits(): 125 global scenes, old_split, old_number 126 127 print_status("Splitting scenes...") 128 129 padding = int(padding_scale.get()) 130 split = int(split_scale.get()) 131 do_number = int(number.get()) 132 if split == old_split and old_number == do_number: 133 return 134 old_split = split 135 old_number = do_number 136 137 scenes = [] 138 139 if scenes_list: 140 width = max(s.width for s in scenes_list) 141 else: 142 width = 0 143 newbreaks = [] 144 for s in scenes_list: 145 new = [] 146 147 s = crop_scene(s, width) 148 149 todo = [s] 150 if split != -1: 151 todo = split_scene(s, split) 152 153 for t in todo: 154 new.append(pad_scene(t, padding)) 155 156 scenes.append(new) 157 158 if do_number: 159 number_images([i for j in scenes for i in j], 1, in_place=True) 160 161 update_break_widgets() 162 163 update_button["state"] = "active" 164 rescan_button["state"] = "active" 165 166 print_status("Done splitting!") 167 168 ## We are assembling two types of things: scenes and lines, their subdivisions. We 169 ## run into two situations when updating the available line breaks: 170 ## 171 ## 1. We reloaded the scenes 172 ## 2. We re-split the scenes into lines 173 ## 174 ## In case 2, in all likelihood most of the scenes will remain the same; thus, we 175 ## should preserve as many of the existing forced breaks as possible. In situation 176 ## 1, we just clear them all. 177 breaks = [] 178 break_widgets = [] 179 180 def update_break_widgets(clear=False): 181 global breaks, break_widgets 182 183 ## Remove old breaks 184 break_values = [] 185 for lines, lines_v in zip(break_widgets, breaks): 186 break_values.append([i.get() == 1 for i in lines_v]) 187 for w in lines: 188 w.grid_forget() 189 190 if clear: 191 break_values = [[-1]*len(i) for i in scenes] 192 193 if len(break_values) < len(scenes): 194 break_values = [[]] * len(scenes) 195 196 breaks = [] 197 break_widgets = [] 198 i = 0 199 for lines, old_values in zip(scenes, break_values): 200 breaks.append([]) 201 break_widgets.append([]) 202 old_values = old_values + [-1]*max(0, len(lines) - len(old_values)) 203 for l, ov in zip(lines, old_values): 204 v = IntVar() 205 v.set(ov) 206 a = Checkbutton(frame.inner, text="Line " + str(i+1), onvalue=1, offvalue=-1, variable=v) 207 a.grid(row=i+1, sticky=N+E+S+W) 208 frame.inner.rowconfigure(i+1, weight=1) 209 break_widgets[-1].append(a) 210 breaks[-1].append(v) 211 i += 1 212 213 old_split = -1 214 215 ## 2. Update output on user change 216 def update(*aargs, **kwargs): 217 global output_fname 218 219 aspect = aspect_scale.get() 220 savgol = savgol_scale.get() 221 do_otsu = finalize.get() 222 223 if output_fname is None: 224 output_fname = filedialog.asksaveasfilename(initialdir=folder_name) 225 226 ## Handles numbering and line split distance 227 update_splits() 228 229 ## We need to handle merging and any postprocessing 230 flat_scenes = [i for j in scenes for i in j] 231 forced_breaks = [n for n, v in enumerate((i for j in breaks for i in j)) if v.get() > 0] 232 233 print_status("Writing temporary scenes...") 234 235 with tempfile.TemporaryDirectory() as outdir: 236 outfiles = [] 237 fmt = "out%07d.png" 238 239 for n, done in enumerate(merge_lines(flat_scenes, aspect=aspect, center=False, center_breaks=False, breaks=forced_breaks), 1): 240 name = os.path.join(outdir, fmt % n) 241 outfiles.append(name) 242 if do_otsu: 243 done = otsu(done.resize((done.width*2, done.height*2), resample=Image.Resampling.LANCZOS)) 244 if savgol > 0: 245 done = do_savgol(done, savgol) 246 done.save(name) 247 248 print_status("Merging output...") 249 250 subp.check_call(["magick", "-density", "300", *outfiles, os.path.join(folder_name, output_fname)]) 251 252 print_status("Done!") 253 254 parser = ap.ArgumentParser() 255 parser.add_argument("-i", "--input", help="Input video to process") 256 parser.add_argument("-o", "--output", default=None, help="Output filename (defaults to <outdir>/out.pdf)") 257 parser.add_argument("-f", "--format", help="Output format", default="png") 258 parser.add_argument("-t", "--dir", help="Intermediate directory") 259 260 args = parser.parse_args() 261 262 input_file = args.input 263 mid_dir = args 264 output_file = args.output 265 266 root = Tk() 267 root.columnconfigure(0, weight=1) 268 269 video_frame = Frame(root) 270 video_frame.grid(row=0, sticky=N+E+S+W) 271 video_frame.columnconfigure(0, weight=1) 272 273 Label(video_frame, text="FFmpeg cutoff:").grid(row=0, sticky=N+E+S+W) 274 ffmpeg_cutoff_scale = Scale(video_frame, from_=0.01, to=0.1) 275 ffmpeg_cutoff_scale.grid(row=1, sticky=N+E+S+W) 276 ffmpeg_cutoff_scale.set(0.03) 277 278 Label(video_frame, text="Scene cutoff:").grid(row=2, sticky=N+E+S+W) 279 cutoff_scale = Scale(video_frame, from_=0.1, to=3) 280 cutoff_scale.grid(row=3, sticky=N+E+S+W) 281 cutoff_scale.set(2) 282 283 remove_line = IntVar() 284 remove_line.set(0) 285 Checkbutton(root, text="Remove line", variable=remove_line, onvalue=1, offvalue=0).grid(row=4, sticky=N+E+S+W) 286 287 video_fname_var = StringVar(video_frame) 288 video_fname_var.set("Select a video to load...") 289 if args.input: 290 video_fname_var.set(args.input) 291 video_fname = args.input 292 root.after(0, reload_video) 293 Label(video_frame, textvariable=video_fname_var).grid(row=5, sticky=N+E+S+W) 294 Button(video_frame, text="Choose Video", command=choose_video).grid(row=6, sticky=N+E+S+W) 295 296 folder_frame = Frame(root) 297 folder_frame.grid(row=1, sticky=N+E+S+W) 298 folder_frame.columnconfigure(0, weight=1) 299 300 folder_name_var = StringVar(video_frame) 301 folder_name_var.set("Select a folder to load...") 302 if args.dir: 303 folder_name_var.set(args.dir) 304 folder_name = args.dir 305 folder_name_set = True 306 if not args.input: 307 root.after(0, reload_folder) 308 Label(folder_frame, textvariable=folder_name_var).grid(row=0, sticky=N+E+S+W) 309 Button(folder_frame, text="Choose Folder", command=choose_folder).grid(row=1, sticky=N+E+S+W) 310 rescan_button = Button(folder_frame, text="Rescan", command=reload_folder) 311 rescan_button.grid(row=2, sticky=N+E+S+W) 312 rescan_button["state"] = "disabled" 313 314 output_fname = None 315 if args.output: 316 output_fname = args.output 317 318 rest_frame = Frame(root) 319 rest_frame.columnconfigure(0, weight=1) 320 rest_frame.grid(row=2, sticky=N+E+S+W) 321 322 Label(rest_frame, text="Padding:").grid(row=0, sticky=N+E+S+W) 323 padding_scale = Scale(rest_frame, from_=5, to=100) 324 padding_scale.grid(row=1, sticky=N+E+S+W) 325 padding_scale.set(10) 326 327 Label(rest_frame, text="Aspect:").grid(row=2, sticky=N+E+S+W) 328 aspect_scale = Scale(rest_frame, from_=0.5, to=0.9) 329 aspect_scale.grid(row=3, sticky=N+E+S+W) 330 aspect_scale.set(8.5/11) 331 332 Label(rest_frame, text="Split:").grid(row=4, sticky=N+E+S+W) 333 split_scale = Scale(rest_frame, from_=1, to=100) 334 split_scale.grid(row=5, sticky=N+E+S+W) 335 split_scale.set(50) 336 337 Label(rest_frame, text="Savgol:").grid(row=6, sticky=N+E+S+W) 338 savgol_scale = Scale(rest_frame, from_=0, to=1) 339 savgol_scale.grid(row=7, sticky=N+E+S+W) 340 savgol_scale.set(0.7) 341 try: 342 import scipy 343 except ModuleNotFoundError: 344 savgol_scale.set(0) 345 savgol_scale["state"] = "disabled" 346 347 number = IntVar() 348 number.set(0) 349 Checkbutton(rest_frame, text="Number lines", variable=number, onvalue=1, offvalue=0).grid(row=8, sticky=N+E+S+W) 350 351 finalize = IntVar() 352 finalize.set(0) 353 Checkbutton(rest_frame, text="Finalize", variable=finalize, onvalue=1, offvalue=0).grid(row=9, sticky=N+E+S+W) 354 355 frame = ScrollableFrame(root) 356 frame.grid(row=3, sticky=N+E+S+W) 357 frame.inner.columnconfigure(0, weight=1) 358 root.rowconfigure(3, weight=1) 359 360 Label(frame.inner, text="Force page break after:").grid(row=0, sticky=N+E+S+W) 361 362 update_button = Button(root, text="Update", command=update, state="disabled") 363 update_button.grid(row=4, sticky=N+E+S+W) 364 365 status = StringVar(root) 366 status.set("Ready!") 367 368 Label(root, textvariable=status).grid(row=5, sticky=N+E+S+W) 369 370 root.wm_title("Unscore") 371 root.mainloop()