这是两篇博客文章中的第一篇,旨在引导用户完成在 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
函数了。)
您应该会看到一个包含图像的窗口
好的,不错。但是,如果我们调整窗口大小,图像会变大或变小,我们就可以开始玩得开心了
注意黑色边框;这是因为我们通过 pixelspacing
输入指定了纵横比,当窗口与图像的纵横比不同时,您将在水平或垂直方向上看到一个边框。尝试不指定 pixelspacing
,您会看到图像会拉伸以填充窗口,但看起来会扭曲
display(img)
(如果您已经为 img
定义了 "pixelspacing"
,这将不起作用;如果需要,请使用 delete!(img, "pixelspacing")
删除该设置。)
接下来,在图像内部的某个位置单击并拖动。您将看到典型的橡皮筋选择,当您松开鼠标时,图像显示将放大到选定区域。
同样,显示的纵横比保持不变。双击图像会将显示恢复到全尺寸。
如果您有滚轮鼠标,请再次放大并滚动滚轮,这应该会导致图像垂直平移。如果您在按住 Shift 键的同时滚动,它会水平平移;按住 Ctrl 键会影响缩放设置。请注意,当您通过鼠标进行缩放时,缩放会始终集中在鼠标指针位置附近,这使得只需将鼠标指向某个小功能,然后按 Ctrl 滚动即可轻松放大它。
Matlab 的长期用户可能会注意到这种行为的一些不错的功能
调整大小和平移比 Matlab 的要流畅得多
Matlab 不会将修饰键与滚轮鼠标结合使用,这使得难以实现这种程度的交互性
在 Matlab 中,使用滚轮鼠标进行缩放始终以显示器的中间为中心,要求您在缩放和平移之间交替,以放大图像或绘图的某个特定小区域。
这些已经让我们体验了在 Julia 中可以轻松实现的一些功能。
但是,这个 GUI 的功能远不止表面上的那么简单。您可以使用以下方法将图像倒置显示
display(img, pixelspacing = [1,1], flipy=true)
或使用以下方法交换 x
和 y
轴
display(img, pixelspacing = [1,1], xy=["y","x"])
要体验完整的功能,您需要一个“4D 图像”,即 3D 图像的电影(时间序列)。如果您手头没有,您可以通过 include("test/test4d.jl")
创建一个,其中 test
表示 ImageView
中的测试目录。(假设您通过包管理器安装了 ImageView
,您可以说 include(joinpath(Pkg.dir(), "ImageView", "test", "test4d.jl"))
。)这将创建一个随时间推移颜色发生变化的实心圆锥体,同样在变量 img
中。然后,键入 display(img)
。您应该会看到类似于这样的内容
绿色圆圈是圆锥体的一个“切片”。在窗口底部,您会看到一些按钮和我们当前的位置,z=1
和 t=1
,分别对应于圆锥体的底部和电影的开头。单击向上指向的绿色箭头,您将在 z
维度上“平移”圆锥体,使圆圈变小。您可以使用向下指向的绿色箭头返回,或使用黑色箭头逐帧前进。接下来,单击“向前播放”按钮将时间向前推进,您将看到颜色从灰色变为洋红色。黑色方块是停止按钮。当然,您可以在输入框中键入特定的 z
、t
位置,或抓住滑块并移动它们。
如果您有滚轮鼠标,Alt-滚动会更改时间,Ctrl-Alt-滚动会更改 z 切片。
您可以通过右键单击导航栏中的空白区域来更改播放速度,这将弹出一个弹出(上下文)菜单
默认情况下,display
会显示 xy
平面的切片。您可能想要查看 4D 图像中不同的切片集
display(img, xy=["x","z"])
最初您将看不到任何内容,因为图像的这一边是黑色的。在 y:
输入框中(请注意它的名称已更改)键入 151 并按 Enter 键,或将“y”滑块移动到其范围的中间;现在您将从侧面看到圆锥体。
此 GUI 也适用于“普通电影”(具有时间的 2D 图像),在这种情况下,z
控件将被省略,它将主要像一个典型的电影播放器一样运行。同样,对于缺少时间成分的 3D 图像,t
控件也将被省略,这使得它成为查看 MRI 扫描的理想查看器。
同样,我们注意到比 Matlab 有很多改进
当您调整窗口大小时,请注意控件会保持其初始大小,而图像会填充窗口。在 Matlab 中,需要付出一些努力才能实现这种行为,但是(正如您将在这些文章的后续部分中看到的那样),在 Julia 和 Tk 中,这几乎是微不足道的。
当我们移动滑块时,显示会在我们拖动时更新,而不仅仅是在我们松开鼠标按钮时更新。
如果您尝试使用更大的 3D 或 4D 图像,您可能还会注意到,显示感觉很流畅,响应迅速,这在 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
的任何像素中生效。
给定合适的data
和mask
数组(这里我们只是将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 行,并使它们占用相同z
或t
轴的按钮控件使用的列范围(由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")
前三行创建窗口和框架。pack
是grid
的替代布局引擎,当你想放置单个项目以使其填充其容器时,它会更方便一些。(你可以混合使用pack
和grid
,只要它们对不同的容器进行操作即可。这里我们将有一个在窗口中pack
的框架,小部件将在框架内grid
。)在那第四行之后,窗口相当小;调用pack
会导致框架扩展以填充整个窗口,但目前框架没有内容,因此窗口尽可能小。
我们需要一个showframe
回调;现在让我们创建一个非常简单的回调,它将有助于测试
showframe = x -> println("showframe z=", x.z, ", t=", x.t)
接下来,加载GUI代码(using ImageView.Navigation
)并创建NavigationState
和NavigationControls
对象
ctrls = NavigationControls()
state = NavigationState(40, 1000, 2, 5)
这里我们在z
中设置了一个假的电影,它有 40 个图像切片,在t
中设置了 1000 个图像堆栈。
最后,我们初始化小部件
init_navigation!(f, ctrls, state, showframe)
现在,当你点击按钮或更改输入框中的文本时,你会看到GUI在运行。你可以从命令行输出中(由showframe
生成)了解内部发生了什么
希望这能演示Julia中开发GUI的另一个优点:构建可重复使用的组件非常简单。此导航框架可以作为任何窗口的元素添加,grid
布局管理器会处理其余部分。你只需要将ImageView/src/navigation.jl
包含到你的模块中,就可以使用几行代码使用它。
并不难,对吧?下一步是渲染图像,这将我们带入Cairo的领域。