Na dzisiejszych zajęciach zajmiemy się projektowaniem prostego interfejsu graficznego wykorzystującego Tkinter oraz OpenCV. Zacznijmy od zadeklarowania potrzebnych nam bibliotek:

from tkinter import *
from tkinter import ttk, filedialog
import numpy as np
import io
import cv2
import matplotlib
from PIL import Image
from PIL import ImageTk
matplotlib.use("TkAgg")
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg, NavigationToolbar2Tk
from matplotlib.figure import Figure

W ramach przypomnienia podstaw Pythona - Struktura kodu powinna najlepiej wyglądać w ten sposób, aby zachować czytelność kodu:

  • importy
  • deklaracje klas i funkcji
  • ewentualny pozostały kod

Dlatego jeżeli plik zawiera poza funkcjami wykonywalny fragment np. if __name__ == "__main__": lub normalny skrypt to powinien on znajdować się na końcu pliku, za wszystkimi zadeklarowanymi funkcjami.

UWAGA POCZĄTKOWA 1 Wszystkie rozmiary jak również lokalizacje w strukturze siatki (grid), są przykładowe jeżeli nie mieszczą się wam one na monitorze lub nie pasuje wam sam układ można śmiało to modyfikować. Większość kodu stara się być niezależna od ich samego położenia.

UWAGA POCZĄTKOWA 2 We wstawianych fragmentach kodu pojawiają się sekcje:

   def nazwa():
       ############################

Oznaczają one, że rozwijamy funkcję nazwa i wprowadzamy w niej modyfikacje. Zwykle polega to na dopisywaniu podanych linii na końcu funkcji, ale nie zawsze tak jest. Czasami modyfikowany będzie fragment w jej centrum. Domyślnie jeżeli nic nie jest napisane to w dalszym ciągu edytujemy poprzednią funkcję.

UWAGA POCZĄTKOWA 3 Wszystkie funkcje poza ostatnią main są częścią tworzonej klasy.

Początkowa struktura

Pracę na nad naszym interfejsem zacznijmy od zadeklarowania klasy będącej kontenerem naszego interfejsu. Znajdziemy w niej dwie zmienne do przechowywania obrazów IMG oraz TempIMG oraz funkcję tworzenia interfejsu (aktualnie zawiera tylko wywołanie funkcji print umożliwiające uruchomienie jej). Na końcu znajduje funkcja wywołująca nasz interfejs. Po wywołaniu poniższego kodu powinno wyświetlić się niewielkie okienko niezawierające niczego.

class Widget(Frame):
    IMG = None
    TempIMG = None
    def __init__(self,parent=None):
        Frame.__init__(self,parent)
        self.parent = parent
        self.pack()
        self.make_widgets()

    def make_widgets(self):
        print('TO DO')

if __name__ == "__main__":
    root = Tk()
    something= Widget(root)
    root.mainloop()

Dodajmy trochę treści do naszego interfejsu. Zmieńmy tytuł naszego okna i ustalmy jakiś wstępny rozmiar:

    def make_widgets(self):
        self.winfo_toplevel().title("GUI")
        self.winfo_toplevel().geometry("1200x600")

Następnie utwórzmy obszar w którym będziemy wyświetlać wczytane przez nas zdjęcie oraz kontener Canvas który wykorzystamy do wyświetlania oraz zakotwiczmy w nim miejsce na zdjęcie. Funkcja .grid pozwala nam osadzić obiekt w domyślnej siatce (można modyfikować jej gęstość), jak również ustalić jak dużą jej część ma zajmować.

    def make_widgets(self):
        ############################
        self.LeftFrame=LabelFrame(self,text="Oryginal image",height=500,width=300)
        self.LeftFrame.grid(row=0,column=0,columnspan=5,rowspan=5)
        
        self.ImagePanel=Canvas(self.LeftFrame,height=500,width=300)
        self.ImagePanel.pack(expand=YES, fill=BOTH)
        self.ImageOnPanel=self.ImagePanel.create_image(0,0 ,anchor=NW)

Teraz musimy utworzyć zestaw dwóch funkcji dla naszej klasy. Pierwsza z funkcji ma za zadanie wywołać okno dialogowe służące do wczytania pliku graficznego:

    def select_image(self):
        filename =  filedialog.askopenfilename(initialdir = "/",title = "Select file",filetypes = (
            ("jpeg files","*.jpg"),
            ("png files","*.png"),
            ("all files","*.*")
        ))
        if len(filename) > 0:
            print(filename)
            tmp = cv2.imread(filename)
            self.IMG = IMG = cv2.cvtColor(tmp, cv2.COLOR_BGR2RGB)
        self.show_pic()

Druga natomiast ma za zadanie wyświetlić nasze zdjęcie wewnątrz interfejsu, jeżeli już takie wczytamy. Wykorzystamy do tego dwie funkcje przekształcające obraz najpierw do formatu Image z biblioteki PIL a następnie ten obraz do formatu ImageTk, który możemy już wyświetlić. Zapisujemy jego kopię wewnątrz struktury, aby uniknąć problemów z automatycznym czyszczeniem zmiennych (garbage collector).

    def show_pic(self):
        if self.IMG.size >0:
            img = Image.fromarray(self.IMG) 
            self.ImagePanel.ImgCatch = ImageTk.PhotoImage(img)
            self.ImagePanel.itemconfigure(self.ImageOnPanel, image=self.ImagePanel.ImgCatch)

Teraz dodajmy przycisk, za pomocą którego dodamy obraz do programu.

    def make_widgets(self):
        ############################
        self.Load=Button(self, text="Select an image", command=self.select_image)
        self.Load.grid(row=6,column=0)

Na tym etapie powinniśmy mieć prosty interfejs, który otwiera i wyświetla plik graficzny po naciśnięciu przycisku.

Skalowanie obrazów przed wyświetleniem

Gdy wczytany przez nas obraz będzie za duży w naszym oknie zostanie wyświetlona tylko jego część. Można temu zapobiec przeskalowując go przed wyświetleniem. Można to zrobić wprost zmieniając obraz tak aby wypełnił cały obszar wyświetlania lub tak my planujemy to zrobić przeskalować zachowując oryginalny stosunek wzajemny długości obu boków obrazu (aspect ratio). Zacznijmy rozwijanie naszej funkcji wyświetlania od pobrania informacji na temat dostępnego nam obszaru wyświetlania:

    def show_pic(self):
        if self.IMG.size >0:
            img = Image.fromarray(self.IMG)             
            h=self.ImagePanel.winfo_height()
            w=self.ImagePanel.winfo_width()

W zmiennych w oraz h będziemy przechowywać rozmiar naszego pola wyświetlania. Teraz musimy sprawdzić jaka część naszego obrazu będzie się wewnątrz naszego obszaru wyświetlania. Informacje na temat rozmiaru naszego obrazu będziemy brać bezpośrednio ze zmiennej self.IMG:

            h_ratio= h/self.IMG.shape[0]
            w_ratio= w/self.IMG.shape[1]

Jeżeli obraz mieści nam się w całości w jakimś z tych dwóch wymiarów wartość ratio dla niego będzie >1. Natomiast jeżeli zmieści się tylko jego fragment będzie zawierał on wartość <1. Jeżeli jeden lub więcej współczynników jest mniejszy niż 1, to musimy podjąć decyzję, który z wymiarów należy bardziej zmniejszyć. Będzie miał on mniejszy współczynnik. Drugi wymiar otrzyma maksymalny możliwy dostępny rozmiar, czyli wielkość naszego obszaru rysowania. Rozmiar mniejszego możemy wyliczyć przy użyciu proporcji. Do samego przeskalowania będziemy wykorzystywać funkcję .resize będącą częścią pakietu PIL. Gotowy fragment odpowiedzialny za skalowanie obrazu wyświetlania powinien wyglądać tak:

            if (h_ratio<1.0) | (w_ratio<1.0):
                if h_ratio<w_ratio:
                    ratio=h_ratio*w/w_ratio
                    img = img.resize((round(ratio),round(h)), Image.ANTIALIAS)
                else:
                    ratio=w_ratio*h/h_ratio
                    img = img.resize((round(w),round(ratio)), Image.ANTIALIAS)

Filtracja obrazu

Dodajmy kolejne elementy do naszego interfejsu tym razem będzie to funkcja, która w kolejnym panelu wyświetli nam obraz poddany działaniu filtru krawędziowego. Zacznijmy od stworzenia dodatkowego obszaru wyświetlania w tej samej formie jak w przypadku oryginalnego obrazu.

    def make_widgets(self):
        ############################
        self.RightFrame=LabelFrame(self,text="Modified image",height=500,width=300)
        self.RightFrame.grid(row=0,column=5,columnspan=5,rowspan=5)
        self.ImagePanel2= Canvas(self.RightFrame,height=500,width=300)
        self.ImagePanel2.pack(expand=YES, fill=BOTH)
        self.ImageOnPanel2=self.ImagePanel2.create_image(0,0 ,anchor=NW)

W przygotowaniu do późniejszych zadań dodajmy również listę rozwijaną pozwalającą na wybór przekształcenia:

    def make_widgets(self):
        ############################
        self.Choose = ttk.Combobox(self,values=["Edges"])
        self.Choose.current(0)
        self.Choose.grid(row=6,column=1,rowspan=3)

Teraz napiszmy, krótką funkcję realizującą nasz filtr krawędziowy, wykorzystując OpenCV, a na końcu wywoła nasze wyświetlanie.

    def confirm(self):
        if(self.Choose.current() == 0):
            self.TempIMG = cv2.Canny(self.IMG, 50, 100)

        self.show_pic()

I na koniec dołączmy do naszej przycisk, który wywoła naszą funkcję.

    def make_widgets(self):
        ############################
        self.Confirm = Button(self, text="Confirm", command=self.confirm)
        self.Confirm.grid(row=6,column=4)

Ostatnią rzeczą jaką musimy dopisać jest dodanie fragmentu do funkcji wyświetlania. Funkcję należy jednak uzupełnić o fragment odpowiadający za skalowanie obrazu.

    def show_pic(self):
        ############################
        if self.TempIMG in not None and self.TempIMG.size >0:
            img = Image.fromarray(self.TempIMG) 
            ##### <- tu dopisać skalowanie
            self.ImagePanel2.ImgCatch = ImageTk.PhotoImage(img)
            self.ImagePanel2.itemconfigure(self.ImageOnPanel2, image=self.ImagePanel2.ImgCatch)

Wyświetlanie grafów i wykresów

Rozwińmy nasz interfejs o fragment pozwalający wyświetlać nam wykresy. W tym przypadku wyświetlający histogram dla poszczególnych składowych kolorów. Zacznijmy od dodania kolejnego obszaru, w który będzie nam służył do wyświetlania. Dodajemy w nim strukturę Figure i montujemy w niej jeden subplot, w której będziemy rysować.

    def make_widgets(self):
        ############################
        self.PlotFrame=LabelFrame(self,text="Plot",height=500,width=300)
        self.PlotFrame.grid(row=0,column=10,columnspan=5,rowspan=5)
        self.Fig = Figure()
        self.Plot = self.Fig.add_subplot(1,1,1)
        
        self.canvas = FigureCanvasTkAgg(self.Fig, self.PlotFrame) 
        self.canvas.draw()
        self.canvas.get_tk_widget().pack()

Następnym krokiem jest stworzenie funkcji, która wyliczy nam i wyrysuje nam histogram. Również do tego wykorzystamy funkcję z pakietu OpenCV. Musimy pamiętać również żeby na początku wyczyścić wykres (funkcja .cla()). Histogram liczymy i rysujemy histogram dla każdego koloru osobno. Na końcu dodajemy wywołanie funkcji rysującej.

    def calc_hist(self):
        if self.IMG.size >0:
            self.Plot.cla()
            color = ('r','g','b')
            for i,col in enumerate(color):
                histr = cv2.calcHist([self.IMG],[i],None,[256],[0,256])
                self.Plot.plot(histr,color = col)
        self.show_pic()

Jak mamy już gotowe wywołanie możemy podpiąć je do nowo utworzonego przycisku.

    def make_widgets(self):
        ############################
        self.Hist = Button(self, text="Calculate Histogram", command=self.calc_hist)
        self.Hist.grid(row=6,column=11)  

Ostatnim krokiem jest dodanie ponownego rysowania wykresu do odświeżającej.

        def show_pic(self):
        ############################
            self.canvas.draw()

Binaryzacja z parametrem

Ostatnim krokiem ćwiczenia jest dodanie funkcji wykorzystującej do działania parametry. Wykorzystamy w tym celu funkcję realizującą binaryzację z progiem. Dzięki dobremu przemyśleniu konstrukcji naszego interfejsu dodanie nowej funkcjonalności wymaga wykonania niewielkiej ilości modyfikacji. Pierwszym krokiem będzie dodanie poziomego suwaka, który będzie dostarczał nam wartość parametru binaryzacji i ustawmy jednego domyślną wartość na 128.

    def make_widgets(self):
        ############################
        self.Slider = Scale(self, from_=0, to=255, orient=HORIZONTAL)
        self.Slider.set(128)
        self.Slider.grid(row=6,column=5)

Następnie dodajmy możliwość wyboru binaryzacji jako opcji w naszym menu poprzez modyfikację jego definicji.

    def make_widgets(self):
        ############# NIE DOKLEJAĆ MODYFIKOWAĆ
        self.Choose = ttk.Combobox(self,values=["Edges","Binary Threshold"])

Ostatnim krokiem jest dodanie obsłużenie nowo dodanej funkcjonalności. Zrobimy to poprzez modyfikację confirm poprzez dodanie wywołania odpowiedniej OpenCV. Funkcja cv2.threshold przyjmuje jako parametr obraz w skali odcieni szarości (obraz kolorowy również można wykorzystać, ale efekt będzie nakładany za każdą warstwę osobo) oraz próg. Do szybkiej konwersji naszego obrazu oryginalnego do skali odcieni szarości również wykorzystamy funkcję z OpenCV, a parametr progu pobierzemy bezpośrednio z suwaka. Gotowa modyfikacja powinna wyglądać tak:

    def confirm(self):
        ############################
        if(self.Choose.current() == 1):  
            res,ret=cv2.threshold(
                cv2.cvtColor(self.IMG, cv2.COLOR_RGB2GRAY),
                self.Slider.get(),255,cv2.THRESH_BINARY)
            self.TempIMG=ret

Zadania

Oprócz już pokazanych już w instrukcji funkcjonalności:

  • filtracja krawędziowa
  • binaryzacja z progiem
  • histogram

Dodać kolejne funkcjonalności z OpenCV przykładowo (o wywołaniach przeczytać w dokumentacji do OpenCV):

  • inne rodzaje binaryzacji
  • wyświetlanie obrazu w skali odcieni szarości
  • binaryzacja z adaptacyjnym progiem
  • różne rozmycia (blur)

Wszystkie rodzaje funkcji jeżeli wymagają parametrów powinny czerpać je z GUI (np. z suwaka).