Python-地理空间开发精要-全-
Python 地理空间开发精要(全)
原文:
zh.annas-archive.org/md5/d1ae42ada99dbaf6b9dca5cf97abc0de译者:飞龙
前言
Python 已经成为地理空间行业许多人的首选语言。有些人使用 Python 来自动化他们的软件工作流程,例如 ArcGIS 或 QGIS。其他人则在 Python 的众多第三方开源地理空间工具包的细节上玩耍。
考虑到所有可用的编程工具和已经熟悉地理空间软件的人员,你没有理由必须选择其中之一。程序员现在可以从头开始开发自己的应用程序,以更好地满足他们的需求。毕竟,Python 被称为快速开发的语言。
通过开发自己的应用程序,你可以享受其中的乐趣,尝试新的视觉布局和创意设计,为专门的流程创建平台,并满足他人的需求。
这本书涵盖的内容
第一章, 准备构建自己的 GIS 应用程序,讨论了开发定制地理空间应用程序的好处,并描述了如何设置你的开发环境,以及创建你的应用程序文件夹结构。
第二章, 访问地理数据,实现了你的应用程序对矢量和栅格数据的至关重要的数据加载和保存功能。
第三章, 设计应用程序的视觉外观,创建并组装应用程序用户界面的基本构建块,让你首次看到你的应用程序将是什么样子。
第四章, 渲染我们的地理数据,增加了渲染功能,使用户可以在应用程序内交互式地查看、缩放和平移数据。
第五章, 管理和组织地理数据,为分割、合并和清理矢量和栅格数据创建基本功能。
第六章, 分析地理数据,为矢量和栅格数据开发基本分析功能,如叠加统计。
第七章, 打包和分发你的应用程序,通过向你展示如何共享和分发你的应用程序来总结一切,使其更容易供你或他人使用。
第八章, 展望未来,考虑了你可能希望如何进一步构建、定制和扩展你的基本应用程序,使其更加复杂或专业化,无论你想要哪种方式。
你需要这本书的内容
对于这本书,没有真正的要求。然而,为了使书籍简短精炼,说明假设您拥有 Windows 操作系统。如果您使用的是 Mac OS X 或 Linux,您仍然可以创建和运行应用程序,但您将不得不找出适用于您操作系统的等效安装说明。您可能被迫处理编译 C++代码,并面临意外错误的可能性。本书将涵盖所有其他安装,包括应使用哪个 Python 版本。
本书面向对象
这本书非常适合那些负责或希望制作可定制的专用 GIS 应用程序的 Python 程序员和软件开发人员,或者对使用空间数据进行清理、分析或地图可视化感兴趣的人。那些寻求一个创意平台来尝试前沿空间分析,但 Python 只是初学者的分析师、政治学家、地理学家和 GIS 专家,也会发现这本书很有益。熟悉 Python 中的 Tkinter 应用程序开发是首选,但不是必需的。
惯例
在本书中,您会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称的显示方式如下:“下载适合我们系统的 Shapely wheel 文件,看起来像Shapely‑1.5.7‑cp27‑none‑win32.whl。”
代码块按照以下方式设置:
class LayerGroup:
def __init__(self):
self.layers = list()
self.connected_maps = list()
def __iter__(self):
for layer in self.layers:
yield layer
def add_layer(self, layer):
self.layers.append(layer)
def move_layer(self, from_pos, to_pos):
layer = self.layers.pop(from_pos)
self.layers.insert(to_pos, layer)
def remove_layer(self, position):
self.layers.pop(position)
def get_position(self, layer):
return self.layers.index(layer)
任何命令行输入或输出都按照以下方式编写:
>>> import PIL, PIL.Image
>>> img = PIL.Image.open("your/path/to/icon.png")
>>> img.save("your/path/to/pythongis/app/icon.ico", sizes=[(255,255),(128,128),(64,64),(48,48),(32,32),(16,16),(8,8)])
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“点击左侧的Inno Setup链接。”
注意
警告或重要注意事项以如下框的形式出现。
小贴士
小贴士和技巧如下所示。
读者反馈
读者反馈始终受到欢迎。让我们知道您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。
要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及本书的标题。
如果你在某个领域有专业知识,并且对撰写或参与一本书感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您是 Packt 书籍的骄傲拥有者,我们有一些东西可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在 www.packtpub.com 购买的 Packt 出版的所有书籍的账户中下载示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
勘误
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表链接,并输入您的勘误详情来报告。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在勘误部分显示。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您在这本书的任何方面遇到问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。
第一章. 准备构建自己的 GIS 应用程序
你在这里是因为你喜欢 Python 编程,并且对制作自己的地理信息系统(GIS)应用程序感兴趣。你希望创建一个桌面应用程序,换句话说,一个用户界面,帮助你或其他人创建、处理、分析和可视化地理数据。这本书将是你实现这一目标的逐步指南。
我们假设你是一个喜欢编程和富有创造力的人,但并不一定是计算机科学专家、Python 专家或经验丰富的 GIS 分析师。为了成功地继续阅读这本书,建议你具备 Python 编程的基本入门知识,包括类、方法和Tkinter工具包,以及一些核心 GIS 概念。如果你是这些领域的初学者,我们仍会介绍一些基础知识,但你需要有兴趣和能力以较快的速度跟上。
在本章介绍中,你将涵盖以下内容:
-
了解从头创建 GIS 应用程序的一些好处
-
设置你的计算机,以便你可以遵循本书的说明。
-
熟悉创建我们应用程序的路线图。
为什么重新发明轮子?
为这本书做准备的第一步是说服自己为什么我们要制作自己的 GIS 应用程序,以及要清楚我们的动机。空间分析和 GIS 已经流行了几十年,市面上有大量的 GIS 软件,那么为什么还要费劲去重新发明轮子呢?首先,我们并不是真的在重新发明轮子,因为 Python 可以通过大量的第三方库来扩展,这些库可以满足我们大部分的地理空间需求(关于这一点稍后还会详细介绍)。
对于我来说,主要的动机源于这样一个问题:如今的大多数 GIS 应用程序都是针对那些对 GIS 或计算机科学非常精通、技术能力很强的用户,它们配备了令人眼花缭乱的按钮和选项,这可能会吓跑许多分析师。我们相信,尝试为初学者 GIS 用户或更广泛的公众创建一个更简单、更用户友好的软件是有价值的,而不必完全从头开始。这样,我们也为用户提供了更多的选择,作为补充当前由少数几个主要巨头(如 ArcGIS 和 QGIS)主导的 GIS 市场,以及其他如 GRASS、uDig、gvSIG 等。
从零开始创建自己的 GIS 的一个特别令人兴奋的原因是,您可以为自己想象中的任何任务创建专门的领域特定软件,无论是水流模型 GIS、生态迁徙 GIS,甚至是儿童 GIS。通常在普通 GIS 中需要许多繁琐步骤的这些专门任务,可以大大简化为一个按钮,并附带适当的功能、设计布局、图标和颜色。以下是一个例子,即亚利桑那州立大学 GeoDa 中心生产的时空犯罪分析软件(CAST),如图所示:

此外,通过从头开始创建自己的 GIS,您可以更好地控制应用程序的大小和便携性。这可以使您的应用程序更小巧——让应用程序具有更快的启动时间,并轻松在互联网或 USB 闪存驱动器上运行。尽管存储空间本身在今天并不是一个大问题,但从用户的角度来看,安装一个 200 MB 的应用程序仍然是一个更大的心理投资,其尝试意愿的代价比一个 30 MB 的应用程序(其他条件相同)要高得多。这在智能手机和平板电脑领域尤其如此,这是一个非常令人兴奋的专用地理空间应用程序市场。虽然本书中我们制作的特定应用程序无法在 iOS 或 Android 设备上运行,但它可以在基于 Windows 8 的混合平板电脑上运行,并且可以围绕不同的 GUI 工具包进行重建,以支持 iOS 或 Android(我们将在第八章展望未来中简要提及一些建议)。
最后,免费和开源软件的实用性和哲学可能是一些人的重要动机。今天,许多人是在完成大学教育或更换工作后失去了对基于订阅的应用程序(如 ArcGIS)的访问后,才开始欣赏开源 GIS 的。通过开发自己的开源 GIS 应用程序并与他人分享,您可以回馈社区,并成为曾经帮助过您的社区的一部分。
设置您的计算机
在本书中,我们遵循在 Windows 环境中开发应用程序的步骤。这并不意味着应用程序不能在 Mac OS X 或 Linux 上开发,但这些平台可能具有略微不同的安装说明,可能需要编译本书范围之外的二进制代码。因此,我们将选择权留给读者。在本书中,我们专注于 Windows,我们尽可能避免编译问题,使用预编译版本(关于这一点将在后面详细介绍)。
开发过程本身将使用 Python 2.7,特别是 32 位版本,尽管理论上也可以使用 64 位(注意,这是你的 Python 安装的位版本,与你的操作系统的位版本无关)。尽管存在许多更新的版本,但 2.7 版本在能够使用第三方包方面是最广泛支持的。据报道,版本 2.7 将继续到 2020 年积极开发和推广。即使在支持结束后,仍然可以使用。如果你还没有 2.7 版本,现在就按照以下步骤安装它:
-
前往Python 官网。
-
在下载下点击下载 Windows 的最新 32 位 Python 2.7 版本,在撰写本文时是 Python 2.7.9。
-
下载并运行安装程序。
对于实际的代码编写和编辑,我们将使用内置的Python 交互式开发环境(IDLE),但你当然可以使用任何你想要的代码编辑器。IDLE 让你可以编写可以保存到文件的长时间脚本,并提供一个交互式 shell 窗口来逐行执行。安装 Python 后,应该有一个指向 Python IDLE 的桌面或开始菜单链接。
安装第三方包
为了制作我们的应用程序,我们必须依赖现有的丰富多样的第三方 GIS 使用包生态系统。
注意
Python 包索引(PyPI)网站目前列出了超过 240 个标记为Topic :: Scientific/Engineering :: GIS的包。为了更轻松地了解更受欢迎的 GIS 相关 Python 库的概述,请查看作者创建的Python-GIS-Resources网站上的目录:
我们将不得不定义要使用和安装哪些包,这取决于我们正在制作的应用程序类型。在这本书中,我们想要制作的是一个轻量级、高度便携、可扩展和通用目的的 GIS 应用程序。出于这些原因,我们避免使用像 GDAL、NumPy、Matplotlib、SciPy 和 Mapnik(每个大约 30MB,如果我们将它们全部组合起来大约是 150-200MB)这样的沉重包。相反,我们专注于为每个特定功能专门设计的较轻的第三方包。
注意
放弃这些沉重的包是一个大胆的决定,因为它们包含了很多功能,并且是可靠、高效的,也是许多其他包的依赖。如果你决定想在大小不是问题的情况下使用它们,你可能现在就可以开始安装多功能的 NumPy 和可能还有 SciPy,它们都从它们的官方网站提供了易于使用的安装程序。其他沉重的包将在后面的章节中简要回顾。
每个包在其相关的章节中都有具体的安装说明(见下表以获取概述),这样如果您不想使用某些功能,可以忽略这些安装。由于我们专注于创建一个基本且轻量级的应用程序,我们将只安装少量包。然而,本书中我们将提供关于其他可能希望稍后添加的相关包的建议。
| 章节 | 安装 | 目的 |
|---|---|---|
| 1 | Python | |
| 1 | PIL | 栅格数据、管理和分析 |
| 1 | Shapely | 向量管理和分析 |
| 2 | PyShp | 数据 |
| 2 | PyGeoj | 数据 |
| 2 | Rtree | 向量数据加速 |
| 4 | PyAgg | 可视化 |
| 7 | Py2exe | 应用程序分发 |
注意
安装 Python 包的典型方法是使用pip(包含在 Python 2.7 中),它直接从 Python 包索引网站下载和安装包。Pip的使用方式如下:
-
第一步——打开您操作系统的命令行(不是 Python IDLE)。在 Windows 上,这可以通过在系统中搜索
cmd.exe并运行它来完成。 -
第二步——在弹出的黑色屏幕窗口中,只需输入
pip install packagename。如果pip在您的系统环境路径上,这将有效。如果不是这种情况,一个快速的解决办法是直接输入pip脚本的完整路径C:\Python27\Scripts\pip而不是仅仅输入pip。
对于基于 C 或 C++的包,将它们作为以.whl结尾的预编译wheel文件提供变得越来越流行,这导致了一些关于如何安装它们的混淆。幸运的是,我们可以使用pip来安装这些 wheel 文件,只需下载 wheel 并将其文件路径指向pip即可。
由于我们的某些依赖项具有多个用途,并不局限于某一章节,我们将现在安装这些依赖项。其中之一是Python 图像库(PIL),我们将用它来进行栅格数据模型和可视化。让我们现在为 Windows 安装 PIL:
-
点击我们 32 位 Python 2.7 环境的最新
.exe文件链接以下载 PIL 安装程序,当前为Pillow-2.6.1.win32-py2.7.exe。 -
运行安装文件。
-
打开 IDLE 交互式外壳,并输入
import PIL以确保它已正确安装。
我们还将使用另一个核心包 Shapely,用于位置测试和几何操作。要在 Windows 上安装它,请执行以下步骤:
-
下载适合我们系统的 Shapely wheel 文件,看起来像
Shapely-1.5.7-cp27-none-win32.whl。 -
如前所述,打开命令行窗口,输入
C:\Python27\Scripts\pip install path\to\Shapely‑1.5.7‑cp27‑none‑win32.whl以解压预编译的二进制文件。 -
为了确保安装正确,打开 IDLE 交互式外壳,并输入
import shapely。
展望未来的路线图
在我们开始开发应用程序之前,重要的是我们要构想出我们希望如何构建我们的应用程序。在 Python 术语中,我们将创建一个多层包,包含各种子包和子模块,以独立于任何用户界面来处理我们功能的不同部分。我们只在底层功能之上创建可视用户界面,作为访问和运行底层代码的方式。这样,我们构建了一个坚实的系统,并允许高级用户通过 Python 脚本访问所有相同的功能,以实现更大的自动化和效率,就像在 ArcGIS 和 QGIS 中一样。
要设置我们应用程序背后的主要 Python 包,在您的计算机上的任何位置创建一个名为 pythongis 的新文件夹。为了 Python 能够将文件夹 pythongis 解释为可导入的包,它需要在那个文件夹中找到一个名为 __init__.py 的文件。执行以下步骤:
-
从 Windows 开始菜单打开 Python IDLE。
-
首先弹出的窗口是交互式外壳。要打开脚本编辑窗口,请点击 文件 和 新建。
-
点击 文件 然后选择 另存为。
-
在弹出的对话框中,浏览到
pythongis文件夹,将文件名输入为__init__.py,然后点击 保存。
GIS 数据主要有两种类型:向量(基于坐标的几何形状,如点、线和多边形)和栅格(由规则间隔的数据点或单元格组成的网格,类似于图像及其像素)。
提示
对于向量与栅格数据之间差异的更详细介绍,以及其他基本 GIS 概念,我们建议读者阅读 Joel Lawhead 所著的《Learning Geospatial Analysis with Python》一书。您可以在以下位置找到这本书:
www.packtpub.com/application-development/learning-geospatial-analysis-python
由于向量和栅格数据在所有方面都存在根本性的不同,我们将我们的包分为两部分,一部分用于向量,另一部分用于栅格。使用之前的方法,我们在 pythongis 包内创建两个新的子包文件夹;一个名为 vector,另一个名为 raster(每个都包含前面提到的空 __init__.py 文件)。因此,我们的包结构将如下所示(注意 : package 不是文件夹名称的一部分):

为了使我们的新 vector 和 raster 子包可由顶级 pythongis 包导入,我们需要在 pythongis/__init__.py 中添加以下相对导入语句:
from . import vector
from . import raster
在本书的整个过程中,我们将在这两个数据类型的相应文件夹中构建它们的函数性,作为一组 Python 模块。最终,我们希望得到一个只包含最基本地理空间工具的 GIS 应用程序,这样我们就能加载、保存、管理、可视化和叠加数据,这些内容将在接下来的章节中介绍。
就我们的最终产品而言,因为我们注重清晰和简洁,所以我们并没有在让它快速或内存高效上投入太多精力。这源于程序员中经常重复的一句话,其中之一可以在《带有 goto 语句的结构化编程》,ACM,计算调查 6(4)中找到:
| 过早优化是万恶之源 | ||
|---|---|---|
| --唐纳德·E·克努特 |
这使得我们的软件最适合处理小文件,这在大多数情况下已经足够好。一旦你有一个工作中的应用程序,并且你觉得你需要支持更大的或更快的文件,那么是否投入额外的优化努力就取决于你了。
本书结尾时你得到的 GIS 应用程序简单但功能齐全,旨在作为一个你可以轻松构建的框架。为了给你一些想法,我们在书中放置了各种信息框,介绍了你可以优化或扩展应用程序的方法。对于我们在本书早期未能涵盖的核心主题和功能,我们在最后一章给出了缺失功能更广泛的讨论和未来建议。
摘要
在本章中,你学习了为什么想要使用 Python 创建一个 GIS 应用程序,设置了我们的编程环境,安装了一些常用的包,并创建了你的应用程序结构和框架。
在下一章中,你将迈出创建地理空间应用程序的第一步,通过从头开始创建一个简单但强大的模块来加载和保存一些常见的地理空间数据格式。
第二章:访问地理数据
所有 GIS 处理都必须从地理数据开始,因此我们开始构建与各种地理文件格式交互、加载和保存的能力。本章分为向量和平铺部分,在每一部分中,我们将涵盖以下内容:
-
首先,我们创建一个数据接口,这意味着理解数据结构以及如何与之交互。
-
其次和第三,任何特定格式的差异都外包给单独的加载器和保存器模块。
这在一个章节中包含了很多功能,但通过逐步工作,你将学会很多关于数据结构和文件格式的东西,并最终为你的应用程序打下坚实的基础。
方法
在我们努力构建本章中的数据访问时,我们关注简洁性、可理解性和轻量级库。我们为向量和平铺数据创建了标准化的数据接口,这样我们就可以在任何数据上使用相同的方法并期望得到相同的结果,而不用担心文件格式差异。它们并不一定针对速度或内存效率进行优化,因为它们会一次性将整个文件加载到内存中。
在我们选择用于加载和保存的第三方库时,我们专注于格式特定的库,这样我们就可以选择支持哪些格式,从而保持应用程序的轻量级。这需要更多的工作,但允许我们了解关于文件格式的复杂细节。
注意
如果在您的应用程序中大小不是问题,您可能希望改用更强大的GDAL库,该库可以单独加载和保存更广泛的向量和平铺格式。要使用 GDAL,我建议从www.lfd.uci.edu/~gohlke/pythonlibs/#gdal下载并安装预编译版本。在 GDAL 之上,Fiona(www.lfd.uci.edu/~gohlke/pythonlibs/#fiona)和Rasterio(www.lfd.uci.edu/~gohlke/pythonlibs/#rasterio)这两个包提供了更方便和 Pythonic 的接口,分别用于 GDAL 的向量和平铺数据功能。
向量数据
我们首先添加对向量数据的支持。我们将在vector包内部创建三个子模块:data、loader和saver。为了使它们可以从父向量包中访问,我们需要在vector/__init__.py中导入它,如下所示:
from . import data
from . import loader
from . import saver
向量数据的数据接口
我们首先想要的是一个可以方便交互的数据接口。这个数据接口将包含在其自己的模块中,所以现在创建这个模块并将其保存为vector/data.py。
我们首先进行一些基本的导入,包括 Shapely 的兼容性函数(我们在第一章中安装了它,准备构建您自己的 GIS 应用程序)和Rtree包的空间索引能力,这是一个我们稍后将要安装的包。请注意,矢量数据的加载和保存由我们尚未创建的单独模块处理,但由于它们通过我们的数据接口访问,因此我们需要在这里导入它们:
# import builtins
import sys, os, itertools, operator
from collections import OrderedDict
import datetime
# import shapely geometry compatibility functions
# ...and rename them for clarity
import shapely
from shapely.geometry import asShape as geojson2shapely
# import rtree for spatial indexing
import rtree
# import internal modules
from . import loader
from . import saver
小贴士
下载示例代码
您可以从www.packtpub.com下载您购买的所有 Packt Publishing 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
矢量数据结构
地理矢量数据可以被视为一个数据表。表中的每一行是一个观测值(例如,一个国家),并包含一个或多个属性,或关于该观测值的信息(例如,人口)。在矢量数据结构中,行被称为要素,并具有额外的几何定义(定义国家形状和位置的坐标)。因此,结构的概述可能看起来像这样:

因此,在我们的矢量数据结构实现中,我们创建了一个名为VectorData的接口。为了创建并填充一个VectorData实例,我们可以提供一个filepath参数,它将通过我们稍后创建的加载模块来加载。我们还允许传递可选的关键字参数给加载器,这将包括指定文本编码的能力。或者,可以通过不传递任何参数来创建一个空的VectorData实例。在创建空实例时,可以指定整个数据实例的几何类型(这意味着它只能包含多边形、线或点几何),否则它将根据添加的第一个要素的几何类型设置数据类型。
除了存储字段名和从行和几何形状创建要素外,VectorData实例还记住加载数据的filepath源(如果适用),以及默认为未投影 WGS84 的坐标参考系统(CRS),如果没有指定。
为了存储要素,而不是使用列表或字典,我们使用一个有序字典,它允许我们使用唯一的 ID 来识别每个要素,对要素进行排序,并执行快速频繁的要素查找。为了确保VectorData中的每个要素都有一个唯一的 ID,我们定义了一个唯一的 ID 生成器,并将独立的 ID 生成器实例附加到每个VectorData实例上。
为了让我们能够与VectorData实例进行交互,我们添加了各种魔法方法来启用标准的 Python 操作,例如获取数据中的特征数量、遍历它们以及通过它们的 ID 进行索引来获取和设置它们。最后,我们还包括了一个方便的add_feature和copy方法。请看以下代码:
def ID_generator():
i = 0
while True:
yield i
i += 1
class VectorData:
def __init__(self, filepath=None, type=None, **kwargs):
self.filepath = filepath
# type is optional and will make the features ensure that all geometries are of that type
# if None, type enforcement will be based on first geometry found
self.type = type
if filepath:
fields,rows,geometries,crs = loader.from_file(filepath, **kwargs)
else:
fields,rows,geometries,crs = [],[],[],"+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs"
self.fields = fields
self._id_generator = ID_generator()
ids_rows_geoms = itertools.izip(self._id_generator,rows,geometries)
featureobjs = (Feature(self,row,geom,id=id) for id,row,geom in ids_rows_geoms )
self.features = OrderedDict([ (feat.id,feat) for feat in featureobjs ])
self.crs = crs
def __len__(self):
"""
How many features in data.
"""
return len(self.features)
def __iter__(self):
"""
Loop through features in order.
"""
for feat in self.features.itervalues():
yield feat
def __getitem__(self, i):
"""
Get a Feature based on its feature id.
"""
if isinstance(i, slice):
raise Exception("Can only get one feature at a time")
else:
return self.features[i]
def __setitem__(self, i, feature):
"""
Set a Feature based on its feature id.
"""
if isinstance(i, slice):
raise Exception("Can only set one feature at a time")
else:
self.features[i] = feature
### DATA ###
def add_feature(self, row, geometry):
feature = Feature(self, row, geometry)
self[feature.id] = feature
def copy(self):
new = VectorData()
new.fields = [field for field in self.fields]
featureobjs = (Feature(new, feat.row, feat.geometry) for feat in self )
new.features = OrderedDict([ (feat.id,feat) for feat in featureobjs ])
if hasattr(self, "spindex"): new.spindex = self.spindex.copy()
return new
当我们加载或添加特征时,它们被存储在一个带有其父VectorData类链接的Feature类中。为了简化、最大程度地提高互操作性和内存效率,我们选择以流行的和广泛支持的GeoJSON格式存储特征几何形状,它只是一个根据某些规则格式化的 Python 字典结构。
注意
GeoJSON 是一种人类可读的文本表示形式,用于描述各种矢量几何形状,如点、线和多边形。有关完整规范,请访问geojson.org/geojson-spec.html。
我们确保给Feature类一些魔法方法来支持标准的 Python 操作,例如通过使用特征父字段列表中所需字段的位置通过字段名索引轻松获取和设置属性。一个返回 Shapely 几何表示的get_shapely方法和一个copy方法也将对以后很有用。以下代码解释了Feature类:
class Feature:
def __init__(self, data, row, geometry, id=None):
"geometry must be a geojson dictionary"
self._data = data
self.row = list(row)
self.geometry = geometry.copy()
# ensure it is same geometry type as parent
geotype = self.geometry["type"]
if self._data.type:
if "Point" in geotype and self._data.type == "Point": pass
elif "LineString" in geotype and self._data.type == "LineString": pass
elif "Polygon" in geotype and self._data.type == "Polygon": pass
else: raise TypeError("Each feature geometry must be of the same type as the file it is attached to")
else: self._data.type = self.geometry["type"].replace("Multi", "")
if id == None: id = next(self._data._id_generator)
self.id = id
def __getitem__(self, i):
if isinstance(i, (str,unicode)):
i = self._data.fields.index(i)
return self.row[i]
def __setitem__(self, i, setvalue):
if isinstance(i, (str,unicode)):
i = self._data.fields.index(i)
self.row[i] = setvalue
def get_shapely(self):
return geojson2shapely(self.geometry)
def copy(self):
geoj = self.geometry
if self._cached_bbox: geoj["bbox"] = self._cached_bbox
return Feature(self._data, self.row, geoj)
计算边界框
尽管我们现在已经有了矢量数据的基本结构,但我们还想添加一些额外的便利方法。对于矢量数据,知道每个特征的边界框通常非常有用,它是一个以四个坐标的序列[xmin, ymin, xmax, ymax]表示的特征的聚合地理描述。计算边界框可能计算成本较高,因此我们允许Feature实例在实例化时接收一个预计算的边界框(如果可用)。因此,在Feature的__init__方法中,我们添加了以下内容:
bbox = geometry.get("bbox")
self._cached_bbox = bbox
此边界框也可以缓存或存储,以供以后使用,这样我们就可以在计算后只需引用该值。使用@property描述符,在我们定义Feature类的bbox方法之前,允许我们将边界框作为简单值或属性访问,尽管它是在方法中的几个步骤中计算的:
@property
def bbox(self):
if not self._cached_bbox:
geotype = self.geometry["type"]
coords = self.geometry["coordinates"]
if geotype == "Point":
x,y = coords
bbox = [x,y,x,y]
elif geotype in ("MultiPoint","LineString"):
xs, ys = itertools.izip(*coords)
bbox = [min(xs),min(ys),max(xs),max(ys)]
elif geotype == "MultiLineString":
xs = [x for line in coords for x,y in line]
ys = [y for line in coords for x,y in line]
bbox = [min(xs),min(ys),max(xs),max(ys)]
elif geotype == "Polygon":
exterior = coords[0]
xs, ys = itertools.izip(*exterior)
bbox = [min(xs),min(ys),max(xs),max(ys)]
elif geotype == "MultiPolygon":
xs = [x for poly in coords for x,y in poly[0]]
ys = [y for poly in coords for x,y in poly[0]]
bbox = [min(xs),min(ys),max(xs),max(ys)]
self._cached_bbox = bbox
return self._cached_bbox
最后,VectorData类中整个特征集合的边界框也非常有用,因此我们在VectorData级别创建了一个类似的例程,但我们不关心缓存,因为VectorData类会频繁地丢失或获得新的特征。我们希望边界框始终保持最新。向VectorData类添加以下动态属性:
@property
def bbox(self):
xmins, ymins, xmaxs, ymaxs = itertools.izip(*(feat.bbox for feat in self))
xmin, xmax = min(xmins), max(xmaxs)
ymin, ymax = min(ymins), max(ymaxs)
bbox = (xmin, ymin, xmax, ymax)
return bbox
空间索引
最后,我们添加一个空间索引结构,将重叠特征的边界框嵌套在一起,以便可以更快地测试和检索特征位置。为此,我们将使用 Rtree 库。执行以下步骤:
-
下载适合我们系统的 wheel 文件,目前是
Rtree-0.8.2.-cp27-none-win32.whl。 -
在 Windows 上安装该软件包,打开您的命令行并输入
C:/Python27/Scripts/pip install path/to/Rtree-0.8.2.-cp27-none-win32.whl。 -
要验证安装是否成功,请打开一个交互式 Python shell 窗口并输入
import rtree。注意
Rtree 只是空间索引的一种类型。另一种常见的是四叉树索引,其主要优势是在需要经常更改索引时,更新索引的速度更快。
PyQuadTree是由作者创建的一个纯 Python 实现,您可以在命令行中通过以下方式安装:C:/Python27/Scripts/pip install pyquadtree。
由于空间索引依赖于边界框,正如我们之前所说的,这可能会带来计算成本,因此我们仅在用户明确请求时创建空间索引。因此,让我们创建一个VectorData类方法,该方法将从 Rtree 库创建空间索引,通过插入每个特征的边界框及其 ID 来填充它,并将其存储为属性。以下代码片段展示了这一过程:
def create_spatial_index(self):
"""Allows quick overlap search methods"""
self.spindex = rtree.index.Index()
for feat in self:
self.spindex.insert(feat.id, feat.bbox)
一旦创建,Rtree 的空间索引有两个主要方法可用于快速空间查找。空间查找仅返回匹配项的 ID,因此我们使用这些 ID 从匹配的 ID 中获取实际的特征实例。给定一个目标边界框,第一个方法找到与之重叠的特征,而另一个方法则按从最近到最远的顺序遍历最近的n个特征。如果目标边界框不是所需的[xmin, ymin,xmax,ymax]格式,我们将强制将其转换为该格式:
def quick_overlap(self, bbox):
"""
Quickly get features whose bbox overlap the specified bbox via the spatial index.
"""
if not hasattr(self, "spindex"):
raise Exception("You need to create the spatial index before you can use this method")
# ensure min,min,max,max pattern
xs = bbox[0],bbox[2]
ys = bbox[1],bbox[3]
bbox = [min(xs),min(ys),max(xs),max(ys)]
# return generator over results
results = self.spindex.intersection(bbox)
return (self[id] for id in results)
def quick_nearest(self, bbox, n=1):
"""
Quickly get n features whose bbox are nearest the specified bbox via the spatial index.
"""
if not hasattr(self, "spindex"):
raise Exception("You need to create the spatial index before you can use this method")
# ensure min,min,max,max pattern
xs = bbox[0],bbox[2]
ys = bbox[1],bbox[3]
bbox = [min(xs),min(ys),max(xs),max(ys)]
# return generator over results
results = self.spindex.nearest(bbox, num_results=n)
return (self[id] for id in results)
加载矢量文件
到目前为止,我们还没有定义从文件加载数据到我们的VectorData接口的例程。这包含在一个单独的模块中,作为vector/loader.py。首先导入必要的模块(如果你之前从未听说过它们,不要担心,我们很快就会安装它们):
# import builtins
import os
# import fileformat modules
import shapefile as pyshp
import pygeoj
加载模块的主要目的是使用一个函数,我们称之为from_file(),它接受一个文件路径并自动检测其文件类型。然后,它使用适当的例程加载该文件。一旦加载,它就返回我们的VectorData类期望的信息:字段名、行列表、几何形状的 GeoJSON 字典列表以及 CRS 信息。一个可选的编码参数确定文件的文本编码(用户将必须事先知道或猜测),但关于这一点我们稍后再说。现在就动手做吧:
def from_file(filepath, encoding="utf8"):
def decode(value):
if isinstance(value, str):
return value.decode(encoding)
else: return value
Shapefile
为了处理老式但非常常用的矢量文件格式——shapefile 格式,我们使用了流行的轻量级PyShp库。在命令行中安装它,只需输入C:/Python27/Scripts/pip install pyshp。
在from_file函数内部,我们首先检测文件是否为 shapefile 格式,然后运行我们的加载程序。该程序开始使用 PyShp 模块通过shapereader对象获取文件内容。使用shapereader对象,我们从每个字段信息元组中提取名称(第一个项目),并排除第一个字段,该字段始终是删除标志字段。通过循环shapereader对象的iterRecords方法来加载行。
加载几何形状稍微复杂一些,因为我们想执行一些额外的步骤。PyShp,像大多数包一样,可以通过其 shape 对象的__geo_interface__属性将几何形状格式化为 GeoJSON 字典。现在,记得从之前的空间索引部分,计算每个单独特征的边界框可能很昂贵。shapefile 格式的优点之一是每个形状的边界框都作为 shapefile 格式的一部分存储。因此,我们利用它们已经为我们计算并存储为 GeoJSON 字典的一部分这一事实,该字典是我们发送以启动我们的VectorData类。我们创建一个getgeoj函数,如果可用(例如,点形状没有bbox属性),则将边界框信息添加到 GeoJSON 字典中,并使用它来处理我们从shapereader对象的iterShapes方法获取的每个形状。
接下来,shapefile 格式有一个可选的.prj文件,包含投影信息,因此如果存在,我们也尝试读取此信息,如果不存在,则默认为未投影的 WGS84。最后,我们让函数返回加载的字段、行、几何形状和投影,以便我们的数据模块可以使用它们来构建一个VectorData实例。
下面是最终的代码:
# shapefile
if filepath.endswith(".shp"):
shapereader = pyshp.Reader(filepath)
# load fields, rows, and geometries
fields = [decode(fieldinfo[0]) for fieldinfo in shapereader.fields[1:]]
rows = [ [decode(value) for value in record] for record in shapereader.iterRecords()]
def getgeoj(obj):
geoj = obj.__geo_interface__
if hasattr(obj, "bbox"): geoj["bbox"] = obj.bbox
return geoj
geometries = [getgeoj(shape) for shape in shapereader.iterShapes()]
# load projection string from .prj file if exists
if os.path.lexists(filepath[:-4] + ".prj"):
crs = open(filepath[:-4] + ".prj", "r").read()
else: crs = "+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs"
return fields, rows, geometries, crs
GeoJSON
GeoJSON 格式比 shapefile 格式更新,由于其简单性,它被广泛使用,尤其是在网络应用程序中。我们将使用的库来读取它们是作者创建的PyGeoj。要安装它,在命令行中,输入C:/Python27/Scripts/pip install pygeoj。
要检测 GeoJSON 文件,没有关于它们的文件名扩展名的规则,但它们通常为.geojson或仅仅是.json。然后我们将 GeoJSON 文件加载到一个 PyGeoj 对象中。GeoJSON 特征不需要具有所有相同的字段,所以我们使用一个方便的方法,只获取所有特征共有的字段名。
通过循环特征并访问properties属性来加载行。这个 PyGeoj 对象的几何形状纯粹由 GeoJSON 字典组成,与我们的数据结构相同,所以我们只需按原样加载几何形状。最后,我们返回所有加载的信息。请参考以下代码:
# geojson file
elif filepath.endswith((".geojson",".json")):
geojfile = pygeoj.load(filepath)
# load fields, rows, and geometries
fields = [decode(field) for field in geojfile.common_attributes]
rows = [[decode(feat.properties[field]) for field in fields] for feat in geojfile]
geometries = [feat.geometry.__geo_interface__ for feat in geojfile]
# load projection
crs = geojfile.crs
return fields, rows, geometries, crs
不支持的文件格式
由于我们目前不打算支持任何额外的文件格式,如果文件路径与之前任何格式都不匹配,我们添加一个else子句,返回一个不支持的文件格式异常:
else:
raise Exception("Could not create vector data from the given filepath: the filetype extension is either missing or not supported")
保存矢量数据
为了将我们的矢量数据保存回文件,创建一个名为vector/saver.py的模块。在脚本顶部,我们导入必要的模块:
# import builtins
import itertools
# import fileformats
import shapefile as pyshp
import pygeoj
保存模块的主要目的是一个简单的to_file函数,它将为我们执行保存操作。我们不允许 CRS 投影参数,因为这将需要一个根据不同标准格式化投影的方法,据我所知,目前只能使用 GDAL 来完成,而我们选择不使用它。
现在,保存包含文本的文件时遇到的一个常见困难是,你必须记得将你的Unicode类型文本(带有花哨的非英语字符的文本)编码回机器可读的字节字符串,或者如果它们是 Python 对象,如日期,我们希望得到它们的字节字符串表示。因此,我们首先创建一个快速函数来完成这项工作,使用to_file函数的文本编码参数。到目前为止,我们的代码如下所示:
def to_file(fields, rows, geometries, filepath, encoding="utf8"):
def encode(value):
if isinstance(value, (float,int)):
# nrs are kept as nrs
return value
elif isinstance(value, unicode):
# unicode is custom encoded into bytestring
return value.encode(encoding)
else:
# brute force anything else to string representation
return bytes(value)
形状文件
为了将矢量数据保存到形状文件格式,一旦我们创建了shapewriter对象,我们首先想要检测并设置所有字段以正确的值类型。为了避免处理潜在的类型不匹配,我们只需检查每个字段中所有有效值是否为数值型,如果不是,则强制转换为文本类型。最后,我们将一个清理并编码的字段名(形状文件不允许名称超过 10 个字符或包含空格)的字段元组、值类型(其中C代表文本字符,N代表数字)、最大文本长度以及数字的十进制精度分配给每个字段。
完成此操作后,我们可以开始编写我们的文件。不幸的是,PyShp 目前没有直接从 GeoJSON 字典保存几何形状的现成方法,所以我们首先创建一个执行此转换的函数。这样做需要创建一个空的 PyShp 形状实例并设置正确的shapeType属性。points属性是所有坐标点的连续列表,对于多几何形状,它在parts属性中指示的索引位置处分开。
然后,我们可以遍历所有我们的要素,使用我们的函数将 GeoJSON 转换为 PyShp 形状实例,将它们追加到编写者的_shapes列表中,使用record方法编码并添加要素的行,最后保存。整个代码如下所示:
# shapefile
if filepath.endswith(".shp"):
shapewriter = pyshp.Writer()
# set fields with correct fieldtype
for fieldindex,fieldname in enumerate(fields):
for row in rows:
value = row[fieldindex]
if value != "":
try:
# make nr fieldtype if content can be made into nr
float(value)
fieldtype = "N"
fieldlen = 16
decimals = 8
except:
# but turn to text if any of the cells cannot be made to float bc they are txt
fieldtype = "C"
fieldlen = 250
decimals = 0
break
else:
# empty value, so just keep assuming nr type
fieldtype = "N"
fieldlen = 16
decimals = 8
# clean fieldname
fieldname = fieldname.replace(" ","_")[:10]
# write field
shapewriter.field(fieldname.encode(encoding), fieldtype, fieldlen, decimals)
# convert geojson to shape
def geoj2shape(geoj):
# create empty pyshp shape
shape = pyshp._Shape()
# set shapetype
geojtype = geoj["type"]
if geojtype == "Null":
pyshptype = pyshp.NULL
elif geojtype == "Point":
pyshptype = pyshp.POINT
elif geojtype == "LineString":
pyshptype = pyshp.POLYLINE
elif geojtype == "Polygon":
pyshptype = pyshp.POLYGON
elif geojtype == "MultiPoint":
pyshptype = pyshp.MULTIPOINT
elif geojtype == "MultiLineString":
pyshptype = pyshp.POLYLINE
elif geojtype == "MultiPolygon":
pyshptype = pyshp.POLYGON
shape.shapeType = pyshptype
# set points and parts
if geojtype == "Point":
shape.points = [ geoj["coordinates"] ]
shape.parts = [0]
elif geojtype in ("MultiPoint","LineString"):
shape.points = geoj["coordinates"]
shape.parts = [0]
elif geojtype in ("Polygon"):
points = []
parts = []
index = 0
for ext_or_hole in geoj["coordinates"]:
points.extend(ext_or_hole)
parts.append(index)
index += len(ext_or_hole)
shape.points = points
shape.parts = parts
elif geojtype in ("MultiLineString"):
points = []
parts = []
index = 0
for linestring in geoj["coordinates"]:
points.extend(linestring)
parts.append(index)
index += len(linestring)
shape.points = points
shape.parts = parts
elif geojtype in ("MultiPolygon"):
points = []
parts = []
index = 0
for polygon in geoj["coordinates"]:
for ext_or_hole in polygon:
points.extend(ext_or_hole)
parts.append(index)
index += len(ext_or_hole)
shape.points = points
shape.parts = parts
return shape
# iterate through original shapes
for row,geom in itertools.izip(rows, geometries):
shape = geoj2shape(geom)
shapewriter._shapes.append(shape)
shapewriter.record(*[encode(value) for value in row])
# save
shapewriter.save(filepath)
GeoJSON
使用 PyGeoj 包保存 GeoJSON 稍微简单一些。我们首先创建一个新的geojwriter对象,然后遍历所有我们的要素,将 Unicode 文本编码为字节字符串,将它们添加到geojwriter实例中,完成后保存:
# GeoJSON file
elif filepath.endswith((".geojson",".json")):
geojwriter = pygeoj.new()
for row,geom in itertools.izip(rows,geometries):
# encode row values
row = (encode(value) for value in row)
rowdict = dict(zip(fields, row))
# add feature
geojwriter.add_feature(properties=rowdict,
geometry=geom)
# save
geojwriter.save(filepath)
不支持的文件格式
最后,我们添加一个 else 子句来提供一个消息,说明用户尝试保存到尚未支持保存的文件格式:
else:
raise Exception("Could not save the vector data to the given filepath: the filetype extension is either missing or not supported")
栅格数据
现在我们已经实现了加载和保存矢量数据的数据结构,我们可以继续为栅格数据做同样的事情。如前所述,我们将在 raster 包内创建三个子模块:data、loader 和 saver。为了使它们可以从其父 raster 包中访问,我们需要在 raster/__init__.py 中导入它,如下所示:
from . import data
from . import loader
from . import saver
栅格数据的数据接口
栅格数据具有非常不同的结构,我们必须适应,我们首先从其数据接口开始。此接口的代码将包含在栅格文件夹内的一个单独的模块中。要创建此模块,现在将其保存为 raster/data.py。从几个基本的导入开始,包括我们尚未创建的加载器和保存器模块,以及我们在第一章(第一章. 准备构建您自己的 GIS 应用程序)中安装的 PIL,准备构建您自己的 GIS 应用程序:
# import builtins
import sys, os, itertools, operator
# import internals
from . import loader
from . import saver
# import PIL as the data container
import PIL.Image, PIL.ImageMath
栅格数据结构
栅格由一个或多个称为 波段 的数据网格组成。这些网格以及它们每个 单元格 中的值表示信息如何在照片中类似像素的空间中流动:

由于栅格数据与影像数据的相似性,我们利用之前导入的现有 PIL 影像库,并将其用于我们应用程序的栅格数据结构。其基于 C 的代码使其运行速度快且内存效率高,并且它已经包含了我们最终想要实现的大多数基于像素的栅格功能。
在顶层,我们的 RasterData 类包含一些栅格元数据和一个或多个 Band 层,这些层只是像素图像数据容器的包装器。当创建一个新的 RasterData 类时,我们通常从文件路径加载。实际的加载工作将外包给稍后创建的加载模块,该模块返回有关栅格的各种元数据字典(info)、一个包含一个或多个波段(bands)的列表以及其坐标参考系统(crs)的定义。我们还可以根据表示网格或普通图像文件的列表列表从非空间数据创建一个新的 RasterData 类,在这种情况下,定义其坐标参考系统(crs)和地理空间元数据(我们稍后回到这一点)取决于我们。
Band 类是实际值存储的地方。我们保留对 PIL 图像(img)的一个引用,以便我们可以使用其各种影像处理方法,并保留对图像像素访问对象(cells)的一个引用,这样我们就可以直接与单个像素进行交互。每个像素都作为 Cell 类的一个实例来访问,它提供了一个方便的方法来获取其行/列位置。
看看以下代码:
class Cell:
def __init__(self, band, col, row):
self.band = band
self.col, self.row = col, row
def __repr__(self):
return "Cell(col=%s, row=%s, value=%s)" %(self.col, self.row, self.value)
@property
def value(self):
return self.band.cells[self.col, self.row]
class Band:
def __init__(self, img, cells):
self.img = img
self.cells = cells
def __iter__(self):
width,height = self.img.size
for row in range(height):
for col in range(width):
yield Cell(self, col, row)
def get(self, col, row):
return Cell(self, col, row)
def set(self, col, row, value):
self.cells[col,row] = value
def copy(self):
img = self.img.copy()
cells = img.load()
return Band(img, cells)
class RasterData:
def __init__(self, filepath=None, data=None, image=None, **kwargs):
self.filepath = filepath
if filepath:
info, bands, crs = loader.from_file(filepath)
elif data:
info, bands, crs = loader.from_lists(data, **kwargs)
elif image:
info, bands, crs = loader.from_image(image, **kwargs)
else:
info, bands, crs = loader.new(**kwargs)
self.bands = [Band(img,cells) for img,cells in bands]
self.info = info
self.crs = crs
self.update_geotransform()
def __iter__(self):
for band in self.bands:
yield band
@property
def width(self):
return self.bands[0].img.size[0]
@property
def height(self):
return self.bands[0].img.size[1]
def copy(self):
new = RasterData(width=self.width, height=self.height, **self.info)
new.bands = [band.copy() for band in self.bands]
new._cached_mask = self.mask
return new
在坐标空间中定位栅格
我们还没有完成。虽然我们的栅格波段的结构网格给我们一个关于每个值在网格中相对位置的感觉,但它并没有说明它们在现实世界中的地理位置,就像Feature几何坐标那样。这就是为什么我们需要关于栅格的额外地理空间元数据。为了在地理空间中定位我们的值,我们可以在info元数据字典中指定两种方式:
-
我们需要将其中一个单元格(
xy_cell)转换为地理或投影坐标(xy_geo),并指定其单元格的坐标宽度和高度(cellwidth和cellheight),以便我们可以移动和调整栅格的大小。这些实际上是接下来描述的变换系数的组成部分。 -
在某些情况下,例如沿任意方向拍摄的一些航空影像,仅仅移动和调整栅格可能不够。我们可能还需要旋转和可能倾斜栅格。为此,我们需要一组仿射变换系数(
transform_ceoffs),这样我们就可以重新计算每个单元格的位置,最终得到扭曲的图像。这也被称为地理变换。注意
还有一种第三种可能性,即使用地理编码点的样本,然后可以使用非线性变换来近似,但我们不涉及这种方法。有关在坐标空间中定位栅格的更多信息,请参阅:
www.remotesensing.org/geotiff/spec/geotiff2.6.html。
以下图表说明了如何通过一组地理变换系数来偏移、缩放和旋转栅格数据集,以便在坐标空间中定位它,这通常需要翻转y轴:

最后,我们还应考虑栅格数据的一个方面,即每个单元格中坐标偏移的锚定位置;要么在单元格中心,要么在其四个角中的任何一个。我们将此信息存储为xy_anchor。然而,由于差异非常小,我们选择不对我们的简单应用进行任何操作。
根据提供的地理空间元数据,我们首先必须计算正则和逆变换系数(update_geotransform()),以便我们可以将单元格位置和空间坐标(cell_to_geo()和geo_to_cell())相互映射。有了这个基础,我们可以获取有关栅格边界框(bbox)的更多信息。最重要的是,我们允许重新定位/扭曲栅格(以及我们稍后创建的 nodata 掩码)到一个新的栅格,该栅格反映了其在指定边界框内以及指定宽度和高度分辨率下的实际位置。这种重新定位可以通过使用 PIL 的 quad 变换从旧坐标边界框转换到新坐标边界框来实现。让我们将这些功能添加到RasterData结构中:
def cell_to_geo(self, column, row):
[xscale, xskew, xoffset, yskew, yscale, yoffset] = self.transform_coeffs
x, y = column, row
x_coord = x*xscale + y*xskew + xoffset
y_coord = x*yskew + y*yscale + yoffset
return x_coord, y_coord
def geo_to_cell(self, x, y, fraction=False):
[xscale, xskew, xoffset, yskew, yscale, yoffset] = self.inv_transform_coeffs
column = x*xscale + y*xskew + xoffset
row = x*yskew + y*yscale + yoffset
if not fraction:
# round to nearest cell
column,row = int(round(column)), int(round(row))
return column,row
@property
def bbox(self):
# get corner coordinates of raster
xleft_coord,ytop_coord = self.cell_to_geo(0,0)
xright_coord,ybottom_coord = self.cell_to_geo(self.width, self.height)
return [xleft_coord,ytop_coord,xright_coord,ybottom_coord]
def update_geotransform(self):
info = self.info
# get coefficients needed to convert from raster to geographic space
if info.get("transform_coeffs"):
[xscale, xskew, xoffset,
yskew, yscale, yoffset] = info["transform_coeffs"]
else:
xcell,ycell = info["xy_cell"]
xgeo,ygeo = info["xy_geo"]
xoffset,yoffset = xgeo - xcell, ygeo - ycell
xscale,yscale = info["cellwidth"], info["cellheight"]
xskew,yskew = 0,0
self.transform_coeffs = [xscale, xskew, xoffset, yskew, yscale, yoffset]
# and the inverse coefficients to go from geographic space to raster
# taken from Sean Gillies' "affine.py"
a,b,c,d,e,f = self.transform_coeffs
det = a*e - b*d
if det != 0:
idet = 1 / float(det)
ra = e * idet
rb = -b * idet
rd = -d * idet
re = a * idet
a,b,c,d,e,f = (ra, rb, -c*ra - f*rb,
rd, re, -c*rd - f*re)
self.inv_transform_coeffs = a,b,c,d,e,f
else:
raise Exception("Error with the transform matrix, \
a raster should not collapse upon itself")
def positioned(self, width, height, coordspace_bbox):
# GET COORDS OF ALL 4 VIEW SCREEN CORNERS
xleft,ytop,xright,ybottom = coordspace_bbox
viewcorners = [(xleft,ytop), (xleft,ybottom), (xright,ybottom), (xright,ytop)]
# FIND PIXEL LOCS OF ALL THESE COORDS ON THE RASTER
viewcorners_pixels = [self.geo_to_cell(*point, fraction=True) for point in viewcorners]
# ON RASTER, PERFORM QUAD TRANSFORM
#(FROM VIEW SCREEN COORD CORNERS IN PIXELS TO RASTER COORD CORNERS IN PIXELS)
flattened = [xory for point in viewcorners_pixels for xory in point]
newraster = self.copy()
#self.update_mask()
mask = self.mask
# make mask over
masktrans = mask.transform((width,height), PIL.Image.QUAD,
flattened, resample=PIL.Image.NEAREST)
for band in newraster.bands:
datatrans = band.img.transform((width,height), PIL.Image.QUAD,
flattened, resample=PIL.Image.NEAREST)
trans = PIL.Image.new(datatrans.mode, datatrans.size)
trans.paste(datatrans, (0,0), masktrans)
# store image and cells
band.img = trans
band.cells = band.img.load()
return newraster,masktrans
Nodata 掩码
有时栅格中的单元格包含缺失数据,因此如果指定,每个RasterData类将在其info元数据字典中定义一个nodata_value。这很重要,因为这些nodata单元格在可视化或执行操作时必须被忽略。因此,在我们的数据接口中,我们需要创建一个额外的图像网格,它知道缺失值的位置,这样我们就可以使用 PIL 来屏蔽或隐藏这些值。这个屏蔽可以通过一个动态属性访问,我们将其缓存以供重复使用。参考以下代码:
@property
def mask(self):
if hasattr(self, "_cached_mask"):
return self._cached_mask
else:
nodata = self.info.get("nodata_value")
if nodata != None:
# mask out nodata
if self.bands[0].img.mode in ("F","I"):
# if 32bit float or int values, need to manually check each cell
mask = PIL.Image.new("1", (self.width, self.height), 1)
px = mask.load()
for col in xrange(self.width):
for row in xrange(self.height):
value = (band.cells[col,row] for band in self.bands)
# mask out only where all bands have nodata value
if all((val == nodata for val in value)):
px[col,row] = 0
else:
# use the much faster point method
masks = []
for band in self.bands:
mask = band.img.point(lambda px: 1 if px != nodata else 0, "1")
masks.append(mask)
# mask out where all bands have nodata value
masks_namedict = dict([("mask%i"%i, mask) for i,mask in enumerate(masks) ])
expr = " & ".join(masks_namedict.keys())
mask = PIL.ImageMath.eval(expr, **masks_namedict).convert("1")
else:
# EVEN IF NO NODATA, NEED TO CREATE ORIGINAL MASK,
# TO PREVENT INFINITE OUTSIDE BORDER AFTER GEOTRANSFORM
nodata = 0
mask = PIL.Image.new("1", self.bands[0].img.size, 1)
self._cached_mask = mask
return self._cached_mask
加载栅格数据
现在,是时候将加载功能添加到Raster类中。不幸的是,除了 GDAL 之外,没有很多独立的文件格式库专注于加载地理栅格格式。尽管如此,我们仍然能够基于 PIL 制作一个针对常见 GeoTIFF 文件格式的最小加载器。我们通过一些导入初始化模块,并将其保存为raster/loader.py:
# import internals
import sys, os, itertools, operator
# import PIL as the image loader
import PIL.Image
我们加载模块的主要目的是提供一个from_file函数,该函数返回必要的组件到我们的栅格数据结构。在我们开始加载每种栅格文件格式之前,我们首先从一个函数开始,该函数用于从有时伴随栅格文件的 ESRI 世界文件格式中读取元数据。世界文件是一个非常简单的文本文件,包含六个值,定义了之前讨论的仿射地理变换元数据,其文件名扩展名为.wld或伴随图像文件类型的变体。
注意
由于支持此世界文件,我们可以轻松地使用 PIL 加载和保存类似图像的栅格格式,如.png、.bmp、.gif或.jpg,但本书中我们不这样做。世界文件有时还附带 ESRI ASCII 栅格格式,这是一种简单的文本文件格式,易于理解和实现。
看看下面的代码:
def from_file(filepath):
def check_world_file(filepath):
worldfilepath = None
# try to find worldfile
dir, filename_and_ext = os.path.split(filepath)
filename, extension = os.path.splitext(filename_and_ext)
dir_and_filename = os.path.join(dir, filename)
# first check generic .wld extension
if os.path.lexists(dir_and_filename + ".wld"):
worldfilepath = dir_and_filename + ".wld"
# if not, check filetype-specific world file extensions
else:
# get filetype-specific world file extension
if extension in ("tif","tiff","geotiff"):
extension = ".tfw"
else:
return None
# check if exists
if os.path.lexists(dir_and_filename + extension):
worldfilepath = dir_and_filename + extension
# then return contents if file found
if worldfilepath:
with open(worldfilepath) as worldfile:
# note that the params are arranged slightly differently
# ...in the world file from the usual affine a,b,c,d,e,f
# ...so remember to rearrange their sequence later
xscale,yskew,xskew,yscale,xoff,yoff = worldfile.read().split()
return [xscale,yskew,xskew,yscale,xoff,yoff]
GeoTIFF
GeoTIFF 是灵活的 TIFF 图像文件格式的地理扩展,唯一的区别是增加了额外的地理特定元数据标签。PIL 可以读取包含元数据标签的 TIFF 文件,但一旦我们完全加载或访问图像内容,PIL 就会通过去除任何格式特定的信息使其格式中立。因此,在处理图像之前,你必须提取地理标签。一旦提取,就由我们来解释标签代码,因为 PIL 不了解 GeoTIFF 规范。我们提取与地理变换、无数据值以及 CRS(坐标参考系统)的名称和文本编码标签相关的标签(还有许多其他 CRS 特定的标签,但在这里处理所有这些标签会太多)。最后,如果图像是复合 RGB 栅格,我们将图像分割成其单独的波段,并返回信息元数据、波段元组以及 CRS。
注意
完整的 GeoTIFF 规范可以在以下网址在线找到:www.remotesensing.org/geotiff/spec/contents.html。
参考以下代码:
elif filepath.lower().endswith((".tif",".tiff",".geotiff")):
main_img = PIL.Image.open(filepath)
raw_tags = dict(main_img.tag.items())
def process_metadata(raw_tags):
# check tag definitions here
info = dict()
if raw_tags.has_key(1025):
# GTRasterTypeGeoKey, aka midpoint pixels vs topleft area pixels
if raw_tags.get(1025) == (1,):
# is area
info["cell_anchor"] = "center"
elif raw_tags.get(1025) == (2,):
# is point
info["cell_anchor"] = "nw"
if raw_tags.has_key(34264):
# ModelTransformationTag, aka 4x4 transform coeffs...
a,b,c,d,
e,f,g,h,
i,j,k,l,
m,n,o,p = raw_tags.get(34264)
# But we don't want to meddle with 3-D transforms,
# ...so for now only get the 2-D affine parameters
xscale,xskew,xoff = a,b,d
yskew,yscale,yoff = e,f,h
info["transform_coeffs"] = xscale,xskew,xoff,yskew,yscale,yoff
else:
if raw_tags.has_key(33922):
# ModelTiepointTag
x, y, z, geo_x, geo_y, geo_z = raw_tags.get(33922)
info["xy_cell"] = x,y
info["xy_geo"] = geo_x,geo_y
if raw_tags.has_key(33550):
# ModelPixelScaleTag
scalex,scaley,scalez = raw_tags.get(33550)
info["cellwidth"] = scalex
info["cellheight"] = -scaley # note: cellheight must be inversed because geotiff has a reversed y- axis (ie 0,0 is in upperleft corner)
if raw_tags.get(42113):
info["nodata_value"] = eval(raw_tags.get(42113)) # eval from string to nr
return info
def read_crs(raw_tags):
crs = dict()
if raw_tags.get(34735):
# GeoKeyDirectoryTag
crs["proj_params"] = raw_tags.get(34735)
if raw_tags.get(34737):
# GeoAsciiParamsTag
crs["proj_name"] = raw_tags.get(34737)
return crs
# read geotiff metadata tags
info = process_metadata(raw_tags)
# if no geotiff tag info look for world file transform coefficients
if len(info) <= 1 and not info.get("transform_coeffs"):
transform_coeffs = check_world_file(filepath)
if transform_coeffs:
# rearrange the world file param sequence to match affine transform
[xscale,yskew,xskew,yscale,xoff,yoff] = transform_coeffs
info["transform_coeffs"] = [xscale,xskew,xoff,yskew,yscale,yoff]
else:
raise Exception("Couldn't find any geotiff tags or world file needed to position the image in space")
# group image bands and pixel access into band tuples
bands = []
for img in main_img.split():
cells = img.load()
bands.append((img,cells))
# read coordinate ref system
crs = read_crs(raw_tags)
return info, bands, crs
不支持的文件格式
与矢量加载器类似,如果尝试加载不支持的栅格文件格式,我们将抛出异常,如下所示:
else:
raise Exception("Could not create a raster from the given filepath: the filetype extension is either missing or not supported")
保存栅格数据
最后,我们希望将我们的栅格数据保存回文件,因此我们创建了一个名为 raster/saver.py 的新模块。我们开始时进行了一些导入,如下所示:
# import builtins
Import os
# import PIL as the saver
import PIL
import PIL.TiffImagePlugin
import PIL.TiffTags
在主 to_file 函数内部,我们定义了一个跨格式函数,用于将栅格波段组合成最终图像以便保存,以及创建包含地理变换的 worldfile 的基本方法:
def to_file(bands, info, filepath):
def combine_bands(bands):
# saving in image-like format, so combine and prep final image
if len(bands) == 1:
img = bands[0].img
return img
elif len(bands) == 3:
# merge all images together
mode = "RGB"
bands = [band.img for band in bands]
img = PIL.Image.merge(mode, bands)
return img
elif len(bands) == 4:
# merge all images together
mode = "RGBA"
bands = [band.img for band in bands]
img = PIL.Image.merge(mode, bands)
return img
else:
# raise error if more than 4 bands, because PIL cannot save such images
raise Exception("Cannot save more than 4 bands to one file; split and save each band separately")
def create_world_file(savepath, geotrans):
dir, filename_and_ext = os.path.split(savepath)
filename, extension = os.path.splitext(filename_and_ext)
world_file_path = os.path.join(dir, filename) + ".wld"
with open(world_file_path, "w") as writer:
# rearrange transform coefficients and write
xscale,xskew,xoff,yskew,yscale,yoff = geotrans
writer.writelines([xscale, yskew, xskew, yscale, xoff, yoff])
GeoTIFF
接下来,我们允许使用 PIL 保存到 GeoTIFF。直到最近,使用 PIL 保存 GeoTIFF 是不可能的。这是因为 PIL 没有实现保存类型为 float 或 double 的 TIFF 标签,这会导致错误,因为大多数 GeoTIFF 标签都是双精度浮点值。最近的一个用户贡献添加了对双精度标签所需的支持,并且在你阅读这篇文章的时候,PIL 的 Pillow 分支应该已经希望升级到一个新的稳定版本 2.8.2。
小贴士
如果 Pillow 仍然是版本 2.8.1,您将不得不自己通过修改 site-packages 中的 PIL 包来添加此支持。在打开 PIL 的 TiffImagePlugin.py 文件后,您会看到 ImageFileDirectory 类有一个从大约第 483 行开始的 save 方法。此方法通过几个 if 语句遍历所有提供的标签,检查不同的标签值类型。在注释为 未类型化数据 和 字符串数据 的 if 语句之间,您必须添加一个新的 if 语句,并使用以下代码为浮点数和双精度浮点数添加,并记得保存您的更改:
elif typ in (11, 12):
# float value
tmap = {11: 'f', 12: 'd'}
if not isinstance(value, tuple):
value = (value,)
a = array.array(tmap[typ], value)
if self.prefix != native_prefix:
a.byteswap()
data = a.tostring()
实际的保存过程是通过利用 PIL 一些不太为人所知的功能来实现的。为了将我们的栅格元数据作为文件本身的标签保存,我们必须参考 PIL 的 TiffImagePlugin 模块,禁用其对 LibTIFF 库的使用,并创建一个空的 ImageFileDirectory 类来保存标签。每个添加的标签值都通过索引设置标签容器,必须在容器上 tagtype 属性的索引设置标签值类型之后设置。
一旦所有地理变换、无数据和投影标签都设置好,我们只需将波段图像组合成一个,并将标签容器作为额外参数传递给最终的 save() 调用:
elif filepath.endswith((".tif", ".tiff", ".geotiff")):
# write directly to tag info
PIL.TiffImagePlugin.WRITE_LIBTIFF = False
tags = PIL.TiffImagePlugin.ImageFileDirectory()
if info.get("cell_anchor"):
# GTRasterTypeGeoKey, aka midpoint pixels vs topleft area pixels
if info.get("cell_anchor") == "center":
# is area
tags[1025] = 1.0
tags.tagtype[1025] = 12 #double, only works with PIL patch
elif info.get("cell_anchor") == "nw":
# is point
tags[1025] = 2.0
tags.tagtype[1025] = 12 #double, only works with PIL patch
if info.get("transform_coeffs"):
# ModelTransformationTag, aka 4x4 transform coeffs...
tags[34264] = tuple(map(float,info.get("transform_coeffs")))
tags.tagtype[34264] = 12 #double, only works with PIL patch
else:
if info.get("xy_cell") and info.get("xy_geo"):
# ModelTiepointTag
x,y = info["xy_cell"]
geo_x,geo_y = info["xy_geo"]
tags[33922] = tuple(map(float,[x,y,0,geo_x,geo_y,0]))
tags.tagtype[33922] = 12 #double, only works with PIL patch
if info.get("cellwidth") and info.get("cellheight"):
# ModelPixelScaleTag
scalex,scaley = info["cellwidth"],info["cellheight"]
tags[33550] = tuple(map(float,[scalex,scaley,0]))
tags.tagtype[33550] = 12 #double, only works with PIL patch
if info.get("nodata_value"):
tags[42113] = bytes(info.get("nodata_value"))
tags.tagtype[42113] = 2 #ascii
# finally save the file using tiffinfo headers
img = combine_bands(bands)
img.save(filepath, tiffinfo=tags)
不支持的文件格式
目前,我们只允许保存到以下这些栅格文件格式:
else:
raise Exception("Could not save the raster to the given filepath: the filetype extension is either missing or not supported")
概要
在本章中,我们构建了我们应用程序的核心基础。在我们的每个 vector 和 raster 文件夹中,我们创建了三个新的模块,使我们能够访问、编辑和共享一些流行的地理数据格式。因此,我们的文件夹结构应该看起来像这样:

这是我们任何类型的 GIS 应用程序所需的最小内容。从理论上讲,到目前为止,我们可以创建一个仅关注加载和处理文件格式的最小化应用程序。在下一章中,我们将直接进入制作可视化界面应用程序,以便我们能够尽快拥有一个真正的交互式应用程序。
第三章:设计应用程序的视觉外观
我们现在已经到了设计我们的应用程序外观和感觉的部分。对于这个图形用户界面(GUI),我们选择了阻力最小的路径,选择了Tkinter库,因为这是官方 Python 安装中的标准内置库,至少在 Windows 和 Mac 上是这样。选择 Tkinter 的其他原因还包括它相对容易使用,并且比一些较新的第三方 GUI 库更符合 Python 风格。
即使您之前没有使用过 Tkinter,也应该能够跟上。Tkinter 的基本思想是,您为 GUI 中的每个图形元素创建小部件类,定义它们的样式和位置。可以通过在小部件内部嵌套小部件来创建复杂元素。您还可以将函数绑定到用户交互事件。
注意
要了解更多关于 Tkinter 的信息,我强烈推荐使用 John W. Shipman 的参考指南,可在infohost.nmt.edu/tcc/help/pubs/tkinter/tkinter.pdf找到。
在本章中,您将:
-
设置通用代码结构,以创建一个主题化和高度可定制的 GIS 应用程序
-
创建一个工具箱,包含专门的用户界面小部件,这些小部件可以连接到我们底层的 GIS 功能
-
使用这个小部件工具箱将应用程序的视觉设计和布局粘合在一起
-
学习如何测试运行我们的应用程序
设置 GUI 包
我们从设置应用程序 GUI 的结构骨架开始本章。这应该与我们的其余代码逻辑上分开,所以我们给它一个自己的子包。在顶级pythongis文件夹内部,创建一个名为app的包文件夹,并在其中包含一个__init__.py文件。让它导入我们将要创建的其余模块:
from . import builder
from . import dialogues
from . import toolkit
from . import icons
为了使我们的app包可以从我们的顶级pythongis包中访问,我们同样需要在pythongis/__init__.py中导入它,如下所示:
from . import app
app包的目的是我们应该能够定义我们的 GUI 的外观和行为,并且通过一行代码pythongis.app.run(),我们应该能够调用它。我们 GUI 的实际定义和布局应该包含在一个我们称为app/builder.py的模块中(我们将在本章末尾回到这个模块)。构建器反过来又依赖于一组预定义的 GUI 构建块,我们在一个名为app/toolkit的子包文件夹中定义这些构建块。这个工具包的__init__.py文件导入了我们将在本章中创建的构建块模块:
from .buttons import *
from .layers import *
from .map import *
from .popups import *
from .ribbon import *
from .statusbar import *
from .toolbars import *
from . import theme
from . import dispatch
除了我们的构建器和工具包之外,我们还需要一个app/dialogues.py模块,该模块定义了应用程序特定的对话框窗口。
最后但同样重要的是,应用程序结构的一个重要部分是如何访问图标和图像。为了使我们的图标能够迅速提供给任何可能需要它们的控件,我们创建了一个app/icons包。这个包文件夹是我们将保存所有图标的地方。当应用程序小部件需要图标时,它只需通过get()请求图标名称和大小,然后返回一个 Tkinter 兼容的PhotoImage对象。现在创建它的__init__.py文件:
import os
import PIL.Image, PIL.ImageTk
ICONSFOLDER = os.path.split(__file__)[0]
def get(iconname, width=None, height=None):
iconpath = os.path.join(ICONSFOLDER, iconname)
if os.path.lexists(iconpath):
img = PIL.Image.open(iconpath)
if width or height:
width = width or img.size[0]
height = height or img.size[1]
img = img.resize((width,height), PIL.Image.ANTIALIAS)
tk_img = PIL.ImageTk.PhotoImage(img)
return tk_img
else:
raise Exception("No icon by that name")
一旦创建了所有这些,我们就应该准备就绪了。你的app文件夹结构应该看起来像这样:

创建工具包构建块
在我们开始设计我们的 GUI 布局之前,我们必须创建包含我们将使用的底层构建块或设计元素的toolkit包。Tkinter 已经提供了一套基本的 GUI 元素或小部件类,例如按钮、标签或复选框,它们提供了将它们放置在应用程序窗口中或嵌套在彼此内部的方法。为了保持这种逻辑的一致性,我们子类化这些 Tkinter 小部件并在此基础上扩展,以创建我们自己的专用构建块小部件。这样,我们的 GUI 代码就变得一致、稳定且可重用。
在我们的toolkit包中,我们希望有一些小部件可以随时使用:图标按钮、工具栏、标签页系统、状态栏、包含数据层的面板概览、地图小部件和弹出窗口模板。我们还需要一种方法让我们的按钮能够与我们的 GIS 代码库中的地理空间工作任务连接并执行,因此我们创建了一个命令调度工具。然而,在我们开始制作小部件之前,让我们设置一种简单的方式来风格化它们。
主题风格
为了给我们的应用程序一种风格感,我们必须在每一个工具包小部件中定义诸如背景颜色或文本字体等元素。为了使这种风格更加灵活,我们将风格说明集中到一个单独的模块中,我们称之为app/toolkit/theme.py。各种小部件可以从中导入风格说明,这些说明可以很容易地在我们开发应用程序时进行更改和修改,或者甚至作为最终用户可定制的功能。
在 Tkinter 中指定颜色时,你可以指定十六进制颜色字符串或 Tkinter 预定义的颜色名称。让我们将应用程序的主要背景颜色设为浅灰色,从浅灰色一直到纯白色共有五种不同的色调。我们还想为突出显示目的添加一些更独特的颜色,一种是两种橙色的色调,另一种是两种蓝色的色调:
color1 = "Grey69"
color2 = "Grey79"
color3 = "Grey89"
color4 = "Grey99"
color5 = "white"
strongcolor1 = "gold"
strongcolor2 = "dark orange"
alterncolor1 = "DodgerBlue"
alterncolor2 = "Blue3"
注意
要查看有效的 Tkinter 颜色名称及其外观的完整列表,请参阅wiki.tcl.tk/37701。
一个人使用的字体类型和大小也是应用程序设计的关键部分,因此我们决定使用 Windows 8 中使用的时尚的 Segoe 字体。Tkinter 字体可以用包含字体名称、大小和可选的强调类型的元组来指定。我们创建了两种主要的文本字体色调,一种是正常的,一种是较弱的,用于不太重要的背景文本。我们还创建了两种标题/标题文本字体类型,一种是正常的,一种是白色的,以防我们需要在较暗的背景上显示文本:
titlefont1 = {"type": ("Segoe UI", 12, "bold"),
"color": "black"}
titlefont1_contrast = {"type": ("Segoe UI", 12, "bold"),
"color": "white"}
font1 = {"type": ("Segoe UI", 10),
"color": "black"}
font2 = {"type": ("Segoe UI", 10),
"color": "Grey42"}
基本按钮
现在,我们可以开始制作小部件了。尽管 Tkinter 已经自带Button小部件,但我们将创建一些自己的,以便每个创建的按钮都按照我们想要的样式进行设计,并且我们可以简化为它们添加图标的过程。因此,我们在toolkit包中创建我们的第一个模块,我们将其命名为app/toolkit/buttons.py。在顶部,我们导入一些必要的东西:
# Import builtins
import sys, os
# Import GUI libraries
import Tkinter as tk
from tkFileDialog import askopenfilenames, asksaveasfilename
import PIL, PIL.Image, PIL.ImageTk
# Import internals
from .. import icons
接下来,我们导入我们的主题模块,并将用于按钮的样式定义为字典条目。在正常情况下,我们希望按钮具有浅色背景色和平滑的立体感。一旦鼠标指针悬停在按钮上,它就会点亮,并显示高亮颜色,如果点击,颜色会变得更鲜艳:
# Import theme
from . import theme
style_button_normal = {"fg": theme.font1["color"],
"font": theme.font1["type"],
"bg": theme.color4,
"relief": "flat",
"activebackground": theme.strongcolor2
}
style_button_mouseover = {"bg": theme.strongcolor1
}
为了实现遵循这种样式的按钮小部件,我们创建了一个继承自标准 Tkinter 按钮的按钮小部件,并使用我们的格式化样式字典作为关键字参数。我们还定义了当鼠标经过按钮时,它应该按照我们定义的悬停字典中的方式点亮:
class Button(tk.Button):
def __init__(self, master, **kwargs):
# get theme style
style = style_button_normal.copy()
style.update(kwargs)
# initialize
tk.Button.__init__(self, master, **style)
# bind event behavior
def mouse_in(event):
event.widget.config(style_button_mouseover)
def mouse_out(event):
event.widget.config(style_button_normal)
self.bind("<Enter>", mouse_in)
self.bind("<Leave>", mouse_out)
我们还添加了一些常用的按钮,例如带有Enter/Return键盘快捷键的确定按钮,当激活时运行指定的函数:
class OkButton(Button):
def __init__(self, master, **kwargs):
# initialize
if kwargs.get("text") == None:
kwargs["text"] = "OK"
okfunc = kwargs.get("command")
Button.__init__(self, master, **kwargs)
# bind enter keypress to command function
def runfunc(event):
okfunc()
self.winfo_toplevel().bind("<Return>", runfunc)
带有图标的按钮
在我们的应用程序中,我们希望通过使用小图标图像来展示按钮的功能,但正如你很快就会看到的,在 Tkinter 中为按钮添加图标需要几个自定义步骤,这些步骤很快就会变得乏味。因此,我们创建了一个专门的图标按钮,它会为我们完成这些步骤。
要创建IconButton类,我们将我们的样式按钮类作为起点,我们只需要添加一个set_icon方法。该方法通过icons包检索图像,大小适合按钮,以样式化图像在按钮内的放置方式,分配它,并将其存储为按钮的一个属性,以便它不会被垃圾回收:
class IconButton(Button):
def __init__(self, master, **kwargs):
# initialize
Button.__init__(self, master, **kwargs)
def set_icon(self, iconname, **kwargs):
# get icon as tkinter photoimage, with an optional resize
tk_img = icons.get(iconname,
width=kwargs.get("width"),
height=kwargs.get("height"))
self.config(image=tk_img, **kwargs)
# resize button to have room for text if compound type
if not kwargs.get("anchor"): kwargs["anchor"] = "center"
if kwargs.get("compound"):
def expand():
self["width"] += tk_img.width()
self["height"] += tk_img.height() / 2
self.after(100, expand)
# store as attribute, so it doesn't get garbage collected
self.tk_img = tk_img
工具栏
按钮不应随意放置。相反,我们希望将逻辑上相关的按钮组合成称为工具栏的区域。我们创建app/toolkit/toolbars.py模块,并从必要的导入和样式设置开始:
# Import GUI
import Tkinter as tk
# Import internals
from .buttons import *
from .popups import *
# Import style
from . import theme
style_toolbar_normal = {"bg": theme.color4}
style_namelabel_normal = {"bg": theme.color4,
"font": theme.font2["type"],
"fg": theme.font2["color"],
"pady": 0}
工具栏区域本身是 Tkinter 框架的子类,它将包括一个框架区域,按钮将并排打包,底部有一个文本区域,指定工具栏的用途。目前,我们只创建了一个方便的Toolbar类,它有一个add_button方法,这样我们就可以通过子类化这个类来后来构建和填充专门的工具栏:
class Toolbar(tk.Frame):
"""
Base class for all toolbars.
"""
def __init__(self, master, toolbarname, **kwargs):
# get theme style
style = style_toolbar_normal.copy()
style.update(kwargs)
# Make this class a subclass of tk.Frame and add to it
tk.Frame.__init__(self, master, **style)
# Divide into button area and toolbar name
self.buttonframe = tk.Frame(self, **style)
self.buttonframe.pack(side="top", fill="y", expand=True)
self.name_label = tk.Label(self, **style_namelabel_normal)
self.name_label["text"] = toolbarname
self.name_label.pack(side="bottom")
def add_button(self, icon=None, **kwargs):
button = IconButton(self.buttonframe)
options = {"text":"", "width":48, "height":32, "compound":"top"}
options.update(kwargs)
if icon:
button.set_icon(icon, **options)
else:
button.config(**options)
button.pack(side="left", padx=2, pady=0, anchor="center")
return button
功能区标签系统
接下来是功能区小部件,它受到微软办公软件新版本的启发,不仅将为我们的应用程序带来时尚现代的外观,还将提供我们所需要的组织水平和简洁性,以避免吓跑我们应用程序的非技术用户。就像上一节中相关的按钮被分组到工具栏中一样,这里工具栏可以被分组到窗口顶部的功能区区域,可以切换和翻页,就像笔记本一样,如下面的截图所示:

在toolkit包中创建app/toolkit/ribbon.py模块后,我们开始导入和样式设置,使用微妙的灰色高亮效果为标签选择器着色:
# Import GUI
import Tkinter as tk
# Import internals
from .toolbars import *
# Import style
from . import theme
style_ribbon_normal = {"bg": theme.color3,
"height": 120,
"pady": 0}
style_tabsarea_normal = {"bg": theme.color3,
"height": 20,
"padx": 1,
"pady": 0}
style_tabselector_normal = {"bg": theme.color3,
"activebackground": theme.color4,
"fg": theme.font1["color"],
"font": theme.font1["type"],
"relief": "flat",
"padx":10, "pady":5}
style_tabselector_mouseover = {"bg": "Grey93" }
style_toolbarsarea_normal = {"bg": theme.color4}
Ribbon类本身是一个框架,顶部区域用于标签选择器,底部区域用于显示当前选中的标签区域,用于相关工具栏。标签作为单独的实例创建,并通过add_tab方法添加,当鼠标悬停在其上时,它也会亮起。当标签选择器被按下时,将调用一个switch方法,它将将其标签区域提升到所有其他标签之上:
class Ribbon(tk.Frame):
"""
Can switch between a series of logically grouped toolbar areas (tabs).
"""
def __init__(self, master, **kwargs):
# get theme style
style = style_ribbon_normal.copy()
style.update(kwargs)
# Make this class a subclass of tk.Frame and add to it
tk.Frame.__init__(self, master, **style)
# Make top area for tab selectors
self.tabs_area = tk.Frame(self, **style_tabsarea_normal)
self.tabs_area.pack(fill="x", side="top")
# Make bottom area for each tab's toolbars
self.toolbars_area = tk.Frame(self, **style_toolbarsarea_normal)
self.toolbars_area.pack(fill="both", expand=True, side="top")
self.pack_propagate(False)
# Create tab list
self.tabs = dict()
def add_tab(self, tabname):
tab = Tab(self.toolbars_area, tabname=tabname)
self.tabs[tab.name] = tab
self.current = tab
# add tab to toolbars area
tab.place(relwidth=1, relheight=1)
# add tabname to tab selector area
tab.selector = tk.Label(self.tabs_area, text=tab.name, **style_tabselector_normal)
tab.selector.pack(side="left", padx=5)
# enable dynamic tab selector styling
def mouse_in(event):
if event.widget["state"] == "normal":
event.widget.config(style_tabselector_mouseover)
def mouse_out(event):
if event.widget["state"] == "normal":
event.widget.config(style_tabselector_normal)
tab.selector.bind("<Enter>", mouse_in)
tab.selector.bind("<Leave>", mouse_out)
# make tab selector selectable
tab.selector.bind("<Button-1>", self.switch)
return tab
def switch(self, event=None, tabname=None):
if event: tabname = event.widget["text"]
# deactivate old tab
self.current.selector["state"] = "normal"
# activate new tab
self.current = self.tabs[tabname]
self.current.selector.config(style_tabselector_normal)
self.current.selector["state"] = "active"
self.current.lift()
当我们使用功能区的add_tab方法时,它返回给我们一个Tab类,我们负责用按钮和其他内容填充它。为了方便,我们给Tab类提供了一个add_toolbar方法:
class Tab(tk.Frame):
"""
Base class for all tabs
"""
def __init__(self, master, tabname, **kwargs):
# get theme style
style = style_toolbarsarea_normal.copy()
style.update(kwargs)
# Make this class a subclass of tk.Frame and add to it
tk.Frame.__init__(self, master, **style)
# remember name
self.name = tabname
def add_toolbar(self, toolbarname):
toolbar = Toolbar(self, toolbarname=toolbarname)
toolbar.pack(side="left", padx=10, pady=0, fill="y")
return toolbar
底部状态栏
另一个重要的 GUI 元素是状态栏,它可以包含一个或多个信息或状态,并且通常放置在应用程序窗口的底部,与背景交织在一起。我们在app/toolkit/statusbar.py模块中创建状态栏,并在开始时进行常规导入和样式设置:
# Import GUI
import Tkinter as tk
# Import style
from . import theme
style_statusbar_normal = {"height": 25,
"bg": theme.color3}
style_status_normal = {"fg": theme.font2["color"],
"font": theme.font2["type"],
"bg": theme.color3}
style_taskstatus_normal = style_status_normal.copy()
style_taskstatus_working = {"fg": theme.font1["color"],
"font": theme.font1["type"],
"bg": theme.strongcolor2}
状态栏小部件本身只是一个包含一个或多个状态小部件的框架。以下是StatusBar类的代码:
class StatusBar(tk.Frame):
def __init__(self, master, **kwargs):
"""
A container bar that contains one or more status widgets
"""
# get theme style
style = style_statusbar_normal.copy()
style.update(kwargs)
# Make this class a subclass of tk.Frame and add to it
tk.Frame.__init__(self, master, **style)
# Insert status items
self.task = TaskStatus(self)
self.task.place(relx=0.0, rely=0.5, anchor="w")
self.projection = ProjectionStatus(self)
self.projection.place(relx=0.20, rely=0.5, anchor="w")
self.zoom = ZoomStatus(self)
self.zoom.place(relx=0.40, rely=0.5, anchor="w")
self.mouse = MouseStatus(self)
self.mouse.place(relx=0.70, rely=0.5, anchor="w")
我们然后为所有状态小部件创建一个基类,称为Status,以及一些它的子类来显示投影名称、缩放级别和鼠标指针坐标,由于这些将由父小部件控制,因此不需要任何事件绑定或行为。一个特殊的TaskStatus小部件可以被设置为start(),并将随着调用者提供的任务描述变为橙色。一旦调用stop方法,它将恢复正常,如下面的代码所示:
class Status(tk.Label):
def __init__(self, master, **kwargs):
"""
The base class used for all status widgets
"""
# get theme style
style = style_status_normal.copy()
style.update(kwargs)
# Make this class a subclass of tk.Label and add to it
tk.Label.__init__(self, master, **style)
self.prefix = ""
def set_text(self, text):
self["text"] = self.prefix + text
def clear_text(self):
self["text"] = self.prefix
class TaskStatus(Status):
def __init__(self, master, **kwargs):
# Make this class a subclass of tk.Label and add to it
default = {"width":30, "anchor":"w"}
default.update(kwargs)
Status.__init__(self, master, **default)
# Set startup status
self.set_text("Ready")
def start(self, taskname):
self.config(**style_taskstatus_working)
self.set_text(taskname)
def stop(self):
self.set_text("Finished!")
self.config(**style_taskstatus_normal)
def reset_text():
self.set_text("Ready")
self.after(1000, reset_text)
class ProjectionStatus(Status):
def __init__(self, master, **kwargs):
# Make this class a subclass of tk.Label and add to it
self.prefix = "Map Projection: "
default = {"text":self.prefix, "width":30, "anchor":"w"}
default.update(kwargs)
Status.__init__(self, master, **default)
class ZoomStatus(Status):
def __init__(self, master, **kwargs):
# Make this class a subclass of tk.Label and add to it
self.prefix = "Horizontal Scale: "
default = {"text":self.prefix, "width":30, "anchor":"w"}
default.update(kwargs)
Status.__init__(self, master, **default)
class MouseStatus(Status):
def __init__(self, master, **kwargs):
# Make this class a subclass of tk.Label and add to it
self.prefix = "Mouse coordinates: "
default = {"text":self.prefix, "width":50, "anchor":"w"}
default.update(kwargs)
Status.__init__(self, master, **default)
层级面板
大多数 GIS 应用中的一个关键元素是图层面板,它显示并允许访问加载的数据,并显示它们的符号以及它们在地图上的渲染顺序。在启动我们新的app/toolkit/layers.py模块后,我们通过一些导入和样式来启动它。请注意,我们还导入了我们的顶级pythongis包和我们的dispatch模块,因为这一层面板需要能够加载和渲染数据:
# Import GUI functionality
import Tkinter as tk
from tkFileDialog import askopenfilenames, asksaveasfilename
# Import internals
from .buttons import *
from .popups import *
# Import style
from . import theme
style_layerspane_normal = {"bg": theme.color4,
"width": 200}
style_layersheader = {"bg": theme.color2,
"font": theme.titlefont1["type"],
"fg": theme.titlefont1["color"],
"anchor": "w", "padx": 5}
style_layeritem_normal = {"bg": theme.color4,
"width": 200,
"relief": "ridge"}
style_layercheck = {"bg": theme.color4}
style_layername_normal = {"bg": theme.color4,
"fg": theme.font1["color"],
"font": theme.font1["type"],
"relief": "flat",
"anchor": "w"}
# Import GIS functionality
import pythongis as pg
from . import dispatch
目前我们只创建了一个带样式的LayersPane类,标题文本为图层,以及一个主列表区域,其中将显示单个加载的图层。关于这些图层项、如何加载它们以及它们的显示和行为将在第四章渲染我们的地理数据中更自然地处理:
class LayersPane(tk.Frame):
def __init__(self, master, layer_rightclick=None, **kwargs):
# get theme style
style = style_layerspane_normal.copy()
style.update(kwargs)
# Make this class a subclass of tk.Frame and add to it
tk.Frame.__init__(self, master, **style)
# Make the top header
self.header = tk.Label(self, text="Layers:", **style_layersheader)
self.header.pack(side="top", fill="x")
# Then, the layer list view
self.layersview = tk.Frame(self, **style)
self.layersview.pack(side="top", fill="x")
self.pack_propagate(False) # important, this prevents layeritem names from deciding the size of layerspane
地图小部件
最后但同样重要的是,没有用于交互式查看地理数据的地图小部件,我们就无法拥有 GIS。创建app/toolkit/map.py模块,并按照以下方式启动它:
# Import builtins
import time
# Import GUI libraries
import Tkinter as tk
# Import internals
from .popups import popup_message
from .. import icons
# Import GIS functionality
import pythongis as pg
from . import dispatch
# Import style
from . import theme
style_map_normal = {"bg": theme.color1}
与图层面板一样,我们在第四章渲染我们的地理数据中更全面地开发地图小部件,因此现在我们只创建初始的MapView类。最终,我们希望我们的地图小部件能够包含渲染的地图图像,并允许用户在地图上平移和缩放,因此我们将其作为 Tkinter Canvas小部件的子类。由于MapView类将在以后调用一些可能很重的渲染操作,我们还需要一种方法将其链接到状态栏,以便报告其进度:
class MapView(tk.Canvas):
def __init__(self, master, **kwargs):
# get theme style
style = style_map_normal.copy()
style.update(kwargs)
# Make this class a subclass of tk.Canvas and add to it
tk.Canvas.__init__(self, master, **style)
# Other
self.proj = kwargs.get("projection", "WGS84")
self.statusbar = None
self.mousepressed = False
self.mouse_mode = "pan"
self.zoomcenter = None
self.zoomfactor = 1
self.zoomdir = None
self.last_zoomed = None
def assign_statusbar(self, statusbar):
statusbar.mapview = self
self.statusbar = statusbar
弹出窗口
在本书的后面部分,我们将在主 GUI 窗口之上打开几个额外的窗口,无论是错误消息还是选项或工具菜单。因此,在我们的toolkit包中,我们想要定义一些窗口模板。
注意
这些窗口模板与我们的应用程序中特定的实际窗口不同,这些窗口在本章中作为我们之前创建的app/dialogues.py模块的一部分定义。
创建一个用于窗口模板的模块app/toolkit/popups.py,并开始导入:
# Import GUI helpers
import Tkinter as tk
import tkMessageBox
# Import internals
from .buttons import IconButton, OkButton, CancelButton
from . import dispatch
from ... import vector
# Define some styles
from . import theme
style_options_helptext = {"font": theme.font1["type"],
"fg": theme.font1["color"]}
style_options_titles = {"font": theme.titlefont1["type"],
"fg": theme.titlefont1["color"]}
style_options_labels = {"font": theme.font1["type"],
"fg": theme.font1["color"]}
首先,我们创建了一些基本的弹出窗口和模板。这包括一个简单的警告popup_message函数,可以用于在 GUI 中引发错误,以及一个基本的Window类模板,具有理想的定位和大小,用作任何其他窗口的起点:
def popup_message(parentwidget, errmsg):
tkMessageBox.showwarning("Warning", errmsg)
class Window(tk.Toplevel):
def __init__(self, master=None, **kwargs):
# Make this class a subclass of tk.Menu and add to it
tk.Toplevel.__init__(self, master, **kwargs)
# Set its size to percent of screen size, and place in middle
width = self.winfo_screenwidth() * 0.6
height = self.winfo_screenheight() * 0.6
xleft = self.winfo_screenwidth()/2.0 - width / 2.0
ytop = self.winfo_screenheight()/2.0 - height / 2.0
self.geometry("%ix%i+%i+%i"%(width, height, xleft, ytop))
# Force and lock focus to the window
self.grab_set()
self.focus_force()
我们还创建了一个模板,这次专门用于构建工具选项输入框架。这不会是一个窗口,而是一个 Tkinter 框架,可以放置在任何其他小部件内部,代表一些工具或功能,用户可以自定义设置或参数,并决定运行工具或取消。让我们创建这个通用的RunToolFrame类,它由一个输入区域组成,所有选项都将构建在左侧,一个帮助区域在右侧,以及一个在底部运行工具的按钮:

下面是创建RunToolFrame类的代码:
class RunToolFrame(tk.Frame):
def __init__(self, master=None, **kwargs):
# Make this class a subclass of tk.Toplevel and add to it
tk.Frame.__init__(self, master, **kwargs)
# Create empty option and input data
self.hidden_options = dict()
self.inputs = list()
self.statusbar = None
self.method = None
self.process_results = None
# Make helpscreen area to the right
self.helpscreen = tk.Frame(self)
self.helpscreen.pack(side="right", fill="y")
self.helptitle = tk.Label(self.helpscreen, text="Help Screen", **style_options_titles)
self.helptitle.pack(fill="x")
self.helptext = tk.Text(self.helpscreen, width=30,
wrap=tk.WORD, cursor="arrow",
**style_options_helptext)
self.helptext.pack(fill="both", expand=True)
# Make main screen where input goes to the left
self.mainscreen = tk.Frame(self)
self.mainscreen.pack(side="left", fill="both", expand=True)
self.maintitle = tk.Label(self.mainscreen, text="User Input", **style_options_titles)
self.maintitle.pack()
self.mainoptions = tk.Frame(self.mainscreen)
self.mainoptions.pack(fill="both", expand=True)
self.mainbottom = tk.Frame(self.mainscreen)
self.mainbottom.pack()
# Make run button at bottom
self.runbut = OkButton(self.mainbottom, command=self.run)
self.runbut.pack(side="right")
为了让我们以后更容易操作,我们还提供了简单的方法来定义要传递给目标操作哪些选项,它使用这些选项自动创建适当的输入小部件。这里最强大的功能是add_option_input(),它添加一个可定制的选项小部件,并具有几个可以调整和组合的参数,以生成适用于许多不同值类型的小部件。这需要两个参数:label——显示在输入小部件旁边的文本,和valuetype——一个函数,用于将从小部件(总是文本)检索到的输入值转换为目标操作期望的类型。
如果没有指定其他参数,此方法将向目标函数添加一个未命名的列表参数,或者通过指定argname,可以将其作为关键字参数。当multi参数为真时,用户将获得一个输入小部件,可以自由输入值并将其添加到选项值的列表中,如果choices参数也为真,则用户被限制只能从选择列表中选择一个或多个项目。设置choices参数而不设置multi参数允许用户只能从下拉列表中选择单个值。default参数定义了小部件的起始值,而minval和maxval试图确保最终参数大于、小于或介于某些限制之间。最后,还有一个add_hidden_option方法,它设置一个选项,而不会将其显示为可定制的控件。请看以下代码:
def add_option_input(self, label, valuetype, argname=None, multi=False, length=None, default=None, minval=None, maxval=None, choices=None):
optionrow = tk.Frame(self.mainoptions)
optionrow.pack(fill="x", anchor="n", pady=5, padx=5)
if multi:
# make a list-type widget that user can add to
inputlabel = tk.Label(optionrow, text=label, **style_options_labels)
inputlabel.pack(side="left", anchor="nw", padx=3)
inputwidget = tk.Listbox(optionrow, activestyle="none",
highlightthickness=0, selectmode="extended",
**style_options_labels)
inputwidget.pack(side="right", anchor="ne", padx=3)
if choices:
# add a listbox of choices to choose from
def addtolist():
for selectindex in fromlist.curselection():
selectvalue = fromlist.get(selectindex)
inputwidget.insert(tk.END, selectvalue)
for selectindex in reversed(fromlist.curselection()):
fromlist.delete(selectindex)
def dropfromlist():
for selectindex in inputwidget.curselection():
selectvalue = inputwidget.get(selectindex)
fromlist.insert(tk.END, selectvalue)
for selectindex in reversed(inputwidget.curselection()):
inputwidget.delete(selectindex)
# define buttons to send back and forth bw choices and input
buttonarea = tk.Frame(optionrow)
buttonarea.pack(side="right", anchor="n")
addbutton = IconButton(buttonarea, command=addtolist,
text="-->", **style_options_labels)
addbutton.pack(anchor="ne", padx=3, pady=3)
dropbutton = IconButton(buttonarea, command=dropfromlist,
text="<--", **style_options_labels)
dropbutton.pack(anchor="ne", padx=3, pady=3)
# create and populate the choices listbox
fromlist = tk.Listbox(optionrow, activestyle="none",
highlightthickness=0, selectmode="extended",
**style_options_labels)
for ch in choices:
fromlist.insert(tk.END, ch)
fromlist.pack(side="right", anchor="ne", padx=3)
else:
# add a freeform entry field and button to add to the listbox
def addtolist():
entryvalue = addentry.get()
inputwidget.insert(tk.END, entryvalue)
addentry.delete(0, tk.END)
def dropfromlist():
for selectindex in reversed(inputwidget.curselection()):
inputwidget.delete(selectindex)
buttonarea = tk.Frame(optionrow)
buttonarea.pack(side="right", anchor="n")
addbutton = IconButton(buttonarea, command=addtolist,
text="-->", **style_options_labels)
addbutton.pack(anchor="ne", padx=3, pady=3)
dropbutton = IconButton(buttonarea, command=dropfromlist,
text="<--", **style_options_labels)
dropbutton.pack(anchor="ne", padx=3, pady=3)
# place the freeform text entry widget
addentry = tk.Entry(optionrow, **style_options_labels)
addentry.pack(side="right", anchor="ne", padx=3)
else:
inputlabel = tk.Label(optionrow, text=label, **style_options_labels)
inputlabel.pack(side="left", anchor="nw")
if choices:
# dropdown menu of choices
choice = tk.StringVar()
if default: choice.set(default)
inputwidget = tk.OptionMenu(optionrow, choice, *choices)
inputwidget.choice = choice
inputwidget.pack(side="right", anchor="ne", padx=3)
else:
# simple number or string entry widget
inputwidget = tk.Entry(optionrow, **style_options_labels)
inputwidget.pack(side="right", anchor="ne")
if default != None:
inputwidget.insert(tk.END, str(default))
# remember for later
inputwidget.meta = dict(argname=argname, label=label, choices=choices,
valuetype=valuetype, multi=multi, length=length,
default=default, minval=minval, maxval=maxval)
self.inputs.append(inputwidget)
def add_hidden_option(self, argname, value):
self.hidden_options[argname] = value
我们现在有方法在窗口中构建一系列可定制的参数小部件,但我们仍然不知道当用户准备好运行工具窗口时应该运行什么操作或目标操作。这个操作必须作为一个能够接收输入小部件参数并传递给set_target_method()的函数。这样做可以记住目标函数以供以后使用,并从给定的函数中检索doc字符串,然后在窗口的帮助区域显示给用户。此外,我们不希望在函数运行时锁定 GUI,因此目标函数将在新线程中调度(稍后将有更多介绍)。使用assign_statusbar()可以让它在等待结果时通知链接的状态栏。我们还需要设置一个在处理结果完成后要运行的函数,使用set_finished_method():
def assign_statusbar(self, statusbar):
self.statusbar = statusbar
def set_target_method(self, taskname, method):
self.taskname = taskname
self.method = method
# use the method docstring as the help text
doc = method.__doc__
if doc:
# clean away tabs, multispaces, and other junk
cleandoc = method.__doc__.strip().replace("\t","").replace(" "," ")
# only keep where there are two newlines after each other
# because single newlines are likely just in-code formatting
cleandoc = "\n\n".join(paragraph.replace("\n","").strip() for paragraph in cleandoc.split("\n\n") )
helptext = cleandoc
else:
helptext = "Sorry, no documentation available..."
self.helptext.insert(tk.END, helptext)
self.helptext["state"] = tk.DISABLED
def set_finished_method(self, method):
self.process_results = method
def get_options(self):
args = list()
kwargs = dict()
for key,val in self.hidden_options.items():
if key == None: args.extend(val) #list arg
else: kwargs[key] = val
for inputwidget in self.inputs:
argname = inputwidget.meta["argname"]
multi = inputwidget.meta["multi"]
choices = inputwidget.meta["choices"]
valuetype = inputwidget.meta["valuetype"]
# ensure within min/max range
def validate(value):
minval = inputwidget.meta["minval"]
if minval and not value >= minval:
return Exception("The input value for %s was smaller than the minimum value %s" %(inputwidget.meta["label"], minval))
maxval = inputwidget.meta["maxval"]
if maxval and not value <= maxval:
return Exception("The input value for %s was larger than the maximum value %s" %(inputwidget.meta["label"], minval))
return value
# get value based on the argument type
if argname == None:
# if argname is None, then it is not a kwarg, but unnamed arg list
get = inputwidget.get(0, last=tk.END)
if get != "":
args.extend( [validate(valuetype(val)) for val in get] )
elif multi:
get = inputwidget.get(0, last=tk.END)
if get != "":
kwargs[argname] = [ validate(valuetype(val)) for val in get ]
elif choices:
get = inputwidget.choice.get()
if get != "":
kwargs[argname] = validate(valuetype(get))
else:
get = inputwidget.get()
if get != "":
kwargs[argname] = validate(valuetype(get))
return args,kwargs
def run(self):
# first ensure the tool has been prepped correctly
if not self.statusbar:
raise Exception("Internal error: The tool has not been assigned a statusbar")
if not self.method:
raise Exception("Internal error: The tool has not been assigned a method to be run")
if not self.process_results:
raise Exception("Internal error: The tool has not been assigned how to process the results")
# get options
try:
args,kwargs = self.get_options()
except Exception as err:
popup_message(self, "Invalid options: \n" + str(err) )
return
# start statusbar
self.statusbar.task.start(self.taskname)
# run task
pending = dispatch.request_results(self.method, args=args, kwargs=kwargs)
# schedule to process results upon completion
def finish(results):
# first run user specified processing
try:
self.process_results(results)
except Exception as err:
popup_message(self, "Error processing results:" + "\n\n" + str(err) )
# then stop the task
self.statusbar.task.stop()
# note: this window cannot be the one to schedule the listening
# ...because this window might be destroyed, so use its master
dispatch.after_completion(self.master, pending, finish)
将重任务调度到线程工作者
在后面的章节中,我们将开始添加在点击不同按钮时运行的特定 GIS 代码。许多 GIS 任务可能非常复杂,需要一些时间才能完成。如果我们只是在我们 Tkinter 主事件处理循环中运行这段冗长的代码,那么我们的应用程序在等待它完成时会冻结。为了避免这种情况,长时间运行的线程必须在除了我们的 GUI 之外的其他线程中运行,同时我们的 GUI 会定期检查结果是否已经准备好。
由于我们预计将通过按钮点击频繁地调用这些重任务,我们通过创建一个app/toolkit/dispatch.py模块来简化线程处理过程,该模块为我们完成工作。每当一个 GUI 工具或按钮需要运行任何类型的地理空间任务或工作负载时,我们只需将函数和参数发送到调度程序的request_results方法。该方法将立即返回一个队列通信对象,然后我们必须将其发送到after_completion()以定期检查结果,而不会阻塞任何新的 GUI 事件或交互,并在完成后运行指定的函数来处理结果。如果在线程处理过程中抛出异常,它将被返回到应用程序以进行适当的处理。
这里是代码:
import threading
import Queue
import traceback
def request_results(func, args=(), kwargs={}):
# prepare request
results = Queue.Queue()
func_args = (args, kwargs)
instruct = func, func_args, results
# ask the thread
worker = threading.Thread(target=_compute_results_, args=instruct)
worker.daemon = True
worker.start()
# return the empty results, it is up to the GUI to wait for it
return results
def after_completion(window, queue, func):
def check():
try:
result = queue.get(block=False)
except:
window.after(1000, check)
else:
func(result)
window.after(100, check)
def _compute_results_(func, func_args, results):
"""
This is where the actual work is done,
and is run entirely in the new worker thread.
"""
args, kwargs = func_args
try: _results = func(*args, **kwargs)
except Exception as errmsg:
_results = Exception(traceback.format_exc() )
results.put( _results )
使用工具包构建 GUI
现在我们已经创建了基本的 GUI 构建块,我们只需要将它们全部组合起来,以创建我们的第一个应用程序:

要做到这一点,请重新打开我们在本章开头创建的空app/builder.py模块。让我们创建一个基本的 GUI 小部件,它代表我们应用程序的全部内容,并使用传统的布局填充我们的小部件:
-
顶部的 Ribbon 小部件
-
左侧的 LayersPane
-
右侧的 MapView
-
底部的 StatusBar 容器
这里是代码:
# Import builtins
import sys, os
import time
# Import GUI library
import Tkinter as tk
# Import internals
from .toolkit import *
from .dialogues import *
# Import GIS functionality
import pythongis as pg
class GUI(tk.Frame):
def __init__(self, master, **kwargs):
tk.Frame.__init__(self, master, **kwargs)
# Place top ribbon area
self.ribbon = Ribbon(self)
self.ribbon.pack(side="top", fill="x")
# Add tabs
hometab = self.ribbon.add_tab("Home")
# Set starting tab
self.ribbon.switch(tabname="Home")
# Place main middle area
middle_area = tk.Frame(self)
middle_area.pack(side="top", expand=True, fill="both")
# Layers pane on left
self.layerspane = LayersPane(middle_area)
self.layerspane.pack(side="left", fill="y")
# Mapwidget on right
self.mapview = MapView(middle_area)
self.mapview.pack(side="left", fill="both", expand=True)
# Place bottom info and mouse coords bar at bottom
self.statusbar = StatusBar(self, height=20, width=100)
self.statusbar.pack(side="bottom", fill="x")
# Assign statusbar to widgets that perform actions
self.mapview.assign_statusbar(self.statusbar)
self.layerspane.assign_statusbar(self.statusbar)
最后,我们创建一个run函数,它独立于 GUI 类,简单地创建主 Tkinter 根窗口,将我们的 GUI 小部件打包到其中,并运行应用程序:
def run():
"""Build the GUI."""
# create main window
window = tk.Tk()
window.wm_title("Python GIS")
try: # windows and mac
window.wm_state('zoomed')
except: # linux
window.wm_attributes("-zoomed", "1")
# pack in the GUI frame
gui = GUI(window)
gui.place(relwidth=1, relheight=1)
# open the window
window.mainloop()
通过在app/__init__.py中添加以下内容,使此函数直接从app包中可用:
from .builder import run
测试我们的应用程序
假设你正确地遵循了所有指示,你现在应该能够使用之前的run函数开始探索我们迄今为止构建的应用程序。随着我们在整本书中添加更多功能,你可能会多次调用此函数进行测试。因此,我们添加了一个现成的脚本,名为guitester.py,将其保存在我们的pythongis包所在的同一目录中,以便后者可以直接导入。该脚本只需要以下代码:
import pythongis as pg
pg.app.run()
如果你现在运行guitester.py,它应该在 Windows 上打开一个看起来像这样的应用程序:

小贴士
你可能还希望开始收集一些矢量文件和栅格文件,以便稍后使用应用程序进行测试。一个获取这些文件的好地方是www.naturalearthdata.com/,它们都在相同的 WGS84 坐标系中。
摘要
到目前为止,我们已经完成了应用程序的基础部分,目前它功能有限,但已经准备好随着我们继续添加新的地理空间功能而进一步扩展。你学会了创建一个坚实的工具包基础,包括工具和控件,我们可以使用这些工具和控件在一个独立且灵活的构建模块中构建我们的 GUI,其中一些将在后面的章节中扩展。在我们可以说我们有一个功能齐全的 GIS 应用程序之前,主要缺少的部分是在我们的地图控件中可视化数据。这就是我们在下一章要解决的问题。
第四章。渲染我们的地理数据
这章可能是本书中最有趣的一章之一。数据的地形可视化是 GIS 应用程序的核心功能之一,无论是用作探索辅助工具还是用于制作地图。学习地形可视化应该在不同层面上都是有益的。在本章中,你将学习以下内容:
-
将渲染过程分解为一系列一个或多个专题图层的渲染
-
根据视图范围和缩放级别实现矢量数据和栅格数据的基本图形渲染
-
将这些渲染连接到我们的视觉用户界面,允许交互式地图可视化
渲染
在 GIS 中的典型用法是向应用程序添加一个或多个地理数据源或图层,然后它立即在地图窗口中渲染。在 第三章,设计应用程序的视觉外观中,我们将它设置为 MapView 小部件。尽管 MapView 小部件负责在交互式 GUI 中显示地图,但我们希望将实际的渲染逻辑分离到一个单独的模块中。这样,如果用户想要的话,也可以通过编码批量生成地图渲染。
通常,图形渲染是通过使用用户的硬件图形能力在屏幕上绘制来最有效地完成的。然而,Tkinter 的屏幕绘制能力(Tkinter Canvas 小部件)可能很慢,如果绘制太多项目,会很快耗尽内存,并且只能产生粗糙的锯齿状图形,没有抗锯齿平滑。我们改用将图形绘制到虚拟图像上的方法,然后将该图像发送到 Tkinter 进行显示。这会在渲染和显示之间产生轻微的延迟,并且不如使用图形硬件快;然而,它几乎与现有 GIS 软件的速度和质量相当,并且比 Tkinter 默认设置好得多。
安装 PyAgg
在我们开始之前,我们需要安装我们将要使用的图形渲染包,这个包叫做 PyAgg,由作者创建。PyAgg 是围绕 Fredrik Lundh 的 Python aggdraw 绑定和 Anti-Grain Geometry C++ 库的高级便利包装。与 Matplotlib 或 Mapnik 等其他流行的渲染库相比,PyAgg 非常轻量,仅约 2 MB,因为它包含了预编译的必要文件,所以不需要复杂的安装步骤。PyCairo 是另一个轻量级图形库,尽管它具有更丰富的功能集,包括线连接、线帽和渐变,但它绘制具有许多顶点的大对象时速度非常慢。因此,我们选择 PyAgg,因为它轻量、速度快,并且具有方便的高级 API。
现在按照以下步骤进行安装:
-
在 Windows 命令行中,输入
C:/Python27/Scripts/pip install pyagg -
如果出于某种原因这不起作用,你可以从
github.com/karimbahgat/PyAgg下载 ZIP 文件,并将其提取到site-packages文件夹中 -
通过在 Python 壳中输入
import pyagg来测试它是否正确导入注意
如果你想尝试其他渲染库,Matplotlib 在其网站上提供了一个易于使用的 Windows 安装程序。你应该将其与Descartes结合使用,以将地理特征转换为 Matplotlib 可以渲染的对象,通过命令行安装 Descartes,即使用 pip install Descartes。
对于 Mapnik,据我所知没有预编译版本,因此你将不得不自己编译它,按照
wiki.openstreetmap.org/wiki/Mapnik/Installation上的说明进行。如果你想要尝试PyCairo,你可以在
www.lfd.uci.edu/~gohlke/pythonlibs/#pycairo获取 Windows 的预编译 wheel 文件。
现在必要的图形库已经安装,我们在pythongis文件夹的根目录下创建一个名为renderer.py的模块。通过以下导入来初始化它:
import random
import pyagg
import PIL, PIL.Image
为了使其对我们的顶级pythongis包可用,只需从pythongis/__init__.py内部导入它:
from . import renderer
图层序列
我们 GIS 应用程序中渲染的基本思想是,我们定义了一系列应该一起可视化的地图图层,例如国家、城市和高速公路。为了方便起见,我们将这些图层集合到一个可迭代的LayerGroup类中,它具有添加或删除图层的方法,以及一个用于移动和更改这些图层绘制顺序的方法。请注意,它可以持有对一个或多个连接的地图小部件的引用,使其可以作为分割视图类型地图应用程序的中心图层存储库。在renderer.py内部,编写以下代码:
class LayerGroup:
def __init__(self):
self.layers = list()
self.connected_maps = list()
def __iter__(self):
for layer in self.layers:
yield layer
def add_layer(self, layer):
self.layers.append(layer)
def move_layer(self, from_pos, to_pos):
layer = self.layers.pop(from_pos)
self.layers.insert(to_pos, layer)
def remove_layer(self, position):
self.layers.pop(position)
def get_position(self, layer):
return self.layers.index(layer)
MapCanvas 绘图器
接下来,我们需要一种方法将地图图层组合成一个最终的复合地图图像。为此,我们创建了一个MapCanvas类,它是 PyAgg 的Canvas类的包装器。MapCanvas类通过要求每个图层将自己渲染到图像上(带有透明背景),然后按照正确的顺序将它们叠加在一起来创建最终的渲染。由于每个图层都有独立的图像渲染,因此可以非常快速地重新排序或删除图层,而无需重新绘制所有图层。
在图像上叠加层是一回事,但我们如何知道哪些层要显示,或者它们在绘图画布上的具体位置呢?要做到这一点,我们需要将我们的地理空间数据的坐标转换成图像的像素坐标,这实际上并不比在图上绘制任意数据值有太大区别。通常在二维计算机图形学中,从一个坐标系转换到另一个坐标系是通过将每个 x 和 y 坐标与一些预先计算的数字相乘来完成的,这些数字被称为仿射变换系数。然而,得到这些系数并不立即直观,需要一点矩阵数学知识。
PyAgg 让我们的工作变得更简单,这也是我们选择使用它的主要原因之一。使用 custom_space 方法,PyAgg 允许我们告诉 Canvas 实例,它所绘制的图像是给定矩形真实世界空间的一个表示。这个空间由坐标的边界框定义,以便所有用于渲染的数据都相对于该坐标系放置,只绘制那些在其边界内的部分。PyAgg 然后使用那个边界框在幕后计算变换系数,在 Sean Gillies 的仿射模块的帮助下。作为对我们有用的另一个特性,PyAgg 允许锁定请求的视图范围的宽高比,使其与画布图像本身的宽高比相同,以避免地理数据变形或拉伸。请参考以下图示:

在启动时,在添加任何数据之前,我们使用 geographic_space() 将 MapCanvas 类的默认坐标空间设置为,它实际上是对 custom_space() 的封装,使用 [-180, 90, 180, -90] 作为边界(这是未投影数据的标准经纬度坐标系)并强制保持宽高比。通过简单地改变坐标空间的边界,MapCanvas 类可以用来渲染任何地理数据,无论其坐标系或 CRS 如何。这样,我们可以通过修改绘图变换系数来创建缩放或平移地图的效果。为此,我们利用 PyAgg 方便的缩放方法,这些方法允许我们用人类可以理解的方式指定我们想要如何缩放或平移绘图变换。
然而,一个挑战是在渲染定义在不同坐标参考系统(CRS)中的数据层时,因为这些数据不会像预期的那样对齐。在 GIS 中,通常的解决方案是在单个公共 CRS 中即时重投影所有地理数据。然而,在地理 CRS 之间进行转换涉及广泛的参数和对地球形状以及投影类型的假设,这使得它比我们之前的仿射变换更复杂。出于这些原因以及其他原因,我们的应用程序将不会处理 CRS 重投影。因此,我们 MapCanvas 类的主要限制是需要所有数据都在相同的 CRS 中,以便正确叠加。我们简要地回到第八章(ch08.html "第八章. 展望未来")的主题,展望未来,以及添加此类功能的方法。以下是 MapCanvas 类的代码:
class MapCanvas:
def __init__(self, layers, width, height, background=None, *args, **kwargs):
# remember and be remembered by the layergroup
self.layers = layers
layers.connected_maps.append(self)
# create the drawer with a default unprojected lat-long coordinate system
self.drawer = pyagg.Canvas(width, height, background)
self.drawer.geographic_space()
self.img = self.drawer.get_image()
def pixel2coord(self, x, y):
return self.drawer.pixel2coord(x, y)
# Map canvas alterations
def offset(self, xmove, ymove):
self.drawer.move(xmove, ymove)
def resize(self, width, height):
self.drawer.resize(width, height, lock_ratio=True)
self.img = self.drawer.get_image()
# Zooming
def zoom_bbox(self, xmin, ymin, xmax, ymax):
self.drawer.zoom_bbox(xmin, ymin, xmax, ymax)
def zoom_factor(self, factor, center=None):
self.drawer.zoom_factor(factor, center=center)
def zoom_units(self, units, center=None):
self.drawer.zoom_units(units, center=center)
# Drawing
def render_one(self, layer):
if layer.visible:
layer.render(width=self.drawer.width,
height=self.drawer.height,
coordspace_bbox=self.drawer.coordspace_bbox)
self.update_draworder()
def render_all(self):
for layer in self.layers:
if layer.visible:
layer.render(width=self.drawer.width,
height=self.drawer.height,
coordspace_bbox=self.drawer.coordspace_bbox)
self.update_draworder()
def update_draworder(self):
self.drawer.clear()
for layer in self.layers:
if layer.visible:
self.drawer.paste(layer.img)
self.img = self.drawer.get_image()
def get_tkimage(self):
# Special image format needed by Tkinter to display it in the GUI
return self.drawer.get_tkimage()
单个层的渲染
之前描述的 MapCanvas 类负责定义一个公共坐标系并组合其层的图像,但不负责任何实际的绘制。我们将这项任务留给单个层类,一个用于 vector,另一个用于 raster。
向量层
向量数据的渲染相当简单。我们只需在 VectorData 类周围创建一个 VectorLayer 实例,并可选地使用关键字参数决定其几何形状的一些样式方面。在样式选项阶段,我们允许所有被样式的特征以相同的方式进行样式化。
小贴士
亲自尝试,你可能希望扩展此功能,以便根据其属性值对单个特征或特征组进行样式化。这将允许你可视化数据如何在空间中流动。
为了渲染自身,向量层在其父类 MapCanvas 的相同图像尺寸上创建一个 PyAgg Canvas,背景为透明。为了确保它只绘制其父类 MapCanvas 应该看到的那些数据部分,我们需要设置 coordspace_bbox 参数为 MapCanvas 类的边界框。该层通过 custom_space() 方法将此信息传递给其 Canvas 实例,以便 PyAgg 可以使用矩阵数学计算正确的绘图变换系数。
当涉及到绘制每个特征时,PyAgg 及其底层的 aggdraw 模块有不同的绘图方法,对坐标的格式有不同的要求。由于我们的几何可以是点、线或多边形,并且存储在 GeoJSON 格式的字典中,我们需要将我们的 GeoJSON 格式转换为 PyAgg 期望的格式。例如,GeoJSON 多边形是一个坐标序列列表,第一个是外部,所有随后的都是孔洞;然后可以将这些信息发送到 PyAgg 的draw_polygon方法,并使用它期望的参数。我们不必学习整个 GeoJSON 格式来正确解析数据并调用正确的方法,PyAgg 的Canvas类可以在draw_geojson方法中为我们完成这些操作。绘制后,渲染的图像会被记住并可供MapCanvas访问:
class VectorLayer:
def __init__(self, data, **options):
self.data = data
self.visible = True
self.img = None
# by default, set random style color
rand = random.randrange
randomcolor = (rand(255), rand(255), rand(255), 255)
self.styleoptions = {"fillcolor": randomcolor}
# override default if any manually specified styleoptions
self.styleoptions.update(options)
def render(self, width, height, coordspace_bbox):
drawer = pyagg.Canvas(width, height, background=None)
drawer.custom_space(*coordspace_bbox)
# get features based on spatial index, for better speeds when zooming
if not hasattr(self.data, "spindex"):
self.data.create_spatial_index()
spindex_features = self.data.quick_overlap(coordspace_bbox)
# draw each as geojson, using same style options for all features
for feat in spindex_features:
drawer.draw_geojson(feat.geometry, **self.styleoptions)
self.img = drawer.get_image()
栅格层
以类似的方式,渲染栅格数据是通过创建一个RasterLayer类来完成的。在自身渲染时,要考虑到栅格网格中的每个单元格在地理空间中都有一个精确的位置和矩形区域。为了将这些单元格坐标从栅格空间转换为图像空间以进行可视化,RasterLayer类必须知道父MapCanvas类的坐标视图范围,并确定每个栅格单元格应该放置在哪些边界内。
幸运的是,我们之前已经为RasterData类提供了一个执行此类网格转换的方法,即利用 PIL 的 quad 转换技术的positioned方法。使用此方法,RasterLayer类根据其父MapCanvas类的大小指定要返回的数据的宽度和高度,并且只包括位于MapCavas类坐标系统边界内的栅格部分。
由于我们的RasterData类的数据结构基于 PIL 图像,它只需将所有波段图像组合在一起以创建一个灰度或 RGB 图像,即可添加到MapCanvas类中进行可视化。positioned方法还会转换并返回RasterLayer类使用的nodata掩码,该掩码用于使缺失值透明。
小贴士
目前,我们不允许自定义用于可视化栅格的颜色,但如果您想添加此功能,使用 PIL 对颜色调色板的支持应该很容易。
class RasterLayer:
def __init__(self, data, **options):
self.data = data
self.styleoptions = dict(**options)
self.visible = True
self.img = None
def render(self, width, height, coordspace_bbox):
# position in space
positioned,mask = self.data.positioned(width, height, coordspace_bbox)
# combine all data bands into one image for visualizing
if len(positioned.bands) == 1:
# greyscale if one band
band1 = positioned.bands[0]
img = band1.img.convert("RGB")
else:
# rgb of first three bands
bands = [band.img for band in positioned.bands[:3] ]
img = PIL.Image.merge("RGB", bands)
# make edge and nodata mask transparent
img.putalpha(mask)
# final
self.img = img
交互式渲染我们的地图
现在我们有了将多个图层组合成渲染地图图像的方法,我们就可以进入更令人兴奋的部分,即如何在我们的应用程序中以交互式方式立即实现这一点。
将 MapView 连接到渲染器
在我们将一系列图层渲染到地图图像上之后,必须将此图像发送到我们的应用程序并显示出来,以便立即获得反馈。这个任务是由我们在第三章中创建的 MapView 小部件完成的,设计应用程序的视觉外观。在构建我们的应用程序时,我们的想法是,我们只需要担心创建这个可视的 MapView 小部件;幕后,MapView 将负责创建自己的MapCanvas渲染器来完成实际工作。由于MapCanvas类需要 LayerGroup 来管理其图层,我们将在app/toolkit/map.py中创建一个 MapView 方法来分配一个 LayerGroup:
def assign_layergroup(self, layergroup):
self.layers = layergroup
然后,我们在 MapView 的__init__方法中添加了这两个组件的链接作为额外的代码。由于渲染器在创建之前需要像素宽度和高度,我们安排 MapView 在启动后不久创建它(因为 Tkinter 在启动前不知道各种小部件需要多少空间):
# Assign a renderer just after startup, because only then can one know the required window size
def on_startup():
# create renderer
width, height = self.winfo_width(), self.winfo_height()
self.renderer = pg.MapCanvas(self.layers, width, height)
# link to self
self.renderer.mapview = self
# fill with blank image
self.tkimg = self.renderer.get_tkimage()
self.image_on_canvas = self.create_image(0, 0, anchor="nw", image=self.tkimg )
self.after(10, on_startup)
请求渲染地图
当 MapView 小部件想要渲染包含所有可见图层的整个新地图时,它会调用此方法,并在单独的线程中这样做,以避免在等待结果时冻结应用程序。它还会更新状态栏上的活动状态,并根据新的缩放级别设置水平比例状态。之后,它必须更新放置在可查看 Tkinter Canvas 上的图像:
def threaded_rendering(self):
# perform render/zoom in separate thread
self.statusbar.task.start("Rendering layers...")
pending = dispatch.request_results(self.renderer.render_all)
def finish(result):
if isinstance(result, Exception):
popup_message(self, "Rendering error: " + str(result) )
else:
# update renderings
self.coords(self.image_on_canvas, 0, 0) # always reanchor rendered image nw at 0,0 in case of panning
self.update_image()
# display zoom scale
self.statusbar.zoom.set_text("1:"+str(self.renderer.drawer. coordspace_units) )
self.statusbar.task.stop()
dispatch.after_completion(self, pending, finish)
def update_image(self):
self.tkimg = self.renderer.get_tkimage()
self.itemconfig(self.image_on_canvas, image=self.tkimg )
按比例调整地图大小以适应窗口大小
如果用户更改应用程序窗口大小为原始启动大小,我们需要相应地调整 MapView 的渲染器大小。我们告诉它只在用户停止调整窗口大小后一秒内调整大小,因为 Tkinter 的调整大小事件在过程中会连续触发。在这种情况下,重要的是坐标系统会相应地改变,以映射新的图像尺寸;幸运的是,我们的 PyAgg Canvas 在调整大小时会自动更新并锁定绘图变换的纵横比:
# Schedule resize map on window resize
self.last_resized = None
def resizing(event):
# record resize time
self.last_resized = time.time()
# schedule to check if finished resizing after x millisecs
self.after(300, process_if_finished)
def process_if_finished():
# only if x time since last resize event
if time.time() - self.last_resized > 0.3:
width, height = self.winfo_width(), self.winfo_height()
self.renderer.resize(width, height)
self.threaded_rendering()
self.bind("<Configure>", resizing)
LayersPane 作为一个图层组
在我们有了能够渲染的基本地图小部件后,我们继续向地图添加数据,然后我们可以在应用程序的图层面板中查看这些数据。LayersPane 小部件仅仅是其连接的 LayerGroup 类中图层序列的视觉表示。因此,app/toolkit/layers.py文件中的 LayersPane 类需要一个方法将其绑定到 LayerGroup:
def assign_layergroup(self, layergroup):
self.layers = layergroup
添加图层
现在,我们将在app/toolkit/layers.py文件中的 LayersPane 类中创建一个add_layer方法。为了使其灵活,我们允许它从文件路径或已加载的数据对象中添加一个图层。
如果它检测到一个文件路径,它首先运行一个 from_filepath 函数,其中它决定是否创建一个矢量或栅格数据类,告诉我们的调度模块使用这个数据类在后台线程中加载文件路径,并安排我们的应用程序每 100 毫秒检查一次结果队列,以查看是否加载完成。
一旦加载或提供了一个已加载的数据对象,它就会直接使用 from_loaded() 函数添加图层。这创建了一个能够渲染自己的 VectorLayer 或 RasterLayer,并在 LayersPane 中添加了一个对右键事件做出响应的图层表示(更多内容将在下一节中介绍),并要求调度将图层渲染为图像并更新与之连接的 MapView 小部件。如果新图层是当前在 LayersPanel 中加载的唯一图层,那么我们将自动缩放到其边界框,以便用户可以立即查看数据。
这里是代码:
def add_layer(self, filepath_or_loaded, name=None, **kwargs):
def from_filepath(filepath):
if filepath.lower().endswith((".shp",".geojson",".json")):
func = pg.vector.data.VectorData
args = (filepath,)
elif filepath.lower().endswith((".asc",".ascii",
".tif",".tiff",".geotiff",
".jpg",".jpeg",
".png",".bmp",".gif")):
func = pg.raster.data.RasterData
args = (filepath,)
else:
popup_message(self, "Fileformat not supported\n\n" + filepath )
return
self.statusbar.task.start("Loading layer from file...")
pending = dispatch.request_results(func, args, kwargs)
def finish(loaded):
if isinstance(loaded, Exception):
popup_message(self, str(loaded) + "\n\n" + filepath )
else:
from_loaded(loaded)
self.statusbar.task.stop()
dispatch.after_completion(self, pending, finish)
def from_loaded(loaded):
# add the data as a rendering layer
if isinstance(loaded, pg.vector.data.VectorData):
renderlayer = pg.renderer.VectorLayer(loaded)
elif isinstance(loaded, pg.raster.data.RasterData):
renderlayer = pg.renderer.RasterLayer(loaded)
self.layers.add_layer(renderlayer)
# list a visual representation in the layerspane list
listlayer = LayerItem(self.layersview, renderlayer=renderlayer, name=name)
listlayer.namelabel.bind("<Button-3>", self.layer_rightclick)
listlayer.pack(fill="x", side="bottom")
# render to and update all mapcanvases connected to the layergroup
for mapcanvas in self.layers.connected_maps:
if len(mapcanvas.layers.layers) == 1:
# auto zoom to layer if it is the only layer
mapcanvas.zoom_bbox(*loaded.bbox)
func = mapcanvas.render_one
args = [renderlayer]
self.statusbar.task.start("Rendering layer...")
pending = dispatch.request_results(func, args)
def finish(loaded):
if isinstance(loaded, Exception):
popup_message(self, "Rendering error: " + str(loaded) )
else:
mapcanvas.mapview.update_image()
self.statusbar.task.stop()
dispatch.after_completion(self, pending, finish)
# load from file or go straight to listing/rendering
if isinstance(filepath_or_loaded, (str,unicode)):
from_filepath(filepath_or_loaded)
else:
from_loaded(filepath_or_loaded)
在 LayersPane 小部件中编辑图层
现在我们可以向 LayersPane 添加图层,我们还想能够对图层进行一些操作。图层表示为一个 LayerItem 小部件,我们尚未定义它。我们在右侧为 LayerItem 添加一个删除按钮,并在左侧添加一个复选框来切换其可见性,如图所示:

删除按钮将需要一个图标,所以让我们先获取一个:
-
前往图标网站,例如 www.iconarchive.com 或
www.flaticon.com。 -
搜索并选择您喜欢的图标。
-
将其保存为
delete_layer.png,大小为 32 像素,并将其放置在您的app/icons文件夹中。
我们还定义了如何重命名图层的名称,它暂时在图层名称显示上方添加一个 Tkinter 输入小部件,以便用户可以更改名称并按 Return 键接受或按 ESC 键取消。现在使用以下代码在 app/toolkit/layers.py 中创建 LayerItem 类:
class LayerItem(tk.Frame):
def __init__(self, master, renderlayer, name=None, **kwargs):
# get theme style
style = style_layeritem_normal.copy()
style.update(kwargs)
# Make this class a subclass of tk.Frame and add to it
tk.Frame.__init__(self, master, **style)
self.layerspane = self.master.master
self.statusbar = self.layerspane.statusbar
# Create a frame to place main row with name etc
self.firstrow = tk.Frame(self, **style)
self.firstrow.pack(side="top", fill="x", expand=True)
# Create the visibility check box
var = tk.BooleanVar(self)
self.checkbutton = tk.Checkbutton(self.firstrow, variable=var, offvalue=False, onvalue=True, command=self.toggle_visibility, **style_layercheck)
self.checkbutton.var = var
self.checkbutton.pack(side="left")
self.checkbutton.select()
# Create Delete button to the right
self.deletebutton = IconButton(self.firstrow, padx=2, relief="flat", command=self.delete)
self.deletebutton.set_icon("delete_layer.png")
self.deletebutton.pack(side="right")
# Create the layername display
self.renderlayer = renderlayer
if name: layername = name
elif self.renderlayer.data.filepath:
layername = os.path.split(self.renderlayer.data.filepath)[-1]
else: layername = "Unnamed layer"
self.namelabel = tk.Label(self.firstrow, text=layername, **style_layername_normal)
self.namelabel.pack(side="left", fill="x", expand=True)
def toggle_visibility(self):
self.layerspane.toggle_layer(self)
def delete(self):
self.layerspane.remove_layer(self)
def ask_rename(self):
# place entry widget on top of namelabel
nameentry = tk.Entry(self)
nameentry.place(x=self.namelabel.winfo_x(), y=self.namelabel.winfo_y(), width=self.namelabel.winfo_width(), height=self.namelabel.winfo_height())
# set its text to layername and select all text
nameentry.insert(0, self.namelabel["text"])
nameentry.focus()
nameentry.selection_range(0, tk.END)
# accept or cancel change via keypress events
def finish(event):
newname = nameentry.get()
nameentry.destroy()
self.namelabel["text"] = newname
def cancel(event):
nameentry.destroy()
nameentry.bind("<Return>", finish)
nameentry.bind("<Escape>", cancel)
LayerItem 类的删除按钮和前一个代码中的可见性复选框都调用了父级 LayersPane 中的方法来完成工作,因为 LayersPane 的连接 MapCanvas 需要在之后进行更新。因此,让我们将这些方法添加到 LayersPane 中。我们还需要一种方法来指定在 LayersPane 中右键点击任何图层时要运行的功能:
def toggle_layer(self, layeritem):
# toggle visibility
if layeritem.renderlayer.visible == True:
layeritem.renderlayer.visible = False
elif layeritem.renderlayer.visible == False:
layeritem.renderlayer.visible = True
# update all mapcanvas
for mapcanvas in self.layers.connected_maps:
mapcanvas.update_draworder()
mapcanvas.mapview.update_image()
def remove_layer(self, layeritem):
# remove from rendering
layerpos = self.layers.get_position(layeritem.renderlayer)
self.layers.remove_layer(layerpos)
for mapcanvas in self.layers.connected_maps:
mapcanvas.update_draworder()
mapcanvas.mapview.update_image()
# remove from layers list
layeritem.destroy()
def bind_layer_rightclick(self, func):
self.layer_rightclick = func
点击并拖动以重新排列图层顺序
一个稍微复杂一些的程序是让用户通过点击并拖动将 LayersPane 中 LayerItems 的绘制顺序重新排列到新位置。这是任何 GIS 软件分层性质的一个基本功能,但不幸的是,Tkinter GUI 框架没有为我们提供任何拖放快捷方式,因此我们必须从头开始构建。我们使其保持简单,并且一次只允许移动一个图层。
要在列表中重新排列图层,我们首先需要监听用户点击 LayerItem 的事件。在这样的事件中,我们记住我们想要移动的图层的位置,并将光标更改为指示正在进行拖放操作。当用户释放鼠标点击时,我们遍历所有 LayerItem 小部件的屏幕坐标,以检测鼠标释放时的图层位置。请注意,渲染在所有其他图层之上的图层的索引位置是列表中的第一个,但在 LayerGroup 的图层序列中是最后一个。我们在 LayerItem 的__init__方法中添加了这种监听行为:
def start_drag(event):
self.dragging = event.widget.master.master
self.config(cursor="exchange")
def stop_drag(event):
# find closest layerindex to release event
def getindex(layeritem):
return self.layerspane.layers.get_position(layeritem.renderlayer)
goingdown = event.y_root - (self.dragging.winfo_rooty() + self.dragging.winfo_height() / 2.0) > 0
if goingdown:
i = len(self.layerspane.layersview.winfo_children())
for layeritem in sorted(self.layerspane.layersview.winfo_children(), key=getindex, reverse=True):
if event.y_root < layeritem.winfo_rooty() + layeritem.winfo_height() / 2.0:
break
i -= 1
else:
i = 0
for layeritem in sorted(self.layerspane.layersview.winfo_children(), key=getindex):
if event.y_root > layeritem.winfo_rooty() - layeritem.winfo_height() / 2.0:
break
i += 1
# move layer
frompos = self.layerspane.layers.get_position(self.dragging.renderlayer)
if i != frompos:
self.layerspane.move_layer(frompos, i)
# clean up
self.dragging = None
self.config(cursor="arrow")
self.dragging = None
self.namelabel.bind("<Button-1>", start_drag)
self.namelabel.bind("<ButtonRelease-1>", stop_drag)
在用户与 LayersPane 交互以告知其移动图层位置后,我们告诉其关联的 LayerGroup 根据“从”和“到”位置重新排列图层顺序。然后我们告诉所有连接到该 LayerGroup 的 MapCanvas 更新它们的绘制顺序和显示的图像。我们必须在LayersPane类中定义此方法:
def move_layer(self, fromindex, toindex):
self.layers.move_layer(fromindex, toindex)
for mapcanvas in self.layers.connected_maps:
mapcanvas.update_draworder()
mapcanvas.mapview.update_image()
self.update_layerlist()
缩放地图图像
到目前为止,我们可以向地图添加和删除图层,并重新排列它们的顺序,但我们仍然不能与地图本身交互。这正是自己制作应用程序的一大优点之一。用户可能会发现,与现有的 GIS 软件相比,他们必须在这两种地图交互模式之间做出选择:一种是平移模式,点击并拖动鼠标会相应地移动地图;另一种是矩形缩放模式,点击并拖动定义要缩放的区域。
在这两种模式之间切换不利于地图探索,而地图探索通常是一个更动态和迭代的进程,涉及在 Google Maps 中使用时同时使用缩放和平移。现在我们有了决定权,让我们通过分别用双击和点击拖动来控制它们,将缩放和平移结合起来。
地图的实际缩放是通过让 MapCanvas 在给定的缩放级别重新绘制地图来完成的。我们将一个以鼠标为中心的 2 倍缩放因子方法绑定到用户在地图上双击的事件上。当用户停止点击后,我们给这种缩放一个三分之一的秒延迟,这样用户就可以连续多次双击以实现更大的缩放,而不会使应用程序渲染多个增量缩放图像过度繁忙。每次缩放级别改变时,我们也会要求更新状态栏的缩放单位比例,这是由 PyAgg 渲染画布提供的。我们添加的所有这些监听行为都在app/toolkit/map.py文件中的 MapView 的__init__方法内部:
# Bind interactive zoom events
def doubleleft(event):
self.zoomfactor += 1
canvasx,canvasy = self.canvasx(event.x),self.canvasy(event.y)
self.zoomcenter = self.renderer.pixel2coord(canvasx, canvasy)
self.zoomdir = "in"
# record zoom time
self.last_zoomed = time.time()
# schedule to check if finished zooming after x millisecs
self.after(300, zoom_if_finished)
def doubleright(event):
self.zoomfactor += 1
canvasx,canvasy = self.canvasx(event.x),self.canvasy(event.y)
self.zoomcenter = self.renderer.pixel2coord(canvasx, canvasy)
self.zoomdir = "out"
# record zoom time
self.last_zoomed = time.time()
# schedule to check if finished zooming after x millisecs
self.after(300, zoom_if_finished)
def zoom_if_finished():
if time.time() - self.last_zoomed >= 0.3:
if self.zoomdir == "out":
self.zoomfactor *= -1
self.renderer.zoom_factor(self.zoomfactor, center=self.zoomcenter)
self.threaded_rendering()
# reset zoomfactor
self.zoomfactor = 1
self.last_zoomed = None
self.bind("<Double-Button-1>", doubleleft)
self.bind("<Double-Button-3>", doubleright)
地图平移和一次性矩形缩放
滚动地图相对简单,因为渲染的地图图像只是一个放置在 Tkinter 可滚动 Canvas 小部件内的图像。渲染的地图图像始终放置在 Tkinter Canvas 的 [0,0] 坐标,即左上角,但当我们平移地图时,我们会让图像开始跟随鼠标。在我们松开鼠标后,渲染器开始通过偏移 MapCanvas 的 PyAgg 坐标系并重新渲染地图来渲染新的地图。我们还允许使用这些点击和释放事件来执行传统的矩形缩放,并配合 Tkinter 内置的画布矩形绘制视觉引导。这种矩形缩放模式应该只作为一次事件,默认回到平移模式,因为矩形缩放相对很少需要。为了指示我们处于矩形缩放模式,我们还将在鼠标悬停在 MapView 小部件上时,将光标替换为类似放大镜图标的东西,因此您需要找到并保存一个 rect_zoom.png 图像到 app/icons 目录。将鼠标移到地图上通常也应该在状态栏中显示鼠标坐标。我们在 app/toolkit/map.py 文件中的 MapView 小部件的 __init__ 方法中定义了这一点:
def mousepressed(event):
if self.last_zoomed: return
self.mousepressed = True
self.startxy = self.canvasx(event.x), self.canvasy(event.y)
if self.mouse_mode == "zoom":
startx,starty = self.startxy
self.rect = self.create_rectangle(startx, starty, startx+1, starty+1, fill=None)
def mousemoving(event):
if self.statusbar:
# mouse coords
mouse = self.canvasx(event.x), self.canvasy(event.y)
xcoord,ycoord = self.renderer.pixel2coord(*mouse)
self.statusbar.mouse.set_text("%3.8f , %3.8f" %(xcoord,ycoord) )
if self.mouse_mode == "pan":
if self.mousepressed:
startx,starty = self.startxy
curx,cury = self.canvasx(event.x), self.canvasy(event.y)
xmoved = curx - startx
ymoved = cury - starty
self.coords(self.image_on_canvas, xmoved, ymoved) # offset the image rendering
elif self.mouse_mode == "zoom":
curx,cury = self.canvasx(event.x), self.canvasy(event.y)
self.coords(self.zoomicon_on_canvas, curx, cury)
if self.mousepressed:
startx,starty = self.startxy
self.coords(self.rect, startx, starty, curx, cury)
def mousereleased(event):
if self.last_zoomed: return
self.mousepressed = False
if self.mouse_mode == "pan":
startx,starty = self.startxy
curx,cury = self.canvasx(event.x), self.canvasy(event.y)
xmoved = int(curx - startx)
ymoved = int(cury - starty)
if xmoved or ymoved:
# offset image rendering
self.renderer.offset(xmoved, ymoved)
self.threaded_rendering()
elif self.mouse_mode == "zoom":
startx,starty = self.startxy
curx,cury = self.canvasx(event.x), self.canvasy(event.y)
self.coords(self.rect, startx, starty, curx, cury)
# disactivate rectangle selector
self.delete(self.rect)
self.event_generate("<Leave>") # fake a mouseleave event to destroy icon
self.mouse_mode = "pan"
# make the zoom
startx,starty = self.renderer.drawer.pixel2coord(startx,starty)
curx,cury = self.renderer.drawer.pixel2coord(curx,cury)
bbox = [startx, starty, curx, cury]
self.renderer.zoom_bbox(*bbox)
self.threaded_rendering()
def mouseenter(event):
if self.mouse_mode == "zoom":
# replace mouse with zoomicon
self.zoomicon_tk = icons.get("zoom_rect.png", width=30, height=30)
self.zoomicon_on_canvas = self.create_image(event.x, event.y, anchor="center", image=self.zoomicon_tk )
self.config(cursor="none")
def mouseleave(event):
if self.mouse_mode == "zoom":
# back to normal mouse
self.delete(self.zoomicon_on_canvas)
self.config(cursor="arrow")
def cancel(event):
if self.mouse_mode == "zoom":
self.event_generate("<Leave>") # fake a mouseleave event to destroy icon
self.mouse_mode = "pan"
if self.mousepressed:
self.delete(self.rect)
# bind them
self.bind("<Button-1>", mousepressed, "+")
self.bind("<Motion>", mousemoving)
self.bind("<ButtonRelease-1>", mousereleased, "+")
self.bind("<Enter>", mouseenter)
self.bind("<Leave>", mouseleave)
self.winfo_toplevel().bind("<Escape>", cancel)
导航工具栏
为了激活一次性的矩形缩放,我们在 app/toolkit/toolbars.py 文件中创建了一个导航工具栏,该工具栏必须连接到 MapView,并给它一个按钮,该按钮简单地打开其连接的 MapView 的一次性缩放模式。在此过程中,我们还创建了一个工具栏按钮,用于缩放到 MapView 的 layergroup 中所有层的全局边界框。请记住找到并保存这两个新按钮的图标,zoom_rect.png 和 zoom_global.png。参见图表:

class NavigateTB(tk.Frame):
def __init__(self, master, **kwargs):
# get theme style
style = style_toolbar_normal.copy()
style.update(kwargs)
# Make this class a subclass of tk.Frame and add to it
tk.Frame.__init__(self, master, **style)
# Modify some options
self.config(width=80, height=40)
def assign_mapview(self, mapview):
mapview.navigation = self
self.mapview = mapview
# Add buttons
self.global_view = IconButton(self, text="zoom global", command=self.mapview.zoom_global)
self.global_view.set_icon("zoom_global.png", width=32, height=32)
self.global_view.pack(side="left", padx=2, pady=2)
self.zoom_rect = IconButton(self, text="zoom to rectangle", command=self.mapview.zoom_rect)
self.zoom_rect.set_icon("zoom_rect.png", width=32, height=32)
self.zoom_rect.pack(side="left", padx=2, pady=2)
实际的缩放调用被定义为 MapView 小部件的方法,在 app/toolkit/map.py 文件中:
def zoom_global(self):
layerbboxes = (layer.data.bbox for layer in self.renderer.layers)
xmins,ymins,xmaxs,ymaxs = zip(*layerbboxes)
globalbbox = [min(xmins), min(ymins), max(xmaxs), max(ymaxs)]
self.renderer.zoom_bbox(*globalbbox)
self.threaded_rendering()
def zoom_rect(self):
self.mouse_mode = "zoom"
self.event_generate("<Enter>")
def zoom_bbox(self, bbox):
self.renderer.zoom_bbox(*bbox)
self.threaded_rendering()
整合所有内容
我们现在已经定义了一个基本渲染应用的所有必要构建块。这些可以以许多不同的方式使用和组合。例如,如果您想,您可以构建一个应用程序,它有一个单独的 LayerGroup/LayersPane 与多个独立可缩放的 MapView 连接,以同时查看相同数据的不同位置。在这本书中,我们选择了更基本的桌面 GIS 外观。
让我们回到我们在 第三章 中创建的 GUI 类,设计应用程序的视觉外观,并在其启动阶段添加更多内容。首先,我们给 GUI 一个 LayerGroup 实例来保存我们的层,并将其链接到 MapView 和 LayersPane 小部件,以便它们可以在以后进行通信。
我们还需要一个按钮来添加数据层。有许多可能的位置可以放置这样一个重要的按钮,但就我们当前的应用程序而言,让我们将其放置在 LayersPane 小部件的标题栏中,以便将所有与层相关的内容逻辑上分组在一起。我们希望这个按钮有一个图标,所以让我们首先找到并保存一个合适的图标,将其命名为 add_layer.png 并保存在 app/icons 文件夹中。具体来说,我们想要创建一个添加层的按钮,将其与我们的图标关联,并将其放置在 LayersPane 标题栏的右侧。当按钮被点击时,它将运行一个 selectfiles 函数,该函数打开一个 Tkinter 文件选择对话框窗口,并将所有选定的文件作为新层添加。
从文件加载数据可能需要我们指定数据的正确文本编码。默认情况下,我们将其设置为 utf8,但用户应该能够在一个单独的数据设置窗口中自定义此和其他数据选项。我们将数据选项字典存储为 GUI 类的属性,并允许用户在设置窗口中更改它。这个设置窗口可以通过我们的 RunToolFrame 模板轻松定义。为了允许用户访问这个设置窗口,我们在添加层按钮旁边添加了一个数据设置按钮。像往常一样,找到并下载用于按钮的图标,命名为 data_options.png。
之后,让我们创建一个用于可视化的选项卡,给它一个按钮,以便将我们的 MapView 小部件的内容保存到图像文件中。记得找到并保存一个 save_image.png 文件,这样我们就可以给这个按钮添加一个图标。最后,我们添加了之前创建的导航工具栏,将其悬挂在 MapView 的上部。
现在我们将这段新代码添加到我们的 GUI 类的 __init__ 方法中,位于 app/builder.py 文件内:
# Create a layergroup that keeps track of all the loaded data
# ...so that all widgets can have access to the same data
self.layers = pg.renderer.LayerGroup()
# Assign layergroup to layerspane and mapview
self.layerspane.assign_layergroup(self.layers)
self.mapview.assign_layergroup(self.layers)
## Visualize tab
visitab = self.ribbon.add_tab("Visualize")
### (Output toolbar)
output = visitab.add_toolbar("Output")
def save_image():
filepath = asksaveasfilename()
self.mapview.renderer.img.save(filepath)
output.add_button(text="Save Image", icon="save_image.png",
command=save_image)
# Place add layer button in the header of the layerspane
def selectfiles():
filepaths = askopenfilenames()
for filepath in filepaths:
encoding = self.data_options.get("encoding")
self.layerspane.add_layer(filepath, encoding=encoding)
button_addlayer = IconButton(self.layerspane.header, command=selectfiles)
button_addlayer.set_icon("add_layer.png", width=27, height=27)
button_addlayer.pack(side="right", anchor="e", ipadx=3, padx=6, pady=3,)
# Place button for setting data options
self.data_options = {"encoding": "utf8"}
button_data_options = IconButton(self.layerspane.header)
button_data_options.set_icon("data_options.png", width=24, height=21)
button_data_options.pack(side="right", anchor="e", ipadx=5, ipady=3, padx=6, pady=3,)
# Open options window on button click
def data_options_window():
win = popups.RunToolWindow(self)
# assign status bar
win.assign_statusbar(self.statusbar)
# place option input for data encoding
win.add_option_input("Vector data encoding", valuetype=str,
argname="encoding", default=self.data_options.get("encoding"))
# when clicking OK, update data options
def change_data_options(*args, **kwargs):
"""
Customize settings for loading and saving data.
Vector data encoding: Common options include "utf8" or "latin"
"""
# update user settings
self.data_options.update(kwargs)
def change_data_options_complete(result):
# close window
win.destroy()
win.set_target_method("Changing data options", change_data_options)
win.set_finished_method(change_data_options_complete)
button_data_options["command"] = data_options_window
# Attach floating navigation toolbar inside mapwidget
self.navigation = NavigateTB(self.mapview)
self.navigation.place(relx=0.5, rely=0.03, anchor="n")
self.navigation.assign_mapview(self.mapview)
大概就是这样!你的应用程序现在应该已经准备好用于渲染地图数据了。运行 guitester.py,尝试添加一些数据并与地图进行交互。如果你一切都做得正确,并且根据你的数据,你的屏幕应该看起来像这样:

摘要
本章是一个基本的里程碑。我们基于可重新排列的层在 LayerGroup 中构建了一个工作地理渲染模块,创建了一个用于交互显示这些地图渲染的 MapView 小部件,制作了我们地图中层的可视化 LayersPane,并启用了 MapView 的交互式缩放和平移。
在遵循每个步骤之后,你现在应该拥有一个看起来和感觉像 GIS 数据检查应用程序的东西。当然,一个更复杂的 GIS 需要额外的功能,不仅用于检查数据,还用于管理和编辑数据——这正是我们接下来要做的。
第五章 管理地理数据
现在我们已经有一个运行中的探索性应用程序,我们可以继续开发一些更实用的日常功能。地理数据用户的一个常见任务是准备、清理、重构和组织数据。在本章中,你将执行以下操作:
-
创建一个窗口来检查每个层的基属性
-
为常用的管理任务构建一些便利函数,稍后将其添加到用户界面。这些函数如下:
-
当用户在 LayersPane 小部件中的每个层上右键单击时,可用的单个层操作(分割、几何清理和重采样)
-
在顶部功能区区域作为按钮提供的多个层的批量操作(合并和镶嵌)
-
-
分配对话框窗口以在运行每个工具时设置参数
创建管理模块
我们首先创建一个单独的子模块来包含功能,一个用于矢量,一个用于栅格。首先,创建vector/manager.py文件,并使用以下导入启动它:
import itertools, operator
from .data import *
接下来,创建raster/manager.py文件,如下所示:
import PIL, PIL.Image
要使这些管理模块对其各自的vector和raster父包可用,请将以下导入语句添加到vector/__init__.py和raster/__init__.py中:
import . import manager
检查文件
作为组织和管理个人文件的最基本方式,人们经常需要检查个人数据和加载层属性及细节。这些信息通常可以在一个单独的层选项窗口中找到。在本章的后面部分,我们将通过在层上右键单击并点击属性下的层特定右键功能子标题来使此窗口可访问。
我们为这种类型的窗口定义了一个模板类,它支持使用我们的功能区类进行标签窗口,并创建了一个方便的方法来以良好的格式添加信息。这是在app/dialogues.py模块中完成的。由于我们尚未设置app/dialogues.py的内容,我们还需要设置其导入和样式,如下面的代码片段所示:
import Tkinter as tk
import ScrolledText as tkst # a convenience module that ships with Tkinter
from .toolkit.popups import *
from .toolkit.ribbon import *
from .toolkit import theme
from . import icons
from .. import vector, raster
style_layeroptions_info = {"fg": theme.font1["color"],
"font": theme.font1["type"],
"relief": "flat"}
class LayerOptionsWindow(Window):
def __init__(self, master, **kwargs):
# Make this class a subclass of tk.Menu and add to it
Window.__init__(self, master, **kwargs)
# Make the top ribbon selector
self.ribbon = Ribbon(self)
self.ribbon.pack(side="top", fill="both", expand=True)
def add_info(self, tab, label, value):
row = tk.Frame(tab, bg=tab.cget("bg"))
row.pack(fill="x", anchor="n", pady=5, padx=5)
# place label
header = tk.Label(row, text=label, bg=tab.cget("bg"), **style_layeroptions_info)
header.pack(side="left", anchor="nw", padx=3)
# place actual info text
value = str(value)
info = tk.Entry(row, width=400, disabledbackground="white", justify="right", **style_layeroptions_info)
info.pack(side="right", anchor="ne", padx=3)
info.insert(0, value)
info.config(state="readonly")
return info
矢量和栅格数据通常具有非常不同的属性,因此我们为每个数据类型创建了一个单独的窗口。首先,对于矢量层:

这里是相同代码的示例:
class VectorLayerOptionsWindow(LayerOptionsWindow):
def __init__(self, master, layeritem, statusbar, **kwargs):
# Make this class a subclass of tk.Menu and add to it
LayerOptionsWindow.__init__(self, master, **kwargs)
self.layeritem = layeritem
###########
### GENERAL OPTIONS TAB
general = self.ribbon.add_tab("General")
# add pieces of info
self.source = self.add_info(general, "Source file: ", layeritem.renderlayer.data.filepath)
self.proj = self.add_info(general, "Projection: ", self.layeritem.renderlayer.data.crs)
self.bbox = self.add_info(general, "Bounding box: ", layeritem.renderlayer.data.bbox)
self.fields = self.add_info(general, "Attribute fields: ", layeritem.renderlayer.data.fields)
self.rows = self.add_info(general, "Total rows: ", len(layeritem.renderlayer.data))
###########
# Set starting tab
self.ribbon.switch(tabname="General")
然后,对于栅格层:

这里是相同代码的示例:
class RasterLayerOptionsWindow(LayerOptionsWindow):
def __init__(self, master, layeritem, statusbar, **kwargs):
# Make this class a subclass of tk.Menu and add to it
LayerOptionsWindow.__init__(self, master, **kwargs)
self.layeritem = layeritem
###########
### GENERAL OPTIONS TAB
general = self.ribbon.add_tab("General")
# add pieces of info
self.source = self.add_info(general, "Source file: ", layeritem.renderlayer.data.filepath)
self.proj = self.add_info(general, "Projection: ", self.layeritem.renderlayer.data.crs)
self.dims = self.add_info(general, "Dimensions: ", "%i, %i"%(self.layeritem.renderlayer.data.width,
self.layeritem.renderlayer.data.height))
self.bands = self.add_info(general, " Raster bands: ", "%i"%len(self.layeritem.renderlayer.data.bands))
self.transform = self.add_info(general, "Transform: ", self.layeritem.renderlayer.data.info)
self.bbox = self.add_info(general, "Bounding box: ", layeritem.renderlayer.data.bbox)
###########
# Set starting tab
self.ribbon.switch(tabname="General")
组织文件
传统上,在 GIS 应用程序中工作时,人们首先从各种组织网站中寻找希望使用的数据文件。理想情况下,人们将这些文件存储在本地计算机上逻辑上组织的文件夹结构中,然后可以从那里将数据加载到 GIS 应用程序中。在本节中,我们添加了帮助用户管理文件以及访问和修改基本文件内容的功能。
注意
对于一些在线可用的 GIS 数据类型和来源的精彩示例,请参阅freegisdata.rtwilson.com/上的列表。
向量数据
向量数据非常灵活;其类似于表格的数据结构意味着它可以在单个文件中包含关于广泛概念的数据,或者只包含关于非常特定概念的数据。对于实际应用来说,如果每个文件都精确地针对所需数据定制,那就更容易了,因为这些数据在应用程序中加载时是以图层的形式表示的。因此,有许多情况下,用户可能希望重新组织数据以更好地满足他们的需求。
在这里,我们将实现三个用于组织和维护向量数据的特定操作:分割、合并和清理。以下插图给出了每个操作的输入和输出的预览:

分割
例如,用户可能有一个将各种概念分组在一起的文件,但只对单独处理某些类型感兴趣。在这种情况下,只需为每个字段的唯一出现分割数据会更简单——这被称为分割。从数据结构的角度来看,这意味着将表格的高度切割成多个表格,以及它们相关的几何形状。我们方便地使用 Python 内置的sorted()和itertools.groupby()函数来完成这项工作。splitfields选项定义了一个要分割的字段名称列表,以便每个唯一值组合定义一个新的分割。因此,前往manager.py文件以处理向量数据,并编写以下代码:
def split(data, splitfields):
fieldindexes = [index for index,field in enumerate(data.fields)
if field in splitfields]
sortedfeatures = sorted(data, key=operator.itemgetter(*fieldindexes))
grouped = itertools.groupby(sortedfeatures, key=operator.itemgetter(*fieldindexes))
for splitid,features in grouped:
outfile = VectorData()
outfile.fields = list(data.fields)
for oldfeat in features:
outfile.add_feature(oldfeat.row, oldfeat.geometry)
yield outfile
合并
用户也可能遇到相反的情况,即希望将分散在多个文件中的多个数据文件组合在一起。这被称为合并操作。合并操作将多个表中的行堆叠成一个大的表,通常会增加空间覆盖范围,因为它导致几何形状的集合更大。此操作的输出属性表也水平扩展以包含其输入文件中的所有变量/字段。最后,请记住,VectorData实例只能包含一种类型的几何形状(点、线或多边形),因此尝试合并不同几何类型图层将导致错误。我们以以下方式实现它:
def merge(*datalist):
#make empty table
firstfile = datalist[0]
outfile = VectorData()
#combine fields from all files
outfields = list(firstfile.fields)
for data in datalist[1:]:
for field in data.fields:
if field not in outfields:
outfields.append(field)
outfile.fields = outfields
#add the rest of the files
for data in datalist:
for feature in data:
geometry = feature.geometry.copy()
row = []
for field in outfile.fields:
if field in data.fields:
row.append( feature[field] )
else:
row.append( "" )
outfile.add_feature(row, geometry)
#return merged file
return outfile
几何清理
地理数据可以来自非常广泛的来源,这意味着它们的完整性水平可能会有很大差异。例如,有许多规则规定了每种几何类型允许或不允许的内容,但并非所有数据生产者(包括软件和个人)都使用相同的规则或以相同程度遵循这些规则。如果数据损坏或未以预期的方式格式化,这可能会成为 GIS 处理、分析应用程序和编程库的问题。数据还可能包含不必要的垃圾信息,这些信息不会增加任何有用的内容(根据所需细节水平而定),从而使文件大小过大。因此,在收集数据时,几何清理可以作为第一步的一个有用功能。
为了做到这一点,我们创建了一个循环我们特征几何体的函数。借助 Shapely 库的帮助,我们修复“蝴蝶结”错误(仅限多边形),删除重复的点,并排除任何根据 GeoJSON 规范被认为无效的剩余几何体。容差参数可以设置为一个大于零的值以减小文件大小,但请注意,这会改变几何体的形状,并降低输出中的细节和精度水平。请参考以下代码:
def clean(data, tolerance=0):
# create new file
outfile = VectorData()
outfile.fields = list(data.fields)
# clean
for feat in data:
shapelyobj = feat.get_shapely()
# try fixing invalid geoms
if not shapelyobj.is_valid:
if "Polygon" in shapelyobj.type:
# fix bowtie polygons
shapelyobj = shapelyobj.buffer(0.0)
# remove repeat points (tolerance=0)
# (and optionally smooth out complex shapes, tolerance > 0)
shapelyobj = shapelyobj.simplify(tolerance)
# if still invalid, do not add to output
if not shapelyobj.is_valid:
continue
# write to file
geojson = shapelyobj.__geo_interface__
outfile.add_feature(feat.row, geojson)
return outfile
注意
更多关于多边形蝴蝶结错误的信息,请访问:
stackoverflow.com/questions/20833344/fix-invalid-polygon-python-shapely
栅格数据
您可能希望实现许多常见的栅格文件管理功能。在这里,我们只关注其中两个:拼接和重采样,如以下截图所示:

为了实现这些功能,我们将利用 PIL 库的图像处理功能。由于我们使用的是一个并非主要用于地理空间数据的图像库,以下代码应被视为高度实验性的,主要用于演示目的;您可能需要自行调试和改进这些方法。
注意
如果您应用程序的主要目的是处理卫星图像、影像和栅格数据,而且您没有时间或感到不舒服自己寻找解决方案使用 PIL,那么您可能最好只是将 NumPy、GDAL 和相关工具作为依赖项添加。
关于 GDAL 处理栅格数据广泛功能的列表,请参阅:
pcjericks.github.io/py-gdalogr-cookbook/
拼接
与矢量数据可以合并在一起的方式类似,也可以将多个相邻的栅格数据集镶嵌成一个更大的栅格数据。我们在这里实现的方式是创建一个 align_rasters() 函数,它接受任意数量的栅格,自动找到包含所有栅格的坐标边界框以及所需的像素尺寸(尽管我们可能应该允许一些用户控制),并使用这些信息将每个栅格定位到包含所有栅格的区域中的相应位置。我们将这个函数添加到 raster/manager.py 文件中:
def align_rasters(*rasters):
"Used internally by other functions only, not by user"
# get coord bbox containing all rasters
for rast in rasters: print rast.bbox
xlefts,ytops,xrights,ybottoms = zip(*[rast.bbox for rast in rasters])
if xlefts[0] < xrights[0]:
xleft,xright = min(xlefts),max(xrights)
else: xleft,xright = max(xlefts),min(xrights)
if ytops[0] > ybottoms[0]:
ytop,ybottom = max(ytops),min(ybottoms)
else: ytop,ybottom = min(ytops),max(ybottoms)
# get the required pixel dimensions (based on first raster, but should probably allow user to specify)
xs,ys = (xleft,xright),(ytop,ybottom)
coordwidth,coordheight = max(xs)-min(xs), max(ys)-min(ys)
rast = rasters[0]
orig_xs,orig_ys = (rast.bbox[0],rast.bbox[2]),(rast.bbox[1],rast.bbox[3])
orig_coordwidth,orig_coordheight = max(orig_xs)-min(orig_xs), max(orig_ys)-min(orig_ys)
widthratio,heightratio = coordwidth/orig_coordwidth, coordheight/orig_coordheight
reqwidth = int(round(rast.width*widthratio))
reqheight = int(round(rast.height*heightratio))
# position into same coordbbox
aligned = []
for rast in rasters:
coordbbox = [xleft,ytop,xright,ybottom]
positioned = rast.positioned(reqwidth, reqheight, coordbbox)
aligned.append(positioned)
return aligned
由于我们现在有了一种对齐和正确定位空间中栅格的方法,我们可以通过简单地创建一个新网格,其尺寸包含所有栅格,并将每个栅格粘贴到其中来轻松地将它们镶嵌成一个新的栅格:
def mosaic(*rasters):
"""
Mosaic rasters covering different areas together into one file.
Parts of the rasters may overlap each other, in which case we use the value
from the last listed raster (the "last" overlap rule).
"""
# align all rasters, ie resampling to the same dimensions as the first raster
aligned = align_rasters(*rasters)
# copy the first raster and reset the cached mask for the new raster
firstalign,firstmask = aligned[0]
merged = firstalign.copy()
del merged._cached_mask
# paste onto each other, ie "last" overlap rule
for rast,mask in aligned[1:]:
merged.bands[0].img.paste(rast.bands[0].img, (0,0), mask)
return merged
注意,与矢量合并不同,矢量合并会保留重叠几何形状的原始形式,而栅格镶嵌需要有一个规则来选择值,当存在重叠的单元格时。在之前的代码中,我们没有支持任何重叠规则的定制,而是简单地逐个将每个栅格粘贴到另一个栅格的上方,使得任何重叠的单元格都保留最后粘贴的栅格的值——这就是所谓的“最后”规则。您可以通过查看 PIL 库中可用的工具来实现其他重叠规则,例如使用 PIL.Image.blend() 的 average 值,或者使用 min 或 max 与 PIL.ImageOps 子模块中找到的函数。
重新采样
对于栅格数据,与矢量清理等效的是移除不必要的细节和减小文件大小,这可以通过重新采样网格单元格的大小和频率来实现。这种重新采样涉及平滑和重新分配旧单元格值到新单元格结构的算法。许多相同的原则也适用于调整图像大小。幸运的是,我们的栅格数据值存储在 PIL Image 类中,所以我们只需使用其 resize 方法,并使用最近邻算法,它要求以像素(或在我们的情况下是网格单元格的数量)为单位指定大小。为了方便用户,我们还提供了另一种选择,即指定每个单元格所需的地理宽度或高度(例如,度或米,取决于数据的坐标参考系统),我们的程序会在幕后计算必要的网格分辨率。如果指定了,请记住,地理坐标的 y 轴通常与栅格坐标的方向相反,因此 cellheight 必须以负数给出。如果用户对现有栅格的网格尺寸或单元格大小感兴趣,请记住,这可以在我们本章早期创建的图层属性窗口中找到。
注意
在这里,一个用于栅格重新采样的替代库将是 PyResample。我选择不在我们的轻量级应用程序中使用它,因为它依赖于 NumPy 和 SciPy。
其他用于探索栅格管理功能的实用库包括前面提到的 GDAL 或依赖于 GDAL 的 Rasterio。
看看下面的代码:
def resample(raster, width=None, height=None, cellwidth=None, cellheight=None):
raster = raster.copy()
if width and height:
# calculate new cell dimensions based on the new raster size
widthfactor = raster.width / float(width)
heightfactor = raster.height / float(height)
oldcellwidth, oldcellheight = raster.info["cellwidth"], raster.info["cellheight"]
newcellwidth, newcellheight = oldcellwidth * widthfactor, oldcellheight * heightfactor
# resample each grid
for band in raster:
band.img = band.img.resize((width, height), PIL.Image.NEAREST)
# update cells access
band.cells = band.img.load()
# remember new celldimensions
raster.info["cellwidth"] = newcellwidth
raster.info["cellheight"] = newcellheight
return raster
elif cellwidth and cellheight:
# calculate new raster size based on the new cell dimensions
widthfactor = raster.info["cellwidth"] / float(cellwidth)
heightfactor = raster.info["cellheight"] / float(cellheight)
oldwidth, oldheight = raster.width, raster.height
newwidth, newheight = int(round(oldwidth * widthfactor)), int(round(oldheight * heightfactor))
# resample each grid
for band in raster:
band.img = band.img.resize((newwidth, newheight), PIL.Image.NEAREST)
# update cells access
band.cells = band.img.load()
# remember new celldimensions
raster.info["cellwidth"] = cellwidth
raster.info["cellheight"] = cellheight
return raster
else:
raise Exception("To rescale raster, either width and height or cellwidth and cellheight must be specified.")
将功能编织到用户界面中
现在,我们来到可以让我们将之前创建的管理功能在可视化用户界面中提供给用户的部分。
层特定右键功能
本章中我们创建的一些功能本质上是绑定到单一层的,因此通过在所需层上右键单击直接操作这些功能是有意义的。这种功能仅针对我们当前正在制作的应用程序,所以让我们在app/dialogues.py模块中定义这个右键菜单。由于 Tkinter 已经有一个格式良好的弹出菜单小部件,并且提供了添加项目和命令的简单方法,我们只需要继承它。矢量层和栅格层将各自获得自己的菜单,但它们都将有共同的重命名、另存为和属性项目。为了使它们具有更好的视觉效果,找到与每个项目同名且为.png格式的三个图像,以便我们可以将它们分配给菜单项,并将它们保存在app/icons文件夹中。
首先,我们为矢量层创建选项菜单。我们给它我们之前创建的split和clean函数,并分配图标,你必须找到并保存为app/icons/split.png和app/icons/clean.png。参看以下截图:

class RightClickMenu_VectorLayer(tk.Menu):
def __init__(self, master, layerspane, layeritem, statusbar, **kwargs):
# Make this class a subclass of tk.Menu and add to it
tk.Menu.__init__(self, master, tearoff=0, **kwargs)
self.layerspane = layerspane
self.layeritem = layeritem
self.statusbar = statusbar
self.imgs = dict()
# Renaming
self.imgs["rename"] = icons.get("rename.png", width=32, height=32)
self.add_command(label="Rename", command=self.layeritem.ask_rename, image=self.imgs["rename"], compound="left")
# Saving
def ask_save():
savepath = asksaveasfilename()
self.statusbar.task.start("Saving layer to file...")
pending = dispatch.request_results(self.layeritem.renderlayer.data.save, args=[savepath])
def finish(result):
if isinstance(result, Exception):
popup_message(self, str(result) + "\n\n" + savepath)
self.statusbar.task.stop()
dispatch.after_completion(self, pending, finish)
self.imgs["save"] = icons.get("save.png", width=32, height=32)
self.add_command(label="Save as", command=ask_save, image=self.imgs["save"], compound="left")
# ---(Breakline)---
self.add_separator()
# Splitting
def open_options_window():
window = VectorSplitOptionWindow(self.layeritem, self.layerspane, self.layeritem, statusbar)
self.imgs["split"] = icons.get("split.png", width=32, height=32)
self.add_command(label="Split to layers", command=open_options_window, image=self.imgs["split"], compound="left")
# ---(Breakline)---
self.add_separator()
# Cleaning
def open_options_window():
window = VectorCleanOptionWindow(self.layeritem, self.layerspane, self.layeritem, statusbar)
self.imgs["clean"] = icons.get("clean.png", width=32, height=32)
self.add_command(label="Clean Geometries", command=open_options_window, image=self.imgs["clean"], compound="left")
# ---(Breakline)---
self.add_separator()
# View properties
def view_properties():
window = VectorLayerOptionsWindow(self.layeritem, self.layeritem, statusbar)
self.imgs["properties"] = icons.get("properties.png", width=32, height=32)
self.add_command(label="Properties", command=view_properties, image=self.imgs["properties"], compound="left")
然后我们转向栅格层选项菜单。这里唯一的层特定功能是resample(),所以找到并保存一个图标作为app/icons/resample.png。你可以在以下截图看到一个名为Resample的图标:

参考以下代码:
class RightClickMenu_RasterLayer(tk.Menu):
def __init__(self, master, layerspane, layeritem, statusbar, **kwargs):
# Make this class a subclass of tk.Menu and add to it
tk.Menu.__init__(self, master, tearoff=0, **kwargs)
self.layerspane = layerspane
self.layeritem = layeritem
self.statusbar = statusbar
self.imgs = dict()
# Renaming
self.imgs["rename"] = icons.get("rename.png", width=32, height=32)
self.add_command(label="Rename", command=self.layeritem.ask_rename, image=self.imgs["rename"], compound="left")
# Saving
def ask_save():
savepath = asksaveasfilename()
self.statusbar.task.start("Saving layer to file...")
pending = dispatch.request_results(self.layeritem.renderlayer.data.save, args=[savepath])
def finish(result):
if isinstance(result, Exception):
popup_message(self, str(result) + "\n\n" + savepath)
self.statusbar.task.stop()
dispatch.after_completion(self, pending, finish)
self.imgs["save"] = icons.get("save.png", width=32, height=32)
self.add_command(label="Save as", command=ask_save, image=self.imgs["save"], compound="left")
# ---(Breakline)---
self.add_separator()
# Resampling
def open_options_window():
window = RasterResampleOptionWindow(self.layeritem, self.layerspane, self.layeritem, statusbar)
self.imgs["resample"] = icons.get("resample.png", width=32, height=32)
self.add_command(label="Resample", command=open_options_window, image=self.imgs["resample"], compound="left")
# ---(Breakline)---
self.add_separator()
# View properties
def view_properties():
window = RasterLayerOptionsWindow(self.layeritem, self.layeritem, statusbar)
self.imgs["properties"] = icons.get("properties.png", width=32, height=32)
self.add_command(label="Properties", command=view_properties, image=self.imgs["properties"], compound="left")
定义工具选项窗口
在前面的代码中,点击菜单中的项目将打开特定工具的选项窗口。我们现在将在app/dialogues.py中创建这些选项窗口,利用我们有用的RunToolFrame模板用适当的选项和小部件填充窗口。由于这些是层特定工具,我们也记得将层数据作为一个隐藏参数设置。最后,将处理结果添加到我们的 LayersPane 中。以下截图显示了矢量清理的选项窗口:

这是实现所提及功能的代码:
class VectorCleanOptionWindow(Window):
def __init__(self, master, layerspane, layeritem, statusbar, **kwargs):
# Make this class a subclass and add to it
Window.__init__(self, master, **kwargs)
# Create runtoolframe
self.runtool = RunToolFrame(self)
self.runtool.pack(fill="both", expand=True)
self.runtool.assign_statusbar(statusbar)
# Add a hidden option from its associated layeritem data
self.runtool.add_hidden_option(argname="data", value=layeritem.renderlayer.data)
# Set the remaining options
self.runtool.set_target_method("Cleaning data...", vector.manager.clean)
self.runtool.add_option_input(argname="tolerance", label="Tolerance (in distance units)",
valuetype=float, default=0.0, minval=0.0, maxval=1.0)
# Define how to process
newname = layeritem.namelabel["text"] + "_cleaned"
def process(result):
if isinstance(result, Exception):
popup_message(self, "Failed to clean the data:" + "\n\n" + str(result) )
else:
layerspane.add_layer(result, name=newname)
self.destroy()
self.runtool.set_finished_method(process)
以下截图展示了填充了可供选择的字段列表的矢量分割选项窗口:

这是实现所提及功能的代码:
class VectorSplitOptionWindow(Window):
def __init__(self, master, layerspane, layeritem, statusbar, **kwargs):
# Make this class a subclass and add to it
Window.__init__(self, master, **kwargs)
# Create runtoolframe
self.runtool = RunToolFrame(self)
self.runtool.pack(fill="both", expand=True)
self.runtool.assign_statusbar(statusbar)
# Add a hidden option from its associated layeritem data
self.runtool.add_hidden_option(argname="data", value=layeritem.renderlayer.data)
# Set the remaining options
self.runtool.set_target_method("Splitting data...", vector.manager.split)
self.runtool.add_option_input(argname="splitfields",
label="Split by fields",
multi=True, choices=layeritem.renderlayer.data.fields,
valuetype=str)
# Define how to process
def process(result):
if isinstance(result, Exception):
popup_message(self, "Failed to split the data:" + "\n\n" + str(result) )
else:
for splitdata in result:
layerspane.add_layer(splitdata)
self.update()
self.destroy()
self.runtool.set_finished_method(process)
如以下截图所示的栅格重采样窗口中,用户可以手动输入栅格的高度和宽度以及单元格数据:

这里是相同功能的代码:
class RasterResampleOptionWindow(Window):
def __init__(self, master, layerspane, layeritem, statusbar, **kwargs):
# Make this class a subclass and add to it
Window.__init__(self, master, **kwargs)
# Create runtoolframe
self.runtool = RunToolFrame(self)
self.runtool.pack(fill="both", expand=True)
self.runtool.assign_statusbar(statusbar)
# Add a hidden option from its associated layeritem data
self.runtool.add_hidden_option(argname="raster", value=layeritem.renderlayer.data)
# Set the remaining options
self.runtool.set_target_method("Resampling data...", raster.manager.resample)
def get_data_from_layername(name):
data = None
for layeritem in layerspane:
if layeritem.name_label["text"] == name:
data = layeritem.renderlayer.data
break
return data
self.runtool.add_option_input(argname="width", label="Raster width (in cells)",
valuetype=int)
self.runtool.add_option_input(argname="height", label="Raster height (in cells)",
valuetype=int)
self.runtool.add_option_input(argname="cellwidth", label="Cell width (in distance units)",
valuetype=float)
self.runtool.add_option_input(argname="cellheight", label="Cell height (in distance units)",
valuetype=float)
# Define how to process after finished
def process(result):
if isinstance(result, Exception):
popup_message(self, "Failed to resample the data:" + "\n\n" + str(result) )
else:
layerspane.add_layer(result)
self.destroy()
self.runtool.set_finished_method(process)
最后,我们需要指示我们的应用程序,在图层上右键单击应打开适当的菜单。我们在app/builder.py模块中定义 GUI 类的初始化阶段中定义了这个,在创建 LayersPane 之后:
# Bind layeritem right click behavior
def layer_rightclick(event):
layeritem = event.widget.master.master
if isinstance(layeritem.renderlayer, pg.renderer.VectorLayer):
menu = RightClickMenu_VectorLayer(self, self.layerspane, layeritem, self.statusbar)
elif isinstance(layeritem.renderlayer, pg.renderer.RasterLayer):
menu = RightClickMenu_RasterLayer(self, self.layerspane, layeritem, self.statusbar)
# Place and show menu
menu.post(event.x_root, event.y_root)
self.layerspane.bind_layer_rightclick(layer_rightclick)
设置管理标签
与单个图层的右键菜单相比,顶部的标签栏应该保留用于更通用的功能,这些功能需要多个图层作为输入。
我们所有的数据管理相关功能都放在一个名为管理的单独标签中,我们将矢量工具栏和栅格工具栏附加到该标签上,每个工具栏都包含一个或多个按钮,这些按钮打开一个选项窗口以运行相关功能。因此,我们在创建标签栏和可视化标签后,在 GUI 类中的app/builder.py中添加了以下内容,如下面的截图所示:

这里是设置管理标签的代码:
## Management tab
managetab = self.ribbon.add_tab("Manage")
### (Vector toolbar)
vectorfiles = managetab.add_toolbar("Vector Files")
def open_merge_window():
window = VectorMergeOptionWindow(self, self.layerspane, self.statusbar)
vectorfiles.add_button(text="Merge", icon="vector_merge.png",
command=open_merge_window)
### (Raster toolbar)
rasterfiles = managetab.add_toolbar("Raster Files")
def open_mosaic_window():
window = RasterMosaicOptionWindow(self, self.layerspane, self.statusbar)
rasterfiles.add_button(text="Mosaic", icon="mosaic.png",
command=open_mosaic_window)
定义工具选项窗口
我们在app/dialogues.py中定义了各种工具特定的选项窗口,就像我们在文本中之前所做的那样。首先是为矢量合并工具窗口,如下面的截图所示:

这里是相同的代码:
class VectorMergeOptionWindow(Window):
def __init__(self, master, layerspane, statusbar, **kwargs):
# Make this class a subclass and add to it
Window.__init__(self, master, **kwargs)
# Create runtoolframe
self.runtool = RunToolFrame(self)
self.runtool.pack(fill="both", expand=True)
self.runtool.assign_statusbar(statusbar)
# Set the remaining options
self.runtool.set_target_method("Merging data...", vector.manager.merge)
def get_data_from_layername(name):
data = None
for layeritem in layerspane:
if layeritem.namelabel["text"] == name:
data = layeritem.renderlayer.data
break
return data
self.runtool.add_option_input(argname=None,
label="Layers to be merged",
multi=True,
choices=[layeritem.namelabel["text"] for layeritem in layerspane],
valuetype=get_data_from_layername)
# Define how to process
def process(result):
if isinstance(result, Exception):
popup_message(self, "Failed to merge the data:" + "\n\n" + str(result) )
else:
layerspane.add_layer(result, name="merged")
self.runtool.set_finished_method(process)
栅格镶嵌工具的选项窗口如下所示:

这里是代码:
class RasterMosaicOptionWindow(Window):
def __init__(self, master, layerspane, statusbar, **kwargs):
# Make this class a subclass and add to it
Window.__init__(self, master, **kwargs)
# Create runtoolframe
self.runtool = RunToolFrame(self)
self.runtool.pack(fill="both", expand=True)
self.runtool.assign_statusbar(statusbar)
# Set the remaining options
self.runtool.set_target_method("Mosaicking data...", raster.manager.mosaic)
def get_data_from_layername(name):
data = None
for layeritem in layerspane:
if layeritem.namelabel["text"] == name:
data = layeritem.renderlayer.data
break
return data
self.runtool.add_option_input(argname=None,
label="Layers to be mosaicked",
multi=True,
choices=[layeritem.namelabel["text"] for layeritem in layerspane],
valuetype=get_data_from_layername)
# Define how to process
def process(result):
if isinstance(result, Exception):
popup_message(self, "Failed to mosaick the data:" + "\n\n" + str(result) )
else:
layerspane.add_layer(result, name="mosaicked")
self.runtool.set_finished_method(process)
概述
在本章中,我们创建了与管理和组织文件相关的功能。这包括一个窗口来检查任何数据层的基本属性。至于操作,我们实现了矢量数据的分割、合并和几何清理,以及栅格数据的镶嵌和重采样。然后,这些功能在应用程序 GUI 中可用,一些是通过在图层上右键单击时从弹出菜单中选择,另一些是通过在顶部的标签栏上的管理标签中单击图标按钮。每个工具都有自己的窗口对话框类,具有可编辑的选项。
通过阅读本章,你现在应该知道添加地理空间功能的一般步骤,使其在 GUI 中可用,并在需要时将其作为新图层添加。当我们进入下一章,我们将构建一些基本分析功能时,我们只需要重复并遵循相同的步骤和程序。
第六章:分析地理数据
在获取、准备和组织数据以适应你的需求之后,你最终会到达一个点,可以真正利用这些数据进行一些更大的好事:形成查询、探索、回答问题、测试假设等等。在本章中,你将开发一些这些能力及其应用组件,特别是:
-
创建分析功能:
-
矢量数据中的叠加汇总和距离缓冲区
-
栅格数据的区域汇总统计
-
-
添加通过用户界面访问它们的方式
创建分析模块
我们首先创建一个名为app/analyzer.py的模块,并添加必要的导入。在vector文件夹中也有一个:
import itertools, operator
from .data import *
import shapely
from shapely.prepared import prep as supershapely
在raster文件夹中也有一个:
import itertools, operator
from .data import *
from .manager import *
import PIL.Image, PIL.ImageMath, PIL.ImageStat
如同往常,我们必须使这些新模块可以从它们的父包导入,因此需要在vector/__init__.py和raster/__init__.py中添加以下导入语句:
from . import analyzer
分析数据
本章的前半部分创建了分析功能,而后半部分将功能融入应用程序设计。让我们先创建功能。这包括矢量数据中的重叠汇总和缓冲区,以及栅格数据的区域统计。
矢量数据
对于矢量数据,我们将关注两个常用的分析工具:重叠汇总和缓冲区。
重叠汇总
在 GIS 中,最基本的空间分析操作之一是对接触或重叠其他图层特征的要素层进行统计汇总。这类分析通常涉及的问题包括:每个国家多边形内有多少个点,或者每个国家的值总和或平均值是多少?这类分析通常使用空间连接工具来完成,其中多对一选项表示多个匹配特征与一个汇总统计。然后,这些汇总统计被附加到原始国家多边形上。空间连接本身不是一种分析,它只是进行用户可以用于后续分析(例如在地图或表格图中)的数值计算。根据我的经验,这是使用空间连接最常见的原因之一,作为预处理步骤,但它仍然是叠加分析的一个关键部分。
以下截图展示了叠加分析可以用来汇总值和可视化模式的一种典型方式:

由于我们的应用程序更多地面向非技术用户,并且我们希望尽可能使一切清晰明了,我们将这种特定的空间连接用法做成一个独立的工具,并给它一个更恰当地描述分析最终结果的名称:重叠摘要。该工具已被分配用于将统计分组到包含将被汇总的值的数据库中,以及一个要计算输出中的字段名统计元组列表,也称为 字段映射。有效的统计值有计数、总和、最大值、最小值和平均值。作为一个字段映射的例子,该工具期望如果我们想让输出文件计算主要城市的数量和它们人口的总和,我们将写成 [("city_id", "count"), ("city_pop", "sum")]。请注意,字段映射遵循通常的 Python 语法,字符串周围有引号,这也是我们稍后通过用户界面输入它们的方式。对于检测重叠,我们使用 Shapely 模块的 intersects 操作。还请注意,使用 Shapely 不太为人所知的 prep 函数(导入为 supershapely)在相同几何形状上的多次重复相交比较中提供了惊人的加速。
因此,进入 vector/analyzer.py 并添加以下函数:
def overlap_summary(groupbydata, valuedata, fieldmapping=[]):
# prep
data1,data2 = groupbydata,valuedata
if fieldmapping: aggfields,aggtypes = zip(*fieldmapping)
aggfunctions = dict([("count",len),
("sum",sum),
("max",max),
("min",min),
("average",lambda seq: sum(seq)/float(len(seq)) ) ])
# create spatial index
if not hasattr(data1, "spindex"): data1.create_spatial_index()
if not hasattr(data2, "spindex"): data2.create_spatial_index()
# create new
new = VectorData()
new.fields = list(data1.fields)
if fieldmapping:
for aggfield,aggtype in fieldmapping:
new.fields.append(aggfield)
# for each groupby feature
for i,feat in enumerate(data1.quick_overlap(data2.bbox)):
geom = feat.get_shapely()
geom = supershapely(geom)
matches = []
# get all value features that intersect
for otherfeat in data2.quick_overlap(feat.bbox):
othergeom = otherfeat.get_shapely()
if geom.intersects(othergeom):
matches.append(otherfeat)
# make newrow from original row
newrow = list(feat.row)
# if any matches
if matches:
def make_number(value):
try: return float(value)
except: return None
# add summary values to newrow based on fieldmapping
for aggfield,aggtype in fieldmapping:
values = [otherfeat[aggfield] for otherfeat in matches]
if aggtype in ("sum","max","min","average"):
# only consider number values if numeric stats
values = [make_number(value) for value in values if make_number(value) != None]
aggregatefunc = aggfunctions[aggtype]
summaryvalue = aggregatefunc(values)
newrow.append(summaryvalue)
# otherwise, add empty values
else:
newrow.extend(("" for _ in fieldmapping))
# write feature to output
new.add_feature(newrow, feat.geometry)
return new
缓冲区
如果在你的分析中,你还想包括那些不一定与分组特征重叠但位于一定距离内的特征,那么缓冲区是一个很好的工具。缓冲操作是指通过指定距离扩展或缩小几何特征。在将几何形状扩展到所需距离后,可以随后使用之前实现的重叠摘要工具,这样也可以将接近重叠的特征包含在统计中。参考以下截图以查看多边形缓冲操作的示例:

我们通过 Shapely 的 buffer 方法非常简单地实现这一点,使用正数进行扩展,使用负数进行缩小。为了使其更有趣,我们允许用户根据表达式动态设置缓冲距离。该表达式应采用表示 Python 代码的字符串形式,引用特征为 feat,这允许根据一个或多个属性或甚至数学表达式进行缓冲。例如,为了根据人均 GDP 和缩放以增强可见性来缓冲国家图层,我们可能写成:(feat['GDP'] / float(feat['population'])) / 500.0。
在 vector/analyzer.py 中添加以下代码:
def buffer(data, dist_expression):
# buffer and change each geojson dict in-place
new = VectorData()
new.fields = list(data.fields)
for feat in data:
geom = feat.get_shapely()
dist = eval(dist_expression)
buffered = geom.buffer(dist)
if not buffered.is_empty:
geojson = buffered.__geo_interface__
geojson["type"] = buffered.type
new.add_feature(feat.row, geojson)
# change data type to polygon
new.type = "Polygon"
return new
栅格数据
在我们的轻量级应用程序中分析栅格数据时,我们多少受到主要依赖项 PIL 提供的速度和功能的限制。幸运的是,PIL 包内有许多隐藏的宝石,其中之一就是 ImageStat 模块,我们用它来实现区域统计分析。
注意
PIL 库中的其他有用功能可以在其ImageMath模块中找到。这将使我们能够根据一个或多个输入栅格层的数学表达式生成输出栅格。然而,如果你的应用程序主要是用于高级栅格数据或卫星图像分析,你可能想要考虑 GDAL/NumPy/SciPy 路线。我们将在最后一章中回到这些可能性。
区域统计
区域统计是常见的 GIS 工具,它从每个栅格中提取每个类别或区域,并总结另一个栅格中重叠单元格的值。在某种程度上,区域统计是重叠摘要的栅格等效物。在我们的实现中,我们返回一个包含每个区域各种统计信息的字典和一个区域栅格的副本,其中每个区域的值基于其全局汇总统计信息之一。用户必须指定要使用区域数据和值数据中的哪个波段,并将 outstat 统计选项设置为以下之一:mean,median,max,min,stdev,var,count或sum。
在raster/analyser.py中编写:
def zonal_statistics(zonaldata, valuedata, zonalband=0, valueband=0, outstat="mean"):
"""
For each unique zone in "zonaldata", summarizes "valuedata" cells that overlap "zonaldata".
Which band to use must be specified for each.
The "outstat" statistics option can be one of: mean, median, max, min, stdev, var, count, or sum
"""
# get nullvalues
nullzone = zonaldata.info.get("nodata_value")
# position value grid into zonal grid
(valuedata,valuemask) = valuedata.positioned(zonaldata.width, zonaldata.height,
zonaldata.bbox)
# pick one image band for each
zonalimg = zonaldata.bands[zonalband].img
valueimg = valuedata.bands[valueband].img
# create output image, using nullzone as nullvalue
outimg = PIL.Image.new("F", zonalimg.size, nullzone)
# get stats for each unique value in zonal data
zonevalues = [val for count,val in zonalimg.getcolors()]
zonesdict = {}
for zoneval in zonevalues:
# exclude nullzone
if zoneval == nullzone: continue
# mask only the current zone
zonemask = zonalimg.point(lambda px: 1 if px == zoneval else 0, "1")
fullmask = PIL.Image.new("1", zonemask.size, 0)
# also exclude null values from calculations
fullmask.paste(zonemask, valuemask)
# retrieve stats
stats = PIL.ImageStat.Stat(valueimg, fullmask)
statsdict = {}
statsdict["min"],statsdict["max"] = stats.extrema[0]
for stattype in ("count","sum","mean","median","var","stddev"):
try: statsdict[stattype] = stats.__getattr__(stattype)[0]
except ZeroDivisionError: statsdict[stattype] = None
zonesdict[zoneval] = statsdict
# write chosen stat to outimg
outimg.paste(statsdict[outstat], (0,0), zonemask)
# make outimg to raster
outraster = Raster(image=outimg, **zonaldata.info)
return zonesdict, outraster
将功能集成到用户界面中
接下来,让我们使到目前为止创建的分析功能在我们的应用程序用户界面中可用。
图层特定的右键单击功能
在第五章中,我们指导我们的应用程序,在图层窗格中右键单击图层将给我们一个菜单,可以选择特定于该图层的操作。在本章中,我们唯一创建的图层特定功能是buffer操作。因此,我们将缓冲菜单选项添加到app/dialogues.py中的RightClickMenu_VectorLayer类中。请记住找到并保存一个app/icons/buffer.png图标,以便它可以在菜单的缓冲项旁边显示:
# Buffering
def open_options_window():
window = VectorBufferOptionWindow(self.layeritem, self.layerspane, self.layeritem, statusbar)
self.imgs["buffer"] = icons.get("buffer.png", width=32, height=32)
self.add_command(label="Buffer", command=open_options_window, image=self.imgs["buffer"], compound="left")
定义工具选项窗口
仍然在app/dialogues.py中,我们定义了应该弹出的图层特定工具选项窗口。由于它们是图层特定的,我们添加了 LayerItem 的数据作为隐藏选项,用户不需要担心设置。这里唯一的用户输入是我们之前基于数据坐标参考系统单位引入的缓冲距离表达式,可以是正数用于增长或负数用于缩小。表达式计算器可能是一个很好的用户自定义选项。

下面是所述功能的代码:
class VectorBufferOptionWindow(Window):
def __init__(self, master, layerspane, layeritem, statusbar, **kwargs):
# Make this class a subclass and add to it
Window.__init__(self, master, **kwargs)
# Create runtoolframe
self.runtool = RunToolFrame(self)
self.runtool.pack(fill="both", expand=True)
self.runtool.assign_statusbar(statusbar)
# Add a hidden option from its associated layeritem data
self.runtool.add_hidden_option(argname="data", value=layeritem.renderlayer.data)
# Set the remaining options
self.runtool.set_target_method("Buffering data...", vector.analyzer.buffer)
self.runtool.add_option_input(argname="dist_expression",
label="Distance calculation",
valuetype=str)
# Define how to process
def process(result):
if isinstance(result, Exception):
popup_message(self, "Failed to buffer the data:" + "\n\n" + str(result) )
else:
layerspane.add_layer(result)
self.destroy()
self.runtool.set_finished_method(process)
设置分析标签页
接下来,我们关注那些应该可在顶部功能区中使用的工具。首先,我们在 GUI 类的初始化阶段进入app/builder.py,为分析添加一个新标签页,并添加工具栏和按钮以实现我们的剩余功能,如下面的截图所示:

下面是创建分析标签页的代码:
## Analysis tab
analysistab = self.ribbon.add_tab("Analyze")
### (Vector toolbar)
vectorfiles = analysistab.add_toolbar("Vector")
def open_overlapsummary_window():
window = VectorOverlapSummaryWindow(self, self.layerspane, self.statusbar)
vectorfiles.add_button(text="Overlap Summary", icon="overlap.png",
command=open_overlapsummary_window)
### (Raster toolbar)
rasterfiles = analysistab.add_toolbar("Raster")
def open_zonalstats_window():
window = RasterZonalStatsOptionWindow(self, self.layerspane, self.statusbar)
rasterfiles.add_button(text="Zonal statistics", icon="zonalstats.png",
command=open_zonalstats_window)
定义工具选项窗口
在工具选项窗口中,对于重叠摘要,我们在app/dialogues.py中定义了标准方式。请注意,为输出添加字段名统计元组的理想过程应从两个下拉列表中选择(一个用于可用的字段名,一个用于可用的统计类型)。由于我们没有现成的这种双下拉小部件,我们改为让用户以两个引号括起来的字符串的形式将其拼写出来作为元组,这不幸地并不是非常用户友好。使用双下拉列表可以作为一个练习供读者实现。此外,由于我们没有根据它们的属性可视化输出数据的方法,这种方法目前在我们的应用程序中是无效的:

下面是实现所述功能的代码:
class VectorOverlapSummaryWindow(Window):
def __init__(self, master, layerspane, statusbar, **kwargs):
# Make this class a subclass and add to it
Window.__init__(self, master, **kwargs)
# Create runtoolframe
self.runtool = RunToolFrame(self)
self.runtool.pack(fill="both", expand=True)
self.runtool.assign_statusbar(statusbar)
# Set the remaining options
self.runtool.set_target_method("Calculating overlap summary on data...", vector.analyzer.overlap_summary)
def get_data_from_layername(name):
data = None
for layeritem in layerspane:
if layeritem.namelabel["text"] == name:
data = layeritem.renderlayer.data
break
return data
self.runtool.add_option_input(argname="groupbydata",
label="Group by data",
default="(Choose layer)",
choices=[layeritem.namelabel["text"] for layeritem in layerspane],
valuetype=get_data_from_layername)
self.runtool.add_option_input(argname="valuedata",
label="Value data",
default="(Choose layer)",
choices=[layeritem.namelabel["text"] for layeritem in layerspane],
valuetype=get_data_from_layername)
self.runtool.add_option_input(argname="fieldmapping",
label="Field mapping",
multi=True,
valuetype=eval)
# Define how to process
def process(result):
if isinstance(result, Exception):
popup_message(self, "Failed to calculate overlap summary on data:" + "\n\n" + str(result) )
else:
layerspane.add_layer(result, name="overlap summary")
self.runtool.set_finished_method(process)
对于区域统计工具,我们做的是相同的:

下面是实现所述功能的代码:
class RasterZonalStatsOptionWindow(Window):
def __init__(self, master, layerspane, statusbar, **kwargs):
# Make this class a subclass and add to it
Window.__init__(self, master, **kwargs)
# Create runtoolframe
self.runtool = RunToolFrame(self)
self.runtool.pack(fill="both", expand=True)
self.runtool.assign_statusbar(statusbar)
# Set the remaining options
self.runtool.set_target_method("Calculating zonal statistics on data...", raster.analyzer.zonal_statistics)
def get_data_from_layername(name):
data = None
for layeritem in layerspane:
if layeritem.namelabel["text"] == name:
data = layeritem.renderlayer.data
break
return data
self.runtool.add_option_input(argname="zonaldata",
label="Zonal data",
default="(Choose layer)",
choices=[layeritem.namelabel["text"] for layeritem in layerspane],
valuetype=get_data_from_layername)
self.runtool.add_option_input(argname="valuedata",
label="Value data",
default="(Choose layer)",
choices=[layeritem.namelabel["text"] for layeritem in layerspane],
valuetype=get_data_from_layername)
self.runtool.add_option_input(argname="zonalband",
label="Zonal band",
valuetype=int,
default=0)
self.runtool.add_option_input(argname="valueband",
label="Value band",
valuetype=int,
default=0)
self.runtool.add_option_input(argname="outstat",
label="Output Raster Statistic",
valuetype=str,
default="mean",
choices=["min","max","count","sum","mean","median","var","stddev"] )
然而,当处理区域统计结果时,我们不仅添加了输出栅格作为图层,还弹出一个显示所有区域汇总统计的滚动窗口。为了创建可滚动的文本小部件,Tkinter 已经有一个预构建的可滚动文本小部件(出于某种奇怪的原因,它放在一个自己的模块中,这里导入为tkst),所以我们使用这个:
# Define how to process
def process(result):
if isinstance(result, Exception):
popup_message(self, "Failed to calculate zonal statistics on the data:" + "\n\n" + str(result) )
else:
zonesdict, outraster = result
# add the resulting zonestatistics layer
layerspane.add_layer(outraster, name="zonal statistic")
# also view stats in window
win = Window()
textbox = tkst.ScrolledText(win)
textbox.pack(fill="both", expand=True)
textbox.insert(tk.END, "Zonal statistics detailed result:")
textbox.insert(tk.END, "\n------------------------ ---------\n")
for zone,stats in zonesdict.items():
statstext = "\n"+"Zone %i:"%zone
statstext += "\n\t" + "\n\t".join(["%s: %f"%(key,val) for key,val in stats.items()])
textbox.insert(tk.END, statstext)
self.runtool.set_finished_method(process)
摘要
在本章中,我们添加了最基本的常用 GIS 分析工具。具体来说,我们添加了一个在右键点击矢量图层时可以使用的灵活缓冲工具,以及一个包含一个用于矢量数据之间重叠摘要的按钮和一个用于栅格数据之间区域统计的按钮的分析标签页。
然而,这仅仅只是触及了在 GIS 应用程序中可以进行的分析类型的一角,而有趣的部分在于当你选择将应用程序进一步发展并构建附加功能时。例如,你可以如何将工具链接起来以简化所需步骤,并创建帮助您或您的目标受众更高效的自定义分析工具。
随着我们应用程序分析组件的完成,我们得到了一个非常简单但功能齐全的 GIS 应用程序,至少在演示目的上是可以工作的。为了让应用程序在我们的开发环境之外也能使用,尤其是对非程序员来说,我们必须将注意力转向将其打造成一个自包含的应用程序,这正是我们将在下一章中要做的。
第七章:打包和分发你的应用程序
我们现在已经到达了应用程序开发过程的最后一步。我们有一个包含许多基本 GIS 功能的工作应用程序。然而,到目前为止,它只能在我们的特定开发环境中运行在我们的电脑上。如果你想让你之外的人从你的应用程序中受益,或者只是让你更容易携带和使用你的应用程序在多台电脑上,你需要打包应用程序,以便更容易安装。在本章中,我们将介绍以下最终步骤:
-
分配一个图标作为我们应用程序的标志
-
将你的开发环境转换为包含可执行文件(
.exe)的自包含文件夹结构,以便运行你的应用程序 -
为你的应用程序提供一个安装向导以实现更持久的安装
附加应用程序标志
到目前为止,你可能已经注意到我们的应用程序在窗口的左上角显示了一个小而相当通用的红色图标,并且在打开的应用程序列表下方。这是 Tkinter GUI 应用程序的标准 Tkinter 标志,包括 Python 中的 IDLE 编辑器。对于你自己的应用程序,显然你想要自己的图标。
图标图像文件
首先,你必须找到或创建你想要的图标。现在,为了将标志分配给你的应用程序,它需要是 .ico 格式,这种格式包含同一图像在不同分辨率下的多个版本,以实现最佳显示。很可能会发现或创建的图像是一个普通的图像文件,例如 .png、.bmp 或 .gif,因此我们需要将其转换。我们将使用 Python 和 PIL 进行一次性处理,因为我们已经安装了它们。
使用这种 PIL 方法,我们可能会遇到一个小障碍,我们可能需要通过破解的方式绕过去。py2exe(我们将使用它来为我们的应用程序创建 EXE 文件)的在线文档警告我们,为了将图标分配给 EXE 文件,图标文件的各个分辨率保存的顺序很重要。大小必须按照从大到小的顺序分配,否则将无法工作。
在 PIL 中,我们遇到了一个障碍,在 2.8.1 或更低版本中,它会在幕后自动以从大到小的顺序排列图像大小,而不管你最初指定的顺序如何。幸运的是,当我提出问题时,PIL/Pillow 开发团队非常响应,所以问题已经得到解决,并且一旦下一个稳定版本 2.8.2 发布,应该不再成为问题。
如果新的修补过的 PIL 版本还没有发布,我们仍然可以自己轻松修复。尽管我们很勇敢,但我们还是深入到 PIL 的内部工作文件中,这些文件位于C:/Python27/Lib/site-packages/PIL。在IcoImagePlugin.py文件中,脚本的上部有一个_save函数。在那里你会看到它使用以下代码按从小到大的顺序对指定的尺寸参数进行排序:sizes = sorted(sizes, key=lambda x: x[0])。我们只需要删除或注释掉那行代码,这样用户就可以完全决定保存尺寸的顺序。
现在,我们已经准备好转换你选择的标志图像。我们只需要做一次,而且相当简单,所以我们就在交互式 Python Shell 窗口中这样做,而不是使用常规的文件编辑器。如果你已经在 Python IDLE 文件编辑器中,只需在顶部菜单中点击运行从 Python Shell。本质上我们只是导入 PIL,加载你选择的图像文件,并将其保存到一个新的.ico扩展名的文件中。在保存时,我们给出一个包含我们想要支持的图标分辨率的宽高元组的尺寸参数列表,按降序排列。将此图标图像保存到pythongis/app文件夹中是有意义的。运行以下命令:
>>> import PIL, PIL.Image
>>> img = PIL.Image.open("your/path/to/icon.png")
>>> img.save("your/path/to/pythongis/app/icon.ico", sizes=[(255,255),(128,128),(64,64),(48,48),(32,32),(16,16),(8,8)])
分配图标
现在我们有了图标文件,我们可以将其分配给我们的应用程序。这是通过将图标分配给 Tkinter 来完成的,它将我们的图标放置在应用程序窗口的左上角,并在活动应用程序的 Windows 任务栏下方。我们在app/builder.py文件中的run函数中这样做,只需将我们的根应用程序窗口指向图标的路径。图标文件与app/builder.py在同一文件夹中,因此有人可能会认为到logo.ico的相对路径就足够了,但显然,对于分配 GUI 图标这个特定任务,Tkinter 需要完整的绝对路径。为此,我们利用全局__file__变量,它指向运行脚本的绝对路径:
# assign logo from same directory as this file
import sys, os
curfolder,curfile = os.path.split(__file__)
logopath = os.path.join(curfolder, "logo.ico")
window.iconbitmap(logopath)
如果你现在运行应用程序,你应该会看到图标出现在左上角和底部。尽管我们已经告诉 Tkinter 在应用程序内部使用图标,但这不会影响我们在 Windows 资源管理器中浏览和查看 EXE 文件时的外观。我们将如何在接下来的打包和创建 EXE 文件的过程中看到这一点。
应用程序启动脚本
由于我们想要一个可以打开并运行我们的应用程序的 EXE 文件,我们需要一个脚本,该脚本明确定义了如何启动我们的应用程序。我们用于本书整个测试目的的guitester.py脚本正是这样做的。因此,我们将我们的测试脚本重命名为mygisapp.py(或你希望给你的应用程序取的任何名字)。我们的主pythongis文件夹的位置应该如下所示:

由于我们所做的只是将之前的 guitester.py 脚本重命名为 mygisapp.py,内容应该保持不变,它看起来应该是这样的:
import pythongis as pg
pg.app.run()
包装你的应用程序
应用程序启动定义好后,我们现在就可以准备包装它了。包装我们的应用程序意味着我们的应用程序将包含所有必要的文件,这些文件被分组到一个文件夹树中(目前它们散布在您电脑的多个位置),以及一个用户可以双击以运行应用程序的 EXE 文件。
安装 py2exe
Python 中有许多用于包装项目的库,我们选择使用 py2exe,因为它非常容易安装:
-
前往 www.py2exe.org。
-
点击顶部的 Download 链接,它将带你去到
sourceforge.net/projects/py2exe/files/py2exe/0.6.9/。 -
下载并运行适用于 Python 2.7 的最新版本,目前是
py2exe-0.6.9.win32-py2.7.exe。注意
py2exe 是针对 Windows 平台的;你必须在 Windows 上构建,并且你的程序只能在 Windows 上使用。
Windows 的另一个替代方案将是 PyInstaller:
pythonhosted.org/PyInstaller/。Mac OS X 的对应工具是 py2app:
pythonhosted.org/py2app/。对于 Linux,你可以使用 cx_Freeze:
cx-freeze.sourceforge.net/。
制定包装策略
包装应用程序有许多方法,所以在我们深入之前,我们应该首先了解 py2exe 的工作原理并相应地制定一个包装策略。给定一个用户想要包装的脚本,py2exe 做的是遍历脚本,递归地检测所有导入语句,从而确定哪些库必须包含在最终的包中。然后它创建一个名为 dist 的文件夹(它还创建了一个名为 build 的文件夹,但对我们来说那个是不相关的),这个文件夹变成了包含所有必需文件和基于我们的启动脚本的 EXE 文件的发行文件夹。
一个关键的决定是我们如何选择捆绑我们的包。我们可以将大多数所需的文件和依赖项捆绑到 EXE 文件本身或 ZIP 文件中,或者不捆绑任何东西,保持所有内容在文件夹结构中松散。起初,捆绑可能看起来是最整洁和最佳组织的选择。不幸的是,py2exe(与其他打包库一样)通常无法正确检测或复制所有必要的文件(尤其是.dll和.pyc文件)从依赖项中,导致我们的应用程序启动失败。我们可以指定一些选项来帮助 py2exe 正确检测和包含所有内容,但对于大型项目来说,这可能会变得繁琐,并且仍然可能无法纠正每个错误。通过将所有内容作为文件和文件夹而不是捆绑起来,我们实际上可以在 py2exe 完成工作后进入并纠正 py2exe 犯的一些错误。
使用非捆绑方法,我们可以获得更大的控制权,因为 EXE 文件变得像 Python 解释器一样,dist文件夹顶层的所有内容都变成了 Python 的site-packages文件夹,用于导入库。这样,通过手动将依赖项完整地从site-packages复制到dist文件夹,它们就可以以与 Python 通常从site-packages导入它们相同的方式导入。py2exe 将检测并正确处理我们的内置 Python 库的导入,但对于更高级的第三方依赖项,包括我们的主要pythongis库,我们希望自行添加。我们可以在创建下一个构建脚本时将这种策略付诸实践。
创建构建脚本
要打包一个项目,py2exe 需要一个非常简单的指令脚本。将其保存为与我们的主pythongis文件夹位于同一目录下的setup.py。以下是目录结构层次:

我们从setup.py文件开始,通过链接到应由 EXE 文件运行的mygisapp.py启动脚本,并指向我们的图标文件路径,这样当浏览时 EXE 文件看起来就会是这样。在选项中,我们根据我们的非捆绑策略将skip_archive设置为True。我们还阻止 py2exe 尝试从pyagg包中读取和复制两个二进制文件,这会导致不必要的错误,因为这些文件只是为了跨版本和跨平台兼容性而提供的。
如果在应用程序演变过程中遇到其他构建错误,可以使用dll_excludes忽略.dll和.pyd文件或模块或包的排除,这可以是一个好的方法来忽略这些错误,并在构建后复制粘贴所需的文件。以下是我们刚才描述的步骤的代码,写在setup.py脚本中:
############
### allow building the exe by simply running this script
import sys
sys.argv.append("py2exe")
############
### imports
from distutils.core import setup
import py2exe
###########
### options
WINDOWS = [{"script": "mygisapp.py",
"icon_resources": [(1,"pythongis/app/logo.ico")] }]
OPTIONS = {"skip_archive": True,
"dll_excludes": ["python26.dll","python27.so"],
"excludes": [] }
###########
### build
setup(windows=WINDOWS,
options={"py2exe": OPTIONS}
)
setup函数将在setup.py旁边的dist文件夹和pythongis文件夹中构建。正如我们在包装策略中之前所述,如果第三方库有如.dll、.pyd、图片或其他数据文件的高级文件布局,py2exe 可能无法正确复制所有这些库。因此,我们选择在脚本中添加一些额外的代码,在构建过程之后将更高级的依赖项,如PIL、Pyagg、Rtree和Shapely从site-packages(假设你没有将它们安装到其他位置)以及我们整个pythongis库复制并覆盖到dist文件夹中。你必须确保site-packages的路径与你的平台匹配。
###########
### manually copy pythongis package to dist
### ...because py2exe may not copy all files
import os
import shutil
frompath = "pythongis"
topath = os.path.join("dist","pythongis")
shutil.rmtree(topath) # deletes the folder copied by py2exe
shutil.copytree(frompath, topath)
###########
### and same with advanced dependencies
### ...only packages, ie folders
site_packages_folder = "C:/Python27/Lib/site-packages"
advanced_dependencies = ["PIL", "pyagg", "rtree", "shapely"]
for dependname in advanced_dependencies:
frompath = os.path.join(site_packages_folder, dependname)
topath = os.path.join("dist", dependname)
shutil.rmtree(topath) # deletes the folder copied by py2exe
shutil.copytree(frompath, topath)
创建了setup.py脚本后,你只需运行脚本以打包你的应用程序。py2exe 复制所有内容可能需要一分钟左右。完成后,将在与setup.py和pythongis相同的文件夹中有一个可用的dist文件夹:

在dist文件夹内,将有一个mygisapp.exe文件(假设这是你的启动脚本名称),它应该看起来像你选择的图标,并且当运行时,应该成功启动你的类似图标的应用程序窗口。当你在dist文件夹内时,检查 py2exe 是否意外包含了你试图避免的任何库。例如,Shapely 有可选的 NumPy 支持,并且会尝试导入 NumPy,这会导致 py2exe 即使你没有使用它也会将其添加到你的dist文件夹中。通过将不想要的包添加到设置脚本中的排除选项来避免这种情况。
添加 Visual C 运行时 DLL
如果你是在 Windows 上操作,在我们应用程序完全独立之前,还有最后一个至关重要的步骤。Python 编程环境依赖于我们在安装 Python 时包含的 Microsoft Visual C 运行时 DLL。然而,存在许多版本的此 DLL,因此并非所有计算机或用户都会有我们应用程序需要的特定版本。py2exe 默认不会包含所需的 DLL,因此我们必须将其包含在我们的dist文件夹中。在安装中包含 DLL 是一个简单的复制和粘贴过程,按照以下步骤操作:
-
尽管从技术上讲,我们已经在电脑上某个地方有了 DLL 文件,但我认为找到正确的一个有足够的变数和陷阱,因此最好是通过干净安装(免费的)Microsoft Visual C redistributable 程序来获取它。下载并安装 Python 版本所使用的版本,对于 32 位 Python 2.7,是Microsoft Visual C++ 2008 Redistributable Package (x86),可以从
www.microsoft.com/download/en/details.aspx?displaylang=en&id=29获取。注意
对于其他 Python 版本和位架构及其所需的 VC++和 DLL 版本的概述,请参阅这篇出色的帖子:
stackoverflow.com/questions/9047072/windows-python-version-and-vc-redistributable-version -
安装完成后,转到新安装的文件夹,它应该是类似于
C:\Program Files\Microsoft Visual Studio 9.0\VC\redist\x86\的路径,尽管这可能会根据你的版本和位架构而有所不同。 -
一旦到了那里,你将找到一个名为
Microsoft.VC90.CRT的文件夹,其中包含以下文件:-
Microsoft.VC90.CRT.manifest -
msvcm90.dll -
msvcp90.dll -
msvcr90.dll
作为免费许可证的一部分,Microsoft 要求你将整个文件夹包含在你的应用程序中,所以请将其复制到你的
dist文件夹中。 -
-
现在,你的 EXE 文件应该总是能够找到所需的 DLLs。如果你遇到麻烦或需要更多信息,请查看官方 py2exe 教程中的 DLL 部分,链接为
www.py2exe.org/index.cgi/Tutorial#Step5。
你现在已经成功打包了你的应用程序,并使其变得便携!注意整个应用程序仅重 30MB,这使得上传、下载甚至通过电子邮件发送变得轻而易举。如果你使用推荐的 32 位 Python 构建了应用程序和包,你的程序应该能在任何 Windows 7 或 8 计算机上运行(在 Python 和 EXE 文件的眼中,它们基本上是相同的)。这包括 32 位和 64 位 Windows,因为 64 位代码与 32 位代码向后兼容。如果你使用了 64 位 Python,它将仅适用于那些拥有 64 位 Windows 的用户,这并不理想。
创建安装程序
到目前为止,你可以理论上将你的dist文件夹作为一个便携式 GIS 应用程序放在 U 盘上,或者通过 ZIP 存档与他人共享。这在一定程度上是可以的,但如果你希望面向更广泛的受众,这不是分发应用程序最专业或最可信的方式。为了运行应用程序,用户(包括你自己)必须在一个他们不理解的长名单中找到 EXE 文件。这仅仅是太多应该直接从盒子里出来的血腥细节和手动工作。
更常见的是,人们习惯于接收一个安装程序文件,该文件指导用户在更永久的位置安装程序,并从他们那里创建快捷方式。这不仅看起来更专业,而且也处理了用户的高级步骤。作为最终步骤,我们将为我们的 GIS 应用程序创建这样一个安装程序,使用广泛推荐的安装软件Inno Setup。
安装 Inno Setup
要安装 Inno Setup,请按照以下步骤操作:
-
点击左侧的Inno Setup链接。
-
点击左侧的下载链接。
-
在稳定标题下,下载并安装名为
isetup-5.5.5.exe的文件。
设置应用程序的安装程序
一旦运行 Inno Setup,您将看到一个欢迎屏幕,您应该选择使用脚本向导创建一个新的脚本文件,如图所示:

这为您提供了一个逐步向导,您将执行以下操作:
-
在向导的第一屏,保持复选框未勾选,并点击下一步。
-
在第二屏,提供应用程序的名称和版本,以及出版者名称和适用的网站。
-
在第三屏,保留默认的安装位置。
-
第四屏是最关键的一屏:您通过点击添加文件夹(您可能需要将其重命名为应用程序的名称)来告诉安装程序您的 EXE 文件位置以及整个自包含的
dist文件夹位置。 -
在第五屏,保留默认的开始菜单选项。
-
在第六屏,您可以提供许可证文本文件,以及/或一些自定义信息文本,用于显示在安装的开始和结束部分。
-
在第七屏,选择安装程序的语言。
-
在第八屏,将自定义编译器输出文件夹设置为您的应用程序名称(安装后的程序文件夹名称),将编译器输出基本文件名(安装程序文件名称)设置为
[您的应用程序名称]_setup,并将自定义安装图标文件设置为之前创建的图标。 -
在第九屏,点击完成以创建安装程序。
-
当提示保存设置脚本时,选择是,并将其保存与您的
setup.py脚本一起,以便您可以在以后重建或修改安装程序。
这样,您现在应该有一个带有您图标的应用程序安装文件,它将引导用户安装您新创建的 GIS 应用程序。您所有的辛勤工作现在都整齐地封装在一个文件中,最终可以与更广泛的受众共享和使用。
摘要
在本章中,您完成了创建 GIS 应用程序的最终打包步骤。您通过给应用程序添加一个显示在可执行文件和应用程序窗口中的标志图标来给它一个完美的收尾。然后,我们将应用程序打包在一个自包含的文件夹中,可以在任何 Windows 7 或 8 计算机上运行(包括 32 位和 64 位系统,前提是您使用了 32 位 Python)。最后,我们通过创建一个安装向导来给应用程序一个更“官方”的介绍和安装,使其看起来更专业。您的应用程序的最终用户不需要知道 Python 编程或它被用来制作程序的事实。他们唯一需要的是运行您友好的安装文件,然后他们可以通过点击 Windows 桌面或开始菜单上新添加的快捷方式来开始使用您的应用程序。
完成了从零开始到结束制作一个简单的 Python GIS 应用程序的步骤后,继续阅读最后一章,我们将快速回顾所学到的知识,并考虑为你进一步扩展和定制你自己的应用程序的可能路径和建议。
第八章. 展望未来
恭喜!您现在已经成为您自己的 GIS 应用程序的骄傲所有者;但并非真的如此。实际上,您只是开始了这段旅程。我们创建的应用程序仍然非常基础,尽管它具有一些核心基本功能,但还缺少许多其他功能。您可能也有一些想法和自定义设置想要自己实现。在我们让您自己去应对之前,在本章的最后,我们将探讨一些您可以继续前进的方法:
-
应该改进现有用户界面的区域
-
使用我们的工具包构建替代 GUI 布局的几点建议
-
向应用程序添加额外 GIS 功能的建议
-
如何在 Mac 和移动设备等额外平台上支持您的应用程序
改进用户界面
在本书中我们创建的应用程序中,我们试图给它一个现代且直观的设计。然而,由于我们必须在构建 GIS 内容的同时保持这种平衡,因此有几个用户界面方面我们没有能够解决。
保存和加载用户会话
我们通用用户界面中缺失的一个明显功能是没有保存或加载用户会话的方法。也就是说,保存当前加载图层的状态及其属性、图层顺序、通用地图选项、投影、缩放级别等,以便我们可以返回到之前使用的相同应用程序会话。主页标签将是一个放置加载和保存会话按钮的好地方,这个按钮也可以通过键盘快捷键 Ctrl + O 和 Ctrl + S 来调用。
为了保存这些设置,我们不得不想出一个文件格式规范以及一个可识别的文件名扩展名。例如,这可以是一个以 .pgs 结尾的简单 JSON 文本文件(如果您的应用程序名称是 Python GIS,那么这就是它的缩写),其中包含一个或多个选项字典。可以根据原始文件路径重新加载图层,也许还可以强制用户保存任何虚拟图层到文件中。
文件拖放
使用添加图层按钮添加数据图层是可行的,但有时每次都需要重复定位文件,尤其是如果它们位于多个位置的深层嵌套文件夹中。从已经打开的 Windows 文件夹拖放一系列文件到应用程序窗口通常是添加图层的一种更受欢迎的方式。目前,我们还没有在我们的应用程序中添加对此的支持,因为 Tkinter 没有内置的检测应用程序间拖放的功能。
注意
幸运的是,在 SourceForge 上存在一个名为 TkDND 的Tk扩展,您需要设置它:sourceforge.net/projects/tkdnd/。以下是在 StackOverflow 上发布的 Python 包装器,它应该可以让您在 Tkinter 应用程序中访问这个Tk扩展:stackoverflow.com/questions/14267900/python-drag-and-drop-explorer-files-to-tkinter-entry-widget。
GUI 小部件
我们在应用程序框架上投入了大量精力,用于调整和创建我们自己的自定义小部件模板,目的是为了小部件的样式化和代码重用性。随着您的前进,我建议进一步遵循这种逻辑,使其更容易构建和扩展用户界面。例如,在我们的RunToolFrame中,我们创建了一个方法,可以在该特定框架内添加常用的小部件组合。然而,为了使其更加灵活,您可以将这些组合变成独立的小部件类,这样您就可以在应用程序的任何地方放置它们。特别是,我建议为您的控件添加滚动条,这是我们当前应用程序所缺少的。
在更表面的层面上,尽管 Tkinter 通常看起来不错,尤其是在自定义样式下,但我们的一些应用程序小部件仍然显得有些不协调,比如下拉选择菜单。不过,通过一些样式实验,您应该能够改善其外观。或者,Python 2.7 及更高版本包含一个名为ttk的 Tkinter 扩展模块,它提供了许多新的下拉小部件,如 ComboBox。如果您选择切换到 ttk 小部件,请注意的唯一区别是,它们使用不同的方法进行样式化,这需要您对基于旧 Tkinter 的代码进行更改。
用户界面的其他变体
我们构建灵活的 GIS 相关小部件工具包的方法之美在于,它们可以以任何数量的方式使用、定位和组合,而不是将自己锁定在 GIS 的传统“图层面板-地图视图”布局中。例如,这里有一些创建不同类型 GIS 应用程序和布局的有用方式的有趣示例。
而不是仅仅一个地图的 GIS 应用程序,您可以将窗口分割成多个窗口,比如中间有一个图层面板的 2 个或 4 个地图。通过将每个地图连接到相同的图层组和图层面板,您定义的图层序列和符号化将影响所有地图,但您还可以拥有多个视角查看相同的数据,在不同的位置和缩放级别。参考以下截图:

或者,你不必在所有地方都拥有所有的小部件。你可以创建一个极简的仅地图应用程序,其中图层可以预先加载和/或以不同的或更隐蔽的方式进行管理。或者,你可以拥有一个更注重管理的应用程序,其中只包含图层面板和用于管理和组织文件的功能。
最后,请记住,我们所有的部件都是根据app/toolkit/theme.py模块中的颜色和字体说明进行样式化和轻松更改的。我们这样设计是有原因的,所以请充分利用它!
添加更多 GIS 功能
你可能希望将许多 GIS 功能添加到你的应用程序中。在许多现有的模块和库中,这里只是提供了一些建议,关于通常需要和可能做到的事情。更全面的列表可以在www.pythongisresources.wordpress.com或 Python 包索引网站上找到。
注意
对于一些这些工具的更深入实现,以及如何在 Python 中实现 GIS 应用程序的进一步阅读和想法,请参阅 Erik Westra 所著的Python 地理空间开发 - 第二版。
基本 GIS 选择
我们还有一些核心数据选择功能尚未实现。重要的是,这些包括基于数据查询对图层进行子集化,或基于区域边界框或与其他图层的重叠进行空间裁剪的能力。这两者都应该像遍历特征并仅保留来自属性查询或空间查询的匹配项一样简单。查看存储在矢量数据中的实际信息的能力也是我们目前所缺乏的,例如在表格中或在特征识别工具中,用户可以点击任何矢量特征或栅格单元并查看它们的属性或值。
更高级的可视化
目前,我们的应用程序在可视化数据方面不太灵活。矢量数据以单个随机颜色渲染所有特征,栅格数据以灰度或 RGB 渲染,没有更改的能力。然而,使用我们的 RunToolFrame 小部件,应该很容易将其打包到图层属性窗口中的符号选项卡中,并分配输入小部件和更新该图层styleoptions字典并重新绘制它的函数。
尽管如此,GIS 可视化的一个标志是我们也应该能够根据每个矢量特征的属性来改变这些颜色和大小,以便可视化模式。同样,我们需要能够根据其属性在特征上渲染文本来标记图层。最后,我们应该能够向地图添加制图元素,例如添加自定义标题、放置图例、比例尺和指向北方的箭头。这些都是你可以努力改进的一些非常激动人心的领域。
在线数据服务
对于我们的应用程序,我们构建了通过指向您计算机上的文件路径来加载数据的功能,这是在 GIS 中工作的传统方式。但是,直接从网络加载通用背景数据或定期更新的数据流,如卫星图像,变得越来越常见,这可以通过开放地理空间联盟(OGC)的 Web 服务接口标准实现。
在 Python 中,我推荐使用 OWSLib,它允许你访问各种在线服务和数据源,并且提供了很好的文档来学习更多关于它的信息。
注意
为了更具体的例子,看看 PyEarthquake 是如何使用 Web 服务检索实时地震数据的:
blog.christianperone.com/?p=1013
在栅格和矢量数据之间进行转换
将栅格网格转换为正方形多边形或中心点的矢量数据,以便进行更定制化的处理,或者将矢量数据转换为给定分辨率的栅格网格,这是经常需要的。这两种功能目前在我们的应用程序中缺失,但应该在现有的框架内实现起来既简单又实用。将矢量数据栅格化本质上等同于在图像上绘制它,因此你可以直接绘制到所需的栅格分辨率,使用 PIL 或 PyAgg。要将栅格数据矢量化,你可以遍历栅格的单元格,并在每个单元格的x和y坐标(或基于单元格边界框的多边形几何)处创建一个点几何。或者,你可以使用 GDAL,它已经具有栅格化和矢量化功能的函数。
投影
如目前所示,我们的应用程序可以处理和可视化定义在任何投影中的数据,但它不能在这些投影之间进行转换。所以如果有多个数据具有不同的投影,那么就没有办法正确地将它们定位或分析彼此之间的关系。幸运的是,PyProj 是一个基于 PROJ4 的优秀的、广泛使用的 Python 包,用于将坐标从一个投影转换为另一个投影,并且相当轻量级。有了这个,你可以添加定义和转换图层投影的工具,以及设置所有图层即时重投影到通用地图投影的工具。
最困难的部分是投影存储的格式种类繁多,例如 EPSG 代码、OGC URN 代码、ESRI WKT、OGC WKT、+proj 字符串和 GeoTIFF 定义,仅举几例。PyProj 要求将投影定义为+proj 字符串,因此挑战在于正确检测、解析和将文件存储的任何投影格式转换为+proj 格式。GDAL 是处理这些转换的最佳方式,或者如果你只期望接收 EPSG 等代码,可以使用www.spatialreference.org。
地理编码
今天,使用免费的在线搜索网站及其编程友好的 API,将地址和其他文本信息地理编码到坐标中相对容易。GeoPy 是一个 Python 包,它提供了访问众多在线地理编码服务的权限,例如 OpenStreetMap、Google、Bing 以及许多其他服务。这可以添加到你的应用程序中,既可以作为一个基于包含文本位置的字段的表地理编码的工具,也可以提供一个交互式地理编码搜索小部件,该小部件在地图上显示结果匹配项。
采用 GDAL/NumPy/SciPy 方案
如果你,在某个时刻,决定将 GDAL、NumPy 和 SciPy 添加为应用程序的依赖项,这将使你的应用程序增加大约 100 MB 的额外大小,但也将打开许多新的大门。例如,不同投影格式之间的转换问题将通过 GDAL 中提供的函数得到解决。添加 GDAL 和 NumPy 还将让你添加大量新的数据加载和保存功能,特别是通过 PyResample、RasterStats 和甚至通过 SMEAR 进行栅格插值等方法,打开栅格管理、分析和重采样的方法。对于矢量数据,它还将为 PySAL 或使用 PyCluster 的各种聚类算法提供更高级的空间统计和热点分析。Matplotlib 结合 Basemap 或 Cartopy 可能会提供你需要的所有视觉投影支持,而无需你做太多额外的工作。
扩展到其他平台
目前,我可以证实该应用程序在 Windows 7 上运行良好,甚至 Windows 8(特别是 MapView 的单点触摸导航特别有趣)。然而,在某个时刻,你可能会发现自己需要将你的 GIS 应用程序分享到除 Windows 之外的其他平台上。Python 和我们应用程序的大多数依赖项原则上都是跨平台的,我亲自测试过,我在本书中创建的应用程序框架在 Mac OS X 上也能运行,尽管安装说明略有不同。
当你完成应用程序的创建并准备分发它时,只需获取你想要支持的操作系统,安装必要的第三方库,然后复制并粘贴你的应用程序文件夹。如果应用程序在 Python 中运行良好,那么只需使用第七章中建议的操作系统打包库之一将其打包即可,第七章是“打包和分发你的应用程序”。
触摸设备
更时尚和令人兴奋的可能性是能够将你的应用移植到新一代的休闲平板电脑和其他移动触摸设备上。遗憾的是,我们当前的 Tkinter 用户界面方法无法打包用于或包含对 Android 或 iPhone 等手机或 iPad 等平板电脑的多点触控手势支持。如果你主要的目标受众是这些设备,你可能保留 GIS 处理引擎,但可能希望将用户界面切换到基于 Kivy 的界面,这是一个越来越受欢迎的新 GUI 包,它支持多点触控输入,据说还支持为 Android、iPhone 和 iPad 打包。如果你只想支持 iOS,那么 Pythonista 应用提供了一个 GUI 构建器,几个核心 Python 包如 PIL、NumPy 和 Matplotlib,访问 iOS 渲染引擎,甚至可以将你的应用打包成一个应用(尽管你仍然需要申请才能将其上传到苹果商店)。
摘要
我们在这本书的开始就寻求从头开始创建一个基本且轻量级的 GIS 应用。随着我们接近书尾,这正是我们所做的。基于一个相互链接的 Python 库底层代码库,我们有一个可分发的可视化用户界面应用,它可以执行基本的数据加载和保存、可视化、管理和空间数据分析。
至少,你已经了解了一些如何创建一个应用的方法。最好的部分在于,你可以完全控制对其进行调整、修改和进一步开发。如果你有特定的需求或者一个出色的自定义工作流应用的想法,只需查看可用的众多工具并自行构建。我非常兴奋地继续使用这个应用框架,并且特别好奇你将想出什么样的 GIS 应用。


浙公网安备 33010602011771号