原文:zh.annas-archive.org/md5/d1ae42ada99dbaf6b9dca5cf97abc0de
译者:飞龙
协议:CC BY-NC-SA 4.0
前言
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),如图所示:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_01_01.jpg
此外,通过从头开始创建自己的 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网站上的目录:
Python GIS 资源
我们将不得不定义要使用和安装哪些包,这取决于我们正在制作的应用程序类型。在这本书中,我们想要制作的是一个轻量级、高度便携、可扩展和通用目的的 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:
前往 pypi.python.org/pypi/Pillow/2.6.1.
点击我们 32 位 Python 2.7 环境的最新.exe文件链接以下载 PIL 安装程序,当前为Pillow-2.6.1.win32-py2.7.exe。
运行安装文件。
打开 IDLE 交互式外壳,并输入import PIL以确保它已正确安装。
我们还将使用另一个核心包 Shapely,用于位置测试和几何操作。要在 Windows 上安装它,请执行以下步骤:
前往 www.lfd.uci.edu/~gohlke/pythonlibs/#shapely。
下载适合我们系统的 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 不是文件夹名称的一部分):
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_01_02.jpg
为了使我们的新 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并注册,以便将文件直接通过电子邮件发送给您。
矢量数据结构
地理矢量数据可以被视为一个数据表。表中的每一行是一个观测值(例如,一个国家),并包含一个或多个属性,或关于该观测值的信息(例如,人口)。在矢量数据结构中,行被称为要素,并具有额外的几何定义(定义国家形状和位置的坐标)。因此,结构的概述可能看起来像这样:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_02_01.jpg
因此,在我们的矢量数据结构实现中,我们创建了一个名为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 库。执行以下步骤:
访问www.lfd.uci.edu/~gohlke/pythonlibs/#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
栅格数据结构
栅格由一个或多个称为 波段 的数据网格组成。这些网格以及它们每个 单元格 中的值表示信息如何在照片中类似像素的空间中流动:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_02_02.jpg
由于栅格数据与影像数据的相似性,我们利用之前导入的现有 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轴:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_02_03.jpg
最后,我们还应考虑栅格数据的一个方面,即每个单元格中坐标偏移的锚定位置;要么在单元格中心,要么在其四个角中的任何一个。我们将此信息存储为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 4×4 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 4×4 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 文件夹中,我们创建了三个新的模块,使我们能够访问、编辑和共享一些流行的地理数据格式。因此,我们的文件夹结构应该看起来像这样:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_02_04.jpg
这是我们任何类型的 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文件夹结构应该看起来像这样:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_03_01.jpg
创建工具包构建块
在我们开始设计我们的 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
功能区标签系统
接下来是功能区小部件,它受到微软办公软件新版本的启发,不仅将为我们的应用程序带来时尚现代的外观,还将提供我们所需要的组织水平和简洁性,以避免吓跑我们应用程序的非技术用户。就像上一节中相关的按钮被分组到工具栏中一样,这里工具栏可以被分组到窗口顶部的功能区区域,可以切换和翻页,就像笔记本一样,如下面的截图所示:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_03_02.jpg
在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类,它由一个输入区域组成,所有选项都将构建在左侧,一个帮助区域在右侧,以及一个在底部运行工具的按钮:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_03_03.jpg
下面是创建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 构建块,我们只需要将它们全部组合起来,以创建我们的第一个应用程序:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_03_04.jpg
要做到这一点,请重新打开我们在本章开头创建的空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 上打开一个看起来像这样的应用程序:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_03_05.jpg
小贴士
你可能还希望开始收集一些矢量文件和栅格文件,以便稍后使用应用程序进行测试。一个获取这些文件的好地方是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 允许锁定请求的视图范围的宽高比,使其与画布图像本身的宽高比相同,以避免地理数据变形或拉伸。请参考以下图示:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_04_01.jpg
在启动时,在添加任何数据之前,我们使用 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 添加一个删除按钮,并在左侧添加一个复选框来切换其可见性,如图所示:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_04_02.jpg
删除按钮将需要一个图标,所以让我们先获取一个:
前往图标网站,例如 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。参见图表:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_04_03.jpg
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,尝试添加一些数据并与地图进行交互。如果你一切都做得正确,并且根据你的数据,你的屏幕应该看起来像这样:
https://github.com/OpenDocCN/freelearn-ds-zh/raw/master/docs/py-geospt-dev-ess/img/5407OS_04_04.jpg
摘要
本章是一个基本的里程碑。我们基于可重新排列的层在 LayerGroup 中构建了一个工作地理渲染模块,创建了一个用于交互显示这些地图渲染的 MapView 小部件,制作了我们地图中层的可视化 LayersPane,并启用了 MapView 的交互式缩放和平移。
在遵循每个步骤之后,你现在应该拥有一个看起来和感觉像 GIS 数据检查应用程序的东西。当然,一个更复杂的 GIS 需要额外的功能,不仅用于检查数据,还用于管理和编辑数据——这正是我们接下来要做的。