unscore - Script for converting score videos into playable PDFs.

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

About | Log | Files | Refs

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()