From d39a4f9000d447df3f8fe6a7ad42100777305abe Mon Sep 17 00:00:00 2001 From: cwerner <13906519+cwerner@users.noreply.github.com> Date: Sun, 26 Apr 2020 01:11:16 +0200 Subject: [PATCH] Improve fastclass fcc (#30) * small refactor in args section * fix missing bracket * rename tk ui section root to parent for better readbility * cleanup fc_clean - move file suffixes * refactor some verbose lines to be more concise * minor refactor of class digits * some more refactoring * change buttons in fcc to use ttk to restore button text in macos mojave * small refactor to revert bad black formatting * change os.path to pathlib and minor refactor in fcc * A BIG rewrite of fc_clean. Apologies to anyone reading through the original code. Much cleaner and shorter design, also a nice GUI upgrade --- fastclass/fc_clean.py | 286 ++++++++++++++++++++++-------------------- 1 file changed, 152 insertions(+), 134 deletions(-) diff --git a/fastclass/fc_clean.py b/fastclass/fc_clean.py index f7fb80d..69c66df 100755 --- a/fastclass/fc_clean.py +++ b/fastclass/fc_clean.py @@ -5,11 +5,14 @@ # Christian Werner, 2018-10-23 import click -import glob -import itertools +from collections import deque +from functools import partial +import itertools as it import os +from pathlib import Path from PIL import ImageTk, Image import tkinter as tk +from tkinter import ttk import shutil from .imageprocessing import image_pad @@ -31,197 +34,211 @@ The counter in the titlebar gives number of classified images\r vs the total number in the input folder.\r -In the output csv file 1,2 depcit class assignments/ ratings, +In the output csv file 1,2 indicate class assignments/ ratings, -1 indicates files marked for deletion (if not excluded with -d).""" +# supported suffixes +suffixes = ["jpg", "jpeg", "png", "tif", "tiff"] +suffixes += [x.upper() for x in suffixes] + +digits = "123456789" + + +class Item(object): + def __init__(self, image_path, size): + self.image_path = image_path + self.label = None + self.size = size + + def __repr__(self): + return f"Item <{self.image_path} [{self.label if self.label else None}]>" + + def show(self): + return ImageTk.PhotoImage(image_pad(self.image_path, self.size)) + + +class ItemList(object): + def __init__(self, items=[], size=(299, 299)): + self._data = deque([Item(i, size) for i in items]) + self._initial = True + + def __iter__(self): + return iter(self._data) + + def __len__(self): + return len(self._data) + + def __repr__(self): + return ", ".join([str(x) for x in self._data]) + + def forward(self): + self._data.rotate(-1) + + def backward(self): + self._data.rotate(1) + + @property + def current(self): + if len(self._data) > 0: + return self._data[0] + + @property + def labels(self): + return [x.label for x in self._data] + class AppTk(tk.Frame): - def __init__(self, *args, **kwargs): + def __init__(self, parent, **kwargs): - INFOLDER = kwargs["infolder"] + INFOLDER = Path(kwargs["infolder"]) OUTFOLDER = kwargs["outfolder"] - if OUTFOLDER: - pass + if OUTFOLDER is None: + OUTFOLDER = INFOLDER.parent / (INFOLDER.name + ".clean") + OUTFOLDER.mkdir(exist_ok=True) else: - OUTFOLDER = INFOLDER + ".clean" - os.makedirs(OUTFOLDER, exist_ok=True) + OUTFOLDER = Path(OUTFOLDER) NOCOPY = kwargs["nocopy"] - for e in ["infolder", "outfolder", "nocopy"]: - kwargs.pop(e) + # remove these kwargs before passing them into tk frame + [kwargs.pop(e) for e in ["infolder", "outfolder", "nocopy"]] - tk.Frame.__init__(self, *args, **kwargs) + tk.Frame.__init__(self, parent, **kwargs) + self.parent = parent # bind keys - args[0].bind("", self.callback) - self.root = args[0] - - # store classification - self._class = {f"c{c}": set() for c in range(1, 10)} - self._delete = set() - - # config settings - suffixes = ["jpg", "jpeg", "png", "tif", "tiff"] - suffixes += [x.upper() for x in suffixes] - files = list( - itertools.chain(*[glob.glob(f"{INFOLDER}/*.{x}") for x in suffixes]) - ) + self.parent.bind("", self.callback) + + files = list(it.chain(*[INFOLDER.glob(f"*.{x}") for x in suffixes])) self.filelist = sorted(set(files)) if len(self.filelist) == 0: print("No files in infolder.") exit(-1) + self.images = ItemList(items=sorted(set(files)), size=(299, 299)) + self.outfolder = OUTFOLDER self.infolder = INFOLDER self.nocopy = NOCOPY - self._classified = 0 - self._index = -1 - - self.size = (299, 299) - # basic setup self.setup() # raise window to top - self.root.lift() - self.root.attributes("-topmost", True) + self.parent.lift() + self.parent.attributes("-topmost", True) # show first image - self.display_next() + self.display() + + @property + def cur_file(self): + return self.images.current @property - def total(self): - return len(self.filelist) + def no_classified(self): + return sum(1 for x in self.images.labels if x is not None) @property - def classified(self): - cnt = 0 - for c in "123456789": - if self.filelist[self._index] in self._class[f"c{c}"]: - cnt += len(self._class[f"c{c}"]) - if self.filelist[self._index] in self._delete: - cnt += len(self._delete) - return cnt + def no_total(self): + return len(self.images) @property def title(self): def get_class(): - for c in "123456789": - if self.filelist[self._index] in self._class[f"c{c}"]: - return f"[ {c} ] " - if self.filelist[self._index] in self._delete: - return "[ X ] " - return "[ ] " - - return ( - os.path.basename(self.filelist[self._index]) - + " - " - + get_class() - + f" ({self.classified}/{self.total})" + label = self.images.current.label + if label is None: + label = " " + return f"[ {label} ] " + + stats = f"{self.no_classified}/{self.no_total}" + label = ( + f"FastClass :: {self.cur_file.image_path.name} - {get_class()} ({stats})" ) + return label def print_titlebar(self): - self.root.title(self.title) + self.parent.title(self.title) + + def button_callback(self, button): + self.images.current.label = button + self.display_next() def callback(self, event=None): def button_action(char): - self._class[f"c{char}"].add(self.filelist[self._index]) + self.images.current.label = char self.display_next() - if event.keysym in "123456789": - button_action(event.keysym) - elif event.keysym == "space": #'': + e = event.keysym + if e in digits + "d": + button_action(e) + elif e == "space": #'': button_action("1") - elif event.keysym == "d": - self._delete.add(self.filelist[self._index]) - self.display_next() - elif event.keysym == "Left": #'': + elif e == "Left": #'': self.display_prev() - elif event.keysym == "Right": #'': + elif e == "Right": #'': self.display_next() - elif event.keysym == "x": - - # write report file - rows_all = [] - rows_clean = [] - for f in self.filelist: - row = (f, "?") - for c in "123456789": - if f in self._class[f"c{c}"]: - row = (f, c) - if f in self._delete: - row = (f, "D") - else: - rows_clean.append(row) - rows_all.append(row) - - with open( - os.path.join(self.infolder.replace(" ", "_") + "_report_all.csv"), "w" - ) as f: - f.write("file;rank\n") - for row in rows_all: - f.write(";".join(row) + "\n") + elif e == "x": + self.save_and_exit() + else: + pass - with open( - os.path.join(self.infolder.replace(" ", "_") + "_report_clean.csv"), "w" - ) as f: + def save_and_exit(self): + # write report file + rows_all, rows_clean = [], [] + for f in self.images: + row = (f.image_path, f.label if f.label else "?") + + if f.label is not "d": + rows_clean.append(row) + rows_all.append(row) + + for ftype, rows in zip(["all", "clean"], [rows_all, rows_clean]): + foutname = Path( + str(self.infolder).replace(" ", "_") + f"_report_{ftype}.csv" + ) + with open(foutname, "w") as f: f.write("file;rank\n") - for row in rows_clean: - f.write(";".join(row) + "\n") + for row in sorted(rows, key=lambda x: x[0]): + f.write(";".join([str(x) for x in row]) + "\n") - if not self.nocopy: - for r in rows_clean: - shutil.copy(r[0], self.outfolder) + if not self.nocopy: + for r in rows_clean: + shutil.copy(r[0], self.outfolder) - self.root.destroy() - else: - pass + self.parent.destroy() def setup(self): - self.Label = tk.Label(self) - self.Label.grid(row=0, column=0, columnspan=6, rowspan=6) # , sticky=tk.N+tk.S) - self.Button = tk.Button(self, text="Prev", command=self.display_prev) - self.Button.grid(row=5, column=7, sticky=tk.S) - self.Button = tk.Button(self, text="Next", command=self.display_next) - self.Button.grid(row=5, column=8, sticky=tk.S) + self.Canvas = tk.Label(self) + self.Canvas.grid(row=0, column=0, columnspan=6, rowspan=6) + ttk.Button(self, text="Prev", command=self.display_prev).grid(row=4, column=6) + ttk.Button(self, text="Next", command=self.display_next).grid(row=4, column=7) + ttk.Button(self, text="Save & Exit", command=self.save_and_exit).grid( + row=5, column=6, columnspan=2 + ) - def display_next(self): + self.lfdata = ttk.Labelframe(self, padding=(2, 2, 4, 4), text="Selection") + self.lfdata.grid(row=0, column=6, columnspan=2, sticky="ne") + for i, item in enumerate(digits + "d"): + ttk.Button( + self.lfdata, text=item, command=partial(self.button_callback, item) + ).grid(in_=self.lfdata, column=6 + i % 2, row=i // 2, sticky="w") + + def display(self): + photoimage = self.images.current.show() + self.Canvas.config(image=photoimage) + self.Canvas.image = photoimage self.print_titlebar() - self._index += 1 - try: - f = self.filelist[self._index] - except IndexError: - self._index = -1 # go back to the beginning of the list. - self.display_next() - return - padded_im = image_pad(f, self.size) - - photoimage = ImageTk.PhotoImage(padded_im) - self.Label.config(image=photoimage) - self.Label.image = photoimage - self.print_titlebar() + def display_next(self): + self.images.forward() + self.display() def display_prev(self): - - self._index -= 1 - try: - f = self.filelist[self._index] - except IndexError: - self._index = -1 # go back to the beginning of the list. - self.display_next() - return - - padded_im = image_pad(f, self.size) - - photoimage = ImageTk.PhotoImage(padded_im) - self.Label.config(image=photoimage) - self.Label.image = photoimage - self.print_titlebar() + self.images.backward() + self.display() def main(INFOLDER, OUTFOLDER, nocopy): @@ -230,7 +247,8 @@ def main(INFOLDER, OUTFOLDER, nocopy): app = AppTk(root, infolder=INFOLDER, outfolder=OUTFOLDER, nocopy=nocopy) - app.grid(row=0, column=0) + app.grid(row=0, column=0, columnspan=8, rowspan=6) + app.configure(background="gray90") # start event loop root.lift()