QGIS-地图应用构建指南-全-

QGIS 地图应用构建指南(全)

原文:zh.annas-archive.org/md5/a65285dc961ec1dee463261f0a4af4e7

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

随着软件应用越来越成为人们生活的一部分,位置和空间的概念变得越来越重要。开发者们经常发现自己不得不处理基于位置的数据。地图、地理空间数据和空间计算正日益成为日常编程技能库中的另一个组成部分。

十年前,地理空间概念和开发仅限于地理信息科学领域的专家。这些人花费数年时间与地图及其背后的复杂数学打交道。这些人通常来自大学背景,这些专家会花费数年时间熟悉特定的地理信息系统(GIS),并以此系统绘制地图和处理地理空间数据作为自己的职业。

虽然流行的谷歌地图意味着任何人都可以查看和操作地图,但更高级的定制显示和地理空间数据处理仍然局限于使用专业 GIS 系统的人。所有这些都在免费(通常是开源)的地理空间数据操作和显示工具的出现下发生了变化。现在,任何人都可以学习必要的概念,并从头开始构建自己的地图应用。开发者不再受限于谷歌地图的最小功能和限制性许可条款,现在可以构建自己的地图系统以满足自己的需求,而且没有限制可以做什么。

虽然必要的工具和库是免费可用的,但开发者仍然需要将它们组合成一个可工作的系统。通常,这是一个相当复杂的过程,需要大量理解地理空间概念,以及如何编译必要的包装器并配置工具以在特定计算机上工作。

幸运的是,现在有一个更简单的方法将地理空间编程工具和技术集成到你的 Python 应用中。得益于免费可用的 QGIS 系统的发展,现在可以轻松安装一个完整的地理空间开发环境,你可以在 Python 代码中直接使用它。无论你选择将你的应用作为 QGIS 系统的插件来构建,还是使用 QGIS 作为外部库编写独立的地图应用,你都可以在代码中使用地理空间功能方面拥有完全的灵活性。

本书涵盖内容

第一章,QGIS 入门,展示了如何安装和运行 QGIS 应用,并介绍了 Python 与 QGIS 的三种主要使用方式。

第二章, QGIS Python 控制台,探讨了 QGIS Python 控制台窗口,并解释了它在构建您自己的自定义映射应用程序时的作用。它还让您尝到了使用 Python 和 QGIS 可以做什么,并提高了您对 QGIS 环境的信心和熟悉度。

第三章, 学习 QGIS Python API,介绍了可供 QGIS Python 开发者使用的 Python 库,并展示了如何使用这些库来处理地理空间数据,并基于您的地理空间数据创建有用和有趣的地图。

第四章, 创建 QGIS 插件,介绍了 QGIS 插件的概念,并解释了如何使用 Python 编写插件。我们深入探讨了插件的工作原理,以及如何创建一个有用的地理空间应用程序作为 QGIS 插件。我们还探讨了 QGIS 插件的可能性和局限性。

第五章, 在外部应用程序中使用 QGIS,完成了构建使用 QGIS Python 库的独立 Python 应用程序的过程。您将学习如何创建一个包装脚本以处理特定平台的依赖项,设计和构建一个简单但完整的独立映射应用程序,并了解基于 QGIS 的应用程序结构。在这个过程中,您将作为一个更熟练的 QGIS 程序员,从头开始构建自己的现成映射应用程序。

第六章, 掌握 QGIS Python API,再次深入 PyQGIS 库,探讨了该库的一些更高级的方面,以及使用 Python 与 QGIS 交互的各种技术。

第七章, 在 PyQGIS 应用程序中选择和编辑要素,探讨了使用 PyQGIS 构建的 Python 程序如何允许用户在地图界面中选择、添加、编辑和删除地理要素。

第八章, 使用 Python 和 QGIS 构建完整的映射应用程序,涵盖了设计和构建一个名为"ForestTrails"的完整现成映射应用程序的过程。您将设计该应用程序,实现整体用户界面,并为应用程序构建一个合适的高分辨率基础地图。

第九章,“完成 ForestTrails 应用程序”,通过实现各种地图编辑工具以及编写在地图上两点之间查找最短路径的功能,完成了“ForestTrails”地图应用程序的实现。

您需要为本书准备的东西

要跟随本书中的示例,您需要在您的计算机上安装以下软件:

  • QGIS 版本 2.2 或更高版本

  • Python 版本 2.6 或更高版本(但不包括 Python 3.x)

  • GDAL/OGR 版本 1.10 或更高版本

  • PyQt4 版本 4.10 或更高版本

  • 根据您的操作系统,您可能还需要安装 Qt 工具包,以便 PyQt 能够正常工作。

所有这些软件都可以免费下载,并且适用于 Mac OS X、MS Windows 和 Linux 计算机。

本书面向对象

本书面向有一定地图和地理空间概念知识的经验丰富的 Python 开发者。虽然必要的概念会在我们进行的过程中进行解释,但至少对投影、地理空间数据格式等内容有所了解将有所帮助。

习惯用法

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称按照以下方式显示:“这使用了我们之前设置的QGIS_PREFIX环境变量,以告诉 QGIS 其资源的位置。”

代码块按照以下方式设置:

app = QApplication(sys.argv)

viewer = MapViewer("/path/to/shapefile.shp")
viewer.show()

app.exec_()

当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:

  def unload(self):
    self.iface.removePluginMenu("Test Plugin", self.action)
 self.iface.removeToolBarIcon(self.action)

任何命令行输入或输出都按照以下方式编写:

export PYTHONPATH="$PYTHONPATH:/Applications/QGIS.app/Contents/Resources/python"

新术语重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“如果您尚未安装 QGIS,请点击主 QGIS 网页上的立即下载按钮以下载 QGIS 软件。”

注意

警告或重要注意事项以如下框的形式出现。

小贴士

小技巧和窍门如下所示。

读者反馈

我们的读者反馈总是受欢迎的。请告诉我们您对本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。

要发送给我们一般性的反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及本书的标题。

如果您在某个领域有专业知识,并且您有兴趣撰写或为本书做出贡献,请参阅我们的作者指南,网址为www.packtpub.com/authors

客户支持

现在您已经是 Packt 图书的骄傲拥有者,我们有一些东西可以帮助您从您的购买中获得最大价值。

下载示例代码

您可以从您在www.packtpub.com的账户下载所有已购买的 Packt 出版物的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

下载本书的彩色图像

我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/4664OS_ColorImages.pdf下载此文件。

错误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然会发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详情来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站或添加到该标题的错误部分下的现有错误列表中。

要查看之前提交的错误,请转到www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误部分下。

海盗行为

在互联网上对版权材料的盗版是所有媒体中持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供疑似盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

问题

如果您在这本书的任何方面遇到问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。

第一章. QGIS 入门

本章概述了 QGIS 系统,以及您如何使用 Python 编程语言与之交互。特别是,本章将涵盖以下内容:

  • 下载、安装和运行 QGIS

  • 熟悉 QGIS 应用程序

  • 在 QGIS 中使用 Python

  • 使用 Python 控制台作为 QGIS 环境的窗口

  • QGIS Python 插件的工作原理

  • 从外部 Python 程序与 QGIS Python API 交互

关于 QGIS

QGIS 是一个流行的、免费的、开源的 地理信息系统 (GIS),可在所有主要操作系统上运行。人们通常使用 QGIS 来查看、编辑和分析地理空间数据。然而,对于我们来说,QGIS 不仅仅是一个 GIS 系统;它还是一个地理空间编程环境,我们可以使用它来构建自己的地理空间应用程序,使用 Python 实现。

QGIS 拥有一个全面的网站 (qgis.org),这使得下载、安装和使用变得容易。

在继续阅读之前,您应该花 15 分钟浏览网站,熟悉应用程序和在线可用的文档。特别是,您应该查看 文档 页面,其中提供了三本重要的手册:QGIS 用户指南/手册QGIS 培训手册PyQGIS 烹饪书

QGIS 用户指南/手册 提供了深入的用户文档,您可能会发现它很有用。QGIS 培训手册 是基于 QGIS 的 GIS 系统和概念的详细介绍;如果您不熟悉地理空间数据和技巧,您可能会发现完成这门课程很有用。最后,PyQGIS 烹饪书 将成为您在开发基于 QGIS 的自己的映射应用程序时的必备参考。

安装和运行 QGIS

如果您尚未安装 QGIS,请点击 QGIS 主网页上的 立即下载 按钮,以下载 QGIS 软件。您接下来的操作取决于您在计算机上运行的操作系统:

  • 对于 MS Windows,您可以下载一个可双击的安装程序,该安装程序一次安装 QGIS 和所有必需的库。请确保您使用的是 OSGeo4W 安装程序,它包括 Python 解释器、QGIS 本身以及所有必需的库。

  • 对于 Mac OS X,您需要访问 Kyngchaos 网站 (www.kyngchaos.com/software/qgis) 下载并安装 GDAL 和 matplotlib 库,然后再安装为您的操作系统专门构建的 QGIS 版本。所有必需的软件包都可在 Kyngchaos 网站上找到。

  • 对于类 Unix 系统,您将使用包管理器从适当的软件仓库下载、编译和安装 QGIS 及所需库。有关在类 Unix 系统上安装的更多信息,请参阅qgis.org/en/site/forusers/alldownloads.html#linux

一旦您安装了 QGIS 系统,您就可以像在计算机上运行任何其他应用程序一样运行它,例如,通过双击您应用程序文件夹中的 QGIS 图标。

如果一切顺利,QGIS 应用程序将启动,您将看到以下窗口:

安装和运行 QGIS

注意

窗口的精确外观可能因您的操作系统而异。不必担心,只要出现一个类似于前一个屏幕截图的窗口,您就是在运行 QGIS。

您现在不必太担心 QGIS 用户界面;QGIS 用户指南详细描述了界面和各种选项。我们不如深入了解一下 QGIS 的工作原理。

理解 QGIS 概念

要理解 QGIS,您必须熟悉以下基本术语和概念:

  • QGIS 与从各种数据源加载的地理空间信息一起工作。这些数据源可以包括磁盘上的矢量数据和栅格数据文件、各种空间数据库,甚至提供来自互联网的地理空间数据的 Web 服务,如Web 地图服务(WMS)服务器。

  • 无论数据来自何处,它都由 QGIS 检索并以地图层的形式显示。地图层可以显示或隐藏,并且可以通过各种方式自定义,以影响数据在地图上的显示方式。

  • 然后将地图层组合并在地图上显示。

  • 最后,各种地图层、地图和其他设置共同构成了一个项目。QGIS 始终只有一个项目,并且正在处理该项目。项目包括所有地图层、地图显示选项和当前加载到 QGIS 中的各种设置。

这些概念以以下方式相关联:

理解 QGIS 概念

注意

注意数据源位于 QGIS 之外。虽然地图层引用数据源,但数据本身存储在其他地方,例如,磁盘上的文件或数据库中。

无论您何时使用 QGIS,您总是在当前项目中工作。您可以保存项目并在以后重新加载它们,或者开始一个新的项目以将 QGIS 重置到其原始状态。

链接 QGIS 和 Python

虽然 QGIS 本身是用 C++编写的,但它提供了广泛的 Python 编程支持。内置了一个 Python 解释器,可以通过 Python 控制台交互式使用,或运行用 Python 编写的插件。还有一个全面的 API,可以使用 Python 代码查询和控制 QGIS 应用程序。

您可以使用以下三种方式使用 Python 与 QGIS 系统一起工作:

  • Python 控制台:您可以打开这个控制台,它运行的是 QGIS 内置的交互式 Python 解释器,允许您输入命令并立即看到结果。

  • Python 插件:这些是为在 QGIS 环境中运行而设计的 Python 包。

  • 外部应用程序:你可以在自己的应用程序中使用 QGIS Python API。这让你可以使用 QGIS 作为地理空间处理引擎,甚至基于 QGIS 构建自己的交互式应用程序。

无论你如何使用 Python 和 QGIS,你都将大量使用 QGIS Python 库,这些库通常被称为 PyQGIS。它们为 QGIS 系统提供了一个完整的程序接口,包括将数据源加载到图层中、操作地图、导出地图可视化以及使用 QGIS 用户界面构建自定义应用程序的调用。虽然对 PyQGIS 库的深入探讨将不得不等到 第三章,即 学习 QGIS Python API,但我们将在下一节关于 Python 控制台的下一节中立即开始尝试。

在本章的剩余部分,我们将检查你可以使用 QGIS 和 Python 交互的三个方法。

探索 Python 控制台

可以通过在 插件 菜单中使用 Python 控制台 项来访问 QGIS Python 控制台窗口。当你选择此命令时,Python 控制台将出现在 QGIS 窗口的右下角。以下是在你首次打开它时 Python 控制台的外观:

探索 Python 控制台

虽然 Python 控制台是一个与现有 QGIS 项目交互的出色工具,但我们将用它从头开始创建一个新项目。不过,在我们这样做之前,我们需要为我们的 QGIS 项目下载一些地理空间数据源。

我们将需要一个合适的 底图 用于我们的项目,以及一些河流和城市信息,以便在底图上显示。让我们使用自然地球网站来获取所需的信息。转到 naturalearthdata.com 并点击 下载 选项卡。

首先,我们希望下载一个看起来很不错的底图。为此,在 中等比例数据,1:50m 部分下选择 栅格 链接,选择 自然地球 1 数据集,然后在 自然地球 I 带阴影和水的 标题下点击 下载小尺寸 链接。

接下来,我们需要一个叠加图层,它将在底图上显示湖泊和河流。要获取这些信息,回到 下载 选项卡,并在 中等比例数据,1:50m 部分下选择 物理 链接。你想要的数据集称为 Rivers, Lake Centerlines,因此点击 下载河流和湖泊中心线 链接以获取此文件。

最后,我们希望突出显示底图上的城市。回到 下载 页面,并在 中等比例数据,1:50m 标题下选择 文化 链接。底部有一个标记为 城市区域 的部分。点击 下载城市区域 链接以下载此文件。

一旦完成所有这些,你应该有以下三个文件:

  • 一个名为 NE1_50M_SR_W.zip 的栅格底图

  • 文件名为 ne_50m_rivers_lake_centerlines.zip 的湖泊和河流矢量数据

  • 文件名为 ne_50m_urban_areas.zip 的城市区域矢量数据

由于这些是 ZIP 存档,你需要解压缩这些文件并将它们存储在硬盘上方便的位置。

小贴士

你需要输入这些数据集的完整路径,因此你可能希望将它们放在一个方便的位置,例如你的家目录或用户目录中。这样,你输入的路径就不会太长。

现在我们有了数据,让我们使用 QGIS Python 控制台将此数据导入到项目中。如果你已经将一些数据加载到 QGIS 中(例如,通过遵循 QGIS 用户指南中的教程),请从 项目 菜单中选择 新建 选项,以使用空白项目重新开始。然后,在 QGIS Python 控制台中输入以下内容:

layer1 = iface.addRasterLayer("/path/to/NE1_50M_SR_W/NE1_50M_SR_W.tif", "basemap")

确保将 /path/to/ 替换为你下载的 NE1_50M_SR_W 目录的完整路径。假设你输入了正确的路径,自然地球 1 基础地图应该出现在 QGIS 窗口中:

探索 Python 控制台

如你所见,我们的基础地图现在有点小。你可以使用窗口顶部的工具栏中的各种平移和缩放命令将其放大,但让我们使用 Python 来做同样的事情:

iface.zoomFull()

这将扩展基础地图以填充整个窗口。

现在我们有了基础地图,让我们将我们的两个矢量图层添加到项目中。为此,请输入以下内容:

layer2 = iface.addVectorLayer("/path/to/ne_50m_urban_areas/ne_50m_urban_areas.shp", "urban", "ogr")

再次确认,将 /path/to/ 替换为你之前下载的 ne_50m_urban_areas 目录的完整路径。城市区域形状文件将被加载到 QGIS 项目中,并作为一系列彩色区域出现在基础地图上。让我们放大到加利福尼亚的一个区域,以便我们可以更清楚地看到它。为此,请在 Python 控制台窗口中输入以下命令:

iface.mapCanvas().setExtent(QgsRectangle(-125, 31, -113, 38))
iface.mapCanvas().refresh()

这将放大地图,以便加利福尼亚的一个区域,包括洛杉矶和旧金山的南部,现在显示在地图上:

探索 Python 控制台

最后,让我们将河流和湖泊数据添加到我们的项目中。为此,请在 Python 控制台中输入以下内容:

layer3 = iface.addVectorLayer("/path/to/ne_50m_rivers_lake_centerlines/ne_50m_rivers_lake_centerlines.shp", "water", "ogr")

如果你查看地图,你会看到河流和湖泊现在可见。然而,它们是以默认的绿色绘制的。让我们将其改为蓝色,以便水现在是蓝色:

from PyQt4.QtGui import QColor
layer3.rendererV2().symbols()[0].setColor(QColor("#4040FF"))
iface.mapCanvas().refresh()

这段代码可能有点令人困惑,但别担心——我们将在 第三章 中学习关于渲染器和符号的内容,学习 QGIS Python API

现在我们已经完成,你可以使用 项目 菜单中的 另存为... 项保存你的项目。正如你所见,使用 Python 设置和自定义 QGIS 项目是完全可能的。

检查 Python 插件

虽然 Python 控制台是一个交互式编码的绝佳工具,但如果你想使用 Python 来扩展 QGIS 的功能,它就不是很实用了。这就是 QGIS 插件发挥作用的地方;你可以创建(或下载)一个插件,添加新功能或改变 QGIS 的工作方式。

由于 QGIS 是使用 Qt 框架编写的,QGIS 插件利用了 Qt 中的 Python 绑定,这些绑定被称为 PyQt。当我们开始在 第四章 中构建自己的插件时,我们将下载并安装 PyQt 和相关工具,创建 QGIS 插件

为了了解 Python 插件是如何工作的,让我们看看 缩放到点 插件。正如其名所示,此插件允许你缩放以显示地图上的给定坐标。它也是用 Python 编写的,是学习插件的一般方便示例。

在我们能够使用它之前,我们必须安装这个插件。从 插件 菜单中选择 管理和安装插件... 项,然后点击 未安装 选项卡。你应该会在可用插件的列表底部看到 缩放到点;点击此插件,然后点击 安装插件 按钮来下载并安装它。

让我们运行这个插件来看看它是如何工作的;在之前创建的项目仍然加载的情况下,点击工具栏中的 缩放到点 插件图标,它看起来像这样:

检查 Python 插件

尝试输入你当前位置的经纬度(如果你不知道,你可能需要 itouchmap.com/latlong.html 的帮助)。你应该能看到你当前位置的基础地图、城市区域和水道。

小贴士

不要忘记 x 等于经度,y 等于纬度。很容易弄错它们的位置。

现在我们已经知道了插件的功能,让我们看看它是如何工作的。下载的插件存储在你用户或主目录中名为 .qgis2 的隐藏目录中。使用你喜欢的文件管理器进入这个隐藏目录(对于 Mac OS X,你可以在 Finder 的 Go 菜单中使用 Go to Folder... 项),然后找到 python/plugins 子目录。Python 插件就是存储在这里。

小贴士

根据你的操作系统和使用的 QGIS 版本,这个隐藏目录的名称可能不同。如果你找不到它,寻找名为 .qgis.qgis2 或类似名称的目录。

你应该会看到一个名为 zoomtopoint 的目录(该目录的完整路径将是 ~/.qgis2/python/plugins/zoomtopoint)。在这个目录中,你会找到组成缩放到点插件的各个文件:

检查 Python 插件

让我们看看这些各种文件的作用:

文件名 用途
__init__.py 这是一个标准的 Python 包初始化文件。此文件还初始化插件并使其可供 QGIS 系统使用。
COPYING 这是一个 GNU 通用公共许可证 (GPL) 的副本。由于 Zoom to Point 插件通常是可用的,这定义了它可以在哪种许可下使用。
icon.png 如其名所示,这是插件的工具栏图标。
Makefile 这是一个标准的 *nix Makefile,用于自动化插件的编译和部署过程。
metadata.txt 这个文件包含了插件的元数据,包括插件的完整名称、描述、当前版本号等。
resources.qrc 这是一个 Qt 资源文件,定义了插件使用的各种资源,如图像和声音文件。
resources.py 这表示 resources.qrc 文件的内容被编译成一个 Python 模块。
ui_zoomtopoint.ui 这是一个 Qt 用户界面模板,定义了插件的主要 UI。
ui_zoomtopoint.py 这表示 ui_zoomtopoint.ui 文件的内容被编译成一个 Python 模块。
zoomtopoint.py 这个文件包含了插件的主要 Python 代码。
zoomtopointdialog.ui 这是一个 ui_zoomtopoint.ui 文件的副本。看起来这个文件是意外包含进来的,因为插件可以在没有它的情况下运行。
zoomtopointdialog.py 这个 Python 模块定义了一个 QtGui.QDialog 子类,它从 ui_zoomtopoint.py 加载对话框的内容。

在你喜欢的文本编辑器中打开 zoomtopoint.py 模块。正如你所看到的,它包含了插件的主要 Python 代码,形式为一个 ZoomToPoint 类。这个类具有以下基本结构:

class ZoomToPoint:
    def __init__(self, iface):
        self.iface = iface

    def initGui(self):
        ...

    def unload(self):
        ...

    def run(self):
        ...

如果你打开 __init__.py 模块,你会看到这个类是如何用来定义插件行为的:

def classFactory(iface): 
      from zoomtopoint import ZoomToPoint 
      return ZoomToPoint(iface) 

当插件被加载时,一个名为 iface 的参数传递给 ClassFactory 函数。这个参数是 QgsInterface 的一个实例,它提供了访问正在运行的 QGIS 应用程序各个部分的能力。正如你所看到的,类工厂创建了一个 ZoomToPoint 对象,并将 iface 参数传递给初始化器,以便 ZoomToPoint 可以使用它。

注意在 Zoomtopoint.py 模块中的 ZoomToPoint.__init__(),如何将 iface 参数存储在一个实例变量中,这样其他方法就可以通过 self.iface 来引用 QGIS 接口。例如:

def __init__(self, iface):
    self.iface = iface

def initGui(self):
    ...
    self.iface.addPluginToMenu("&Zoom to point...", self.action)

这使得插件能够与 QGIS 用户界面进行交互和操作。

ZoomToPoint 类定义的四个方法都非常直接:

  • __init__(): 这个方法初始化一个新的 ZoomToPoint 对象。

  • initGui(): 这个方法初始化插件的用户界面,准备使用。

  • unload(): 这个方法从 QGIS 用户界面中移除插件。

  • run(): 当插件被激活时调用这个方法,即当用户在工具栏中点击插件的图标,或从插件菜单中选择插件时。

不要过于担心这里的所有细节;我们将在后面的章节中查看初始化和卸载插件的过程。现在,更仔细地看看 run() 方法。这个方法本质上看起来如下:

def run(self):
    dlg = ZoomToPointDialog()
    ...
    dlg.show()
    result = dlg.exec_()
    if result == 1:
        x = dlg.ui.xCoord.text()
        y = dlg.ui.yCoord.text()
        scale = dlg.ui.spinBoxScale.value()
        rect = QgsRectangle(float(x) – scale,
                            float(y) - scale,
                            float(x) + scale,
                            float(y) + scale)
        mc=self.iface.mapCanvas() 
        mc.setExtent(rect)
        mc.refresh()
        ...

我们已经排除了记住用户之前输入的值并将这些值在插件运行时复制回对话框的代码。查看之前的代码,逻辑似乎相当简单,解释如下:

  • 创建一个 ZoomToPointDialog 对象。

  • 向用户显示对话框。

  • 如果用户点击 确定 按钮,提取输入的值,使用这些值创建一个新的边界矩形,并将地图的范围设置为这个矩形。

虽然这个插件相当简单直接,实际代码并没有做很多,但它是一个有用的示例,说明了 Python 插件应该是什么样子,以及 Python 插件所需的各个文件。特别是,你应该注意以下几点:

  • 插件只是一个包含 Python 包初始化文件 (__init__.py)、一些 Python 模块以及其他使用 Qt Designer 创建的文件的目录。

  • __init__.py 模块必须定义一个名为 ClassFactory 的顶级函数,该函数接受一个 iface 参数并返回一个代表插件的对象。

  • 插件对象必须定义一个 initGui() 方法,该方法用于初始化插件的用户界面,以及一个 unload() 方法,该方法用于从 QGIS 应用程序中移除插件。

  • 插件可以通过传递给类工厂的 iface 对象与 QGIS 应用程序交互并操作。

  • resources.qrc 文件列出了各种资源,如图片,这些资源由插件使用。

  • 使用 PyQt 命令行工具将 resources.qrc 文件编译成 resources.py 文件。

  • 对话框和其他窗口是通过 Qt Designer 模板创建的,这些模板通常存储在名为 ui_Foo.ui 的文件中。

  • 然后将 UI 模板文件编译成 Python 代码,使用 PyQt 命令行工具。如果模板命名为 ui_foo.ui,则相关的 Python 模块将被命名为 ui_foo.py

  • 一旦定义了对话框的用户界面,你将创建 QtGui.QDialog 的一个子类,并将该用户界面模块加载到其中。这根据你的模板定义了对话框的内容。

  • 你的插件可以按要求显示对话框,提取输入的值,并使用这些结果通过 iface 变量与 QGIS 交互。

插件是扩展和定制 QGIS 的有用方式。我们将在 第四章 中回到 QGIS 插件的主题,我们将从头开始创建自己的插件。

编写外部应用程序

与 Python 和 QGIS 一起工作的最终方式是编写一个完全独立的 Python 程序,该程序导入 QGIS 库并直接与之交互。在许多方面,这是编写您自己的自定义地图应用程序的理想方式,因为您的程序不需要在现有的 QGIS 用户界面中运行。然而,当您尝试以这种方式使用 Python 和 QGIS 时,有一些事情您需要注意:

  1. 您的 Python 程序需要在运行之前能够找到 QGIS Python 库。由于这些库捆绑在 QGIS 应用程序本身中,您需要将 PyQGIS 库安装的目录添加到您的 Python 路径中。

  2. 您还需要告诉 PyQGIS 库 QGIS 应用程序资源存储的位置。

  3. 由于应用程序是在 QGIS 应用程序外部运行的,因此您将无法访问 iface 变量。您也不能使用假设您在 QGIS 内部运行的 PyQGIS 库的某些部分。

虽然这些都不是很繁重,但它们可能会在您第一次尝试从外部 Python 代码访问 PyQGIS 时让您感到困惑。让我们看看在编写您自己的 Python 程序时如何避免这些陷阱。

首先,为了使您的程序能够访问 PyQGIS 库,您需要在导入任何 QGIS 包之前修改您的 Python 路径(以及可能的一些其他环境变量)。对于 MS Windows,您可以在命令行中运行以下操作:

SET OSGEO4W_ROOT=C:\OSGeo4W
SET QGIS_PREFIX=%OSGEO4W_ROOT%\apps\qgis
SET PATH=%PATH%;%QGIS_PREFIX%\bin
SET PYTHONPATH=%QGIS_PREFIX%\python;%PYTHONPATH%

如果你正在运行 Mac OS X,以下命令将为你设置 Python 路径:

export PYTHONPATH="$PYTHONPATH:/Applications/QGIS.app/Contents/Resources/python"
export DYLD_FRAMEWORK_PATH="/Applications/QGIS.app/Contents/Frameworks"
export QGIS_PREFIX="/Applications/QGIS.app/Contents/Resources"

对于运行 Linux 版本的计算机,您可以使用以下命令:

export PYTHONPATH="/path/to/qgis/build/output/python/"
export LD_LIBRARY_PATH="/path/to/qgis/build/output/lib/"
export QGIS_PREFIX="/path/to/qgis/build/output/"

注意

显然,您需要将 /path/to/qgis 替换为您 QGIS 安装的实际路径。

如果您将 QGIS 安装在非标准位置,您可能需要修改这些命令才能使其生效。要检查它们是否已生效,请启动 Python 解释器并输入以下命令:

>>> import qgis

如果一切顺利,您将简单地看到 Python 提示符:

>>> 

另一方面,您可能会看到以下错误:

ImportError: No module named qgis

在这种情况下,PYTHONPATH 变量尚未正确设置,您将不得不检查您之前输入的设置此环境变量的命令,并可能对其进行修改以允许非标准位置的 QGIS 库。

注意

注意,在某些情况下,这还不够,因为 Python 库只是底层 C++ 库的包装器;您可能还需要告诉您的计算机在哪里可以找到这些 C++ 库。为了检查这是否是一个问题,您可以尝试以下操作:

import qgis.core

您可能会遇到一个看起来像这样的错误:

ImportError: libqgis_core.so.1.5.0: cannot open shared object file: No such file or directory

您将不得不告诉您的计算机在哪里可以找到底层共享库。我们将在查看编写我们自己的外部应用程序时返回这个问题;如果您想查看详细信息,请跳转到第五章,在外部应用程序中使用 QGIS

路径设置好后,你现在可以导入你想要使用的 PyQGIS 库的各个部分,例如:

from qgis.core import *

现在我们已经可以访问 PyQGIS 库了,我们的下一个任务是要初始化这些库。如前所述,我们必须告诉 PyQGIS 它可以找到各种 QGIS 资源。我们使用 QgsApplication.setPrefixPath() 函数来做这件事,如下所示:

import os
QgsApplication.setPrefixPath(os.environ['QGIS_PREFIX'], True)

这使用我们之前设置的 QGIS_PREFIX 环境变量来告诉 QGIS 它的资源在哪里。完成这个步骤后,你可以通过以下调用初始化 PyQGIS 库:

QgsApplication.initQgis()

现在,我们可以使用 PyQGIS 在我们的应用程序中做我们想做的任何事情。当我们的程序退出时,我们还需要通知 PyQGIS 库我们正在退出:

QgsApplication.exitQgis()

将所有这些放在一起,我们的最小 Python 应用程序看起来像这样:

import os
from qgis.core import *

QgsApplication.setPrefixPath(os.environ['QGIS_PREFIX'], True)
QgsApplication.initQgis()

# ...

QgsApplication.exitQgis()

当然,这个应用程序目前还没有做任何有用的事情——它只是启动并关闭 PyQGIS 库。所以让我们用一些有用的代码替换掉 "..." 行,以显示一个基本的地图小部件。为此,我们需要定义一个 QMainWindow 子类,它显示地图小部件,然后创建并使用一个 QApplication 对象来显示这个窗口,并在应用程序运行时处理各种用户界面事件。

注意

QMainWindowQApplication 都是 PyQt 类。在我们使用 QGIS 和 Python 开发自己的外部应用程序时,我们将广泛使用各种 PyQt 类。

让我们先替换掉 "..." 行,用以下代码替换,该代码显示地图查看器,然后运行应用程序的主事件循环:

app = QApplication(sys.argv)

viewer = MapViewer("/path/to/shapefile.shp")
viewer.show()

app.exec_()

如你所见,创建并显示了一个 MapViewer 实例(我们很快就会定义它),并通过调用 exec_() 方法运行 QApplication 对象。为了简单起见,我们传递了一个 shapefile 的名称,在地图查看器中显示。

运行此代码将导致地图查看器显示,并且应用程序将运行,直到用户关闭窗口或从菜单中选择 退出 命令。

现在,让我们定义 MapViewer 类。以下是类的定义看起来像这样:

class MapViewer(QMainWindow):
    def __init__(self, shapefile):
        QMainWindow.__init__(self)
        self.setWindowTitle("Map Viewer")

        canvas = QgsMapCanvas()
        canvas.useImageToRender(False)
        canvas.setCanvasColor(Qt.white)
        canvas.show()

        layer = QgsVectorLayer(shapefile, "layer1", "ogr")
        if not layer.isValid():
            raise IOError("Invalid shapefile")

        QgsMapLayerRegistry.instance().addMapLayer(layer)
        canvas.setExtent(layer.extent())
        canvas.setLayerSet([QgsMapCanvasLayer(layer)])

        layout = QVBoxLayout()
        layout.addWidget(canvas)

        contents = QWidget()
        contents.setLayout(layout)
        self.setCentralWidget(contents)

不要过于担心这个类的细节;我们基本上只是创建一个窗口,并在其中放置一个 QgsMapCanvas 对象。然后我们创建一个地图图层(QgsVectorLayer 的实例)并将其添加到地图画布上。最后,我们将画布添加到窗口的内容中。

注意到 QgsMapCanvasQgsVectorLayer 都是 PyQGIS 的一部分,而 QMainWindowQVBoxLayoutQWidget 都是 PyQt 类。这个应用程序在 PyQt 应用程序中使用 PyQGIS 类,混合了两个来源的类。这是可能的,因为 QGIS 是使用 Qt 构建的,而各种 PyQGIS 类都是基于 PyQt 的。

要将前面的代码转换成一个可工作的应用程序,我们只需要在模块顶部添加一些额外的 import 语句:

import sys
from PyQt4.QtGui import *
from PyQt4.QtCore import Qt

提示

下载示例代码

您可以从www.packtpub.com下载您购买的所有 Packt Publishing 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便直接将文件通过电子邮件发送给您。

如果您运行此应用程序,地图查看器将显示,显示由代码引用的 shapefile 内容。例如:

编写外部应用程序

这个应用程序仍然有点丑陋——您可以看到地图顶部和底部有空白,因为它们没有考虑到地图数据的长宽比。此外,也没有放大或滚动地图的功能。然而,这些功能可以很容易地添加,如您所见,创建基于 QGIS 的独立地图应用程序并不困难。

摘要

在本章中,我们熟悉了 QGIS 以及它作为 Python 地理空间开发系统可以使用的各种方式。我们安装并探索了 QGIS 应用程序本身,然后查看 Python 如何与 QGIS 一起使用。我们看到了 QGIS 如何使用数据源、地图图层、地图和项目来组织和处理地理空间数据。接下来,我们检查了您可以使用 Python 和 QGIS 的三种方式:通过在 Python 控制台中输入命令、编写 Python 插件或编写利用 QGIS Python API 的外部应用程序。

我们然后查看与 QGIS 一起提供的广泛的 Python 库集,称为 PyQGIS,您可以使用它进行地理空间开发。我们看到了如何使用 QGIS Python 控制台直接操作 QGIS 项目,添加图层,放大缩小,更改选项等。

接下来,我们下载并检查了一个 QGIS Python 插件。在这个过程中,我们了解到 QGIS 插件只是安装在您家目录或用户目录中名为.qgis2(或.qgis)的隐藏目录中的 Python 包。插件利用 Qt 库来定义和构建资源,例如用户界面模板。

最后,我们看到了如何编写外部 Python 应用程序,这些应用程序可以从 QGIS 系统中加载 PyQGIS 库,然后在更大的 PyQt 应用程序中使用这些库。

在下一章中,我们将更详细地探讨 QGIS Python 控制台,并使用它来熟悉 PyQGIS 库,同时看看我们如何在我们的 Python 地理空间开发项目中使用它。

第二章 QGIS Python 控制台

在本章中,我们将探讨您可以使用 QGIS Python 控制台作为地理空间开发工具的方法。我们还将使用控制台作为窥视镜来检查 QGIS 编程的世界。特别是,我们将学习以下内容:

  • 探索控制台可以用来开发和执行 Python 代码的方法

  • 学习如何使用控制台的内置源代码编辑器编写 Python 脚本

  • 发现各种技巧和技术来使用 QGIS 控制台

  • 想出如何使用 Python 命令在 QGIS 中操作当前项目

  • 使用控制台访问地理空间数据并执行地理空间计算

  • 在我们的 Python 程序中使用各种 QGIS 用户界面元素

使用控制台

虽然您在上一章中已经短暂使用过 QGIS 控制台,但详细检查 QGIS 控制台窗口是值得的,这样您就会了解可用的各种功能。

如果您还没有打开它,请从插件菜单中选择Python 控制台项以打开控制台。以下截图显示了控制台窗口的各个部分:

使用控制台

让我们更详细地看看这些不同的部分:

  • 清除控制台按钮会清除解释器日志的内容

  • 导入类弹出窗口包含导入一些常用 PyQGIS 类的快捷方式使用控制台

    这些相当于输入import Processingfrom PyQt4.QtCore import *from PyQt4.QtGui import *

  • 运行命令按钮简单地执行在 Python Shell 字段中输入的命令

    注意

    当然,您也可以通过按回车键来运行输入的命令,所以这个命令只有在您真的想使用鼠标运行命令时才有用。

  • 显示编辑器按钮用于显示或隐藏内置的源代码编辑器。我们稍后会查看这一点

  • 设置按钮显示控制台的设置窗口,允许您自定义控制台的外观和行为

  • 帮助按钮会弹出内置的帮助查看器页面,其中包含有关如何使用控制台的有用信息

  • Python Shell字段是您输入 Python 命令和其他输入的地方

  • 解释器日志显示了您输入的命令和 Python 解释器的输出的完整历史记录

正如我们已经看到的,您可以在 shell 中输入 Python 命令并按回车键来执行它们。您输入的命令以及 Python 解释器的输出将出现在解释器日志中。

Python Shell 被设计成使与 Python 的交互式工作更加容易。以下是目前支持的功能:

  • 按下上箭头和下箭头键可以在命令历史记录中移动,这使得重新输入之前输入的 Python 命令变得容易。

  • 您可以通过按Ctrl + Shift + Space(在 Mac 上为command + Shift + Space)来显示之前输入的命令列表。

  • 如果你选中了解释器日志中的某些文本,你可以使用输入选中命令将文本移动到 shell 中并执行它。此命令在控制台的弹出菜单中可用,或者可以通过按 Ctrl + E(如果你正在运行 Mac OS X,则为 command + E)来访问。

  • Python Shell 支持自动完成。当你输入时,会出现一个弹出菜单,显示 PyQGIS 和 PyQt API 中的匹配类、函数和方法名称。然后你可以按上箭头和下箭头键选择你想要的确切名称,并按 Tab 键来选择它。

  • 当你输入一个开括号时,控制台会自动为你输入闭括号。如果你希望关闭这个功能,可以通过使用设置窗口来实现。

  • 当你输入 from XXX 时,控制台会自动为你输入单词 import。同样,你可以在设置窗口中关闭这个功能,如果你不喜欢这种行为。

  • 当你为函数或方法输入开括号时,该函数或方法的 C++ 签名将显示出来。尽管它是 C++ 格式,但这告诉你期望的参数和返回值的类型。

  • 你可以在 shell 中输入 _api;你的网络浏览器将打开 PyQGIS API 参考文档。同样,如果你输入 _pyqgis,你的网络浏览器将显示 PyQGIS 开发者手册。

虽然在 Python Shell 中输入命令是探索 QGIS Python 库的有用方法,并且对于一次性命令来说很好用,但如果需要输入多行 Python 文本或者反复输入相同的命令集,很快就会变得乏味。毕竟,这就是我们为什么将 Python 代码存储在 .py 文件中并执行它们,而不是直接在 Python 命令行界面中输入所有内容的原因。

QGIS 控制台自带编辑器,允许你在控制台中直接编写 Python 脚本并执行它们。让我们快速看看这是如何工作的。

在 QGIS 控制台打开的情况下,点击显示编辑器图标(使用控制台)。控制台窗口将分为两部分,Python 源代码编辑器现在占据了窗口的右侧:

使用控制台

不同的工具栏图标提供了标准的编辑行为,例如加载和保存文件、复制粘贴文本、检查语法以及执行你的脚本:

使用控制台

你可能需要记住前三个图标,因为目前还没有快捷键可以用来打开和保存 Python 脚本。

让我们使用控制台编辑器创建一个简单的 Python 程序并运行它。在加载了 QGIS 项目后,将以下内容输入到编辑器中:

for layer in iface.legendInterface().layers():
    print layer.name() 

如您可能猜到的,此程序会打印出当前项目中各种图层的名称。要运行此程序,请通过点击 另存为... 工具栏图标保存它;然后,要么点击 运行脚本 工具栏图标 使用控制台,要么输入键盘快捷键,Ctrl + Shift + E(在 Mac 上是 command + Shift + E)。您应该在解释器日志中看到如下内容:

>>> execfile(u'/.../tmp1NR24f.py'.encode('utf-8'))
water
urban
basemap

注意,QGIS 使用 execfile() 函数(它是 Python 标准库的一部分)来执行您的脚本。

小贴士

如果您的程序没有显示任何图层的名称,请确保您已加载了一个至少包含一个图层的项目。在这个例子中,我们使用了我们在上一章中创建的示例项目,其中包含三个图层。

当然,我们可以用 QGIS 控制台及其内置的 Python 编辑器做更多的事情,我们很快就会用它来做一些有用的工作。在我们这样做之前,还有两件关于 QGIS 控制台的事情您应该知道。

首先,控制台本身是用 PyQt 和 PyQScintilla2 编辑器用 Python 编写的。您可以通过查看控制台的源代码来了解 QGIS 的实现方式,控制台的源代码可在 github.com/qgis/QGIS/tree/master/python/console 找到。

您应该知道的第二件事是,控制台作为 Qt “可停靠”窗口实现;也就是说,它可以被拖动到主 QGIS 窗口内的一个面板中。如果您点击并按住控制台的标题栏,您可以将其拖入主窗口,如下面的插图所示:

使用控制台

控制台可以被移动到 QGIS 窗口中的任何现有面板中,并且它将停留在那里,直到您将其移出。

要将控制台再次转换为窗口,请点击标题栏并将其拖出 QGIS 窗口。或者,您可以双击控制台的标题栏,在作为独立窗口或停靠面板之间切换。

如果您在小型屏幕上工作,这种停靠行为可能会让人烦恼,因为在移动控制台窗口以查看其下方内容时,您可能会意外地将控制台窗口停靠。幸运的是,由于 QGIS 控制台是用 PyQt 实现的,您可以通过运行以下 Python 代码轻松禁用此功能:

from console import console
from PyQt4.QtCore import Qt
console._console.setAllowedAreas(Qt.DockWidgetAreas(Qt.NoDockWidgetArea))

如果你想,你可以创建一个启动脚本,这样每次 QGIS 启动时都会自动显示控制台,并使其不可停靠。启动脚本存储在你用户或主文件夹中的一个隐藏目录中。使用你的文件管理器,在你的用户或主目录中查找名为 .qgis2 的隐藏目录(或 .qgis,具体取决于你运行的 QGIS 版本)(对于 Mac OS X,你可以在 Finder 的 Go 菜单中使用 Go to Folder... 项)。在这个目录内,将有一个名为 python 的子目录。在 python 目录内,创建一个名为 startup.py 的文件,并将以下内容放入此文件中:

from console import console
from PyQt4.QtCore import Qt
console.show_console()
console._console.setAllowedAreas(Qt.DockWidgetAreas(Qt.NoDockWidgetArea))

如你所见,我们唯一改变的是添加了对 console.show_console() 的调用,以便在 QGIS 启动时打开控制台窗口。

注意

如果控制台当前已停靠,此脚本不会将其取消停靠,尽管它会防止你意外再次停靠控制台。

在控制台中处理地理空间数据

到目前为止,我们已将 QGIS 控制台用作一个华丽的 Python 解释器,运行标准 Python 程序并操作 QGIS 用户界面。但 QGIS 是一个地理信息系统(GIS),GIS 的主要用途之一是操作和查询地理空间数据。因此,让我们编写一些 Python 代码,以便在 QGIS 控制台中直接处理地理空间数据。

在上一章中,我们使用 Python 将三个 shapefile 加载到 QGIS 项目中。以下是我们将 shapefile 加载到 QGIS 地图层中使用的典型指令:

layer = iface.addVectorLayer("/path/to/shapefile.shp", "layer_name", "ogr")

虽然这在你想以编程方式创建 QGIS 项目时很有用,但你可能只想加载一个 shapefile,以便分析其内容,而不将数据放入地图层。为此,我们必须获取适当的数据提供者,并要求它打开 shapefile,如下所示:

registry = QgsProviderRegistry.instance()
provider = registry.provider("ogr","/path/to/shapefile.shp")
if not provider.isValid():
    print "Invalid shapefile."
    return

如果 shapefile 无法加载,isValid() 方法将返回 False;这允许我们在出现错误时优雅地失败。

一旦我们有了数据提供者,我们可以要求它提供用于存储 shapefile 每个特征的属性值的字段列表:

for field in provider.fields():
      print field.name(), field.typeName()

我们还可以使用 QgsFeatureRequest 对象扫描 shapefile 内的特征。例如:

for feature in provider.getFeatures(QgsFeatureRequest()):
    print feature.attribute("name")

当然,这仅仅是对使用 QGIS 库查询和操作地理空间数据所能做到的一小部分。然而,让我们利用我们所学的知识来构建一个简单的程序,该程序可以计算并显示 shapefile 内容的信息。Shapefiles 包含地理空间特征,如多边形、线和点,每个特征可以与任何数量的属性相关联。我们将编写一个程序,打开并扫描 shapefile,识别特征并计算每条线特征的长度和每个多边形特征的面积。我们还将计算所有特征的总长度和面积。

我们将面临的挑战之一是 shapefile 可以是任何地图投影。这意味着我们的面积和长度计算必须考虑地图投影;例如,如果我们简单地在一个使用 EPSG 4326 投影(即经纬度坐标)的 shapefile 中计算一个要素的线性长度,那么计算出的长度将是纬度和经度的度数——这是一个完全没有意义的数字。我们希望以千米为单位计算要素长度,以平方千米为单位计算面积。这是可能的,但需要我们做更多的工作。

让我们开始编写我们的程序。首先创建一个新的 Python 脚本,并输入以下内容:

from PyQt4.QtGui import *

为了使程序更容易使用,我们将定义一个函数并将所有程序逻辑放在这个函数中,如下所示:

def analyze_shapefile():
    ...

analyze_shapefile()

现在,让我们开始编写analyze_shapefile()函数的内容。到目前为止,我们一直在硬编码 shapefile 的名称,但这次,让我们使用 QGIS 的图形界面提示用户选择一个 shapefile:

def analyze_shapefile():
    filename = QFileDialog.getOpenFileName(iface.mainWindow(),
                                           "Select Shapefile",
                                           "~", '*.shp')
    if not filename:
        print "Cancelled."
        return

然后,我们可以打开选定的 shapefile:

    registry = QgsProviderRegistry.instance()
    provider = registry.provider("ogr",filename)
    if not provider.isValid():
        print "Invalid shapefile."
        return

为了识别一个要素,我们需要为该要素显示一个有意义的标签。为此,我们将寻找一个看起来可能的名字的属性。如果没有合适的属性,我们不得不使用要素的 ID。

首先,让我们构建一个包含在这个 shapefile 中存储的各种属性的列表:

    attr_names = []
    for field in provider.fields():
        attr_names.append(field.name())

现在,我们已经准备好开始扫描 shapefile 的要素。在我们这样做之前,让我们初始化一些变量来保存我们需要计算的总量:

    tot_length = 0
    tot_area = 0

我们还需要设置一个QgsDistanceArea对象来为我们进行距离和面积计算。

    crs = provider.crs()
    calculator = QgsDistanceArea()
    calculator.setSourceCrs(crs)
    calculator.setEllipsoid(crs.ellipsoidAcronym())
    calculator.setEllipsoidalMode(crs.geographicFlag())

我们将使用此对象来计算 shapefile 要素的真实长度和面积,分别以米和平方米为单位。

现在,我们已经准备好扫描 shapefile 的内容,依次处理每个要素:

    for feature in provider.getFeatures(QgsFeatureRequest()):
        ...

对于每个要素,我们想要计算一个标签来标识该要素。我们将通过寻找名为"name""NAME""Name"的属性,并使用该属性的值作为要素标签来完成此操作。如果没有具有这些字段名称之一的属性,我们将回退到使用要素的 ID。以下是相关代码:

        if "name" in attr_names:
            feature_label = feature.attribute("name")
        elif "Name" in attr_names:
            feature_label = feature.attribute("Name")
        elif "NAME" in attr_names:
            feature_label = feature.attribute("NAME")
        else:
            feature_label = str(feature.id())

接下来,我们需要获取与要素相关联的几何对象。几何对象代表一个多边形、线或点。获取要素底层几何对象的引用很简单:

        geometry = feature.geometry()

现在,我们可以使用我们之前初始化的QgsDistanceArea计算器来计算线要素的长度和多边形要素的面积。为此,我们首先必须确定我们正在处理要素的类型:

        if geometry.type() == QGis.Line:
            ...
        elif geometry.type() == QGis.Polygon:
            ...
        else:
            ...

对于线几何形状,我们将计算线的长度并更新总长度:

        if geometry.type() == QGis.Line:
            length = int(calculator.measure (geometry) / 1000)
            tot_length = tot_length + length
            feature_info = "line of length %d kilometers" % length

对于多边形几何形状,我们将计算多边形的面积并更新总面积:

        elif geometry.type() == QGis.Polygon:
            area = int(calculator.measure (geometry) / 1000000)
            tot_area = tot_area + area
            feature_info = "polygon of area %d square kilometers" % area

最后,对于其他类型的几何形状,我们只需显示几何形状的类型:

        else:
            geom_type = qgis.vectorGeometryType(geometry.type())
            feature_info = "geometry of type %s" % geom_type

现在我们已经完成了这些计算,我们可以显示要素的标签以及我们为此要素计算的信息:

        print "%s: %s" % (feature_label, feature_info)

最后,当我们完成对要素的迭代后,我们可以显示该 shapefile 中所有要素的总行长度和多边形面积:

    print "Total length of all line features: %d" % tot_length
    print "Total area of all polygon features: %d" % tot_area

这完成了我们分析 shapefile 内容的程序。此程序的完整源代码可在本书提供的代码示例中找到。要测试我们的程序,请在控制台的脚本编辑器中键入或复制粘贴,保存文件,然后单击运行脚本按钮(或按Ctrl + Shift + E)。以下是程序输出的示例:

Antigua and Barbuda: polygon of area 549 square kilometers
Algeria: polygon of area 2334789 square kilometers
Azerbaijan: polygon of area 86109 square kilometers
Albania: polygon of area 28728 square kilometers
Armenia: polygon of area 29732 square kilometers
...
Jersey: polygon of area 124 square kilometers
South Georgia South Sandwich Islands: polygon of area 3876 square kilometers
Taiwan: polygon of area 36697 square kilometers
Total length of all line features: 0
Total area of all polygon features: 147363163

小贴士

此输出是使用可在thematicmapping.org/downloads/world_borders.php找到的世界边界数据集生成的。这是一组有用的地理空间数据,它提供了简单的世界地图和相关元数据。如果您还没有这样做,您应该为自己获取一份此数据集的副本,因为我们将在此书中使用此 shapefile。

如您所见,创建能够读取和分析地理空间数据的 Python 程序是完全可能的,并且您可以直接从 QGIS 控制台运行这些程序。您还可以使用 PyQGIS 库创建和操作地理空间数据源。

脚本化 QGIS 用户界面

尽管我们之前创建的示例程序用户交互非常有限,但完全有可能构建您的程序以直接使用 QGIS 用户界面元素,例如状态栏、消息栏、进度指示器和 QGIS 日志窗口。您还可以创建自定义表单和窗口,以便您的程序输出看起来就像 QGIS 本身的任何其他功能。让我们更详细地看看如何在您的 Python 程序中使用一些这些 QGIS 用户界面元素。

状态栏

QGIS 窗口有一个状态栏。您可以使用它来显示 Python 程序当前的状态,例如:

iface.mainWindow().statusBar().showMessage("Please wait...")

状态消息将出现在窗口底部,如下所示:

状态栏

如您所见,状态栏上空间有限,因此您需要保持状态消息简短。要再次隐藏消息,请执行以下操作:

iface.mainWindow().statusBar().clearMessage()

消息栏

消息栏出现在窗口中,用于向用户显示消息,例如:

消息栏

消息栏有几个有用的功能:

  • 消息可以堆叠,这样如果同时出现多个消息,用户就不会错过早期的消息

  • 消息有一个级别,它表示消息的重要性,并影响消息的显示方式

  • 消息有一个可选的标题以及要显示的文本

  • 消息可以留在屏幕上,直到用户关闭它们,或者它们可以超时,在给定秒数后自动消失

  • 您可以向消息栏添加各种 Qt 小部件来自定义其行为和外观

QGIS 中的任何窗口都可以有自己的消息栏。iface变量有一个messageBar()方法,它返回主 QGIS 窗口的消息栏,但您也可以根据需要向自己的自定义窗口添加消息栏。

要向消息栏添加消息,请调用消息栏的pushMessage()方法。要创建不带标题的消息,请使用以下方法签名:

messageBar.pushMessage(text, level=QsgMessageBar.INFO, duration=None)

例如:

from qgis.gui import *
iface.messageBar().pushMessage("Hello World",
         level=QgsMessageBar.INFO)

要包含标题,请使用以下方法签名:

messageBar.pushMessage(title, text, level=QgsMessageBar.INFO, duration=None)

在这两种情况下,level参数可以设置为QgsMessageBar.INFOQgsMessageBar.WARNINGQgsMessageBar.CRITICAL,如果指定了duration参数,则表示消息隐藏前的秒数。

要移除当前显示的所有消息,您可以调用messageBar.clearWidgets()方法。

进度指示器

您还可以利用消息栏来显示 Qt 进度指示器。为此,请使用messageBar.createMessage()方法创建一个用于显示消息的小部件,然后修改该小部件以包含额外的 Qt 控件,最后调用messageBar.pushWidget()方法来显示消息和您添加的控件。例如:

progressMessage = iface.messageBar().createMessage("Please wait")
progressBar = QProgressBar()
progressBar.setMaximum(100)
progressBar.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
progressMessage.layout().addWidget(progressBar)
iface.messageBar().pushWidget(progressMessage)
...
progressBar.setValue(n)
...
iface.messageBar().clearWidgets()

注意

QGIS 2.2 的 Mac 版本中存在一个错误,这会阻止用户界面在 Python 代码运行时更新。解决这个问题的方法是在以下文章中描述的线程:snorf.net/blog/2013/12/07/multithreading-in-qgis-python-plugins

QGIS 日志

您可以使用 QGIS 内置的日志功能在单独的窗口中显示输出。例如:

for i in range(100):
    QgsMessageLog.logMessage("Message %d" % i)

日志消息将在日志视图中显示,您可以通过导航到视图 | 面板 | 日志消息来显示它。

如果您愿意,您可以通过在logMessage()调用中添加消息级别来更改消息的重要性,例如:

QgsMessageLog.logMessage("Something is wrong",
                         level=QgsMessageLog.CRITICAL)

您也可以选择让所有日志消息单独出现在一个面板中,通过在logMessage()调用中添加一个标签,如下所示:

QgsMessageLog.logMessage("Test Message", tag="my panel")

您的日志消息将随后出现在一个单独的面板中,如下所示:

QGIS 日志

自定义对话框和窗口

由于 QGIS 建立在 Qt 之上,您可以使用 PyQt 类创建自己的窗口和对话框,并直接从 Python 代码中显示它们。例如,以下是一个显示自定义对话框的脚本,提示用户输入纬度和经度值:

from PyQt4.QtGui import *

class MyDialog(QDialog):
    def __init__(self):
        QDialog.__init__(self)
        self.setWindowTitle("Enter Coordinate")

        layout = QFormLayout(self)

        self.lat_label = QLabel("Latitude", self)
        self.lat_field = QLineEdit(self)

        self.long_label = QLabel("Longitude", self)
        self.long_field = QLineEdit(self)

        self.ok_btn = QPushButton("OK", self)
        self.ok_btn.clicked.connect(self.accept)

        self.cancel_btn = QPushButton("Cancel", self)
        self.cancel_btn.clicked.connect(self.reject)

        btn_layout = QHBoxLayout(self)
        btn_layout.addWidget(self.ok_btn)
        btn_layout.addWidget(self.cancel_btn)

        layout.addRow(self.lat_label, self.lat_field)
        layout.addRow(self.long_label, self.long_field)
        layout.addRow(btn_layout)

        self.setLayout(layout)

dialog = MyDialog()
if dialog.exec_() == QDialog.Accepted:
    lat = dialog.lat_field.text()
    long = dialog.long_field.text()
    print lat,long

运行此脚本将显示以下对话框:

自定义对话框和窗口

如果用户点击 确定 按钮,输入的纬度和经度值将被打印到控制台。当然,这只是一个简单的示例——这里没有错误检查或将输入的值从文本转换回数字。然而,这只是一个简单的示例。使用 PyQt 库可以完成更多的事情,而且人们已经为此主题撰写了整本书。然而,现在要认识到的主要一点是,由于 QGIS 是建立在 Qt 之上的,您可以使用 PyQt 的所有功能来构建复杂的用户界面。您当然不仅仅局限于使用 Python 控制台与用户交互。

摘要

在本章中,我们探讨了 QGIS Python 控制台及其在多种编程任务中的应用。我们还使用控制台更深入地研究了 QGIS Python 编程环境。

在我们学习这一章的过程中,我们了解了 QGIS 控制台中的各种工具栏按钮和控制功能,以及如何使用 Python Shell 输入命令。我们探讨了如何使用 Python 解释器日志查看之前的输出并重新输入之前执行的命令。我们看到了如何使用自动完成快速输入 Python 代码,还了解了各种 PyQGIS 函数和方法接受的参数。

然后,我们探讨了如何使用内置的源代码编辑器输入和执行 Python 脚本。我们发现 Python 控制台本身是用 Python 编写的,这使得您可以使用 Python 代码探索源代码并操纵控制台本身。

我们学习了如何创建一个启动脚本,该脚本在 QGIS 启动时自动运行,以及如何使用它来设置控制台以自动打开并防止其作为可停靠窗口运行。

接下来,我们检查了直接使用您的 Python 脚本加载地理空间数据的过程,而无需首先将其加载到 QGIS 地图图层中。我们了解了如何识别由 shapefile 定义的属性,如何扫描 shapefile 内部的要素,以及 PyQGIS 库允许您执行常见地理空间计算的方式。

然后,我们探讨了在您的 Python 脚本中如何使用 QGIS 用户界面元素的各种方法,包括状态栏、消息栏、进度指示器和 QGIS 消息日志。

最后,我们看到了如何使用标准的 PyQt 类创建自己的窗口和对话框,为您的 Python 脚本提供复杂的用户界面。

在下一章中,我们将更直接地与 QGIS Python 库合作,学习这些库的结构以及如何使用它们执行各种类型的地理空间数据处理并在地图上显示结果。

第三章:学习 QGIS Python API

在本章中,我们将更深入地了解 QGIS Python 开发者可用的 Python 库,并探讨我们可以使用这些库在 QGIS 中执行有用任务的多种方式。

尤其是你会学到:

  • QGIS Python 库是如何基于底层 C++ API 的

  • 如何将 C++ API 文档作为参考来处理 Python API

  • PyQGIS 库是如何组织的

  • PyQGIS 库中最重要概念和类以及如何使用它们

  • 使用 PyQGIS 执行有用任务的实用示例

关于 QGIS Python API

QGIS 系统本身是用 C++编写的,并有一套自己的 API,这些 API 也是用 C++编写的。Python API 作为这些 C++ API 的包装器来实现。例如,有一个名为QgisInterface的 Python 类,它作为同名的 C++类的包装器。QgisInterface的 C++版本实现的所有方法、类变量等,都通过 Python 包装器提供。

这意味着当你访问 Python QGIS API 时,你不是直接访问 API。相反,包装器将你的代码连接到底层的 C++对象和方法,如下所示:

关于 QGIS Python API

幸运的是,在大多数情况下,QGIS Python 包装器简单地隐藏了底层 C++代码的复杂性,所以 PyQGIS 库会按你期望的方式工作。然而,也有一些需要注意的问题,我们将在遇到时进行讨论。

解读 C++文档

由于 QGIS 是用 C++实现的,因此 QGIS API 的文档都是基于 C++的。这可能会让 Python 开发者难以理解和使用 QGIS API。例如,QgsInterface.zoomToActiveLayer()方法的 API 文档:

解读 C++文档

如果你不太熟悉 C++,这可能会相当令人困惑。幸运的是,作为一个 Python 程序员,你可以跳过很多复杂性,因为它们对你不适用。特别是:

  • virtual关键字是你不需要关心的实现细节

  • void表示该方法不返回任何值

  • QgisInterface::zoomToActiveLayer中的双冒号是 C++中用于分隔类名和方法名的约定

就像在 Python 中一样,括号表明该方法不接收任何参数。所以如果你有一个QgisInterface的实例(例如,作为 Python 控制台中的标准iface变量),你可以通过简单地输入以下内容来调用此方法:

iface.zoomToActiveLayer()

现在,让我们看看一个稍微复杂一点的例子:QgisInterface.addVectorLayer()方法的 C++文档如下所示:

解读 C++文档

注意 virtual 关键字后面跟随的是 QgsVectorLayer* 而不是 void。这是此方法的返回值;它返回一个 QgsVector 对象。

注意

从技术上来说,* 表示该方法返回一个指向类型为 QgsVectorLayer 的对象的指针。幸运的是,Python 包装器会自动处理指针,因此您无需担心这一点。

注意文档底部对此方法的简要描述;虽然许多 C++ 方法几乎没有,甚至没有任何附加信息,但其他方法有更详细的信息。显然,您应该仔细阅读这些描述,因为它们会告诉您更多关于方法的功能。

即使没有任何描述,C++ 文档仍然很有用,因为它告诉您方法的名称、它接受哪些参数以及返回的数据类型。

在前面的方法中,您可以看到在括号之间列出了三个参数。由于 C++ 是一种强类型语言,因此在定义函数时必须定义每个参数的类型。这对 Python 程序员很有帮助,因为它告诉您应该提供什么类型的值。除了 QGIS 对象外,您还可能在 C++ 文档中遇到以下数据类型:

数据类型 描述
int 标准的 Python 整数值
long 标准的 Python 长整数值
float 标准的 Python 浮点(实数)数
bool 布尔值(truefalse
QString 字符串值。请注意,QGIS Python 包装器会自动将 Python 字符串转换为 C++ 字符串,因此您无需直接处理 QString 对象
QList 此对象用于封装其他对象的列表。例如,QList<QString*> 表示字符串列表

就像在 Python 中一样,方法可以为每个参数设置默认值。例如,QgisInterface.newProject() 方法的样子如下:

解析 C++ 文档

在此情况下,thePromptToSaveFlag 参数有一个默认值,如果没有提供值,将使用此默认值。

在 Python 中,类使用 __init__ 方法进行初始化。在 C++ 中,这被称为构造函数。例如,QgsLabel 类的构造函数如下所示:

解析 C++ 文档

就像在 Python 中一样,C++ 类会 继承 它们的超类中定义的方法。幸运的是,QGIS 没有庞大的类层次结构,因此大多数类没有超类。但是,如果您在类的文档中找不到您要查找的方法,不要忘记检查超类。

最后,请注意,C++ 支持方法重载的概念。一个方法可以定义多次,其中每个版本接受不同的参数集。例如,看看 QgsRectangle 类的构造函数——您会看到有四种不同版本的这个方法。

第一个版本接受四个坐标作为浮点数:

解读 C++ 文档

第二个版本使用两个 QgsPoint 对象构建一个矩形:

解读 C++ 文档

第三个版本将坐标从 QRectF(这是一个 Qt 数据类型)复制到一个 QgsRectangle 对象中:

解读 C++ 文档

最后一个版本将坐标从另一个 QgsRectangle 对象复制过来:

解读 C++ 文档

C++ 编译器根据提供的参数选择正确的方法。Python 没有方法重载的概念;只需选择接受您想要提供的参数的方法版本,QGIS Python 包装器将自动为您选择正确的方法。

如果您记住这些指南,解读 QGIS 的 C++ 文档并不那么困难。它看起来比实际更复杂,这要归功于所有特定的 C++ 复杂性。然而,您的头脑很快就会开始过滤掉 C++ 的混乱,您将能够几乎像阅读为 Python 编写的文档一样轻松地使用 QGIS 参考文档。

组织 QGIS Python 库

现在我们能够理解面向 C++ 的文档,让我们看看 PyQGIS 库是如何组织的。所有的 PyQGIS 库都组织在一个名为 qgis 的包下。然而,您通常不会直接导入 qgis,因为所有有趣的库都是这个主包内的子包;以下是构成 PyQGIS 库的五个包:

qgis.core 这提供了访问 QGIS 中使用的核心 GIS 功能。
qgis.gui 这定义了一系列 GUI 小部件,您可以将它们包含在自己的程序中。
qgis.analysis 这提供了分析矢量格式和栅格格式数据的空间分析工具。
qgis.networkanalysis 这提供了构建和分析拓扑的工具。
qgis.utils 这实现了允许您使用 Python 与 QGIS 应用程序一起工作的各种函数。

前两个包(qgis.coreqgis.gui)实现了 PyQGIS 库的最重要部分,花些时间熟悉它们定义的概念和类是值得的。现在让我们更详细地看看这两个包。

qgis.core

qgis.core包定义了在整个 QGIS 系统中使用的根本类。这个包的大部分内容是专门用于处理矢量格式和栅格格式地理空间数据,并在地图中显示这些类型的数据。让我们看看这是如何实现的。

地图和地图图层

地图由多个图层组成,这些图层一个叠在另一个上面:

地图和地图图层

QGIS 支持三种类型的地图图层:

  • 矢量图层:此图层绘制地理空间特征,如点、线和多边形

  • 栅格图层:此图层将栅格(位图)数据绘制到地图上

  • 插件图层:此图层允许插件直接在地图上绘制

这些类型的地图图层在qgis.core库中都有相应的类。例如,矢量地图图层将由qgis.core.QgsVectorLayer类型的对象表示。

我们将很快更详细地了解矢量图层和栅格图层。不过,在我们这样做之前,我们需要了解地理空间数据(矢量数据和栅格数据)是如何定位在地图上的。

坐标参考系统

由于地球是一个三维物体,而地图将地球表面表示为二维平面,因此必须有一种方法将地球表面的点转换为地图内的(x,y)坐标。这是通过使用坐标参考系统CRS)来完成的:

坐标参考系统

地球仪图像由维基媒体提供(commons.wikimedia.org/wiki/File:Rotating_globe.gif

坐标参考系统(CRS)有两个部分:一个椭球体,它是地球表面的数学模型,以及一个投影,它是一个将球面上各点转换为地图上的(x,y)坐标的公式。

幸运的是,大多数时候,你可以简单地选择与你要使用的数据的 CRS 相匹配的适当 CRS。然而,由于多年来已经设计了多种不同的坐标参考系统,因此在绘制你的地理空间数据时使用正确的 CRS 至关重要。如果你不这样做,你的特征将显示在错误的位置或具有错误的形状。

今天大多数可用的地理空间数据使用EPSG 4326坐标参考系统(有时也称为 WGS84)。此 CRS 定义坐标为经纬度值。这是将新数据导入 QGIS 时使用的默认 CRS。但是,如果你的数据使用不同的坐标参考系统,你将需要为你的地图图层创建并使用不同的 CRS。

qgis.core.QgsCoordinateReferenceSystem类表示一个 CRS。一旦你创建了你的坐标参考系统,你可以告诉你的地图图层在访问底层数据时使用该 CRS。例如:

crs = QgsCoordinateReferenceSystem(4326,
           QgsCoordinateReferenceSystem.EpsgCrsId)
layer.setCrs(crs)

注意,不同的地图图层可以使用不同的坐标参考系统。每个图层在将图层内容绘制到地图上时都会使用其自己的 CRS。

矢量图层

矢量层以点、线、多边形等形式将地理空间数据绘制到地图上。矢量格式的地理空间数据通常从矢量数据源(如 shapefile 或数据库)加载。其他矢量数据源可以在内存中存储矢量数据,或从互联网上的网络服务加载数据。

矢量格式数据源具有许多特征,其中每个特征代表数据源中的单个记录qgis.core.QgsFeature类代表数据源中的特征。每个特征具有以下组件:

  • ID:这是特征在数据源中的唯一标识符

  • 几何形状:这是地图上特征的底层点、线、多边形等,代表地图上的特征。例如,城市数据源会为每个城市有一个特征,几何形状通常是表示城市中心的点,或者表示城市轮廓的多边形(或多边形集合)。

  • 属性:这些是键值对,提供了关于特征的额外信息。例如,代表城市的城市数据源可能具有total_area(总面积)、population(人口)、elevation(海拔)等属性。属性值可以是字符串、整数或浮点数。

在 QGIS 中,数据提供者允许矢量层访问数据源中的特征。数据提供者是一个qgis.core.QgsVectorDataProvider的实例,包括:

  • 几何类型:这是在数据源中存储的几何类型

  • 一个提供关于每个特征存储的属性信息的字段列表

  • 使用getFeatures()方法和QgsFeatureRequest类在数据源中的特征中进行搜索的能力

您可以通过使用qgis.core.QgsProviderRegistry类来访问各种矢量(以及栅格)数据提供者。

矢量层本身由一个qgis.core.QgsVectorLayer对象表示。每个矢量层包括:

  • 数据提供者:这是连接到包含要显示的地理空间信息的底层文件或数据库的连接

  • 坐标参考系统:这表示地理空间数据使用哪个 CRS

  • 渲染器:这决定了如何显示特征

让我们更详细地看看渲染器的概念以及如何在矢量地图层中显示特征。

显示矢量数据

矢量地图层中的特征是通过渲染器符号对象的组合来显示的。渲染器选择用于特定特征的符号,而符号执行实际的绘制。

QGIS 定义了三种基本的符号类型:

  • 标记符号:这以填充圆的形式显示点

  • 线符号:这使用给定的线宽和颜色绘制线

  • 填充符号:这使用给定的颜色绘制多边形的内部

这三种类型的符号作为qgis.core.QgsSymbolV2类的子类实现:

  • qgis.core.QgsMarkerSymbolV2

  • qgis.core.QgsLineSymbolV2

  • qgis.core.QgsFillSymbolV2

    注意

    你可能想知道为什么所有这些类的名称中都有“V2”。这是 QGIS 的历史特性。QGIS 的早期版本支持渲染的“旧”和“新”系统,而“V2”命名指的是新的渲染系统。旧的渲染系统已不再存在,但“V2”命名继续与现有代码保持向后兼容。

内部来说,符号相当复杂,使用“符号层”来在彼此之上绘制多个元素。然而,在大多数情况下,你可以使用“简单”版本的符号。这使得创建新符号时不必处理符号层的内部复杂性。例如:

symbol = QgsMarkerSymbolV2.createSimple({'width' : 1.0,
                                         'color' : "255,0,0"})

当符号将特征绘制到地图上时,渲染器用于选择用于绘制特定特征的符号。在最简单的情况下,同一符号用于图层内的每个特征。这被称为单个符号渲染器,由qgis.core.QgsSingleSymbolRenderV2类表示。其他可能性包括:

  • 分类符号渲染器qgis.core.QgsCategorizedSymbolRendererV2):此渲染器根据属性的值选择符号。分类符号渲染器具有属性值到符号的映射。

  • 渐变符号渲染器qgis.core.QgsGraduatedSymbolRendererV2):此类渲染器使用属性值的范围,并将每个范围映射到适当的符号。

使用单个符号渲染器非常直接:

symbol = ...
renderer = QgsSingleSymbolRendererV2(symbol)
layer.setRendererV2(renderer)

要使用分类符号渲染器,你首先定义一个qgis.core.QgsRendererCategoryV2对象的列表,然后使用它来创建渲染器。例如:

symbol_male = ...
symbol_female = ...

categories = []
categories.append(QgsRendererCategoryV2("M", symbol_male, "Male"))
categories.append(QgsRendererCategoryV2("F", symbol_female,
                    "Female"))

renderer = QgsCategorizedSymbolRendererV2("", categories)
renderer.setClassAttribute("GENDER")
layer.setRendererV2(renderer)

注意,QgsRendererCategoryV2构造函数接受三个参数:所需的值、使用的符号以及用于描述该类别的标签。

最后,要使用渐变符号渲染器,你首先定义一个qgis.core.QgsRendererRangeV2对象的列表,然后使用它来创建你的渲染器。例如:

symbol1 = ...
symbol2 = ...

ranges = []
ranges.append(QgsRendererRangeV2(0, 10, symbol1, "Range 1"))
ranges.append(QgsRendererRange(11, 20, symbol2, "Range 2"))

renderer = QgsGraduatedSymbolRendererV2("", ranges)
renderer.setClassAttribute("FIELD")
layer.setRendererV2(renderer)

访问矢量数据

除了在地图中显示矢量图层的内容外,你还可以使用 Python 直接访问底层数据。这可以通过数据提供者的getFeatures()方法完成。例如,要遍历图层内的所有特征,你可以执行以下操作:

provider = layer.dataProvider()
for feature in provider.getFeatures(QgsFeatureRequest()):
  ...

如果你想要根据某些标准搜索特征,你可以使用QgsFeatureRequest对象的setFilterExpression()方法,如下所示:

provider = layer.dataProvider()
request = QgsFeatureRequest()
request.setFilterExpression('"GENDER" = "M"')
for feature in provider.getFeatures(QgsFeatureRequest()):
  ...

一旦你有了特征,很容易获取特征的几何形状、ID 和属性。例如:

  geometry = feature.geometry()
  id = feature.id()
  name = feature.attribute("NAME")

feature.geometry()调用返回的对象,它将是一个qgis.core.QgsGeometry实例,代表特征的几何形状。此对象有大量你可以使用的方法来提取底层数据并执行各种地理空间计算。

空间索引

在前面的章节中,我们根据属性值搜索特征。然而,有时您可能希望根据它们在空间中的位置来查找特征。例如,您可能希望找到所有位于给定点一定距离内的特征。为此,您可以使用空间索引,该索引根据特征的位置和范围进行索引。空间索引在 QGIS 中由QgsSpatialIndex类表示。

为了性能原因,不会为每个矢量图层自动创建空间索引。然而,当您需要时创建一个很容易:

provider = layer.dataProvider()
index = QgsSpatialIndex()
for feature in provider.getFeatures(QgsFeatureRequest()):
  index.insertFeature(feature)

不要忘记,您可以使用QgsFeatureRequest.setFilterExpression()方法来限制添加到索引中的特征集。

一旦您有了空间索引,您就可以使用它来根据特征的位置执行查询。特别是:

  • 您可以使用nearestNeighbor()方法找到与给定点最近的特征。例如:

    features = index.nearestNeighbor(QgsPoint(long, lat), 5)
    

    注意,此方法需要两个参数:所需的点作为一个QgsPoint对象以及要返回的特征数量。

  • 您可以使用intersects()方法找到与给定矩形区域相交的所有特征,如下所示:

    features = index.intersects(QgsRectangle(left, bottom,
                         right, top))
    

栅格图层

栅格格式的地理空间数据本质上是一个位图图像,其中图像中的每个像素或“单元格”对应于地球表面的一个特定部分。栅格数据通常组织成波段,其中每个波段代表不同的信息。波段的一个常见用途是在单独的波段中存储像素颜色的红色、绿色和蓝色成分。波段也可能代表其他类型的信息,例如湿度水平、海拔或土壤类型。

栅格信息可以以多种方式显示。例如:

  • 如果栅格数据只有一个波段,则像素值可以用作调色板的索引。调色板将每个像素值映射到特定的颜色。

  • 如果栅格数据只有一个波段但没有提供调色板,则像素值可以直接用作灰度值;也就是说,较大的数字较亮,较小的数字较暗。或者,像素值可以通过伪彩色算法来计算要显示的颜色。

  • 如果栅格数据有多个波段,那么通常,波段会被组合起来生成所需的颜色。例如,一个波段可能代表颜色的红色成分,另一个波段可能代表绿色成分,而另一个波段可能代表蓝色成分。

  • 或者,可以使用调色板、灰度或伪彩色图像绘制多波段栅格数据源,通过选择用于颜色计算的特定波段。

让我们更仔细地看看如何将栅格数据绘制到地图上。

栅格数据的显示方式

与栅格波段关联的绘图风格控制了栅格数据的显示方式。以下是目前支持的绘图风格:

绘图风格 描述
PalettedColor 对于单波段栅格数据源,调色板将每个栅格值映射到颜色。
SingleBandGray 对于单波段栅格数据源,栅格值直接用作灰度值。
SingleBandPseudoColor 对于单波段栅格数据源,栅格值用于计算伪颜色。
PalettedSingleBandGray 对于具有调色板的单波段栅格数据源,这种绘图风格告诉 QGIS 忽略调色板并直接使用栅格值作为灰度值。
PalettedSingleBandPseudoColor 对于具有调色板的单波段栅格数据源,这种绘图风格告诉 QGIS 忽略调色板并使用栅格值计算伪颜色。
MultiBandColor 对于多波段栅格数据源,为红色、绿色和蓝色颜色组件分别使用一个单独的波段。对于这种绘图风格,可以使用setRedBand()setGreenBand()setBlueBand()方法来选择每个颜色组件使用的波段。
MultiBandSingleBandGray 对于多波段栅格数据源,选择一个波段用作灰度颜色值。对于这种绘图风格,使用setGrayBand()方法指定要使用的波段。
MultiBandSingleBandPseudoColor 对于多波段栅格数据源,选择一个波段用于计算伪颜色。对于这种绘图风格,使用setGrayBand()方法指定要使用的波段。

要设置绘图风格,使用layer.setDrawingStyle()方法,传入包含所需绘图风格名称的字符串。您还需要调用前面表格中描述的各个setXXXBand()方法,以告诉栅格层哪些波段包含用于绘制每个像素的值。

注意,当您调用前面的函数来更改栅格数据的显示方式时,QGIS 不会自动更新地图。要立即显示您的更改,您需要执行以下操作:

  1. 关闭栅格图像缓存。这可以通过调用layer.setImageCache(None)来实现。

  2. 告诉栅格层重新绘制自身,通过调用layer.triggerRepaint()

访问栅格数据

与矢量格式数据一样,您可以通过数据提供者的identify()方法访问底层栅格数据。这样做最简单的方法是传入一个坐标并检索该坐标处的值或值。例如:

provider = layer.dataProvider()
values = provider.identify(QgsPoint(x, y),
              QgsRaster.IdentifyFormatValue)
if values.isValid():
  for band,value in values.results().items():
    ...

如您所见,您需要检查给定坐标是否存在于栅格数据中(使用isValid()调用)。values.results()方法返回一个将波段编号映射到值的字典。

使用这种技术,您可以提取与栅格层中给定坐标相关联的所有底层数据。

提示

你还可以使用 provider.block() 方法一次性检索大量坐标的波段数据。我们将在本章后面讨论如何做到这一点。

其他有用的 qgis.core 类

除了所有涉及数据源和地图图层操作的相关类和功能外,qgis.core 库还定义了其他一些你可能觉得有用的类:

描述
QgsProject 这代表当前的 QGIS 项目。请注意,这是一个单例对象,因为一次只能打开一个项目。QgsProject 类负责加载和存储属性,这对于插件可能很有用。
QGis 这个类定义了 QGIS 系统中使用的各种常量、数据类型和函数。
QgsPoint 这是一个通用类,用于存储二维平面内点的坐标。
QgsRectangle 这是一个通用类,用于存储二维平面内矩形区域的坐标。
QgsRasterInterface 这是处理栅格数据的基础类,例如,将一组栅格数据重新投影到新的坐标系中,应用过滤器以改变栅格数据的亮度和颜色,重采样栅格数据,以及通过以各种方式渲染现有数据来生成新的栅格数据。
QgsDistanceArea 这个类可以用来计算给定几何形状的距离和面积,自动将源坐标参考系统转换为米。
QgsMapLayerRegistry 这个类提供了对当前项目中所有已注册地图图层的访问。
QgsMessageLog 这个类在 QGIS 程序中提供了一般的日志功能。这让你可以将调试消息、警告和错误发送到 QGIS 的“日志消息”面板。

qgis.gui 包

qgis.gui 包定义了一系列用户界面小部件,你可以将其包含在你的程序中。让我们首先看看最重要的 qgis.gui 类,然后简要地看看一些你可能觉得有用的其他类。

QgisInterface 类

QgisInterface 代表 QGIS 系统的用户界面。它允许以编程方式访问地图画布、菜单栏和其他 QGIS 应用程序的各个部分。当在脚本或插件中运行 Python 代码,或直接从 QGIS Python 控制台运行时,通常可以通过 iface 全局变量获得对 QgisInterface 的引用。

注意

QgisInterface 对象仅在运行 QGIS 应用程序本身时才可用。如果你正在运行外部应用程序并将 PyQGIS 库导入到你的应用程序中,QgisInterface 将不可用。

你可以使用 QgisInterface 对象做一些更重要的事情:

  • 通过 legendInterface() 方法获取当前 QGIS 项目中图层列表的引用。

  • 使用 mapCanvas() 方法获取主应用程序窗口中显示的地图画布的引用。

  • 使用 activeLayer() 方法检索项目中的当前活动层,并使用 setActiveLayer() 方法设置当前活动层。

  • 通过调用 mainWindow() 方法获取应用程序的主窗口引用。如果您想创建使用主窗口作为其父窗口的附加 Qt 窗口或对话框,这可能很有用。

  • 通过调用 messageBar() 方法获取 QGIS 系统的消息栏引用。这允许您在 QGIS 主窗口中直接向用户显示消息。

QgsMapCanvas 类

地图画布负责将各种地图层绘制到窗口中。QgsMapCanvas 类代表一个地图画布。此类包括:

  • 当前显示的地图层列表。可以使用 layers() 方法访问。

    小贴士

    注意,地图画布内可用的地图层列表与 QgisInterface.legendInterface() 方法中包含的地图层列表之间有一个细微的区别。地图画布的层列表仅包括当前可见的层列表,而 QgisInterface.legendInterface() 返回所有地图层,包括当前隐藏的层。

  • 该地图使用的地图单位(米、英尺、度等)。可以通过调用 mapUnits() 方法检索地图的单位。

  • 范围,即当前在画布中显示的地图区域。当用户缩放和平移地图时,地图的范围将发生变化。可以通过调用 extent() 方法获取当前地图的范围。

  • 当前地图工具,用于控制用户与地图画布内容的交互。可以使用 setMapTool() 方法设置当前地图工具,并通过调用 mapTool() 方法检索当前地图工具(如果有)。

  • 用于绘制所有地图层背景的背景颜色。您可以通过调用 canvasColor() 方法来更改地图的背景颜色。

  • 坐标转换,将地图坐标(即数据源坐标参考系中的坐标)转换为窗口内的像素。您可以通过调用 getCoordinateTransform() 方法检索当前坐标转换。

QgsMapCanvasItem 类

地图画布项是在地图画布上绘制的项。地图画布项将出现在地图层之前。虽然您可以根据需要创建 QgsMapCanvasItem 的子类以在地图画布上绘制自定义项,但您会发现使用现有的子类更容易,这些子类为您做了很多工作。目前有三个 QgsMapCanvasItem 的子类可能对您有用:

  • QgsVertexMarker:在地图上给定点的周围绘制一个图标(一个 "X"、一个 "+" 或一个小方块)。

  • QgsRubberBand:这将在地图上绘制任意多边形或多段线。它的目的是在用户在地图上绘制多边形时提供视觉反馈。

  • QgsAnnotationItem:这用于以气球的形式显示有关要素的附加信息,该气球与要素相连。QgsAnnotationItem 类有各种子类,允许您自定义信息显示的方式。

QgsMapTool 类

地图工具允许用户与地图画布进行交互和操作,捕获鼠标事件并做出相应的响应。许多 QgsMapTool 子类提供了标准地图交互行为,例如点击放大、拖动平移地图以及点击要素进行识别。您还可以通过继承 QgsMapTool 并实现响应用户界面事件的各种方法来创建自己的自定义地图工具,例如按下鼠标按钮、拖动画布等。

一旦您创建了地图工具,您可以通过将地图工具与工具栏按钮关联来允许用户激活它。或者,您也可以通过在您的 Python 代码中调用 mapCanvas.setMapTool(...) 方法来激活它。

我们将在 使用 PyQGIS 库 这一部分中查看创建您自己的自定义地图工具的过程。

其他有用的 qgis.gui 类

虽然 qgis.gui 包定义了大量的类,但您最可能发现有用的类在以下表中给出:

班级 描述
QgsLegendInterface 这提供了访问地图图例的途径,即当前项目中的地图图层列表。请注意,地图图层可以在图例中分组、隐藏和显示。
QgsMapTip 当用户将鼠标悬停在要素上时,在地图画布上显示提示。地图提示将显示要素的显示字段;您可以通过调用 layer.setDisplayField("FIELD") 来设置此字段。
QgsColorDialog 这是一个允许用户选择颜色的对话框。
QgsDialog 这是一个具有垂直框布局和按钮框的通用对话框,这使得向对话框中添加内容和标准按钮变得容易。
QgsMessageBar 这是一个显示非阻塞消息给用户的栏。我们在上一章中讨论了消息栏类。
QgsMessageViewer 这是一个通用类,它在一个模态对话框中向用户显示长消息。
QgsBlendModeComboBox QgsBrushStyleComboBox QgsColorRampComboBox QgsPenCapStyleComboBox QgsPenJoinStyleComboBox QgsScaleComboBox 这些 QComboBox 用户界面小部件允许您提示用户选择各种绘图选项。除了允许用户选择地图比例的 QgsScaleComboBox 之外,所有其他的 QComboBox 子类都允许用户选择各种 Qt 绘图选项。

使用 PyQGIS 库

在上一节中,我们查看了一些由 PyQGIS 库提供的类。让我们利用这些类来执行一些实际的地理空间开发任务。

分析栅格数据

我们将首先编写一个程序来加载一些栅格格式数据并分析其内容。为了使这个过程更有趣,我们将使用数字高程模型DEM)文件,这是一种包含高程数据的栅格格式数据文件。

全球陆地一千米基础高程项目GLOBE)为全球提供免费的 DEM 数据,其中每个像素代表地球表面的一个平方公里。GLOBE 数据可以从www.ngdc.noaa.gov/mgg/topo/gltiles.html下载。下载 E 图块,它包括美国西部的一半。生成的文件,命名为e10g,包含您所需的高度信息。您还需要下载e10g.hdr头文件,以便 QGIS 能够读取该文件——您可以从www.ngdc.noaa.gov/mgg/topo/elev/esri/hdr下载。一旦下载了这两个文件,将它们合并到一个方便的目录中。

您现在可以使用以下代码将 DEM 数据加载到 QGIS 中:

registry = QgsProviderRegistry.instance()
provider = registry.provider("gdal", "/path/to/e10g")

不幸的是,这里有一点复杂性。由于 QGIS 不知道数据使用的是哪个坐标参考系统,它会显示一个对话框,要求您选择 CRS。由于 GLOBE DEM 数据位于 WGS84 CRS 中,这是 QGIS 默认使用的,因此此对话框是多余的。为了禁用它,我们需要在程序顶部添加以下内容:

from PyQt4.QtCore import QSettings
QSettings().setValue("/Projections/defaultBehaviour", "useGlobal")

现在我们已经将我们的栅格 DEM 数据加载到 QGIS 中,我们可以分析它了。虽然我们可以用 DEM 数据做很多事情,但让我们计算数据中每个唯一高程值出现的频率。

注意

注意,我们正在直接使用QgsRasterDataProvider加载 DEM 数据。我们不想在地图上显示这些信息,因此我们不想(或不需要)将其加载到QgsRasterLayer中。

由于 DEM 数据是栅格格式,您需要遍历单个像素或单元格以获取每个高度值。provider.xSize()provider.ySize()方法告诉我们 DEM 中有多少个单元格,而provider.extent()方法给出了 DEM 覆盖的地球表面区域。使用这些信息,我们可以以下述方式从 DEM 的内容中提取单个高程值:

raster_extent = provider.extent()
raster_width = provider.xSize()
raster_height = provider.ySize()
block = provider.block(1, raster_extent, raster_width,
            raster_height)

返回的block变量是QgsRasterBlock类型的一个对象,它本质上是一个值的二维数组。让我们遍历栅格并提取单个高程值:

for x in range(raster_width):
  for y in range(raster_height):
    elevation = block.value(x, y)
    ....

现在我们已经加载了单个高程值,很容易从这些值中构建直方图。以下是整个程序,用于将 DEM 数据加载到内存中,然后计算并显示直方图:

from PyQt4.QtCore import QSettings
QSettings().setValue("/Projections/defaultBehaviour", "useGlobal")

registry = QgsProviderRegistry.instance()
provider = registry.provider("gdal", "/path/to/e10g")

raster_extent = provider.extent()
raster_width = provider.xSize()
raster_height = provider.ySize()
no_data_value = provider.srcNoDataValue(1)

histogram = {} # Maps elevation to number of occurrences.

block = provider.block(1, raster_extent, raster_width,
            raster_height)
if block.isValid():
  for x in range(raster_width):
    for y in range(raster_height):
      elevation = block.value(x, y)
      if elevation != no_data_value:
        try:
          histogram[elevation] += 1
        except KeyError:
          histogram[elevation] = 1

for height in sorted(histogram.keys()):
  print height, histogram[height]

注意,我们在代码中添加了一个 无数据值 检查。栅格数据通常包括没有与之关联值的像素。在 DEM 的情况下,高程数据仅提供陆地区域的数据;海洋上的像素没有高程,我们必须排除它们,否则我们的直方图将不准确。

操作矢量数据并将其保存为 shapefile

让我们创建一个程序,该程序接受两个矢量数据源,从另一个数据源中减去一组矢量,并将结果几何体保存到一个新的 shapefile 中。在这个过程中,我们将了解 PyQGIS 库的一些重要内容。

我们将使用 QgsGeometry.difference() 函数。此函数执行从一个几何体到另一个几何体的几何减法,如下所示:

操作矢量数据并将其保存为 shapefile

让我们先让用户选择第一个 shapefile,并为该文件打开一个矢量数据提供者:

filename_1 = QFileDialog.getOpenFileName(iface.mainWindow(),
                     "First Shapefile",
                     "~", "*.shp")
if not filename_1:
  return

registry = QgsProviderRegistry.instance()
provider_1 = registry.provider("ogr", filename_1)

然后,我们可以从该文件中读取几何体到内存中:

geometries_1 = []
for feature in provider_1.getFeatures(QgsFeatureRequest()):
  geometries_1.append(QgsGeometry(feature.geometry()))

这段代码的最后一句包含了一个重要的特性。注意,我们使用以下方法:

QgsGeometry(feature.geometry())

我们使用前面的行而不是以下行:

feature.geometry()

这是为了获取要添加到列表中的几何体对象。换句话说,我们必须基于现有几何体对象创建一个新的几何体对象。这是 QGIS Python 包装器工作方式的一个限制:feature.geometry() 方法返回一个几何体的引用,但 C++ 代码不知道你在 Python 代码中将这个引用存储起来。所以,当特征不再需要时,特征几何体使用的内存也会被释放。如果你后来尝试访问该几何体,整个 QGIS 系统将会崩溃。为了解决这个问题,我们创建几何体的一个副本,这样我们就可以在特征内存释放后仍然引用它。

现在我们已经将第一组几何体加载到内存中,让我们对第二个 shapefile 也做同样的操作:

filename_2 = QFileDialog.getOpenFileName(iface.mainWindow(),
                     "Second Shapefile",
                     "~", "*.shp")
if not filename_2:
  return

provider_2 = registry.provider("ogr", filename_2)

geometries_2 = []
for feature in provider_2.getFeatures(QgsFeatureRequest()):
  geometries_2.append(QgsGeometry(feature.geometry()))

当两组几何体被加载到内存中后,我们就可以开始从一组中减去另一组了。然而,为了使这个过程更高效,我们将第二个 shapefile 中的几何体合并成一个大的几何体,然后一次性减去,而不是逐个减去。这将使减法过程变得更快:

combined_geometry = None
for geometry in geometries_2:
  if combined_geometry == None:
    combined_geometry = geometry
  else:
    combined_geometry = combined_geometry.combine(geometry)

我们现在可以通过减去一个来计算新的几何体集:

dst_geometries = []
for geometry in geometries_1:
  dst_geometry = geometry.difference(combined_geometry)
  if not dst_geometry.isGeosValid(): continue
  if dst_geometry.isGeosEmpty(): continue
  dst_geometries.append(dst_geometry)

注意,我们检查目标几何体是否在数学上是有效的,并且不为空。

注意

在操作复杂形状时,无效的几何体是一个常见问题。有修复它们的方法,例如将多几何体分开并执行缓冲操作。然而,这超出了本书的范围。

我们最后的任务是保存结果几何体到一个新的 shapefile 中。我们首先会要求用户输入目标 shapefile 的名称:

dst_filename = QFileDialog.getSaveFileName(iface.mainWindow(),
                      "Save results to:",
                      "~", "*.shp")
if not dst_filename:
  return

我们将使用矢量文件写入器将几何形状保存到形状文件中。让我们首先初始化文件写入器对象:

fields = QgsFields()
writer = QgsVectorFileWriter(dst_filename, "ASCII", fields,
               dst_geometries[0].wkbType(),
               None, "ESRI Shapefile")
if writer.hasError() != QgsVectorFileWriter.NoError:
  print "Error!"
  return

我们的形状文件中没有属性,因此字段列表为空。现在写入器已经设置好,我们可以将几何形状保存到文件中:

for geometry in dst_geometries:
  feature = QgsFeature()
  feature.setGeometry(geometry)
  writer.addFeature(feature)

现在所有数据都已写入磁盘,让我们显示一个消息框,通知用户我们已经完成:

QMessageBox.information(iface.mainWindow(), "",
            "Subtracted features saved to disk.")

如您所见,在 PyQGIS 中创建新的形状文件非常简单,使用 Python 操作几何形状也很容易——只要您复制您想要保留的QgsGeometry对象。如果您的 Python 代码在操作几何形状时开始崩溃,这可能是您应该首先查找的问题。

在地图中使用不同符号表示不同特征

让我们使用在上一章中下载的世界边界数据集来绘制世界地图,为不同的洲使用不同的符号。这是一个使用分类符号渲染器的良好示例,尽管我们将将其组合到一个脚本中,该脚本将将形状文件加载到地图层中,并设置符号和地图渲染器以显示您想要的地图。然后我们将保存生成的地图为图像。

让我们首先创建一个地图层来显示世界边界数据集形状文件的内容:

layer = iface.addVectorLayer("/path/to/TM_WORLD_BORDERS-0.3.shp", 
               "continents", "ogr")

世界边界数据集形状文件中的每个唯一区域代码对应一个洲。我们想要定义每个这些区域使用的名称和颜色,并使用这些信息来设置显示地图时使用的各种类别:

from PyQt4.QtGui import QColor
categories = []
for value,color,label in [(0,   "#660000", "Antarctica"),
                          (2,   "#006600", "Africa"),
                          (9,   "#000066", "Oceania"),
                          (19,  "#660066", "The Americas"),
                          (142, "#666600", "Asia"),
                          (150, "#006666", "Europe")]:
  symbol = QgsSymbolV2.defaultSymbol(layer.geometryType())
  symbol.setColor(QColor(color))
  categories.append(QgsRendererCategoryV2(value, symbol, label))

在设置好这些类别后,我们只需更新地图层以使用基于region属性值的分类渲染器,然后重新绘制地图:

layer.setRendererV2(QgsCategorizedSymbolRendererV2("region",
                          categories))
layer.triggerRepaint()

由于这是一个可以多次运行的脚本,让我们让我们的脚本在添加新层之前自动删除现有的continents层(如果存在)。为此,我们可以在脚本的开头添加以下内容:

layer_registry = QgsMapLayerRegistry.instance()
for layer in layer_registry.mapLayersByName("continents"):
  layer_registry.removeMapLayer(layer.id())

现在当我们的脚本运行时,它将创建一个(并且只有一个)层,显示不同颜色的各种大陆。这些在打印的书中将显示为不同的灰色阴影,但在计算机屏幕上颜色将是可见的:

在地图中使用不同符号表示不同特征

现在,让我们使用相同的数据集根据每个国家的相对人口对其进行着色。我们首先删除现有的"population"层(如果存在):

layer_registry = QgsMapLayerRegistry.instance()
for layer in layer_registry.mapLayersByName("population"):
  layer_registry.removeMapLayer(layer.id())

接下来,我们将世界边界数据集打开到一个新的层中,称为"population"

layer = iface.addVectorLayer("/path/to/TM_WORLD_BORDERS-0.3.shp", 
               "population", "ogr")

然后,我们需要设置我们的各种人口范围:

from PyQt4.QtGui import QColor
ranges = []
for min_pop,max_pop,color in [(0,        99999,     "#332828"),
                              (100000,   999999,    "#4c3535"),
                              (1000000,  4999999,   "#663d3d"),
                              (5000000,  9999999,   "#804040"),
                              (10000000, 19999999,  "#993d3d"),
                              (20000000, 49999999,  "#b33535"),
                              (50000000, 999999999, "#cc2828")]:
  symbol = QgsSymbolV2.defaultSymbol(layer.geometryType())
  symbol.setColor(QColor(color))
  ranges.append(QgsRendererRangeV2(min_pop, max_pop,
                   symbol, ""))

现在我们有了人口范围及其相关颜色,我们只需设置一个渐变符号渲染器,根据pop2005属性值选择符号,并告诉地图重新绘制自己:

layer.setRendererV2(QgsGraduatedSymbolRendererV2("pop2005",
                         ranges))
layer.triggerRepaint()

结果将是一个地图层,根据每个国家的人口进行着色:

在地图中使用不同符号表示不同特征

计算两个用户定义点之间的距离

在我们使用 PyQGIS 库的最后一个示例中,我们将编写一些代码,当运行时,它将开始监听用户的鼠标事件。如果用户点击一个点,拖动鼠标,然后再次释放鼠标按钮,我们将显示这两个点之间的距离。这是一个如何将您自己的地图交互逻辑添加到 QGIS 中的好例子,使用 QgsMapTool 类。

这是我们的 QgsMapTool 子类的结构基础:

class DistanceCalculator(QgsMapTool):
  def __init__(self, iface):
    QgsMapTool.__init__(self, iface.mapCanvas())
    self.iface = iface

  def canvasPressEvent(self, event):
    ...

  def canvasReleaseEvent(self, event):
    ...

要使这个地图工具生效,我们将创建一个新的实例并将其传递给 mapCanvas.setMapTool() 方法。一旦完成,当用户在地图画布上点击或释放鼠标按钮时,我们的 canvasPressEvent()canvasReleaseEvent() 方法将被调用。

让我们从响应用户在画布上点击的代码开始。在这个方法中,我们将从用户点击的像素坐标转换为相应的地图坐标(即纬度和经度值)。然后我们将记住这些坐标,以便以后可以引用它们。以下是必要的代码:

def canvasPressEvent(self, event):
  transform = self.iface.mapCanvas().getCoordinateTransform()
  self._startPt = transform.toMapCoordinates(event.pos().x(),
                        event.pos().y())

当调用 canvasReleaseEvent() 方法时,我们希望对用户释放鼠标按钮的点执行相同的操作:

def canvasReleaseEvent(self, event):
  transform = self.iface.mapCanvas().getCoordinateTransform()
  endPt = transform.toMapCoordinates(event.pos().x(),
                    event.pos().y())

现在我们有了两个所需的坐标,我们想要计算它们之间的距离。我们可以使用 QgsDistanceArea 对象来完成这项工作:

  crs = self.iface.mapCanvas().mapRenderer().destinationCrs()
  distance_calc = QgsDistanceArea()
  distance_calc.setSourceCrs(crs)
  distance_calc.setEllipsoid(crs.ellipsoidAcronym())
  distance_calc.setEllipsoidalMode(crs.geographicFlag())
  distance = distance_calc.measureLine([self._startPt,
                     endPt]) / 1000

注意,我们将结果值除以 1000。这是因为 QgsDistanceArea 对象返回的距离是以米为单位的,而我们希望以千米为单位显示距离。

最后,我们将计算出的距离显示在 QGIS 消息栏中:

  messageBar = self.iface.messageBar()
  messageBar.pushMessage("Distance = %d km" % distance,
              level=QgsMessageBar.INFO,
              duration=2)

现在我们已经创建了我们的地图工具,我们需要激活它。我们可以通过将以下内容添加到脚本末尾来实现:

calculator = DistanceCalculator(iface)
iface.mapCanvas().setMapTool(calculator)

在地图工具激活后,用户可以在地图上点击并拖动。当鼠标按钮释放时,两个点之间的距离(以千米为单位)将在消息栏中显示:

计算两个用户定义点之间的距离

摘要

在本章中,我们深入探讨了 PyQGIS 库以及如何在您的程序中使用它们。我们了解到,QGIS Python 库作为 C++ 中实现的 QGIS API 的包装器来实现。我们看到了 Python 程序员如何理解和使用 QGIS 参考文档,尽管它是为 C++ 开发人员编写的。我们还了解了 PyQGIS 库是如何组织成不同的包的,并学习了在 qgis.coreqgis.gui 包中定义的最重要类。

然后,我们看到了如何使用坐标参考系统(CRS)将地球三维表面上的点转换为二维地图平面内的坐标。

我们了解到矢量格式数据由特征组成,其中每个特征都有一个 ID、一个几何形状和一组属性,并且使用符号在地图层上绘制矢量几何形状,而渲染器用于选择给定特征应使用的符号。

我们了解到如何使用空间索引来加速对矢量特征的访问。

接着,我们看到了栅格格式数据是如何组织成代表颜色、高程等信息的光谱的,并探讨了在地图层中显示栅格数据源的各种方法。在这个过程中,我们学习了如何访问栅格数据源的内容。

最后,我们探讨了使用 PyQGIS 库执行有用任务的各种技术。

在下一章中,我们将学习更多关于 QGIS Python 插件的内容,然后继续使用插件架构作为在地图应用程序中实现有用功能的一种方式。

第四章 创建 QGIS 插件

在第一章中,我们简要地了解了 QGIS Python 插件的组织方式。在本章中,我们将利用这些知识来创建两个插件:一个简单的“Hello World”风格插件,以便您了解过程,以及一个更复杂、更有用的插件,它可以显示关于点击的几何体的信息。在这个过程中,我们将学习插件的工作原理,如何创建和分发插件,插件将允许我们做什么,以及将您的映射应用程序作为 QGIS 插件实现的一些可能性和局限性。

准备就绪

在我们深入插件开发过程之前,您需要做三件事:

  1. 从 Qt 开发者网站(qt-project.org)安装Qt 开发者工具

  2. www.riverbankcomputing.co.uk/software/pyqt安装 Qt 的 Python 绑定,称为PyQt。虽然我们不会直接使用 Python 绑定,但 PyQt 中包含两个我们将需要的命令行工具。

    小贴士

    QGIS 目前基于 PyQt4。请确保您安装 Qt 开发者工具和 PyQt 绑定的第 4 版本,以确保您获得兼容版本。

    PyQt 可以作为 MS Windows 的安装程序和 Linux 的源代码形式提供。对于 Mac OS X 用户,可以在sourceforge.net/projects/pyqtx找到二进制安装程序。

  3. 您应该安装并启用 QGIS 的插件重载器插件。这使得开发测试插件变得更加容易。为此,您需要通过从插件菜单中选择管理并安装插件…项,点击设置选项卡,然后打开显示实验性插件复选框来开启实验性插件支持。您将能够看到实验性插件,包括插件重载器。选择此插件,然后点击安装插件按钮进行安装。

    插件重载器向 QGIS 工具栏中添加了按钮,您可以通过点击这些按钮来重新加载您的插件:

    准备就绪

    这允许您对插件进行修改并立即看到结果。如果没有插件重载器,您将不得不退出并重新启动 QGIS 才能使您的更改生效。

理解 QGIS 插件架构

正如我们在第一章中看到的,QGIS 插件存储在~/.qgis2/python/plugins目录中,作为 Python 包。

小贴士

根据您的操作系统和您使用的 QGIS 版本,.qgis2目录可能被命名为.qgis

插件的包包括多个 Python 模块和其他文件。至少,插件包必须包括:

  • __init__.py:这是一个包初始化模块,其中包含类工厂函数,该函数创建并初始化插件。

  • metadata.txt:这是一个包含有关插件信息的文本文件,包括插件的版本号、插件名称和插件作者。

此外,大多数插件还会包括:

  • 一个单独的 Python 模块,其中包含插件的类定义。插件类实现了一些特别命名的、用于启动和关闭插件的方法。

  • 一个或多个扩展名为.ui的用户界面模板文件。

  • 每个用户界面模板的编译版本,形式为一个与模板同名的 Python 模块。

  • 一个resources.qrc文件,这是一个 XML 格式文件,列出了插件使用的各种图像和其他资源。

  • 资源文件的编译版本,形式为一个名为resources.py的 Python 模块。

各种.ui模板文件是使用Qt Designer创建的,它是标准 Qt 安装的一部分。将.qrc.ui文件转换为 Python 模块的命令行工具是 PyQt 的一部分。

当 QGIS 启动时,它会查找在~/.qgis2/python/plugins目录中找到的各个 Python 包。对于每个包,它会尝试调用插件__init__.py文件中的顶级函数ClassFactory()。这个函数应该导入并返回插件对象的实例,如下所示:

  def ClassFactory(iface):
  from myPlugin import MyPlugin
  return MyPlugin(iface)

小贴士

显然,当你编写真正的插件时,你应该将myPlugin(以及MyPlugin)的名称改为更有意义的东西。

虽然通常会在单独的模块中定义插件,但如果你愿意,也可以直接在__init__.py模块中创建它。重要的是要定义一个提供以下方法的类:

  • __init__(iface):这个方法初始化插件对象。请注意,这应该接受传递给类工厂的iface变量并将其存储在实例变量中以供以后使用。

  • initGui():这个方法初始化插件的用户界面。这通常涉及将插件添加到 QGIS 菜单和工具栏,并设置信号处理程序以响应各种事件。

  • unload():这个方法移除插件的用户界面元素。这通常包括从 QGIS 菜单和工具栏中移除插件,以及断开在插件的initGui()方法中定义的信号处理程序。

__init__(iface)方法由你的类工厂函数调用以初始化插件对象本身。然后,当程序启动或用户安装插件时,QGIS 会调用initGui()方法。最后,当用户卸载插件或 QGIS 关闭时,会调用unload()方法。

插件通常在 QGIS 启动时不会立即运行。相反,它安装各种菜单和工具栏项,用户可以选择这些项来执行各种操作。例如,一个简单的插件可能只有一个菜单项和一个工具栏项,当用户选择其中一个时,插件执行其唯一且仅有的操作。更复杂的插件可能有各种菜单和工具栏项,每个项执行不同的操作。

许多插件使用 iface.addPluginToMenu() 方法将其菜单项添加到 插件 菜单中。这为插件的菜单项在 插件 菜单中创建了一个子菜单,使用户能够轻松地看到哪些菜单项是由某个插件提供的。或者,插件可能会选择将其菜单项添加到 向量栅格数据库 菜单中的现有子菜单中,具体取决于情况。

以同样的方式,插件可能会将其图标或小部件添加到插件工具栏中,或者如果它更喜欢的话,添加到其他工具栏中。如果插件想要的话,它还可以在 QGIS 窗口中添加一个全新的工具栏。

创建一个简单的插件

现在我们已经看到了插件的结构和使用方法,让我们创建一个非常简单的 "Hello World" 风格的插件,看看制作一个插件需要哪些步骤。虽然有一些工具,如 插件构建器 插件,可以为您创建各种文件,但我们将避免使用它们,而是手动创建插件。这将使过程更清晰,并避免出现代码在没有了解原因或方式的情况下神奇地工作的情况。

前往 ~/.qgis2/python/plugins 目录,并创建一个名为 testPlugin 的子目录。在这个目录中,创建一个名为 metadata.txt 的文件,并将以下值输入到其中:

[general]
name=Test Plugin
email=test@example.com
author=My Name Here
qgisMinimumVersion=2.0
description=Simple test plugin.
about=A very simple test plugin.
version=version 0.1

这是您为插件需要输入的最小元数据。显然,如果您想的话,可以更改这些值。现在,创建一个包初始化文件,__init__.py,并将以下内容输入到该文件中:

def classFactory(iface):
  from testPlugin import TestPlugin
  return TestPlugin(iface)

如您所见,我们将定义一个名为 TestPlugin 的类,它代表我们的插件对象,并在名为 testPlugin.py 的模块中实现它。现在让我们创建这个模块:

from PyQt4.QtCore import *
from PyQt4.QtGui import *

class TestPlugin:
  def __init__(self, iface):
    self.iface = iface

  def initGui(self):
    self.action = QAction("Run", self.iface.mainWindow())
    QObject.connect(self.action, SIGNAL("triggered()"),
            self.onRun)
    self.iface.addPluginToMenu("Test Plugin", self.action)

  def unload(self):
    self.iface.removePluginMenu("Test Plugin", self.action)

  def onRun(self):
    QMessageBox.information(self.iface.mainWindow(), "debug",
                "Running")

如您所见,我们为我们的菜单项创建了一个 Qt QAction 对象,命名为 Run,并将其添加到名为 "Test Plugin" 的子菜单中的 插件 菜单中。然后我们将该操作连接到我们的 onRun() 方法,该方法简单地显示一个消息给用户,说明插件正在运行。

对于一个非常简单的插件,我们只需要这些。让我们来测试一下。启动 QGIS 并从 插件 菜单中选择 管理并安装插件… 项。QGIS 插件管理器 窗口将出现,如果您向下滚动,应该会看到您的插件列在列表中:

创建一个简单的插件

如果您点击复选框,插件将被激活。如果您然后在 插件 菜单中查看,应该会看到您的插件列在列表中,如果您从插件的子菜单中选择 运行 项,应该会显示 "正在运行" 消息框。

如果您的插件不起作用,或者它没有在插件管理器窗口中列出,您可能在代码中犯了错误。如果由于某种原因插件无法加载,当您尝试安装或重新加载插件时,将出现一个窗口,显示 Python 跟踪回溯:

创建一个简单的插件

如果您的插件代码在运行时生成异常,此窗口也会出现。

小贴士

如果您的插件存在问题,阻止其加载(例如,metadata.txt文件中的错误),您可能需要检查日志消息面板以查看错误。您可以通过从视图菜单中的面板子菜单中选择它来显示此面板;确保您点击插件选项卡以查看与您的插件相关的日志消息:

创建一个简单的插件

让我们在测试插件中添加一个额外的功能:一个工具栏项,当点击时,也会调用onRun()方法。找到一个合适的 24 x 24 像素的 PNG 格式图像(这是 QGIS 工具栏图标的默认大小),并将该图像保存到您的插件目录下,文件名为icon.png。然后,将您的initGui()方法更改为以下内容:

  def initGui(self):
 icon = QIcon(":/plugins/testPlugin/icon.png")
 self.action = QAction(icon, "Run",
 self.iface.mainWindow())
    QObject.connect(self.action, SIGNAL("triggered()"),
            self.onRun)
    self.iface.addPluginToMenu("Test Plugin", self.action)
 self.iface.addToolBarIcon(self.action)

已更改的行已被突出显示。正如您所看到的,我们已向我们的QAction对象添加了一个图标,然后还调用了addToolBarIcon()方法将我们的操作添加到插件工具栏中。

我们还必须在unload()方法中添加一行额外的代码,以便在插件卸载时删除工具栏图标:

  def unload(self):
    self.iface.removePluginMenu("Test Plugin", self.action)
 self.iface.removeToolBarIcon(self.action)

在我们的工具栏图标起作用之前,我们还需要做最后一件事;我们需要告诉 QGIS,icon.png文件是我们插件使用的资源。这是通过resources.qrc文件完成的。现在创建此文件,将其放入您的插件目录中,并使用您喜欢的文本编辑器进行编辑,使其包含以下 XML 格式文本:

<RCC>
  <qresource prefix="/plugins/testPlugin">
    <file>icon.png</file>
  </qresource>
</RCC>

QGIS 无法直接使用此文件;它必须使用pyrcc4命令行工具编译成resources.py模块。此工具作为 PyQt 的一部分安装;一旦您创建了您的resources.qrc文件,请使用以下命令编译它:

pyrcc4 resources.qrc -o resources.py

小贴士

根据 PyQt 安装的位置,您可能需要包含pyrcc4命令的路径。如果您从除插件目录以外的目录运行此命令,您还需要包含resources.qrcresource.py文件的路径。

最后,我们需要将以下内容添加到我们的testPlugin.py模块顶部:

import resources

这使得编译后的资源可供我们的插件使用。当您重新加载插件时,工具栏中应出现一个图标,并且如果您点击该图标,应显示“正在运行”消息框。

虽然这个插件非常基础,但我们实际上学到了很多:如何创建和安装插件,插件如何将自己添加到 QGIS 用户界面,插件如何与用户交互,插件中的错误如何处理,以及如何处理图像和其他插件资源。现在,在我们创建一个真正有用的插件之前,让我们更详细地看看通常用于开发和分发插件的过程。

插件开发过程

在上一节中,我们手动创建了一个插件,直接将必要的文件存储在隐藏的~/.qgis2目录中。这不是构建插件的一种特别稳健的方法。在本节中,我们将探讨一些开发和管理插件的最佳实践,以及创建自己的插件时需要注意的一些事项。

使用插件构建器

QGIS 提供了一个名为插件构建器的插件,您可以使用它从标准模板创建新的插件。插件构建器是一个复杂且有用的工具,用于创建插件,但它确实对您的插件结构以及它将执行的操作做了一些假设。因此,我们故意没有在我们的示例插件中使用插件构建器。

更多关于插件构建器的信息可以在geoapt.net/pluginbuilder找到。您可以直接从 QGIS 中安装插件构建器,使用插件菜单中的管理并安装插件...项。安装后,您只需在工具栏中点击插件构建器的图标,系统会提示您填写有关您新插件的各种详细信息:

使用插件构建器

填写信息后,您将被提示选择存储插件源代码的目录。然后,插件构建器将为您创建必要的文件。

是否使用插件构建器以及是否使用它提供的所有功能取决于您。例如,插件构建器提供了一个make目标,用于使用 Sphynx 创建您插件的 HTML 格式帮助文件。如果您更喜欢以不同的方式创建帮助文件,或者根本不想创建帮助文件,您可以直接忽略此选项。

使用插件构建器的一个问题是生成的插件复杂性。从一开始,您的插件将包括:

  • 帮助文件,包括 reStructuredText 和 HTML 格式,以及用于存储图像和 HTML 模板的目录

  • 国际化支持

  • 一个用于自动化插件构建过程的 Makefile

  • 一个用于将插件上传到 QGIS 插件库的 Python 脚本

  • 一个pylintrc文件,允许您使用 Pylint 代码分析系统检查您的插件 Python 源文件

  • 两个单独的 README 文件,一个是 HTML 格式,另一个是纯文本格式

  • 各种 shell 脚本

  • 一系列标准单元测试

  • 当插件运行时显示对话框的 UI 模板和 Python 代码

所有这些都导致了一个相当复杂的目录结构,其中包含许多可能或可能与你无关的文件。你当然可以删除你不需要的各种文件和目录,但如果你不知道这些文件和目录的作用,这可能会很危险。

由于所有这些复杂性,我们不会在这本书中使用 Plugin Builder。相反,我们将手动创建我们的插件,只添加你需要的文件和目录,这样你就可以理解每件事的作用。

自动化构建过程

对于我们的示例插件,我们必须创建 resources.qrc 文件,然后使用 pyrcc4 命令行工具将此文件编译成 resources.py 文件。每次我们对 resources.qrc 文件进行更改时,都必须记得重新编译它。同样,这也适用于插件中的任何用户界面模板(.ui)文件。

每次更改时手动运行编译器是糟糕的编程实践。相反,你应该使用 Makefile来自动化这个过程。我们不会详细介绍如何使用 make(有关于这个主题的完整书籍),但我们将使用它通过单个命令编译所有必要的文件。我们还将把插件源文件存储在不同的目录中,并使用 make 编译和复制所有必要的文件到 ~/.qgis2 目录:

自动化构建过程

这确保了运行中的插件中的各种文件都是一致的——你不会忘记编译一个模板,或者在没有重新编译模板之前运行更新的 Python 源文件,从而破坏正在运行的插件。将源文件与运行代码分开也是一项优秀的编程实践。

通过这种方式使用 make,你最终会得到一个高度高效的插件开发和测试过程:

自动化构建过程

一个典型的用于构建和运行 QGIS 插件的 Makefile 看起来像这样:

PLUGINNAME = testPlugin
PY_FILES = testPlugin.py __init__.py
EXTRAS = icon.png metadata.txt
UI_FILES = testPluginDialog.py
RESOURCE_FILES = resources.py

default: compile

compile: $(UI_FILES) $(RESOURCE_FILES)

%.py : %.qrc
  pyrcc4 -o $@ $<

%.py : %.ui
  pyuic4 -o $@ $<

deploy: compile
  mkdir -p $(HOME)/.qgis2/python/plugins/$(PLUGINNAME)
  cp -vf $(PY_FILES) $(HOME)/.qgis2/python/plugins/$(PLUGINNAME)
  cp -vf $(UI_FILES) $(HOME)/.qgis2/python/plugins/$(PLUGINNAME)
  cp -vf $(RESOURCE_FILES) $(HOME)/.qgis2/python/plugins/$(PLUGINNAME)
  cp -vf $(EXTRAS) $(HOME)/.qgis2/python/plugins/$(PLUGINNAME)

clean:
  rm $(UI_FILES) $(RESOURCE_FILES)

Makefile 的顶部部分设置了五个变量,告诉 make 关于你的插件的信息:

  • PLUGINNAME 当然是你的插件名称。

  • PY_FILES 是一个包含构成你的插件源代码的 Python 源文件的列表。

  • EXTRAS 是一个包含应与你的插件一起包含的附加文件的列表。你通常会包含 metadata.txt 文件以及插件使用的任何其他图像或其他文件。

  • UI_FILES 是一个包含需要编译以使插件工作的 UI 模板的列表。请注意,你必须为每个模板文件使用 .py 后缀,这样你就是在告诉 make 当相应的 .ui 文件更改时,你想重新编译哪个文件。

  • RESOURCE_FILES 是一个包含应用程序使用的资源文件的列表。同样,你必须为每个资源文件使用 .py 后缀,而不是文件的 .qrc 版本。

通常,您只需更改这五个变量的值即可设置您的 Makefile。然而,如果 pyrcc4pyuic4 命令行工具位于非标准位置,或者如果 QGIS 使用除 ~/.qgis2/python/plugins 之外的其他目录作为其 Python 插件目录,那么您将不得不修改 Makefile 的其他部分,以便它与您的特定开发设置兼容。

一旦设置好,Makefile 提供了三个 make 目标,您可以使用:

  • make compile(或仅 make)将您的插件 .ui.qrc 文件编译成相应的 .py 模块。

  • make deploy 将编译 .ui.qrc 文件,然后将所有必要的文件复制到 QGIS 插件目录中。

  • make clean 将删除 .ui.qrc 文件的 .py 版本。

您可以使用 make deploy 并在 QGIS 中点击插件重载工具来运行您插件的最新版本,以便您可以对其进行测试。

插件帮助文件

QGIS 允许您为您的插件包含一个 HTML 格式的帮助文件。如果您的插件调用 qgis.utils.showPluginHelp() 函数,则该文件将使用内置的 QGIS 帮助浏览器显示。此函数具有以下签名:

showPluginHelp(packageName=None, filename='index', section='')

各种参数如下:

  • packageName:这是可以找到帮助文件的 Python 包的名称。如果指定了包,QGIS 将在给定的包目录中查找帮助文件。否则,它将在调用 showPluginHelp() 的 Python 模块所在的同一目录中查找帮助文件。请注意,插件使用此参数的情况相当不常见,您通常会将其设置为 None

  • filename:这是要显示的 HTML 帮助文件的基名。请注意,将添加适当的后缀(例如,.html)到该基名。

  • section:这是一个可选的 HTML 锚点标签的名称,当帮助文件打开时,它将滚动到该标签。

注意,filename 参数是所需 HTML 文件的 基本 名称。QGIS 允许您将帮助文件翻译成多种语言,并且会根据当前区域设置自动选择适当的文件版本。如果当前语言中没有可用的翻译版本,则 QGIS 将回退到显示帮助文件的美国英语版本;如果该版本也不可用,则将使用名为 filename.html 的文件。

这允许您在需要时包含翻译版本的帮助文件(例如,index-es.htmlindex-de.htmlindex-fr-ca.html),但如果您不想有翻译的帮助文件,一个单独的 index.html 文件就足够了。

您可以通过几种方式组织您插件的在线帮助。以下是一些示例:

  1. 您可以将您插件的全部文档放入一个名为 index.html 的单个文件中,然后只需调用 showPluginHelp() 函数(不带参数)来显示该帮助文件,当用户请求帮助时。

  2. 你可以为你的帮助文件使用不同的文件名,并在调用showPluginHelp()时在filename参数中提供该名称,例如,showPluginHelp(filename="plugin_help")

  3. 你不仅限于只有一个帮助文件。你可以有一个包含多个帮助文件的整个目录,让index.html文件充当插件在线帮助的目录。为此,调用showPluginHelp时将filename设置为类似os.path.join("help_files", "index")的值,这样帮助文件就会在子目录中而不是主插件目录中找到。

  4. 如果你有多份帮助文件,例如,每份对应你插件的主要功能之一,你可能根据用户当时使用的是哪个功能来选择显示相应的帮助文件。例如,你可能会在复杂的对话框或窗口中添加一个帮助按钮,并让该按钮调用showPluginHelp(filename="my_dialog")

  5. 最后,你可能将所有文档放入一个单独的文件中,并使用 HTML 锚点标签(例如,<a id="my_dialog">My Dialog</a>)来定义文档的各个部分。然后,你可以使用section参数直接跳转到插件文档的该部分,如下所示:showPluginHelp(section="my_dialog")

当然,虽然你的帮助文件最终需要以 HTML 格式呈现,但你可能不想直接编写 HTML。相反,你可以使用 Markdown、reStructuredText 或 Latex 等标记语言编写你的文档,然后使用文档生成器将标记文件转换为 HTML。这是一个可以通过 Makefile 自动化的完美示例,实际上,插件构建器的默认 Makefile 包括了使用 Sphinx 将 reStructuredText 标记转换为 HTML 的支持。

单元测试

单元测试是一种常见的编程技术,用于确保你的代码的每一部分都能按预期工作。以下是一个用 Python 编写的非常简单的单元测试示例:

import unittest

def double(n):
  return n * 2

class TestDouble(unittest.TestCase):
  def test(self):
    self.assertEqual(double(2), 4)

你可以直接从命令行运行这个单元测试,或者添加额外的代码来创建一个TestRunner对象,然后你可以使用该对象来运行测试。

我们不会描述单元测试背后的原理,或者如何使用unittest库来测试你的 Python 代码。然而,花些时间学习如何为你的 QGIS 插件编写和运行单元测试是值得的。

注意

如果你之前没有使用过unittest模块,请查看docs.python-guide.org/en/latest/writing/tests

单元测试是在 QGIS 本身之外进行的;也就是说,单元测试作为外部 Python 应用程序运行,该应用程序加载你的插件然后对其进行测试。这样做并不像听起来那么糟糕;在 第一章,QGIS 入门 中,我们查看了一个基于 QGIS 的简单外部应用程序,我们可以使用几乎相同的过程来编写我们的测试代码。以下是从 第一章,QGIS 入门 复制的样板外部应用程序示例:

import os

from qgis.core import *

QgsApplication.setPrefixPath(os.environ['QGIS_PREFIX'], True)
QgsApplication.initQgis()

# ...

QgsApplication.exitQgis()

你还需要使用一个适当的包装脚本,如 第一章,QGIS 入门 中所述,以确保正确设置 Python 路径和其他环境变量。

在 QGIS 单元测试中,你必须在测试运行之前设置 QGIS 环境,然后在测试完成后再次关闭。这是通过将样板代码的适当部分放入单元测试的 setup()tearDown() 方法中实现的,如下所示:

import unittest

import os

from qgis.core import *

class MyTest(unittest.TestCase):
  def setup(self):
    QgsApplication.setPrefixPath(os.environ['QGIS_PREFIX'], True)
    QgsApplication.initQgis()

  def tearDown(self):
    QgsApplication.exitQgis()

  def test_plugin(self):
    ...

然后,你可以在 test_plugin() 方法中导入并测试你的插件 Python 代码。

小贴士

当然,你可以在你的测试用例中拥有多个 test_XXX() 方法。PyQGIS 库将在第一个测试运行之前初始化,并在最后一个测试完成后关闭。

以这种方式测试插件确实揭示了这种方法的一个主要局限性:没有 QgisInterface 对象可供你的插件使用。这意味着你正在测试的插件部分不能通过 iface 变量与 QGIS 系统的其他部分交互。

单元测试通过创建一个假的 QGIS 环境(包括 QgisInterface 的 Python 实现)来克服这个限制,插件可以使用这个环境进行测试。然后,通过将插件目录添加到 sys.path 并调用插件的 ClassFactory() 函数,使用假的 QgisInterface 来加载插件:

sys.path.append("/path/to/my/plugin")
import MyPlugin
plugin = MyPlugin.classFactory(fake_iface)

虽然这个过程看起来很复杂,可能会引入仅在插件测试期间出现的错误,但实际上这个过程非常有用。如果你想使用单元测试,你可以实现自己的 QgsInterface 或使用 Plugin Builder 提供的单元测试框架。

注意

如果你想要自己编写单元测试,一个很好的起点可以在 snorf.net/blog/2014/01/04/writing-unit-tests-for-qgis-python-plugins 找到。

如果你正在进行单元测试,那么你通常会向你的 Makefile 中添加一个额外的目标,这样你就可以通过简单地输入命令来运行单元测试:

make test

分发你的插件

为了与他人分享你的插件,你必须将其上传到插件仓库。让我们看看完成这一步骤的步骤。

首先,你需要确保你的插件遵循以下规则:

  • 您插件文件夹的名称必须只包含大写和小写字母、数字、下划线和连字符,并且不能以数字开头。

  • 您的 metadata.txt 文件必须存在,并包含以下条目:

    元数据条目 描述
    name 您插件的名称。
    qgisMinimumVersion 您的插件将运行的 QGIS 的最低版本。
    description 您插件及其功能的简要文本描述。
    version 您插件的版本号,作为字符串。请注意,您不能上传具有相同版本的插件副本。
    author 插件作者的姓名。
    email 作者的电子邮件地址。

如果您不遵循这些规则,当您尝试上传插件时,它将被拒绝。

下一步是将插件压缩成 ZIP 归档。请注意,您应该压缩包含您的插件的文件夹,这样 ZIP 归档就只有一个条目(插件的目录),而不是一系列单独的文件。

最后一步是将 ZIP 归档上传到 QGIS 插件仓库。这里有您两个选择:

  • 您可以使用官方插件仓库plugins.qgis.org。这将使您的插件对所有 QGIS 用户可用。

  • 您可以设置自己的插件仓库。这意味着只有知道您的仓库或可以访问它的人(例如,通过 VPN)才能下载您的插件。

设置您自己的插件仓库并不像听起来那么可怕;您只需创建一个 XML 文件,列出您希望提供的插件,然后将该 XML 文件以及插件本身上传到 Web 服务器。以下是 XML 文件的外观:

<?xml version="1.0"?>
<plugins>
 <pyqgis_plugin name="MyPlugin" version="0.1">
  <description>This is a test plugin</description>
  <homepage>http://my-site.com/qgis/myplugin</homepage>
  <qgis_minimum_version>2.2</qgis_minimum_version>
  <file_name>myplugin.zip</file_name>
  <author_name>My Name</author_name>
  <download_url>http://my-site.com/myplugin.zip</download_url>
 </pyqgis_plugin>
</plugins>

为您仓库中的每个插件创建一个 <pyqgis_plugin> 部分。一旦上传此文件,用户只需转到 QGIS 插件管理器窗口,点击 设置 选项卡,然后点击窗口中 插件仓库 部分的 添加 按钮。用户将被要求输入新仓库的详细信息:

分发您的插件

URL 字段应设置为已上传 XML 文件的完整 URL,例如 http://my-site.com/qgis_plugins.xml。一旦添加了仓库,XML 文件中列出的插件将出现在插件管理器中,用户可以直接安装它们。

编写有用的插件

现在我们将所学知识应用于构建一个有用的有趣插件。虽然 QGIS 中有内置工具可以查询要素并识别要素的属性,但没有简单的方法来获取与要素相关的 几何形状 信息。因此,让我们编写一个插件,允许用户点击要素并显示该要素几何形状的各种统计数据。

我们将把我们的新插件命名为几何信息。当用户点击我们的插件工具栏图标时,我们将激活一个地图工具,该工具会监听地图画布上的鼠标点击。当用户点击地图画布时,我们将找到用户点击的特征,并计算并显示该特征的几何统计信息。

让我们先为我们的插件设置一个基本模板。创建一个名为geometryInfo的目录,将其放置在方便的位置,并在该目录中创建一个__init__.py文件。在该文件中,放置以下代码:

def classFactory(iface):
  from geometryInfo import GeometryInfoPlugin
  return GeometryInfoPlugin(iface)

接下来,我们需要定义我们插件的数据。创建metadata.txt文件,并将以下内容添加到该文件中:

[general]
name=Geometry Info
email=*your email address*
author=*your name*
qgisMinimumVersion=2.0
description=Displays information about the clicked-on geometry.
about=Plugin used as an example in Chapter 4 of Building Mapping
   Applications with QGIS.
version=version 0.1

接下来,我们需要为我们的插件创建一个图标。我们将使用以下图标:

编写有用的插件

该图标的副本包含在此书的示例代码中,尽管您可以创建自己的图标或找到其他图标来使用;只需确保生成的图像文件命名为icon.png,并且图标大小为 24 x 24 像素。将此文件放入您的geometryInfo目录中,与其他文件一起放置。

我们接下来需要定义一个resources.qrc文件,这个文件会告诉 QGIS 关于我们图标的信息。创建这个文件,并将以下文本放入其中:

<RCC>
  <qresource prefix="/plugins/geometryInfo">
    <file>icon.png</file>
  </qresource>
</RCC>

最后,让我们创建一个 Makefile 来自动化编译和部署我们插件的过程。以下是一个合适的 Makefile,供您开始使用:

PLUGINNAME = geometryInfo
PY_FILES = geometryInfo.py __init__.py
EXTRAS = icon.png metadata.txt
RESOURCE_FILES = resources.py

default: compile

compile: $(RESOURCE_FILES)

%.py : %.qrc
  pyrcc4 -o $@ $<

deploy: compile
  mkdir -p $(HOME)/.qgis2/python/plugins/$(PLUGINNAME)
  cp -vf $(PY_FILES) $(HOME)/.qgis2/python/plugins/$(PLUGINNAME)
  cp -vf $(RESOURCE_FILES) $(HOME)/.qgis2/python/plugins/$(PLUGINNAME)
  cp -vf $(EXTRAS) $(HOME)/.qgis2/python/plugins/$(PLUGINNAME)

clean:
  rm $(RESOURCE_FILES)

您可能需要修改此文件中的路径以适应您的开发设置。请注意,由于我们的插件没有任何 UI 模板,我们已经从 Makefile 中移除了编译和部署模板文件的相应部分。

现在我们已经为我们的插件创建了框架,让我们开始编写实际工作的代码。我们插件需要的最后一个文件将命名为geometryInfo.py。创建此文件,并将以下代码放入其中:

from PyQt4.QtCore import *
from PyQt4.QtGui import *
import resources
from qgis.core import *
from qgis.gui import *

class GeometryInfoPlugin:
  def __init__(self, iface):
    self.iface = iface

  def initGui(self):
    icon = QIcon(":/plugins/geometryInfo/icon.png")
    self.action = QAction(icon, "Get Geometry Info",
               self.iface.mainWindow())
    QObject.connect(self.action, SIGNAL("triggered()"),
            self.onClick)
    self.iface.addPluginToMenu("Geometry Info", self.action)
    self.iface.addToolBarIcon(self.action)

  def unload(self):
    self.iface.removePluginMenu("Geometry Info", self.action)
    self.iface.removeToolBarIcon(self.action)

  def onClick(self):
    QMessageBox.information(self.iface.mainWindow(), "debug",
                "Click")

除了几个额外的import语句(我们稍后会用到)之外,这几乎与我们的早期示例插件相同。onClick()方法当然只是一个占位符,这样我们就可以知道插件是否在正常工作。

我们现在可以通过在命令行中输入make deploy来运行我们的插件,启动 QGIS,并使用管理并安装插件...命令启用插件,就像我们之前做的那样。如果一切顺利,插件图标应该出现在 QGIS 工具栏中,并且当您选择它时,应该显示“点击”消息。

接下来,我们希望使我们的工具栏图标可勾选。也就是说,当用户点击我们的图标时,我们希望突出显示它,激活我们的地图工具,并保持图标突出显示,直到用户再次点击图标或切换到不同的工具。为了使工具栏图标可勾选,请将以下行添加到您的initGui()方法中,紧接在self.action = QAction(...)语句之后:

    self.action.setCheckable(True)

然后,我们必须对工具栏图标的勾选和取消勾选做出响应,通过激活和停用我们的地图工具。以下是代码的示例:

  def onClick(self):
    if not self.action.isChecked():
      # ...deactivate map tool...
      return
    self.action.setChecked(True)
    # ...activate map tool...

我们首先检查用户是否取消选中了我们的图标,如果是这样,我们就停用地图工具。否则,我们通过调用 self.action.setChecked(True) 来视觉上突出显示图标,然后激活我们的地图工具。这样,我们的插件将像 QGIS 中的一个模式一样工作;点击图标将激活地图工具,再次点击它(或选择不同的图标)将停用它。

现在,我们已经准备好实现我们的地图工具。之前,我们看了如何使用 QgsMapTool 类来响应地图画布内的鼠标点击。在这种情况下,我们将使用 QgsMapTool 的一个子类,称为 QgsMapToolIdentify。这个类使得在给定点查找功能变得容易。当用户点击地图画布时,我们将使用 QgsMapToolIdentify.identify() 方法来找到第一个点击的功能,然后计算并显示该功能几何形状的各种统计数据。

将以下代码添加到您的 geometryInfo.py 模块末尾:

class GeometryInfoMapTool(QgsMapToolIdentify):
  def __init__(self, iface):
    QgsMapToolIdentify.__init__(self, iface.mapCanvas())
    self.iface = iface

  def canvasReleaseEvent(self, event):
    QMessageBox.information(self.iface.mainWindow(), "debug",
                "Canvas Click")

这定义了我们的 QgsMapToolIdentify 子类。目前它还没有做任何有用的事情,但它会在用户点击地图画布时响应一个简单的“Canvas Click”消息。现在,让我们完成编写我们插件中的 onClick() 方法,以便在用户点击我们的工具栏图标时激活和停用我们的地图工具。onClick() 方法应该看起来像这样:

  def onClick(self):
    if not self.action.isChecked():
      self.iface.mapCanvas().unsetMapTool(self.mapTool)
      self.mapTool = None
      return
    self.action.setChecked(True)
    self.mapTool = GeometryInfoMapTool(self.iface)
    self.mapTool.setAction(self.action)
    self.iface.mapCanvas().setMapTool(self.mapTool)

现在,您应该能够通过输入 make deploy 来运行您的插件,然后在 QGIS 中重新加载它以查看其工作情况。如果一切顺利,当您点击图标时,工具栏图标将被突出显示,当您点击地图画布时,“Canvas Click”消息应该会出现。

现在,让我们用识别用户点击的功能的代码替换 GeometryInfoMapTool.canvasReleaseEvent() 方法。以下是必要的代码:

    def canvasReleaseEvent(self, event):
    found_features = self.identify(event.x(), event.y(),
                    self.TopDownStopAtFirst,
                    self.VectorLayer)
    if len(found_features) > 0:
      layer = found_features[0].mLayer
      feature = found_features[0].mFeature
      geometry = feature.geometry()

如您所见,我们调用 QgsMapToolIdentify.identify() 来查看用户点击了哪个功能。我们使用的参数告诉该方法只返回用户点击点的最顶层矢量功能;identify() 方法还可以返回给定点的所有功能或像素值(如果用户点击了栅格图层),但在此情况下,我们只想得到最顶层的矢量功能。

一旦我们找到了点击的功能,我们就确定该功能位于哪个地图图层上,并提取该功能的几何形状。有了这些信息,我们可以分析几何形状并显示计算出的统计数据,这正是我们插件的全部目的。

一个 QGSGeometry 对象可以表示一个点、一条线、一个多边形、多个点、多条线、多个多边形,或者不同类型几何形状的集合。为了分析任何 QGSGeometry 对象的统计数据,我们必须准备好处理所有这些不同类型的几何形状。幸运的是,基本逻辑非常简单:

  • 如果几何形状有多个部分,我们将几何形状分割成其组成部分,并依次处理每个部分

  • 对于点几何形状,我们计算点的数量

  • 对于线几何形状,我们计算线的数量并计算它们的总长度

  • 对于多边形几何形状,我们计算多边形的数量并计算它们的总面积和周长

让我们在GeometryInfoMapTool类中添加两个方法来分析几何形状:

  def analyzeGeometry(self, geometry, layer, info):
    crs = layer.dataProvider().crs()
    calculator = QgsDistanceArea()
    calculator.setSourceCrs(crs)
    calculator.setEllipsoid(crs.ellipsoidAcronym())
    calculator.setEllipsoidalMode(crs.geographicFlag())

    if geometry.isMultipart():
      self.add(info, 'num_multi', 1)
      parts = geometry.asGeometryCollection()
      for sub_geometry in parts:
        self.analyzeGeometry(sub_geometry, layer, info)
    elif geometry.type() == QGis.Point:
      self.add(info, 'num_points', 1)
    elif geometry.type() == QGis.Line:
      self.add(info, 'num_lines', 1)
      self.add(info, 'tot_line_length',
           calculator.measure(geometry))
    elif geometry.type() == QGis.Polygon:
      self.add(info, 'num_polygons', 1)
      self.add(info, 'tot_poly_area',
           calculator.measure(geometry))
      self.add(info, 'tot_poly_perimeter',
           calculator.measurePerimeter(geometry))

  def add(self, info, key, n):
    if key in info:
      info[key] = info[key] + n
    else:
      info[key] = n

add()方法只是一个辅助方法,如果字典条目存在,则将其添加到数字中,如果不存在,则创建该条目。这允许我们使用info字典在计算过程中存储结果。

如您所见,analyzeGeometry()方法使用QgsDistanceArea对象来计算几何形状的长度和面积。请注意,我们的analyzeGeometry()方法是递归的;如果一个几何形状有多个部分,每个子几何形状也可能有多个部分,因此我们在每个部分上递归调用analyzeGeometry()以正确处理这些嵌套几何形状。

当我们在给定的QGSGeometry上调用analyzeGeometry()时,分析结果将存储在info字典中。让我们在我们的canvasReleaseEvent()方法的末尾添加一些代码来分析点击的几何形状并显示结果:

  info = {}
  self.analyzeGeometry(geometry, layer, info)
  QMessageBox.information(self.iface.mainWindow(), "debug",
              repr(info))

如果您现在执行make deploy并重新加载插件,您应该能够点击一个要素并显示该要素几何形状的信息。插件输出应如下所示:

编写有用的插件

这当然告诉我们一些有用的信息,但可读性并不高。让我们看看我们如何改进显示统计数据的方式。

首先,请注意面积和周长值并不特别有用;QgsDistanceArea对象返回的长度和面积以米为单位,但对于大多数几何形状,这些值过于精确且过大。让我们通过将计算出的长度和面积转换为千米整数来使其更易于阅读。为此,请在您的analyzeGeometry()方法中进行以下突出显示的更改:

    ...
    elif geometry.type() == QGis.Line:
      self.add(info, 'num_lines', 1)
      self.add(info, 'tot_line_length',
 int(calculator.measure(geometry)/1000))
    elif geometry.type() == QGis.Polygon:
      self.add(info, 'num_polygons', 1)
      self.add(info, 'tot_poly_area',
 int(calculator.measure(geometry)/1000000))
      self.add(info, 'tot_poly_perimeter',
 int(calculator.measurePerimeter(geometry)/1000))

如您所见,我们只是将计算出的长度除以一千以得到千米长度,将计算出的面积除以一百万以得到平方千米面积。

我们想要做的最后一件事是以更友好的方式显示这些计算出的统计数据。为此,将您的canvasReleaseEvent()方法末尾的QMessageBox.information()调用替换为以下内容:

      fields = [("num_multi",
             "Number of multipart geometries", ""),
           ("num_points",
             "Number of point geometries", ""),
           ("num_lines",
             "Number of line geometries", ""),
           ("tot_line_length",
             "Total length of line geometries",
             "km"),
           ("num_polygons",
             "Number of polygon geometries", ""),
           ("tot_poly_area",
             "Total area of polygon geometries",
             "square km"),
           ("tot_poly_perimeter",
             "Total perimeter of polygon geometries",
             "km")]

      results = []
      for field,label,suffix in fields:
        if field in info:
          results.append("%s = %s %s" %
                  (label, str(info[field]),
                  suffix))

      QMessageBox.information(self.iface.mainWindow(),
                  "Geometry Info",
                  "\n".join(results))

您的插件现在将以更易读的格式显示统计数据,例如:

编写有用的插件

现在我们已经完成了我们的插件,并且可以使用它来显示 QGIS 中任何几何形状的信息。更重要的是,我们已经学会了如何创建一个完整且有用的 QGIS 插件,您可以根据这些知识创建自己的插件。

插件的可能性和局限性

正如我们所见,编写一个作为复杂地图工具直接集成到 QGIS 用户界面中的插件是完全可能的,该插件与地图画布交互,并以各种方式响应用户的操作。您可以使用 QGIS 插件完成的其他一些事情包括:

  • 创建自己的QgsMapCanvasItem子类,这样您的插件就可以直接在 QGIS 地图画布上绘制项目。

  • 通过继承QgsPluginLayer来创建自定义地图图层。这使得您的插件可以作为一个完全独立的地图图层。

  • 使用信号处理器来拦截标准 QGIS 操作,例如,在发送信号时重绘画布并执行自己的代码。

  • 通过编程方式创建地图图层,设置数据提供者,以及创建自定义符号和渲染器来控制地图数据的显示方式。

  • 使用 QGIS 地图组合工具来组合渲染的地图图层、标签、图例、表格等,模仿纸质地图的布局。生成的地图视图可以在窗口中显示、打印或保存为图像或 PDF 文件。

然而,QGIS 插件所能做的事情有一些限制:

  • 由于插件本质上是位于运行的 QGIS 应用程序内部,因此您的插件将与用户安装的所有其他插件并行运行,并共享相同的外观界面和菜单结构。这意味着您不能将一键式地图应用程序作为 QGIS 插件实现。QGIS 的全部复杂性都呈现给用户,这可能对那些寻找只执行一项任务的定制应用程序的用户来说令人望而却步。在这种情况下,最好将您的代码编写为使用 PyQGIS 库的外部应用程序,而不是尝试将其编写为插件。

  • 由于插件在 QGIS 本身内运行,插件代码与 QGIS 环境之间有许多接触点。由于 QGIS 不断进化,这意味着当发布新的 QGIS 版本时,插件可能会停止工作。与使用 PyQGIS 库编写的代码相比,这种情况在插件中更为常见。

  • 由于插件使用的是 QGIS 本身内置的 Python 解释器,您无法使用 QGIS Python 解释器中未包含的第三方 Python 库。虽然您可以绕过这一点来使用纯 Python 库(通过将 Python 源代码作为插件的一部分包含),但如果您想要的库使用了用 C 编写的扩展,那么您将无法在插件中使用该库。

最终,决定是否使用插件来实现您的地图应用程序取决于您。对于某些应用程序,插件是理想的;它们当然比外部应用程序更容易开发和分发,如果您的应用程序的目标用户已经是 QGIS 用户,那么插件方案是一种合理的做法。在其他情况下,基于 PyQGIS 构建的外部应用程序可能更适合。

摘要

在本章中,我们深入探讨了 QGIS 插件编程的主题。我们创建了两个独立的插件,一个简单的用于入门,一个更复杂且实用的插件,用于显示点击特征几何信息。我们还探讨了 QGIS 插件架构、插件开发过程以及 QGIS 插件的一些可能性和限制。在这个过程中,我们了解了开发 QGIS 插件所需的工具,发现插件只是包含某些特殊文件的 Python 包,并看到了如何使用 PyQt 命令行工具将用户界面模板和资源描述文件编译成 Python 模块,以便在插件中使用。

我们还探讨了你的插件如何通过图标和菜单项集成到 QGIS 用户界面中,如何运行你的插件,以及当你的插件崩溃时会发生什么。我们还简要地介绍了插件构建器,以及它可能的有用之处。

接下来,我们探讨了如何使用 Makefile 来自动化插件的编译和部署,以及用于开发插件时常用的编写-重新加载-测试循环。我们看到了如何在插件内部编写和使用 HTML 帮助文件,如何使用单元测试来为 QGIS 插件服务,以及如何分发你的插件,无论是到官方 QGIS 插件仓库还是到你自己设置的仓库。

我们了解到,你可以用插件做很多事情,包括在地图画布上绘图、创建自定义图层、拦截 QGIS 操作、以编程方式创建地图图层,以及组合复杂地图。同时,我们也看到了 QGIS 插件在功能上的一些限制,包括需要与其他所有插件共享 QGIS 用户界面、无法创建一键式地图应用、兼容性问题,以及使用某些第三方 Python 库的困难。

在下一章中,我们将探讨如何在你的外部 Python 程序中使用 PyQGIS 库的过程。这可以绕过 QGIS 插件的一些限制,但代价是增加了额外的复杂性。

第五章。在外部应用程序中使用 QGIS

在 第一章,使用 QGIS 入门中,我们简要地查看了一个使用 PyQt 和 PyQGIS 库构建的独立 Python 程序。在本章中,我们将使用相同的技巧,使用 PyQGIS 构建一个完整的即插即用地图应用程序。在这个过程中,我们将:

  • 设计和构建一个简单但完整的独立地图应用程序

  • 学习如何在我们的 Python 程序运行之前使用包装脚本来处理平台特定的依赖项

  • 在单独的 Python 模块中定义我们应用程序的用户界面,以便我们将 UI 与应用程序的业务逻辑分开

  • 根据用户的偏好动态显示和隐藏地图图层

  • 学习如何使用基于规则的渲染器根据地图当前的缩放级别选择性地显示特征

  • 看看如何使用数据定义的属性来计算用于标签的字体大小,基于特征的属性

  • 实现谷歌地图风格的平移和缩放

介绍 Lex

我们的地图应用程序将显示世界地图,允许用户缩放和平移,并在地图上显示各种地标。如果用户点击一个地标,将显示该地标的详细信息。

我们将把我们的应用程序称为 Lex,它是 Landmark explorer 的缩写。Lex 将使用两个免费提供的地理空间数据集:一个高分辨率的阴影地形图,以及一个全面的地点名称数据库,我们将使用它作为显示的地标列表:

介绍 Lex

我们将使用 PyQt 构建 Lex 应用程序,并利用 QGIS 内置的 PyQGIS 库来完成大部分繁重的工作。

对于 Lex 应用程序,我们的要求如下:

  • 它必须作为一个即插即用应用程序运行。双击启动器脚本必须启动 PyQt 程序,加载所有数据,并向用户展示一个完整的工作应用程序。

  • 用户界面必须尽可能专业,包括键盘快捷方式和美观的工具栏图标。

  • 当用户点击一个地标时,应显示该地标的名称和管辖区域、时区和经纬度。

  • 外观和感觉应尽可能类似于谷歌地图。

    注意

    这个最后的要求是一个重要的点,因为 QGIS 内置的缩放和平移工具比我们希望在即插即用地图应用程序中拥有的要复杂。大多数用户已经熟悉谷歌地图的行为,我们希望模仿这种行为,而不是使用 QGIS 提供的默认平移和缩放工具。

不再拖延,让我们开始构建我们的应用程序。我们的第一步将是下载应用程序将基于的地理空间数据。

获取数据

Lex 将使用两个地图层:一个底图层显示阴影高程栅格图像,以及一个地标层根据一组地名显示单个地标。这两个数据集都可以从自然地球数据网站下载。访问www.naturalearthdata.com,并点击获取数据链接跳转到下载页面。

通过点击栅格链接可以找到底图数据。我们希望使用最高分辨率的可用数据,因此请使用大比例尺数据,1:10m部分中的链接。

虽然你可以使用这些数据集作为底图,但我们将下载自然地球 I 带阴影高程、水和排水数据集。确保你下载这个数据集的高分辨率版本,这样当用户放大时,栅格图像仍然看起来很好。

对于地标,我们将使用“人口密集地区”数据集。返回主下载页面,在大比例尺数据,1:10m部分点击文化链接。向下滚动到人口密集地区部分,并点击下载人口密集地区链接。

下载完成后,你应该在电脑上有两个 ZIP 存档:

NE1_HR_LC_SR_W_DR.zip

ne_10m_populated_places.zip

创建一个名为data的文件夹,解压缩前面的两个 ZIP 存档,并将生成的目录放入你的data文件夹中。

设计应用程序

我们现在有一份我们映射应用的需求列表,以及我们想要显示的地理空间数据。然而,在我们开始编码之前,退一步思考我们应用的用户界面是个好主意。

我们的应用程序将有一个主窗口,我们将称之为地标探索器。为了使其易于使用,我们将显示一个地图画布以及一个简单的工具栏。我们的基本窗口布局将如下所示:

设计应用程序

除了主窗口外,我们的 Lex 应用程序还将有一个包含以下菜单的菜单栏:

设计应用程序

工具栏将使新用户通过点击工具栏图标来使用 Lex 变得容易,而经验丰富的用户可以利用广泛的键盘快捷键来访问程序的功能。

带着这个设计思路,让我们开始编码。

创建应用程序框架

首先创建一个用于存放应用程序源代码的文件夹,并将你之前创建的数据文件夹移动到其中。接下来,我们想要使用我们在第一章中学习的技术来创建我们应用程序的基本框架,即使用 QGIS 入门。创建一个名为lex.py的模块,并将以下内容输入到该文件中:

import os, os.path, sys

from qgis.core import *
from qgis.gui import *
from PyQt4.QtGui import *
from PyQt4.QtCore import *

class MapExplorer(QMainWindow):
    def __init__(self):
        QMainWindow.__init__(self)
        self.setWindowTitle("Landmark Explorer")
        self.resize(800, 400)

def main():
    QgsApplication.setPrefixPath(os.environ['QGIS_PREFIX'], True)
    QgsApplication.initQgis()

    app = QApplication(sys.argv)

    window = MapExplorer()
    window.show()
    window.raise_()

    app.exec_()
    app.deleteLater()
    QgsApplication.exitQgis()

if __name__ == "__main__":
    main()

我们只是导入所需的各个库,并使用我们之前学到的技术设置一个外部 PyQGIS 应用程序。然后我们创建并显示一个空白窗口,以便应用程序在启动时能做些事情。

由于我们希望 Lex 应用程序能在任何操作系统上运行,我们不会将 QGIS 的路径硬编码到我们的源代码中。相反,我们将编写一个 封装脚本,在启动我们的 Python 程序之前设置所需的环境变量。由于这些封装脚本依赖于操作系统,您需要为您的操作系统创建一个适当的封装脚本。

注意

注意,我们在 lex.py 模块中使用 os.environ['QGIS_PREFIX'] 以避免将 QGIS 应用程序的路径硬编码到我们的源代码中。我们的封装脚本将负责在应用程序运行之前设置这个环境变量。

如果您使用的是 Microsoft Windows 计算机上的计算机,您的封装脚本看起来可能如下所示:

SET OSGEO4W_ROOT=C:\OSGeo4W
SET QGIS_PREFIX=%OSGEO4W_ROOT%\apps\qgis
SET PATH=%QGIS_PREFIX%\bin;%OSGWO4W_ROOT\bin;%PATH%
SET PYTHONPATH=%QGIS_PREFIX%\python;%OSEO4W_ROOT%\apps\Python27;%PYTHONPATH%
SET PYTHONHOME=%OSGEO4W_ROOT%\apps\Python27
python lex.py

将此脚本命名为有意义的名称,例如,run.bat,并将其放在与您的 lex.py 模块相同的目录中。

如果您使用的是运行 Linux 的计算机,您的封装脚本将被命名为类似 run.sh 的名称,并看起来如下所示:

export PYTHONPATH="/path/to/qgis/build/output/python/"
export LD_LIBRARY_PATH="/path/to/qgis/build/output/lib/"
export QGIS_PREFIX="/path/to/qgis/build/output/"
python lex.py

您需要修改路径以指向 QGIS 已安装的目录。

对于运行 Mac OS X 的用户,您的封装脚本也将被命名为 run.sh,并包含以下内容:

export PYTHONPATH="$PYTHONPATH:/Applications/QGIS.app/Contents/Resources/python"
export DYLD_FRAMEWORK_PATH="/Applications/QGIS.app/Contents/Frameworks"
export QGIS_PREFIX="/Applications/QGIS.app/Contents/Resources"
python lex.py

注意,对于 Mac OS X 和 Linux 系统,我们必须设置框架或库路径。这允许 PyQGIS 的 Python 封装器找到它们所依赖的底层 C++ 共享库。

提示

如果您在 Linux 或 Mac OS X 下运行,您还必须使您的封装脚本可执行。为此,请在 bash shell 或终端窗口中输入 chmod +x run.sh

一旦您创建了您的 shell 脚本,尝试运行它。如果一切顺利,您的 PyQt 应用程序应该启动并显示一个空白窗口,如下所示:

创建应用程序框架

如果它不起作用,您需要检查您的封装脚本和/或您的 lex.py 模块。您可能需要修改目录路径以匹配您的 QGIS 和 Python 安装。

添加用户界面

现在我们程序正在运行,我们可以开始实现用户界面(UI)。一个典型的 PyQt 应用程序将使用 Qt Designer 将应用程序的 UI 存储在一个模板文件中,然后将其编译成一个 Python 模块,以便在您的应用程序中使用。

由于描述如何使用 Qt Designer 来布局带有工具栏和菜单的窗口需要很多页面,我们将采取捷径,直接在 Python 中创建用户界面。同时,我们还将创建我们的 UI 模块,就像它是使用 Qt Designer 创建的一样;这使我们的应用程序 UI 保持独立,同时也展示了如果使用 Qt Designer 设计用户界面,我们的应用程序将如何工作。

创建一个名为ui_explorerWindow.py的新模块,并将以下代码输入到该模块中:

from PyQt4 import QtGui, QtCore

import resources

class Ui_ExplorerWindow(object):
    def setupUi(self, window):
        window.setWindowTitle("Landmark Explorer")

        self.centralWidget = QtGui.QWidget(window)
        self.centralWidget.setMinimumSize(800, 400)
        window.setCentralWidget(self.centralWidget)

        self.menubar = window.menuBar()
        self.fileMenu = self.menubar.addMenu("File")
        self.viewMenu = self.menubar.addMenu("View")
        self.modeMenu = self.menubar.addMenu("Mode")

        self.toolBar = QtGui.QToolBar(window)
        window.addToolBar(QtCore.Qt.TopToolBarArea, self.toolBar)

        self.actionQuit = QtGui.QAction("Quit", window)
        self.actionQuit.setShortcut(QtGui.QKeySequence.Quit)

        self.actionShowBasemapLayer = QtGui.QAction("Basemap", window)
        self.actionShowBasemapLayer.setShortcut("Ctrl+B")
        self.actionShowBasemapLayer.setCheckable(True)

        self.actionShowLandmarkLayer = QtGui.QAction("Landmarks", window)
        self.actionShowLandmarkLayer.setShortcut("Ctrl+L")
        self.actionShowLandmarkLayer.setCheckable(True)

        icon = QtGui.QIcon(":/icons/mActionZoomIn.png")
        self.actionZoomIn = QtGui.QAction(icon, "Zoom In", window)
        self.actionZoomIn.setShortcut(QtGui.QKeySequence.ZoomIn)

        icon = QtGui.QIcon(":/icons/mActionZoomOut.png")
        self.actionZoomOut = QtGui.QAction(icon, "Zoom Out", window)
        self.actionZoomOut.setShortcut(QtGui.QKeySequence.ZoomOut)

        icon = QtGui.QIcon(":/icons/mActionPan.png")
        self.actionPan = QtGui.QAction(icon, "Pan", window)
        self.actionPan.setShortcut("Ctrl+1")
        self.actionPan.setCheckable(True)

        icon = QtGui.QIcon(":/icons/mActionExplore.png")
        self.actionExplore = QtGui.QAction(icon, "Explore", window)
        self.actionExplore.setShortcut("Ctrl+2")
        self.actionExplore.setCheckable(True)

        self.fileMenu.addAction(self.actionQuit)

        self.viewMenu.addAction(self.actionShowBasemapLayer)
        self.viewMenu.addAction(self.actionShowLandmarkLayer)
        self.viewMenu.addSeparator()
        self.viewMenu.addAction(self.actionZoomIn)
        self.viewMenu.addAction(self.actionZoomOut)

        self.modeMenu.addAction(self.actionPan)
        self.modeMenu.addAction(self.actionExplore)

        self.toolBar.addAction(self.actionZoomIn)
        self.toolBar.addAction(self.actionZoomOut)
        self.toolBar.addAction(self.actionPan)
        self.toolBar.addAction(self.actionExplore)

        window.resize(window.sizeHint())

此模块实现了我们的 Lex 应用程序的用户界面,为每个工具栏和菜单项定义了一个QtAction对象,创建了一个用于容纳我们的地图画布的小部件,并在QtMainWindow对象内布局一切。此模块的结构与 Qt Designer 和pyuic4命令行工具将用户界面模板提供给 Python 代码的方式相同。

注意,Ui_ExplorerWindow类使用了多个工具栏图标。我们需要创建这些图标图像并在资源描述文件中定义它们,就像我们在上一章中创建resources.py模块一样。

我们将需要以下图标图像:

  • mActionZoomIn.png

  • mActionZoomOut.png

  • mActionPan.png

  • mActionExplore.png

如果你愿意,你可以从 QGIS 源代码库中下载这些图像文件(SVG 格式)github.com/qgis/QGIS/tree/master/images/themes/default,但你需要将它们从.svg转换为.png以避免图像文件格式问题。如果你不想自己转换图标,这些图像作为本书提供的源代码的一部分可用。完成后,将这些四个文件放置在 Lex 应用程序的主目录中。

小贴士

注意,mActionExplore.png图标文件是源代码库中mActionIdentify.svg图像的转换副本。我们将图像文件重命名为与 Lex 应用程序中工具的名称相匹配。

接下来,我们需要创建我们的resources.qrc文件,以便 PyQt 可以使用这些图像。创建此文件并输入以下内容:

<RCC>
    <qresource prefix="/icons">
        <file>mActionZoomIn.png</file>
        <file>mActionZoomOut.png</file>
        <file>mActionPan.png</file>
        <file>mActionExplore.png</file>
    </qresource>
</RCC>

你需要使用pyrcc4编译此文件。这将为你提供用户界面所需的resources.py模块。

现在我们已经定义了我们的用户界面,让我们修改lex.py模块以使用它。将以下import语句添加到模块的顶部:

from ui_explorerWindow import Ui_ExplorerWindow
import resources

接下来,我们想要用我们新的 UI 替换MapExplorer窗口的占位实现。MapExplorer类的定义应该如下所示:

class MapExplorer(QMainWindow, Ui_ExplorerWindow):
    def __init__(self):
        QMainWindow.__init__(self)

        self.setupUi(self)

如果一切顺利,我们的应用程序现在应该运行带有完整的用户界面——工具栏、菜单和我们的地图画布的空间:

添加用户界面

当然,我们的用户界面目前还没有任何功能,但我们的 Lex 应用程序开始看起来像是一个真正的程序。现在,让我们实现 UI 背后的行为。

连接操作

你可能已经注意到,菜单命令和工具栏图标目前都没有任何作用——即使是退出命令也不工作。在我们操作之前,我们必须将它们连接到适当的方法。为此,请将以下内容添加到MapExplorer.__init__()方法中,紧接在调用setupUi()之后:

        self.connect(self.actionQuit,
                     SIGNAL("triggered()"), qApp.quit)
        self.connect(self.actionShowBasemapLayer,
                     SIGNAL("triggered()"), self.showBasemapLayer)
        self.connect(self.actionShowLandmarkLayer,
                     SIGNAL("triggered()"),
                     self.showLandmarkLayer)
        self.connect(self.actionZoomIn,
                     SIGNAL("triggered()"), self.zoomIn)
        self.connect(self.actionZoomOut,
                     SIGNAL("triggered()"), self.zoomOut)
        self.connect(self.actionPan,
                     SIGNAL("triggered()"), self.setPanMode)
        self.connect(self.actionExplore,
                     SIGNAL("triggered()"), self.setExploreMode)

我们将我们的 退出 动作连接到 qApp.quit() 方法。对于其他动作,我们将在 MapExplorer 类本身内部调用方法。让我们为这些方法定义一些占位符:

    def showBasemapLayer(self):
        pass

    def showLandmarkLayer(self):
        pass

    def zoomIn(self):
        pass

    def zoomOut(self):
        pass

    def setPanMode(self):
        pass

    def setExploreMode(self):
        pass

我们将在地图画布设置好并运行之后实现这些方法。

创建地图画布

我们的 Ui_ExplorerWindow 类定义了一个名为 centralWidget 的实例变量,它作为窗口内容的占位符。由于我们想在窗口中放置一个 QGIS 地图画布,让我们实现创建地图画布并将其放置到这个中央小部件中的代码。将以下内容添加到 MapExplorer 窗口的 __init__() 方法的末尾(在 lex.py 中):

        self.mapCanvas = QgsMapCanvas()
        self.mapCanvas.useImageToRender(False)
        self.mapCanvas.setCanvasColor(Qt.white)
        self.mapCanvas.show()

        layout = QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(self.mapCanvas)
        self.centralWidget.setLayout(layout)

接下来,我们希望将底图和地标图图层填充到地图画布中。为此,我们将定义一个新的方法,称为 loadMap(),并在适当的时候调用它。将以下方法添加到您的 MapExplorer 类中:

    def loadMap(self):
        cur_dir = os.path.dirname(os.path.realpath(__file__))
        filename = os.path.join(cur_dir, "data",
                                "NE1_HR_LC_SR_W_DR",
                                "NE1_HR_LC_SR_W_DR.tif")
        self.basemap_layer = QgsRasterLayer(filename, "basemap")
        QgsMapLayerRegistry.instance().addMapLayer(
                self.basemap_layer)

        filename = os.path.join(cur_dir, "data",
                                "ne_10m_populated_places",
                                "ne_10m_populated_places.shp")
        self.landmark_layer = QgsVectorLayer(filename,
                                             "landmarks", "ogr")
        QgsMapLayerRegistry.instance().addMapLayer(
               self.landmark_layer)

        self.showVisibleMapLayers()
        self.mapCanvas.setExtent(QgsRectangle(-127.7, 24.4, -79.3, 49.1))

此方法加载我们放置在 data 目录中的栅格和矢量数据集。然后我们调用一个新的方法 showVisibleMapLayers() 来使这些图层可见,并在应用程序首次启动时设置地图画布的范围以显示美国大陆。

让我们实现 showVisibleMapLayers() 方法:

    def showVisibleMapLayers(self):
        layers = []
        if self.actionShowLandmarkLayer.isChecked():
            layers.append(QgsMapCanvasLayer(self.landmark_layer))
        if self.actionShowBasemapLayer.isChecked():
            layers.append(QgsMapCanvasLayer(self.basemap_layer))
        self.mapCanvas.setLayerSet(layers)

由于用户可以选择单独显示或隐藏底图和地标图层,我们只显示用户选择显示的图层。我们还将其放入一个单独的方法中,以便在用户切换图层的可见性时调用它。

在我们的地图可以显示之前,还有一些事情要做。首先,在调用 window.raise_() 之后,立即在 main() 函数中添加以下行:

    window.loadMap()

这将在窗口显示后加载地图。接下来,将以下内容添加到主窗口的 __init__() 方法的末尾:

        self.actionShowBasemapLayer.setChecked(True)
        self.actionShowLandmarkLayer.setChecked(True)

这使得两个图层在程序启动时可见。最后,让我们实现我们之前定义的两个方法,以便用户可以选择显示哪些图层:

    def showBasemapLayer(self):
        self.showVisibleMapLayers()

    def showLandmarkLayer(self):
        self.showVisibleMapLayers()

运行程序应显示两个地图图层,您可以使用 视图 菜单中的命令显示或隐藏每个图层:

创建地图画布

标记点

如前图所示,每个地标仅由一个彩色点表示。为了使程序更有用,我们希望显示每个地标的名称。这可以通过使用 QGIS 内置的 "PAL" 标签引擎来完成。将以下代码添加到您的 loadMap() 方法中,在调用 self.showVisibleMapLayers() 之前立即执行:

        p = QgsPalLayerSettings()
        p.readFromLayer(self.landmark_layer)
        p.enabled = True
        p.fieldName = "NAME"
        p.placement = QgsPalLayerSettings.OverPoint
        p.displayAll = True
        p.setDataDefinedProperty(QgsPalLayerSettings.Size,
                                 True, True, "12", "")
        p.quadOffset = QgsPalLayerSettings.QuadrantBelow
        p.yOffset = 1
        p.labelOffsetInMapUnits = False
        p.writeToLayer(self.landmark_layer)

        labelingEngine = QgsPalLabeling()
        self.mapCanvas.mapRenderer().setLabelingEngine(labelingEngine)

这将为地图上的每个点添加标签。不幸的是,有很多点,结果地图几乎无法阅读:

标记点

过滤地标

我们的标签之所以难以阅读,是因为显示的地标太多。然而,并非所有地标在所有缩放级别都相关——我们希望在地图缩放时隐藏太小而无法使用的地标,同时当用户放大时仍然显示这些地标。为此,我们将使用 QgsRuleBasedRendererV2 对象并利用 SCALERANK 属性来选择性地隐藏对于当前缩放级别来说太小的不必要特征。

在调用 self.showVisibleMapLayers() 之前,将以下代码添加到您的 loadMap() 方法中:

        symbol = QgsSymbolV2.defaultSymbol(self.landmark_layer.geometryType())
        renderer = QgsRuleBasedRendererV2(symbol)
        root_rule = renderer.rootRule()
        default_rule = root_rule.children()[0]

        rule = default_rule.clone()
        rule.setFilterExpression("(SCALERANK >= 0) and (SCALERANK <= 1)")
        rule.setScaleMinDenom(0)
        rule.setScaleMaxDenom(99999999)
        root_rule.appendChild(rule)

        rule = default_rule.clone()
        rule.setFilterExpression("(SCALERANK >= 2) and (SCALERANK <= 4)")
        rule.setScaleMinDenom(0)
        rule.setScaleMaxDenom(10000000)
        root_rule.appendChild(rule)

        rule = default_rule.clone()
        rule.setFilterExpression("(SCALERANK >= 5) and (SCALERANK <= 7)")
        rule.setScaleMinDenom(0)
        rule.setScaleMaxDenom(5000000)
        root_rule.appendChild(rule)

        rule = default_rule.clone()
        rule.setFilterExpression("(SCALERANK >= 7) and (SCALERANK <= 10)")
        rule.setScaleMinDenom(0)
        rule.setScaleMaxDenom(2000000)
        root_rule.appendChild(rule)

        root_rule.removeChildAt(0)
        self.landmark_layer.setRendererV2(renderer)

这将在地图缩放时隐藏过小的地标(即具有过大 SCALERANK 值的地标)。现在,我们的地图看起来更加合理:

过滤地标

目前,我们还想添加一个功能;目前,所有标签的大小都是相同的。然而,我们希望较大的地标显示更大的标签。为此,将您的程序中的 p.setDataDefinedProperty(...) 行替换为以下内容:

        expr = ("CASE WHEN SCALERANK IN (0,1) THEN 18" +
                "WHEN SCALERANK IN (2,3,4) THEN 14 " +
                "WHEN SCALERANK IN (5,6,7) THEN 12 " +
                "WHEN SCALERANK IN (8,9,10) THEN 10 " +
                "ELSE 9 END")
        p.setDataDefinedProperty(QgsPalLayerSettings.Size, True,
                                 True, expr, "")

这根据特征的 SCALERANK 属性值计算字体大小。正如您所想象的,以这种方式使用数据定义属性可以非常有用。

实现缩放工具

接下来,我们希望支持缩放和放大。如前所述,Lex 应用程序的一个要求是它必须像 Google Maps 而不是 QGIS 一样工作,这是一个我们必须支持的地方。QGIS 有一个缩放工具,用户点击它,然后在地图上点击或拖动以缩放。在 Lex 中,用户将直接点击工具栏图标来进行缩放。幸运的是,这很容易做到;只需以下方式实现 zoomIn()zoomOut() 方法:

    def zoomIn(self):
        self.mapCanvas.zoomIn()

    def zoomOut(self):
        self.mapCanvas.zoomOut()

现在,尝试运行您的程序。在您缩放和放大时,您可以看到各种地标的出现和消失,您也应该能够看到根据每个特征的 SCALERANK 值使用的不同字体大小。

实现平移工具

平移(即点击并拖动地图以移动)是另一个 QGIS 默认行为并不完全符合我们期望的领域。QGIS 包括一个 classQgsMapToolPan 类,它实现了平移;然而,它还包含了一些可能会让来自 Google Maps 的用户感到困惑的功能。特别是,如果用户点击而不拖动,地图将重新居中到点击的点。我们不会使用 classQgsMapToolPan,而是将实现我们自己的平移地图工具。幸运的是,这很简单:只需在 MapExplorer 类定义之后,将以下类定义添加到您的 lex.py 模块中:

class PanTool(QgsMapTool):
    def __init__(self, mapCanvas):
        QgsMapTool.__init__(self, mapCanvas)
        self.setCursor(Qt.OpenHandCursor)
        self.dragging = False

    def canvasMoveEvent(self, event):
        if event.buttons() == Qt.LeftButton:
            self.dragging = True
            self.canvas().panAction(event)

    def canvasReleaseEvent(self, event):
        if event.button() == Qt.LeftButton and self.dragging:
            self.canvas().panActionEnd(event.pos())
            self.dragging = False

我们需要将以下内容添加到主窗口的 __init__() 方法末尾,以创建我们的平移工具的实例:

        self.panTool = PanTool(self.mapCanvas)
        self.panTool.setAction(self.actionPan)

我们现在可以实施我们的 setPanMode() 方法来使用这个地图工具:

    def setPanMode(self):
        self.actionPan.setChecked(True)
        self.mapCanvas.setMapTool(self.panTool)

最后,我们希望在应用程序启动时选择平移模式。为此,在调用 window.loadMap() 之后,将以下内容添加到您的 main() 函数中:

    window.setPanMode()

实现探索模式

到目前为止,用户可以选择显示哪些地图图层,并且可以缩放和平移地图视图。唯一缺少的是应用程序的整个目的:探索地标。为此,我们必须实现应用程序的 探索 模式。

在上一章中,我们看到了如何使用 QgsMapToolIdentify 子类来响应用户点击矢量要素。我们将在这里使用相同的逻辑来实现一个新的地图工具,我们将称之为 ExploreTool。在 PanTool 类定义之后,将以下类定义添加到您的 lex.py 模块中:

class ExploreTool(QgsMapToolIdentify):
    def __init__(self, window):
        QgsMapToolIdentify.__init__(self, window.mapCanvas)
        self.window = window

    def canvasReleaseEvent(self, event):
        found_features = self.identify(event.x(), event.y(),
                                       self.TopDownStopAtFirst,
                                       self.VectorLayer)
        if len(found_features) > 0:
            layer = found_features[0].mLayer
            feature = found_features[0].mFeature
            geometry = feature.geometry()

            info = []

            name = feature.attribute("NAME")
            if name != None: info.append(name)

            admin_0 = feature.attribute("ADM0NAME")
            admin_1 = feature.attribute("ADM1NAME")
            if admin_0 and admin_1:
                info.append(admin_1 + ", " + admin_0)

            timezone = feature.attribute("TIMEZONE")
            if timezone != None:
                info.append("Timezone: " + timezone)

            longitude = geometry.asPoint().x()
            latitude  = geometry.asPoint().y()
            info.append("Lat/Long: %0.4f, %0.4f" % (latitude,
                                                    longitude))

            QMessageBox.information(self.window,
                                    "Feature Info",
                                    "\n".join(info))

此工具识别用户点击的地标要素,提取该要素的相关属性,并在消息框中显示结果。要使用我们新的地图工具,我们必须将以下内容添加到 MapExplorer 窗口的 __init__() 方法的末尾:

        self.exploreTool = ExploreTool(self)
        self.exploreTool.setAction(self.actionExplore)

我们接下来需要实现我们的 setExploreMode() 方法来使用这个工具:

        def setExploreMode(self):
        self.actionPan.setChecked(False)
        self.actionExplore.setChecked(True)
        self.mapCanvas.setMapTool(self.exploreTool)

注意,当用户切换到探索模式时,我们必须取消勾选平移模式操作。这确保了两种模式是互斥的。我们必须采取的最后一步是修改我们的 setPanMode() 方法,以便当用户切换回平移模式时取消勾选探索模式操作。为此,将以下突出显示的行添加到您的 setPanMode() 方法中:

    def setPanMode(self):
        self.actionPan.setChecked(True)
 self.actionExplore.setChecked(False)
        self.mapCanvas.setMapTool(self.panTool)

这完成了我们的 Lex 程序。现在用户可以放大和缩小,平移地图,并点击要素以获取有关该地标的更多信息:

实现探索模式

进一步的改进和增强

当然,虽然 Lex 是一个有用且完整的地图应用程序,但它实际上只是一个起点。免费提供的已有人口数据集提供的信息并不构成一个特别有趣的地标集,而且我们的应用程序仍然相当基础。以下是一些您可以对 Lex 应用程序进行的建议性改进:

  • 添加一个 搜索 操作,用户可以输入要素的名称,Lex 将缩放和平移地图以显示该要素。

  • 让用户选择任意两个地标,并显示这两个点之间的距离,单位为千米和英里。

  • 允许用户加载他们自己的地标集,无论是从 shapefile 还是 Excel 电子表格中。当从 shapefile 加载时,用户可能会被提示选择要显示的每个要素的属性。当从电子表格(例如使用 xlrd 库)加载数据时,不同的列将包含纬度和经度值,以及要显示的标签和其他数据。

  • 看看将 Lex 应用程序和 QGIS 本身捆绑成一个双击即可安装的操作系统安装程序涉及哪些内容。PyQGIS 开发者手册中提供了一些关于如何做到这一点的技巧,并且有各种工具,如 py2exepy2app,您可以用它们作为起点。

实现这些额外功能是了解 PyQGIS 以及如何在您自己的独立地图程序中使用它的绝佳方式。

摘要

在本章中,我们使用 PyQGIS 设计并实现了一个简单但完整的即用型地图应用程序。在这个过程中,我们学习了如何使用包装脚本将特定平台的设置排除在您的 Python 程序之外。我们还看到了即使我们不使用 Qt Designer 创建用户界面模板,我们也可以在单独的模块中定义我们应用程序的 UI。

我们学习了如何使用 QGIS 内置的 "PAL" 标签引擎在矢量地图层中为每个要素显示标签。我们看到 QgsRuleBasedRendererV2 对象可以用来根据地图的缩放因子显示或隐藏某些要素,并且数据定义的属性允许我们计算诸如标签字体大小之类的值;我们还看到了如何使用 CASE...WHEN 表达式以复杂的方式计算数据定义的属性。

最后,我们看到了如何在地图应用程序中实现 Google Maps 风格的平移和缩放。

在下一章中,我们将了解 QGIS Python API 的更多高级功能以及我们如何在我们的地图应用程序中使用它们。

第六章. 掌握 QGIS Python API

在本章中,我们将探讨 PyQGIS 库的许多更高级的方面,以及使用 Python 操作 QGIS 的各种技术。特别是,我们将学习:

  • 如何与符号层一起工作

  • 使用符号在地图上绘制矢量数据的高级方法

  • 如何在 Python 中实现自己的符号和渲染器

  • 如何使用 Python 创建自定义地图层

  • 如何实现自己的自定义地图画布项

  • 如何使用内存数据提供者

使用符号层进行工作

在前面的章节中,我们通过实例化QgsSymbolV2的三个基本子类之一来创建符号以显示矢量要素:

  • QgsMarkerSymbolV2用于点几何形状

  • QgsLineSymbolV2用于线几何形状

  • QgsFillSymbolV2用于多边形几何形状

我们这样做是通过调用前面类的一个静态createSimple()方法,或者通过请求QgsSymbolV2类为我们提供一个给定几何类型的默认符号。无论我们如何操作,结果都是一个现成的符号对象,用于显示给定的矢量几何类型。

内部,符号由一个或多个符号层组成,这些层一个接一个地显示,以绘制矢量要素:

使用符号层

注意

符号层按它们添加到符号中的顺序绘制。因此,在这个例子中,符号层 1将在符号层 2之前绘制。这会导致第二个符号层在第一个符号层之上绘制。确保你正确地排列了符号层的顺序,否则你可能会发现符号层被另一个层完全遮挡。

虽然我们迄今为止所使用的符号只有一个层,但你可以使用多层符号执行一些巧妙的技巧。我们将在组合符号层这一节中查看多层符号。

当你创建一个符号时,它将自动使用默认的符号层进行初始化。例如,一个线符号(QgsLineSymbolV2的一个实例)将创建一个单层的QgsSimpleLineSymbolLayerV2类型。这个层用于在地图上绘制线要素。

要使用符号层,你需要移除这个默认层,并用你自己的符号层或多个符号层替换它。例如:

symbol = QgsSymbolV2.defaultSymbol(layer.geometryType())
symbol.deleteSymbolLayer(0) # Remove default symbol layer.

symbol_layer_1 = QgsSimpleFillSymbolLayerV2()
symbol_layer_1.setFillColor(QColor("yellow"))

symbol_layer_2 = QgsLinePatternFillSymbolLayer()
symbol_layer_2.setLineAngle(30)
symbol_layer_2.setDistance(2.0)
symbol_layer_2.setLineWidth(0.5)
symbol_layer_2.setColor(QColor("green"))

symbol.appendSymbolLayer(symbol_layer_1)
symbol.appendSymbolLayer(symbol_layer_2)

可以使用以下方法来操作符号内的层:

  • symbol.symbolLayerCount(): 这将返回此符号中符号层的数量。

  • symbol.symbolLayer(index): 这将返回符号中的给定符号层。请注意,第一个符号层的索引为零。

  • symbol.changeSymbolLayer(index, symbol_layer): 这将替换符号中的指定符号层。

  • symbol.appendSymbolLayer(symbol_layer): 这将在符号中追加一个新的符号层。

  • symbol.insertSymbolLayer(index, symbol_layer): 这将在指定的索引处插入一个符号层。

  • symbol.deleteSymbolLayer(index): 这将移除给定索引处的符号层。

    注意

    记住,一旦创建符号,你需要创建一个适当的渲染器,然后将该渲染器分配给你的地图层。例如:

    renderer = QgsSingleSymbolRendererV2(symbol)
    layer.setRendererV2(renderer)
    
    

以下符号层类可供您使用:

PyQGIS 类 描述 示例
QgsSimpleMarkerSymbolLayerV2 这将点几何形状显示为一个小彩色的圆圈。 使用符号层
QgsEllipseSymbolLayerV2 这将点几何形状显示为椭圆。 使用符号层
QgsFontMarkerSymbolLayerV2 这将点几何形状显示为单个字符。你可以选择要显示的字体和字符。 使用符号层
QgsSvgMarkerSymbolLayerV2 这使用单个 SVG 格式图像显示点几何形状。 使用符号层
QgsVectorFieldSymbolLayer 这通过绘制位移线来显示点几何形状。线的一端是点的坐标,而另一端使用特征的属性计算得出。 使用符号层
QgsSimpleLineSymbolLayerV2 这使用给定颜色、宽度和样式的线条显示线几何形状或多边形几何形状的轮廓。 使用符号层
QgsMarkerLineSymbolLayerV2 这通过沿线的长度重复绘制标记符号来显示线几何形状或多边形几何形状的轮廓。 使用符号层
QgsSimpleFillSymbolLayerV2 这通过填充给定实心颜色并在周围绘制线条来显示多边形几何形状。 使用符号层
QgsGradientFillSymbolLayerV2 这使用颜色或灰度渐变填充多边形几何形状的内部。 使用符号层
QgsCentroidFillSymbolLayerV2 这将在多边形几何形状的质心处绘制一个简单的点。 使用符号层
QgsLinePatternFillSymbolLayer 这使用重复的线绘制多边形几何形状的内部。你可以选择用于线的角度、宽度和颜色。 使用符号层
QgsPointPatternFillSymbolLayer 这使用重复的点绘制多边形几何形状的内部。 使用符号层
QgsSVGFillSymbolLayer 这使用重复的 SVG 格式图像绘制多边形几何形状的内部。 使用符号层

这些预定义的符号层,无论是单独使用还是以各种组合方式,都为你提供了在显示特征时的巨大灵活性。然而,如果您觉得这些还不够,您也可以使用 Python 实现自己的符号层。我们将在本章后面讨论如何实现。

符号层的组合

通过组合符号层,你可以实现一系列复杂的视觉效果。例如,你可以将QgsSimpleMarkerSymbolLayerV2的一个实例与QgsVectorFieldSymbolLayer的一个实例结合起来,同时使用两个符号来显示点几何图形:

组合符号层

符号层的主要用途之一是绘制不同的 LineString 或 PolyLine 符号来表示不同类型的道路。例如,你可以通过组合多个符号层来绘制复杂的道路符号,如下所示:

组合符号层

这种效果是通过使用三个独立的符号层实现的:

组合符号层

这里是用于生成前面地图符号的 Python 代码:

symbol =QgsLineSymbolV2.createSimple({})
symbol.deleteSymbolLayer(0) # Remove default symbol layer.

symbol_layer = QgsSimpleLineSymbolLayerV2()
symbol_layer.setWidth(4)
symbol_layer.setColor(QColor("light gray"))
symbol_layer.setPenCapStyle(Qt.FlatCap)
symbol.appendSymbolLayer(symbol_layer)

symbol_layer = QgsSimpleLineSymbolLayerV2()
symbol_layer.setColor(QColor("black"))
symbol_layer.setWidth(2)
symbol_layer.setPenCapStyle(Qt.FlatCap)
symbol.appendSymbolLayer(symbol_layer)

symbol_layer = QgsSimpleLineSymbolLayerV2()
symbol_layer.setWidth(1)
symbol_layer.setColor(QColor("white"))
symbol_layer.setPenStyle(Qt.DotLine)
symbol.appendSymbolLayer(symbol_layer)

如你所见,你可以设置线宽、颜色和样式来创建你想要的效果。像往常一样,你必须按正确的顺序定义层,最底层的符号层首先定义。通过这种方式组合线符号层,你可以创建几乎任何类型的道路符号。

你也可以在显示多边形几何图形时使用符号层。例如,你可以在QgsSimpleFillSymbolLayerV2之上绘制QgsPointPatternFillSymbolLayer,以便在简单填充多边形上重复出现点,如下所示:

组合符号层

最后,你可以利用透明度来使各种符号层(或整个符号)相互融合。例如,你可以通过组合两个符号层来创建点状条纹效果,如下所示:

symbol = QgsFillSymbolV2.createSimple({})
symbol.deleteSymbolLayer(0) # Remove default symbol layer.

symbol_layer = QgsGradientFillSymbolLayerV2()
symbol_layer.setColor2(QColor("dark gray"))
symbol_layer.setColor(QColor("white"))
symbol.appendSymbolLayer(symbol_layer)

symbol_layer = QgsLinePatternFillSymbolLayer()
symbol_layer.setColor(QColor(0, 0, 0, 20))
symbol_layer.setLineWidth(2)
symbol_layer.setDistance(4)
symbol_layer.setLineAngle(70)
symbol.appendSymbolLayer(symbol_layer)

结果相当微妙且视觉上令人愉悦:

组合符号层

除了为符号层更改透明度外,你还可以更改整个符号的透明度。这通过使用setAlpha()方法完成,如下所示:

symbol.setAlpha(0.3)

结果看起来像这样:

组合符号层

注意

注意,setAlpha()接受一个介于 0.0 和 1.0 之间的浮点数,而QColor对象(如我们之前使用的)的透明度是通过介于 0 和 255 之间的 alpha 值指定的。

在 Python 中实现符号层

如果内置的符号层不足以满足你的需求,你可以使用 Python 实现自己的符号层。为此,你创建适当的符号层类型的子类(QgsMarkerSymbolLayerV2QgsLineSymbolV2QgsFillSymbolV2)并自行实现各种绘图方法。例如,这里是一个简单的标记符号层,用于绘制点几何图形的十字:

class CrossSymbolLayer(QgsMarkerSymbolLayerV2):
    def __init__(self, length=10.0, width=2.0):
        QgsMarkerSymbolLayerV2.__init__(self)
        self.length = length
        self.width  = width

    def layerType(self):
        return "Cross"

    def properties(self):
        return {'length' : self.length,
                'width' : self.width}

    def clone(self):
        return CrossSymbolLayer(self.length, self.width)

    def startRender(self, context):
        self.pen = QPen()
        self.pen.setColor(self.color())
        self.pen.setWidth(self.width)

    def stopRender(self, context):
        self.pen = None

    def renderPoint(self, point, context):
        left = point.x() - self.length
        right = point.x() + self.length
        bottom = point.y() - self.length
        top = point.y() + self.length

        painter = context.renderContext().painter()
        painter.setPen(self.pen)
        painter.drawLine(left, bottom, right, top)
        painter.drawLine(right, bottom, left, top)

在你的代码中使用这个自定义符号层很简单:

symbol = QgsMarkerSymbolV2.createSimple({})
symbol.deleteSymbolLayer(0)

symbol_layer = CrossSymbolLayer()
symbol_layer.setColor(QColor("gray"))

symbol.appendSymbolLayer(symbol_layer) 

运行此代码将在每个点几何图形的位置绘制一个十字,如下所示:

在 Python 中实现符号层

当然,这是一个简单的例子,但它展示了如何使用在 Python 中实现的自定义符号层。现在让我们更仔细地看看CrossSymbolLayer类的实现,并看看每个方法的作用:

  • __init__(): 注意 __init__ 方法接受参数来定制符号层的工作方式。这些参数,它们应该始终分配有默认值,是与符号层关联的 属性。如果你想使你的自定义符号在 QGIS 图层属性 窗口中可用,你需要注册你的自定义符号层并告诉 QGIS 如何编辑符号层的属性。我们很快就会看到这一点。

  • layerType(): 此方法返回你的符号层的唯一名称。

  • properties(): 这应该返回一个包含此符号层使用的各种属性的字典。此方法返回的属性将存储在 QGIS 项目文件中,并在以后用于恢复符号层。

  • clone(): 此方法应返回符号层的副本。由于我们已经将属性定义为 __init__ 方法的参数,实现此方法只需创建一个新实例的类,并将当前符号层的属性复制到新实例中。

  • startRender(): 在渲染地图层中的第一个要素之前调用此方法。这可以用来定义绘制要素所需的任何对象。与其每次都创建这些对象,不如只创建一次以渲染所有要素,这样更高效(因此更快)。在这个例子中,我们创建了将用于绘制点几何形状的 QPen 对象。

  • stopRender(): 在渲染最后一个要素之后调用此方法。这可以用来释放由 startRender() 方法创建的对象。

  • renderPoint(): 这是绘制点几何形状的所有工作所在。正如你所见,此方法接受两个参数:绘制符号的点,以及用于绘制符号的 渲染上下文QgsSymbolV2RenderContext 的实例)。

  • 渲染上下文提供了各种方法来访问正在显示的要素,以及有关渲染操作、当前比例因子等信息。最重要的是,它允许你访问用于在屏幕上实际绘制符号的 PyQt QPainter 对象。

renderPoint() 方法仅用于绘制点几何形状的符号层。对于线几何形状,你应该实现 renderPolyline() 方法,该方法的签名如下:

def renderPolyline(self, points, context):

points 参数将是一个包含构成 LineString 的各个点的 QPolygonF 对象,而 context 将是用于绘制几何形状的渲染上下文。

如果你的符号层旨在与多边形一起工作,你应该实现 renderPolygon() 方法,其外观如下:

def renderPolygon(self, outline, rings, context):

在这里,outline是一个包含构成多边形外部的点的QPolygonF对象,而rings是一个包含定义多边形内部环或“洞”的QPolygonF对象列表。一如既往地,context是在绘制几何形状时使用的渲染上下文。

以这种方式创建的自定义符号层,如果您只想在自己的外部 PyQGIS 应用程序中使用它,将正常工作。但是,如果您想在运行的 QGIS 副本中使用自定义符号层,特别是如果您想允许最终用户使用图层属性窗口与符号层一起工作,您需要采取一些额外步骤,如下所述:

  • 如果您希望在用户点击符号时符号在视觉上突出显示,您需要更改符号层的renderXXX()方法,以查看正在绘制的要素是否被用户选中,如果是,则更改其绘制方式。最简单的方法是更改几何形状的颜色。例如:

    if context.selected():
        color = context.selectionColor()
    else:
        color = self.color
    
  • 要允许用户编辑符号层的属性,您应该创建QgsSymbolLayerV2Widget的子类,它定义了编辑属性的用户界面。例如,可以定义一个简单的用于编辑CrossSymbolLayer长度和宽度的用户界面小部件,如下所示:

    class CrossSymbolLayerWidget(QgsSymbolLayerV2Widget):
        def __init__(self, parent=None):
            QgsSymbolLayerV2Widget.__init__(self, parent)
            self.layer = None
    
            self.lengthField = QSpinBox(self)
            self.lengthField.setMinimum(1)
            self.lengthField.setMaximum(100)
            self.connect(self.lengthField,
                         SIGNAL("valueChanged(int)"),
                         self.lengthChanged)
    
            self.widthField = QSpinBox(self)
            self.widthField.setMinimum(1)
            self.widthField.setMaximum(100)
            self.connect(self.widthField,
                         SIGNAL("valueChanged(int)"),
                         self.widthChanged)
    
            self.form = QFormLayout()
            self.form.addRow('Length', self.lengthField)
            self.form.addRow('Width', self.widthField)
    
            self.setLayout(self.form)
    
        def setSymbolLayer(self, layer):
            if layer.layerType() == "Cross":
                self.layer = layer
                self.lengthField.setValue(layer.length)
                self.widthField.setValue(layer.width)
    
        def symbolLayer(self):
            return self.layer
    
        def lengthChanged(self, n):
            self.layer.length = n
            self.emit(SIGNAL("changed()"))
    
        def widthChanged(self, n):
            self.layer.width = n
            self.emit(SIGNAL("changed()"))
    

    我们使用标准的__init__()初始化器定义我们小部件的内容。如您所见,我们定义了两个字段,lengthFieldwidthField,允许用户分别更改符号层的lengthwidth属性。

    setSymbolLayer()方法告诉小部件使用哪个QgsSymbolLayerV2对象,而symbolLayer()方法返回小部件正在编辑的QgsSymbolLayerV2对象。最后,当用户更改字段值时,会调用两个XXXChanged()方法,使我们能够更新符号层的属性以匹配用户设置的值。

  • 最后,您需要注册您的符号层。为此,创建QgsSymbolLayerV2AbstractMetadata的子类,并将其传递给QgsSymbolLayerV2Registry对象的addSymbolLayerType()方法。以下是我们CrossSymbolLayer类的元数据示例实现,以及将其在 QGIS 中注册的代码:

    class CrossSymbolLayerMetadata(QgsSymbolLayerV2AbstractMetadata):
        def __init__(self):
            QgsSymbolLayerV2AbstractMetadata.__init__(self, "Cross", "Cross marker", QgsSymbolV2.Marker)
    
        def createSymbolLayer(self, properties):
            if "length" in properties:
                length = int(properties['length'])
            else:
                length = 10
            if "width" in properties:
                width = int(properties['width'])
            else:
                width = 2
            return CrossSymbolLayer(length, width)
    
        def createSymbolLayerWidget(self, layer):
            return CrossSymbolLayerWidget()
    
    registry = QgsSymbolLayerV2Registry.instance()
    registry.addSymbolLayerType(CrossSymbolLayerMetadata())
    

注意,QgsSymbolLayerV2AbstractMetadata.__init__()方法的参数如下:

  • name: 符号层的唯一名称,它必须与符号层的layerType()方法返回的名称匹配。

  • visibleName: 这是此符号层的显示名称,在图层属性窗口中向用户展示。

  • type: 此符号层将要使用的符号类型。

createSymbolLayer()方法用于根据在项目保存时存储在 QGIS 项目文件中的属性恢复符号层。调用createSymbolLayerWidget()方法来创建用户界面小部件,允许用户查看和编辑符号层的属性。

在 Python 中实现渲染器

如果你需要根据比内置渲染器提供的更复杂的标准选择符号,你可以使用 Python 编写自己的自定义 QgsFeatureRendererV2 子类。例如,以下 Python 代码实现了一个简单的渲染器,该渲染器在显示点特征时交替使用奇数和偶数符号:

class OddEvenRenderer(QgsFeatureRendererV2):
    def __init__(self):
        QgsFeatureRendererV2.__init__(self, "OddEvenRenderer")
        self.evenSymbol = QgsMarkerSymbolV2.createSimple({})
        self.evenSymbol.setColor(QColor("light gray"))
        self.oddSymbol = QgsMarkerSymbolV2.createSimple({})
        self.oddSymbol.setColor(QColor("black"))
        self.n = 0

    def clone(self):
        return OddEvenRenderer()

    def symbolForFeature(self, feature):
        self.n = self.n + 1
        if self.n % 2 == 0:
            return self.evenSymbol
        else:
            return self.oddSymbol

    def startRender(self, context, layer):
        self.n = 0
        self.oddSymbol.startRender(context)
        self.evenSymbol.startRender(context)

    def stopRender(self, context):
        self.oddSymbol.stopRender(context)
        self.evenSymbol.stopRender(context)

    def usedAttributes(self):
        return []

使用此渲染器将导致各种点几何图形以交替颜色显示,例如:

在 Python 中实现渲染器

让我们更仔细地看看这个类是如何实现的,以及各种方法的作用:

  • __init__(): 这是你的标准 Python 初始化器。注意,在调用 QgsFeatureRendererV2.__init__() 方法时,我们必须为渲染器提供一个唯一的名称;这用于在 QGIS 本身中跟踪各种渲染器。

  • clone(): 这将创建此渲染器的副本。如果你的渲染器使用属性来控制其工作方式,则此方法应将这些属性复制到新的渲染器对象中。

  • symbolForFeature(): 这返回用于绘制给定特征的符号。

  • startRender(): 这为你提供了在渲染特征之前准备你的渲染器和任何你使用的符号的机会。请注意,你必须为你的渲染器使用的每个符号调用 startRender() 方法;因为渲染器可以使用多个符号,所以你需要实现这一点,以便你的符号也有机会为渲染做准备。

  • stopRender(): 这完成了特征的渲染。同样,你需要实现这一点,以便你的符号在渲染过程完成后有机会进行清理。

  • usedAttributes(): 应实现此方法以返回渲染器使用的特征属性列表。如果你的渲染器不使用属性在各个符号之间进行选择,则不需要实现此方法。

如果你愿意,你也可以实现自己的小部件,让用户能够更改渲染器的工作方式。这是通过子类化 QgsRendererV2Widget 并设置小部件来编辑渲染器的各种属性来完成的,就像我们实现了 QgsSymbolLayerV2Widget 的子类来编辑符号层的属性一样。你还需要通过子类化 QgsRendererV2AbstractMetadata 为你的新渲染器提供元数据,并使用 QgsRendererV2Registry 对象来注册你的新渲染器。如果你这样做,用户将能够为新地图层选择你的自定义渲染器,并通过编辑渲染器的属性来更改渲染器的工作方式。

与自定义地图图层一起工作

与使用具有数据提供者、特征、符号等的标准地图层不同,您可以使用 Python 完全实现自己的自定义地图层。自定义地图层通常用于绘制作为矢量格式数据表示过于复杂的特定数据,或者用于在地图上绘制特殊视觉特征,如网格或水印。

通过继承 QgsPluginLayer 类来实现自定义地图层。实际上,这个过程非常简单,尽管您需要在不同坐标之间进行转换,以便您在 Python 层中绘制的项目与画布中其他层绘制的特征相匹配。

注意

不要被名称所迷惑;您不需要编写 QGIS 插件来创建自己的 QgsPluginLayer 子类。

让我们看看我们如何创建自己的 QgsPluginLayer 子类。我们将创建一个简单的网格,它可以作为地图中的一个层出现。让我们首先定义 QgsPluginLayer 子类本身:

class GridLayer(QgsPluginLayer):
    def __init__(self):
        QgsPluginLayer.__init__(self, "GridLayer", "Grid Layer")
        self.setValid(True)

在我们的 __init__() 方法中,我们给插件层赋予一个唯一名称("GridLayer")和一个用户可见名称("Grid Layer"),然后告诉 QGIS 该层是有效的。

接下来,我们需要设置我们层的坐标参考系统和范围。由于我们正在创建一个覆盖整个地球的网格,我们将使用标准的 EPSG 4236 坐标系统(即纬度/经度坐标),并将层的范围设置为覆盖整个地球表面:

        self.setCrs(QgsCoordinateReferenceSystem(4326))
        self.setExtent(QgsRectangle(-180, 90, 180, 90))

现在,我们准备好定义绘制层内容的方法了。正如您所想象的,这个方法被称为 draw()。让我们首先获取我们将用于实际绘制的 QPainter 对象:

    def draw(self, renderContext):
        painter = renderContext.painter()

接下来,我们想要找到当前可见的地球表面部分:

        extent = renderContext.extent()

这为我们提供了想要绘制的网格部分。为了确保网格线位于整个纬度和经度上,我们将范围向上和向下取整到最接近的整数,如下所示:

        xMin = int(math.floor(extent.xMinimum()))
        xMax = int(math.ceil(extent.xMaximum()))
        yMin = int(math.floor(extent.yMinimum()))
        yMax = int(math.ceil(extent.yMaximum()))

接下来,我们需要设置绘图器来绘制网格线:

        pen = QPen()
        pen.setColor(QColor("light gray"))
        pen.setWidth(1.0)
        painter.setPen(pen)

现在,我们几乎准备好开始绘制网格了。但是,为了绘制网格线,我们需要一种方法来在经纬度值和计算机屏幕上的像素坐标之间进行转换。我们将使用 QgsMapToPixel 对象来完成这项工作,我们可以从渲染上下文中获取它:

        mapToPixel = renderContext.mapToPixel()

现在,我们终于准备好绘制网格线了。让我们从在每个整度经度上绘制一条垂直网格线开始:

        for x in range(xMin, xMax+1):
            coord1 = mapToPixel.transform(x, yMin)
            coord2 = mapToPixel.transform(x, yMax)
            painter.drawLine(coord1.x(), coord1.y(),
                             coord2.x(), coord2.y())

我们可以为水平网格线做同样的操作:

        for y in range(yMin, yMax+1):
            coord1 = mapToPixel.transform(xMin, y)
            coord2 = mapToPixel.transform(xMax, y)
            painter.drawLine(coord1.x(), coord1.y(),
                             coord2.x(), coord2.y())

我们需要做的最后一件事是告诉 QGIS 我们已成功绘制了该层。我们通过让我们的 draw() 方法返回 True 来完成此操作:

        return True

这完成了我们对 GridLayer 类的实现。如果您想在 QGIS 脚本或插件中使用此类,您需要注册该类,以便 QGIS 了解它。幸运的是,这样做很简单:

class GridLayerType(QgsPluginLayerType):
    def __init__(self):
        QgsPluginLayerType.__init__(self, "GridLayer")

    def createLayer(self):
        return GridLayer()

registry = QgsPluginLayerRegistry.instance()
registry.addPluginLayerType(GridLayerType())

如果您在 QGIS 中运行此程序并将 GridLayer 添加到您的项目中,您将看到地图上绘制的网格线:

使用自定义地图层

仔细观察前面的图像;您会看到网格线是在多边形之前、圆之后绘制的。这是实现自己的地图层而不是使用地图画布项的主要好处之一;您可以选择哪些图层出现在您的自定义地图层的前面或后面。

创建自定义地图画布项

地图画布项是放置在地图画布上的项。标准地图画布项包括文本注释、顶点标记以及特征的视觉突出显示。您还可以通过继承 QgsMapCanvasItem 来创建自己的自定义地图画布项。为了了解这是如何工作的,让我们创建一个地图画布项,在地图上绘制罗盘玫瑰:

创建自定义地图画布项

我们将首先创建基本的 QgsMapCanvasItem 子类:

class CompassRoseItem(QgsMapCanvasItem):
    def __init__(self, canvas):
        QgsMapCanvasItem.__init__(self, canvas)
        self.center = QgsPoint(0, 0)
        self.size   = 100

    def setCenter(self, center):
        self.center = center

    def center(self):
        return self.center

    def setSize(self, size):
        self.size = size

    def size(self):
        return self.size

    def boundingRect(self):
        return QRectF(self.center.x() - self.size/2,
                      self.center.y() - self.size/2,
                      self.center.x() + self.size/2,
                      self.center.y() + self.size/2)

    def paint(self, painter, option, widget):
        # ...

如您所见,我们通过定义 centersize 实例变量将罗盘玫瑰放置在地图画布上,并提供方法来检索和设置这些值。我们还实现了所需的 boundingRect() 方法,它返回画布项的整体边界矩形,以屏幕坐标表示。

这就留下了 paint() 方法,它负责绘制罗盘玫瑰。虽然此方法有三个参数,但我们只会使用第一个参数,即我们将用于绘制罗盘玫瑰的 QPainter 对象。

罗盘玫瑰可能看起来相当复杂,但实现它的代码相当简单。最复杂的部分是确定 "N""S""E""W" 标签的尺寸,以便我们为罗盘玫瑰本身留出足够的空间。让我们先计算一下将要显示的标签的一些基本信息:

    def paint(self, painter, option, widget):
        fontSize = int(18 * self.size/100)
        painter.setFont(QFont("Times", pointSize=fontSize,weight=75))
        metrics = painter.fontMetrics()
        labelSize = metrics.height()
        margin    = 5

我们计算用于标签的字体大小(以点为单位),然后设置我们的画家使用该大小的粗体 "Times" 字体。然后我们获取一个 QFontMetrics 对象,我们将使用它来计算标签的尺寸,并定义一个硬编码的像素边距,以便我们在标签和罗盘玫瑰本身之间留出间隙。

接下来,我们希望用浅灰色和黑色分别绘制罗盘玫瑰的两个中心部分。为此,我们将使用 QPainterPath 对象来定义要填充的区域:

        x = self.center.x()
        y = self.center.y()
        size = self.size - labelSize - margin

        path = QPainterPath()
        path.moveTo(x, y - size * 0.23)
        path.lineTo(x - size * 0.45, y - size * 0.45)
        path.lineTo(x - size * 0.23, y)
        path.lineTo(x - size * 0.45, y + size * 0.45)
        path.lineTo(x, y + size * 0.23)
        path.lineTo(x + size * 0.45, y + size * 0.45)
        path.lineTo(x + size * 0.23, y)
        path.lineTo(x + size * 0.45, y - size * 0.45)
        path.closeSubpath()

        painter.fillPath(path, QColor("light gray"))

        path = QPainterPath()
        path.moveTo(x, y - size)
        path.lineTo(x - size * 0.18, y - size * 0.18)
        path.lineTo(x - size, y)
        path.lineTo(x - size * 0.18, y + size * 0.18)
        path.lineTo(x, y + size)
        path.lineTo(x + size * 0.18, y + size * 0.18)
        path.lineTo(x + size, y)
        path.lineTo(x + size * 0.18, y - size * 0.18)
        path.closeSubpath()

        painter.fillPath(path, QColor("black"))

最后,我们希望在四个罗盘点上绘制标签:

        labelX = x - metrics.width("N")/2
        labelY = y - self.size + labelSize - metrics.descent()
        painter.drawText(QPoint(labelX, labelY), "N")

        labelX = x - metrics.width("S")/2
        labelY = y + self.size - labelSize + metrics.ascent()
        painter.drawText(QPoint(labelX, labelY), "S")

        labelX = x - self.size + labelSize/2 - metrics.width("E")/2
        labelY = y - metrics.height()/2 + metrics.ascent()
        painter.drawText(QPoint(labelX, labelY), "E")

        labelX = x + self.size - labelSize/2 - metrics.width("W")/2
        labelY = y - metrics.height()/2 + metrics.ascent()
        painter.drawText(QPoint(labelX, labelY), "W")

这就完成了我们的 QgsMapCanvasItem 子类的实现。要使用它,我们只需创建并初始化一个新的 CompassRoseItem。以下是如何在地图画布中显示 CompassRoseItem 的示例:

rose = CompassRoseItem(iface.mapCanvas())
rose.setCenter(QPointF(150, 400))
rose.setSize(80)

您的新 QgsMapCanvasItem 在对象初始化时将自动添加到地图画布上——您不需要显式将其添加到画布。要移除地图画布上的罗盘玫瑰,您可以执行以下操作:

iface.mapCanvas().scene().removeItem(rose)

注意,地图画布项浮在地图图层之上,不幸的是,它们不能直接与用户交互——你不能使用地图画布项拦截和响应用户的鼠标事件。

使用基于内存的图层

通常,地图图层会显示来自外部数据源(如 shapefile、栅格 DEM 文件或数据库)的地理空间数据,但也可以直接从你的 Python 代码中创建地理空间要素。例如,想象你编写了一个程序来显示道路的中点。这个中点可以用 QgsPoint 几何体表示,它将使用适当的标记符号在地图上显示。由于你正在计算这个点,所以这不是你想要存储在 shapefile 或数据库中的要素。相反,要素是在程序运行时计算并显示的。

这是一种理想的基于内存图层的应用。这种类型的图层在内存中存储地理空间要素,允许你在运行时创建新要素并在地图图层中显示它们。

要创建一个基于内存的地图图层,实例化一个新的 QgsVectorLayer 对象,就像正常一样。这个类的初始化器看起来如下所示:

layer = QgsVectorLayer(path, baseName, providerLib)

注意

这只是稍微简化了一下——还有一个参数 loadDefaultStyleFlag,它不适用于基于内存的图层。幸运的是,这个参数有一个默认值,所以我们可以忽略它。

让我们看看创建基于内存的地图图层所需的三个参数:

  • path:这个字符串提供了创建基于内存图层所需的信息,包括图层将存储的信息类型。我们将在稍后详细讨论这个参数。

  • baseName:这是用于基于内存图层的名称。名称可以是任何你喜欢的,尽管用户会在 QGIS 图层列表中看到它。

  • providerLib:对于基于内存的图层,应该设置为 "memory"

要创建一个简单的基于内存的图层,你可以这样做:

layer = QgsVectorLayer("Polygon", "My Layer", "memory")

这将创建一个名为 "My Layer" 的基于内存的图层,其中存储没有属性的多边形要素。

path 参数将使我们能够做比仅仅定义要存储在图层中的几何类型更多的事情。path 参数具有以下总体语法:

geometryType?key=value&key=value...

这种类似于 URL 的语法以几何类型开始,可以包含任意数量的键/值对,这些键/值对提供了关于内存图层的额外信息。目前支持以下几何类型:

  • Point

  • LineString

  • Polygon

  • MultiPoint

  • MultiLineString

  • MultiPolygon

使用键/值对,你还可以定义:

  • 图层应使用的坐标参考系统。例如:

    crs=IGNF:WGS84G
    

    坐标参考系统可以使用 CRS 权威代码定义,就像前面的例子一样,或者你可以指定 WKT 格式的 CRS,例如:crs=+proj=longlat +a=69000 +b=55000 +no_defs

    注意

    如果您以这种方式没有定义坐标参考系统,当您的程序运行时,QGIS 将提示用户选择一个 CRS。这可能会使用户感到非常困惑,因此您在创建内存层时应该始终指定一个 CRS。

  • 在层内为每个特征存储的属性。以下是一个属性定义的示例:

    field=phone_number:string
    

    当前支持以下类型的字段:

    • integer

    • double

    • string

    您还可以通过列出这些属性来指定字段长度和精度,例如,field=height:double(10,2)field=name:string(50)

    如果您想要有多个属性,您只需为要定义的每个属性有一个field=...条目即可。

    注意

    内存层的数据提供者有一个addAttributes()方法,您可能会认为您会使用它来定义属性。然而,addAttributes()方法只将属性添加到数据提供者,而不是地图层,这可能导致 QGIS 崩溃。为了避免这种情况,最好在设置地图层时在路径中定义您的属性,而不是尝试稍后添加它们。

  • 该层特征的空问索引:

    index=yes
    

让我们使用这个方法来创建一个更复杂的内存层,该层使用指定的坐标参考系统、空间索引和一些属性来存储点几何形状。以下是我们可以如何实现这一点:

layer = QgsVectorLayer(
"Point?crs=EPSG:4326&field=height:double&field=name:string(255)&index=yes", "Point Layer", "memory")

一旦我们实例化了我们的内存层,我们就可以创建我们想要显示的各种特征,然后将它们添加到层中。以下伪代码显示了如何完成此操作:

provider = layer.dataProvider()

feature1 = ...
feature2 = ...

provider.addFeatures([feature1, feature2, ...])

如您所见,我们定义了各种特征(它们是QgsFeature的实例),然后一次性将它们全部添加到内存层中。当然,您也可以逐个添加特征,但通常定义一个特征列表并一次性添加它们会更有效率。

现在我们来看看我们如何创建一个特征。我们首先定义特征将要显示的底层几何形状。有各种创建几何形状的方法,包括:

  • 实例化一个QgsPointQgsPolyLineQgsPolygon或相关对象,然后使用QgsGeometry.fromXXX()方法之一来创建QgsGeometry对象。例如:

    point = QgsPoint(x, y)
    geometry = QgsGeometry.fromPoint(point)
    
  • 创建一个表示几何形状的 WKT 格式字符串,然后使用该字符串创建QgsGeometry对象。例如:

    geometry = QgsGeometry.fromWkt("POINT (10 10)")
    
  • 通过使用几何形状操作方法之一从现有几何形状中创建一个新的QgsGeometry对象。例如:

    new_geometry = old_geometry.buffer(10)
    

一旦我们有了几何形状,我们就可以创建QgsFeature对象本身:

feature = QgsFeature()
feature.setGeometry(geometry)

接下来,我们想要设置该特征的属性。在我们能够这样做之前,我们需要告诉特征它将存储哪些属性。这是以下方式完成的:

fields = provider.fields()
feature.setFields(fields)

最后,我们可以设置属性值。例如:

feature.setAttribute("height", 301)
feature.setAttribute("name", "Eiffel Tower")

将所有这些放在一起,让我们构建一个完整的示例程序,该程序创建一个内存层,用几个QgsPoint特征填充它,并更新地图画布以显示这些点。以下是此示例程序:

layer = QgsVectorLayer("Point?crs=EPSG:4326&field=height:double&field=name:string(255)", "Point Layer", "memory")
provider = layer.dataProvider()
QgsMapLayerRegistry.instance().addMapLayer(layer)

fields = provider.fields()
features = []

feature = QgsFeature()
feature.setGeometry(QgsGeometry.fromWkt("POINT (2.2945 48.8582)"))
feature.setFields(fields)
feature.setAttribute("height", 301)
feature.setAttribute("name", "Eiffel Tower")
features.append(feature)

feature = QgsFeature()
feature.setGeometry(QgsGeometry.fromWkt("POINT (0.0761 51.5081)"))
feature.setFields(fields)
feature.setAttribute("height", 27)
feature.setAttribute("name", "Tower of London")
features.append(feature)

feature = QgsFeature()
feature.setGeometry(QgsGeometry.fromWkt("POINT (10.3964 43.7231)"))
feature.setFields(fields)
feature.setAttribute("height", 56)
feature.setAttribute("name", "Leaning Tower of Pisa")
features.append(feature)

provider.addFeatures(features)
layer.updateExtents()
iface.mapCanvas().zoomToFullExtent()

在 QGIS 内运行此程序将创建一个名为 "Point Layer" 的新基于记忆的地图层,其中包含三个要素,代表西欧三个著名塔的位置:

使用基于记忆的层

为了使这个例子更有用,我们会添加符号以更有意义的方式绘制塔,并且可能还会在每个点的旁边显示名称和高度作为标签。然而,您可以看到如何使用记忆层在程序内部创建空间数据并将其作为地图中的一个图层包含进来。

注意,您不仅限于使用基于记忆的层来表示实际的地理空间数据。您同样可以使用记忆层来显示不表示位置的信息。例如,您可以使用记忆层在地图上绘制箭头,或者使用半透明多边形来阴影地图的某些区域。基于记忆的地图层是一个非常强大的工具,您在编写基于 QGIS 的程序时经常会使用它。

摘要

在本章中,我们探讨了 QGIS Python API 的许多高级功能。我们学习了如何使用各种内置符号层在地图上绘制几何形状,如何以有用的方式组合符号层,以及如何使用 Python 实现自己的符号层。然后,我们探讨了编写自己的自定义渲染器以选择每个要素应使用的符号,以及如何使用 Python 代码创建自己的自定义地图层。我们研究了创建自定义地图画布项,然后看到了如何使用基于记忆的地图层以编程方式将要素添加到地图中。

通过这种方式,我们完成了对 PyQGIS 更高级方面的探索。在下一章中,我们将学习如何创建自定义地图工具,使用户能够在 PyQGIS 应用程序中选择、添加、编辑和删除要素。

第七章. 在 PyQGIS 应用程序中选择和编辑要素

当运行 QGIS 应用程序时,用户有一系列工具可用于创建和操作地理要素。例如,添加要素工具允许用户创建新要素,而移动要素工具和节点工具允许用户移动和编辑现有的地理要素。然而,这些工具仅在 QGIS 本身内可用——如果您想在 PyQGIS 库之上编写外部应用程序,这些内置工具不可用,您将必须自己实现这些功能。

在本章中,我们将探讨向 PyQGIS 应用程序添加功能所涉及的内容,以便用户可以选择和编辑地理要素。特别是,我们将检查:

  • 如何处理选择

  • 如何使用图层编辑模式来保存或撤销用户对地图层所做的更改

  • 如何创建允许用户添加和编辑点几何形状的地图工具

  • 如何允许用户从地图层中删除几何形状

  • 如何实现自定义地图工具,允许用户将线字符串和多边形几何形状添加到地图层

  • 如何允许用户编辑线字符串或多边形几何形状

处理选择

向量图层类QgsVectorLayer包括跟踪用户当前选择的支持。这样做相对简单:有设置和更改选择的方法,以及检索所选要素的方法。当要素被选中时,它们在屏幕上以视觉方式突出显示,以便用户可以看到已选择的内容。

小贴士

如果你创建了自己的自定义符号层,你需要自己处理所选要素的高亮显示。我们已经在第六章,掌握 QGIS Python API,标题为在 Python 中实现符号层的部分中看到了如何做到这一点。

虽然用户有多种选择要素的方法,但最直接的方法是点击它们。这可以通过使用一个简单的地图工具来实现,例如:

class SelectTool(QgsMapToolIdentify):
    def __init__(self, window):
        QgsMapToolIdentify.__init__(self, window.mapCanvas)
        self.window = window
        self.setCursor(Qt.ArrowCursor)

    def canvasReleaseEvent(self, event):
        found_features = self.identify(event.x(), event.y(),
                         self.TopDownStopAtFirst,
                         self.VectorLayer)
        if len(found_features) > 0:
            layer = found_features[0].mLayer
            feature = found_features[0].mFeature

            if event.modifiers() & Qt.ShiftModifier:
                layer.select(feature.id())
            else:
                layer.setSelectedFeatures([feature.id()])
        else:
            self.window.layer.removeSelection()

这与我们在上一章 Lex 应用程序中实现的ExploreTool非常相似。唯一的区别是,我们不是显示关于点击的要素的信息,而是告诉地图层选择它。

注意,我们检查是否按下了Shift键。如果是,则将点击的要素添加到当前选择中;否则,当前选择将被新选中的要素替换。此外,如果用户点击地图的背景,当前选择将被移除。这些都是用户熟悉的标准的用户界面约定。

一旦我们有了选择,从地图层中获取所选要素就相当简单。例如:

if layer.selectedFeatureCount() == 0:
    QMessageBox.information(self, "Info",
                            "There is nothing selected.")
else:
    msg = []
    msg.append("Selected Features:")
    for feature in layer.selectedFeatures():
        msg.append("   " + feature.attribute("NAME"))
    QMessageBox.information(self, "Info", "\n".join(msg))

如果您想看到所有这些功能在实际中的应用,您可以下载并运行本章示例代码中包含的 SelectionExplorer 程序。

使用图层编辑模式

要让用户更改地图图层的内容,您首先必须打开该图层的编辑模式。图层编辑模式类似于数据库中处理事务的方式:

使用图层编辑模式

您对图层所做的更改将保留在内存中,直到您决定将更改提交到图层,或者回滚更改以丢弃它们。以下伪代码是使用 PyQGIS 实现此功能的示例:

layer.startEditing()

# ...make changes...

if modified:
    reply = QMessageBox.question(window, "Confirm",
                                 "Save changes to layer?",
                                 QMessageBox.Yes | QMessageBox.No,
                                 QMessageBox.Yes)
    if reply == QMessageBox.Yes:
        layer.commitChanges()
    else:
        line.rollBack()
else:
     layer.rollBack()

如您所见,我们通过调用 layer.startEditing() 打开特定地图图层的编辑模式。除了设置一个内部 编辑缓冲区 来保存您所做的更改外,这还告诉图层通过在每个顶点上绘制小顶点标记来视觉上突出显示图层的要素,如下面的图像所示:

使用图层编辑模式

然后,我们允许用户更改图层的要素。我们将在本章的后续部分学习如何实现这一点。当用户关闭编辑模式时,我们会检查是否进行了更改,如果有,则向用户显示确认消息框。根据用户的响应,我们通过调用 layer.commitChanges() 保存更改,或者通过调用 layer.rollBack() 抛弃更改。

commitChanges()rollBack() 都会关闭编辑模式,隐藏顶点标记并擦除编辑缓冲区的内容。

注意

当您使用图层的编辑模式时,您必须使用 QgsVectorLayer 中的各种方法来修改要素,而不是使用数据提供者中的等效方法。例如,您应该调用 layer.addFeature(feature) 而不是 layer.dataProvider().addFeatures([feature])

图层的编辑方法仅在图层处于编辑模式时才有效。这些方法将更改添加到内部编辑缓冲区,以便在适当的时候提交或回滚。如果您直接对数据提供者进行更改,您将绕过编辑缓冲区,因此回滚功能将不会工作。

现在我们已经看到了编辑地图图层内容的整体过程,让我们创建一些地图工具,使用户能够添加和编辑地理空间数据。

添加点

以下地图工具允许用户向给定图层添加新的点要素:

class AddPointTool(QgsMapTool):
    def __init__(self, canvas, layer):
        QgsMapTool.__init__(self, canvas)
        self.canvas = canvas
        self.layer  = layer
        self.setCursor(Qt.CrossCursor)

    def canvasReleaseEvent(self, event):
        point = self.toLayerCoordinates(self.layer, event.pos())

        feature = QgsFeature()
        feature.setGeometry(QgsGeometry.fromPoint(point))
        self.layer.addFeature(feature)
        self.layer.updateExtents()

如您所见,这个简单的地图工具将鼠标光标设置为十字形,当用户在地图画布上释放鼠标时,会创建一个新的 QgsGeometry 对象,该对象代表当前鼠标位置的一个点。然后,使用 layer.addFeature() 将此点添加到图层中,并更新图层的范围,以防新添加的点位于图层的当前范围之外。

当然,这个地图工具只是一个起点——你通常会添加代码来设置特征的属性,并通知应用程序一个点已经被添加。然而,正如你所见,允许用户创建一个新的点特征相当简单。

编辑点

编辑点特征也相当简单:由于几何形状只包含一个点,用户可以简单地点击并拖动来在地图层中移动点。以下是一个实现此行为的地图工具:

class MovePointTool(QgsMapToolIdentify):
    def __init__(self, mapCanvas, layer):
        QgsMapToolIdentify.__init__(self, mapCanvas)
        self.setCursor(Qt.CrossCursor)
        self.layer    = layer
        self.dragging = False
        self.feature  = None

    def canvasPressEvent(self, event):
        found_features = self.identify(event.x(), event.y(),
                                       [self.layer],
                                       self.TopDownAll)
        if len(found_features) > 0:
            self.dragging = True
            self.feature  = found_features[0].mFeature
        else:
            self.dragging = False
            self.feature  = None

    def canvasMoveEvent(self, event):
        if self.dragging:
            point = self.toLayerCoordinates(self.layer,
                                            event.pos())

            geometry = QgsGeometry.fromPoint(point)

            self.layer.changeGeometry(self.feature.id(), geometry)
            self.canvas().refresh()

    def canvasReleaseEvent(self, event):
        self.dragging = False
        self.feature  = None

正如你所见,我们为这个地图工具继承自 QgsMapToolIdentify。这让我们可以使用 identify() 方法找到用户点击的几何形状,就像我们在本章前面实现的 SelectTool 一样。

注意,我们的 canvasMoveEvent() 方法跟踪用户当前的鼠标位置。它还通过调用 layer.changeGeometry() 来更新特征的几何形状,以记住用户移动点时的变化鼠标位置。canvasPressEvent() 只在用户点击点时启用拖动,而 canvasReleaseEvent() 方法整理好,以便用户可以通过点击来移动另一个点。

如果你正在编写一个包含基于点的 QgsVectorLayer 的独立 PyQGIS 应用程序,你可以使用我们在这里定义的 AddPointToolMovePointTool 类来允许用户在你的矢量层中添加和编辑点特征。对于点几何来说,唯一缺少的功能是删除点的功能。现在让我们来实现这个功能。

删除点和其他特征

幸运的是,删除点特征所需的代码也适用于其他类型的几何形状,因此我们不需要实现单独的 DeletePointToolDeleteLineToolDeletePolygonTool 类。相反,我们只需要一个通用的 DeleteTool。以下代码实现了这个地图工具:

class DeleteTool(QgsMapToolIdentify):
    def __init__(self, mapCanvas, layer):
        QgsMapToolIdentify.__init__(self, mapCanvas)
        self.setCursor(Qt.CrossCursor)
        self.layer   = layer
        self.feature = None

    def canvasPressEvent(self, event):
        found_features = self.identify(event.x(), event.y(),
                                       [self.layer],
                                       self.TopDownAll)
        if len(found_features) > 0:
            self.feature = found_features[0].mFeature
        else:
            self.feature = None

    def canvasReleaseEvent(self, event):
        found_features = self.identify(event.x(), event.y(),
                                       [self.layer],
                                       self.TopDownAll)
        if len(found_features) > 0:
            if self.feature.id() == found_features[0].mFeature.id():
                self.layer.deleteFeature(self.feature.id())

再次强调,我们使用 QgsMapToolIdentify 类来快速找到用户点击的特征。我们使用 canvasPressEvent()canvasReleaseEvent() 方法来确保用户在同一个特征上点击和释放鼠标;这确保了地图工具比简单地删除用户点击的特征更加用户友好。如果鼠标点击和释放都在同一个特征上,我们会删除它。

在这些地图工具的帮助下,实现一个允许用户在地图层中添加、编辑和删除点特征的 PyQGIS 应用程序相当简单。然而,这些都是“低垂的果实”——我们的下一个任务,即让用户添加和编辑 LineString 和 Polygon 几何形状,要复杂得多。

添加线和多边形

要添加 LineString 或 Polygon 几何形状,用户将依次点击每个顶点来 绘制 所需的形状。用户点击每个顶点时,将显示适当的反馈。例如,LineString 几何形状将以以下方式显示:

添加线和多边形

要绘制 Polygon 几何形状的轮廓,用户将再次依次单击每个顶点。然而,这次,多边形本身将显示出来,以便使结果形状清晰,如下面的图像所示:

添加线和多边形

在这两种情况下,点击每个顶点和显示适当反馈的基本逻辑是相同的。

QGIS 包含一个名为 QgsMapToolCapture 的地图工具,它正好处理这种行为:它允许用户通过依次单击每个顶点来绘制 LineString 或 Polygon 几何形状的轮廓。不幸的是,QgsMapToolCapture 并不是 PyQGIS 库的一部分,因此我们必须自己使用 Python 重新实现它。

让我们从查看我们的 QgsMapToolCapture 端口设计开始,我们将称之为 CaptureTool。这将是一个标准的地图工具,由 QgsMapTool 派生而来,它使用 QgsRubberBand 对象来绘制 LineString 或 Polygon 在绘制时的视觉高亮。

QgsRubberBand 是一个地图画布项,它在地图上绘制一个几何形状。由于橡皮筋以单色和样式绘制其整个几何形状,因此在我们的捕获工具中,我们必须使用两个橡皮筋:一个用于绘制已经捕获的几何形状的部分,另一个临时橡皮筋用于将几何形状扩展到当前鼠标位置。以下插图显示了这对于 LineString 和 Polygon 几何形状是如何工作的:

添加线和多边形

这里有一些我们将在 CaptureTool 中包含的附加功能:

  • 它将有一个 捕获模式,指示用户是否正在创建 LineString 或 Polygon 几何形状。

  • 用户可以按 BackspaceDelete 键来删除最后添加的顶点。

  • 用户可以按 EnterReturn 键来完成捕获过程。

  • 如果我们正在捕获 Polygon,当用户完成捕获时,几何形状将被 封闭。这意味着我们向几何形状添加一个额外的点,以便轮廓从同一点开始和结束。

  • 当用户完成捕获几何形状时,几何形状将被添加到层中,并使用回调函数来通知应用程序已添加新的几何形状。

既然我们知道我们在做什么,让我们开始实现 CaptureTool 类。我们类定义的第一部分将看起来如下:

class CaptureTool(QgsMapTool):
    CAPTURE_LINE    = 1
    CAPTURE_POLYGON = 2

    def __init__(self, canvas, layer, onGeometryAdded,
                 captureMode):
        QgsMapTool.__init__(self, canvas)
        self.canvas          = canvas
        self.layer           = layer
        self.onGeometryAdded = onGeometryAdded
        self.captureMode     = captureMode
        self.rubberBand      = None
        self.tempRubberBand  = None
        self.capturedPoints  = []
        self.capturing       = False
        self.setCursor(Qt.CrossCursor)

在我们类的顶部,我们定义了两个常量,CAPTURE_LINECAPTURE_POLYGON,它们定义了可用的捕获模式。然后我们有类初始化器,它将接受以下参数:

  • canvas:这是这个地图工具将作为一部分的 QgsMapCanvas

  • layer:这是几何形状将被添加到的 QgsVectorLayer

  • onGeometryAdded:这是一个 Python 可调用对象(即,一个方法或函数),当新的几何形状被添加到地图层时将被调用。

  • captureMode:这表示我们正在捕获 LineString 或 Polygon 几何形状。

然后我们将各种实例变量设置为其初始状态,并告诉地图工具使用十字光标,这使用户更容易看到他们确切点击的位置。

我们接下来的任务是实现各种 XXXEvent() 方法以响应用户的操作。我们将从 canvasReleaseEvent() 开始,它响应左键点击通过向几何形状添加新顶点,以及右键点击通过完成捕获过程然后将几何形状添加到地图层。

注意

我们在 canvasReleaseEvent() 方法中实现这种行为,而不是 canvasPressEvent(),因为我们希望顶点在用户释放鼠标按钮时添加,而不是在它们最初按下时。

这是 canvasReleaseEvent() 方法的实现。注意我们使用了几个辅助方法,我们将在稍后定义:

    def canvasReleaseEvent(self, event):
        if event.button() == Qt.LeftButton:
            if not self.capturing:
                self.startCapturing()
            self.addVertex(event.pos())
        elif event.button() == Qt.RightButton:
            points = self.getCapturedGeometry()
            self.stopCapturing()
            if points != None:
                self.geometryCaptured(points)

接下来,我们有 canvasMoveEvent() 方法,它响应用户移动鼠标的动作,通过更新临时橡皮筋以反映当前鼠标位置:

    def canvasMoveEvent(self, event):
        if self.tempRubberBand != None and self.capturing:
            mapPt,layerPt = self.transformCoordinates(event.pos())
            self.tempRubberBand.movePoint(mapPt)

这里有趣的部分是对 tempRubberBand.movePoint() 的调用。QgsRubberBand 类在地图坐标中工作,因此我们首先必须将当前鼠标位置(以像素为单位)转换为地图坐标。然后我们调用 movePoint(),它将橡皮筋中的当前顶点移动到新位置。

还有一个事件处理方法需要定义:onKeyEvent()。该方法响应用户按下 BackspaceDelete 键,通过移除最后一个添加的顶点,以及用户按下 ReturnEnter 键通过关闭并保存当前几何形状。以下是此方法的代码:

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Backspace or \
           event.key() == Qt.Key_Delete:
            self.removeLastVertex()
            event.ignore()
        if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter:
            points = self.getCapturedGeometry()
            self.stopCapturing()
            if points != None:
                self.geometryCaptured(points)

现在我们已经定义了事件处理方法,接下来定义这些事件处理器所依赖的各种辅助方法。我们将从 transformCoordinates() 方法开始,该方法将鼠标位置(在画布坐标中)转换为地图和层坐标:

    def transformCoordinates(self, canvasPt):
        return (self.toMapCoordinates(canvasPt),
                self.toLayerCoordinates(self.layer, canvasPt))

例如,如果鼠标当前位于画布上的 (17,53) 位置,这可能转换为地图和层坐标 lat=37.234long=-112.472。由于地图和层可能使用不同的坐标参考系统,我们计算并返回两者的坐标。

现在让我们定义 startCapturing() 方法,它准备我们的两个橡皮筋并将 self.capturing 设置为 True,这样我们知道我们目前正在捕获几何形状:

    def startCapturing(self):
        color = QColor("red")
        color.setAlphaF(0.78)

        self.rubberBand = QgsRubberBand(self.canvas,
                                        self.bandType())
        self.rubberBand.setWidth(2)
        self.rubberBand.setColor(color)
        self.rubberBand.show()

        self.tempRubberBand = QgsRubberBand(self.canvas,
                                            self.bandType())
        self.tempRubberBand.setWidth(2)
        self.tempRubberBand.setColor(color)
        self.tempRubberBand.setLineStyle(Qt.DotLine)
        self.tempRubberBand.show()

        self.capturing = True

注意,我们使用另一个辅助方法 bandType() 来决定橡皮筋应该绘制的几何类型。现在让我们定义这个方法:

    def bandType(self):
        if self.captureMode == CaptureTool.CAPTURE_POLYGON:
            return QGis.Polygon
        else:
            return QGis.Line

接下来是 stopCapturing() 方法,它从地图画布中移除我们的两个橡皮筋,将实例变量重置到初始状态,并告诉地图画布刷新自身,以便隐藏橡皮筋:

    def stopCapturing(self):
        if self.rubberBand:
            self.canvas.scene().removeItem(self.rubberBand)
            self.rubberBand = None
        if self.tempRubberBand:
            self.canvas.scene().removeItem(self.tempRubberBand)
            self.tempRubberBand = None
        self.capturing = False
        self.capturedPoints = []
        self.canvas.refresh()

现在我们来到addVertex()方法。此方法在点击的鼠标位置向当前几何形状添加一个新的顶点,并更新橡皮筋以匹配:

    def addVertex(self, canvasPoint):
        mapPt,layerPt = self.transformCoordinates(canvasPoint)

        self.rubberBand.addPoint(mapPt)
        self.capturedPoints.append(layerPt)

        self.tempRubberBand.reset(self.bandType())
        if self.captureMode == CaptureTool.CAPTURE_LINE:
            self.tempRubberBand.addPoint(mapPt)
        elif self.captureMode == CaptureTool.CAPTURE_POLYGON:
            firstPoint = self.rubberBand.getPoint(0, 0)
            self.tempRubberBand.addPoint(firstPoint)
            self.tempRubberBand.movePoint(mapPt)
            self.tempRubberBand.addPoint(mapPt)

注意,我们将捕获的点添加到self.capturedPoints列表中。这是我们完成捕获后定义几何形状的点列表。设置临时橡皮筋有点复杂,但基本思想是定义 LineString 或 Polygon,使其覆盖新几何形状当前高亮显示的部分。

现在让我们定义removeLastVertex()方法,当用户按下退格删除键撤销上一次点击时,该方法会被调用。这个方法稍微复杂一些,因为我们必须更新两个橡皮筋以移除最后一个顶点,以及更新self.capturedPoints列表:

    def removeLastVertex(self):
        if not self.capturing: return

        bandSize     = self.rubberBand.numberOfVertices()
        tempBandSize = self.tempRubberBand.numberOfVertices()
        numPoints    = len(self.capturedPoints)

        if bandSize < 1 or numPoints < 1:
            return

        self.rubberBand.removePoint(-1)

        if bandSize > 1:
            if tempBandSize > 1:
                point = self.rubberBand.getPoint(0, bandSize-2)
                self.tempRubberBand.movePoint(tempBandSize-2,
                                              point)
        else:
            self.tempRubberBand.reset(self.bandType())

        del self.capturedPoints[-1]

我们现在已经为我们的CaptureTool定义了相当多的方法。幸运的是,只剩下两个方法。现在让我们定义getCapturedGeometry()方法。此方法检查 LineString 几何形状是否至少有两个点,以及 Polygon 几何形状是否至少有三个点。然后关闭多边形并返回组成捕获几何形状的点列表:

    def getCapturedGeometry(self):
        points = self.capturedPoints
        if self.captureMode == CaptureTool.CAPTURE_LINE:
            if len(points) < 2:
                return None
        if self.captureMode == CaptureTool.CAPTURE_POLYGON:
            if len(points) < 3:
                return None
        if self.captureMode == CaptureTool.CAPTURE_POLYGON:
            points.append(points[0]) # Close polygon.
        return points

最后,我们有geometryCaptured()方法,它响应捕获的几何形状。此方法创建给定类型的新几何形状,将其作为要素添加到地图层,并使用传递给我们的CaptureTool初始化器的onGeometryAdded可调用对象,通知应用程序其余部分已向层添加了新几何形状:

    def geometryCaptured(self, layerCoords):
        if self.captureMode == CaptureTool.CAPTURE_LINE:
            geometry = QgsGeometry.fromPolyline(layerCoords)
        elif self.captureMode == CaptureTool.CAPTURE_POLYGON:
            geometry = QgsGeometry.fromPolygon([layerCoords])

        feature = QgsFeature()
        feature.setGeometry(geometry)
        self.layer.addFeature(feature)
        self.layer.updateExtents()
        self.onGeometryAdded()

虽然CaptureTool很复杂,但它是一个非常强大的类,允许用户向地图层添加新的线和多边形。这里还有一些我们没有实现的功能(坐标捕捉、检查生成的几何形状是否有效,以及添加对形成多边形内环的支持),但即使如此,这也是一个有用的工具,可以用来向地图添加新要素。

编辑线和多边形

我们将要考察的最后一项主要功能是编辑 LineString 和 Polygon 要素的能力。正如CaptureTool允许用户点击并拖动来创建新的线和多边形一样,我们将实现EditTool,它允许用户点击并拖动来移动现有要素的顶点。以下图片显示了当用户使用此工具移动顶点时将看到的内容:

编辑线和多边形

我们的编辑工具还将允许用户通过双击线段来添加新的顶点,并通过右击相同的线段来删除顶点。

让我们定义我们的EditTool类:

class EditTool(QgsMapTool):
    def __init__(self, mapCanvas, layer, onGeometryChanged):
        QgsMapTool.__init__(self, mapCanvas)
        self.setCursor(Qt.CrossCursor)
        self.layer             = layer
        self.onGeometryChanged = onGeometryChanged
        self.dragging          = False
        self.feature           = None
        self.vertex            = None

如您所见,EditToolQgsMapTool的子类,初始化器接受三个参数:地图画布、要编辑的图层,以及一个onGeometryChanged可调用对象,当用户对几何形状进行更改时,将调用此对象。

接下来,我们想要定义canvasPressEvent()方法。我们首先将识别用户点击的要素:

    def canvasPressEvent(self, event):
        feature = self.findFeatureAt(event.pos())
        if feature == None:
            return

我们将在稍后实现findFeatureAt()方法。现在我们知道用户点击了哪个要素,我们想要识别该要素中离点击点最近的顶点,以及用户点击离顶点有多远。以下是相关代码:

        mapPt,layerPt = self.transformCoordinates(event.pos())
        geometry = feature.geometry()

        vertexCoord,vertex,prevVertex,nextVertex,distSquared = \
            geometry.closestVertex(layerPt)

        distance = math.sqrt(distSquared)

如您所见,我们正在使用transformCoordinates()方法的副本(从我们的CaptureTool类中借用)来将画布坐标转换为地图和图层坐标。然后,我们使用QgsGeometry.closestVertex()方法来识别鼠标点击位置最近的顶点。此方法返回多个值,包括从最近顶点到鼠标位置的距离的平方。我们使用math.sqrt()函数将其转换为常规距离值,该值将在图层坐标中。

现在我们知道鼠标点击离顶点有多远,我们必须决定距离是否太远。如果用户没有在顶点附近点击任何地方,我们将想要忽略鼠标点击。为此,我们将计算一个容差值。容差是指点击点可以离顶点多远,同时仍然将其视为对该顶点的点击。与之前计算的距离值一样,容差是以图层坐标来衡量的。我们将使用一个辅助方法calcTolerance()来计算这个值。以下是需要在我们的canvasPressEvent()方法末尾添加的相关代码:

        tolerance = self.calcTolerance(event.pos())
        if distance > tolerance: return

如您所见,如果鼠标点击位置离顶点太远,即距离大于容差,我们将忽略鼠标点击。现在我们知道用户确实在顶点附近点击了,我们想要对此鼠标点击做出响应。我们如何做这取决于用户是否按下了左键或右键:

        if event.button() == Qt.LeftButton:
            # Left click -> move vertex.
            self.dragging = True
            self.feature  = feature
            self.vertex   = vertex
            self.moveVertexTo(event.pos())
            self.canvas().refresh()
        elif event.button() == Qt.RightButton:
            # Right click -> delete vertex.
            self.deleteVertex(feature, vertex)
            self.canvas().refresh()

如您所见,我们依赖于许多辅助方法来完成大部分工作。我们将在稍后定义这些方法,但首先,让我们完成我们的事件处理方法实现,从canvasMoveEvent()开始。此方法响应用户将鼠标移过画布。它是通过将拖动的顶点(如果有)移动到当前鼠标位置来实现的:

    def canvasMoveEvent(self, event):
        if self.dragging:
            self.moveVertexTo(event.pos())
            self.canvas().refresh()

接下来,我们有canvasReleaseEvent(),它将顶点移动到其最终位置,刷新地图画布,并更新我们的实例变量以反映我们不再拖动顶点的事实:

    def canvasReleaseEvent(self, event):
        if self.dragging:
            self.moveVertexTo(event.pos())
            self.layer.updateExtents()
            self.canvas().refresh()
            self.dragging = False
            self.feature  = None
            self.vertex   = None

我们最终的事件处理方法是canvasDoubleClickEvent(),它通过向要素添加新顶点来响应双击。此方法与canvasPressEvent()方法类似;我们必须识别被点击的要素,然后识别用户双击的是哪条线段:

    def canvasDoubleClickEvent(self, event):
        feature = self.findFeatureAt(event.pos())
        if feature == None:
            return

        mapPt,layerPt = self.transformCoordinates(event.pos())
        geometry      = feature.geometry()

        distSquared,closestPt,beforeVertex = \
            geometry.closestSegmentWithContext(layerPt)

        distance = math.sqrt(distSquared)
        tolerance = self.calcTolerance(event.pos())
        if distance > tolerance: return

如您所见,如果鼠标位置离线段太远,我们将忽略双击。接下来,我们想要将新顶点添加到几何形状中,并更新地图图层和地图画布以反映这一变化:

        geometry.insertVertex(closestPt.x(), closestPt.y(),
                              beforeVertex)
        self.layer.changeGeometry(feature.id(), geometry)
        self.canvas().refresh()

这完成了我们 EditTool 的所有事件处理方法。现在让我们实现我们的各种辅助方法,从识别点击的要素的 findFeatureAt() 方法开始:

    def findFeatureAt(self, pos):
        mapPt,layerPt = self.transformCoordinates(pos)
        tolerance = self.calcTolerance(pos)
        searchRect = QgsRectangle(layerPt.x() - tolerance,
                                  layerPt.y() - tolerance,
                                  layerPt.x() + tolerance,
                                  layerPt.y() + tolerance)

        request = QgsFeatureRequest()
        request.setFilterRect(searchRect)
        request.setFlags(QgsFeatureRequest.ExactIntersect)

        for feature in self.layer.getFeatures(request):
            return feature

        return None

我们使用容差值来定义一个以点击点为中心的搜索矩形,并识别与该矩形相交的第一个要素:

编辑线和多边形

接下来是 calcTolerance() 方法,它计算在点击被认为太远于顶点或几何形状之前我们可以容忍的距离:

    def calcTolerance(self, pos):
        pt1 = QPoint(pos.x(), pos.y())
        pt2 = QPoint(pos.x() + 10, pos.y())

        mapPt1,layerPt1 = self.transformCoordinates(pt1)
        mapPt2,layerPt2 = self.transformCoordinates(pt2)
        tolerance = layerPt2.x() - layerPt1.x()

        return tolerance

我们通过识别地图画布上相距十像素的两个点,并将这两个坐标都转换为层坐标来计算这个值。然后我们返回这两个点之间的距离,这将是层坐标系中的容差。

现在我们来到了有趣的部分:移动和删除顶点。让我们从将顶点移动到新位置的方法开始:

    def moveVertexTo(self, pos):
        geometry = self.feature.geometry()
        layerPt = self.toLayerCoordinates(self.layer, pos)
        geometry.moveVertex(layerPt.x(), layerPt.y(), self.vertex)
        self.layer.changeGeometry(self.feature.id(), geometry)
        self.onGeometryChanged()

如您所见,我们将位置转换为层坐标,告诉 QgsGeometry 对象将顶点移动到这个位置,然后告诉层保存更新的几何形状。最后,我们使用 onGeometryChanged 可调用对象告诉应用程序的其他部分几何形状已被更改。

删除一个顶点稍微复杂一些,因为我们必须防止用户在没有足够的顶点来构成有效几何形状的情况下删除顶点——LineString 至少需要两个顶点,而多边形至少需要三个。以下是我们的 deleteVertex() 方法的实现:

    def deleteVertex(self, feature, vertex):
        geometry = feature.geometry()

        if geometry.wkbType() == QGis.WKBLineString:
            lineString = geometry.asPolyline()
            if len(lineString) <= 2:
                return
        elif geometry.wkbType() == QGis.WKBPolygon:
            polygon = geometry.asPolygon()
            exterior = polygon[0]
            if len(exterior) <= 4:
                return

        if geometry.deleteVertex(vertex):
            self.layer.changeGeometry(feature.id(), geometry)
            self.onGeometryChanged()

注意,多边形检查必须考虑到多边形外部的第一个和最后一个点实际上是相同的。这就是为什么我们检查多边形是否至少有四个坐标而不是三个。

这完成了我们对 EditTool 类的 EditTool 类的实现。要查看这个地图工具的实际效果,以及其他我们在本章中定义的几何形状编辑地图工具,请查看包含在本章示例代码中的 GeometryEditor 程序。

摘要

在本章中,我们学习了如何编写一个 PyQGIS 应用程序,允许用户选择和编辑要素。我们创建了一个地图工具,它使用 QgsVectorLayer 中的选择处理方法来让用户选择要素,并学习了如何在程序内部处理当前选定的要素。然后我们探讨了层的编辑模式如何允许用户进行更改,然后要么提交这些更改,要么丢弃它们。最后,我们创建了一系列地图工具,允许用户在地图层内添加、编辑和删除点、线字符串和多边形几何形状。

将所有这些工具整合在一起,您的 PyQGIS 应用程序将具备一套完整的选区和几何编辑功能。在本书的最后两章中,我们将使用这些工具,结合前几章所获得的知识,利用 Python 和 QGIS 构建一个完整的独立地图应用程序。

第八章. 使用 Python 和 QGIS 构建完整的地图应用程序

在本章中,我们将设计和开始构建一个完整的交钥匙地图应用程序。虽然我们的示例应用程序可能看起来有些专业,但设计和实现这个应用程序的过程以及我们使用的很大一部分代码,将适用于你可能会自己编写的所有类型的地图应用程序。

由于我们创建的应用程序复杂,我们将分两章实现。在本章中,我们将通过以下方式为地图应用程序打下基础:

  • 设计应用程序

  • 构建高分辨率底图,我们的矢量数据将在底图上显示

  • 实现应用程序的整体结构

  • 定义应用程序的用户界面

在下一章中,我们将实现地图工具,使用户能够输入和操作地图数据,编辑属性,并计算两点之间的最短路径。

介绍 ForestTrails

想象一下,你为一家负责开发和维护大型娱乐森林的公司工作。人们使用森林中的各种通道和专门建造的小径进行步行、骑自行车和骑马。你的任务是编写一个计算机程序,让用户创建一个数据库,包含通道和路径,以协助森林的持续维护。为了简单起见,我们将使用路径一词来指代通道或小径。每个路径都将具有以下属性:

  • 类型:轨道是步行小径、自行车小径、马术小径还是通道

  • 名称:并非所有小径和通道都有名称,尽管有些有

  • 方向:一些小径和通道是单向的,而其他则可以双向通行

  • 状态:轨道目前是否开放或关闭

由于娱乐森林持续发展,新的路径正在定期添加,而现有的路径有时会被修改,甚至在不再需要时被移除。这意味着你不能将路径集硬编码到你的程序中;你需要包含一个路径编辑模式,以便用户可以添加、编辑和删除路径。

你被赋予的特定要求是制作一套方向指南,以便轨道维护团队可以从一个给定的起点到达森林中的任何地方。为了实现这一点,程序将允许用户选择起点和终点,并计算并显示这两个点之间的最短可用路径

设计 ForestTrails 应用程序

根据我们的需求集,很明显,路径可以用 LineString 几何形状来表示。我们还需要一个合适的底图,这些几何形状将在底图上显示。这意味着我们的应用程序至少将包含以下两个地图层:

设计 ForestTrails 应用程序

由于我们希望数据持久化,我们将使用 SpatiaLite 数据库来存储我们的轨迹数据,而底图则是一个我们加载并显示的 GeoTIFF 栅格图像。

除了这两个主要地图层之外,我们还将使用基于内存的层来在地图上显示以下临时信息:

  • 当前选定的起点

  • 当前选定的终点

  • 这两点之间的最短路径

为了使事情更简单,我们将将这些信息分别显示在不同的地图层中。这意味着我们的应用程序将总共拥有五个地图层:

  • basemapLayer

  • trackLayer

  • startPointLayer

  • endPointLayer

  • shortestPathLayer

除了地图本身之外,我们的应用程序还将具有一个工具栏和一个菜单栏,这两个栏都允许用户访问系统的各种功能。以下操作将在工具栏和菜单栏中可用:

  • 放大:这将允许用户放大地图。

  • 缩小:这允许用户缩小地图。

  • 平移:这是我们之前实现的平移模式,允许用户在地图上移动。

  • 编辑:单击此项目将打开轨迹编辑模式。如果我们已经在轨迹编辑模式中,再次单击它将提示用户在关闭编辑模式之前保存他们的更改。

  • 添加轨迹:这允许用户添加新轨迹。请注意,此项目仅在轨迹编辑模式下可用。

  • 编辑轨迹:这允许用户编辑现有轨迹。只有当用户处于轨迹编辑模式时,此功能才可用。

  • 删除轨迹:这允许用户删除轨迹。此功能仅在轨迹编辑模式下可用。

  • 获取信息:这启用了获取信息地图工具。当用户点击一个轨迹时,此工具将显示该轨迹的属性,并允许用户更改这些属性。

  • 设置起点:这允许用户为最短路径计算设置当前起点。

  • 设置终点:此项目允许用户在地图上单击以设置最短路径计算的目标点。

  • 找到最短路径:这将显示当前起始点和终点之间的最短可用路径。再次单击此项目将隐藏路径。

这让我们对我们的应用程序的外观和工作方式有了很好的了解。现在,让我们开始编写 ForestTrails 程序,通过实现应用程序及其主窗口的基本逻辑。

创建应用程序

我们的应用程序将是一个独立的 Python 程序,使用 PyQt 和 PyQGIS 库构建。以我们在第五章中实现的 Lex 应用程序为起点,在外部应用程序中使用 QGIS,让我们看看我们如何组织 ForestTrails 系统的源文件。我们将从以下基本结构开始:

创建应用程序

这与我们在 Lex 应用程序中使用的结构非常相似,所以其中大部分内容对你来说应该是熟悉的。主要区别在于我们使用两个子目录来存放额外的文件。让我们看看每个文件和目录将用于什么:

  • constants.py:这个模块将包含 ForestTrails 系统中使用的各种常量。

  • data:这是一个目录,我们将用它来存放我们的栅格底图以及包含我们轨迹的 SpatiaLite 数据库。

  • forestTrails.py:这是我们的应用程序的主程序。

  • Makefile:这个文件告诉 make 工具如何将 resources.qrc 文件编译成我们的应用程序可以使用的 resources.py 模块。

  • mapTools.py:这个模块实现了我们的各种地图工具。

  • resources:这是一个目录,我们将在这里放置各种图标和其他资源。由于我们有这么多图标文件,将这些文件放入子目录而不是让主目录充斥着这些文件是有意义的。

  • resources.qrc:这是我们的应用程序的资源描述文件。

  • run_lin.sh:这个 bash shell 脚本用于在 Linux 系统上运行我们的应用程序。

  • run_mac.sh:这个 bash shell 脚本用于在 Mac OS X 系统上运行我们的应用程序。

  • run_win.bat:这个批处理文件用于在 MS Windows 机器上运行我们的应用程序。

  • ui_mainWindow.py:这个 Python 模块定义了我们主窗口的用户界面。

布局应用程序

让我们一步一步地实现 ForestTrails 系统。创建一个目录来存放 ForestTrails 系统的源代码,然后在其中创建 dataresources 子目录。由于主目录中的许多文件都很直接,我们不妨直接创建以下文件:

  • Makefile 应该看起来像这样:

    RESOURCE_FILES = resources.py
    
    default: compile
    
    compile: $(RESOURCE_FILES)
    
    %.py : %.qrc
      pyrcc4 -o $@ $<
    
    %.py : %.ui
      pyuic4 -o $@ $<
    
    clean:
      rm $(RESOURCE_FILES)
      rm *.pyc
    

    提示

    注意,如果你的 pyrcc4 命令在非标准位置,你可能需要修改此文件,以便 make 可以找到它。

  • 按照以下方式创建 resources.qrc 文件:

    <RCC>
    <qresource>
    <file>resources/mActionZoomIn.png</file>
    <file>resources/mActionZoomOut.png</file>
    <file>resources/mActionPan.png</file>
    <file>resources/mActionEdit.svg</file>
    <file>resources/mActionAddTrack.svg</file>
    <file>resources/mActionEditTrack.png</file>
    <file>resources/mActionDeleteTrack.svg</file>
    <file>resources/mActionGetInfo.svg</file>
    <file>resources/mActionSetStartPoint.svg</file>
    <file>resources/mActionSetEndPoint.svg</file>
    <file>resources/mActionFindShortestPath.svg</file>
    </qresource>
    </RCC>
    

    注意,我们已经包含了将被用于我们的工具栏动作的各种图像文件。所有这些文件都在我们的 resources 子目录中。我们将在稍后查看如何获取这些图像文件。

  • run-lin.sh 文件应该看起来像这样:

    #!/bin/sh
    export PYTHONPATH="/path/to/qgis/build/output/python/"
    export LD_LIBRARY_PATH="/path/to/qgis/build/output/lib/"
    export QGIS_PREFIX="/path/to/qgis/build/output/"
    python forestTrails.py
    
  • 类似地,run-mac.sh 应该包含以下内容:

    export PYTHONPATH="$PYTHONPATH:/Applications/QGIS.app/Contents/Resources/python"
    export DYLD_FRAMEWORK_PATH="/Applications/QGIS.app/Contents/Frameworks"
    export QGIS_PREFIX="/Applications/QGIS.app/Contents/Resources"
    python forestTrails.py
    
  • run-win.bat 文件应该包含:

    SET OSGEO4W_ROOT=C:\OSGeo4W
    SET QGIS_PREFIX=%OSGEO4W_ROOT%\apps\qgis
    SET PATH=%PATH%;%QGIS_PREFIX%\bin
    SET PYTHONPATH=%QGIS_PREFIX%\python;%PYTHONPATH%
    python forestTrails.py
    

    注意

    如果你的 QGIS 安装在一个非标准位置,你可能需要修改相应的脚本,以便可以找到所需的库。

由于 resources.qrc 文件导入了我们的各种工具栏图标并使它们可供应用程序使用,我们将想要设置这些图标文件。现在让我们来做这件事。

定义工具栏图标

我们总共需要为 11 个工具栏动作显示图标:

定义工具栏图标

您可以自由创建或下载这些工具栏动作的自定义图标,或者您可以使用本章提供的源代码中包含的图标文件。文件格式并不重要,只要在resoures.qrc文件中包含正确的后缀,并在ui_mainWindow.py中初始化工具栏动作时即可。

确保将这些文件放入resources子目录中,并运行make来构建resources.py模块,以便这些图标可供您的应用程序使用。

在完成这些基础工作后,我们就可以开始定义应用程序代码本身了。让我们从constants.py模块开始。

constants.py模块

此模块将包含我们用来表示轨道属性值的各种常量;通过在同一个地方定义它们,我们确保属性值被一致地使用,我们不必记住确切的值。例如,轨道层的type属性可以有以下值:

  • ROAD

  • WALKING

  • BIKE

  • HORSE

而不是每次需要这些值时都硬编码它们,我们将定义这些值在constants.py模块中。创建此模块并将以下代码输入其中:

TRACK_TYPE_ROAD    = "ROAD"
TRACK_TYPE_WALKING = "WALKING"
TRACK_TYPE_BIKE    = "BIKE"
TRACK_TYPE_HORSE   = "HORSE"

TRACK_DIRECTION_BOTH     = "BOTH"
TRACK_DIRECTION_FORWARD  = "FORWARD"
TRACK_DIRECTION_BACKWARD = "BACKWARD"

TRACK_STATUS_OPEN   = "OPEN"
TRACK_STATUS_CLOSED = "CLOSED"

我们将在继续的过程中添加更多常量,但这已经足够我们开始了。

forestTrails.py模块

此模块定义了 ForestTrails 应用程序的主程序。它看起来与我们在第五章中定义的lex.py模块非常相似,即在外部应用程序中使用 QGIS。创建您的forestTrails.py文件,并将以下import语句输入其中:

import os, os.path, sys

from qgis.core import *
from qgis.gui import *
from PyQt4.QtGui import *
from PyQt4.QtCore import *

from ui_mainWindow import Ui_MainWindow

import resources
from constants import *
from mapTools import *

接下来,我们想在类中定义我们应用程序的主窗口,我们将称之为ForestTrailsWindow。这是应用程序代码的大部分将得到实现的地方;这个类将变得相当复杂,但我们将从简单开始,只定义窗口本身,并为所有工具栏动作定义空占位符方法。

让我们定义类本身和__init__()方法来初始化一个新窗口:

class ForestTrailsWindow(QMainWindow, Ui_MainWindow):
    def __init__(self):
        QMainWindow.__init__(self)

        self.setupUi(self)

        self.connect(self.actionQuit, SIGNAL("triggered()"),
                     self.quit)
        self.connect(self.actionZoomIn, SIGNAL("triggered()"),
                     self.zoomIn)
        self.connect(self.actionZoomOut, SIGNAL("triggered()"),
                     self.zoomOut)
        self.connect(self.actionPan, SIGNAL("triggered()"),
                     self.setPanMode)
        self.connect(self.actionEdit, SIGNAL("triggered()"),
                     self.setEditMode)
        self.connect(self.actionAddTrack, SIGNAL("triggered()"),
                     self.addTrack)
        self.connect(self.actionEditTrack, SIGNAL("triggered()"),
                     self.editTrack)
        self.connect(self.actionDeleteTrack,SIGNAL("triggered()"),
                     self.deleteTrack)
        self.connect(self.actionGetInfo, SIGNAL("triggered()"),
                     self.getInfo)
        self.connect(self.actionSetStartPoint,
                     SIGNAL("triggered()"),
                self.setStartPoint)
        self.connect(self.actionSetEndPoint,
                     SIGNAL("triggered()"),
                  self.setEndPoint)
        self.connect(self.actionFindShortestPath,
                     SIGNAL("triggered()"),
                     self.findShortestPath)

        self.mapCanvas = QgsMapCanvas()
        self.mapCanvas.useImageToRender(False)
        self.mapCanvas.setCanvasColor(Qt.white)
        self.mapCanvas.show()

        layout = QVBoxLayout()
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(self.mapCanvas)
        self.centralWidget.setLayout(layout)

这与 Lex 应用程序的__init__()方法非常相似;我们将在ui_mainWindow.py模块中定义Ui_MainWindow类来设置应用程序的用户界面。这就是所有那些actionXXX实例变量将被定义的地方。在我们的__init__()方法中,我们将这些动作连接到各种方法,当用户从工具栏或菜单栏选择动作时,这些方法将做出响应。

__init__()方法的其余部分只是设置地图画布并将其布局在窗口内。有了这个方法,我们现在可以定义所有那些动作处理方法。我们可以直接从lex.py借用其中两个:

    def zoomIn(self):
        self.mapCanvas.zoomIn()

    def zoomOut(self):
        self.mapCanvas.zoomOut()

对于其余部分,我们将推迟实现它们,直到应用程序更加完整。为了允许我们的程序运行,我们将为剩余的动作处理程序设置空占位符方法:

    def quit(self):
        pass

    def setPanMode(self):
        pass

    def setEditMode(self):
        pass

    def addTrack(self):
        pass

    def editTrack(self):
        pass

    def deleteTrack(self):
        pass

    def getInfo(self):
        pass

    def setStartingPoint(self):
        pass

    def setEndingPoint(self):
        pass

    def findShortestPath(self):
        pass

forestTrails.py模块的最后部分是main()函数,当程序运行时会被调用:

def main():
    QgsApplication.setPrefixPath(os.environ['QGIS_PREFIX'], True)
    QgsApplication.initQgis()

    app = QApplication(sys.argv)

    window = ForestTrailsWindow()
    window.show()
    window.raise_()
    window.setPanMode()

    app.exec_()
    app.deleteLater()
    QgsApplication.exitQgis()

if __name__ == "__main__":
    main()

再次强调,这与我们在 Lex 应用程序中看到的代码几乎相同。

这完成了forestTrails.py模块的初始实现。我们的下一步是创建一个模块,用于存放我们所有的地图工具。

mapTools.py 模块

我们在 Lex 应用程序中使用了mapTools.py来分别定义我们的各种地图工具,而不在主程序中定义。我们在这里也将这样做。不过,目前我们的mapTools.py模块几乎是空的:

from qgis.core import *
from qgis.gui import *
from PyQt4.QtGui import *
from PyQt4.QtCore import *
from constants import *

显然,随着我们开始实现各种地图工具,我们还将添加更多内容,但就目前而言,这已经足够了。

ui_mainWindow.py 模块

这是我们需要为 ForestTrails 系统的初始实现定义的最后一个模块。与 Lex 应用程序一样,这个模块定义了一个Ui_MainWindow类,它实现了应用程序的用户界面,并为各种菜单和工具栏项定义了QAction对象。我们将首先导入我们的类需要的模块:

from PyQt4.QtGui import *
from PyQt4.QtCore import *
import resources

接下来,我们将定义Ui_MainWindow类和setupUi()方法,它将完成所有工作:

class Ui_MainWindow(object):
    def setupUi(self, window):

setupUi()方法的第一部分设置了窗口的标题,创建了一个centralWidget实例变量来保存地图视图,并初始化应用程序的菜单和工具栏:

        window.setWindowTitle("Forest Trails")

        self.centralWidget = QWidget(window)
        self.centralWidget.setMinimumSize(800, 400)
        window.setCentralWidget(self.centralWidget)

        self.menubar = window.menuBar()
        self.fileMenu = self.menubar.addMenu("File")
        self.mapMenu = self.menubar.addMenu("Map")
        self.editMenu = self.menubar.addMenu("Edit")
        self.toolsMenu = self.menubar.addMenu("Tools")

        self.toolBar = QToolBar(window)
        window.addToolBar(Qt.TopToolBarArea, self.toolBar)

接下来,我们想要定义各种工具栏和菜单项的QAction对象。对于每个动作,我们将定义动作的图标和键盘快捷键,并检查动作是否可勾选(即用户点击时保持选中状态):

        self.actionQuit = QAction("Quit", window)
        self.actionQuit.setShortcut(QKeySequence.Quit)

        icon = QIcon(":/resources/mActionZoomIn.png")
        self.actionZoomIn = QAction(icon, "Zoom In", window)
        self.actionZoomIn.setShortcut(QKeySequence.ZoomIn)

        icon = QIcon(":/resources/mActionZoomOut.png")
        self.actionZoomOut = QAction(icon, "Zoom Out", window)
        self.actionZoomOut.setShortcut(QKeySequence.ZoomOut)

        icon = QIcon(":/resources/mActionPan.png")
        self.actionPan = QAction(icon, "Pan", window)
        self.actionPan.setShortcut("Ctrl+1")
        self.actionPan.setCheckable(True)

        icon = QIcon(":/resources/mActionEdit.svg")
        self.actionEdit = QAction(icon, "Edit", window)
        self.actionEdit.setShortcut("Ctrl+2")
        self.actionEdit.setCheckable(True)

        icon = QIcon(":/resources/mActionAddTrack.svg")
        self.actionAddTrack = QAction(icon, "Add Track", window)
        self.actionAddTrack.setShortcut("Ctrl+A")
        self.actionAddTrack.setCheckable(True)

        icon = QIcon(":/resources/mActionEditTrack.png")
        self.actionEditTrack = QAction(icon, "Edit", window)
        self.actionEditTrack.setShortcut("Ctrl+E")
        self.actionEditTrack.setCheckable(True)

        icon = QIcon(":/resources/mActionDeleteTrack.svg")
        self.actionDeleteTrack = QAction(icon, "Delete", window)
        self.actionDeleteTrack.setShortcut("Ctrl+D")
        self.actionDeleteTrack.setCheckable(True)

        icon = QIcon(":/resources/mActionGetInfo.svg")
        self.actionGetInfo = QAction(icon, "Get Info", window)
        self.actionGetInfo.setShortcut("Ctrl+I")
        self.actionGetInfo.setCheckable(True)

        icon = QIcon(":/resources/mActionSetStartPoint.svg")
        self.actionSetStartPoint = QAction(
                icon, "Set Start Point", window)
        self.actionSetStartPoint.setCheckable(True)

        icon = QIcon(":/resources/mActionSetEndPoint.svg")
        self.actionSetEndPoint = QAction(
                icon, "Set End Point", window)
        self.actionSetEndPoint.setCheckable(True)

        icon = QIcon(":/resources/mActionFindShortestPath.svg")
        self.actionFindShortestPath = QAction(
                icon, "Find Shortest Path", window)
        self.actionFindShortestPath.setCheckable(True)

然后我们将各种动作添加到应用程序的菜单中:

        self.fileMenu.addAction(self.actionQuit)

        self.mapMenu.addAction(self.actionZoomIn)
        self.mapMenu.addAction(self.actionZoomOut)
        self.mapMenu.addAction(self.actionPan)
        self.mapMenu.addAction(self.actionEdit)

        self.editMenu.addAction(self.actionAddTrack)
        self.editMenu.addAction(self.actionEditTrack)
        self.editMenu.addAction(self.actionDeleteTrack)
        self.editMenu.addAction(self.actionGetInfo)

        self.toolsMenu.addAction(self.actionSetStartPoint)
        self.toolsMenu.addAction(self.actionSetEndPoint)
        self.toolsMenu.addAction(self.actionFindShortestPath)

最后,我们将动作添加到工具栏中,并告诉窗口根据内容调整大小:

        self.toolBar.addAction(self.actionZoomIn)
        self.toolBar.addAction(self.actionZoomOut)
        self.toolBar.addAction(self.actionPan)
        self.toolBar.addAction(self.actionEdit)
        self.toolBar.addSeparator()
        self.toolBar.addAction(self.actionAddTrack)
        self.toolBar.addAction(self.actionEditTrack)
        self.toolBar.addAction(self.actionDeleteTrack)
        self.toolBar.addAction(self.actionGetInfo)
        self.toolBar.addSeparator()
        self.toolBar.addAction(self.actionSetStartPoint)
        self.toolBar.addAction(self.actionSetEndPoint)
        self.toolBar.addAction(self.actionFindShortestPath)

        window.resize(window.sizeHint())

这完成了ui_mainWindow.py模块的实现。我们现在有一个完整的小型应用程序,应该能够运行。让我们试试它。

运行应用程序

现在你已经输入了所有这些代码,是时候检查它是否工作。让我们尝试使用适当的启动脚本运行应用程序。打开一个终端或命令行窗口,导航到forestTrails目录,并运行相应的启动脚本。

如果一切顺利,你应该会看到应用程序的主窗口以及工具栏和菜单项:

运行应用程序

当然,主窗口的地图视图是空的,工具栏或菜单项还没有任何功能,但至少我们为我们的应用程序提供了一个工作的框架。我们的下一步是获取应用程序的基础地图,设置我们的地图层,然后开始实现各种工具栏和菜单栏项。

获取基础地图

为了继续本章的这一部分,您将需要访问 GDAL 命令行工具。GDAL 可能已经安装在您的计算机上,因为 QGIS 使用了它。如果您还没有安装 GDAL,请访问www.gdal.org并点击下载链接,将副本下载并安装到您的机器上。

编写地图应用的一个挑战是在上面显示您的地理空间数据的优质底图。在我们的案例中,我们希望底图显示森林的航空照片。我们将使用新西兰罗托鲁瓦的 Whakarewarewa 森林作为我们的 ForestTrails 应用。幸运的是,新西兰土地信息网站提供了合适的航空照片。

访问以下网页,该网页提供了新西兰丰盛湾的高分辨率航空照片:

data.linz.govt.nz/layer/1760-bay-of-plenty-025m-rural-aerial-photos-2011-2012/

我们想要下载一个覆盖 Whakarewarewa 森林的底图,该森林位于罗托鲁瓦市以南。在页面右侧的地图上,平移并缩放到以下地图区域:

获取底图

地图中心黑暗的圆形区域是罗托鲁阿湖。进一步放大并向下平移到罗托鲁阿以南的区域:

获取底图

这张地图显示了我们要下载的 Whakarewarewa 森林图像。接下来,点击右上角的裁剪工具 (获取底图) 并选择以下地图区域:

获取底图

在选择了适当的地图区域后,点击右上角的“下载或订购”链接。出现的窗口为您提供下载底图的选择。请确保您选择以下选项:

  • 地图投影将为 NZGD2000

  • 原始图像格式将为 TIFF,保持原始分辨率

    注意

    您需要注册才能下载文件,但注册过程只需几秒钟,且不收费。

生成的下载文件大小约为 2.8 GB,略低于本站文件下载的 3 GB 限制。如果文件太大,您将不得不选择较小的区域进行下载。

下载文件后,您将得到一个包含多个 TIFF 格式栅格图像文件的 ZIP 存档。接下来,我们需要将这些图像合并成一个单独的.tif文件作为我们的底图。为此,我们将使用 GDAL 附带的gdal_merge.py命令:

gdal_merge.py -o /dst/path/basemap.tif *.tif

选择basemap.tif文件的适当目的地(例如,通过将/dst/path替换为合理的位置,例如桌面路径)。如果当前目录未设置为包含下载的.tif文件的文件夹,您还需要在命令中指定源路径。

这个命令将需要一段时间来拼接各种图像,但结果应该是一个名为 basemap.tif 的单个大文件。这是一个包含您所选航空照片的 TIFF 格式栅格图像,并且地理参考到地球表面的适当部分。

不幸的是,我们无法直接使用此文件。要了解原因,请在下载的文件上运行gdalinfo命令:

gdalinfo basemap.tif

此外,这告诉我们文件使用的是哪个坐标参考系统:

    Coordinate System is:
    PROJCS["NZGD2000 / New Zealand Transverse Mercator 2000",
        GEOGCS["NZGD2000",
            DATUM["New_Zealand_Geodetic_Datum_2000",
                SPHEROID["GRS 1980",6378137,298.2572221010002,
                    AUTHORITY["EPSG","7019"]],
                AUTHORITY["EPSG","6167"]],
            PRIMEM["Greenwich",0],
            UNIT["degree",0.0174532925199433],
            AUTHORITY["EPSG","4167"]],
        ...

如您所见,下载的底图使用的是新西兰横轴墨卡托 2000坐标系。我们需要将其转换为 WGS84(地理纬度/经度坐标)坐标系,以便在 ForestTrails 程序中使用。为此,我们将使用gdalwarp命令,如下所示:

 gdalwarp -t_srs EPSG:4326 basemap.tif basemap_wgs84.tif

如果您使用gdalinfo查看生成的图像,您会看到它已被转换为纬度/经度坐标系:

    Coordinate System is:
    GEOGCS["WGS 84",
        DATUM["WGS_1984",
            SPHEROID["WGS 84",6378137,298.257223563,
                AUTHORITY["EPSG","7030"]],
            AUTHORITY["EPSG","6326"]],
        PRIMEM["Greenwich",0],
        UNIT["degree",0.0174532925199433],
        AUTHORITY["EPSG","4326"]]

注意

您可能会想知道为什么我们没有直接以 WGS84 坐标系下载文件。我们以原始 CRS 下载文件,因为这使我们能够更好地控制最终图像。自己重新投影图像也更容易看到图像在重新投影时发生了哪些变化。

到目前为止,一切顺利。然而,如果我们查看生成的图像,我们会看到另一个问题:

获取底图

从 NZGD2000 到 WGS84 的转换使底图略微旋转,因此地图的边界看起来不太好。现在,我们需要裁剪地图以去除不需要的边界。为此,我们将再次使用gdal_warp命令,这次带有目标范围:

gdalwarp -te 176.241 -38.2333 176.325 -38.1557 basemap_wgs84.tif basemap_trimmed.tif

提示

如果您在下载底图时选择了略微不同的边界,您可能需要调整纬度/经度值。gdalinfo显示的角落坐标值将为您提供有关要使用哪些值的线索。

生成的文件是我们用于 ForestTrails 程序的理想栅格底图:

获取底图

将最终图像复制到您的forestTrails/data目录,并将其重命名为basemap.tif

定义地图层

我们知道我们希望在应用程序中总共拥有五个地图层。底图层将显示我们刚刚下载的basemap.tif文件,而轨迹层将使用 SpatiaLite 数据库来存储和显示用户输入的轨迹数据。其余的地图层将显示内存中持有的临时特征。

让我们从在forestTrails.py模块中定义一个新的方法开始,以初始化我们将用于轨迹层的 SpatiaLite 数据库:

    def setupDatabase(self):
        cur_dir = os.path.dirname(os.path.realpath(__file__))
        dbName = os.path.join(cur_dir, "data", "tracks.sqlite")
        if not os.path.exists(dbName):
            fields = QgsFields()
            fields.append(QgsField("id", QVariant.Int))
            fields.append(QgsField("type", QVariant.String))
            fields.append(QgsField("name", QVariant.String))
            fields.append(QgsField("direction", QVariant.String))
            fields.append(QgsField("status", QVariant.String))

            crs = QgsCoordinateReferenceSystem(4326,
                        QgsCoordinateReferenceSystem.EpsgCrsId)

            writer = QgsVectorFileWriter(dbName, 'utf-8', fields,
                                         QGis.WKBLineString,
                                         crs, 'SQLite',
                                         ["SPATIALITE=YES"])

            if writer.hasError() != QgsVectorFileWriter.NoError:
                print "Error creating tracks database!"

            del writer

如您所见,我们检查我们的data子目录中是否存在 SpatiaLite 数据库文件,并在必要时创建一个新的数据库。我们定义了将保存各种轨迹属性的各种字段,并使用QgsVectorFileWriter对象创建数据库。

你还需要修改 main() 函数以调用 setupDatabase() 方法。在调用 window.raise_() 之后添加以下行到这个函数中:

    window.setupDatabase()

现在我们已经为轨迹层设置了数据库,我们可以定义我们的各种地图层。我们将创建一个名为 setupMapLayers() 的新方法来完成这个任务。让我们首先定义一个 layers 变量来保存各种地图层,并初始化我们的基础地图层:

    def setupMapLayers(self):
        cur_dir = os.path.dirname(os.path.realpath(__file__))
        layers = []

        filename = os.path.join(cur_dir, "data", "basemap.tif")
        self.baseLayer = QgsRasterLayer(filename, "basemap")
        QgsMapLayerRegistry.instance().addMapLayer(self.baseLayer)
        layers.append(QgsMapCanvasLayer(self.baseLayer))

接下来,我们想要设置我们的 tracks 层。由于这个层存储在 SpatiaLite 数据库中,我们必须使用 QgsDataSourceURI 对象将数据库连接到地图层。以下代码展示了如何完成这个操作:

        uri = QgsDataSourceURI()
        uri.setDatabase(os.path.join(cur_dir, "data",
 "tracks.sqlite"))
        uri.setDataSource('', 'tracks', 'GEOMETRY')

        self.trackLayer = QgsVectorLayer(uri.uri(), "Tracks",
                                         "spatialite")
        QgsMapLayerRegistry.instance().addMapLayer(
            self.trackLayer)
        layers.append(QgsMapCanvasLayer(self.trackLayer))

现在,我们可以设置一个基于内存的地图层来显示最短路径:

        self.shortestPathLayer = QgsVectorLayer(
            "LineString?crs=EPSG:4326",
            "shortestPathLayer", "memory")
        QgsMapLayerRegistry.instance().addMapLayer(
            self.shortestPathLayer)
        layers.append(QgsMapCanvasLayer(self.shortestPathLayer))

我们在 第六章 中看到了如何创建基于内存的地图层,掌握 QGIS Python API,所以这里不应该有任何惊喜;我们只是在定义一个用于保存 LineString 几何的最短路径层。

接下来,我们想要设置另一个基于内存的地图层来显示用户的选定起点:

        self.startPointLayer = QgsVectorLayer(
                                   "Point?crs=EPSG:4326",
                                   "startPointLayer", "memory")
        QgsMapLayerRegistry.instance().addMapLayer(
            self.startPointLayer)
        layers.append(QgsMapCanvasLayer(self.startPointLayer))

此外,我们还想为终点设置另一个地图层:

        self.endPointLayer = QgsVectorLayer(
             "Point?crs=EPSG:4326",
             "endPointLayer", "memory")
        QgsMapLayerRegistry.instance().addMapLayer(
            self.endPointLayer)
        layers.append(QgsMapCanvasLayer(self.endPointLayer))

这完成了我们五个地图层的所有设置。setupMapLayers() 方法的最后一部分将这些各种层添加到地图画布上。请注意,因为我们按从后向前的顺序定义了地图层(换句话说,layers 中的第一个条目是底图,它应该出现在后面),在将它们添加到地图画布之前,我们必须反转这些层。以下是相关代码:

        layers.reverse()
        self.mapCanvas.setLayerSet(layers)
        self.mapCanvas.setExtent(self.baseLayer.extent())

我们最后要做的就是从我们的 main() 函数中添加对 setupMapLayers() 的调用。在 window.setupDatabase() 行之后立即添加以下内容:

window.setupMapLayers()

现在我们已经设置了地图层,我们可以再次运行我们的程序。目前还没有矢量数据,但底图应该是可见的,我们可以使用工具栏图标进行缩放:

定义地图层

定义地图渲染器

现在我们有了地图层,我们将想要设置适当的符号和渲染器来将矢量数据绘制到地图上。让我们首先定义一个名为 setupRenderers() 的方法,它为我们的各种地图层创建渲染器。我们的第一个渲染器将显示轨迹层,我们使用 QgsRuleBasedRendererV2 对象根据轨迹类型、轨迹是否开放以及是否为双向或只能单向使用来以不同的方式显示轨迹。以下是相关代码:

    def setupRenderers(self):
        root_rule = QgsRuleBasedRendererV2.Rule(None)

        for track_type in (TRACK_TYPE_ROAD, TRACK_TYPE_WALKING,
                           TRACK_TYPE_BIKE, TRACK_TYPE_HORSE):
            if track_type == TRACK_TYPE_ROAD:
                width = ROAD_WIDTH
            else:
                width = TRAIL_WIDTH

            lineColor = "light gray"
            arrowColor = "dark gray"

            for track_status in (TRACK_STATUS_OPEN,TRACK_STATUS_CLOSED):
                for track_direction in (TRACK_DIRECTION_BOTH,
                                        TRACK_DIRECTION_FORWARD,
                                        TRACK_DIRECTION_BACKWARD):
                    symbol = self.createTrackSymbol(width,lineColor, arrowColor,track_status,track_direction)
                    expression = ("(type='%s') and " +
                                  "(status='%s') and " +
                                  "(direction='%s')") % (track_type,track_status,                            track_direction)

                    rule = QgsRuleBasedRendererV2.Rule(symbol,filterExp=expression)
                    root_rule.appendChild(rule)

        symbol = QgsLineSymbolV2.createSimple({'color' : "black"})
        rule = QgsRuleBasedRendererV2.Rule(symbol, elseRule=True)
        root_rule.appendChild(rule)

        renderer = QgsRuleBasedRendererV2(root_rule)
        self.trackLayer.setRendererV2(renderer)

如您所见,我们遍历所有可能的轨迹类型。根据轨迹类型,我们选择合适的线宽。我们还选择用于线条和箭头的颜色——目前,我们只是为每种轨迹类型使用相同的颜色。然后,我们遍历所有可能的状态和方向值,并调用名为createTrackSymbol()的辅助方法来为该轨迹类型、状态和方向创建合适的符号。然后,我们创建一个QgsRuleBasedRendererV2.Rule对象,该对象使用该符号为给定类型、状态和方向的轨迹。最后,我们为渲染器定义一个“else”规则,如果轨迹没有预期的属性值,则将其显示为简单的黑色线条。

我们剩余的地图层将使用简单的线条或标记符号来显示最短路径以及起点和终点。以下是setupRenderers()方法的其余部分,它定义了这些地图渲染器:

        symbol = QgsLineSymbolV2.createSimple({'color' : "blue"})
        symbol.setWidth(ROAD_WIDTH)
        symbol.setOutputUnit(QgsSymbolV2.MapUnit)
        renderer = QgsSingleSymbolRendererV2(symbol)
        self.shortestPathLayer.setRendererV2(renderer)

        symbol = QgsMarkerSymbolV2.createSimple(
                            {'color' : "green"})
        symbol.setSize(POINT_SIZE)
        symbol.setOutputUnit(QgsSymbolV2.MapUnit)
        renderer = QgsSingleSymbolRendererV2(symbol)
        self.startPointLayer.setRendererV2(renderer)

        symbol = QgsMarkerSymbolV2.createSimple({'color' : "red"})
        symbol.setSize(POINT_SIZE)
        symbol.setOutputUnit(QgsSymbolV2.MapUnit)
        renderer = QgsSingleSymbolRendererV2(symbol)
        self.endPointLayer.setRendererV2(renderer)

现在我们已经定义了setupRenderers()方法本身,让我们修改我们的main()函数来调用它。在调用setupMapLayers()之后立即添加以下行:

window.setupRenderers()

为了完成我们的地图渲染器的实现,我们还需要做一些其他的事情。首先,我们需要定义我们用来设置轨迹渲染器的createTrackSymbol()辅助方法。将以下内容添加到您的ForestTrailsWindow类中:

    def createTrackSymbol(self, width, lineColor, arrowColor,
                          status, direction):
        symbol = QgsLineSymbolV2.createSimple({})
        symbol.deleteSymbolLayer(0) # Remove default symbol layer.

        symbolLayer = QgsSimpleLineSymbolLayerV2()
        symbolLayer.setWidth(width)
        symbolLayer.setWidthUnit(QgsSymbolV2.MapUnit)
        symbolLayer.setColor(QColor(lineColor))
        if status == TRACK_STATUS_CLOSED:
            symbolLayer.setPenStyle(Qt.DotLine)
        symbol.appendSymbolLayer(symbolLayer)

        if direction == TRACK_DIRECTION_FORWARD:
            registry = QgsSymbolLayerV2Registry.instance()
            markerLineMetadata = registry.symbolLayerMetadata(
                "MarkerLine")
            markerMetadata     = registry.symbolLayerMetadata(
                "SimpleMarker")

            symbolLayer = markerLineMetadata.createSymbolLayer(
                                {'width': '0.26',
                                 'color': arrowColor,
                                 'rotate': '1',
                                 'placement': 'interval',
                                 'interval' : '20',
                                 'offset': '0'})
            subSymbol = symbolLayer.subSymbol()
            subSymbol.deleteSymbolLayer(0)
            triangle = markerMetadata.createSymbolLayer(
                                {'name': 'filled_arrowhead',
                                 'color': arrowColor,
                                 'color_border': arrowColor,
                                 'offset': '0,0',
                                 'size': '3',
                                 'outline_width': '0.5',
                                 'output_unit': 'mapunit',
                                 'angle': '0'})
            subSymbol.appendSymbolLayer(triangle)

            symbol.appendSymbolLayer(symbolLayer)
        elif direction == TRACK_DIRECTION_BACKWARD:
            registry = QgsSymbolLayerV2Registry.instance()
            markerLineMetadata = registry.symbolLayerMetadata(
                "MarkerLine")
            markerMetadata     = registry.symbolLayerMetadata(
                "SimpleMarker")

            symbolLayer = markerLineMetadata.createSymbolLayer(
                                {'width': '0.26',
                                 'color': arrowColor,
                                 'rotate': '1',
                                 'placement': 'interval',
                                 'interval' : '20',
                                 'offset': '0'})
            subSymbol = symbolLayer.subSymbol()
            subSymbol.deleteSymbolLayer(0)
            triangle = markerMetadata.createSymbolLayer(
                                {'name': 'filled_arrowhead',
                                 'color': arrowColor,
                                 'color_border': arrowColor,
                                 'offset': '0,0',
                                 'size': '3',
                                 'outline_width': '0.5',
                                 'output_unit': 'mapunit',
                                 'angle': '180'})
            subSymbol.appendSymbolLayer(triangle)

            symbol.appendSymbolLayer(symbolLayer)

        return symbol

这个方法的复杂部分是绘制箭头到轨迹上以指示轨迹方向的代码。除此之外,我们只是使用指定的颜色和宽度绘制线条来表示轨迹,如果轨迹是闭合的,我们将其绘制为虚线。

我们在这里的最终任务是向我们的constants.py模块添加一些条目来表示我们的渲染器使用的各种大小和线宽。将以下内容添加到该模块的末尾:

ROAD_WIDTH  = 0.0001
TRAIL_WIDTH = 0.00003
POINT_SIZE  = 0.0004

所有这些值都在地图单位中。

不幸的是,我们目前看不到这些渲染器被使用,因为我们还没有任何矢量要素来显示,但我们需要现在实现它们,以便我们的代码在需要时能够工作。我们将在下一章中看到这些渲染器的实际效果,当用户开始添加轨迹并在地图上选择起点和终点时。

平移工具

为了让用户在地图上移动,我们将使用我们在早期章节中实现的PanTool类。将以下类定义添加到mapTools.py模块中:

class PanTool(QgsMapTool):
    def __init__(self, mapCanvas):
        QgsMapTool.__init__(self, mapCanvas)
        self.setCursor(Qt.OpenHandCursor)
        self.dragging = False

    def canvasMoveEvent(self, event):
        if event.buttons() == Qt.LeftButton:
            self.dragging = True
            self.canvas().panAction(event)

    def canvasReleaseEvent(self, event):
        if event.button() == Qt.LeftButton and self.dragging:
            self.canvas().panActionEnd(event.pos())
            self.dragging = False

在我们的forestTrails.py模块中,添加以下新方法:

    def setupMapTools(self):
        self.panTool = PanTool(self.mapCanvas)
        self.panTool.setAction(self.actionPan)

此方法将初始化我们的应用程序将使用的各种地图工具;我们将随着进展添加到这个方法中。现在,在调用window.setupRenderers()之后,向您的main()函数中添加以下内容:

    window.setupMapTools()

我们现在可以用真实的东西替换我们的setPanMode()的模拟实现:

    def setPanMode(self):
        self.mapCanvas.setMapTool(self.panTool)

如果您现在运行程序,您会看到用户现在可以放大和缩小,并使用平移工具在基本地图上移动。

实现轨迹编辑模式

本章的最后一个任务是实现轨道编辑模式。我们在上一章学习了如何为地图层打开编辑模式,然后使用各种地图工具让用户添加、编辑和删除功能。我们将在第九章,完成 ForestTrails 应用程序中开始实现实际的地图工具,但现在,让我们定义我们的轨道编辑模式本身。

setEditMode()方法用于进入和退出轨道编辑模式。用这个新实现替换你之前定义的占位符方法:

    def setEditMode(self):
        if self.editing:
            if self.modified:
                reply = QMessageBox.question(self, "Confirm",
                                             "Save Changes?",
                                             QMessageBox.Yes |
                                             QMessageBox.No,
                                             QMessageBox.Yes)
                if reply == QMessageBox.Yes:
                    self.trackLayer.commitChanges()
                else:
                    self.trackLayer.rollBack()
            else:
                self.trackLayer.commitChanges()
            self.trackLayer.triggerRepaint()
            self.editing = False
            self.setPanMode()
        else:
            self.trackLayer.startEditing()
            self.trackLayer.triggerRepaint()
            self.editing  = True
            self.modified = False
            self.setPanMode()
        self.adjustActions()

如果用户目前正在编辑轨道并已进行了某些更改,我们将询问用户他们是否想要保存更改,然后提交更改或撤销更改。如果没有进行任何更改,我们将撤销(关闭矢量层的编辑模式)并切换回平移模式。

我们在这里使用了一些实例变量来监控轨道编辑的状态:self.editing将在我们正在编辑轨道时设置为True,而self.modified将在用户在轨道层中更改了任何内容时设置为True。我们必须在我们的ForestTrailsWindow.__init__()方法中添加以下内容来初始化这两个实例变量:

        self.editing  = False
        self.modified= False

另有一个我们之前没有见过的方法:adjustActions()。这个方法将根据应用程序的当前状态启用/禁用和检查/取消选中各种操作:例如,当我们进入轨道编辑模式时,我们的adjustActions()方法将启用添加、编辑和删除工具,当用户离开轨道编辑模式时,这些工具将再次被禁用。

我们目前无法实现所有的adjustActions(),因为我们还没有定义应用程序将使用的各种地图工具。现在,我们将编写这个方法的前半部分:

    def adjustActions(self):
       if self.editing:
            self.actionAddTrack.setEnabled(True)
            self.actionEditTrack.setEnabled(True)
            self.actionDeleteTrack.setEnabled(True)
            self.actionGetInfo.setEnabled(True)
            self.actionSetStartPoint.setEnabled(False)
            self.actionSetEndPoint.setEnabled(False)
            self.actionFindShortestPath.setEnabled(False)
        else:
            self.actionAddTrack.setEnabled(False)
            self.actionEditTrack.setEnabled(False)
            self.actionDeleteTrack.setEnabled(False)
            self.actionGetInfo.setEnabled(False)
            self.actionSetStartPoint.setEnabled(True)
            self.actionSetEndPoint.setEnabled(True)
            self.actionFindShortestPath.setEnabled(True)

我们还需要在调用setPanMode()之后在我们的main()函数中添加对adjustActions()的调用:

    window.adjustActions()

实现了轨道编辑模式后,用户可以点击编辑工具栏图标进入轨道编辑模式,再次点击它以退出该模式。当然,我们目前还不能进行任何更改,但代码本身已经就位。

我们还想在我们的应用程序中添加一个功能;如果用户对轨道层进行了某些更改然后尝试退出应用程序,我们希望给用户一个保存更改的机会。为此,我们将实现一个quit()方法,并将其链接到actionQuit操作:

    def quit(self):
        if self.editing and self.modified:
            reply = QMessageBox.question(self, "Confirm",
                                         "Save Changes?",
                                         QMessageBox.Yes |
                                         QMessageBox.No |
                                         QMessageBox.Cancel,
                                         QMessageBox.Yes)
            if reply == QMessageBox.Yes:
                self.curEditedLayer.commitChanges()
            elif reply == QMessageBox.No:
                self.curEditedLayer.rollBack()

            if reply != QMessageBox.Cancel:
                qApp.quit()
        else:
            qApp.quit()

这与setEditMode()方法中允许用户退出轨道编辑模式的部分非常相似,只不过我们在最后调用qApp.quit()来退出应用程序。我们还有一个方法需要定义,它拦截关闭窗口的尝试并调用self.quit()。这会在用户在编辑时关闭窗口时提示用户保存他们的更改。以下是此方法的定义:

    def closeEvent(self, event):
        self.quit()

摘要

在本章中,我们设计和开始实施了一个完整的映射应用程序,用于维护休闲森林内轨迹和道路的地图。我们实现了应用程序本身,定义了我们的地图层,为我们的应用程序获取了高分辨率的基础地图,并实现了缩放、平移以及编辑轨迹层所需的代码。

在下一章中,我们将通过实现地图工具来完善我们的 ForestTrails 系统的实施,使用户能够添加、编辑和删除轨迹。我们还将实现编辑轨迹属性和查找两点之间最短可用路径的代码。

第九章:完成 ForestTrails 应用程序

在本章中,我们将完成我们在上一章中开始构建的 ForestTrails 应用程序的实施。到目前为止,我们的应用程序显示了基础地图,并允许用户在地图上缩放和平移。我们已实现了轨道编辑模式,尽管用户还不能输入或编辑轨道数据。

在本章中,我们将向 ForestTrails 应用程序添加以下功能:

  • 允许用户添加、编辑和删除轨迹的地图工具

  • 一个工具栏动作,允许用户查看和编辑轨迹的属性

  • 设置起点设置终点 动作

  • 使用基于内存的地图层计算并显示两个选定点之间的最短可用路径

添加轨迹地图工具

我们的首要任务是让用户在轨道编辑模式下添加新的轨迹。这涉及到定义一个新的地图工具,我们将称之为 AddTrackTool。然而,在我们开始实现 AddTrackTool 类之前,我们将创建一个混合类,为我们的地图工具提供各种辅助方法。我们将把这个混合类称为 MapToolMixin

这里是我们 MapToolMixin 类的初始实现,应该放在你的 mapTools.py 模块顶部附近:

class MapToolMixin
    def setLayer(self, layer):
        self.layer = layer

    def transformCoordinates(self, screenPt):
        return (self.toMapCoordinates(screenPt),
                self.toLayerCoordinates(self.layer, screenPt))

    def calcTolerance(self, pos):
        pt1 = QPoint(pos.x(), pos.y())
        pt2 = QPoint(pos.x() + 10, pos.y())

        mapPt1,layerPt1 = self.transformCoordinates(pt1)
        mapPt2,layerPt2 = self.transformCoordinates(pt2)
        tolerance = layerPt2.x() - layerPt1.x()

        return tolerance

我们在创建第七章 选择和编辑 PyQGIS 应用程序中的要素 中的几何编辑地图工具时已经看到了 transformCoordinates()calcTolerance() 方法。唯一的区别是我们存储了对编辑地图层的引用,这样我们就不必每次计算容差或转换坐标时都提供它。

我们现在可以开始实现 AddTrackTool 类。这与我们在第七章 选择和编辑 PyQGIS 应用程序中的要素 中定义的 CaptureTool 非常相似,除了它只捕获 LineString 几何形状,并且在用户完成定义轨迹时创建一个新的具有默认属性的轨迹要素。以下是新地图工具的类定义和 __init__() 方法,应该放在 mapTools.py 模块中:

class AddTrackTool(QgsMapTool, MapToolMixin):
    def __init__(self, canvas, layer, onTrackAdded):
        QgsMapTool.__init__(self, canvas)
        self.canvas         = canvas
        self.onTrackAdded   = onTrackAdded
        self.rubberBand     = None
        self.tempRubberBand = None
        self.capturedPoints = []
        self.capturing      = False
        self.setLayer(layer)
        self.setCursor(Qt.CrossCursor)

如您所见,我们的类从 QgsMapToolMapToolMixin 继承。我们还调用了 setLayer() 方法,这样混合类就知道要使用哪个图层。这也使得当前编辑的图层通过 self.layer 可用。

我们接下来定义了我们地图工具的各种事件处理方法:

    def canvasReleaseEvent(self, event):
        if event.button() == Qt.LeftButton:
            if not self.capturing:
                self.startCapturing()
            self.addVertex(event.pos())
        elif event.button() == Qt.RightButton:
            points = self.getCapturedPoints()
            self.stopCapturing()
            if points != None:
                self.pointsCaptured(points)

    def canvasMoveEvent(self, event):
        if self.tempRubberBand != None and self.capturing:
            mapPt,layerPt = self.transformCoordinates(event.pos())
            self.tempRubberBand.movePoint(mapPt)

    def keyPressEvent(self, event):
        if event.key() == Qt.Key_Backspace or \
           event.key() == Qt.Key_Delete:
            self.removeLastVertex()
            event.ignore()
        if event.key() == Qt.Key_Return or \
           event.key() == Qt.Key_Enter:
            points = self.getCapturedPoints()
            self.stopCapturing()
            if points != None:
                self.pointsCaptured(points)

再次,我们在 CaptureTool 类中看到了这种逻辑。唯一的区别是我们只捕获 LineString 几何形状,所以我们不需要担心捕获模式。

现在,我们来到了 startCapturing()stopCapturing() 方法。这些方法创建并释放我们地图工具使用的橡皮筋:

    def startCapturing(self):
        color = QColor("red")
        color.setAlphaF(0.78)

        self.rubberBand = QgsRubberBand(self.canvas, QGis.Line)
        self.rubberBand.setWidth(2)
        self.rubberBand.setColor(color)
        self.rubberBand.show()

        self.tempRubberBand = QgsRubberBand(self.canvas, QGis.Line)
        self.tempRubberBand.setWidth(2)
        self.tempRubberBand.setColor(color)
        self.tempRubberBand.setLineStyle(Qt.DotLine)
        self.tempRubberBand.show()

        self.capturing = True

    def stopCapturing(self):
        if self.rubberBand:
            self.canvas.scene().removeItem(self.rubberBand)
            self.rubberBand = None
        if self.tempRubberBand:
            self.canvas.scene().removeItem(self.tempRubberBand)
            self.tempRubberBand = None
        self.capturing = False
        self.capturedPoints = []
        self.canvas.refresh()

接下来,我们有 addVertex() 方法,它将一个新的顶点添加到轨迹中:

    def addVertex(self, canvasPoint):
        mapPt,layerPt = self.transformCoordinates(canvasPoint)

        self.rubberBand.addPoint(mapPt)
        self.capturedPoints.append(layerPt)

        self.tempRubberBand.reset(QGis.Line)
        self.tempRubberBand.addPoint(mapPt)

注意,我们调用了 self.transformCoordinates(),这是我们混合类定义的一个方法。

我们的下一种方法是 removeLastVertex()。当用户按下删除键时,它会删除最后添加的顶点:

    def removeLastVertex(self):
        if not self.capturing: return

        bandSize     = self.rubberBand.numberOfVertices()
        tempBandSize = self.tempRubberBand.numberOfVertices()
        numPoints    = len(self.capturedPoints)

        if bandSize < 1 or numPoints < 1:
            return

        self.rubberBand.removePoint(-1)

        if bandSize > 1:
            if tempBandSize > 1:
                point = self.rubberBand.getPoint(0, bandSize-2)
                self.tempRubberBand.movePoint(tempBandSize-2,
                                              point)
        else:
            self.tempRubberBand.reset(QGis.Line)

        del self.capturedPoints[-1]

我们现在定义 getCapturedPoints() 方法,它返回用户点击的点集或 None(如果用户点击的点不足以形成一个 LineString):

    def getCapturedPoints(self):
        points = self.capturedPoints
        if len(points) < 2:
            return None
        else:
            return points

我们的最后一种方法是 pointsCaptured(),当用户完成对新轨迹点的点击时,它会做出响应。与 CaptureTool 中的等效方法不同,我们必须为新轨迹设置各种属性:

    def pointsCaptured(self, points):
        fields = self.layer.dataProvider().fields()

        feature = QgsFeature()
        feature.setGeometry(QgsGeometry.fromPolyline(points))
        feature.setFields(fields)
        feature.setAttribute("type",      TRACK_TYPE_ROAD)
        feature.setAttribute("status",    TRACK_STATUS_OPEN)
        feature.setAttribute("direction", TRACK_DIRECTION_BOTH)

        self.layer.addFeature(feature)
        self.layer.updateExtents()
        self.onTrackAdded()

现在我们已经定义了我们的地图工具,让我们更新我们的应用程序以使用这个工具。回到 forestTrails.py 模块,在 setupMapTools() 方法的末尾添加以下内容:

        self.addTrackTool = AddTrackTool(self.mapCanvas,
                                         self.trackLayer,
                                         self.onTrackAdded)
        self.addTrackTool.setAction(self.actionAddTrack)

我们现在可以定义我们的 addTrack() 方法如下:

    def addTrack(self):
        if self.actionAddTrack.isChecked():
            self.mapCanvas.setMapTool(self.addTrackTool)
        else:
            self.setPanMode()

如果用户勾选了添加轨迹操作,我们将激活添加轨迹工具。如果用户再次点击取消勾选该操作,我们将切换回平移模式。

最后,我们必须定义一个名为 onTrackAdded() 的辅助方法。该方法在用户将新轨迹添加到我们的轨迹层时做出响应。以下是此方法的实现:

    def onTrackAdded(self):
        self.modified = True
        self.mapCanvas.refresh()
        self.actionAddTrack.setChecked(False)
        self.setPanMode()

测试应用程序

在实现了所有这些代码后,是时候测试我们的应用程序了。运行适当的启动脚本,并在地图上稍微放大。然后点击编辑操作,然后点击添加轨迹操作。如果一切顺利,你应该能够点击地图来定义新轨迹的顶点。完成时,按回车键创建新轨迹。结果应该类似于以下截图:

测试应用程序

如果你然后再次点击编辑轨迹图标,你会被问是否要保存你的更改。继续操作,你的新轨迹应该被永久保存。

现在回到轨迹编辑模式,尝试创建一个与第一个轨迹连接的第二个轨迹。例如:

测试应用程序

如果你然后放大,你会很快发现我们应用程序设计中的一个重大缺陷,如下一张截图所示:

测试应用程序

轨迹没有连接在一起。由于用户可以在地图上的任何地方点击,因此无法确保轨迹是连接的——如果轨迹没有连接,找到最短路径命令将无法工作。

我们有几种方法可以解决这个问题,但在这个情况下,最简单的方法是实现顶点吸附,也就是说,如果用户点击接近一个现有的顶点,我们将点击位置吸附到顶点上,以便将各种轨迹连接起来。

顶点吸附

为了实现顶点吸附,我们将在 MapToolMixin 中添加一些新方法。我们将从 findFeatureAt() 方法开始。此方法找到点击位置附近的一个特征。以下是此方法的实现:

    def findFeatureAt(self, pos, excludeFeature=None):
        mapPt,layerPt = self.transformCoordinates(pos)
        tolerance = self.calcTolerance(pos)
        searchRect = QgsRectangle(layerPt.x() - tolerance,
                                  layerPt.y() - tolerance,
                                  layerPt.x() + tolerance,
                                  layerPt.y() + tolerance)

        request = QgsFeatureRequest()
        request.setFilterRect(searchRect)
        request.setFlags(QgsFeatureRequest.ExactIntersect)

        for feature in self.layer.getFeatures(request):
            if excludeFeature != None:
                if feature.id() == excludeFeature.id():
                    continue
            return feature

        return None

注意

如你所见,这种方法包含一个可选的 excludeFeature 参数。这允许我们排除搜索中的特定功能,这在之后会变得很重要。

接下来,我们将定义 findVertexAt() 方法,该方法用于识别接近给定点击位置的顶点(如果有的话)。以下是该方法的实现:

    def findVertexAt(self, feature, pos):
        mapPt,layerPt = self.transformCoordinates(pos)
        tolerance     = self.calcTolerance(pos)

        vertexCoord,vertex,prevVertex,nextVertex,distSquared = \
            feature.geometry().closestVertex(layerPt)

        distance = math.sqrt(distSquared)
        if distance > tolerance:
            return None
        else:
            return vertex

如你所见,我们使用 QgsGeometry.closestVertex() 方法来找到接近给定位置的顶点,然后查看该顶点是否在容差距离内。如果是这样,我们返回被点击顶点的顶点索引;否则,我们返回 None

注意到这种方法使用了 math.sqrt() 函数。为了能够使用这个函数,你需要在模块顶部附近添加以下内容:

import math

定义了这两个新方法后,我们就可以开始实现顶点吸附功能了。下面是我们将要编写的函数签名:

snapToNearestVertex(pos, trackLayer, excludeFeature=None)

在这个方法中,pos 是点击位置(在画布坐标中),trackLayer 是对我们轨迹层的引用(其中包含我们需要检查的功能和顶点),而 excludeFeature 是在寻找附近顶点时可选排除的功能。

注意

当我们开始编辑轨迹时,excludeFeature 参数将很有用。我们将使用它来阻止轨迹吸附到自身。

完成后,我们的方法将返回被点击顶点的坐标。如果用户没有点击在功能附近,或者接近顶点,那么这个方法将返回点击位置,并转换为图层坐标。这使得用户可以在地图画布上点击远离任何顶点的地方来绘制新功能,同时当用户点击时仍然吸附到现有的顶点上。

下面是我们 snapToNearestVertex() 方法的实现:

    def snapToNearestVertex(self, pos, trackLayer,
                            excludeFeature=None):
        mapPt,layerPt = self.transformCoordinates(pos)
        feature = self.findFeatureAt(pos, excludeFeature)
        if feature == None: return layerPt

        vertex = self.findVertexAt(feature, pos)
        if vertex == None: return layerPt

        return feature.geometry().vertexAt(vertex)

如你所见,我们使用 findFeatureAt() 方法来搜索接近给定点击点的功能。如果我们找到一个功能,我们就调用 self.findVertexAt() 来找到接近用户点击位置的顶点。最后,如果我们找到一个顶点,我们就返回该顶点的坐标。否则,我们返回原始点击位置转换为图层坐标。

通过扩展我们的混合类,我们可以轻松地为 AddTrack 工具添加吸附功能。我们只需要将我们的 addVertex() 方法替换为以下内容:

    def addVertex(self, canvasPoint):
        snapPt = self.snapToNearestVertex(canvasPoint, self.layer)
        mapPt = self.toMapCoordinates(self.layer, snapPt)

        self.rubberBand.addPoint(mapPt)
        self.capturedPoints.append(snapPt)

        self.tempRubberBand.reset(QGis.Line)
        self.tempRubberBand.addPoint(mapPt)

现在我们已经启用了顶点吸附功能,确保我们的轨迹连接起来将变得容易。请注意,我们还将使用顶点吸附来编辑轨迹,以及当用户选择最短可用路径计算的开始和结束点时。这就是为什么我们将这些方法添加到我们的混合类中,而不是添加到 AddTrack 工具中。

编辑轨迹图工具

我们下一个任务是实现编辑路径动作。为此,我们将使用在第七章中定义的 EditTool,即 在 PyQGIS 应用程序中选择和编辑要素,并修改它以专门用于路径。幸运的是,我们只需要支持 LineString 几何形状,并可以利用我们的混合类,这将简化新地图工具的实现。

让我们从向 mapTools.py 模块添加我们的新类定义以及 __init__() 方法开始:

class EditTrackTool(QgsMapTool, MapToolMixin):
    def __init__(self, canvas, layer, onTrackEdited):
        QgsMapTool.__init__(self, canvas)
        self.onTrackEdited = onTrackEdited
        self.dragging      = False
        self.feature       = None
        self.vertex        = None
        self.setLayer(layer)
        self.setCursor(Qt.CrossCursor)

我们现在定义我们的 canvasPressEvent() 方法,以响应用户在地图画布上按下鼠标按钮:

    def canvasPressEvent(self, event):
        feature = self.findFeatureAt(event.pos())
        if feature == None:
            return

        vertex = self.findVertexAt(feature, event.pos())
        if vertex == None: return

        if event.button() == Qt.LeftButton:
            # Left click -> move vertex.
            self.dragging = True
            self.feature  = feature
            self.vertex   = vertex
            self.moveVertexTo(event.pos())
            self.canvas().refresh()
        elif event.button() == Qt.RightButton:
            # Right click -> delete vertex.
            self.deleteVertex(feature, vertex)
            self.canvas().refresh()

如您所见,我们正在使用我们的混合类的方法来查找点击的要素和顶点。这简化了 canvasPressedEvent() 方法的实现。

我们现在来到 canvasMoveEvent()canvasReleaseEvent() 方法,它们基本上与在 第七章 中定义的 EditTool 方法相同,即 在 PyQGIS 应用程序中选择和编辑要素

    def canvasMoveEvent(self, event):
        if self.dragging:
            self.moveVertexTo(event.pos())
            self.canvas().refresh()

    def canvasReleaseEvent(self, event):
        if self.dragging:
            self.moveVertexTo(event.pos())
            self.layer.updateExtents()
            self.canvas().refresh()
            self.dragging = False
            self.feature  = None
            self.vertex   = None

我们的 canvasDoubleClickEvent() 方法也非常相似,唯一的区别在于我们可以使用由我们的混合类定义的 findFeatureAt() 方法:

    def canvasDoubleClickEvent(self, event):
        feature = self.findFeatureAt(event.pos())
        if feature == None:
            return

        mapPt,layerPt = self.transformCoordinates(event.pos())
        geometry      = feature.geometry()

        distSquared,closestPt,beforeVertex = \
            geometry.closestSegmentWithContext(layerPt)

        distance = math.sqrt(distSquared)
        tolerance = self.calcTolerance(event.pos())
        if distance > tolerance: return

        geometry.insertVertex(closestPt.x(), closestPt.y(),
                              beforeVertex)
        self.layer.changeGeometry(feature.id(), geometry)
        self.onTrackEdited()
        self.canvas().refresh()

我们现在有 moveVertexTo() 方法,它将点击的顶点移动到当前鼠标位置。虽然逻辑与我们的 EditTool 中同名方法非常相似,但我们还希望支持顶点吸附,以便用户可以点击现有的顶点将两条路径连接起来。以下是此方法的实现:

    def moveVertexTo(self, pos):
        snappedPt = self.snapToNearestVertex(pos, self.layer,
                                             self.feature)

        geometry = self.feature.geometry()
        layerPt = self.toLayerCoordinates(self.layer, pos)
        geometry.moveVertex(snappedPt.x(), snappedPt.y(),
                            self.vertex)
        self.layer.changeGeometry(self.feature.id(), geometry)
        self.onTrackEdited()

注意,我们的 snapToNearestVertex() 调用使用了 excludeFeature 参数来排除点击的要素,以便在寻找吸附顶点时排除。这确保了我们不会将一个要素吸附到它自己上。

最后,我们有 deleteVertex() 方法,它几乎是从 EditTool 类直接复制过来的:

    def deleteVertex(self, feature, vertex):
        geometry = feature.geometry()

        lineString = geometry.asPolyline()
        if len(lineString) <= 2:
            return

        if geometry.deleteVertex(vertex):
            self.layer.changeGeometry(feature.id(), geometry)
            self.onTrackEdited()

在实现了这个复杂的地图工具之后,我们现在可以使用它来让用户编辑一条路径。回到 forestTrails.py 模块,在 setupMapTools() 方法的末尾添加以下内容:

        self.editTrackTool = EditTrackTool(self.mapCanvas,
                                           self.trackLayer,
                                           self.onTrackEdited)
        self.editTrackTool.setAction(self.actionEditTrack)

我们现在想用以下内容替换我们的 editTrack() 方法占位符:

    def editTrack(self):
        if self.actionEditTrack.isChecked():
            self.mapCanvas.setMapTool(self.editTrackTool)
        else:
            self.setPanMode()

addTrack() 方法一样,当用户点击我们的动作时,我们切换到编辑工具,如果用户再次点击动作,则切换回平移模式。

我们最后需要做的是实现 ForestTrailsWindow.onTrackEdited() 方法,以响应用户对路径的更改。以下是这个新方法:

    def onTrackEdited(self):
        self.modified = True
        self.mapCanvas.refresh()

我们只需要记住轨道层已被修改,并重新绘制地图画布以显示更改。请注意,我们不会切换回平移模式,因为用户将继续修改轨道顶点,直到他们通过点击工具栏图标第二次或从工具栏中选择不同的操作来明确关闭编辑工具。

实现此功能后,您可以重新运行您的程序,切换到轨道编辑模式,并点击编辑轨道操作来添加、移动或删除顶点。如果您仔细观察,您会发现当您将鼠标移到您正在拖动的顶点附近时,该顶点会自动吸附到另一个特征的顶点上。与EditTool一样,您可以通过双击一个段来添加一个新顶点,或者按住Ctrl键并点击一个顶点来删除它。

删除轨道地图工具

现在,我们想要实现删除轨道操作。幸运的是,执行此操作的地图工具非常简单,多亏了我们的 mixin 类。将以下类定义添加到mapTools.py模块中:

class DeleteTrackTool(QgsMapTool, MapToolMixin):
    def __init__(self, canvas, layer, onTrackDeleted):
        QgsMapTool.__init__(self, canvas)
        self.onTrackDeleted = onTrackDeleted
        self.feature        = None
        self.setLayer(layer)
        self.setCursor(Qt.CrossCursor)

    def canvasPressEvent(self, event):
        self.feature = self.findFeatureAt(event.pos())

    def canvasReleaseEvent(self, event):
        feature = self.findFeatureAt(event.pos())
        if feature != None and feature.id() == self.feature.id():
            self.layer.deleteFeature(self.feature.id())
            self.onTrackDeleted()

然后,在forestTrails.py模块中,将以下内容添加到setupMapTools()方法的末尾:

        self.deleteTrackTool = DeleteTrackTool(
            self.mapCanvas, self.trackLayer, self.onTrackDeleted)
        self.deleteTrackTool.setAction(self.actionDeleteTrack)

然后将占位符deleteTrack()方法替换为以下内容:

    def deleteTrack(self):
        if self.actionDeleteTrack.isChecked():
            self.mapCanvas.setMapTool(self.deleteTrackTool)
        else:
            self.setPanMode()

最后,添加一个新的onTrackDeleted()方法来响应用户删除轨道的情况:

    def onTrackDeleted(self):
        self.modified = True
        self.mapCanvas.refresh()
        self.actionDeleteTrack.setChecked(False)
        self.setPanMode()

使用这个地图工具,我们现在拥有了添加、编辑和删除轨道所需的所有逻辑。我们现在有一个完整的地图应用程序,用于维护森林小径数据库,并且您可以使用这个程序输入您想要的任何数量的轨道。

删除轨道地图工具

当然,我们还没有完成。特别是,我们目前还不能指定轨道的类型;目前每个轨道都是一条道路。为了解决这个问题,我们的下一个任务是实现获取信息操作。

获取信息地图工具

当用户点击工具栏中的获取信息项时,我们将激活一个自定义地图工具,允许用户点击轨道以显示和编辑该轨道的属性。让我们一步一步地实现这个功能,从GetInfoTool类本身开始。将以下内容添加到您的mapTools.py模块中:

class GetInfoTool(QgsMapTool, MapToolMixin):
    def __init__(self, canvas, layer, onGetInfo):
        QgsMapTool.__init__(self, canvas)
        self.onGetInfo = onGetInfo
        self.setLayer(layer)
        self.setCursor(Qt.WhatsThisCursor)

    def canvasReleaseEvent(self, event):
        if event.button() != Qt.LeftButton: return
        feature = self.findFeatureAt(event.pos())
        if feature != None:
            self.onGetInfo(feature)

当用户点击轨道时,此地图工具会调用onGetInfo()方法(该方法作为参数传递给地图工具的初始化器)。现在,让我们在我们的forestTrails.py模块中添加以下代码到setupMapTools()方法的末尾,以在程序中使用此地图工具:

        self.getInfoTool = GetInfoTool(self.mapCanvas,
                                       self.trackLayer,
                                       self.onGetInfo)
        self.getInfoTool.setAction(self.actionGetInfo)

我们可以将我们的占位符getInfo()方法替换为以下内容:

    def getInfo(self):
        self.mapCanvas.setMapTool(self.getInfoTool)

这会在用户点击工具栏图标时激活地图工具。最后一步是实现onGetInfo()方法,该方法在用户选择地图工具并点击轨道时被调用。

当调用onGetInfo()时,我们希望向用户显示点击的轨迹的各种属性。这些属性将在对话框中显示,用户如果愿意可以做出更改。当用户提交更改时,我们必须更新特征以包含新的属性值,并指示轨迹已被更改。

我们的大部分工作将是设置对话框窗口,以便用户可以显示和编辑属性。为此,我们将创建一个新的类TrackInfoDialog,它将是QDialog的子类。

将以下代码添加到forestTrails.py模块中,在main()函数定义之前立即添加:

class TrackInfoDialog(QDialog):
    def __init__(self, parent=None):
        QDialog.__init__(self, parent)
        self.setWindowTitle("Track Info")

__init__()方法将设置对话框窗口的内容。到目前为止,我们已经初始化了对话框对象本身,并给窗口添加了一个标题。现在让我们定义一个用户可以选择的可用轨迹类型列表:

        self.trackTypes = ["Road",
                           "Walking Trail",
                           "Bike Trail",
                           "Horse Trail"]

同样,我们想要一个可用的方向选项列表:

        self.directions = ["Both",
                           "Forward",
                           "Backward"]

我们还想要一个可用的轨迹状态选项列表:

        self.statuses = ["Open",
                         "Closed"]

在定义了上述选项集之后,我们现在可以开始布置对话框窗口的内容。我们将从使用一个QFormLayout对象开始,它允许我们将表单标签和小部件并排排列:

        self.form = QFormLayout()

接下来,我们想要定义我们将用于显示和更改轨迹属性的各个输入小部件:

        self.trackType = QComboBox(self)
        self.trackType.addItems(self.trackTypes)

        self.trackName = QLineEdit(self)

        self.trackDirection = QComboBox(self)
        self.trackDirection.addItems(self.directions)

        self.trackStatus = QComboBox(self)
        self.trackStatus.addItems(self.statuses)

现在我们已经有了小部件本身,让我们将它们添加到表单中:

        self.form.addRow("Type",      self.trackType)
        self.form.addRow("Name",      self.trackName)
        self.form.addRow("Direction", self.trackDirection)
        self.form.addRow("Status",    self.trackStatus)

接下来,我们想要定义对话框窗口底部的按钮:

        self.buttons = QHBoxLayout()

        self.okButton = QPushButton("OK", self)
        self.connect(self.okButton, SIGNAL("clicked()"),
                     self.accept)

        self.cancelButton = QPushButton("Cancel", self)
        self.connect(self.cancelButton, SIGNAL("clicked()"),
                     self.reject)

        self.buttons.addStretch(1)
        self.buttons.addWidget(self.okButton)
        self.buttons.addWidget(self.cancelButton)

最后,我们可以在对话框中放置表单和我们的按钮,并安排好一切:

        self.layout = QVBoxLayout(self)
        self.layout.addLayout(self.form)
        self.layout.addSpacing(10)

        self.layout.addLayout(self.buttons)
        self.setLayout(self.layout)
        self.resize(self.sizeHint())

关于__init__()方法就到这里。设置好对话框后,我们接下来想要定义一个方法,用于在对话框窗口中复制特征的属性:

    def loadAttributes(self, feature):
        type_attr      = feature.attribute("type")
        name_attr      = feature.attribute("name")
        direction_attr = feature.attribute("direction")
        status_attr    = feature.attribute("status")

        if   type_attr == TRACK_TYPE_ROAD:    index = 0
        elif type_attr == TRACK_TYPE_WALKING: index = 1
        elif type_attr == TRACK_TYPE_BIKE:    index = 2
        elif type_attr == TRACK_TYPE_HORSE:   index = 3
        else:                                 index = 0
        self.trackType.setCurrentIndex(index)

        if name_attr != None:
            self.trackName.setText(name_attr)
        else:
            self.trackName.setText("")

        if   direction_attr == TRACK_DIRECTION_BOTH:     index = 0
        elif direction_attr == TRACK_DIRECTION_FORWARD:  index = 1
        elif direction_attr == TRACK_DIRECTION_BACKWARD: index = 2
        else:                                            index = 0
        self.trackDirection.setCurrentIndex(index)

        if   status_attr == TRACK_STATUS_OPEN:   index = 0
        elif status_attr == TRACK_STATUS_CLOSED: index = 1
        else:                                    index = 0
        self.trackStatus.setCurrentIndex(index)

我们在这里需要定义的最后一个方法是saveAttributes(),它将存储从对话框窗口中返回的特征属性中的更新值:

    def saveAttributes(self, feature):
        index = self.trackType.currentIndex()
        if   index == 0: type_attr = TRACK_TYPE_ROAD
        elif index == 1: type_attr = TRACK_TYPE_WALKING
        elif index == 2: type_attr = TRACK_TYPE_BIKE
        elif index == 3: type_attr = TRACK_TYPE_HORSE
        else:            type_attr = TRACK_TYPE_ROAD

        name_attr = self.trackName.text()

        index = self.trackDirection.currentIndex()
        if   index == 0: direction_attr = TRACK_DIRECTION_BOTH
        elif index == 1: direction_attr = TRACK_DIRECTION_FORWARD
        elif index == 2: direction_attr = TRACK_DIRECTION_BACKWARD
        else:            direction_attr = TRACK_DIRECTION_BOTH

        index = self.trackStatus.currentIndex()
        if   index == 0: status_attr = TRACK_STATUS_OPEN
        elif index == 1: status_attr = TRACK_STATUS_CLOSED
        else:            status_attr = TRACK_STATUS_OPEN

        feature.setAttribute("type",      type_attr)
        feature.setAttribute("name",      name_attr)
        feature.setAttribute("direction", direction_attr)
        feature.setAttribute("status",    status_attr)

在定义了TrackInfoDialog类之后,我们最终可以在ForestTrailsWindow类中实现onGetInfo()方法,用于在对话框中显示点击的轨迹的属性,并在用户点击确定按钮时保存更改:

    def onGetInfo(self, feature):
        dialog = TrackInfoDialog(self)
        dialog.loadAttributes(feature)
        if dialog.exec_():
            dialog.saveAttributes(feature)
            self.trackLayer.updateFeature(feature)
            self.modified = True
            self.mapCanvas.refresh()

现在您应该能够运行程序,切换到编辑模式,点击获取信息工具栏图标,然后点击一个特征以显示该特征的属性。生成的对话框窗口应该看起来像这样:

获取信息地图工具

您应该能够更改这些属性中的任何一个,然后点击确定按钮以保存更改。当您更改轨迹类型、状态和方向时,您应该看到更改反映在地图上轨迹的显示方式上。

设置起点和设置终点操作

设置起点设置终点工具栏操作允许用户设置起点和终点,以便计算这两个点之间的最短路径。为了实现这些操作,我们需要一个新的地图工具,允许用户点击轨道顶点来选择起始点或结束点。

注意

通过将起点和终点定位在顶点上,我们确保这些点位于轨道的 LineString 上。理论上我们可以更复杂一些,将起始点和结束点捕捉到轨道段上的任何位置,但这需要更多的工作,而我们正在尝试保持实现简单。

回到mapTools.py模块,并将以下类定义添加到该文件中:

class SelectVertexTool(QgsMapTool, MapToolMixin):
    def __init__(self, canvas, trackLayer, onVertexSelected):
        QgsMapTool.__init__(self, canvas)
        self.onVertexSelected = onVertexSelected
        self.setLayer(trackLayer)
        self.setCursor(Qt.CrossCursor)

    def canvasReleaseEvent(self, event):
        feature = self.findFeatureAt(event.pos())
        if feature != None:
            vertex = self.findVertexAt(feature, event.pos())
            if vertex != None:
                self.onVertexSelected(feature, vertex)

这个地图工具使用混入的方法来识别用户点击了哪个特征和顶点,然后调用onVertexSelected()回调,允许应用程序响应用户的选择。

让我们使用这个地图工具来实现设置起点设置终点操作。回到forestTrails.py模块,在setupMapTools()方法的末尾添加以下内容:

        self.selectStartPointTool = SelectVertexTool(
            self.mapCanvas, self.trackLayer,
            self.onStartPointSelected)

        self.selectEndPointTool = SelectVertexTool(
            self.mapCanvas, self.trackLayer,
            self.onEndPointSelected)

这两个SelectVertexTool实例使用不同的回调方法来响应用户点击轨道顶点。使用这些工具,我们现在可以实施setStartPoint()setEndPoint()方法,这些方法之前只是占位符:

    def setStartPoint(self):
        if self.actionSetStartPoint.isChecked():
            self.mapCanvas.setMapTool(self.selectStartPointTool)
        else:
            self.setPanMode()

    def setEndPoint(self):
        if self.actionSetEndPoint.isChecked():
            self.mapCanvas.setMapTool(self.selectEndPointTool)
        else:
            self.setPanMode()

如往常一样,当用户点击工具栏操作时,我们激活地图工具,如果用户再次点击操作,则切换回平移模式。

现在只剩下两个回调方法,onStartPointSelected()onEndPointSelected()。让我们从onStartPointSelected()的实现开始。这个方法将首先要求特征的几何形状返回被点击顶点的坐标,我们将这些坐标存储到self.curStartPt中:

    def onStartPointSelected(self, feature, vertex):
        self.curStartPt = feature.geometry().vertexAt(vertex)

现在我们知道了起点在哪里,我们想在地图上显示这个起点。如果你记得,我们之前创建了一个基于内存的地图层startPointLayer来显示这个点。我们需要首先清除这个内存层的内容,删除任何现有特征,然后在给定的坐标处创建一个新的特征:

        self.clearMemoryLayer(self.startPointLayer)

        feature = QgsFeature()
        feature.setGeometry(QgsGeometry.fromPoint(
                                            self.curStartPt))
        self.startPointLayer.dataProvider().addFeatures([feature])
        self.startPointLayer.updateExtents()

最后,我们将重新绘制地图画布以显示新添加的点,并切换回平移模式:

        self.mapCanvas.refresh()
        self.setPanMode()
        self.adjustActions()

我们需要实现clearMemoryLayer()方法,但在我们这样做之前,让我们也定义onEndPointSelected()回调方法,这样我们就可以在用户点击终点时做出响应。这段代码几乎与onStartPointSelected()的代码相同:

    def onEndPointSelected(self, feature, vertex):
        self.curEndPt = feature.geometry().vertexAt(vertex)

        self.clearMemoryLayer(self.endPointLayer)

        feature = QgsFeature()
        feature.setGeometry(QgsGeometry.fromPoint(self.curEndPt))
        self.endPointLayer.dataProvider().addFeatures([feature])
        self.endPointLayer.updateExtents()
        self.mapCanvas.refresh()
        self.setPanMode()
        self.adjustActions()

为了完成这两个操作,我们需要实现clearMemoryLayer()方法,并初始化curStartPtcurEndPt实例变量,以便程序知道何时首次设置这些变量。

这是clearMemoryLayer()方法的实现:

    def clearMemoryLayer(self, layer):
        featureIDs = []
        provider = layer.dataProvider()
        for feature in provider.getFeatures(QgsFeatureRequest()):
            featureIDs.append(feature.id())
        provider.deleteFeatures(featureIDs)

我们只需获取给定内存层中所有特征的列表,然后要求数据提供者删除它们。由于这些数据是瞬时的且存储在内存中,删除所有特征并不是什么大问题。

最后,让我们初始化这两个实例变量。将以下内容添加到 ForestTrailsWindow.__init__() 方法的末尾:

        self.curStartPt = None
        self.curEndPt   = None

实现了所有这些之后,用户现在可以点击一个顶点来设置起点或终点,如下面的截图所示:

设置起点和设置终点操作

查找最短路径操作

这是我们将要实现的 ForestTrails 的最后一个功能。当用户点击此工具栏图标时,我们希望计算给定起点和终点之间的最短可用路径。幸运的是,QGIS 网络分析库将为我们执行实际计算。我们只需要在轨迹层上运行最短路径计算,构建与该最短路径相对应的 LineString,并在基于内存的地图层中显示该 LineString 几何形状。

所有这些逻辑都将实现在 findShortestPath() 方法中。我们将从一些基本工作开始我们的实现:如果用户取消选中查找最短路径工具栏图标,我们将清除最短路径内存层,切换回平移模式,并重新绘制地图画布以显示没有之前路径的地图:

    def findShortestPath(self):
        if not self.actionFindShortestPath.isChecked():
            self.clearMemoryLayer(self.shortestPathLayer)
            self.setPanMode()
            self.mapCanvas.refresh()
            return

当用户点击查找最短路径工具栏操作并选中它时,方法的其他部分将执行。将以下代码添加到你的方法中:

        directionField = self.trackLayer.fieldNameIndex(
            "direction")
        director = QgsLineVectorLayerDirector(
                       self.trackLayer, directionField,
                       TRACK_DIRECTION_FORWARD,
                       TRACK_DIRECTION_BACKWARD,
                       TRACK_DIRECTION_BOTH, 3)

        properter = QgsDistanceArcProperter()
        director.addProperter(properter)

        crs = self.mapCanvas.mapRenderer().destinationCrs()
        builder = QgsGraphBuilder(crs)

        tiedPoints = director.makeGraph(builder, [self.curStartPt,
                                                  self.curEndPt])
        graph = builder.graph()

        startPt = tiedPoints[0]
        endPt   = tiedPoints[1]

        startVertex = graph.findVertex(startPt)
        tree = QgsGraphAnalyzer.shortestTree(graph,
                                             startVertex, 0)

        startVertex = tree.findVertex(startPt)
        endVertex   = tree.findVertex(endPt)

        if endVertex == -1:
            QMessageBox.information(self.window,
                                    "Not Found",
                                    "No path found.")
            return

        points = []
        while startVertex != endVertex:
            incomingEdges = tree.vertex(endVertex).inArc()
            if len(incomingEdges) == 0:
                break
            edge = tree.arc(incomingEdges[0])
            points.insert(0, tree.vertex(edge.inVertex()).point())
            endVertex = edge.outVertex()

        points.insert(0, startPt)

上述代码是从 PyQGIS 烹饪书复制的,并对变量名进行了一些更改以使意义更清晰。最后,points 将是一个包含 QgsPoint 对象的列表,这些对象定义了连接起点和终点的 LineString 几何形状。这种方法最有趣的部分如下:

director = QgsLineVectorLayerDirector(
                       self.trackLayer, directionField,
                       TRACK_DIRECTION_FORWARD,
                       TRACK_DIRECTION_BACKWARD,
                       TRACK_DIRECTION_BOTH, 3)

这段代码创建了一个对象,该对象将一组 LineString 特征转换为层特征的抽象。各种参数指定了哪些轨迹属性将被用来定义轨迹可以跟随的各种方向。双向轨迹可以双向跟随,而正向和反向方向的轨迹只能单向跟随。

注意

最后一个参数,值为 3,告诉导演将任何没有有效方向值的轨迹视为双向。

一旦我们有了定义最短路径的点集,很容易将这些点作为 LineString 显示在内存层中,并在地图上显示结果路径:

        self.clearMemoryLayer(self.shortestPathLayer)

        provider = self.shortestPathLayer.dataProvider()
        feature = QgsFeature()
        feature.setGeometry(QgsGeometry.fromPolyline(points))
        provider.addFeatures([feature])
        self.shortestPathLayer.updateExtents()
        self.mapCanvas.refresh()

如果你定义了起点和终点,然后点击查找最短路径工具栏操作,结果路径将在地图上以蓝色线条显示,如下面的截图所示:

查找最短路径操作

如果您仔细查看前面的截图,您会看到所走的路径并不是最短的;起点在底部,终点在单行自行车道的末端附近,因此最短可用路径涉及返回单行道的起点,然后跟随它到终点。这正是我们预期的行为,并且考虑到轨迹的单向性质,这是正确的。

调整工具栏操作

现在我们已经完成了所有必要的地图工具和实例变量的创建,我们最终可以实施adjustActions()方法的其余部分,以调整工具栏和菜单项以反映系统的当前状态。首先,我们希望更改本方法的最后一行,以便查找最短路径操作仅在起点和终点都已设置时启用:

self.actionFindShortestPath.setEnabled(
     self.curStartPt != None andself.curEndPt != None)

在本方法的最后部分,我们希望找到与当前地图工具关联的操作并检查该操作,同时取消选中所有其他操作。为此,请将以下代码添加到您的adjustActions()方法末尾:

        curTool = self.mapCanvas.mapTool()

        self.actionPan.setChecked(curTool == self.panTool)
        self.actionEdit.setChecked(self.editing)
        self.actionAddTrack.setChecked(
                        curTool == self.addTrackTool)
        self.actionEditTrack.setChecked(
                        curTool == self.editTrackTool)
        self.actionDeleteTrack.setChecked(
                        curTool == self.deleteTrackTool)
        self.actionGetInfo.setChecked(curTool == self.getInfoTool)
        self.actionSetStartPoint.setChecked(
                        curTool == self.selectStartPointTool)
        self.actionSetEndPoint.setChecked(
                        curTool == self.selectEndPointTool)
        self.actionFindShortestPath.setChecked(False)

小贴士

注意,此代码应放在您已在本方法中输入的if...else语句之外。

这完成了我们对adjustActions()方法的实现,实际上也完成了对整个 ForestTrails 系统的实现。恭喜!我们现在有一个完整的运行映射应用程序,所有功能都已实现并正常工作。

建议的改进

当然,没有任何应用程序是完全完成的,总有可以改进的地方。以下是一些您可以采取的改进 ForestTrails 应用程序的建议:

  • 在轨迹图层上添加标签,使用QgsPalLabeling引擎在地图足够放大以便读取名称时仅显示轨迹名称。

  • 根据轨迹类型更改用于轨迹的颜色。例如,您可能会用红色绘制所有自行车道,用绿色绘制所有步行道,用黄色绘制所有马道。

  • 添加一个视图菜单,用户可以选择要显示的轨迹类型。例如,用户可能选择隐藏所有马道,或者只显示步行道。

  • 扩展最短路径计算的逻辑,排除任何当前关闭的轨迹。

  • 添加另一个地图图层以在地图上显示各种障碍物。障碍物可能是阻挡轨迹的东西,可以用点几何表示。典型的障碍物可能包括倒下的树木、山体滑坡和正在进行的轨迹维护。根据障碍物,轨迹可能会关闭,直到障碍物被清除。

  • 使用打印作曲家生成地图的可打印版本。这可以用于根据当前森林小径的状态打印地图。

摘要

在本章中,我们完成了 ForestTrails 地图应用的开发。我们的应用现在允许用户添加、编辑和删除路径;查看和输入路径属性;设置起点和终点;并显示这两点之间的最短可用路径。在我们实现应用的过程中,我们发现路径无法连接的问题,并通过添加顶点吸附功能解决了这个问题。我们还学会了如何编写自定义的 QDialog 以供用户查看和编辑属性,以及如何使用 QGIS 网络分析库来计算两点之间的最短可用路径。

虽然 ForestTrails 应用只是一个专业地图应用的例子,但它提供了一个很好的示例,说明了如何使用 PyQGIS 实现独立的地图应用。你应该能够使用大部分代码来开发自己的地图应用,同时在你使用 Python 和 QGIS 编写自己的地图应用时,也可以在前面章节介绍的技术基础上进行扩展。

希望你们已经享受了这次旅程,并且学到了很多关于如何在 Python 程序中使用 QGIS 作为地图工具包的知识。继续前进吧!

posted @ 2025-10-24 09:52  绝不原创的飞龙  阅读(10)  评论(0)    收藏  举报