4 設計一個圖像標注工具

學習設計圖形用戶界面(簡稱 GUI)也許是一件令人苦惱的事兒,各種 GUI 專屬名稱,各種設計元素等讓人眼花繚亂。為了讓 GUI 設計不再是一件令人痛苦的事,PySimpleGUI 提供了一個十分 Pythonic 且學習周期短,易于擴展的接口。

1 PySimpleGUI 簡介

PySimpleGUI(https://github.com/PySimpleGUI/PySimpleGUI)倉庫對 tkinter, Qt, Remi, WxPython 進行封裝,使得 GUI 開發更加人性化。下面僅僅討論 pip install PySimpleGUI 獲得的基于 tkinter 的模塊。 本文討論的 PySimpleGUI 是 '4.14.0 Released 23-Dec-2019' 版本的模塊,它有兩個十分重要的基礎類:ElementWindowElement 構成了 PySimpleGUI 設計的 GUI 界面的基本元素,常用的子類有:ButtonButtonMenuCanvasGraphFrameTabTabGroupColumnPaneCheckboxRadioComboImageInputTextListboxMenuMultilineOutputText。它們代表了 GUI 的基本組件(或者稱其為小部件)。Window 則創建了 GUI 的窗口界面。

下面直接以例子來講解這些的使用方法。

2 從一個簡單的例子開始

現以一個簡單的例子作為 GUI 開發的引入:

import PySimpleGUI as sg
# 改變 Window 的主題
sg.theme('DarkAmber')
# 定義 Window 的布局
layout = [
    [sg.Text('第一行:寫一些說明性的文字')],
    [sg.Text('第二行:寫入說明性文字'), sg.InputText()],
    [sg.Button('確認'), sg.Button('取消')]
]

# 創建一個 Window
window = sg.Window('Window 的標題', layout)

# 獲取 Window 的“事件”以及“取值”的循環
while 1:
    event, values = window.read()
    # 對 event 進行邏輯選擇
    if event in (None, '取消'):
        break
    else:
        print('您鍵入的值是', values[0])
window.close() # 關閉 Window

代碼雖然很短,但也基本交代清楚了 GUI 設計的思路:

  1. sg.theme('DarkAmber') 設置 Window 的主題;(可選的)
  2. 定義 Window 的布局 layout
  3. layout 傳入 sg.Window 用以創建 Window;
  4. 在一個循環里通過 window.read() 獲取Window 的“事件”以及“取值”;
  5. event(有時也會用到 values)進行邏輯選擇;
  6. 防止資源泄露,最后需要 window.close() 關閉 Window。

我們看看最終該代碼生成了什么樣的 Window?效果圖見圖1:

圖1 一個 GUI 的例子

從圖1 可以看出:

  1. 元素(或稱小部件)sg.Text 用于在 Window 上打印文字;
  2. 元素 sg.Button 組成了 Window 的“按鈕”,當您點擊按鈕會觸發一些事件;
  3. 元素 sg.InputText()(可以簡寫為 sg.Input())用于記錄用戶使用鍵盤輸入的信息,并以 dict 的形式保存在 values 之中。即 values 的值為 {'0': 用戶輸入的信息},這里的 '0' 是 Window 中的類似于 sg.InputText() 的元素的返回值的序號。

再來看看 layout,它是由 [[...],...] 這樣的二維列表數據進行 Window 的布局設計的。具體而言,即 [[a], [b]] 表示了兩行的 Window 布局,第一行由元素 a 構建,第二行由元素 b 進行構建。

可以看此 layout 是 Window 的核心,它定義了 Window 的布局,所以,接下來的內容我們主要關注如何創建 layout

3 Window 的布局設計

前文介紹了 sg.Window 的元素 sg.Textsg.Buttonsg.InputText() 實現了 Window 的顯示和事件觸發機制。但是這些功能太單一了,接下來需要了解如何創建更加復雜的 Window 布局。

3.1 同步更新 Window 的信息

例2:您也許會有這樣一種需求:通過按鈕實現同步更新 Window 的信息的功能。該功能的實現需要借助 sg.Windowupdate 實例方法進行實現,在例1 中我們使用 print 函數打印 values 的值,但是其值并沒有顯示在 Window 之中,為了讓其值在 Window 中顯示,需要修改例1 為:

import PySimpleGUI as sg
# 改變 Window 的主題
sg.theme('DarkAmber')
# 定義 output 的輸出文本的樣式
output_text = {
    'key' : 'output', # 文本 Key
    'size' : (25, 1), # 文本占用字符框size為 25x1
    'text_color' : 'white', # 文本顏色
    'background_color' : 'red', # 背景顏色
    'font' : ('Times', 16) # 設置字體 family 與 size
}
# 創建 Window 的布局
layout = [
    [sg.Text('第一行:寫一些說明性的文字')],
    [sg.Text('第二行:寫入說明性文字'), sg.InputText()],
    [sg.Text('您鍵入的值是:'), sg.Text(**output_text)],
    [sg.Button('確認'), sg.Button('取消')]
]
# 創建一個 Window
window = sg.Window('Window 的標題', layout)
# 獲取 Window 的“事件”以及“取值”的循環
while 1:
    event, values = window.read()
    if event in (None, '取消'):
        break
    else:
        # 更新 window['output'] 的值
        window['output'].update(values[0])
    print(event, values)
window.close() # 關閉 Window

顯示的結果見圖2:

圖2 同步更新 Window 的信息

從圖2 可以看到 sg.Text 的主題風格是可以修改的,output_text 設置了文本框的一些屬性,其中 'size','text_color','background_color' 用于指定文本框的大小,顏色以及背景顏色;'font' 則指定了文字的字體與字號大小。指定 sg.Text 的 'key' 方便 sg.Window 查找并修改其值。

這里的 Window 只有一個 sg.InputText() 元素,所以使用 values[0] 即可獲取其值,但是,如果有多個 sg.InputText() 元素,直接使用序號獲取其值便不是很方便,為此,您需要為 sg.InputText() 傳入 'key' 參數,讓 sg.Window 可以通過 'key' 來獲取其值。

關于 event, values = window.read(),其中 eventWundow 包括:1) 點擊 Button;2) 使用 X 關閉 Window。一般地, values 收集的是 Wundowsg.InputText() 元素。

3.2 設置 Window 的菜單

例3:Window 的菜單是大多數 GUI 的必選元素,下面就以創建備忘錄為例來說明如何創建菜單欄:

import PySimpleGUI as sg
# 預設
sg.change_look_and_feel('DarkTeal7')
# 定義菜單選項
menu_def = [['文件', ['載入', '保存']], ['關閉', ['確認', '取消']]]
layout = [[sg.Menu(menu_def)],  # 定義菜單欄
          [sg.Text('To Do List', font='Helvetica 15',
                  relief=sg.RELIEF_GROOVE)]] # 定義文本框的風格樣式
layout += [[sg.Text(k),
            sg.Checkbox('', default=True if k == 1 else False),
            sg.Input()] for k in range(4)]
window = sg.Window("",layout,
                   grab_anywhere=True, # 非阻塞
                   no_titlebar=True  # 移除標題欄
                  )
# 事件循環
while 1:
    event, values = window.read()
    print(event, values)
    if event in (None, '確認'):
        break
    elif event == '保存':
        window.save_to_disk('ToDoList.out')
    elif event == '載入':
        window.load_from_disk('ToDoList.out')
window.close()

使用 sg.Menu(menu_def) 創建了菜單欄,在 sg.Text 中參數 relief 定義了文本框的風格樣式。sg.Checkbox(可簡寫為 sg.CBox)是用來創建復選框的 Window 元素,如果其參數default賦值為 True,則該復選框是被選中的,即打勾。具體的效果見圖3:

圖3 一個創建菜單欄的例子

當您填寫好表單后,點擊菜單欄的'文件'按鈕下的'保存'選項,則會利用 window.save_to_disk('ToDoList.out') 將這個 Window 的配置保存到本地磁盤,效果見圖4:

圖4 保存 Window 到本地磁盤

接著,您點擊菜單欄的'關閉'按鈕下的確認選項,則會關閉 Window,效果見圖5:

圖5 從菜單欄關閉 Window

當您再次打開 Window 時,點擊菜單欄的'文件'按鈕下的'載入'選項,則會利用 window.load_to_disk('ToDoList.out') 將這個 Window 的配置從本地磁盤重新載入。

有時,需要為菜單欄的選項設置快捷鍵,您可以修改 menu_def 為:

menu_def = [['文件(&F)', ['載入(&L)', '保存(&S)']], ['關閉(&C)', ['確認(&Y)', '!取消(&N)']]]

即通過在字母前添加 & 來設置快捷鍵為 Alt + 對應的字母。而在字符串最前方添加 ! 將設定該選項為不可選。具體的效果圖見圖6:

圖6 設置菜單欄的快捷鍵

有時,需要使用鼠標右鍵的菜單,您需要修改代碼為:

import PySimpleGUI as sg
# 預設
sg.change_look_and_feel('DarkTeal7')
# 定義菜單選項
menu_def = [['文件(&F)', ['載入(&L)', '保存(&S)']], ['關閉(&C)', ['確認(&Y)', '!取消(&N)']]]
right_click_menu = menu_def[0]
layout = [[sg.Menu(menu_def)],
          [sg.Text('To Do List', font='Helvetica 15',
                  relief=sg.RELIEF_GROOVE)]]
layout += [[sg.Text(k),
            sg.Checkbox('', default=True if k == 1 else False),
            sg.Input()] for k in range(4)]
window = sg.Window("",layout,
                   grab_anywhere=True, # 非阻塞
                   no_titlebar=True,  # 移除標題欄
                   right_click_menu = right_click_menu # 添加右鍵菜單
                  )
# 事件循環
while 1:
    event, values = window.read()
    print(event, values)
    if event in (None, '確認(Y)'):
        break
    elif event == '保存(S)':
        window.save_to_disk('ToDoList.out')
    elif event == '載入(L)':
        window.load_from_disk('ToDoList.out')
window.close()

這樣,只要您在 sg.Text 或者 sg.Input 生成的 Window 元素所在的位置單擊鼠標右鍵便會彈出一個右鍵菜單,具體的效果見圖7 和圖8:

圖7 Text 的右鍵菜單
圖8 InputText 的右鍵菜單

有時還需要按鈕菜單:

import PySimpleGUI as sg
# 預設
sg.change_look_and_feel('DarkTeal7')
# 定義菜單選項
menu_def = [['文件(&F)', ['載入(&L)', '保存(&S)']], ['關閉(&C)', ['確認(&Y)', '!取消(&N)']]]
right_click_menu = menu_def[0]
button_menu = ['提交(&M)', ['確認(&Y)', '取消(&N)']]
layout = [[sg.Menu(menu_def)],
          [sg.Text('To Do List', font='Helvetica 15',
                  relief=sg.RELIEF_GROOVE)]]
layout += [[sg.Text(k), 
            sg.Checkbox('', default=True if k == 1 else False), 
            sg.Input()] for k in range(4)]
layout += [[sg.ButtonMenu('關閉', menu_def=button_menu, key='關閉')]]
window = sg.Window("",layout,
                   grab_anywhere=True, # 非阻塞
                   no_titlebar=True,  # 移除標題欄
                   right_click_menu = right_click_menu # 添加右鍵菜單
                  )
# 事件循環
while 1:
    event, values = window.read()
    print(event, values)
    if event in (None, '確認(Y)'):
        break
    elif event == '保存(S)':
        window.save_to_disk('ToDoList.out')
    elif event == '載入(L)':
        window.load_from_disk('ToDoList.out')
    elif event == '關閉':
        if 'Y' in values['關閉']:
            break  
window.close()

顯示的效果圖見圖9:

圖9 按鈕菜單

4 設計計算機視覺的圖形用戶界面

前面 3 節的內容已經滿足對 GUI 開發的基礎,接下來便可以開發計算機視覺的圖形用戶界面。

4.1

import PySimpleGUI as sg
# 預設
sg.change_look_and_feel('LightGreen')
sg.set_options(element_padding=(0, 0))
version = '0.0.1'
# 定義菜單選項
menu_def = [
    ['文件(&F)', ['打開文件(&O)', '打開文件夾(&U)',
                '保存(&S)', '屬性(&P)', '退出(&X)']],
    ['編輯(&E)', ['復制(&C)', '修改(&M)']],
    ['工具箱(&T)', ['---', '載入標簽(&L)']],
    ['幫助(&H)', '關于(&A)']
]
layout = [[sg.Menu(menu_def, tearoff=True, pad=(20,1))],
          [sg.Output(size=(60,20), key='output')],  # print() 的顯示結果
         ]

window = sg.Window("計算機視覺",layout,default_element_size=(12, 1),
                   grab_anywhere=True, # 非阻塞
                  ) 

# 事件循環
while True:
    event, values = window.read()
    print('Event = ', event)
    if event in (None, '退出(X)'):
        break
    elif 'A' in event:
        window.disappear()  # 隱藏 window
        sg.Popup('關于該軟件的版本號為:', version, grab_anywhere=True)
        window.reappear()   # 重現 window
    elif 'O' in event:
        file_name = sg.popup_get_file('打開文件...', no_window=True)
        print(file_name)
    elif 'U' in event:
        folder_name = sg.popup_get_folder('打開文件夾...', no_window=True)
        print(folder_name)
    else:
        print('新功能正在開發中...')
window.close()
import PySimpleGUI as sg


class GraphX:
    def __init__(self, canvas_w, canvas_h):
        self.canvas_w = canvas_w  # 畫布的寬度
        self.canvas_h = canvas_h  # 畫布的高度

    def graph(self, key="-GRAPH-", background_color='lightblue'):
        param_dict = {
            'canvas_size': (self.canvas_w, self.canvas_h),
            'graph_bottom_left': (0, 0),
            'graph_top_right': (self.canvas_w, self.canvas_h),
            'key': key,
            'change_submits': True,     # mouse click events
            'background_color': background_color,
            'drag_submits': True
        }
        return sg.Graph(**param_dict)

    def radio(self, text, group_id, key, default=False, enable_events=True):
        '''自定義可選按鈕'''
        param_dict = {
            'text': text,  # 按鈕的名稱
            'group_id': group_id,  # 按鈕的組號
            'default': default,  # 是否默認選中(bool)
            'disabled': False,  # 設置按鈕的狀態是否可用
            'background_color': None,  # 背景顏色
            'text_color': None,  # 文本顏色
            'font': None,  # 字體設置 family, size
            'key': key,  # sg.Window 的 key
            'enable_events': enable_events  # 事件驅動
        }
        return sg.Radio(**param_dict)

    @property
    def col(self):
        col = [
            [sg.Text('選擇單擊圖片時需要做的事情:', enable_events=True)],
            [self.radio('畫矩形框', 1, '-Rect-')],
            [self.radio('畫圓形', 1, '-Circle-')],
            [self.radio('畫橢圓形', 1, '-Oval-')],
            [self.radio('畫線段', 1, '-Line-')],
            [self.radio('畫點', 1, '-Point-')],
            [self.radio('擦除', 1, '-erase-')],
            [self.radio('擦除全部', 1, '-clear-')],
            [self.radio('Send to back', 1, '-back-')],
            [self.radio('Bring to front', 1, '-front-')],
            [self.radio('Move Everything', 1, '-move all-')],
            [self.radio('Move Stuff', 1, '-move-', True)]]
        return sg.Column(col)

    @property
    def layout(self):
        _layout = [[self.graph("-GRAPH-", 'lightblue'), self.col]]
        _layout += [[sg.Text(key='info', size=(100, 1))]]
        return _layout

    def window(self, finalize=True):
        return sg.Window("畫圖與移動", self.layout,
                         finalize=finalize,
                         background_color='lightgreen')

    def draw_image(self, graph, filename):
        '''在 sg.Graph 中載入 圖片
        
        參數
        ========
        :filename 僅 支持 GIF 或 PNG
        :location 為圖片的左上角位置坐標
        '''
        location = (0, self.canvas_h)
        graph.draw_image(filename, location=location)

class GraphRun(GraphX):
    def __init__(self, canvas_w, canvas_h):
        super().__init__(canvas_w, canvas_h)
        self._reset()
          

    def _reset(self):
        '''重置參數'''
        # 能夠抓取新的框
        self.start_point, self.end_point = [None]*2
        self.dragging = False
        self.prior_rect = None

    def update(self, graph, values):
       ...
        
        
    def modify(self, graph, values):
        ...

    def run(self, filename):
        window = self.window()
        # 獲得 sg.Graph 元素
        graph = window["-GRAPH-"]
        self.draw_image(graph, filename)
        #graph.bind('<Button-3>', '+RIGHT+')
        while True:
            event, values = window.read()
            if event is None:
                break  # exit
            if 'move' in event:
                graph.set_cursor(cursor='fleur')          
            elif not event.startswith('-GRAPH-'):
                graph.set_cursor(cursor='left_ptr')        
            if event == "-GRAPH-":  # if there's a "Graph" event, then it's a mouse
                x, y = values["-GRAPH-"]
                if not self.dragging:
                    self.start_point = (x, y)
                    self.dragging = True
                    drag_figures = graph.get_figures_at_location((x,y))
                    lastxy = x, y
                else:
                    self.end_point = (x, y)
                if self.prior_rect:
                    graph.delete_figure(self.prior_rect)
                delta_x, delta_y = x - lastxy[0], y - lastxy[1]
                lastxy = x,y
                if None not in (self.start_point, self.end_point):
                    if values['-move-']:
                        for fig in drag_figures:
                            graph.move_figure(fig, delta_x, delta_y)
                            graph.update()
                    elif values['-Rect-']:
                        self.prior_rect = graph.draw_rectangle(self.start_point, self.end_point, line_color='blue')
                    elif values['-Circle-']:
                        self.prior_rect = graph.draw_circle(self.start_point, self.end_point[0]-self.start_point[0], line_color='blue')
                    elif values['-Oval-']:
                        self.prior_rect = graph.draw_oval(self.start_point, self.end_point, line_color='blue')
                    elif values['-Line-']:
                        self.prior_rect = graph.draw_line(self.start_point, self.end_point, color='red', width=1)
                    elif values['-Point-']:
                        self.prior_rect = graph.draw_point(self.start_point,  color='red', size=1)
                    elif values['-erase-']:
                        for figure in drag_figures:
                            graph.delete_figure(figure)
                    elif values['-clear-']:
                        graph.erase()
                        self.draw_image(graph, filename)
                    elif values['-move all-']:
                        graph.move(delta_x, delta_y)
                    elif values['-front-']:
                        for fig in drag_figures:
                            graph.bring_figure_to_front(fig)
                    elif values['-back-']:
                        for fig in drag_figures:
                            graph.send_figure_to_back(fig)
            elif event.endswith('+UP'):  # The drawing has ended because mouse up
                info = window["info"]
                info.update(value=f"grabbed rectangle from {self.start_point} to {self.end_point}")
                self._reset()
        window.close()

if __name__ == '__main__':
    canvas_size = (800, 600)
    filename = 'D:/github/test_gui/1.png'
    self = GraphRun(*canvas_size)
    self.run(filename)
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
禁止轉載,如需轉載請通過簡信或評論聯系作者。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,825評論 6 546
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,814評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,980評論 0 384
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 64,064評論 1 319
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,779評論 6 414
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,109評論 1 330
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,099評論 3 450
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,287評論 0 291
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,799評論 1 338
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,515評論 3 361
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,750評論 1 375
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,221評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,933評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,327評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,667評論 1 296
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,492評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,703評論 2 380

推薦閱讀更多精彩內容