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('', '+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)

你可能感兴趣的:(4 设计一个图像标注工具)