使用 Julia、Tk 和 Cairo 构建 GUI,第一部分

2013 年 5 月 23 日 | Timothy E. Holy

这是两篇博客文章中的第一篇,旨在引导用户完成在 Julia 中创建 GUI 的过程。关注 Julia 开发的人都知道,Julia 中的绘图仍在不断发展,因此有人可能会认为现在用 Julia 构建 GUI 为时过早。我最近的经验告诉我,这种预期是错误的:与在 Matlab 中构建 GUI 相比(我之前唯一的 GUI 编写经验),Julia 已经提供了一些相当引人注目的优势。我们将在下面看到这些优势的展示。

我们将介绍创建图像查看器 GUI 所需的重点。在深入了解如何编写此 GUI 之前,让我们先玩一玩它,以了解它是如何工作的。最好自己尝试这些命令,因为静态文本和图片很难捕捉到交互性等内容。

您需要 ImageView

Pkg.add("ImageView")

值得指出的是,这个包预计会随着时间的推移而发展;但是,如果与本博客中描述的内容有所不同,请尝试直接从 存储库 中检出“blog”分支。我还应该指出,这个包是在作者的 Linux 系统上开发的,其他平台可能无法正常工作。

首先让我们用一张照片来试试。以这种方式加载一张照片

using Images
using ImageView
img = imread("my_photo.jpg")

任何典型的图像格式都可以,不需要是 jpg。现在以这种方式显示图像

display(img, pixelspacing = [1,1])

查看图像的基本命令是 display。可选的 pixelspacing 输入告诉 display 该图像具有固定纵横比,并且在显示图像时需要保持这个纵横比。(或者,您可以设置 img["pixelspacing"] = [1,1],然后您就不需要告诉 display 函数了。)

您应该会看到一个包含图像的窗口

photo

好的,不错。但是,如果我们调整窗口大小,图像会变大或变小,我们就可以开始玩得开心了

photo

注意黑色边框;这是因为我们通过 pixelspacing 输入指定了纵横比,当窗口与图像的纵横比不同时,您将在水平或垂直方向上看到一个边框。尝试不指定 pixelspacing,您会看到图像会拉伸以填充窗口,但看起来会扭曲

display(img)

photo

(如果您已经为 img 定义了 "pixelspacing",这将不起作用;如果需要,请使用 delete!(img, "pixelspacing") 删除该设置。)

接下来,在图像内部的某个位置单击并拖动。您将看到典型的橡皮筋选择,当您松开鼠标时,图像显示将放大到选定区域。

photo photo

同样,显示的纵横比保持不变。双击图像会将显示恢复到全尺寸。

如果您有滚轮鼠标,请再次放大并滚动滚轮,这应该会导致图像垂直平移。如果您在按住 Shift 键的同时滚动,它会水平平移;按住 Ctrl 键会影响缩放设置。请注意,当您通过鼠标进行缩放时,缩放会始终集中在鼠标指针位置附近,这使得只需将鼠标指向某个小功能,然后按 Ctrl 滚动即可轻松放大它。

Matlab 的长期用户可能会注意到这种行为的一些不错的功能

这些已经让我们体验了在 Julia 中可以轻松实现的一些功能。

但是,这个 GUI 的功能远不止表面上的那么简单。您可以使用以下方法将图像倒置显示

display(img, pixelspacing = [1,1], flipy=true)

或使用以下方法交换 xy

display(img, pixelspacing = [1,1], xy=["y","x"])

photo photo

要体验完整的功能,您需要一个“4D 图像”,即 3D 图像的电影(时间序列)。如果您手头没有,您可以通过 include("test/test4d.jl") 创建一个,其中 test 表示 ImageView 中的测试目录。(假设您通过包管理器安装了 ImageView,您可以说 include(joinpath(Pkg.dir(), "ImageView", "test", "test4d.jl"))。)这将创建一个随时间推移颜色发生变化的实心圆锥体,同样在变量 img 中。然后,键入 display(img)。您应该会看到类似于这样的内容

GUI snapshot

绿色圆圈是圆锥体的一个“切片”。在窗口底部,您会看到一些按钮和我们当前的位置,z=1t=1,分别对应于圆锥体的底部和电影的开头。单击向上指向的绿色箭头,您将在 z 维度上“平移”圆锥体,使圆圈变小。您可以使用向下指向的绿色箭头返回,或使用黑色箭头逐帧前进。接下来,单击“向前播放”按钮将时间向前推进,您将看到颜色从灰色变为洋红色。黑色方块是停止按钮。当然,您可以在输入框中键入特定的 zt 位置,或抓住滑块并移动它们。

如果您有滚轮鼠标,Alt-滚动会更改时间,Ctrl-Alt-滚动会更改 z 切片。

您可以通过右键单击导航栏中的空白区域来更改播放速度,这将弹出一个弹出(上下文)菜单

GUI snapshot



默认情况下,display 会显示 xy 平面的切片。您可能想要查看 4D 图像中不同的切片集

display(img, xy=["x","z"])

最初您将看不到任何内容,因为图像的这一边是黑色的。在 y: 输入框中(请注意它的名称已更改)键入 151 并按 Enter 键,或将“y”滑块移动到其范围的中间;现在您将从侧面看到圆锥体。

GUI snapshot

此 GUI 也适用于“普通电影”(具有时间的 2D 图像),在这种情况下,z 控件将被省略,它将主要像一个典型的电影播放器一样运行。同样,对于缺少时间成分的 3D 图像,t 控件也将被省略,这使得它成为查看 MRI 扫描的理想查看器。

同样,我们注意到比 Matlab 有很多改进

总而言之,这些优势共同使用 Julia 编写的 GUI 应用程序具有更加精致的感觉。

这完成了我们对该 GUI 功能的浏览。现在让我们浏览创建它所需的一些重点。我们将分段进行;这不仅使学习更容易,还说明了如何构建可重用组件。让我们从导航框架开始。

第一步:导航框架

首先,我要承认,这个 GUI 是建立在许多为 Julia 的 Cairo 和 Tk 包做出贡献的人的工作基础上的。对于这一步,我们将特别利用 John Verzani 为 Tk 的大多数小部件功能贡献的一大套便利包装器。John 写了一组很好的 示例,展示了您可以使用它做到的许多事情;第一部分本质上只是一个“更长”的示例,对于任何阅读过他的文档的人来说都不会感到惊讶。

让我们创建几个类型来保存我们需要的数据。我们需要一个类型来存储“GUI 状态”,这里包括当前查看的图像位置以及实现“播放”功能所需的信息

type NavigationState
    # Dimensions:
    zmax::Int          # number of frames in z, set to 1 if only 2 spatial dims
    tmax::Int          # number of frames in t, set to 1 if only a single image
    z::Int             # current position in z-stack
    t::Int             # current moment in time
    # Other state data:
    timer              # nothing if not playing, TimeoutAsyncWork if we are
    fps::Float64       # playback speed in frames per second
end

接下来,让我们创建一个类型来保存所有小部件的“句柄”

type NavigationControls
    stepup                            # z buttons...
    stepdown
    playup
    playdown
    stepback                          # t buttons...
    stepfwd
    playback
    playfwd
    stop
    editz                             # edit boxes
    editt
    textz                             # static text (information)
    textt
    scalez                            # scale (slider) widgets
    scalet
end

保存所有小部件的句柄可能不是严格必要的(您可以使用回调来完成所有操作),但它们的存在很方便。例如,如果您不喜欢我创建的图标,您可以轻松地初始化 GUI 并使用句柄将图标替换为更好的东西。

我们稍后会讨论初始化;现在,假设我们有一个名为 state 的变量,其类型为 NavigationState,它保存着(可能)4D 图像中的当前位置,以及 ctrls,它包含一组完全初始化的小部件句柄。

每个按钮都需要一个回调函数,以便在单击按钮时执行。让我们浏览一下控制 t 的函数。首先是一个与任何按钮都没有关联的通用实用程序,但它会影响许多控件

function updatet(ctrls, state)
    set_value(ctrls.editt, string(state.t))
    set_value(ctrls.scalet, state.t)
    enableback = state.t > 1
    set_enabled(ctrls.stepback, enableback)
    set_enabled(ctrls.playback, enableback)
    enablefwd = state.t < state.tmax
    set_enabled(ctrls.stepfwd, enablefwd)
    set_enabled(ctrls.playfwd, enablefwd)
end

前两行将输入框和滑块同步到 state.t 的当前值;当前选择的时间可以通过多种不同的机制改变(其中一个按钮、在输入框中键入或移动滑块),因此我们使 state.t 成为“权威”值并将所有内容同步到它。此函数的剩余行控制哪个 t 导航按钮是启用的(如果 t==1,我们不能在电影中再早一点,因此我们将向后按钮变灰)。

第二个实用程序函数修改 state.t

function incrementt(inc, ctrls, state, showframe)
    state.t += inc
    updatet(ctrls, state)
    showframe(state)
end

请注意上面描述的 updatet 的调用。此函数的新部分是 showframe 函数,它的作用是将图像帧(或任何其他视觉信息)显示给用户。通常,实际的 showframe 函数将需要其他信息,例如在何处渲染图像,但是您可以使用匿名函数提供这些信息。我们将在下一部分中看到它是如何工作的;下面我们只创建一个简单的“存根”函数。

现在我们进入回调,我们将把回调“绑定”到步进和播放按钮

function stept(inc, ctrls, state, showframe)
    if 1 <= state.t+inc <= state.tmax
        incrementt(inc, ctrls, state, showframe)
    else
        stop_playing!(state)
    end
end

function playt(inc, ctrls, state, showframe)
    if !(state.fps > 0)
        error("Frame rate is not positive")
    end
    stop_playing!(state)
    dt = 1/state.fps
    state.timer = TimeoutAsyncWork(i -> stept(inc, ctrls, state, showframe))
    start_timer(state.timer, iround(1000*dt), iround(1000*dt))
end

stept()t 帧按指定量(通常为 1 或 -1)递增,而 playt() 启动一个定时器,该定时器将定期调用 stept。如果播放达到电影的开头或结尾,则定时器将停止。stop_playing! 函数检查我们是否有一个活动的定时器,如果有,则停止它

function stop_playing!(state::NavigationState)
    if !is(state.timer, nothing)
        stop_timer(state.timer)
        state.timer = nothing
    end
end

处理播放的另一种方法是不使用定时器,而是在循环中使用,例如

function stept(inc, ctrls, state, showframe)
    if 1 <= state.t+inc <= state.tmax
        incrementt(inc, ctrls, state, showframe)
    end
end

function playt(inc, ctrls, state, showframe)
    state.isplaying = true
    while 1 <= state.t+inc <= state.tmax && state.isplaying
        tcl_doevent()    # allow the stop button to take effect
        incrementt(inc, ctrls, state, showframe)
    end
    state.isplaying = false
end

使用这个版本,我们将使用一个布尔值来指示是否正在进行播放。这里的一个关键点是调用 tcl_doevent(),它允许 Tk 中断循环的执行以处理用户交互(在本例中,单击停止按钮)。但是使用定时器则没有必要,而且定时器还可以让我们控制播放速度。

最后,有一些用于输入框和小部件的回调

function sett(ctrls,state, showframe)
    tstr = get_value(ctrls.editt)
    try
        val = int(tstr)
        state.t = val
        updatet(ctrls, state)
        showframe(state)
    catch
        updatet(ctrls, state)
    end
end

function scalet(ctrls, state, showframe)
    state.t = get_value(ctrls.scalet)
    updatet(ctrls, state)
    showframe(state)
end

sett 在用户在编辑框中键入内容时运行;如果用户键入诸如“foo”之类的无意义内容,它会将其优雅地重置到当前位置。

对于z控件,有一组互补的功能。

这些回调实现了这个“导航”GUI的功能。另一个主要任务是初始化。我们不会详细介绍(你可以浏览代码),但让我们重点介绍几个要点。

创建按钮

你可以使用图像文件(例如,.png 文件)作为你的图标,但这里的是通过编程创建的。为此,请指定两种颜色,即“前景”和“背景”,以字符串形式。还需要一个data数组(类型为Bool),用于应由前景颜色着色的像素,而false用于设置为背景的像素。还有一个mask数组,它可以阻止data数组在mask中标记为false的任何像素中生效。

给定合适的datamask数组(这里我们只是将mask设置为trues),以及颜色字符串,我们可以创建图标并将其分配给按钮,如下所示

icon = Tk.image(data, mask, "gray70", "black")  # background=gray70, foreground=black
ctrls.stop = Button(f, icon)

这里f是“父框架”,导航控制器将在其中渲染。框架是一个容器,用于组织一组相关的GUI元素。稍后我们将了解如何创建一个。

将回调分配给小部件

“停止”和“向后播放”按钮看起来像这样

bind(ctrls.stop, "command", path -> stop_playing!(state))
bind(ctrls.playback, "command", path -> playt(-1, ctrls, state, showframe)

path输入由Tk/Tcl生成,但我们不需要使用它。相反,我们使用匿名函数传递与这个特定GUI实例相关的参数。请注意,这两个按钮共享state;这意味着一个回调所做的任何更改都会影响另一个。

将按钮放置在框架中(布局管理)

这里我们的布局需求非常简单,但我建议你阅读关于Tk的grid布局引擎的出色 教程grid提供了Matlab中缺少的大量功能,特别是允许在调整窗口大小时实现灵活且精致的GUI行为。

我们以这种方式定位停止按钮

grid(ctrls.stop, 1, stopindex, padx=3*pad, pady=pad)

在按钮本身的句柄之后,接下来的两个输入决定了小部件的行、列位置。这里列位置是使用一个变量(一个整数)设置的,该变量的值将取决于z控件是否存在。pad设置只是在按钮周围应用了一些水平和垂直填充。

要定位滑块小部件,我们可以执行以下操作

ctrls.scalez = Slider(f, 1:state.zmax)
grid(ctrls.scalez, 2, start:stop, sticky="we", padx=pad)

这将它们定位在框架网格的第 2 行,并使它们占用相同zt轴的按钮控件使用的列范围(由start:stop指示)。sticky设置意味着它将从西到东(从左到右)拉伸以填充。

在主GUI中,我们将使用grid的另一个功能,因此让我们现在介绍一下。此功能控制窗口调整大小时窗口区域的扩展或收缩方式

grid_rowconfigure(win, 1, weight=1)
grid_columnconfigure(win, 1, weight=1)

这表示第 1 行、第 1 列将在图形变大时以1的速率扩展。你可以为不同的GUI组件设置不同的权重。默认值为 0,表示它不应该扩展。这就是我们希望为这个导航框架设置的,这样按钮在调整窗口大小时会保持其大小。更大的权重值表示给定组件应以更快的速度扩展(或收缩)。

将所有内容整合在一起并进行测试

我们将把导航控件放置在Tk框架内。让我们从命令行创建一个

using Tk
win = Toplevel()
f = Frame(win)
pack(f, expand=true, fill="both")

前三行创建窗口和框架。packgrid的替代布局引擎,当你想放置单个项目以使其填充其容器时,它会更方便一些。(你可以混合使用packgrid,只要它们对不同的容器进行操作即可。这里我们将有一个在窗口中pack的框架,小部件将在框架内grid。)在那第四行之后,窗口相当小;调用pack会导致框架扩展以填充整个窗口,但目前框架没有内容,因此窗口尽可能小。

GUI snapshot

我们需要一个showframe回调;现在让我们创建一个非常简单的回调,它将有助于测试

showframe = x -> println("showframe z=", x.z, ", t=", x.t)

接下来,加载GUI代码(using ImageView.Navigation)并创建NavigationStateNavigationControls对象

ctrls = NavigationControls()
state = NavigationState(40, 1000, 2, 5)

这里我们在z中设置了一个假的电影,它有 40 个图像切片,在t中设置了 1000 个图像堆栈。

最后,我们初始化小部件

init_navigation!(f, ctrls, state, showframe)

GUI snapshot

现在,当你点击按钮或更改输入框中的文本时,你会看到GUI在运行。你可以从命令行输出中(由showframe生成)了解内部发生了什么

GUI snapshot

希望这能演示Julia中开发GUI的另一个优点:构建可重复使用的组件非常简单。此导航框架可以作为任何窗口的元素添加,grid布局管理器会处理其余部分。你只需要将ImageView/src/navigation.jl包含到你的模块中,就可以使用几行代码使用它。

并不难,对吧?下一步是渲染图像,这将我们带入Cairo的领域。