在本期文章中,我们将介绍低级图形(使用 Cairo)以及在 GUI 中绘制图形(使用 Winston)。我们再次依赖于许多人构建的基础设施,包括 Jeff Bezanson、Mike Nolta 和 Keno Fisher。
图像的显示由 Cairo 处理,Cairo 是一个用于二维绘图的 C 库。Julia 的 Cairo 包装器目前没有文档,所以让我们先介绍几个基本概念。
如果您不熟悉 Cairo 等图形库,有一些概念可能并不立即显而易见,但在 Cairo 的教程中进行了介绍。关键概念是 Cairo API 的工作方式类似于“盖章”,其中源通过路径指定的区域应用于目标。在这里,目标将是对应于屏幕上窗口区域的像素。我们将控制源和路径以实现我们想要的效果。
让我们来试试这个。首先,在一个新窗口中,我们创建一个支持 Cairo 的 Canvas 用于绘图
using Base.Graphics
using Cairo
using Tk
win = Toplevel("Test", 400, 200)
c = Canvas(win)
pack(c, expand=true, fill="both")
我们创建了一个宽度为 400 像素,高度为 200 像素的窗口。c
是我们的 Canvas,在 Tk
包中定义的一种类型。稍后我们将深入探讨其内部结构,但现在,可以简单地说 Canvas 是一个多组件对象,您通常可以将其视为一个黑盒。创建画布的初始调用会将其许多字段留为空,因为您还不知道画布大小等关键细节。对 pack
的调用指定此画布填充整个窗口,并同时填充 Canvas 对象本身中缺少的信息。
请注意,窗口当前是空白的,因为我们还没有在其中绘制任何内容,因此您可以看到其下方的任何内容。在我的情况下,它捕获了桌面的一小部分
现在让我们进行一些绘图。Cairo 不了解 Tk Canvas,因此我们必须提取其中直接与 Cairo 协同工作的部分
ctx = getgc(c)
getgc
表示“获取图形上下文”,返回一个对象(此处为 ctx
),其中包含有关当前绘图状态的所有相关信息。
Cairo 的一个不错的功能是坐标是抽象的;最终我们关心的是屏幕像素,但我们可以设置用户坐标,这些坐标具有对问题自然而言的任何缩放比例。我们只需要告诉 Cairo 如何将用户坐标转换为设备(屏幕)坐标。我们使用 set_coords
设置坐标系,该函数在 base/graphics.jl
中定义
function set_coords(ctx::GraphicsContext, x, y, w, h, l, r, t, b)
x
(水平)和 y
(垂直)指定绘图区域在设备坐标中的左上角,w
和 h
分别指定其宽度和高度。(请注意,Cairo 使用 (0,0) 表示窗口的左上角。)l
、r
、t
和 b
分别是对应于此区域的左、右、上和下边界。请注意,set_coords
还将剪辑在 x
、y
、w
和 h
定义的区域之外发生的任何绘图;但是,您指定的坐标系扩展到无穷大,您可以通过调用 reset_clip()
绘制到画布的边缘。
让我们用颜色填充绘图区域,以便我们可以看到它
# Set coordinates to go from 0 to 10 within a 300x100 centered region
set_coords(ctx, 50, 50, 300, 100, 0, 10, 0, 10)
set_source_rgb(ctx, 0, 0, 1) # set color to blue
paint(ctx) # paint the entire clip region
也许令人惊讶的是,什么也没发生。原因是 Tk Canvas 实现了一种称为双缓冲的技术,这意味着您将所有绘图操作都执行到一个后(隐藏)表面,然后将完成的结果blit到前(可见)表面。我们可以简单地将另一个窗口置于我们用于绘图的窗口顶部,然后将我们的窗口带回顶部,从而看到这一点;突然之间,您将在窗口内看到一个漂亮的蓝色矩形,周围环绕着背景窗口中的内容
幸运的是,要显示图形,您不必依赖用户更改窗口的堆叠顺序:调用 reveal(c)
以使用后表面的内容更新前表面,然后调用 update()
(或者可能更好,Tk.update()
,因为 update
是一个相当通用的名称)以使 Tk 有机会将前表面公开给操作系统的窗口管理器。
现在让我们画一条红线
move_to(ctx, -1, 5)
line_to(ctx, 7, 6)
set_source_rgb(ctx, 1, 0, 0)
set_line_width(ctx, 5)
stroke(ctx)
reveal(c)
Tk.update()
我们从坐标区域之外的位置开始(我们将看到剪辑的作用)。下一条命令 line_to
创建路径的一段,这是在 Cairo 中定义区域的方式。stroke
命令沿着路径轨迹绘制一条线,之后路径将被清除。(如果要稍后将此路径用于其他目的,可以使用 stroke_preserve
。)
让我们通过添加一个带有品红色边框的实心绿色矩形来说明这一点,使其溢出先前定义的坐标区域的边缘
reset_clip(ctx)
rectangle(ctx, 7, 5, 4, 4)
set_source_rgb(ctx, 0, 1, 0)
fill_preserve(ctx)
set_source_rgb(ctx, 1, 0, 1)
stroke(ctx)
reveal(c)
Tk.update()
fill
与 paint
的区别在于,fill
在当前定义的路径内工作,而 paint
填充整个剪辑区域。
这是我们的杰作,其中“背景”在您这里可能有所不同(我的背景位于维基百科页面底部)
图像在 Cairo 中的rectangle
(控制图像的位置)之后渲染,然后是 fill
。到目前为止,这与上面的简单绘图一样。不同之处在于源,它现在将是表面而不是 RGB 颜色。如果您是从 Julia 绘制,则很可能要显示一个内存中的数组。主要技巧是 Cairo 要求此数组为 Uint32
类型的矩阵,用于编码颜色。方案是最不重要的字节是蓝色值(范围从 0x00
到 0xff
),下一个是绿色,下一个是红色。(如果指定在图像表面中使用透明度,则最重要的字节可以编码 alpha 值或透明度。)
Winston
和 Images
都可以为您生成 Uint32
的缓冲区。让我们尝试 Images
中的一个
using Images
img = imread("some_photo.jpg")
buf = uint32color(img)'
image(ctx, CairoRGBSurface(buf), 0, 0, 10, 10)
reveal(c)
Tk.update()
我们不手动调用 rectangle
和 fill
,而是使用便利方法 image(ctx, surf, x, y, w, h)
(在 Cairo.jl
中定义)。这里 x
、y
、w
、h
是画布的用户坐标,而不是屏幕上的像素或图像中的像素;能够以用户坐标表示位置是使用 image()
的主要优势。
图像现在应该显示在您的窗口中(被压缩,因为我们没有考虑纵横比)
它仅填充窗口的一部分,因为我们已经建立了坐标系,其中范围 0:10
对应于窗口中心的一个内嵌区域。
虽然这是一个次要问题,但请注意 CairoRGBSurface
会为您进行转置,以将 Julia 中矩阵的主列顺序转换为 Cairo 的主行约定。Images
仅在必要时才会进行转置,并且能够处理任何存储顺序的图像。这里我们进行转置,准备让 CairoRGBSurface
将其转换回其原始形状。如果性能至关重要,您可以通过直接调用 CairoImageSurface
来避免 CairoRGBSurface
的默认行为(请参阅 Cairo.jl
代码)。
窗口的一个基本功能是在调整大小操作下正常运行。这并非完全免费,尽管 Tk 的网格(和 pack)管理器为我们处理了许多细节。但是,对于 Canvas,我们需要做一些额外的工作;要了解我的意思,只需尝试调整我们上面创建的窗口的大小。
关键是要有一个回调,当画布大小发生变化时激活,并且该回调能够以任意大小重绘窗口。Canvas 通过一个字段 resize
使此操作变得容易,您可以将回调分配给该字段。此函数将接收一个参数,即画布本身,但与往常一样,您可以提供更多信息。以我们的图像示例为例,我们可以设置
c.resize = c->redraw(c, buf)
然后定义
function redraw(c::Canvas, buf)
ctx = getgc(c)
set_source_rgb(ctx, 1, 0, 0)
paint(ctx)
set_coords(ctx, 50, 50, Tk.width(c)-100, Tk.height(c)-100, 0, 10, 0, 10)
image(ctx, CairoRGBSurface(buf), 0, 0, 10, 10)
reveal(c)
Tk.update()
end
在这里,您可以看到我们的目标是更加完善,并且希望避免在绘图区域的边界周围看到桌面的部分。因此,在显示图像之前,我们用纯色填充窗口(但选择一种俗气的红色,以确保我们注意到它)。我们还必须重新创建坐标系,因为该坐标系也被销毁了,在这种情况下,我们动态地将坐标调整为画布的大小。最后,我们重绘图像。请注意,我们不必再次经历转换为基于 Uint32
的颜色的过程。显然,您甚至可以在窗口的初始渲染中使用此 redraw
函数,因此以这种方式设置代码实际上没有任何额外的工作。
如果您抓住窗口句柄并调整其大小,现在您应该会看到类似以下内容
瞧!我们现在真的取得了一些进展。
与完整的 GUI 不同,此实现没有保留图像纵横比的选项。但是,这里并没有什么神奇之处;它归根结底是计算大小并控制绘图区域和坐标系。
一个重点:调整窗口大小会导致现有的 Cairo 上下文被销毁,并创建适合新画布大小的新上下文。结果是您的旧 ctx
变量现在无效,尝试将其用于绘图将导致段错误。因此,您永远不应单独存储 ctx 对象;始终通过再次调用 getgc(c)
开始绘图。
Canvas 已经预备好了一组用于鼠标事件的字段。例如,在完整的 GUI 中,我们有以下等效项
selectiondonefunc = (c, bb) -> zoombb(imgc, img2, bb)
c.mouse.button1press = (c, x, y) -> rubberband_start(c, x, y, selectiondonefunc)
rubberband_start
(在 rubberband.jl
中定义的函数)现在将在用户按下鼠标左键时被调用。selectiondonefunc
是我们提供的回调;它将在用户释放鼠标按钮时执行,并且它需要实现我们想要使用选定区域实现的目标(在本例中为缩放操作)。rubberband_start
执行的部分操作是通过 c.mouse.button1release
将 selectiondonefunc
绑定到鼠标按钮的释放。bb
是一个 BoundingBox
(在 base/graphics.jl
中定义的类型),它将存储用户选择的区域,并将传递给 selectiondonefunc
。(zoombb
的前两个输入 imgc
和 img2
存储与此特定 GUI 相关的设置,这里将不详细介绍。)
Canvas
内部的 mouse
是 MouseHandler
类型的对象,它具有用于所有 3 个鼠标按钮的 press
和 release
以及用于移动的其他字段。但是,MouseHandler
中没有几个案例(恰好与该 GUI 相关)。以下是一些有关如何配置这些操作的示例
# Bind double-clicks
bind(c.c, "<Double-Button-1>", (path,x,y)->zoom_reset(imgc, img2))
# Bind Shift-scroll (using the wheel mouse)
bindwheel(c.c, "Shift", (path,delta)->panhorz(imgc,img2,int(delta)))
滚轮鼠标的 delta
参数将编码滚动的方向。
橡皮筋的支持功能在文件rubberband.jl
中提供。类似于navigation.jl
,这是一个独立的功能集,您可以将其整合到其他项目中。它绘制了一个虚线矩形,使用了我们在本页顶部描述的相同机制,并进行了一些小的修改来创建虚线(通过set_dash
函数)。到目前为止,这一切都应该相当简单。
然而,这些函数使用了另一个值得一提的技巧。让我们最后看看Tk的Canvas
对象。
type Canvas
c::TkWidget
front::CairoSurface # surface for window
back::CairoSurface # backing store
frontcc::CairoContext
backcc::CairoContext
mouse::MouseHandler
redraw
function ...
在这里,我们可以明确地看到用于双缓冲的两个缓冲区及其关联的上下文。getgc(c)
,其中c
是一个Canvas
,简单地返回backcc
。这就是为什么所有绘图都发生在后表面上的原因。对于橡皮筋,我们选择改为在前表面上绘制,然后(随着橡皮筋大小的变化)通过从后表面复制来“修复损坏”。由于我们只需要修改橡皮筋本身的像素,所以速度很快。您可以在rubberband.jl
中看到这些细节。
对于许多Julia中的GUI,一个重要的组件将是能够以图形方式显示数据的能力。虽然我们可以使用Cairo直接绘制图形,但从头开始构建将需要大量的工作;幸运的是,有一个优秀的包Winston已经完成了这项工作。
由于有一套很好的关于Winston可以做的事情的示例,因此我们的重点非常狭窄:如何将Winston绘图集成到使用Tk构建的GUI中。幸运的是,这非常容易。让我们来看一个例子。
using Tk
using Winston
win = Toplevel("Testing", 400, 200)
fwin = Frame(win)
pack(fwin, expand=true, fill="both")
我们选择用一个框架fwin
填充整个窗口,以便此GUI内部的所有内容都具有统一的背景。所有其他对象都将放置在fwin
内部。
接下来,让我们设置元素,左侧为Canvas,右侧为单个按钮。
c = Canvas(fwin, 300, 200)
grid(c, 1, 1, sticky="nsew")
fctrls = Frame(fwin)
grid(fctrls, 1, 2, sticky="sw", pady=5, padx=5)
grid_columnconfigure(fwin, 1, weight=1)
grid_rowconfigure(fwin, 1, weight=1)
ok = Button(fctrls, "OK")
grid(ok, 1, 1)
最后,让我们在Canvas内部绘制一些内容。
x = linspace(0.0,10.0,1001)
y = sin(x)
p = FramedPlot()
add(p, Curve(x, y, "color", "red"))
Winston.display(c, p)
reveal(c)
Tk.update()
您会注意到您可以调整此窗口的大小,并且绘图也会相应地放大或缩小。
很简单,对吧?此代码中唯一特定于GUI的部分是Winston.display(c, p)
这一行,我们在这里指定希望我们的绘图出现在特定的Canvas内。当然,Winston内部有很多魔法,但介绍其内部细节超出了我们在这里的范围。
还可以涵盖更多内容,但其余大部分内容都非常特定于此特定的GUI。需要相当数量的代码来处理坐标:选择4d图像内的特定区域,并将渲染结果输出到输出画布的特定区域。如果您想深入了解这些细节,最好的方法是从阅读ImageView
代码开始,但这里不再详细介绍。
希望到目前为止,您已经对如何使用Tk、Cairo和Winston生成屏幕输出有了很好的了解。熟练掌握这些工具需要一些练习,但最终结果非常强大。祝您编程愉快!