Python-地理信息处理-全-
Python 地理信息处理(全)
原文:Geoprocessing with Python
译者:飞龙
第一章. 简介
本章涵盖
-
介绍空间数据的基本类型
-
地理处理是什么?
-
使用 QGIS
人类制作地图的历史比我们书写的历史要长得多,甚至法国著名的拉斯科洞穴的墙上也有星图。我们知道世界各地的人们都使用过地图,包括巴比伦人、希腊人和中国人。制图艺术在千年间不断发展,从洞穴墙壁作为媒介到泥板、羊皮纸、纸张,再到现在的数字。随着技术的发展和改进,地图也变得更加详细和准确。事实上,我们大多数人可能很难将最原始的地图识别为地图。
人类从洞穴墙壁到大规模生产的道路地图的转变经历了很长时间,但过去几十年的变化程度是惊人的。地理信息系统(GIS)变得更加普遍且易于使用,使更多的人能够分析空间数据并制作自己的高质量地图。随后出现了网络地图和在线制作并分享地图的服务。我们中许多人甚至随身携带可以显示当前位置并告诉我们如何到达想要尝试的新餐厅的设备。不仅如此,可用的数据也发生了巨大的变化。那些早期地图的制作者会对我们叠加在航空摄影上的道路地图和我们的语音 GPS 单位感到震惊。
多亏了这些最近的技术进步以及免费和开源的工具,你现在可以访问到强大的软件来处理自己的数据。本书旨在教你处理空间数据的基本概念以及如何使用 Python 编程语言和一些开源工具来完成这些工作。阅读完这本书后,你将能够编写 Python 脚本来解决基本的数据分析问题,并拥有回答更复杂问题的背景知识。
1.1. 为什么使用 Python 和开源工具?
使用 Python 和开源工具处理空间数据有几个令人信服的理由。首先,Python 是一种功能强大的编程语言,它比其他一些语言更容易学习,而且易于阅读。如果你以前从未编程过,Python 是一个很好的起点,如果你来自其他语言,你可能会发现 Python 很容易上手。
学习 Python 是一个好的选择,即使你在阅读这本书之后再也不用它进行空间分析。Python 有许多不同的模块可供广泛的应用,包括网络开发、科学数据分析以及 3D 动画。实际上,地理空间应用只是 Python 应用的一部分。
此外,Python 是多平台的,除非你使用了针对特定操作系统的额外模块,否则你在一台机器上编写的 Python 脚本可以在任何其他机器上运行,前提是安装了所需的模块。你可以使用你的 Linux 机器开发一系列脚本,然后将它们交给使用 Windows 的同事,一切应该都能正常工作。你确实需要安装一个 Python 解释器来运行代码,但这些解释器在主要的桌面操作系统上都是免费可用的。
Python 随带核心语言和许多可选模块,你可以在代码中使用这些模块。此外,还有更多模块可以从其他来源获得。例如,Python 包索引(PyPI),可在 pypi.python.org/pypi 找到,列出了超过 60,000 个额外的模块,它们用于不同的目的,且全部免费。但这并不意味着 Python 的一切都是免费的。无疑,许多来自地理信息系统背景的人对 ArcPy 都很熟悉,这是一个与 ArcGIS 一起提供的 Python 模块,没有 ArcGIS 许可证是无法使用的。
不仅有许多免费的 Python 包,而且其中许多也是开源的。尽管许多人将开源软件与免费软件联系起来,但这只是其中一部分。真正的含义是,如果你愿意,源代码是可供你使用的。你能够访问源代码意味着没有什么是一个“黑盒”(如果你愿意花时间去了解盒子里面的内容),而且你也可以修改代码以满足你的需求。这是非常自由的。我曾使用过一些开源工具,它们并没有完全满足我的需求,所以我修改了源代码,重新编译,然后得到了一个正好满足我需求的实用工具。这是专有软件所无法做到的。与开源软件相关的这两种自由使其成为一个有吸引力的模式。
存在着几种不同的开源许可证,其中一些不仅允许你根据需要修改代码,甚至允许你转而销售你的衍生作品,而无需提供源代码和你的修改。其他许可证则要求,如果你使用该软件,那么你的软件也必须是开源的。
本书将介绍几个流行的开源 Python 地理空间数据模块。其中一些最初是在其他语言中开发的,但变得非常普遍和受到尊重,以至于它们被移植到其他语言,或者开发了绑定,以便可以在其他语言中使用。例如,地理空间数据抽象库(GDAL)是一个用于读取和写入空间数据的极受欢迎的 C/C++ 库,并为 Python、.NET、Ruby 和其他语言开发了绑定。GDAL 库甚至被许多专有软件包使用。由于该库的广泛应用,本书重点介绍了 GDAL/OGR。如果你能学会使用它,那么迁移到其他库应该不会很难。实际上,在 GDAL/OGR 之上构建了几个很好的库,它们可能更容易使用,但并不一定提供 GDAL 中存在的所有功能。有关本书中使用的模块的安装说明,请参阅附录 A。
选择开源工具的另一个优点是,一些这些软件包存在活跃的用户社区,你可能会发现与许多专有软件包相比,错误和其他问题得到解决的速度要快得多。你甚至可以通过电子邮件列表与实际的开发者讨论库的细节。
1.2. 空间数据类型
你将学习如何处理两种主要的空间数据类型:矢量和栅格。矢量数据由点、线和多边形组成,而栅格数据是由数据值组成的二维或三维数组,例如照片中的像素。包含国家边界的数据集是矢量数据的一个例子。在这种情况下,每个国家通常表示为一个多边形。使用线条表示道路或河流,或使用点表示气象站位置的数据集是其他例子。早期的原始地图,如绘制在洞穴墙壁上的地图,仅显示了特征本身。后来的地图包含了感兴趣特征的标签,例如城市或海港;例如,图 1.1 中所示的西北非洲的波托兰地图。
图 1.1. 约于 1590 年的非洲西北海岸的波托兰地图

使用数字数据,你可以将多个属性值附加到每个特征上,无论你计划是否在地图上显示信息。对于每条道路,你可以存储诸如名称、速度限制、车道数或其他任何你能想到的信息。图 1.2 显示了可能存储在数据集中每个国家中的数据示例。
图 1.2. 你可以在数据集中为每个地理特征存储属性,如名称和人口。

为什么这有用的几个原因中,明显的一个是你可以使用其中一个属性来标记要素。例如,图 1.2 可以显示国名以及轮廓。所有这些数据也可以帮助你制作更有趣的地图,甚至可能讲述一个故事。存储在图 1.2 中每个要素中的人口计数可以用来根据人口象征国家,这样一眼就能看出哪个国家人口最多(图 1.3)。
图 1.3. 根据人口象征的国家

使用矢量数据进行空间叠加分析也很容易。比如说,你想知道维多利亚湖在乌干达、肯尼亚和坦桑尼亚中的百分比。你总是可以根据图 1.4 猜测答案,但你也可以使用 GIS 软件获得更准确的数字。当你完成这本书时,你将能够进行这样的简单分析。
图 1.4. 维多利亚湖横跨乌干达、肯尼亚和坦桑尼亚。空间分析可以帮助你确定湖泊在每个国家中的比例。

附属于要素的属性值也可以增强空间操作的能力。例如,假设你有一个包含水井位置的数据集,其属性包括深度和流量。如果你还有同一区域包含地质地形或土壤类型的数据集,你可以分析这些数据以查看流量或所需井深是否受地形或土壤类型的影响。
与早期的地图制作者不同,你还可以访问栅格数据。栅格,正如数据集被称为,是值的两维或三维数组,就像照片是像素值的两维数组一样。事实上,如图 1.5 所示的航空照片是一种常用的栅格数据类型。卫星图像有时看起来很相似,尽管它们的分辨率通常较低。关于卫星图像的酷之处在于,其中很大一部分是使用非可见光收集的,因此它可以提供简单照片无法提供的信息。
图 1.5. 华盛顿州西雅图附近的一张航空照片

栅格数据集非常适合任何连续数据,而不仅仅是照片。如图 1.6 所示的降水数据就是一个很好的例子。雨水通常不会在突然的边界处停止,因此很难围绕它绘制多边形。相反,降水量的网格工作得更好,并且可以更容易地捕捉局部变化。同样的想法也适用于温度数据以及许多其他变量。另一个例子是数字高程模型 (DEM),其中每个像素包含一个高程值。
图 1.6. 显示降水的栅格数据集(PRISM 气候组,俄勒冈州立大学,2015)

栅格数据比矢量数据更适合进行不同类型的分析。卫星图像和航空照片常用于植被制图等任务。因为水只向下流,所以高程模型可以用来确定流域边界。甚至简单的数学运算也可以用来使用栅格数据进行有用的分析。例如,一个波长值与另一个波长值的简单比率可以帮助识别健康植被或测量土壤湿度。
相邻像素块也可以用来计算有用的信息。例如,你可以使用数字高程模型(DEM)来计算坡度,这可以用于径流分析、植被制图或滑雪胜地规划。但要计算坡度,你需要周围单元格的高程。在图 1.7 中,你使用显示的所有像素值来计算中心像素的坡度。对于任何其他像素,你需要周围九个单元格来计算它的坡度。这些像素集被称为窗口,你可以通过在栅格上移动窗口来进行许多其他类型的分析,使每个像素都位于其自己的窗口中心。
图 1.7. 这里显示的所有九个高程值都将用于计算中心像素的坡度。

向量和栅格数据也可以一起使用。想象一下一个混合网络地图应用,它显示一个带有道路的照片底图。底图是栅格数据,而上面显示的道路是矢量。图 1.8 展示了使用科罗拉多大峡谷的栅格 DEM 作为底图,并在其上显示矢量线数据集的简单地图示例。
图 1.8. 在栅格高程数据集上绘制矢量道路层的科罗拉多大峡谷简单地图

1.3. 地理处理是什么?
地理处理是一个通用的术语,用于操作空间数据,无论是栅格还是矢量。正如你可以想象的那样,这涵盖了大量的领域。我一直认为使用 GIS 进行地理处理就像使用统计学一样,它几乎可以应用于任何事物。即使你没有意识到,你甚至每天都在使用地理处理。例如,我倾向于根据我是开车还是骑自行车上班选择不同的路线,因为我更喜欢避开没有车道的拥堵道路。在开车时,陡峭的山丘也不是问题,但在骑自行车时却是。基于空间因素,如道路方向和海拔增益,以及属性,如交通量和道路宽度来选择路线,这是一种地理处理。你可能每天都在做出类似的决策。
除了选择上班路线之外,你有很多理由对地理处理感兴趣。让我们看看一些应用实例。早期空间分析的一个著名例子是 19 世纪居住在英国的医生约翰·斯诺的故事。尽管故事的部分内容存在争议,但其大意是,他使用空间分析来确定 1854 年霍乱爆发的病因。他的地图的一部分显示在图 1.9 中,中间是布罗德街水泵。你可以看到这些条形图看起来像是锚定在附近的街道上。每个条形由水平线组成,每条线代表一个霍乱受害者。斯诺意识到,大多数受害者可能都是从布罗德街的水泵那里获取的水,因为那是最近的一个,他说服当局关闭了水泵。这不仅是因为它是空间分析的早期例子,而且还因为当时尚未知道霍乱是通过受污染的水传播的。正因为如此,斯诺被认为是现代流行病学的奠基人之一。
图 1.9. 1854 年索霍霍乱爆发时约翰·斯诺地图的一部分

空间分析仍然是流行病学的一个重要部分,但它也被用于许多其他事情。我参与过的研究项目包括研究受威胁物种的习惯、模拟大面积的植被覆盖、比较洪水前后事件的数据以查看河流渠道的变化,以及模拟森林中的碳封存。你可能在任何你感兴趣的地方都能找到空间分析的例子。让我们考虑一些更多的例子。
中国研究人员罗等^([1]) 使用空间分析和历史记录来确定丝绸之路沿线缺失的驿站位置。历史记录包含了路线描述,包括站点之间的旅行距离和一般方向。已知几个站点的位置,研究人员知道古代旅行者不太可能沿直线行进,而是会沿着河流或其他地形行进。他们利用所有这些信息来确定仍缺失的站点可能的地理位置。然后他们使用高分辨率卫星图像在这些区域搜索可能为驿站遗址的几何形状。在亲自访问这些地点后,他们确定其中一个是古老的驿站,另外两个很可能是汉代的军事设施。
¹
罗洛,王翔,刘晨,郭辉,杜翔. 2014. 面向中国西北部河西走廊考古勘探的集成遥感、GIS 和 GPS 方法:古代敦煌皇家道路案例研究. 考古科学杂志. 50: 178-190. doi:10.1016/j.jas.2014.07.009.
对于一个完全不同的应用,Moody 等人^([2])对使用微藻作为生物燃料的潜力感兴趣。他们使用了一个微藻生长模型和来自全球各地不同位置的气象数据来模拟生物质生产力。由于气象数据仅来自某些地点,因此结果随后进行了空间插值,以提供全球生产力潜力的地图。结果发现,最有潜力的地点在澳大利亚、巴西、哥伦比亚、埃及、埃塞俄比亚、印度、肯尼亚和沙特阿拉伯。
²
Moody, J. W., C. M. McGinty, and J. C. Quinn. 2014. 微藻生物燃料潜力的全球评估。美国国家科学院院刊。111: 8691-8696. doi: 10.1073/pnas.1321652111.
这很有趣,但空间分析也影响着你的日常生活。你注意到你的汽车保险费率会根据你居住的地方而有所不同吗?很可能某种空间分析也影响了你最喜欢的咖啡店或杂货店的地理位置。在我所在的社区,正在建设几所新的小学和中学,它们的地理位置部分是由未来学生的空间分布以及合适的地产可用性决定的。
空间分析并不仅限于地理学。Rose 等人^([3])证明了 GIS 可以用来分析骨骼中纳米和微结构的分布。他们可以利用这一点来观察骨重塑事件如何与经历高压缩和拉伸的骨骼部分相对应。
³
Rose, D. C., A. M. Agnew, T. P. Gocha, S. D. Stout, and J. S. Field. 2012. 技术简报:使用地理信息系统软件进行骨微结构的空间分析。美国物理人类学杂志。148: 648–654. doi: 10.1002/ajpa.22099.
你可能需要使数据更适合地图,例如消除不需要的特征或简化复杂的线条,以便它们在网页地图上更快地显示。或者你可能分析人口统计数据来规划未来的交通需求。也许你对植被如何对不同土地管理实践(如预定燃烧或割草)做出反应感兴趣。或者可能完全是其他事情。
虽然地理处理技术可能相当复杂,但许多都是相当简单的。这本书中你会了解到这些简单的技术,但它们是其他一切的基础。等你完成时,你将能够以多种格式读取和写入空间数据,包括矢量和栅格。你将根据属性值或空间位置对矢量数据进行子集化。你将知道如何执行简单的矢量地理处理,包括叠加和邻近分析。此外,你还将知道如何处理栅格数据集,包括调整像素大小、基于多个数据集进行计算以及移动窗口分析。
你将知道如何使用 Python 而不是通过在软件包中点击按钮来完成所有这些操作。以这种方式脚本化你的流程是非常强大的。这不仅使得批量处理多个数据集变得容易(我经常这样做),而且还让你能够自定义你的分析,而不仅仅是受限于软件用户界面允许的功能。你可以根据你的工作流程构建自己的自定义工具包,并反复使用这些工具包。自动化是另一个很大的优势,这也是我最初爱上脚本化的原因。我讨厌点击按钮并重复做同样的事情,但我愿意花时间找出如何自动化某件事,这样我就再也不用去想它了。最后,我要提到的最后一个优势是,只要你没有丢失你的脚本,你就始终确切地知道你做了什么,因为所有的一切都明明白白。
1.4. 探索你的数据
在使用 Python 处理数据时,你会看到可视化数据的方法,但探索数据的最佳方式仍然是使用桌面 GIS 软件包。它允许你以多种方式轻松地在空间上可视化数据,同时还可以检查数据中包含的属性。如果你已经拥有 GIS 软件,QGIS 是一个很好的开源选择,并且在本书中需要时我们会使用它。它可以从 www.qgis.org 获取,并且可以在 Linux、Mac OS X 和 Windows 上运行。
可下载的代码和样本数据
本书中的示例使用的是可以从以下链接下载的代码和样本数据。如果你想跟上,你需要下载这些内容。代码包含本书中的示例,以及示例中使用的自定义工具,并且所有示例中使用的数据都包含在内。
-
代码:
github.com/cgarrard/osgeopy-code和 www.manning.com/books/geoprocessing-with-python -
数据:
app.box.com/osgeopy和 www.manning.com/books/geoprocessing-with-python
这不是一本关于 QGIS 的书,所以我不会过多地谈论如何使用它。他们的网站上提供了文档,你还可以找到一些关于这个主题的书籍。然而,我会简要地讨论如何加载数据并查看一下。如果你以前从未使用过 GIS,那么当你第一次打开 QGIS 时,它可能看起来有点令人畏惧,但使用它来查看数据并不难。例如,要加载本书示例数据中的一个 shapefile,请在 QGIS 的图层菜单中选择“添加矢量图层...”。在打开的对话框中,确保选择了文件按钮,然后使用浏览按钮选择一个 shapefile。一个好的起点是位于美国文件夹中的 countyp010.shp 文件(图 1.10)。
图 1.10. 向 QGIS 添加矢量层的对话框

在选择文件后,在“添加矢量图层”对话框中单击“打开”,空间数据将在 QGIS 中绘制,如图 1.11 所示。你可以使用放大镜工具(如图 1.11 中所示圆圈处)来放大地图的一部分。
图 1.11. 加载 countyp010.shp 后的 QGIS 窗口

你还会在左侧的图层列表中看到图层名称,在本例中为 countyp010。双击一个图层,你将获得一个属性对话框。如果你单击“样式”选项卡,那么你可以更改数据的绘制方式。让我们更改县图层,使其不是用相同的颜色绘制所有县,而是颜色取决于县所在的州。为此,从下拉列表中选择“分类”,将列设置为“STATE”,从下拉列表中选择一个颜色渐变,然后单击“分类”。你将看到所有州及其绘制颜色的列表,如图 1.12 所示。你可以通过从列表中选择一个新的颜色渐变、单击“删除全部”然后再次单击“分类”来更改颜色渐变。你还可以通过双击州缩写旁边的颜色块来更改列表中的特定条目。
图 1.12. QGIS 风格对话框配置为用不同颜色绘制每个州的县

注意:打印书籍读者:彩色图形
本书中的许多图形最好以彩色查看。电子书版本显示彩色图形,因此应在阅读时参考。要获取免费电子书(PDF、ePub 和 Kindle 格式),请访问 www.manning.com/books/geoprocessing-with-python 并注册您的印刷版书籍。
一旦你对颜色满意,请单击“应用”,颜色将在主 QGIS 窗口中应用(如图 1.13 所示)。
图 1.13. 将 图 1.12 中的符号应用于县图层的结果

你可以通过在图层列表中右键单击图层名称并选择“打开属性表”来查看附加到空间数据的属性数据。图 1.14 中显示的表中的每一行都对应于地图上绘制的一个县。实际上,尝试通过单击最左侧列中的数字来选择一行,然后单击“缩放到所选行”按钮(如图 1.14 中所示圆圈处),并观察发生了什么。
图 1.14. 县图层属性表

请花时间玩玩 QGIS,并至少阅读网站上的部分文档。这款软件功能强大,值得深入了解。我将在整本书中更多地讨论它,但不会过多。你将希望用它来检查样本数据和任何你创建的数据的结果。
1.5. 概述
-
Python 是一种功能强大的多平台编程语言,相对容易学习。
-
自由和开源软件不仅就价格而言是免费的(免费啤酒),而且在使用方式上也允许许多自由(言论自由)。
-
存在许多优秀的开源 Python 模块,用于处理矢量和栅格地理空间数据。
-
使用开源工具并不会牺牲质量。实际上,这些软件包中的一些也被专有软件所使用。
第二章. Python 基础知识
本章涵盖
-
使用 Python 解释器与编写脚本
-
使用 Python 的核心数据类型
-
控制代码执行顺序
你可以使用桌面 GIS 软件(如 QGIS)做很多事情,但如果你长时间处理空间数据,你不可避免地会想要做一些软件界面中不可用的操作。如果你懂得编程,并且足够聪明,你可以编写出正好满足你需求的代码。另一个常见场景是需要自动化重复处理任务,而不是一遍又一遍地使用点选方法。编程不仅比点选更有趣、更有智力挑战性,而且在重复性任务中效率也更高。你可以学习并使用许多语言,但由于 Python 与许多 GIS 软件包(包括 QGIS 和 ArcGIS)一起使用,因此它是一个处理空间数据的优秀语言。它也非常强大,同时相对容易学习,这使得它对于编程初学者来说是一个很好的选择。
使用 Python 的另一个原因是它是一种解释型语言,因此用 Python 编写的程序可以在任何带有解释器的计算机上运行,而且对于你可能会使用的任何操作系统,都存在相应的解释器。要运行 Python 脚本,你需要脚本和一个解释器,这与运行.exe 文件不同,例如,你只需要一个文件。但是,如果你有一个.exe 文件,你只能在 Windows 操作系统下运行它,如果你想在 Mac 或 Linux 上运行,这会是一个遗憾。然而,如果你有一个 Python 脚本,你可以在任何有解释器的位置运行它,因此你不再局限于单一的操作系统中。
2.1. 编写和执行代码
解释型语言的另一个优点是你可以以交互式方式使用它们。这对于探索和学习一门语言来说非常棒,因为你可以输入一行代码并立即看到结果。你可以在终端窗口中运行 Python 解释器,但使用与 Python 一起安装的简单开发环境IDLE可能更容易。IDLE 中存在两种不同类型的窗口,shell和编辑窗口。shell 是一个交互式窗口,你可以在其中输入 Python 代码并立即得到结果。如果你看到一个>>>提示符,就像图 2.1 中的那样,你就知道你正在查看一个交互式窗口。你可以在该提示符后输入代码,并通过按 Enter 键执行它。本书中的许多示例都是这样运行的,以展示结果。这种方法运行超过几行代码效率不高,而且它不会保存你的代码以供以后使用。这就是编辑窗口发挥作用的地方。你可以在 IDLE 的文件菜单中打开一个新窗口,它将包含一个空文件。你可以在那里输入代码,然后使用运行菜单执行脚本,尽管你需要首先将其保存为.py 扩展名。脚本的输出将被发送到交互式窗口。说到输出,在本书的许多交互式示例中,我输入一个变量名来查看变量包含的内容,但如果你是从脚本中运行代码,这就不起作用了。相反,你需要使用print来明确告诉它将信息发送到输出窗口。
图 2.1. IDLE shell 窗口

在图 2.1 中,我输入的字符串'Hello world!'和输出结果都进行了颜色编码。这种语法高亮非常有用,因为它可以帮助你一眼就识别出关键词、内置函数、字符串和错误信息。如果某些内容在你预期中应该变色但没有变色,这也可以帮助你发现拼写错误。IDLE 的另一个有用功能是自动补全。如果你开始输入一个变量或函数名,然后按下 Tab 键,就会弹出一个选项列表,如图 2.2 所示。你可以继续输入,它会缩小搜索范围。你还可以使用箭头键在列表中滚动。当你想要的单词被高亮时,再次按下 Tab 键,该单词就会出现在你的屏幕上。
图 2.2. 开始输入并按下 Tab 键,以获取与您输入匹配的可能变量或函数的列表。

因为 Python 脚本是纯文本文件,所以如果你不想使用 IDLE,就不必强制使用它。你可以使用你喜欢的任何文本编辑器来编写脚本。许多编辑器很容易配置,因此你可以在不离开编辑器的情况下直接运行 Python 脚本。查看你喜欢的编辑器的文档,了解如何这样做。专门为处理 Python 代码设计的软件包包括 Spyder、PyCharm、Wing IDE 和 PyScripter。每个人都有自己的首选开发环境,你可能需要尝试几种不同的环境,才能找到一个你喜欢的环境。
2.2. 脚本的基本结构
在大多数 Python 脚本的顶部,你最先看到的一些东西可能是 import 语句。这些代码行加载额外的模块,以便脚本可以使用它们。模块基本上是一个代码库,你可以从脚本中访问和使用它,而庞大的专业模块生态系统也是使用 Python 的另一个优势。如果没有为这种用途设计的额外模块,你将很难在 Python 中处理 GIS 数据,这类似于 GIMP 和 Photoshop 等工具使处理数字图像变得更容易的方式。本书的整个目的就是教你如何使用这些工具来处理 GIS 数据。在这个过程中,你还将使用一些 Python 附带的一些模块,因为它们对于处理文件系统等任务来说是必不可少的。
让我们看看一个使用内置模块的简单示例。使用模块的第一件事是使用 import 加载它。然后,你可以通过在模块名前加上前缀来访问模块中的对象,这样 Python 就知道在哪里找到它们。这个例子加载了 random 模块,然后使用该模块中包含的 gauss 函数从标准正态分布中获取一个随机数:
>>> import random
>>> random.gauss(0, 1)
-0.22186423850882403
在 Python 脚本中,你可能还会注意到缺少分号和大括号,这些在其他语言中常用于结束行和设置代码块。Python 使用空白来实现这些功能。通常,你不会使用分号来结束一行,而是按 Enter 键开始新的一行。然而,有时一行代码太长,无法舒适地放在文件的一行中。在这种情况下,在合理的位置断行,例如逗号之后,Python 解释器就会知道这些行属于一起。至于缺少的大括号,Python 使用缩进来定义代码块。如果你习惯了使用大括号或 end 语句,这可能会让你一开始觉得奇怪,但缩进同样有效,并迫使你编写更易读的代码。正因为如此,你需要小心处理缩进。实际上,初学者经常因为不正确的缩进而遇到语法错误。例如,代码行开头多一个空格也会导致错误。你将在第 2.5 节中看到缩进是如何使用的示例。
Python 也是区分大小写的,这意味着大写字母和小写字母是不同的。例如,random.Gauss(0, 1) 在上一个例子中不会工作,因为 gauss 需要全部小写。如果你收到有关某些内容未定义的错误消息(这意味着 Python 不认识它),但你确信它存在,请检查你的拼写和大小写是否有误。
在代码中添加注释也是一个好主意,这有助于你记住代码的功能或为什么以某种方式编写它。我可以保证,在你编写代码时看似明显的事情,六个月后可能就不会那么明显了。当脚本运行时,Python 会忽略注释,但对于真正查看代码的人来说,注释可能非常有价值。要创建注释,请在文本前加上井号(#):
# This is a comment
除了注释外,描述性的变量名也能提高代码的可读性。例如,如果你将一个变量命名为 m,你需要阅读整个代码来弄清楚该变量存储了什么。如果你将其命名为 mean_value,其内容就会很明显。
2.3. 变量
除非你的脚本非常简单,否则在运行过程中需要一种存储信息的方法,这就是变量的作用。想想当你使用软件打开文件时会发生什么,无论文件类型如何。软件显示一个打开对话框,你选择一个文件并点击确定,然后文件被打开。当你点击确定时,所选文件的名称被存储为一个变量,这样软件就知道要打开哪个文件。即使你从未编写过任何程序,你也可能熟悉这个数学概念。回想一下代数课,根据 x 的值计算 y 的值。x 变量可以取任何值,而 y 会相应地变化。在编程中也有类似的概念。你会使用许多不同的变量,或 x,这些变量会影响脚本的输出。输出可以是任何你想要的东西,而不仅限于单个 y 值。如果目标是计算数据上的统计量,它可能是一个数字,但也可能是一个或多个全新的数据集。
在 Python 中创建变量非常简单。给它一个名字并赋予一个值。例如,这行代码将值 10 赋给名为 n 的变量,然后将其打印出来:
>>> n = 10
>>> n
10
如果你使用过其他编程语言,如 C++ 或 Java,你可能想知道为什么你不需要指定变量 n 将要存储整数值。Python 是一种动态类型语言,这意味着变量类型直到运行时才进行检查,你甚至可以更改存储在变量中的数据类型。例如,你可以将 n 从整数更改为字符串,而没有人会抱怨:
>>> n = 'Hello world'
>>> n
Hello world
虽然你可以在变量中存储任何你想要的内容而不必担心数据类型,但如果你试图以与存储在其中的数据类型不一致的方式使用变量,你将会遇到麻烦。因为数据类型直到运行时才会被检查,所以错误不会在脚本执行之前发生,因此你不会事先得到任何警告。你会在 Python 交互窗口中得到与脚本中发生的相同错误,所以如果你不确定某件事是否可行,你总是可以在那里测试示例。例如,你不能将字符串和整数相加,这显示了如果你尝试这样做会发生什么:
>>> msg = n + 1
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: Can't convert 'int' object to str implicitly
记住n包含Hello world,这不能与1相加。如果你使用 Python 2.7,问题的核心是相同的,但你的错误信息将如下所示:
TypeError: cannot concatenate 'str' and 'int' objects
注意,你使用单个等号来给变量赋值。为了测试相等性,始终使用双等号:
>>> n = 10
>>> n == 10
True
当你刚开始时,你可能更愿意在脚本中将值硬编码而不是使用变量,即使你不必这样做。例如,假设你需要在脚本中打开一个文件,可能在第 37 行。你可能会在文件打开时在第 37 行输入文件名。这当然可以工作,但你很快会发现,如果你在脚本早期定义一个包含文件名的变量并在第 37 行使用它,事情会更容易改变。首先,这使得找到你需要更改的值变得更容易,但更重要的是,这将使你更容易调整代码,以便在更多情况下使用它。而不是让第 37 行看起来像这样,
myfile = open('d:/temp/cities.csv')
你会在一开始就定义一个变量,并在需要时使用它:
fn = 'd:/temp/cities.csv'
<snip a bunch of code>
myfile = open(fn)
起初可能很难记住这样做,但如果你需要调整代码以使用其他数据,你会很高兴你做了这件事。
2.4. 数据类型
随着你的代码变得越来越复杂,你会发现将所有脚本所需的信息以数字和字符串的形式存储变得极其困难。幸运的是,你可以使用许多不同类型的数据结构,从简单的数字到可以包含许多不同类型数据的复杂对象。虽然可以使用无限数量的这些对象类型(因为你可以定义自己的),但只有少数核心数据类型存在,更复杂的数据类型都是基于这些核心数据类型构建的。我将在下面简要讨论其中几个。请参阅更全面的 Python 文档以获取更多详细信息,因为这里省略了许多信息。
2.4.1. 布尔值
布尔变量表示真或假值。两个区分大小写的关键字True和False用于表示这些值。它们可以用于标准的布尔运算,如下所示:
>>> True or False
True
>>> not False
True
>>> True and False
False
>>> True and not False
True
其他值在值测试和执行布尔运算时也可以解析为True或False。例如,0、None关键字、空字符串以及空列表、元组、集合和字典在布尔表达式中都解析为False。其他任何东西都解析为True。你将在第 2.5 节中看到这个例子。
2.4.2. 数字类型
正如你所期望的,你可以使用 Python 处理数字。然而,你可能不会预料到存在不同类型的数字。整数是整数,例如 5、27 或 592。另一方面,浮点数是带有小数点的数字,例如 5.3、27.0 或 592.8。知道 27 和 27.0 是不同的会令你惊讶吗?一方面,它们可能占用不同的内存量,尽管具体取决于你的操作系统和 Python 版本。如果你使用 Python 2.7,这两个数字在数学运算中的使用方式存在重大差异,因为整数不考虑小数位。看看这个 Python 2.7 的例子:
>>> 27 / 7
3
>>> 27.0 / 7.0
3.857142857142857
>>> 27 / 7.0
3.857142857142857
如你所见,如果你将一个整数除以另一个整数,你仍然会得到一个整数,即使有余数。如果操作中使用的数字之一或两个都是浮点数,你会得到正确答案。然而,在 Python 3.x 中,这种行为已经改变。现在无论哪种方式,你都会得到浮点数数学,但你仍然可以使用//地板除法运算符强制整数数学:
>>> 27 / 7
3.857142857142857
>>> 27 // 7
3
警告
Python 3.x 默认执行浮点数数学,即使是在整数上,但 Python 的旧版本如果所有输入都是整数,则执行整数数学。这种整数数学往往会导致不理想的结果,例如 2 而不是 2.4,在这种情况下,你必须确保至少有一个输入是浮点数。
幸运的是,你有一种简单的方法可以将一种数值数据类型转换为另一种类型,尽管请注意,以这种方式将浮点数转换为整数会截断数字而不是四舍五入:
>>> float(27)
27.0
>>> int(27.9)
27
如果你想要四舍五入数字,你必须使用round函数:
>>> round(27.9)
28
Python 还支持复数,它们包含实部和虚部。你可能还记得,这些值是在对负数开平方时得到的。我们在这本书中不会使用复数,但如果你感兴趣,可以在 python.org 上了解更多关于它们的信息。
2.4.3. 字符串
字符串是文本值,例如'Hello world'。你可以通过将文本用单引号或双引号包围来创建字符串——哪种都可以,尽管如果你以一种类型开始字符串,就不能用另一种类型结束它,因为 Python 不会将其识别为字符串的结尾。两种方式都可以使用的事实使得在字符串中包含引号变得容易。例如,如果你需要在字符串内部使用单引号,就像在 SQL 语句中那样,将整个字符串用双引号包围,如下所示:
sql = "SELECT * FROM cities WHERE country = 'Canada'"
如果你需要在字符串中包含与用于界定它的相同的引号类型,你可以在引号前使用反斜杠。这里的第一种情况会导致错误,因为“don’t”中的单引号结束了字符串,这不是你想要的。第二种情况由于反斜杠而有效:
>>> 'Don't panic!'
File "<stdin>", line 1
'Don't panic!'
^
SyntaxError: invalid syntax
>>> 'Don\'t panic!'
"Don't panic!"
注意 Python 遇到麻烦的地方下的撇号符号(^)。这可以帮助你缩小语法错误的位置。当字符串打印时,包围字符串的双引号不是字符串的一部分。它们表明这是一个字符串,在这个例子中很明显,但如果字符串是"42",则不会如此。如果你使用print函数,引号就不会显示:
>>> print('Don\'t panic!')
Don't panic!
小贴士
虽然大多数来自交互窗口的示例都没有使用print将输出发送到屏幕,但你必须使用它从脚本中发送输出到屏幕。如果不这样做,它就不会显示。在 Python 3 中,print是一个函数,就像所有函数一样,你必须将参数传递到括号内。在 Python 2 中,print是一个语句,括号不是必需的,但它们也不会造成任何问题。
字符串连接
你有几种方法可以将字符串连接起来。如果你只是连接两个字符串,那么最简单、最快的方法是使用+运算符:
>>> 'Beam me up ' + 'Scotty'
'Beam me up Scotty'
如果你需要连接多个字符串,format方法是一个更好的选择。它还可以连接不是所有都是字符串的值,这是+运算符无法做到的。要使用它,你创建一个模板字符串,该字符串使用大括号作为占位符,然后传递值以替换占位符。你可以在线阅读 Python 文档,了解你可以用这种方式进行复杂格式化的许多方法,但我们将查看指定顺序的基本方法。在这里,传递给format的第一个项目替换了{0}占位符,第二个替换了{1},依此类推:
>>> 'I wish I were as smart as {0} {1}'.format('Albert', 'Einstein')
'I wish I were as smart as Albert Einstein'
要看到数字占位符有何不同,尝试交换它们的位置,但保持其他一切不变:
>>> 'I wish I were as smart as {1}, {0}'.format('Albert', 'Einstein')
'I wish I were as smart as Einstein, Albert'
事实是占位符引用特定值意味着如果你需要在字符串中插入一个项目多次,你可以在多个位置使用相同的占位符。这样你就不必在传递给format的值列表中重复任何内容。
转义字符
记得你之前用来在字符串中包含引号的反斜杠吗?这被称为转义字符,也可以用来在字符串中包含不可打印的字符。例如,"\n"包含一个换行符,而"\t"代表一个制表符:
>>> print('Title:\tMoby Dick\nAuthor:\tHerman Melville')
Title: Moby Dick
Author: Herman Melville
Windows 使用反斜杠作为路径分隔符,这给使用 Windows 的初学者带来了困扰,因为他们往往会忘记单个反斜杠不是反斜杠。例如,假设你在 d:\temp 文件夹中有一个名为 cities.csv 的文件。尝试询问 Python 它是否存在:
>>> import os
>>> os.path.exists('d:\temp\cities.csv')
False
为了理解为什么它会失败,当你知道文件确实存在时,尝试打印字符串而不是文件名:
>>> print('d:\temp\cities.csv')
d: emp\cities.csv
"\t"被当作制表符处理!你有三种方法解决这个问题。要么使用正斜杠或双反斜杠,要么在字符串前加一个r来告诉 Python 忽略转义字符:
>>> os.path.exists('d:/temp/cities.csv')
True
>>> os.path.exists('d:\\temp\\cities.csv')
True
>>> os.path.exists(r'd:\temp\cities.csv')
True
如果我在复制粘贴路径,我更喜欢后者,因为添加一个字符在开头比添加多个反斜杠要容易得多。
2.4.4. 列表和元组
列表是有序的项目集合,通过它们的索引访问。列表中的第一个项目索引为 0,第二个索引为 1,依此类推。项目不必都是相同的数据类型。你可以使用一组方括号[]创建一个空列表,或者立即填充它。例如,这创建了一个包含数字和字符串混合的列表,然后访问其中的一些:
>>> data = [5, 'Bob', 'yellow', -43, 'cat']
>>> data[0]
5
>>> data[2]
'yellow'
你也可以使用列表末尾的偏移量,最后一个项目具有索引-1:
>>> data[-1]
'cat'
>>> data[-3]
'yellow'
你也不限于一次检索一个项目。你可以提供一个起始和结束索引来提取一个切片或子列表。但是,结束索引的项目不包括在返回值中:
>>> data[1:3]
['Bob', 'yellow']
>>> data[-4:-1]
['Bob', 'yellow', -43]
你可以使用索引更改列表中的单个值,甚至切片:
>>> data[2] = 'red'
>>> data
[5, 'Bob', 'red', -43, 'cat']
>>> data[0:2] = [2, 'Mary']
>>> data
[2, 'Mary', 'red', -43, 'cat']
使用append向列表末尾添加项目,并使用del删除项目:
>>> data.append('dog')
>>> data
[2, 'Mary', 'red', -43, 'cat', 'dog']
>>> del data[1]
>>> data
[2, 'red', -43, 'cat', 'dog']
同样,也很容易找出列表中有多少项,或者它是否包含特定的值:
>>> len(data)
5
>>> 2 in data
True
>>> 'Mary' in data
False
元组也是有序的项目集合,但一旦创建就不能更改。与方括号不同,元组由圆括号包围。你可以像访问列表一样访问项目和检查是否存在:
>>> data = (5, 'Bob', 'yellow', -43, 'cat')
>>> data[1:3]
('Bob', 'yellow')
>>> len(data)
5
>>> 'Bob' in data
True
就像我说的一样,一旦创建元组,就不允许更改:
>>> data[0] = 10
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
因此,当可能改变数据时,最好使用列表而不是元组。
错误信息是你的朋友
当你收到错误信息时,一定要仔细查看它提供的信息,因为这可以节省你解决问题的时间。最后一行是一个消息,它给你一个关于问题的总体概念,就像这里看到的:
>>> data[0] = 10
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
你可以从这个错误信息中推断出你的代码试图以某种方式编辑一个元组对象。在错误信息之前,你会看到在遇到问题之前执行的一行代码列表。这被称为堆栈跟踪。在这个例子中,<stdin>表示交互式窗口,所以行号并不那么有用。但看看下面的,它追踪了两个代码行:

最后一行告诉你错误是由于尝试将整数和字符串相加而产生的。跟踪信息告诉你问题始于文件 trace_example.py 的第 7 行。第 7 行调用了一个名为 add 的函数,错误发生在该函数的第 2 行。你可以使用堆栈跟踪信息来确定错误发生的位置,以及触发它的原始代码行。在这个例子中,你知道要么你在第 7 行的 add 函数中传递了错误的数据,要么在第 2 行的 add 函数中存在错误。这为你提供了两个具体的错误查找位置。
2.4.5. 集合
集合 是由项目组成的无序集合,但每个值只能出现一次,这使得它成为从列表中删除重复项的一种简单方法。例如,这个集合是通过包含两个 13 的实例的列表创建的,但结果集合中只有一个:
>>> data = set(['book', 6, 13, 13, 'movie'])
>>> data
{'movie', 6, 'book', 13}
你可以添加新的值,但如果它们已经在集合中,则会被忽略,例如本例中的 'movie':
>>> data.add('movie')
>>> data.add('game')
>>> data
{'movie', 'game', 6, 'book', 13}
集合是无序的,因此你不能访问特定元素。然而,你可以检查项目是否在集合中:
>>> 13 in data
True
集合还使得执行诸如合并集合(并集)或找出两个集合中都包含哪些项目(交集)等操作变得容易:

你已经看到你可以使用集合从列表中删除重复项。确定列表是否包含重复值的一个简单方法是从列表创建一个集合,并检查集合和列表的长度是否相同。如果它们不同,那么你就知道列表中有重复项。
2.4.6. 字典
字典 是索引集合,类似于列表和元组,但索引不是列表中的偏移量。相反,你可以选择索引值,称为 键。键可以是数字、字符串或其他数据类型,以及它们引用的值。使用花括号创建新字典:
>>> data = {'color': 'red', 'lucky number': 42, 1: 'one'}
>>> data
{1: 'one', 'lucky number': 42, 'color': 'red'}
>>> data[1]
'one'
>>> data['lucky number']
42
与列表一样,你可以添加、更改和删除项目:
>>> data[5] = 'candy'
>>> data
{1: 'one', 'lucky number': 42, 5: 'candy', 'color': 'red'}
>>> data['color'] = 'green'
>>> data
{1: 'one', 'lucky number': 42, 5: 'candy', 'color': 'green'}
>>> del data[1]
>>> data
{'lucky number': 42, 5: 'candy', 'color': 'green'}
你还可以测试字典中是否存在键:
>>> 'color' in data
True
当你事先不知道数据将是什么时,这是一种强大的数据存储方式。例如,假设你需要记住地理数据集集合中每个文件的空间范围,但每次运行你的脚本时数据集列表都会改变。你可以创建一个字典,并使用文件名作为键,空间范围作为值,然后这些信息就可以在脚本后面的任何地方轻松获取。
2.5. 控制流
你写的第一个脚本可能是由一系列按顺序执行的语句组成的,就像我们之前看过的所有例子一样。然而,编程的真正力量在于能够根据不同的条件改变发生的事情。类似于你可能会用折扣价来决定在超市买哪些蔬菜,你的代码应该使用数据,比如它是在处理点还是线,来确定确切需要做什么。控制流是改变代码执行顺序的概念。
2.5.1. 如果语句
改变执行顺序可能最简单的方法是测试一个条件,并根据测试结果执行不同的操作。这可以通过if语句来实现。这里有一个简单的例子:
if n == 1:
print('n equals 1')
else:
print('n does not equal 1')
如果n变量的值是1,那么会打印出字符串“n 等于 1”。否则,会打印出字符串“n 不等于 1”。注意,if和else行以冒号结尾,并且依赖于条件的代码缩进在条件之下。这是必需的。一旦你停止缩进代码,那么代码就会停止依赖于条件。你认为以下代码会打印出什么?
n = 1
if n == 1:
print('n equals 1')
else:
print('n does not equal 1')
print('This is not part of the condition')
好吧,n等于1,所以等式信息会被打印出来,然后控制权传递到第一个没有缩进的代码行,所以这就是结果:
n equals 1
This is not part of the condition
你也可以用这种方式测试多个条件:
if n == 1:
print('n equals 1')
elif n == 3:
print('n equals 3')
elif n > 5:
print('n is greater than 5')
else:
print('what is n?')
在这种情况下,n首先与1进行比较。如果它不等于1,那么它就会与3进行比较。如果它也不等于那个数,那么它会检查n是否大于5。如果这些条件都不成立,那么就会执行else语句下的代码。你可以有任意多的elif语句,但只能有一个if语句,并且不能有超过一个的else。与elif语句不需要一样,else语句也不需要。如果你想,你可以单独使用一个if语句。
这是一个说明不同值在测试条件时可以评估为True或False的好地方。记住,除非字符串为空,否则字符串会解析为True。让我们用一个if语句来测试这个:
>>> if '':
... print('a blank string acts like True')
... else:
... print('a blank string acts like false')
...
a blank string acts like false
如果你使用了一个包含任何字符的字符串,包括单个空格,那么前面的例子就会解析为True而不是False。如果你有一个 Python 控制台打开,就去试一试,看看结果如何。让我们再看一个解析为True的例子,因为列表不为空:
>>> if [1]:
... print('a non-empty list acts like True')
... else:
... print('a non-empty list acts like False')
...
a non-empty list acts like True
你可以用这个相同的概念来测试一个数字是否不等于零,因为零等同于False,但任何其他数字,无论是正数还是负数,都会被视为True。
2.5.2. 当循环语句
while 语句只要条件为 True 就会执行一段代码。条件被评估,如果它是 True,则执行代码。然后再次检查条件,如果它仍然是 True,则再次执行代码。这会一直持续到条件变为 False。如果条件永远不会变为 False,则代码将永远运行,这被称为 无限循环,是你绝对想要避免的场景。以下是一个 while 循环的例子:
>>> n = 0
>>> while n < 5:
... print(n)
... n += 1
...
0
1
2
3
4
+= 语法表示“将右侧的值加到左侧的值上”,因此 n 的值增加 1。一旦 n 等于 5,它就不再小于 5,因此条件变为 False,缩进的代码不再执行。
2.5.3. 对于语句
for 语句允许你遍历一系列值并对每个值执行一些操作。当你写一个 for 语句时,你不仅提供了要遍历的序列,还提供了一个变量名。每次通过循环时,这个变量包含序列中的不同值。这个例子遍历一个名字列表并为每个名字打印一条消息:
>>> names = ['Chris', 'Janet', 'Tami']
>>> for name in names:
... print('Hello {}!'.format(name))
...
Hello Chris!
Hello Janet!
Hello Tami!
循环第一次执行时,name 变量等于 'Chris',第二次它保持 'Janet',最后一次它等于 'Tami'。我称这个变量为 name,但你可以称它为你想要的任何名字。
range 函数
range 函数使得遍历一系列数字变得简单。尽管这个函数有更多的参数,但最简单的方法是提供一个数字 n,然后它将创建一个从 0 到 n-1 的序列。例如,这将计算循环执行了多少次:
>>> n = 0
>>> for i in range(20):
... n += 1
...
>>> print(n)
20
变量 i 在这段代码中没有使用,但没有什么阻止你使用它。让我们使用它来计算 20 的阶乘,尽管这次我们将序列的起始值设置为 1,并且让它增加到但不包括数字 21:
>>> n = 1
>>> for i in range(1, 21):
... n = n * i
...
>>> print(n)
2432902008176640000
你将在后面的章节中看到,这个变量在访问数据集中的单个项目时也非常有用,即使它们不是直接可迭代的。
2.5.4. break、continue 和 else
一些语句适用于 while 和 for 循环。第一个是 break,它将完全退出循环,就像这个例子中当 i 等于 3 时停止循环:
>>> for i in range(5):
... if i == 3:
... break
... print(i)
...
0
1
2
如果没有 break 语句,这个循环将打印从 0 到 4 的数字。
continue 语句将跳回到循环的顶部并开始下一次迭代,跳过在当前循环迭代期间通常要执行的其余代码。在这个例子中,continue 用于跳过当 i 等于 3 时打印 i 的代码:
>>> for i in range(5):
... if i == 3:
... continue
... print(i)
...
0
1
2
4
循环也可以有一个else子句。当循环执行完毕时,除非使用break停止循环,否则会执行这个子句中的代码。这里我们将检查数字 2 是否在数字列表中。如果是,我们将跳出循环。否则,else子句用于通知我们没有找到数字。在第一种情况下,找到了数字,使用break退出循环,并忽略else子句:
>>> for i in [0, 5, 7, 2, 3]:
... if i == 2:
... print('Found it!')
... break
... else:
... print('Could not find 2')
...
Found it!
但如果找不到数字,那么break从未被调用,那么else子句将被执行:
>>> for i in [0, 5, 7, 3]:
... if i == 2:
... print('Found it!')
... break
... else:
... print('Could not find 2')
...
Could not find 2
如果在列表中找不到合适的值,可以使用这种模式为某个东西设置默认值。例如,假设你需要在一个文件夹中找到并编辑具有特定格式的文件。如果你找不到具有正确格式的文件,你需要创建一个。你可以遍历文件夹中的文件,如果找到一个合适的文件,就可以跳出循环。你可以在else子句中创建一个新文件,并且只有当没有找到合适的现有文件时,这段代码才会运行。
2.6. 函数
如果你发现你反复使用相同的代码片段,你可以创建自己的函数并调用它,而不是重复相同的代码。这样做可以使事情变得更容易,也更不容易出错,因为你不会在那么多地方犯拼写错误。当你创建一个函数时,你需要给它一个名字,并告诉用户需要提供哪些参数才能使用它。让我们创建一个简单的函数来计算阶乘:
def factorial(n):
answer = 1
for i in range(1, n + 1):
answer = answer * i
return answer
这个函数的名称是factorial,它接受一个参数n。它使用你之前使用的相同算法来计算阶乘,然后使用return语句将答案发送回调用者。你可以像这样使用这个函数:
>>> fact5 = factorial(5)
函数也可以有可选参数,用户不需要提供这些参数。要创建这样的参数,你必须在创建函数时为它提供一个默认值。例如,你可以修改阶乘函数,使其可选地打印出答案:
def factorial(n, print_it=False):
answer = 1
for i in range(1, n + 1):
answer = answer * i
if print_it:
print('{0}! = {1}'.format(n, answer))
return answer
如果你只传递一个数字调用这个函数,什么也不会打印出来,因为print_it的默认值是False。但是,如果你将True作为第二个参数传递,那么在返回答案之前会打印一条消息:
>>> fact5 = factorial(5, True)
5! = 120
通过将它们保存在.py 文件中,然后以导入其他模块的方式导入它们,很容易重用你的函数。唯一的问题是,你的文件需要位于 Python 可以找到的位置。一种方法是将它放在你正在运行的脚本的同一文件夹中。例如,如果factorial函数保存在一个名为 myfuncs.py 的文件中,你可以导入myfuncs(注意没有.py 扩展名),然后调用其中的函数:
import myfuncs
fact5 = myfuncs.factorial(5)
由于模块名称中不允许使用某些字符,并且模块名称只是没有扩展名的文件名,因此在命名文件时需要小心。例如,下划线在模块名称中是允许的,但破折号则不行。
2.7. 类
随着你阅读这本书,你将遇到一些附加了其他数据和函数的变量。这些是从类中创建的对象。虽然我们在这本书中不会介绍如何创建自己的类,但你需要了解它们,因为你会继续使用其他人定义的类。类是一个非常强大的概念,但为了这本书的目的,你需要理解的是,它们是包含自身内部数据和函数的数据类型。这种类型的对象或变量包含这些数据和函数,并且函数作用于特定的对象。你已经在之前查看的一些数据类型中看到了这一点,例如列表。你可以有一个类型为list的变量,并且这个变量包含所有与列表相关的函数,例如append。当你对一个列表调用append时,它只会向该特定列表添加数据,而不会向你可能拥有的任何其他列表变量添加。
类也可以有适用于特定对象但不适用于数据类型的函数。例如,Python 的 datetime 模块包含一个名为date的类或类型。让我们从模块中提取这个数据类型,然后使用它来创建一个新的日期对象,然后我们可以询问它是星期几,其中星期一是 0,星期天是 6:
>>> import datetime
>>> datetype = datetime.date
>>> mydate = datetype.today()
>>> mydate
datetime.date(2014, 5, 18)
>>> mydate.weekday()
6
datetype变量持有对date类型的引用,而不是对特定日期对象的引用。该数据类型有一个名为today的方法,它创建一个新的日期对象。存储在mydate变量中的日期对象内部存储日期信息,并使用这些信息来确定日期指的是星期几,在本例中是星期天。你不能询问datetype变量它是星期几,因为它不包含任何关于特定日期的信息。你不需要获取数据类型的引用,可以用datetime.date.today()创建mydate。现在假设你想知道 2010 年 5 月 18 日是星期几。你可以基于现有的日期对象创建一个新的日期对象,但年份已更改,然后你可以询问新的对象它代表的是星期几:
>>> newdate = mydate.replace(year=2010)
>>> newdate
datetime.date(2010, 5, 18)
>>> newdate.weekday()
1
显然,2010 年 5 月 18 日是星期二。原始的mydate变量没有改变,仍然会报告它指的是星期天。
你将在整本书中使用从类中创建的对象。例如,每次你打开一个数据集时,你都会得到一个代表该数据集的对象。根据数据类型的不同,该对象将具有不同的信息和函数。显然,你需要了解用于创建这些对象的类,以便你知道它们包含哪些数据和函数。GDAL 模块包含相当广泛的类,这些类在附录 B、C 和 D 中有文档说明。(附录 C 至 E 可在 Manning Publications 网站上在线获取,网址为www.manning.com/books/geoprocessing-with-python。)
2.8. 摘要
-
Python 解释器对于学习事物的工作原理或尝试一小段代码非常有用,但编写脚本在运行多行代码时更有效率。此外,你可以保存脚本并在以后使用它们,这是编程的主要原因之一。
-
模块是一组你可以加载到脚本中并使用的代码库。如果你需要用 Python 做某事,很可能某个模块存在,可以帮助你完成,无论你试图做什么。
-
习惯于将数据存储在变量中,因为这会使你的代码在以后更容易适应。
-
Python 有一些核心数据类型,它们对于不同类型的数据和不同情况都非常有用。
-
你可以使用控制流语句根据各种条件改变代码的执行行,或者重复执行相同的代码多次。
-
使用函数可以使你的代码可重用。
第三章. 读取和写入矢量数据
本章涵盖
-
理解矢量数据
-
介绍 OGR
-
读取矢量数据
-
创建新的矢量数据集
-
更新现有数据集
这些似乎现在很少见,但你可能见过一种设计用来折叠并保存在车里的纸质路线图。与我们现在习惯使用的较新的网络地图不同,这些地图不使用航空影像。相反,地图上的特征都是绘制为几何对象——即点、线和多边形。这些类型的地理特征都是独立对象的数据,被称为矢量数据集。
除非你只打算查看别人制作过的地图,否则你需要知道如何读取和写入这些类型的数据。如果你想以任何方式处理现有数据,无论是总结、编辑、推导新数据还是执行复杂的空间分析,你首先需要从文件中读取它。你还需要将任何新或修改后的数据写回到磁盘上。例如,如果你有一个全国性的城市数据集,但只需要分析人口在 10 万或以上的城市数据,你可以从原始数据集中提取这些城市,并在它们上进行分析,同时忽略较小的城镇。可选地,你也可以将较小的数据集保存到新文件中供以后使用。
在本章中,你将了解矢量数据背后的基本概念以及如何使用 OGR 库读取、写入和编辑这些类型的数据集。
3.1. 矢量数据简介
在最基本的意义上,矢量数据是指将地理特征表示为离散几何形状的数据——具体来说,是点、线和多边形。具有明确边界的地理特征,如城市,非常适合作为矢量数据,但连续数据,如海拔,则不适合。在山区,要绘制一个包含所有相同海拔区域的单一多边形可能会很困难。然而,你可以使用多边形来区分不同的海拔范围。例如,显示一个区域亚高山区的多边形可以很好地代表一个海拔范围,但你会在这些多边形内失去大部分详细的海拔数据。尽管如此,许多类型的数据非常适合矢量表示,例如前面提到的路线图中的特征。道路表示为线,县和州表示为多边形,而根据地图的比例尺,城市可能被绘制为点或多边形。实际上,地图上的所有特征可能都表示为点、线或多边形。
然而,绘制特征所使用的几何类型可能取决于比例尺。图 3.1 展示了这样一个例子。在纽约州的地图上,城市被表示为点,主要道路为线,县为多边形。较小区域的地图,如纽约市,将用不同的符号表示特征。在这种情况下,道路仍然是线,但城市及其行政区为多边形而不是点。现在点将用来表示图书馆或警察局等特征。
图 3.1. 比例尺如何改变绘制某些特征所使用的几何形状的例子。纽约市在州地图上是一个点,但在城市地图上由几个多边形组成。

你可以想象许多其他适合以这种方式表示的地理数据示例。任何可以用单一坐标集描述的事物,如纬度和经度,都可以表示为一个点。这包括城市、餐馆、山峰、气象站和地理藏宝地点。除了它们的 x 和 y 坐标(如纬度和经度)之外,点还可以有一个表示高度的第三个 z 坐标。
具有封闭边界的地理区域可以用多边形表示。例如,州、湖泊、国会选区、邮政编码、土地所有权,以及许多可以像点一样表示的特征,如城市和公园。其他可能表示为多边形但可能不是点的特征包括国家、大陆和海洋。
线性特征,如道路、河流、电力线和公交路线,都可以被描述为线。然而,比例尺仍然可以产生影响。例如,新奥尔良的地图可能会将密西西比河表示为多边形而不是线,因为它非常宽。这也会使地图能够显示河流不规则的河岸,而不仅仅是像图 3.2 中所示的那样一条平滑的线。图 3.2。
图 3.2. 使用多边形
和线
几何形状来表示密西西比河的区别。多边形显示了河岸的细节,而线则没有。

向量数据不仅仅是几何形状。每一个这样的特征也都有相关的属性。这些属性可以直接关联到几何形状本身,例如多边形的面积或周长,或线的长度,但可能也存在其他属性。图 3.3 展示了一个简单的示例,一个存储州名、缩写、人口和其他数据的州数据集,每个特征都与之相关。如图所示,这些属性可以是各种类型。它们可以是数值,如城市人口或道路速度限制,也可以是字符串,如城市或道路名称,或者是日期,如土地地块购买或最后评估的日期。某些类型的向量数据还支持 BLOBs(二进制大对象),可以用来存储二进制数据,如照片。
图 3.3. 包含美国州边界的数据集的属性表。每个州多边形在数据表中都有一个与之关联的行,包括州名和 2010 年的人口等几个属性。

到现在为止,应该很清楚这种类型的数据非常适合制作地图,但某些原因可能并不那么明显。一个例子是它在绘图时的缩放能力。如果你熟悉网络图形,你可能知道,当以不同比例显示时,矢量图形(如 SVG 可缩放矢量图形)比位图图形(如 PNG)表现得更好。即使你对 SVG 一无所知,你也一定在网站上看到过像素化且难看的图片。那是在比设计时更高的分辨率下显示的位图图形。矢量图形不会发生这种情况,矢量 GIS 数据也是如此。无论比例如何,它总是看起来很平滑。
尽管如此,这并不意味着比例尺度无关紧要。如你之前所见,比例尺度影响用于表示地理特征的几何形状类型,但它也影响特征的分辨率。简单来说,分辨率可以等同于细节。分辨率越高,可以显示的细节就越多。例如,美国地图不会显示华盛顿州海岸外的所有圣胡安群岛,实际上,数据集甚至不需要包括它们。然而,仅华盛顿州的地图肯定需要一个更高分辨率的包含岛屿的数据集,如图 3.4 所示。记住,分辨率不仅对显示很重要,对分析也很重要。例如,华盛顿州的两个地图会提供海岸线长度的极其不同的测量结果。
图 3.4. 展示分辨率差异的示例。具有粗轮廓线的数据集分辨率低于具有阴影的数据集。注意两个数据集中可用的细节量差异。

海岸线悖论
你是否想过如何测量陆地的海岸线?正如英国数学家刘易斯·弗赖·理查森最初指出的,这比你想象的要困难,因为最终测量完全取决于比例尺。例如,想象一段有多个海角的海岸线,旁边有一条道路。想象你沿着这条路开车,用你车的里程表来测量距离,然后你下车沿着你来的路走回去。但是当你步行时,你沿着海角边缘走,并跟随道路没有的沿海曲线。你应该可以想象你会走得更远,因为你走了更多的弯路。当测量整个海岸线时,这个原理同样适用,因为如果你以更小的增量进行测量,你可以测量更多的变化。事实上,用 50 公里增量而不是 100 公里增量来测量大不列颠的海岸线,最终测量会增加大约 600 公里。你可以在图 3.3 中看到另一个例子。如果你要测量高分辨率数据集中所有的曲折和转弯,你会得到比测量由深色线表示的低分辨率海岸线更长的海岸线测量值,而这条深色线甚至不包括许多岛屿。
如前所述,矢量数据不仅用于制作地图。实际上,即使我的生命取决于它,我也无法制作出漂亮的地图,但我确实对数据分析了解得更多一些。一种常见的矢量数据分析类型是测量地理要素之间的关系,通常通过将它们叠加在一起来确定它们的空间关系。例如,你可以确定两个要素在空间上是否重叠以及重叠区域的大小。图 3.5 显示了新奥尔良市边界叠加在湿地数据集上。你可以使用这些信息来确定新奥尔良市内湿地的位置以及城市面积中有多少是湿地,多少不是。
图 3.5. 向量叠加操作的示例。深色轮廓是新奥尔良市的边界,而更深的土地区域是湿地。这两个数据集可以用来确定新奥尔良市边界内湿地土地面积的比例。

空间关系的另一个方面是两个要素之间的距离。你可以找到两个气象站之间的距离,或者你办公室一英里范围内的所有三明治店。几年前,我帮助进行了一项研究,研究人员需要距离和空间关系。他们需要知道 GPS 项圈鹿在两次读数之间的移动距离,但还需要知道它们的移动方向以及它们如何与道路等人工特征相互作用。特别是有一个问题,即它们是否穿越道路,如果是的话,频率如何。
说到道路,矢量数据集在表示网络,如道路网络方面也做得很好。一个配置正确的道路网络可以用来查找两个地点之间的路线和驾驶时间,类似于你在各种网络地图网站上看到的结果。企业也可以使用此类信息来提供服务。例如,一家比萨店可能会使用网络分析来确定在 15 分钟内可以到达的城市哪些部分,以设定他们的配送区域。
与其他类型的数据一样,你有多种方式来存储矢量数据。类似于你可以将照片存储为 JPEG、PNG、TIFF、位图或许多其他文件类型,许多不同的文件格式可以用于存储矢量数据。我将在下一章中更多地讨论可能性,但现在我将简要提及一些常见的格式,其中我们将使用本章中的几个。
Shapefiles 是一种流行的矢量数据存储格式。然而,一个 shapefile 并不是由单个文件组成的。实际上,这种格式至少需要三个二进制文件,每个文件都服务于不同的目的。几何信息存储在.shp 和.shx 文件中,属性值存储在.dbf 文件中。此外,其他数据,如索引或空间参考信息,可以存储在更多的文件中。通常你不需要了解这些文件,但你确实需要确保它们都保存在同一个文件夹中。
另一种广泛使用的格式,尤其是对于网络地图应用,是 GeoJSON。这些是纯文本文件,你可以在任何文本编辑器中打开并查看。与 shapefile 不同,GeoJSON 数据集由一个文件组成,该文件存储了所有必要的信息。
矢量数据也可以存储在关系数据库中,这允许多用户访问以及各种类型的索引。其中最常见的两种选项是为广泛使用的数据库系统构建的空间扩展。PostGIS 扩展在 PostgreSQL 之上运行,SpatiaLite 与 SQLite 数据库协同工作。另一种流行的数据库格式是 Esri 文件地理数据库,它与现有数据库系统完全不同。
3.2. OGR 简介
OGR 简单特征库是地理空间数据抽象库(GDAL)的一部分,GDAL 是一个用于读取和写入空间数据的极其流行的开源库。GDAL 的 OGR 部分是提供读取和写入许多不同矢量数据格式能力的部分。OGR 还允许你创建和操作几何形状;编辑属性值;根据属性值或空间位置过滤矢量数据;它还提供数据分析功能。简而言之,如果你想使用 GDAL 处理矢量数据,你需要了解 OGR,你将在接下来的四章中了解到这一点。
GDAL 库最初是用 C 和 C++ 编写的,但它为几种其他语言提供了绑定,包括 Python,因此有一个从 Python 到 GDAL/OGR 库的接口,并不是代码是用 Python 重写的。因此,要使用 Python 中的 GDAL,你需要安装 GDAL 库及其 Python 绑定。如果你还没有这样做,请参阅附录 A 以获取详细的安装说明。
备注
那么,OGR 这个缩写究竟代表什么意思呢?它曾经代表 OpenGIS 简单特征参考实现,但由于 OGR 并不完全符合 OpenGIS 简单特征规范,因此名称被更改,现在 OGR 部分不再代表任何含义,只是具有历史性质。
本章中使用的几个函数来自可在 www.manning.com/books/geoprocessing-with-python 下载的 ospybook Python 模块。你也需要安装这个模块。示例数据集可以从同一网站获取。
在开始使用 OGR 之前,查看 OGR 宇宙中各种对象之间的关系是有用的,如图 3.6 所示。如果你不理解这个层次结构,那么读取和写入数据所需的步骤就不会很有意义。当你使用 OGR 打开数据源,例如 shapefile、GeoJSON 文件、SpatiaLite 或 PostGIS 数据库时,你会得到一个 DataSource 对象。这个数据源可以有一个或多个子 Layer 对象,每个对象对应数据源中包含的一个数据集。许多矢量数据格式,如本章中使用的 shapefile 示例,只能包含一个数据集。但其他格式,如 SpatiaLite,可以包含多个数据集,你将在下一章中看到这方面的示例。无论数据源中有多少个数据集,每个数据集都被 OGR 视为一个图层。甚至一些经常在课程和研究中使用 GIS 的我的学生,如果他们主要使用 shapefile,也会对此感到困惑,因为他们觉得被称为图层的对象位于数据源和实际数据之间,这不符合直观。
图 3.6. OGR 类结构。每个数据源可以包含多个图层,每个图层可以包含多个要素,每个要素包含一个几何形状和一个或多个属性。

说到实际数据,每个图层包含一个 Feature 对象的集合,这些对象持有几何形状及其属性。如果你将矢量数据加载到 GIS 中,如 QGIS,然后查看属性表,你会看到类似于 图 3.7 的内容。表中的每一行对应一个要素,例如代表阿富汗的要素。每一列对应一个属性字段,在这种情况下,两个属性是 SOVEREIGNT 和 TYPE。尽管你可以打开没有与要素关联的任何空间信息或几何形状的数据表,但我们将使用具有几何形状的数据集。正如你在 图 3.7 中可以看到的,几何形状在 QGIS 的属性表中不会显示,尽管其他 GIS 软件包,如 ArcGIS,在属性表中确实显示了形状列。
图 3.7. 在 QGIS 中显示的属性表的示例。表中的每一行对应一个要素,每一列是一个属性字段。

访问任何矢量数据的第一步是打开数据源。为此,你需要一个合适的驱动程序,告诉 OGR 如何处理你的数据格式。GDAL/OGR 网站列出了 OGR 能够读取的超过 70 种矢量格式,尽管它不能写入所有这些格式。这些格式中的每一个都有自己的驱动程序。很可能你的 OGR 版本不支持所有列出的格式,但如果你需要某些缺失的功能,你总是可以自己编译它(请注意,在许多情况下,这比说起来容易做起来难)。有关所有可用格式及其具体细节的列表,请参阅 www.gdal.org/ogr_formats.html。
定义
驱动程序是特定数据格式的翻译器,例如 Geo-JSON 或 shapefile。它告诉 OGR 如何读取和写入该特定格式。如果没有为格式编译驱动程序到 OGR 中,那么 OGR 就无法处理它。
如果你不确定你的 GDAL/OGR 安装是否支持特定的数据格式,你可以使用 ogrinfo 命令行实用程序来找出哪些驱动程序可用。此实用程序在计算机上的位置取决于你的操作系统以及你如何安装 GDAL,因此你可能需要参考 附录 A。如果你不习惯使用命令行,你可能想双击 ogrinfo 可执行文件,但这不会带给你任何有用的结果。相反,你需要从终端窗口或 Windows 命令提示符运行 ogrinfo。无论如何,一旦找到可执行文件,你将希望使用 --formats 选项运行它。图 3.8 展示了在我的 Windows 7 机器上运行它的示例,尽管我已经切掉了大部分输出。
图 3.8. 在 Windows 计算机上从 GDAL 命令提示符运行 ogrinfo 实用程序的示例

如您所见,ogrinfo不仅会告诉您 OGR 版本中包含哪些驱动程序,还会告诉您它是否可以写入每个驱动程序以及从中读取。
小贴士
在www.gdal.org/ogr_formats.html可以找到 OGR 支持的矢量格式信息。
您也可以使用 Python 确定哪些驱动程序可用。实际上,让我们试试。首先打开您喜欢的 Python 交互环境。我将使用 IDLE(图 3.9),因为它与 Python 一起打包,但您可以使用您感到舒适的任何一种。您需要做的第一件事是导入ogr模块,以便可以使用它。此模块位于osgeo包中,当您安装 GDAL 的 Python 绑定时已安装。此包中的所有模块都使用小写字母命名,这就是您在 Python 中引用它们的方式。一旦您导入了ogr,然后您可以使用ogr.GetDriverByName来查找特定的驱动程序:
from osgeo import ogr
driver = ogr.GetDriverByName('GeoJSON')
图 3.9. 示例 Python 交互会话,展示如何获取驱动程序

使用 OGR 矢量格式网页上的代码列中的名称。如果您得到一个有效的驱动程序并打印出来,您将看到有关对象在内存中存储位置的信息。重要的是,它确实有东西可以打印出来,因为这表明您成功找到了驱动程序。如果您传递一个无效的名称,或者丢失的驱动程序的名称,该函数将返回None。请参见图 3.9 中的示例。
ospybook 模块中有一个名为print_drivers的函数,它也会打印出可用的驱动程序列表。这如图 3.9 所示。
3.3. 阅读矢量数据
现在您知道了可以与之一起工作的格式,是时候读取数据了。您将从城市 shapefile 开始,即您 osgeopy-data 文件夹的全局子文件夹中的 ne_50m_populated_places.shp 数据集。您可以自由地在 QGIS 中打开它并查看。您不仅会看到图 3.10 中显示的城市,还会看到属性表包含一系列字段,其中大多数在截图中都不可见。
图 3.10. 在 QGIS 中看到的 ne_50m_populated_places.shp 中的几何形状和属性

列表 3.1 显示了一个小脚本,它打印出该数据集中前 10 个要素的名称、人口和坐标。如果您一开始看不懂,不要担心,因为我们将稍后以极其详细的方式讲解它。该文件包含在本章的源代码中,因此如果您想尝试它,您可以在 IDLE 中打开它,将代码第三行的文件名更改为与您的设置匹配,然后选择运行菜单下的“运行模块”。
列表 3.1. 打印 shapefile 中前十个要素的数据

基本框架很简单。你做的第一件事是打开 shapefile 并确保该操作的结果不等于None,因为这将意味着数据源无法打开。我倾向于将这个变量称为ds,即数据源的简称。在确认文件已打开后,你从数据源中检索第一个图层。然后你遍历图层中的前 10 个特征,并为每个特征获取几何对象、其坐标以及NAME和POP_MAX属性值。然后你在继续到下一个之前打印有关该特征的信息。完成后,你删除ds变量以强制文件关闭。
如果你成功运行了代码,你应该有 10 行输出,看起来像这样,尽管如果你使用 Python 3,你将不会看到括号:
('Bombo', 75000, 32.533299524864844, 0.5832991056146284)
('Fort Portal', 42670, 30.27500161597942, 0.671004121125236)
<snip>
('Clermont-Ferrand', 233050, 3.080008095928406, 45.779982115759424)
让我们更详细地看看这个。你通过传递文件名和一个可选的更新标志到Open函数来打开一个数据源。这是一个 OGR 模块中的独立函数,因此你需要在函数名前加上模块名,这样 Python 才能找到它。如果第二个参数没有提供,它将默认为 0,这将以只读模式打开文件。你可以传递1或True来以更新或编辑模式打开它。
如果文件无法打开,那么Open函数将返回None,所以接下来你要检查这一点,并在需要时打印出错误消息并退出。我喜欢检查这一点,这样我就可以立即以我选择的方式(在这种情况下是退出)解决问题,而不是等待脚本在尝试使用不存在的数据源时崩溃。如果你想看到这种行为在实际操作中的表现,可以将列表 3.1 中的文件名更改为一个无效的名称并运行脚本:
fn = r'D:\osgeopy-data\global\ne_50m_populated_places.shp'
ds = ogr.Open(fn, 0)
if ds is None:
sys.exit('Could not open {0}.'.format(fn))
lyr = ds.GetLayer(0)
记住,数据源由一个或多个包含数据的图层组成,因此打开数据源后,你需要从其中获取图层。数据源有一个名为GetLayer的函数,它接受一个图层索引或图层名称,并返回该特定数据源中的相应Layer对象。图层索引从 0 开始,因此第一个图层的索引是 0,第二个是 1,依此类推。如果你没有为GetLayer提供任何参数,那么它将返回数据源中的第一个图层。Shapefile 只有一个图层,所以在这种情况下,索引在技术上不是必需的。
现在你想要从你的图层中获取数据。回想一下,每个图层由一个或多个特征组成,每个特征代表一个地理对象。几何形状和属性值都附加到这些特征上,因此你需要查看它们来获取你的数据。在列表 3.1 中的代码的第二部分会遍历图层中的前 10 个特征,并打印每个特征的信息。以下是它的有趣部分再次呈现:
for feat in lyr:
pt = feat.geometry()
x = pt.GetX()
y = pt.GetY()
name = feat.GetField('NAME')
pop = feat.GetField('POP_MAX')
print(name, pop, x, y)
层是一个可以遍历的特征集合,您可以使用for循环遍历它。每次循环迭代时,feat变量将是层中的下一个特征,循环将在遍历层中的所有特征后停止。但是,您不想打印出所有 1,249 个特征,所以您强制它在第一个 10 个特征后停止。
在循环内部的第一件事是获取特征的几何形状并将其存储在一个名为pt的变量中。一旦您有了几何形状,您就获取其 x 和 y 坐标并将它们存储在变量中以供以后使用。
接下来,您从NAME和POP_MAX字段中检索值,并将这些值也存储在变量中。GetField函数接受一个属性名称或索引,并返回该字段的值。一旦您有了属性,您就会打印出关于当前特征收集的所有信息。
您应该注意的一件事是,GetField函数返回的数据类型与底层数据集中的数据类型相同。在这个例子中,name变量中的值是字符串,但存储在pop中的值是数字。如果您想以其他格式存储数据,请查看附录 B,以查看返回特定类型值的函数列表。例如,如果您想将pop转换为字符串以便将其连接到另一个字符串,您可以使用GetFieldAsString。
pop = feat.GetFieldAsString('POP_MAX')
注意,并非所有数据格式都支持所有字段类型,并且并非所有数据都可以成功地在类型之间转换,因此在依赖这些自动转换之前,您应该彻底测试这些事情。这些函数不仅对于在类型之间转换数据很有用,而且您还可以使用它们使代码中的数据类型更加明显。例如,如果您使用GetFieldAsInteger,那么任何阅读您代码的人都会清楚地知道该值是整数。
3.3.1. 访问特定特征
有时候你不需要所有功能,因此你没有必要像之前那样迭代所有功能。限制功能到子集的一种强大方法是按属性值或空间范围选择它们,你将在第五章中这样做。另一种方法是查看具有特定偏移量的功能,也称为功能 ID(FIDs)。偏移量是功能在数据集中的位置,从零开始。它完全取决于功能在文件中的位置,与内存中的排序顺序无关。例如,如果你在 QGIS 中打开 ne_50m_populated_places 形状文件并查看属性表,它将显示 Bombo 作为表中的第一条记录,如图 3.11A 所示。看到最左侧列的数字了吗?那些是偏移值。现在尝试通过点击 NAME 列标题按名称对表进行排序,如图 3.11B 所示。现在表中显示的第一条记录是 Abakan 的记录,但它的偏移量为 346。正如你所见,最左侧列不是像你在电子表格中看到的行号,行号无论你如何排序数据总是按正确顺序排列。这些数字代表文件中的顺序。
图 3.11。ne_50m_populated_places 形状文件的属性表。表 A 显示了原始排序顺序,FIDs 按顺序排列。表 B 已按城市名称排序,FIDs 不再按顺序排列。

如果你知道你想要的功能的偏移量,你可以通过 FID 请求该功能。要获取梵蒂冈城的功能,你使用GetFeature(7)。
你也可以使用GetFeatureCount获取功能总数,因此你可以这样获取图层中的最后一个功能:
>>> num_features = lyr.GetFeatureCount()
>>> last_feature = lyr.GetFeature(num_features - 1)
>>> last_feature.NAME
'Hong Kong'
你必须从功能总数中减去一个,因为第一个索引是零。如果你尝试获取索引num_features处的功能,你会收到一个错误消息,说功能 ID 超出了可用范围。这个片段还展示了从功能中检索属性值的一种替代方法,而不是使用GetField,但这只在你事先知道名称的情况下有效,这样你就可以将它们硬编码到你的脚本中。
当前功能
另一个重要的一点是,返回特征的函数会跟踪最后访问的是哪个特征;这是当前的特征。当您第一次获取图层对象时,它没有当前特征。但是如果您开始遍历特征,第一次通过循环时,当前特征是 FID 为零的那个。第二次通过循环时,当前特征是偏移量为 1 的那个,以此类推。如果您使用 GetFeature 获取 FID 为 5 的那个,那么现在它就是当前特征,如果您随后调用 GetNextFeature 或开始一个循环,返回的下一个特征将是偏移量为 6 的那个。是的,您没有看错。如果您遍历图层中的特征,如果您已经设置了当前特征,它不会从第一个开始。
根据您迄今为止所学的内容,您认为如果您遍历所有特征并打印出它们的名称和人口,然后在稍后再次遍历以打印出它们的名称和坐标会发生什么?如果您猜测不会打印出坐标,您是对的。第一个循环在运行完所有特征后停止,因此当前特征指针指向最后一个特征之后,并且不会重置到开始位置(见图 3.12)。当第二个循环开始时,没有下一个特征,所以什么也不会发生。您如何让当前特征指针再次指向开始位置?您不想使用零的 FID,因为如果您尝试遍历所有特征,第一个特征将被跳过。为了解决这个问题,请使用 layer.ResetReading() 函数,该函数将当前特征指针设置在第一个特征之前的位置,类似于您第一次打开图层时。
图 3.12. 当前特征指针在各个时间点的位置

3.3.2. 查看您的数据
在我们继续之前,您可能想知道关于 ospybook 模块中的函数的信息,这些函数可以帮助您在不打开其他软件程序的情况下可视化您的数据。这些函数不允许与数据的交互达到 GIS 的水平,因此仍然在 QGIS 中打开是探索数据的更好选择。
查看属性
您可以使用 print_attributes 函数将属性值打印到屏幕上,其外观如下:
print_attributes(lyr_or_fn, [n], [fields], [geom], [reset])
-
lyr_or_fn是一个图层对象或数据源的路径。如果是数据源,则将使用第一个图层。 -
n是可选的记录数量,用于打印。默认情况下,打印所有记录。 -
fields是一个可选的属性字段列表,用于包含在打印输出中。默认情况下,包含所有字段。 -
geom是一个可选的布尔标志,指示是否打印几何类型。默认值为True。 -
reset是一个可选的布尔标志,指示在打印之前是否应该将图层重置到第一条记录。默认值为True。
例如,要打印出人口密集地区形状文件中前三个城市的名称和人口,你可以在 Python 交互窗口中这样做:
>>> import ospybook as pb
>>> fn = r'D:\osgeopy-data\global\ne_50m_populated_places.shp'
>>> pb.print_attributes(fn, 3, ['NAME', 'POP_MAX'])
FID Geometry NAME POP_MAX
0 POINT (32.533, 0.583) Bombo 75000
1 POINT (30.275, 0.671) Fort Portal 42670
2 POINT (15.799, 40.642) Potenza 69060
3 of 1249 features
通常,你必须按照它们列出的顺序为函数提供参数,但如果你想提供一个可选参数而不指定早期可选参数的值,你可以使用关键字来指定你指的是哪个参数。例如,如果你想将 geom 设置为 False 而不指定字段列表,你可以这样做:
pb.print_attributes(fn, 3, geom=False)
此函数对于查看少量属性效果良好,但如果你用它来打印大文件的全部属性,你可能会后悔。
绘制空间数据
ospybook 模块还包含方便的类,可以帮助你从空间上可视化你的数据,尽管你将在最后一章中学习如何自己做到这一点。要使用这些类,你必须安装 matplotlib Python 模块。要绘制你的数据,你需要创建 VectorPlotter 类的新实例,并将一个布尔参数传递给构造函数,以指示你是否想使用交互模式。如果是交互模式,数据将在绘图时立即绘制。如果不是交互模式,你需要在绘制数据后调用 draw,所有内容将一次性绘制。无论哪种方式,一旦创建了此对象,你就可以使用它通过 plot 方法绘制你的数据:
plot(self, geom_or_lyr, [symbol], [name], [kwargs])
-
geom_or_lyr是一个几何形状、图层或数据源的路径。如果是一个数据源,则将绘制第一层。 -
symbol是一个可选的pyplot符号,用于绘制几何图形。 -
name是一个可选的名称,用于分配给数据,以便以后可以访问它。 -
kwargs是通过关键字指定的可选pyplot绘图参数(你将经常看到kwargs缩写用于不定数量的关键字参数)。
plot 函数可以可选地使用 matplotlib 的 pyplot 接口中的参数。你将在本书中看到一些使用示例,但要了解更多,你可以阅读 matplotlib.org/1.5.0/api/pyplot_summary.html 上的 pyplot 文档。让我们从一个示例开始,该示例在国家轮廓上绘制人口密集地区的形状文件:
>>> import os
>>> os.chdir(r'D:\osgeopy-data\global')
>>> from ospybook.vectorplotter import VectorPlotter
>>> vp = VectorPlotter(True)
>>> vp.plot('ne_50m_admin_0_countries.shp', fill=False)
>>> vp.plot('ne_50m_populated_places.shp', 'bo')
你首先要做的是使用内置的 os 模块更改你的工作目录,这允许你以后使用文件名而不是输入整个路径。然后你将 True 传递给 VectorPlotter 以创建一个交互式绘图器。fill pyplot 参数导致国家形状文件被绘制为空心多边形,而 'bo' 符号表示人口密集地区为蓝色圆圈。这导致了一个看起来像 图 3.13 的图表。
图 3.13. 在国家轮廓上绘制全球人口密集地区形状文件的输出

如果你想在脚本中使用这些信息,你不需要做任何特殊的事情,但你应该知道,当绘图器不是在交互模式下创建时,它将停止脚本执行,直到你关闭弹出的窗口。我还发现,根据我运行脚本的运行环境,有时如果我用交互模式创建它,它有时会自动关闭,所以我从未有机会查看它。因此,如果我在脚本中使用VectorPlotter而不是 Python 交互窗口,我通常使用非交互模式创建它,并在脚本末尾调用draw。本章的源代码有这个示例的例子。
3.4. 获取数据元数据
有时候你也需要了解数据集的一般信息,例如特征数量、空间范围、几何类型、空间参考系统,或者属性字段的名称和类型。例如,假设你想在谷歌地图上显示你的数据。你需要确保你的数据使用与谷歌相同的空间参考系统,并且你需要知道空间范围,这样你才能将地图缩放到世界的正确部分。因为不同的几何类型有不同的绘图选项,所以你还需要知道几何类型来定义你特征的症状。
你已经看到了如何获取一些这些信息,例如使用GetFeatureCount获取图层中的特征数量。请记住,这适用于图层而不是数据源,因为数据源中的每个图层可以有不同的特征数量、几何类型、空间范围或属性。
图层的空间范围是由所有方向上的最小和最大边界坐标构成的矩形。图 3.14 显示了华盛顿大型城市文件及其范围。你可以通过图层对象使用GetExtent函数来获取这些边界坐标,该函数返回一个包含数字的元组,格式为(min_x, max_x, min_y, max_y)。以下是一个示例:
>>> ds = ogr.Open(r'D:\osgeopy-data\Washington\large_cities.geojson')
>>> lyr = ds.GetLayer(0)
>>> extent = lyr.GetExtent()
>>> print(extent)
(-122.66148376464844, -117.4260482788086, 45.638729095458984,
48.759552001953125)
图 3.14。在这里,你可以看到大型城市数据集的空间范围。最小和最大经度(x)值分别约为-122.7 和-117.4。最小和最大纬度(y)值分别约为 45.6 和 48.8。

将这些数字与图 3.14 中的数字进行比较,以更好地理解返回的范围元组。
你也可以从图层对象中获取几何类型,但有一个限制。GetGeomType函数返回一个整数而不是可读的字符串。但这有什么用呢?OGR 模块有许多常量,如表 3.1 所示,这些常量基本上是不可更改的变量,具有描述性的名称和数值。你可以将GetGeomType返回的值与这些常量之一进行比较,以检查它是否是那种几何类型。例如,点几何形状的常量是wkbPoint,多边形的常量是wkbPolygon,所以继续前面的例子,你可以这样找出 large_cities.shp 是点还是多边形形状文件:
![052fig01_alt.jpg]
表 3.1. 常见几何类型常量。你可以在附录 B 中找到更多信息。
| 几何类型 | OGR 常量 |
|---|---|
| Point | wkbPoint |
| Mulitpoint | wkbMultiPoint |
| Line | wkbLineString |
| Multiline | wkbMultiLineString |
| Polygon | wkbPolygon |
| Multipolygon | wkbMultiPolygon |
| Unknown geometry type | wkbUnknown |
| No geometry | wkbNone |
如果图层包含不同类型的几何形状,例如点和多边形的混合,GetGeomType将返回wkbUnknown。
注意
OGR 几何常量中的 wkb 前缀代表已知二进制(WKB),这是一种用于在不同软件包之间交换几何形状的标准二进制表示。因为它是以二进制形式存在的,所以它不是可读的,但存在一种已知文本(WKT)格式,它是可读的。
有时候你可能更希望有一个可读的字符串,而你可以从特征几何中获取这个字符串。以下示例从图层中获取第一个特征,从该特征中获取几何对象,然后打印出几何名称:
>>> feat = lyr.GetFeature(0)
>>> print(feat.geometry().GetGeometryName())
POINT
你还可以从图层对象中获取另一条有用的数据,即空间参考系统,它描述了数据集使用的坐标系。你的 GPS 设备默认可能显示未投影的或地理坐标。这是我们所有人都熟悉的纬度和经度坐标。然而,这些地理坐标可以转换为许多其他类型的坐标系,如果你不知道数据集使用的是哪种系统,那么你就无法知道这些坐标在地球上的位置。显然,这是一项至关重要的元数据,我将在第八章中更多地讨论它。现在,你只需要知道你可以获取这些信息。如果你打印出来,你会得到一个字符串,它以 WKT 格式描述了参考系统,就像列表 3.2 中所示的那样。
列表 3.2. 空间参考系统已知文本表示的示例
>>> print(lyr.GetSpatialRef())
GEOGCS["NAD83",
DATUM["North_American_Datum_1983",
SPHEROID["GRS 1980",6378137,298.257222101,
AUTHORITY["EPSG","7019"]],
TOWGS84[0,0,0,0,0,0,0],
AUTHORITY["EPSG","6269"]],
PRIMEM["Greenwich",0,
AUTHORITY["EPSG","8901"]],
UNIT["degree",0.0174532925199433,
AUTHORITY["EPSG","9122"]],
AUTHORITY["EPSG","4269"]]
根据你的 GIS 经验,这个输出可能对你来说意义不大。如果你现在觉得没有意义,不要担心,因为稍后你会了解到所有关于它的信息。
最后,你还可以获取有关附加到图层的属性字段的信息。最简单的方法是使用图层对象的模式属性来获取FieldDefn对象列表。这些对象中的每一个都包含诸如属性列名称和数据类型等信息。以下是一个打印出每个字段名称和数据类型的示例:
>>> for field in lyr.schema:
... print(field.name, field.GetTypeName())
...
CITIESX020 Integer
FEATURE String
NAME String
<snip>
为了节省空间,这里省略了一部分输出,但你可以运行代码自己查看图层中其余的字段。你将在 3.5.2 节中了解更多关于与FieldDefn对象一起工作的内容。
3.5. 写入矢量数据
读取数据肯定是有用的,但你可能还需要编辑现有或创建新的数据集。列表 3.3 展示了如何创建一个新的 shapefile,该 shapefile 只包含全球已填充地点 shapefile 中对应首都城市的要素。输出将类似于图 3.15 中的城市。
图 3.15. 带有国家轮廓的首都城市,供参考

列表 3.3. 将首都城市导出到新的 shapefile

在这个例子中,你打开一个文件夹作为数据源,而不是 shapefile。shapefile 驱动程序的一个优点是,如果文件夹中的大多数文件是 shapefile,它将把文件夹视为数据源,并且每个 shapefile 被视为一个图层。请注意,你将1作为Open函数的第二个参数传递,这将允许你在文件夹中创建一个新的图层(shapefile)。你将不带扩展名的 shapefile 名称传递给GetLayer以获取作为图层的已填充地点 shapefile。即使你在这里以不同的方式打开它,与列表 3.1 相比,你仍然可以以完全相同的方式使用它。
因为 OGR 不会覆盖现有的图层,所以你需要检查输出图层是否已经存在,如果存在,则删除它。显然,如果你不希望图层被覆盖,你不会想这样做,但在这个例子中,你可以在测试不同的事情时覆盖数据。
然后你创建一个新的图层来存储你的输出数据。CreateLayer函数的唯一必需参数是图层的名称,该名称应在数据源内是唯一的。然而,你确实有多个可选参数,在可能的情况下你应该设置这些参数:
CreateLayer(name, [srs], [geom_type], [options])
-
name是要创建的图层的名称。 -
srs是图层将使用的空间参考系统。默认值为None,表示不会分配任何空间参考系统。 -
geom_type是从表 3.1 中获取的几何类型常量,它指定了图层将持有的几何类型。默认值为wkbUnknown。 -
options是一个可选的图层创建选项列表,仅适用于某些矢量格式类型。
这些可选参数中的第一个是空间参考,如果没有提供,则默认为None。请记住,如果没有空间参考信息,很难确定特征在地球上的位置。有时空间参考隐含在数据中;例如,KML 仅支持使用 WGS 84 大地基准的未投影坐标,但如果有可能,您应该设置它。在这种情况下,您将空间参考信息从原始 shapefile 复制到新文件中。我们将在第八章中更详细地讨论空间参考系统及其使用方法。
第二个可选参数是来自表 3.1 或附录 B 的 OGR 几何类型常量之一。这指定了图层将包含的几何类型。如果没有提供,它默认为ogr.wkbUnknown,尽管在许多情况下,在您向图层添加特征后,它将被更新为正确的值,并且可以从它们中确定。
最后一个可选参数是一系列以option=value形式的图层创建选项字符串。这些在 OGR 格式网页上的每个驱动程序中都有文档说明。并非所有矢量数据格式都有图层创建选项,即使格式有选项,您也没有义务使用它们。
您可以使用以下代码创建一个名为 capital_cities.shp 的新点 shapefile,它使用与人口密集地区 shapefile 相同的空间参考系统。不过,您还需要做一件事。输入图层的schema属性返回该图层属性字段定义的列表,您将此列表传递给CreateFields以在新的图层中创建完全相同的属性字段集:
out_lyr = ds.CreateLayer('capital_cities',
in_lyr.GetSpatialRef(),
ogr.wkbPoint)
out_lyr.CreateFields(in_lyr.schema)
现在,要向图层添加一个特征,您需要创建一个虚拟特征,将几何和属性添加到其中,然后将其插入到图层中。下一步是创建这个空白特征。创建特征需要一个包含有关几何类型和所有属性字段信息的特征定义,并使用它来创建具有相同字段和几何类型的空特征。您需要从您计划添加特征的图层中获取特征定义,但您必须在添加、删除或更新任何字段之后进行。如果您首先获取特征定义,然后以任何方式更改字段,定义将过时。这意味着基于这个过时的定义尝试插入的特征将不会与实际情况匹配,如图 3.16 所示。这将导致 Python 以可怕的方式死亡,您肯定不希望那样。
图 3.16。在更改字段后,始终获取特征定义,否则定义将不会与实际情况匹配。

out_defn = out_lyr.GetLayerDefn()
out_feat = ogr.Feature(out_defn)
现在你已经有了一个可以存放信息的特征,是时候开始遍历输入数据集了。对于每个特征,你检查其FEATURECLA属性是否等于'Admin-0 capital',这意味着它是一个首都。如果是的话,你就将几何信息从它复制到虚拟特征中。然后你遍历属性表中的所有字段,并将输入特征的值复制到输出特征中。这是因为你在新的 shapefile 中创建的字段是基于原始文件中的字段创建的,所以它们在两个 shapefile 中的顺序是相同的。如果它们的顺序不同,你将不得不使用它们的名称来访问它们,但在这里你可以使用索引,因为你知道它们是一致的:
for in_feat in in_lyr:
if in_feat.GetField('FEATURECLA') == 'Admin-0 capital':
geom = in_feat.geometry()
out_feat.SetGeometry(geom)
for i in range(in_feat.GetFieldCount()):
value = in_feat.GetField(i)
out_feat.SetField(i, value)
out_lyr.CreateFeature(out_feat)
一旦你复制了所有的属性字段,你使用CreateFeature将特征插入到图层中。这个函数将特征及其所有添加的信息的副本保存到图层中。特征对象可以被重复使用,你对它所做的任何操作都不会影响已经添加到图层中的数据。这样你就不需要创建多个特征,因为你可以创建一个,并在每次你想向图层添加新特征时编辑其数据。
你在脚本末尾删除了ds变量,这强制文件关闭,并将所有编辑写入磁盘。删除图层变量不起作用;你必须关闭数据源。如果你想保持数据源打开,你可以对图层或数据源对象调用SyncToDisk,如下所示:
ds.SyncToDisk()
警告
你必须关闭你的文件或调用SyncToDisk以将你的编辑刷新到磁盘。如果你不这样做,并且你的交互式环境仍然打开了你的数据源,你将失望地发现一个空的数据集。
仔细检查你的输出以确保你得到你想要的结果总是一个好主意。最好的方式是在 QGIS 中打开它,或者你也可以通过 Python 将其绘制出来来获得一个很好的概念(图 3.17):
>>> vp = VectorPlotter(True)
>>> vp.plot('ne_50m_admin_0_countries.shp', fill=False)
>>> vp.plot('capital_cities.shp', 'bo')
图 3.17。将新的首都 shapefile 叠加在国家轮廓上的结果

让我们暂时回到为属性添加值的话题。你可能想知道,在检索值时存在多个函数,设置属性字段值时是否也存在多个函数。答案是通常没有。大多数数据都会自动转换为正确的类型,但如果你无法进行转换,结果可能并不理想。例如,假设你犯了一个错误,将人口数据插入到Name字段中,而将名称插入到Population字段中。你认为人口数据能否转换为字符串并成功插入到Name字段中?又或者将国家名称转换为数字以便放入Population字段中?好吧,将数字转换为字符串是可行的,但将字符串转换为数字则存在问题。字符串"3578"可以转换为数字 3578,但"Russia"字符串怎么办?如果你在 Python 交互式窗口中输入int('Russia'),你会得到一个错误,但 OGR 会将在Population字段中插入零而不是崩溃。有时这种行为对你有利,因为你不需要在将数据插入要素之前转换数据,但如果错误地尝试将错误类型的数据插入字段,这也可能成为一个问题。
3.5.1. 创建新的数据源
你在列表 3.3 中使用了现有的数据源,但有时你需要创建新的数据源。幸运的是,这并不困难。也许最重要的部分是使用正确的驱动程序。在这里,驱动程序负责工作,每个驱动程序只知道如何处理一种矢量格式,因此使用正确的驱动程序是至关重要的。例如,GeoJSON 驱动程序不会创建 shapefile,即使你要求它创建一个具有.shp 扩展名的文件。如图 3.18 所示,输出将具有.shp 扩展名,但本质上仍然是一个 GeoJSON 文件。
图 3.18. 使用 GeoJSON 驱动程序创建具有.shp 扩展名的文件仍然会创建一个 GeoJSON 文件,而不是 shapefile。

你有几种方法可以获取所需的驱动程序。第一种是从你已打开的数据集中获取驱动程序,这将允许你使用与现有数据源相同的矢量数据格式创建新的数据源。在这个例子中,driver变量将包含 ESRI shapefile 驱动程序:
ds = ogr.Open(r'D:\osgeopy-data\global\ne_50m_admin_0_countries.shp')
driver = ds.GetDriver()
获取驱动程序对象的第二种方式是使用 OGR 函数GetDriverByName,并传递驱动程序的简称。请记住,这些名称可以在 OGR 网站上找到,通过使用 GDAL/OGR 附带的ogrinfo实用程序,或者通过使用本书代码中可用的print_drivers函数。此示例将获取 GeoJSON 驱动程序:
json_driver = ogr.GetDriverByName('GeoJSON')
一旦你有了驱动程序对象,你可以通过提供数据源名称来使用它创建一个空的数据源。这个新的数据源自动打开用于写入,你可以像在列表 3.3 中做的那样向其中添加图层。如果无法创建数据源,则CreateDataSource返回None,因此你需要检查这个条件:
json_ds = json_driver.CreateDataSource(json_fn)
if json_ds is None:
sys.exit('Could not create {0}.'.format(json_fn))
一些数据格式在创建数据源时提供了你可以使用的创建选项,尽管这些选项不是必需的。就像图层创建选项一样,这些参数在 OGR 网站上都有文档说明。不要混淆这两者,因为数据源和图层创建选项是两回事。然而,这两种类型都作为字符串列表传递。让我们看看如何使用数据源创建选项来创建一个完整的 SpatiaLite 数据源而不是 SQLite。如果你的 OGR 版本没有使用 SpatiaLite 支持构建,这将失败:
driver = ogr.GetDriverByName('SQLite')
ds = driver.CreateDataSource(r'D:\ osgeopy-data\global\earth.sqlite',
['SPATIALITE=yes'])
在创建新数据源时需要注意的另一件事是,你不能覆盖现有的数据源。如果你的代码可能会合法地尝试覆盖数据集,那么在尝试创建新数据源之前,你需要删除旧的。处理这个问题的一种方法是在尝试创建数据源之前使用 Python 的os.path.exists函数来检查文件是否已经存在;或者你可以等待,如果原始尝试失败(无论是检查None还是使用 try/except 块),再处理它。无论如何,你应该使用驱动程序来删除现有的源,而不是使用 Python 内置函数。为什么?因为驱动程序将确保删除所有必需的文件。例如,如果你正在删除 shapefile,shapefile 驱动程序将删除.shp、.dbf、.shx 以及可能存在的任何其他可选文件。如果你使用 Python 内置模块删除 shapefile,你必须确保你的代码检查了所有这些文件。以下是一个处理现有数据源的方法示例:
if os.path.exists(json_fn):
json_driver.DeleteDataSource(json_fn)
json_ds = json_driver.CreateDataSource(json_fn)
if json_ds is None:
sys.exit('Could not create {0}.'.format(json_fn))
小贴士
如果你尝试创建一个作为数据源而不是图层(数据源是包含文件夹)的 shapefile,并且 shapefile 已经存在,你会得到一个奇怪的错误消息,说 shapefile 不是一个目录。
| |
使用 OGR 异常
默认情况下,OGR 在遇到问题,例如无法创建新的数据源时,不会引发错误。这就是为什么你需要检查None,但 Python 程序员通常期望会引发错误。如果你希望启用这种行为,可以在代码开头调用ogr.UseExceptions()。尽管大多数时候这会按预期工作,但我发现它并不总是在我期望的时候引发错误。例如,如果 OGR 无法打开数据源,则不会引发错误。然而,在它确实引发错误的情况下,你不需要在继续之前检查None。使用异常也为你处理错误提供了灵活性。
例如,这里是一个假设的情况,我假装在处理数据,然后我想将一些临时数据保存到 GeoJSON 文件中,然后我想继续处理其他数据。如果我不能创建临时文件,我想跳过该步骤并继续处理下一部分数据,而不是崩溃。以下是一个示例代码:

假设 africa.geojson 文件已经存在。此代码不会检查这一点,所以你知道在调用CreateDataSource时它将失败。如果你没有使用 OGR 异常,此脚本将在该点失败,并且永远不会到达最后的print语句。但是,因为你使用了异常,你会得到一个错误消息,说明文件无法创建,然后它会继续到最后一个print语句,输出将如下所示:
Doing some preliminary analysis...
The GeoJSON driver does not overwrite existing files.
Doing some more analysis...
尝试自己操作并注释掉第一行,然后观察行为如何变化。
3.5.2. 创建新字段
你在列表 3.3 中看到了如何从一个图层复制属性字段定义到另一个图层,但你也可以定义自己的自定义字段。有几种不同的字段类型可用,但并非所有数据格式都支持。在这种情况下,各种格式的在线文档将非常有用,所以希望你已经将该页面添加到书签。
要将字段添加到图层中,你需要一个包含字段重要信息的FieldDefn对象,例如名称、数据类型、宽度和精度。你在列表 3.3 中使用的schema属性返回一个列表,其中包含图层中每个字段的这些信息。然而,你也可以通过向FieldDefn构造函数提供新字段的名称和数据类型来自定义它。数据类型是表 3.2 中的常量之一。
表 3.2. 字段类型常量。更多内容请参阅附录 B,但我一直无法在 Python 中使用它们。
| 字段数据类型 | OGR 常量 |
|---|---|
| 整数 | OFTInteger |
| 整数列表 | OFTIntegerList |
| 浮点数 | OFTReal |
| 浮点数列表 | OFTRealList |
| 字符串 | OFTString |
| 字符串列表 | OFTStringList |
| 日期 | OFTDate |
| 一天中的时间 | OFTTime |
| 日期和时间 | OFTDateTime |
在创建基本字段定义之后,但在使用它将字段添加到图层之前,你可以添加其他约束,例如浮点数精度或字段宽度,尽管我注意到这些约束并不总是有效,这取决于所使用的驱动程序。例如,我无法在 GeoJSON 文件中设置精度,我还发现如果你想在 shapefile 中设置字段精度,你必须设置字段宽度。以下示例将创建两个字段来存储 x 和 y 坐标,精度为3:

您可能已经注意到,您在这里没有创建两个不同的字段定义对象。一旦您使用字段定义在图层中创建了一个字段,您就可以更改定义的属性并重新使用它来创建另一个字段,这使得这更容易,因为您想要两个除了名称之外完全相同的字段。
此外,如果字段宽度对于提供的数据来说太小,有时会被忽略。例如,如果您创建了一个宽度为 6 的字符串字段,但随后尝试插入一个 11 个字符长的值,在某些情况下,字段的宽度会增加以容纳整个字符串。然而,这并不总是可能的,最好具体说明您想要的内容,而不是希望这种事情方便地发生。
3.6. 更新现有数据
有时您需要更新现有数据而不是创建全新的数据集。这是否可能以及哪些编辑被支持取决于数据的格式。例如,您不能编辑 GeoJSON 文件,但在 shapefiles 上允许许多不同的编辑。我们将在下一章讨论如何获取有关支持的信息。
3.6.1. 更改图层定义
根据您正在处理的数据类型,您可以通过添加新字段、删除现有字段或更改字段属性(如名称)来编辑图层定义。与添加新字段一样,您需要字段定义来更改字段。一旦您对字段定义满意,您就可以使用 AlterFieldDefn 函数用新字段替换现有字段:
AlterFieldDefn(iField, field_def, nFlags)
-
iField是您想要更改的字段的索引。在这种情况下,字段名不起作用。 -
field_def是新的字段定义对象。 -
nFlags是一个整数,它是 表 3.3 中显示的一个或多个常量的总和。表 3.3. 用于指定字段定义哪些属性可以更改的标志。要使用多个标志,只需将它们相加
需要更改的字段属性 OGR 常量 仅字段名称 ALTER_NAME_FLAG 仅字段类型 ALTER_TYPE_FLAG 仅字段宽度和/或精度 ALTER_WIDTH_PRECISION_FLAG 以上所有 ALTER_ALL_FLAG
要更改字段的属性,您需要创建一个包含新属性的字段定义,找到现有字段的索引,并决定使用 表 3.3 中的哪些常量以确保您的更改生效。要将字段名从 'Name' 更改为 'City_Name',您可能做如下操作:
i = lyr.GetLayerDefn().GetFieldIndex('Name')
fld_defn = ogr.FieldDefn('City_Name', ogr.OFTString)
lyr.AlterFieldDefn(i, fld_defn, ogr.ALTER_NAME_FLAG)
如果您需要更改多个属性,例如浮点属性字段的名称和精度,您将传递 ALTER_NAME_FLAG 和 ALTER_WIDTH_PRECISION_FLAG 的总和,如下所示:
lyr_defn = lyr.GetLayerDefn()
i = lyr_defn.GetFieldIndex('X')
width = lyr_defn.GetFieldDefn(i).GetWidth()
fld_defn = ogr.FieldDefn('X_coord', ogr.OFTReal)
fld_defn.SetWidth(width)
fld_defn.SetPrecision(4)
flag = ogr.ALTER_NAME_FLAG + ogr.ALTER_WIDTH_PRECISION_FLAG
lyr.AlterFieldDefn(i, fld_defn, flag)
注意,在创建新的字段定义时,你使用的是原始字段宽度。我通过艰难的方式发现,如果你没有设置足够大的宽度来容纳原始数据,那么结果将会是不正确的。为了解决这个问题,使用原始宽度。为了使精度变化生效,所有记录都必须重写。然而,将精度设置得比原来更大并不会给你带来更多的精度,因为数据不能凭空创造。然而,精度可以降低。
你可以不计算标志值,而使用 ALTER_ALL_FLAG。只有当你的新字段定义正好是你想要编辑后字段看起来像的样子时才这样做。其他标志限制了可以更改的内容,但这个标志没有限制。例如,如果你的字段定义与原始字段的数据类型不同,但你传递了 ALTER_NAME_FLAG,那么数据类型将不会改变,但如果你传递了 ALTER_ALL_FLAG,则会改变。
3.6.2. 添加、更新和删除特征
向现有层添加新特征与向全新的层添加它们完全一样。根据层定义创建一个空的特征,填充它,并将其插入到层中。更新特征与添加特征类似,只是你处理的是层中已经存在的特征而不是空白特征。找到你想要编辑的特征,进行所需的更改,然后通过将更新后的特征传递给 SetFeature 而不是 CreateFeature 来更新层中的信息。例如,你可以这样做来向层中的每个特征添加一个唯一的 ID 值:
lyr.CreateField(ogr.FieldDefn('ID', ogr.OFTInteger))
n = 1
for feat in lyr:
feat.SetField('ID', n)
lyr.SetFeature(feat)
n += 1
首先,你添加一个 ID 字段,然后遍历特征并将 ID 设置为 n 变量的值。因为你在循环中每次都会增加 n,所以每个特征都有一个唯一的 ID 值。最后,通过传递给 SetFeature 来更新层中的特征。
删除特征甚至更简单。你只需要知道你想要删除的特征的 FID。如果你不知道这个数字,或者通过其他方式不知道,你可以从特征本身获取它,就像这样:
for feat in lyr:
if feat.GetField('City_Name') == 'Seattle':
lyr.DeleteFeature(feat.GetFID())
对于层中的每个特征,你需要检查它的 'City_Name' 属性是否等于 'Seattle',如果是,你从特征本身检索 FID,然后将该数字传递给 DeleteFeature。
然而,某些格式在此点并不会完全删除要素。你可能看不到它,但有时要素只是被标记为删除,而不是完全被丢弃,因此它仍然潜伏在阴影中。因此,你不会看到其他要素被分配该 FID,这也意味着如果你删除了许多要素,文件中可能会有很多不必要的空间。请参见图 3.19 以获取一个简单的示例。删除这些要素将回收空间。如果你在关系数据库方面有很多经验,你应该熟悉这个概念。它与在 Microsoft Access 数据库上运行“压缩和修复”或在使用 PostgreSQL 数据库上使用VACUUM类似。
图 3.19. 真空吸尘或重新打包数据库的影响。请注意,FID 值发生了变化。

如何回收空间或确定是否需要这样做,取决于所使用的矢量数据格式。以下是针对形状文件和 SQLite 进行此操作的示例:

在这两种情况下,你都需要打开数据源,然后在其上执行一个 SQL 语句来压缩数据库。对于形状文件,你需要知道层的名称,所以如果层名为"cities",则 SQL 语句为"REPACK cities"。
形状文件(shapefiles)的另一个问题是,当现有要素被修改或删除时,它们不会更新其空间范围(spatial extent)的元数据(metadata)。如果你编辑现有的几何形状或删除要素,可以通过调用以下命令来确保空间范围得到更新:
ds.ExecuteSQL('RECOMPUTE EXTENT ON ' + lyr.GetName())
然而,如果你插入要素,这并不是必要的,因为这些范围变化是有记录的。如果没有可能你的编辑改变层的范围,那么这也不是必要的。
3.7. 摘要
-
向量数据格式最适合可以表征为点、线或多边形的要素。
-
向量数据集中的每个地理要素都可以附加属性数据,例如名称或人口。
-
用于建模特定要素的几何类型可能会根据比例尺而变化。一个城市在某个国家的地图上可能被表示为一个点,但在较小区域(如县)的地图上则可能是一个多边形。
-
向量数据集在测量地理要素之间的关系方面表现出色,例如距离或重叠。
-
你可以使用 OGR 读取和写入许多不同类型的向量数据,但具体哪些取决于哪些驱动程序被编译到你的 GDAL/OGR 版本中。
-
数据源可以包含一个或多个层(取决于数据格式),而层可以包含一个或多个要素。每个要素都有一个几何形状和可变数量的属性字段。
-
新创建的数据源会自动打开以供写入。如果你想编辑现有数据,请记住打开数据源以供写入。
-
在获取层定义并创建要素以添加或更新数据之前,请记住对层进行更改,例如添加或删除字段。
第四章. 使用不同的矢量文件格式
本章涵盖
-
选择矢量数据文件格式
-
使用各种矢量数据格式
-
检查数据源允许的编辑操作
如前一章所述,存在许多不同的矢量文件格式,它们并不总是可以互换的,至少在实用意义上是这样。某些格式比其他格式更适合某些用途。在本章中,您将了解这些差异以及它们的优缺点。
在格式方面,另一个考虑因素是使用 OGR 可以做什么和不能做什么。一般来说,处理一种类型与处理另一种类型相同,但有时打开数据源的方式可能不同。更大的问题是每个驱动程序的功能差异。例如,某些格式可以读取但不能写入,而其他格式可以创建但无法编辑现有数据。您还将学习如何确定您可以使用数据集做什么和不能做什么。
4.1. 矢量文件格式
到目前为止,您只使用过 shapefiles,但还有许多其他矢量文件格式可供选择。您可能只会经常使用其中的一小部分,但您需要了解可用的选项。一些格式具有开放规范,并被许多不同的软件程序支持,而其他一些则使用较少。某些格式支持比其他格式更多的功能。大多数这些格式允许用户之间轻松传输,就像您可以将电子表格文件给其他人一样。然而,一些格式使用数据库服务器,这允许许多用户从中央位置访问和编辑相同的数据集,但有时会使数据从一个地方移动到另一个地方变得更加困难。
4.1.1. 基于文件的格式,如 shapefiles 和 geoJSON
我称之为基于文件的格式由一个或多个位于磁盘驱动器上的文件组成,可以轻松地从一处转移到另一处,例如从您的硬盘驱动器转移到另一台计算机或外部驱动器。其中一些是关系数据库,但设计得可以轻松移动(想想微软 Access 的关系数据库),因此在本讨论中被认为是基于文件的。其中一些格式具有开放标准,因此任何人都可以创建软件来使用它们,而其他一些则是专有的,并且仅限于较小的软件数量。开放格式的例子包括 GeoJSON、KML、GML、shapefiles 和 SpatiaLite。
空间数据也可以存储在 Excel 表格中,逗号分隔或制表符分隔的文件中,或其他类似格式,尽管这在只需要 x 和 y 坐标的点数据时最为常见。然而,大多数空间数据都是使用专门为 GIS 数据设计的格式存储的。这些格式中有几个是纯文本格式,这意味着您可以在任何文本编辑器中打开它们并查看它们,而其他一些则是二进制文件,需要能够理解它们的软件。
如前所述,纯文本文件的一个优点是你可以用文本编辑器打开它们并检查其内容。如果你愿意,甚至可以手动编辑它们,而不是使用 GIS 软件。列表 4.1 展示了一个 GeoJSON 文件的示例,其中包含瑞士的两个城市,日内瓦和洛桑,两者都表示为点。
列表 4.1. 一个包含两个特征的 GeoJSON 文件示例
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "NAME": "Geneva", "PLACE": "city" },
"geometry": {
"type": "Point",
"coordinates": [ 6.1465886, 46.2017589 ]
}
},
{
"type": "Feature",
"properties": { "NAME": "Lausanne", "PLACE": "city" },
"geometry": {
"type": "Point",
"coordinates": [ 6.6327025, 46.5218269 ]
}
},
]
}
如果你没有完全理解这个示例,那没关系。这里的重点是你可以用文本编辑器打开和编辑文件,而不是使用 GIS 软件。例如,你可以轻松地纠正城市名称的拼写或调整一个点的坐标。在此话题上,值得一提的是,小型的 GeoJSON 文件在上传到 GitHub 时会自动渲染为交互式地图。这里展示的示例保存为 gist,在 gist.github.com/cgarrard/8049400。如果你有 GitHub 账户,你可以将这个 gist 复制到自己的账户,进行修改,并立即看到结果。
如 GeoJSON、KML 和 GML 这样的纯文本格式,对于传输少量数据和网络应用来说很受欢迎,但它们在数据分析方面并不适用。一方面,这三种格式都允许在同一个数据集中存在不同的几何类型,而 GIS 软件并不真正欣赏这一点。例如,流行的 shapefile 格式的数据包含所有点、所有线或所有多边形,但不混合。因此,shapefile 可以包含道路(线)或城市边界(多边形),但不能两者兼有。另一方面,GeoJSON 文件可以在同一个数据集中包含所有三种几何类型的组合,例如前面提到的道路和城市边界,这些内容原本需要存在于两个不同的 shapefile 中。因为你只需要下载和处理一个文件,所以这是一个将数据传递给网络浏览器以便在地图上渲染的绝佳解决方案。然而,大多数 GIS 软件只期望有点、只有线或只有多边形,如果数据混合,则无法正确读取数据。如果你需要将数据加载到 GIS 软件,即使允许,也不要将多个几何类型组合到一个数据集中。
在进行数据分析时,纯文本格式可能存在一个更严重的问题,那就是它们没有像许多二进制格式那样的索引能力。索引用于快速搜索和访问数据。属性索引允许在特征属性字段中的值上进行搜索,例如,搜索具有超过 10 万人口的某个数据集中的所有城市。空间索引存储数据集中特征的空间位置信息,以便搜索可以限制在特定地理区域内的特征,例如,当你在更大规模的水文监测站数据集上叠加一个小流域多边形时。空间索引可以快速找到位于流域边界内的监测站。如果不存在适当的属性或空间索引,这两个操作(寻找大城市和寻找水文监测站)在大数据集中将会很慢。此外,空间索引可以帮助数据集更快地绘制,因为它们有助于找到位于视口内的特征。例如,如果你正在查看亚洲城市,并放大到日本,空间索引有助于更快地找到日本城市,同时忽略中国西部的城市。
在小数据集中,这些问题并不重要,但在大数据集中,它们却极为重要。某些格式有解决这些问题的方法。例如,尽管 KML 格式没有真正的空间索引,但它确实允许将数据集拆分为不同空间位置的不同文件。这允许在用户在地图上缩放和平移时加载更小的数据集,从而提高了渲染速度。
几种矢量数据格式在底层使用熟悉的基于桌面或个人关系型数据库软件。这是 Esri 个人地理数据库和 GeoMedia .mdb 文件的情况,它们使用 Microsoft Access 数据库来存储数据。另一个基于现有数据库格式的矢量格式示例是 SpatiaLite,它是 SQLite 数据库管理系统的一个空间扩展。这些矢量数据格式可以利用数据库软件中内置的功能,例如索引。底层数据库还对存储数据施加了更严格的规则。例如,数据集中的所有地理特征必须具有相同的几何类型和相同的属性字段集合。与非空间数据库可以包含多个表的方式类似,空间数据库可以包含多个数据集。尽管单个数据集限制为单个几何类型,但单个数据库文件可以包含多个数据集,每个数据集具有不同的几何类型和属性字段。这对于将相关数据集放在一起以及将它们从磁盘移动到磁盘来说很方便。图 4.1 显示了包含具有不同几何形状的多个数据集的单个 SpatiaLite 数据库文件的示意图。
图 4.1. 一个包含多个不同几何类型的 SpatiaLite 数据库示例。所有这些各种数据集都包含在一个易于携带的文件中。

其他矢量格式由多个文件组成,例如一直很受欢迎的 shapefile。这些数据集将几何形状、属性值和索引存储在单独的文件中。如果你将 shapefile 从一个位置移动到另一个位置,你需要确保移动所有必需的文件。其他需要多个文件的格式类型通过使用包含必要文件的专用文件夹来简化这个过程。与 shapefile 一样,你不需要了解任何关于单个文件的信息,但你不应该更改文件夹中的任何内容。使用这种系统的格式示例包括 Esri 网格和文件地理数据库。
这里没有提到许多其他矢量数据格式,但你现在应该对格式类型及其优缺点有所了解。
4.1.2. 多用户数据库格式,如 PostGIS
你已经看到,基于文件的格式有很多形状和大小,包括桌面关系数据库模型,如 SpatiaLite。这些格式的局限性之一是它们不允许多个人同时编辑,有时甚至使用特定的数据集。这就是多用户客户端-服务器数据库架构发挥作用的地方,因为数据存储在多个客户端可以通过网络访问的数据库中。用户从服务器访问数据,而不是在本地磁盘上打开文件。虽然这当然不是为每个人准备的,但它是一个从中央位置向许多用户提供数据的绝佳选择。如果数据经常更新或被许多不同的用户使用,这尤其有用,因为所有用户将立即能够访问更新后的数据。它还允许多个人同时编辑数据集,这在基于文件的格式中通常是不可能的。此外,在许多情况下,这些数据库系统的索引和查询能力在访问数据时提供了更快的性能。
空间数据最流行的客户端-服务器数据库解决方案包括带有 PostGIS 空间扩展的 PostgreSQL、ArcSDE、SQL Server 以及 Oracle Spatial 和 Graph。如果你想在你的电脑上托管数据,你需要投资这样的系统。我最喜欢的是 PostGIS (www.postgis.net),因为它开源,提供了一个功能丰富的环境,包含许多针对空间数据特定的函数、运算符和索引。即使有大量数据,你仍然可以获得良好的性能。虽然你不能压缩 PostGIS 数据集并通过电子邮件发送给同事,但它附带了一些导入和导出几种流行文件格式的小工具,并且运行查询并将数据导出到便携格式非常直接。PostGIS 不仅存储数据,你还可以用它进行许多类型的分析,而无需其他 GIS 软件。PostGIS 还支持栅格数据。
如果你不太熟悉关系数据库,那么设置这些系统之一并学习如何使用它可能需要付出努力。但如果你需要为多个用户提供同时访问数据的能力,那么它非常强大,值得在脑细胞上的投资。
4.2. 处理更多数据格式
到目前为止,我们只处理了众多数据格式中的一种。尽管格式不同,基本原理没有变化。一旦打开数据源,读取数据基本上是相同的。但为了好玩,让我们看看支持多个层的几种格式,因为我们还没有这样做过。到目前为止,我们只使用数据源中的第一个和唯一一层,但如果存在多个层,你需要知道你感兴趣的那个层的名称或索引。通常,我会使用 ogrinfo 来获取这些信息,但鉴于这是一本关于 Python 的书,让我们编写一个简单的函数,该函数打开数据源,遍历层,并打印它们的名称和索引:
def print_layers(fn):
ds = ogr.Open(fn, 0)
if ds is None:
raise OSError('Could not open {}'.format(fn))
for i in range(ds.GetLayerCount()):
lyr = ds.GetLayer(i)
print('{0}: {1}'.format(i, lyr.GetName()))
这个函数接受数据源文件名作为参数,它首先执行的操作是打开文件。然后它使用GetLayerCount来找出数据源包含多少层,并循环多次。每次循环中,它使用i变量来获取对应迭代索引的层。然后它打印出层的名称及其索引。这个函数包含在 ospybook 模块中,你将在下面的示例中使用它来检查其他数据源。
4.2.1. SpatiaLite
让我们从 SpatiaLite 数据库开始。这种类型的数据源可以包含许多不同的层,所有层都有独特(并且希望是描述性的)名称。为了查看这一点,请在数据下载中的 natural_earth_50m.sqlite 文件中列出层:
>>> import ospybook as pb
>>> pb.print_layers(r'D:\osgeopy-data\global\natural_earth_50m.sqlite')
0: countries
1: populated_places
如你所见,数据集有两个层。你将如何获取 populated_places 层的句柄?嗯,你可以使用索引或层名,所以 ds.GetLayer(1) 和 ds.GetLayer('populated_places') 都可以行得通。然而,使用名称而不是索引可能更好,因为索引可能会在数据源中添加其他层时发生变化。为了证明这一点,尝试绘制层,这将显示代表世界各地城市的点,如图 4.2 所示。
图 4.2. natural_earth_50m.sqlite 中的 populated_places 层

>>> ds = ogr.Open(r'D:\osgeopy-data\global\natural_earth_50m.sqlite')
>>> lyr = ds.GetLayer('populated_places')
>>> vp = VectorPlotter(True)
>>> vp.plot(lyr, 'bo')
Ogrinfo
GDAL 包含几个极其有用的命令行工具,实际上,你已经看到了如何使用 ogrinfo 来找出你的 OGR 版本支持的矢量数据格式。你也可以使用 ogrinfo 来获取有关特定数据源和层的详细信息。如果你传递一个数据源名称,它将打印出该数据源中包含的层列表:
D:\osgeopy-data\global>ogrinfo natural_earth_50m.sqlite
INFO: Open of `natural_earth_50m.sqlite'
using driver `SQLite' successful.
1: countries (Multi Polygon)
2: populated_places (Point)
你还可以使用 ogrinfo 来查看层的元数据,甚至所有属性数据。以下示例将仅显示自然地球 SQLite 数据库中 countries 层的摘要(-so)。这包括范围、空间参考以及属性字段及其数据类型列表。第二个示例将显示层中第一个特征的全部属性值。
ogrinfo -so natural_earth_50m.sqlite countries
要显示具有 FID 为 1 的特征的全部属性值,你可以这样做,其中 –q 表示 不打印元数据,而 –geom=NO 表示 不打印几何形状的文本表示(这将非常长)。
ogrinfo -fid 1 -q -geom=NO natural_earth_50m.sqlite countries
有关 ogrinfo 的完整文档,请参阅 www.gdal.org/ogrinfo.html。
4.2.2. PostGIS
那么,连接到数据库服务器,如 PostgreSQL 的 PostGIS 空间扩展呢?请注意一些额外的考虑因素,这些因素你不需要在本地文件中担心。你需要知道要使用的连接字符串,这涉及到主机、端口、数据库名称、用户名和密码。你还需要有权限连接到相关的数据库和表。如果你不是管理自己的数据库服务器,那么你可能需要与数据库管理员交谈,以设置所有这些。以下示例连接到由运行在我本地机器上的 PostgreSQL 实例提供服务的地理数据库。除非你费心安装 PostgreSQL 和 PostGIS,并设置数据库,否则它不会对你起作用。
>>> pb.print_layers('PG:user=chris password=mypass dbname=geodata')
0: us.counties
1: global.countries
2: global.populated_places
3: time_zones
你在这里看到四个层,但它们被分成三个不同的组,或称为模式。时区层位于默认模式中,县位于 us 模式,其余两个位于 global 模式。数据库的每个用户都可以访问不同的模式,甚至一个模式内的不同层,具体取决于数据库管理员如何设置安全设置。
如您所见,您可以使用 OGR 访问 PostGIS 数据库,但您可以使用 PostGIS 数据库做很多事情,这些内容本书并未涵盖。如果您想了解更多关于它的信息,可以看看由 Manning 出版的PostGIS in Action。
4.2.3. 作为数据源的文件夹(shapefiles 和 CSV)
在某些情况下,OGR 会将整个文件夹视为数据源。两个例子是 shapefile 和逗号分隔的文本文件(.csv)驱动程序,它们可以用来打开单个文件或整个文件夹作为数据源。如果您使用文件夹,那么文件夹中的每个文件都被视为一个图层。如果一个文件夹包含多种文件类型,则使用 shapefile 驱动程序。例如,尝试列出 US 文件夹中的图层:
>>> pb.print_layers(r'D:\osgeopy-data\US')
0: citiesx020 (Point)
1: cities_48 (Point)
2: countyp010 (Polygon)
3: roadtrl020 (LineString)
4: statep010 (Polygon)
5: states_48 (Polygon)
6: volcanx020 (Point)
将此列表与文件夹内容进行比较,您会发现它列出了每个形状文件,但没有列出其他文件。然而,CSV 驱动程序比较挑剔,它希望文件夹中的所有文件都是 CSV 文件。尽管它不能与 US 文件夹一起工作,但它与 csv 子文件夹配合得很好。这意味着您不能打开一个包含大量其他文件的文件夹中的 CSV 文件吗?幸运的是,不是这样。您只需将 CSV 文件本身视为只有一个图层的单一数据源。您也可以通过提供.shp 文件名来用形状文件做同样的事情。
4.2.4. Esri 文件地理数据库
在场的 Esri 用户可能期望在文件地理数据库中看到特征数据集被像 PostGIS 中的模式一样处理。如果是这样,您可能会失望,因为您看到的所有都是特征类名称。图 4.3 显示了在 ArcCatalog 中自然地球文件地理数据库的外观,但大比例尺特征数据集的名称并未包含在 OGR 使用的图层名称中。
图 4.3. 在 ArcCatalog 中看到的 natural_earth 文件地理数据库

>>> pb.print_layers(r'D:\osgeopy-data\global\natural_earth.gdb')
0: countries_10m
1: populated_places_10m
2: countries_110m
3: populated_places_110m
幸运的是,您不需要特征数据集的名称来访问图层;特征类名称就足够了:
>>> ds = ogr.Open(r'D:\osgeopy-data\global\natural_earth.gdb')
>>> lyr = ds.GetLayer('countries_10m')
文件地理数据库有两个不同的驱动程序。您可以在 OGR 网站上了解更多关于它们之间的区别,但一个巨大的区别是,只读的 OpenFileGDB 驱动程序默认编译进 OGR,而读写 FileGDB 驱动程序则不是,因为它需要来自 Esri 的第三方库。如果有人给了您一个需要修改的文件地理数据库,但您没有访问 FileGDB 驱动程序的权限,您仍然可以使用 OpenFileGDB 驱动程序打开地理数据库并将数据复制到您可以编辑的格式。这可能不是最佳选择,但至少您有这个选项。例如,您可以将自然地球地理数据库中的 countries_110m 要素类复制到如下形状文件:
gdb_ds = ogr.Open(r'D:\osgeopy-data\global\natural_earth.gdb')
gdb_lyr = gdb_ds.GetLayerByName('countries_110m')
shp_ds = ogr.Open(r'D:\Temp', 1)
shp_ds.CopyLayer(gdb_lyr, 'countries_110m')
del shp_ds, gdb_ds
你之前还没有见过 CopyLayer 方法。这允许你轻松地将整个图层的所有内容复制到新的数据源或相同的数据源,但具有不同的图层名称。要使用它,你需要获取你想要复制的图层以及你想要保存副本的数据源。然后在对获取副本的数据源调用 CopyLayer,并传递原始图层和为新图层创建的名称。
如果你已经安装了 Esri FileGDB 驱动程序,你可以创建新的文件地理数据库,甚至特征数据集,尽管 OGR 不会显示特征数据集的名称。列表 4.2 展示了一个将数据源中的所有图层导入文件地理数据库中的特征数据集的功能,但请注意,这仅在你有 FileGDB 驱动程序的情况下有效。如果你在没有安装该驱动程序的情况下尝试使用此功能,你会收到一个错误消息,显示 AttributeError: ‘NoneType’ 对象没有属性 ‘CreateDataSource’。
列表 4.2. 导入图层到文件地理数据库的功能

此函数需要三个参数:原始数据源的路径、文件地理数据库的路径以及要复制图层的特征数据集的名称。在打开原始数据源后,它会检查文件地理数据库是否存在。如果存在,则地理数据库将用于写入。如果不存在,则创建它。特征数据集使用图层创建选项指定,因此创建了一个包含单个 FEATURE_DATASET 选项的列表。之后,将遍历原始数据源中的所有图层,并将它们复制到地理数据库中,同时保持相同的图层名称(尽管如果地理数据库中发生命名冲突,它们将被重命名)。如果没有提供 FEATURE_DATASET 图层创建选项,则图层将被添加到文件地理数据库中,但将位于顶级而不是特征数据集中。
现在你有了这个函数,你可以将文件夹中的所有 shapefile 复制到地理数据库中,如下所示:
layers_to_feature_dataset(
r'D:\osgeopy-data\global', r'D:\Temp\osgeopy-data.gdb', 'global')
如果你想要将特征类保存到地理数据库的顶级而不是特征数据集中,你可以修改此函数,使其在 dataset_name 参数为 None 或空字符串时不将选项列表传递给 CopyLayer。
4.2.5. 网络特征服务
你还可以访问在线服务,例如 Web Feature Services (WFS)。让我们尝试使用由美国国家海洋和大气管理局 (NOAA) 托管的 WFS,该服务提供危险天气警报和咨询。首先获取可用图层的列表:
>>> url = 'WFS:http://gis.srh.noaa.gov/arcgis/services/watchWarn/' + \
... 'MapServer/WFSServer'
>>> pb.print_layers(url)
0: watchWarn:WatchesWarnings (MultiPolygon)
1: watchWarn:CurrentWarnings (MultiPolygon)
你可以像处理其他数据源中的图层一样遍历这些图层,但所有数据都是立即获取的,所以如果列表中有许多特征,可能会有相当大的延迟。看起来第二个图层只包含警告,比监视更严重,所以它应该有更少的数据。让我们找出第一个特征代表的是哪种类型的警告。我发现如果尝试使用 FID 与GetFeature一起使用,事情会崩溃,但你可以使用GetNextFeature来完成它:
>>> ds = ogr.Open(url)
>>> lyr = ds.GetLayer(1)
>>> feat = lyr.GetNextFeature()
>>> print(feat.GetField('prod_type'))
Tornado Warning
如果你只想获取前几个特征,我可以推荐一种更简单、更快的方法。只需在你的 URL 上添加一个MAXFEATURES参数,如下所示:
>>> url += '?MAXFEATURES=1'
>>> ds = ogr.Open(url)
>>> lyr = ds.GetLayer(1)
>>> lyr.GetFeatureCount()
1
你也可以使用 WFS 中的几何形状。图 4.4 展示了当我使用 VectorPlotter 在州上绘制 watchWarn:WatchesWarnings 层时的结果。
图 4.4. 来自 NOAA 网络特征服务的 WatchesWarnings 层。如果你绘制它,你的结果会有所不同,因为这个图层显示实时数据。

让我们做一些不同的事情——从 WFS 中保存实时数据,并使用它通过 Folium 构建一个简单的网络地图,Folium 是一个创建 Leaflet 地图的 Python 模块。如果你不知道 Leaflet 是什么,没关系,因为你不需要了解任何关于网络地图的知识来完成这个示例。不过,你首先需要安装 Folium。在我的 Windows 电脑上,我打开了一个命令提示符,并使用 pip 安装了 Folium 和 Jinja2(Folium 工作所需的另一个模块),如下所示:
C:\Python33\Scripts\pip install Jinja2
C:\Python33\Scripts\pip install folium
如果你不太熟悉通过 pip 安装 Python 模块,请参阅附录 A 中的安装说明。现在让我们看看示例脚本,它将代码分解成函数,以便代码可以轻松重用。列表 4.3 包含一个从 WFS 检索流量计数据并将其保存为 GeoJSON 的函数;一个创建显示这些流量计的网络地图的函数;一个获取几何形状的函数,以便地图聚焦于单个州而不是整个国家;以及几个辅助函数,用于格式化 WFS 请求和地图的数据。
列表 4.3. 从 WFS 数据创建网络地图


你可能可以理解get_state_geom函数的作用及其工作方式,因为你之前已经见过同样的过程。它接受一个州名作为参数,在图层中找到相应的特征,并返回克隆的几何形状。文件名是硬编码的,因为你假设这个州边界文件的位置不会改变。
两个辅助函数也很简单。get_center函数接受一个几何形状,获取其质心,然后以[y, x]列表的形式返回坐标。这个顺序可能看起来很奇怪,但这是 Folium 希望它们在地图中的顺序。
get_bbox 函数接受一个几何形状,并以 min_x,min_y,max_x,max_y 的格式返回其边界坐标作为字符串。这是 WFS 用于空间子集结果的格式,也是你如何将测量结果限制在州边界框内的方法。此函数利用字符串格式化规则重新排列 GetEnvelope 的结果,它返回一个几何形状的边界框(图 4.5)作为一个 [min_x, max_x, min_y, max_y] 列表。
图 4.5。这条线是俄克拉荷马州的边界框。

现在我们来看一下稍微复杂一点的 save_state_gauges 函数。在这里,你将 WFS 的 URL 硬编码为返回高级水文预测服务观察到的河流水位数据的 URL。你还创建了一个包含要传递给 WFS 的参数的字典。正如你所知,typeNames 参数是要检索数据的层的名称。version 是要使用的 WFS 版本,而 srsName 指定你希望数据返回的坐标系。你可以在 WFS 的能力输出中看到此选项,你可以在服务 URL 的末尾附加 ?request=GetCapabilities 并在网页浏览器中访问它。例如,gis.srh.noaa.gov/arcgis/services/ahps_gauges/MapServer/WFSServer?request=GetCapabilities 的部分输出如下:
<wfs:FeatureType>
<wfs:Name>ahps_gauges:Observed_River_Stages</wfs:Name>
<wfs:Title>Observed_River_Stages</wfs:Title>
<wfs:DefaultSRS>urn:ogc:def:crs:EPSG:6.9:4269</wfs:DefaultSRS>
<wfs:OtherSRS>urn:ogc:def:crs:EPSG:6.9:4326</wfs:OtherSRS>
<snip>
</wfs:FeatureType>
从这里你可以看到默认的坐标参考系统 (DefaultSRS) 是 EPSG 4269,这恰好是使用 NAD83 基准的未投影数据。如果你现在还不明白,不用担心,因为你在第八章中会学到所有关于它的知识。你现在需要知道的是,网络地图库通常希望使用 WGS84 坐标,这对应于 EPSG 4326。幸运的是,这在能力输出中列为 OtherSRS 选项,所以你将其插入到参数字典中:
parms = {
'version': '1.1.0',
'typeNames': 'ahps_gauges:Observed_River_Stages',
'srsName': 'urn:ogc:def:crs:EPSG:6.9:4326',
}
if bbox:
parms['bbox'] = bbox
如果用户向该函数提供了 bbox 参数,你也需要将其插入到你的字典中。如果向 WFS 提供了 bbox 参数,它将返回该框内的要素,而不是返回所有要素。请记住,你的 get_bbox 函数根据几何形状的边界框创建一个正确格式的字符串。
创建这个字典并不是绝对必要的,因为你可以用与之前示例相同的方式构建你的查询字符串,但我认为使用字典可以使看到传递的参数更容易。使用 urlencode 函数很容易从字典中创建查询字符串,它会为你格式化一切。在 Python 2 中,这个函数位于 urllib 模块中,但在 Python 3 中位于 urllib.parse,这就是为什么你有一个 try/except 块中的下一步。你尝试使用 Python 2 函数创建查询字符串,但如果因为脚本是用 Python 3 运行的而失败,那么你就用 Python 3 的方式来做:
try:
request = 'WFS:{0}?{1}'.format(url, urllib.urlencode(parms))
except:
request = 'WFS:{0}?{1}'.format(url, urllib.parse.urlencode(parms))
在创建你的查询字符串之后,你使用它来打开与 WFS 的连接并获取层。不过,这次你想要将输出保存到本地文件中,所以你创建了一个空的 GeoJSON 数据源。数据源有一个 CopyLayer 函数,可以将现有的层复制到数据源中;这个现有的层可以完全来自另一个数据源。你使用这个函数将 WFS 中的数据复制到你的新 GeoJSON 文件中:
json_ds.CopyLayer(wfs_lyr, '')
CopyLayer 的第二个参数是新层的名称,但 GeoJSON 层没有名称,所以你传递一个空字符串。你也可以传递一个真实的层名称,但这并不会带来太多好处。当你的函数在创建层之后返回,数据源就会超出作用域,因此文件会自动关闭,这就是为什么你不需要在函数内部关闭它们的原因。
你最后编写的函数被称作 make_map。它需要一个状态名称以及输出 GeoJSON 和 HTML 文件的文件名。它还可以接受其他命名参数,这些参数会被传递给 Folium,这使得你可以在不担心它们的情况下传递可选的 Folium 参数:
def make_map(state_name, json_fn, html_fn, **kwargs):
"""Make a folium map."""
geom = get_state_geom(state_name)
save_state_gauges(json_fn, get_bbox(geom))
fmap = folium.Map(location=get_center(geom), **kwargs)
fmap.geo_json(geo_path=json_fn)
fmap.create_map(path=html_fn)
基本轮廓在 图 4.6 中显示,但这个函数首先做的事情是获取感兴趣州份的几何形状。然后它获取几何形状的 bbox 并将其传递,包括输出 GeoJSON 文件名,到保存 WFS 数据到文件的函数。然后它创建一个以几何形状为中心的 Folium 地图,并使用用户可能传递的任何命名参数。记住,** 会将字典展开成键/值对,所以所有的参数都被当作是名为 kwargs 的展开字典来处理。你可以在 folium.readthedocs.org/en/latest/ 中阅读有关可选参数的信息。这个地图默认使用 OpenStreetMap 瓦片作为底图,但你也可以更改这一点。
图 4.6. make_map 函数中的任务

在创建基本地图之后,GeoJSON 文件的内容会被添加,并且地图会被保存到用户提供的 HTML 文件名中。剩下的就是使用它了。
os.chdir(r'D:\Dropbox\Public\webmaps')
make_map('Oklahoma', 'ok.json', 'ok.html',
zoom_start=7)
我使用了一个 Dropbox 文件夹,这样我就可以通过 Dropbox 公共链接功能在网络上查看输出。如果你不使用网络服务器,直接从本地驱动器查看输出可能不会有太多运气。如果你没有可用的类似 Dropbox 的服务,请查看侧边栏了解如何在本地机器上启动简单的 Python 网络服务器。我想制作一个俄克拉荷马州的地图,我还将一个可选参数 zoom_start 传递给了 Folium。默认情况下,Folium 地图以 10 级缩放开始,这太近了,无法看到整个州。在这个例子中,7 级起始缩放效果会更好。
Python SimpleHTTPServer
Python 随带了一个简单的网络服务器,你可以用它来测试一些事情,尽管你可能不应该用它来生产网站。使用它的最简单方法是打开一个终端窗口或命令提示符,切换到包含你想要提供服务的文件的目录,然后从命令行调用服务器。
对于 Python 2:
D:\>cd dropbox\public\webmaps
D:\Dropbox\Public\webmaps>c:\python27\python -m SimpleHTTPServer
对于 Python 3:
D:\>cd dropbox\public\webmaps
D:\Dropbox\Public\webmaps>c:\python33\python -m http.server
这将在本地端口 8000 上启动一个网络服务器,因此你可以在网页浏览器中通过 http://localhost:8000/ 访问它。如果你启动服务器所在的文件夹中有一个名为 index.html 的文件(在这个例子中是 d:\dropbox\public\webmaps),那么该页面将自动显示。否则,将显示文件夹中的文件列表,你可以点击其中一个来查看它。俄克拉荷马州示例的 URL 将是 http://localhost:8000/ok.html。
运行脚本后,你可以获取 ok.html 的 Dropbox 公共链接并在网页浏览器中查看它。如果一切顺利,它看起来就像 图 4.7 一样。
图 4.7. 使用 GeoJSON 文件制作的简单 Folium 地图
![04fig07.jpg]
图 4.7 中的地图显示了流量计的位置,但除此之外,它并不太有用。更小的标记会更好,如果你点击标记,弹出窗口会提供计数值。不幸的是,我相信没有直接通过添加 GeoJSON 文件到地图上来实现这一功能的方法,但这并不难手动完成。让我们添加一个函数来制作自定义标记,以及几个辅助函数,然后更改 make_map 函数,使其使用这些而不是直接将 GeoJSON 添加到地图中。
列表 4.4. Folium 地图的自定义标记


在这里你首先需要设置要使用的颜色。这些来自该地图服务的在线图例,该图例可在 gis.srh.noaa.gov/arcgis/rest/services/ahps_gauges/MapServer/0 找到。colors 字典中的键是 Status 属性字段中的可能值,而值是描述颜色的十六进制字符串。
get_popup 函数通过将特征的属性字典展开,并将值插入到模板字符串中相应的占位符来创建一个 HTML 字符串。例如,Location 字段中的值将被插入到模板字符串中的“{location}”位置。
标记是在 add_markers 函数中创建的,该函数遍历 GeoJSON 图层并为图层中的每个点创建一个标记。这使用了 Folium 的 circle_marker 函数,它希望第一个参数是一个 [y, x] 列表。这是标记将在地图上放置的位置。你根据该位置的洪水状态使用了不同的颜色,并为标记添加了一个弹出窗口。radius 参数是标记的像素半径。你的标记比默认的要大一些。
最后的步骤是将 make_map 函数更改为调用 add_markers 而不是 geo_json,然后创建一个新的地图。这次你使用 Stamen Toner 瓦片而不是 OpenStreetMap,主要是因为这样标记更容易看到。你的输出应该看起来像 图 4.8,如果你点击一个标记,你会看到一个包含相关信息的弹出窗口。
图 4.8. 通过手动构建带弹出窗口的彩色标记创建的更美观的地图

虽然这不是本书的主题,但我希望你喜欢这次对网络地图的短暂探索。如果你对这个主题一无所知,并且像我一样,你现在又有一个“要学习”的项目。
4.3. 测试格式功能
如前所述,并非所有操作都适用于所有数据格式和驱动程序。除了尝试并祈祷你的代码不会崩溃之外,你如何找出你的数据允许什么操作?幸运的是,如果询问,驱动程序、数据源和图层都愿意传达这些信息。表 4.1 展示了你可以检查每种数据类型的哪些功能。
表 4.1. 用于测试功能的常量
| 驱动程序功能 | OGR 常量 |
|---|---|
| 创建新数据源 | ODrCCreateDataSource |
| 删除现有数据源 | ODrCDeleteDataSource |
| 数据源功能 | OGR 常量 |
| 创建新图层 | ODsCCreateLayer |
| 删除现有图层 | ODsCDeleteLayer |
| 图层功能 | OGR 常量 |
| 使用 GetFeature 读取随机特征 | OLCRandomRead |
| 添加新特征 | OLCSequentialWrite |
| 更新现有特征 | OLCRandomWrite |
| 支持高效的空间过滤 | OLCFastSpatialFilter |
| 具有高效的 GetFeatureCount 实现 | OLCFastFeatureCount |
| 具有高效的 GetExtent 实现 | OLCFastGetExtent |
| 创建新字段 | OLCCreateField |
| 删除现有字段 | OLCDeleteField |
| 在属性表中重新排序字段 | OLCReorderFields |
| 修改现有字段的属性 | OLCAlterFieldDefn |
| 支持事务 | OLCTransactions |
| 删除现有要素 | OLCDeleteFeature |
| 具有高效的 SetNextByIndex 实现 | OLCFastSetNextByIndex |
| 字符串字段的值保证是 UTF-8 编码 | OLCStringsAsUTF8 |
| 支持在获取要素数据时忽略字段,这可以加快数据访问速度 | OLCIgnoreFields |
要检查某个特定功能,你只需在驱动程序、数据源或层上调用TestCapability函数,并将表 4.1 中的一个常量作为参数传递。如果该操作允许,函数将返回True,如果不允许,则返回False。尝试使用此方法确定是否可以将新的 shapefiles 添加到文件夹中:

如你或许能猜到的,当文件夹以写入方式打开时,你可以创建新层,但在只读方式打开时则不行。你如何使用这个信息来确保你不尝试做会导致错误的事情?你可以在尝试任何编辑之前修改你的代码以添加检查:

如果不允许添加字段到层,这个片段将引发错误并停止继续。如果你需要,你可以捕获并处理这个错误,或者让它退出。如果你不想处理错误,事先检查的最大原因是在开始编辑之前确保所有编辑都是可能的。
例如,如果一个层支持编辑字段但不支持删除要素,而你想要同时进行这两项操作?如果你在删除要素之前编辑了字段,那么部分更改(字段编辑)将在你的代码尝试删除要素时崩溃之前发生。显然,如果你想要所有或没有更改,这是一个问题。如果你不介意部分更改,那么你可能不需要担心这个问题,但你可以通过事先检查功能并在你不被允许进行所有更改时停止操作来避免这个问题。
另一个选项是,如果你认为部分编辑是可以接受的,但仍然想要处理错误而不是让脚本崩溃,可以使用 OGR 异常。你不需要添加任何代码来测试功能,但你需要记得在脚本早期添加ogr.Use-Exceptions()。使用这种方法,尝试删除要素的尝试仍然会失败,但它会抛出一个你可以捕获的RuntimeError。
ospybook 模块中的一个函数print_capabilities会打印出驱动程序、数据源或层支持的功能。这是如何在 Python 交互式窗口中使用它的方法:
>>> driver = ogr.GetDriverByName('ESRI Shapefile')
>>> pb.print_capabilities(driver)
*** Driver Capabilities ***
ODrCCreateDataSource: True
ODrCDeleteDataSource: True
因为这个函数只打印出信息,所以你不能在代码中使用它来确定基于可用功能采取什么行动。不过,你可以在交互式窗口中使用它来确定对象上允许执行哪些操作。
4.4. 摘要
-
你选择使用的矢量文件格式可能取决于应用。你可能选择 GeoJSON 来制作网络地图,但使用 shapefiles 或 PostGIS 进行数据分析。
-
可能最受欢迎的数据传输格式是 shapefile,因为它简单,规范是公开的,并且已经存在很长时间了。
-
基于数据库的格式,如 SpatiaLite、PostGIS 和 Esri 地理数据库,通常比其他矢量格式更高效,并支持更多功能。
-
尽管打开各种数据源类型的语法不同,但一旦数据源打开,无论数据源如何,你都可以以相同的方式访问图层和功能。
-
数据源中的多个图层可能彼此不同。例如,它们可以具有不同的几何类型、属性字段、空间范围和空间参考系统。
-
你可以使用
TestCapability来确定在你的数据集中允许哪些编辑。
第五章. 使用 OGR 过滤数据
本章涵盖
-
使用属性值高效选择特性
-
使用空间位置选择特性
-
从不同层连接属性表
在第三章中,你学习了如何遍历一个层中的所有特性,并使用每个特性的属性值来确定它是否有趣。然而,你有更简单的方法来丢弃不需要的特性,这就是过滤器的作用所在。使用过滤器,你可以轻松选择符合特定标准的特性,例如某一天的所有动物 GPS 位置或城市树木清单中的所有苹果树。过滤器还允许你通过空间范围限制特性,因此你可以将苹果树限制在特定的社区内,或将 GPS 位置限制在动物喂食站一公里范围内。以这种方式过滤数据可以轻松提取或处理你感兴趣的特性。我已使用这些技术从更大的数据集中提取城市边界等特性,或从道路数据集中提取高速公路和快速路,同时忽略较小的住宅道路。
你也可以使用 SQL 查询将来自不同层的属性表连接起来。例如,如果你有一个包含所有商店分店位置的层,并且每个特性都有一个表示商店所在城市的属性,那么你可以将这个层与包含城市的层连接起来。如果城市层包含每个城市的人口统计信息,那么这些数据将与商店数据相关联,你就可以轻松地比较不同商店之间的统计数据。
定义
SQL 代表结构化查询语言,尽管你很少看到它以这种方式书写。如果你使用过关系型数据库,那么你可能已经使用过 SQL,即使你没有意识到。例如,如果你在 Microsoft Access 中构建了一个图形查询,它仍然在后台构建一个 SQL 查询,如果你切换到 SQL 视图,你就可以看到它。SQL 在其他数据库软件中(如 PostgreSQL)更为突出。
5.1. 属性过滤器
如果你需要通过一个或多个属性字段中的值来限制特性,那么你需要一个属性过滤器。要设置这样的过滤器,你需要提出一个类似于 SQL 语句中的WHERE子句的条件语句。你将一个属性字段的值与另一个值进行比较,然后返回所有比较结果为真的特性。标准的逻辑运算符,如=, !=, <>, >, <, >=, 和 <=,允许你使用以下之类的语句:

您可能已经猜到了这些比较的作用;它们都测试的是相等或不相等。请注意,如果您正在比较字符串,您需要在字符串值周围加上引号,但它们可以是单引号或双引号。确保它们与您用于包围整个查询字符串的引号不同,否则您将提前结束字符串并得到语法错误。不要在数字周围使用引号,因为这会将它们转换为字符串值,您将不会得到预期的比较结果。您可能还注意到,您使用单个等号来测试相等,这与编程语言通常的做法不同。但这是 SQL 的做法,所以我们有什么理由争论呢?此外,如果您想测试某个值是否不等于另一个值,您可以使用 != 或 <>。
您也可以使用 AND 或 OR 来组合语句:
'(Population > 25000) AND (Population < 50000)'
'(Population > 50000) OR (Place_type = "County Seat")'
第一个选择具有超过 25,000 但少于 50,000 人口的区域。第二个选择的是人口超过 50,000 或是县首府(或两者都是)的区域。
可以使用 NOT 来否定条件,NULL 用于在属性表中指示空或无数据值:
'(Population < 50000) OR NOT (Place_type = "County Seat")'
'County NOT NULL'
第一个示例选择的是人口少于 50,000 或不是县首府的区域。再次强调,如果一个区域满足上述一个或两个条件,它将被选中。第二个示例选择的是具有 County 属性值的区域。
如果您想检查一个值是否介于两个其他值之间,您可以使用 BETWEEN 而不是使用两个不同的比较并用 AND 连接。例如,以下两个语句是等价的,并且都选择人口在 25,000 到 50,000 之间的区域:
'Population BETWEEN 25000 AND 50000'
'(Population > 25000) AND (Population < 50000)'
您有一种简单的方法来检查一个值是否等于几个不同的值。再次强调,这两个选择功能 Type_code 的值是 4、3 或 7:
'Type_code IN (4, 3, 7)'
'(Type_code = 4) OR (Type_code = 3) OR (Type_code = 7)'
这也适用于字符串:
'Place_type IN ("Populated Place", "County Seat")'
最后,您可以使用正常的逻辑运算符(a 小于 c)来比较字符串,或者您可以使用更复杂的、不区分大小写的字符串匹配,使用 LIKE。这允许您使用通配符来匹配字符串中的任何字符。下划线匹配任何单个字符,百分号匹配任意数量的字符。表 5.1 展示了示例,以下是使用方法:
'Name LIKE "%Seattle%"'
表 5.1. 使用 LIKE 运算符的匹配示例
| 模式 | 匹配 | 不匹配 |
|---|---|---|
| _eattle | Seattle | Seattle WA |
| Seattle% | Seattle, Seattle WA | North Seattle |
| %Seattle% | Seattle, Seattle WA, North Seattle | Tacoma |
| Sea%le | Seattle | Seattle WA |
| Sea_le | Seatle (note misspelling) | Seattle |
如果你想了解更多关于 OGR 中可用的 SQL 语法,请查看www.gdal.org/ogr_sql.html和www.gdal.org/ogr_sql_sqlite.html上的在线文档。但现在,让我们看看如何使用这些新获得的信息。如果你为测试而启动 Python 交互式窗口,这肯定会更有趣,因为你可以使用VectorPlotter类来交互式地绘制你的选择。配置交互式矢量绘图器后,打开全球数据文件夹,获取低分辨率国家图层:
>>> ds = ogr.Open(r'D:\osgeopy-data\global')
>>> lyr = ds.GetLayer('ne_50m_admin_0_countries')
然后绘制特征,但如果绘制输出图 5.1 需要几秒钟,请耐心等待,因为它有相当多的数据需要绘制。记住,设置fill=False告诉它只绘制国家轮廓。
图 5.1. 全球数据文件夹中的 ne_50m_admin_0_countries 形状文件图层,未应用过滤器

>>> vp.plot(lyr, fill=False)
现在通过打印前几个特征的名字来检查图层属性:
>>> pb.print_attributes(lyr, 4, ['name'], geom=False)
FID name
0 Aruba
1 Afghanistan
2 Angola
3 Anguilla
4 of 241 features
注意特征 ID(FID)是有序的,以及该图层中有 241 个特征。现在使用属性过滤器找出其中有多少在亚洲。为此,将条件语句传递给SetAttributeFilter:
>>> lyr.SetAttributeFilter('continent = "Asia"')
0
>>> lyr.GetFeatureCount()
53
现在图层认为它只有 53 个特征。当你调用SetAttributeFilter时输出的零表示查询执行成功。现在你已经选择了亚洲国家,尝试用黄色绘制它们;你的结果应该像图 5.2 所示:
>>> vp.plot(lyr, 'y')
图 5.2. 已将选择亚洲国家的属性过滤器应用于图 5.1 中显示的国家图层,并将结果绘制在原始图之上。

注意:印刷版书籍读者:彩色图形
本书中的许多图形最好以彩色查看。电子书版本显示彩色图形,因此阅读时应参考这些版本。要获取免费电子书(PDF、ePub 和 Kindle 格式),请访问www.manning.com/books/geoprocessing-with-python注册您的印刷版书籍。
你可以通过打印前几个特征的特征来更仔细地查看过滤器发生的情况:
>>> pb.print_attributes(lyr, 4, ['name'], geom=False)
FID name
1 Afghanistan
7 United Arab Emirates
9 Armenia
17 Azerbaijan
4 of 53 features
哎。现在你丢失了一大批 FID。这是因为这些特征不在亚洲,所以在遍历图层时被忽略了。然而,通过特定的 FID 获取特征并不尊重过滤器,因为特征并没有真正被删除,因此 FID 值没有改变。你可以通过使用 FID 获取一个或两个特征来证明这一点:
>>> lyr.GetFeature(2).GetField('name')
'Angola'
您可以从这一点看出,即使安哥拉在遍历过滤图层时没有显示出来,它仍然存在。现在您应该很明显,使用特定的 FID 遍历过滤图层是一个坏主意,您不会得到期望的结果。相反,您需要使用for循环遍历图层。
如果您设置另一个属性过滤器,它不会创建当前过滤特性的子集。相反,新过滤器应用于整个图层。为了说明这一点,尝试应用一个新的过滤器,该过滤器选择南美洲的国家,并将它们用蓝色绘制出来,这会导致您在图 5.3 中看到的着色效果。
图 5.3. 已将选择南美洲国家的属性过滤器应用于国家图层,并将结果绘制在图 5.2 中的先前数据之上。

>>> lyr.SetAttributeFilter('continent = "South America"')
>>> vp.plot(lyr, 'b')
然而,您可以将属性和空间过滤器一起使用来细化您的结果,您将在下一节中看到一个例子。要清除属性过滤器并恢复所有 241 个特性,只需将None传递给SetAttributeFilter:
>>> lyr.SetAttributeFilter(None)
>>> lyr.GetFeatureCount()
241
移除过滤器也会将当前特性重置到开始位置,就像您刚刚打开图层一样。
5.2. 空间过滤器
空间过滤器允许您通过空间范围而不是属性值来限制特征。这些过滤器可以用来选择另一个几何体内部的特性或边界框内的特性。例如,如果您有一个全球城市的数据集,没有属性表明这些城市所在的国界,但您还有一个具有相同空间参考系统的数据集,其中包含德国的国界,您可以使用空间过滤器来选择德国的城市。
空间参考系统和空间过滤器
用于空间过滤的几何体或坐标必须使用与您试图过滤的图层相同的空间参考系统。为什么是这样?假设您有一个使用通用横轴墨卡托(UTM)空间参考系统的图层。该图层中的坐标将是大数字,与我们所有人都熟悉的纬度和经度值大不相同。这意味着如果它们叠加在一起,它们就不会对齐,并且它们看起来似乎没有重叠的空间范围。例如,盐湖城首府的 UTM 东西坐标大约为 425045 和 4514422,但相应的经度和纬度是-111.888 和 40.777。这些坐标彼此之间非常不同,除非其中一个被转换到与另一个相同的空间参考系统,否则它们不会重叠。
尝试使用自然地球形状文件选择德国的城市。在交互式窗口中设置矢量绘图器后,打开数据源文件夹并获取国家图层。然后使用属性过滤器将国家限制为德国,并获取相应的要素和几何形状:
>>> ds = ogr.Open(r'D:\osgeopy-data\global')
>>> country_lyr = ds.GetLayer('ne_50m_admin_0_countries')
>>> vp.plot(country_lyr, fill=False)
>>> country_lyr.SetAttributeFilter('name = "Germany"')
>>> feat = country_lyr.GetNextFeature()
>>> germany = feat.geometry().Clone()
在这种情况下,可以假设属性过滤器将返回一个且仅有一个要素,因此使用 GetNextFeature 将获取过滤结果中的第一个且唯一的要素。然后获取几何形状并克隆它,这样即使要素从内存中移除后,也可以使用该几何形状。哦,并且你还在应用过滤器之前绘制了世界国家,这样在稍后就可以为城市提供上下文。现在打开人口地点图层,并将所有城市(见图 5.4)绘制为黄色点:
>>> city_lyr = ds.GetLayer('ne_50m_populated_places')
>>> city_lyr.GetFeatureCount()
1249
>>> vp.plot(city_lyr, 'y.')
图 5.4. 自然地球数据集中人口地点图层中的所有城市

GetFeatureCount 调用表明在完整图层中有 1,249 个城市要素。现在尝试通过传递之前获取的 germany 几何形状到 SetSpatialFilter,然后以大点形式绘制结果城市:
>>> city_lyr.SetSpatialFilter(germany)
>>> city_lyr.GetFeatureCount()
5
>>> vp.plot(city_lyr, 'bo')
现在图层声称只有五个要素,因此五个城市位于德国边界多边形内。你还可以从你的绘图中看到,圆圈位于正确的地理区域内。如果你想要放大查看德国,可以使用绘图窗口底部的“缩放至矩形”工具(图 5.5)。
图 5.5. 已将空间过滤器应用于人口地点图层,以限制要素仅限于德国边界内。这些过滤点以大点形式显示。

克隆与否?
几何对象有一个 Clone 函数,它创建对象的副本。你为什么想使用它?当你从一个要素获取几何形状时,该几何形状仍然与该要素相关联。如果该要素随后被删除(或变量被填充了不同的要素),那么该几何形状就不再可用。实际上,如果你尝试使用它,Python 将崩溃而不是输出错误。然而,通过克隆几何形状可以轻松解决这个问题。现在你可以存储不再与其它对象关联的要素或几何形状的副本,即使父对象消失,这些副本仍然存在。想看看这个动作吗?尝试在交互式窗口中这样做:

在此示例中,geom 变量持有由存储在 feat 变量中的 Feature 对象拥有的 Geometry 对象,但 geom_clone 变量持有与该要素已断开关联的几何形状。在你用不同的要素填充 feat 变量后,你仍然可以使用 geom_clone 几何形状,但不能使用存储在 geom 变量中的对象,因为你不再有来自该要素的句柄。
Incidentally, this is related to why all of these examples would also cause Python to crash:
feat = ogr.Open(fn, 0).GetLayer(0).GetNextFeature()
# or
lyr = ogr.Open(fn, 0).GetLayer(0)
feat = lyr.GetNextFeature()
# or
ds = ogr.Open(fn, 0)
lyr = ds.GetLayer(0)
del ds
feat = lyr.GetNextFeature()
在每种情况下,数据源在你尝试使用图层之前就已经超出范围或被删除了。但是,图层与数据源相关联,一旦数据源消失,图层就变得不可用,就像如果其父特征消失,几何体变得不可用一样。如果你仍然需要访问图层,永远不要关闭你的数据源。
如承诺的那样,你现在可以结合空间和属性查询。通过找到人口超过 100 万的城市来进一步细化你的选择,并将它们绘制成图 5.6 中显示的方块:
>>> city_lyr.SetAttributeFilter('pop_min > 1000000')
>>> city_lyr.GetFeatureCount()
3
>>> vp.plot(city_lyr, 'rs')
图 5.6. 属性过滤器与空间过滤器相结合,以选择人口超过 100 万人的德国城市。选定的特征以方块而不是圆圈的形式显示。

从这些结果来看,有三个德国城市的人口超过 100 万。图 5.6 显示了放大后的德国输出图,你可以看到这些特征。但如果你决定想知道世界上有多少个城市的人口达到如此规模?你只需要通过将None传递给SetSpatialFilter来移除空间过滤器。请注意,属性过滤器仍然有效。现在就试试吧,用三角形绘制结果:
>>> city_lyr.SetSpatialFilter(None)
>>> city_lyr.GetFeatureCount()
246
>>> vp.plot(city_lyr, 'm^', markersize=8)
现在你已经知道了世界上最大城市的位置(图 5.7)。
图 5.7. 空间过滤器已被移除,但属性过滤器仍然有效,因此现在所有人口超过 100 万的世界城市都作为三角形绘制在所有城市原始点之上。

如果你想要在空间上过滤特征但没有可用的几何体,你还可以通过提供最小和最大的 x 和 y 坐标来使用矩形范围:
SetSpatialFilterRect(minx, miny, maxx, maxy)
你可以使用这个信息来选择图 5.8 中显示的框内的国家。再次,先绘制所有国家:
>>> vp.clear()
>>> country_lyr.SetAttributeFilter(None)
>>> vp.plot(country_lyr, fill=False)
图 5.8. 澳大利亚周围矩形的最大和最小 x 和 y 值可以用来设置全球国家图层上的空间范围。

现在输入图 5.8 中显示的边界坐标:
>>> country_lyr.SetSpatialFilterRect(110, -50, 160, 10)
>>> vp.plot(country_lyr, 'y')
现在你应该有一个看起来类似于图 5.9 的图表,澳大利亚及其周边的一些国家被阴影覆盖。
图 5.9. 使用图 5.8 中显示的矩形范围选定了阴影覆盖的国家。

小贴士
要清除空间过滤器,无论是用几何体还是边界框创建的,都通过将None传递给SetSpatialFilter。你不能使用SetSpatialFilterRect来清除过滤器。
5.3. 使用 SQL 创建临时图层
如果您熟悉 SQL 或愿意学习,您可以使用数据源上的 ExecuteSQL 函数创建更复杂的查询并做一些有趣的事情。此函数适用于数据源而不是层,因为它允许您在需要时使用多个层。它需要一个 SQL 查询,并可选择使用一个几何体作为空间过滤器。此外,您还可以指定不同的 SQL 方言,但关于这一点稍后讨论。以下是签名:
ExecuteSQL(statement, [spatialFilter], [dialect])
-
statement是要使用的 SQL 语句。 -
spatialFilter是一个可选的几何对象,用作结果上的空间过滤器。默认情况下没有过滤器。 -
dialect是一个字符串,用于指定要使用的 SQL 方言。可用选项是OGRSQL和SQLite。默认情况下,使用 OGR 方言,除非数据源有自己的 SQL 引擎(例如 SpatiaLite 数据库)。
这个函数与过滤函数不同,因为它返回一个包含结果集的新层,而不是仅从现有层中过滤出特征。让我们通过几个示例来了解这个技术,从一个简单的示例开始,该示例返回按人口降序排列的全球国家:
>>> ds = ogr.Open(r'D:\osgeopy-data\global')
>>> sql = '''SELECT ogr_geom_area as area, name, pop_est
... FROM 'ne_50m_admin_0_countries' ORDER BY POP_EST DESC'''
>>> lyr = ds.ExecuteSQL(sql)
>>> pb.print_attributes(lyr, 3)
FID Geometry area name pop_est
41 MULTIPOLYGON 950.9810937547769 China 1338612970.0
98 MULTIPOLYGON 278.3474038553223 India 1166079220.0
226 MULTIPOLYGON 1115.1781907153158 United States 313973000.0
3 of 241 features
如您从这些结果中看到的,世界上人口最多的三个国家按顺序是中国、印度和美国。查询返回每个国家的名称和人口属性,因为您在 SQL 语句中请求了它们。您还使用了特殊的 ogr_geom_area 字段来获取每个几何体的面积(表 5.2),FID 和几何体本身会自动返回。此示例使用默认的 OGR SQL 方言,因为 shapefiles 没有任何内置的 SQL 支持。
表 5.2. OGR SQL 方言中使用的特殊字段
| 字段 | 返回值 |
|---|---|
| FID | 特征 ID。 |
| OGR_GEOMETRY | OGR 几何类型常量(见 表 3.1)。这对于在一个层中支持多种几何类型的数据格式特别有用。 |
| OGR_GEOM_WKT | 特征几何的已知文本(WKT)表示形式。 |
| OGR_GEOM_AREA | 特征几何的面积。对于没有面积(例如,点或线)的几何体返回零。 |
| OGR_STYLE | 如果存在,则返回特征的样式字符串。很少应用程序使用此功能。 |
如果您正在查询具有自己的 SQL 支持的数据源,则将使用该原生 SQL 版本。例如,如果您有 SQLite 驱动程序,您可以使用 SQLite 版本的 SQL 从 natural_earth_50m.sqlite 数据库中获取相同的信息。此方言还允许您限制返回的特征数量,因此您可以限制结果集为人口最多的三个国家:
>>> ds = ogr.Open(r'D:\osgeopy-data\global\natural_earth_50m.sqlite')
>>> sql = '''SELECT geometry, area(geometry) AS area, name, pop_est
... FROM countries ORDER BY pop_est DESC LIMIT 3'''
>>> lyr = ds.ExecuteSQL(sql)
>>> pb.print_attributes(lyr)
FID Geometry area name pop_est
0 MULTIPOLYGON 950.9810937547769 China 1338612970.0
1 MULTIPOLYGON 278.3474038553223 India 1166079220.0
2 MULTIPOLYGON 1115.1781907153158 United States 313973000.0
3 of 3 features
这次你可以为整个图层打印属性,因为只返回了三个特征。你也应该注意,现在你使用的是area函数而不是特殊的字段名,如果你不使用AS area语法重命名它,那么它将被调用为area(geometry)。你还得特别请求几何形状,因为 SpatiaLite 引擎默认不返回几何形状。
你也可以使用ExecuteSQL将多个图层的属性连接起来。看看这段代码,看看你是否能弄清楚它在做什么:

首先,要注意的是你使用了 ne_50m_populated_places 和 ne_50m_admin_0_countries 形状文件,并将它们分别重命名为pp和c。你是通过在图层名称后直接放置别名来做到这一点的。当然,这不是必需的,但这确实使你的 SQL 语句变得更短,因为那些图层名称相当长。你还通过使用连接将这两个图层链接在一起,这允许你使用共享属性来链接表。在这里,你使用 LEFT JOIN 来保留左表(人口地点)中的所有记录,并且如果右表(国家)中存在匹配的记录,那么你也会从该记录中获取数据。但是它是如何确定匹配的呢?这就是ON子句发挥作用的地方。对于pp中的每个要素,它都会取adm0_a3属性值,并尝试在具有相同adm0_a3字段值的 countries 图层中找到一个要素。参见图 5.10 以了解插图。
图 5.10。一个 SQL 查询的插图,该查询从人口地点表中选择 adm0cap 等于 1 的记录,然后根据两个表中的 adm0_a3 字段获取相关数据。

现在你已经知道了数据来自哪些表,回到 SQL 语句的开始部分,看看正在请求哪些属性字段。你从人口地点图层请求了NAME和POP_MIN字段,以及从国家图层请求了NAME和POP_EST字段。因为两个图层中的字段具有相同的名称,所以重命名它们是有意义的,这样你就可以知道是什么。最后,你使用WHERE子句将结果限制在代表首都的要素(adm0cap = 1)上。
如果你想要同时查看多个图层的相关数据,这个技术就很有用。如果没有这个,你可能需要分别查询城市人口和国家人口,但现在你可以在城市旁边看到国家的人口。要查看这一点,请查看此查询返回的图层:
pb.print_attributes(lyr, 3, geom=False)
FID city city_pop country country_pop
7 Vatican City 832 Vatican 832.0
48 San Marino 29000 San Marino 30324.0
51 Vaduz 5342 Liechtenstein 34761.0
3 of 200 features
我没有打印几何列,因为它不会舒适地适应页面,但因为这个使用了 OGR SQL 方言,几何体会被自动返回。但哪个:城市还是国家?是城市,因为这是连接中使用的主要表,并且只有当城市存在相应的国家信息时才会返回国家信息。如果你愿意,你可以绘制图层来证明这一点。
现在查看一个使用 SQLite 方言的类似示例,但仍然是 shapefile 数据源(当然,你也可以使用 SQLite 数据库,但我想要证明 SQLite 方言可以与其他数据源类型一起工作)。看看你是否能发现差异:
ds = ogr.Open(r'D:\osgeopy-data\global')
sql = '''SELECT pp.name AS city, pp.pop_min AS city_pop,
c.name AS country, c.pop_est AS country_pop
FROM ne_50m_populated_places pp
LEFT JOIN ne_50m_admin_0_countries c
ON pp.adm0_a3 = c.adm0_a3
WHERE pp.adm0cap = 1 AND c.continent = "South America"'''
lyr = ds.ExecuteSQL(sql, dialect='SQLite')
pb.print_attributes(lyr, 3)
最明显的区别是ExecuteSQL函数中包含了dialect参数。但你还在 SQL 中添加了一项与 OGR 方言不兼容的内容。这次结果被限制在南美洲的城市,通过检查国家层中的大陆字段值。OGR 方言不支持在WHERE子句中使用连接表中的字段,因此允许的属性只能是来自人口地点层的属性。另外,因为在使用 SQLite 方言时,如果你想返回几何体,需要特别请求,所以这个特定的查询没有返回任何几何体。你可以通过指定pp.geometry以及其他字段来添加它们。
如果你使用的 OGR 版本是带有 SpatiaLite 支持(不仅仅是 SQLite)构建的,你还可以在 SQL 中操作几何体。警告:这可能会花费一些时间,具体取决于你尝试做什么。作为一个例子,如果你有 SpatiaLite 支持,尝试将加利福尼亚州的所有县合并成一个大的几何体。从绘制单个县开始,这样你就可以比较你的结果了:
>>> ds = ogr.Open(r'D:\osgeopy-data\US')
>>> sql = 'SELECT * FROM countyp010 WHERE state = "CA"'
>>> lyr = ds.ExecuteSQL(sql)
>>> vp.plot(lyr, fill=False)
这将绘制出加利福尼亚州的县地图,如图图 5.11A 所示。现在尝试使用 SpatiaLite 的st_union函数将所有县的多边形合并成一个,如图图 5.11B 所示:
>>> sql = 'SELECT st_union(geometry) FROM countyp010 WHERE state = "CA"'
>>> lyr = ds.ExecuteSQL(sql, dialect='SQLite')
>>> vp.plot(lyr, 'w')
图 5.11。部分 A(左侧)显示了加利福尼亚州各个县的单独绘制。另一方面,部分 B 显示了在县上运行 SpatiaLite st_union函数的结果。它们都被合并成了一个几何体。

几何操作也适用于具有自己原生 SQL 风格和执行几何操作能力的数据源。SpatiaLite 和 PostGIS 是这一点的两个明显例子。例如,这是使用 PostGIS 数据源完成相同操作的方法:
conn_str = 'PG:host=localhost user=chrisg password=mypass dbname=geodata'
ds = ogr.Open(conn_str)
sql = "SELECT st_union(geom) FROM us.counties WHERE state = 'CA'"
lyr = ds.ExecuteSQL(sql)
vp.plot(lyr)
如果你想要执行此类操作但不是使用 PostGIS 或 SpatiaLite,不要担心,因为下一章你将学习如何在没有数据库的情况下完成它。
5.4. 利用过滤器
记得在第三章中,你将全球形状文件中的所有首都复制到一个新的形状文件中吗?你遍历形状文件中的每个要素,检查相应的属性,如果它是首都就复制该要素。如果想要选择的要素可以通过过滤器进行选择,整个过程可以变得容易得多。你还记得在第 4.2.4 节中引入的 CopyLayer 方法吗?作为提醒,它会将现有层复制到新的数据源中。你认为如何使用这个方法来执行类似于列表 3.3 中的代码,但更简单?思考一下这个问题,然后看看下一个例子:
ds = ogr.Open(r'D:\osgeopy-data\global', 1)
in_lyr = ds.GetLayer('ne_50m_populated_places')
in_lyr.SetAttributeFilter("FEATURECLA = 'Admin-0 capital'")
out_lyr = ds.CopyLayer(in_lyr, 'capital_cities2')
在这里,CopyLayer 的调用在 ds 数据源中复制了 in_lyr。在这种情况下,它恰好与原始层相同的数据源,但它可以是任何数据源。因为你已经在 in_lyr 上设置了属性过滤器,所以只有过滤后的要素被复制。这当然比逐个检查要简单得多。
如果你只想获取某些属性,可以使用通过 ExecuteSQL 创建的层。编写一个 SQL 查询以提取你想要的属性,并将结果复制到一个新的层中:
sql = """SELECT NAME, ADM0NAME FROM ne_50m_populated_places
WHERE FEATURECLA = 'Admin-0 capital'"""
in_lyr2 = ds.ExecuteSQL(sql)
out_lyr2 = ds.CopyLayer(in_lyr2, 'capital_cities3')
到现在为止,你应该很明显,你可以通过尽可能利用过滤器和 ExecuteSQL 函数来简化你的生活。
5.5. 概述
-
属性过滤器可以用来根据属性值高效地选择特定的要素。
-
空间过滤器允许你通过使用边界多边形或边界框的坐标来根据要素的位置选择要素。设置空间过滤器时必须使用与要过滤的数据相同的空间参考系统。
-
空间和属性过滤器可以组合使用。
-
你可以使用 SQL 查询创建由多个层通过属性值连接而成的临时层。
-
一旦对象的所有者超出作用域,就不能再使用对象,所以如果你在失去要素句柄后还想使用几何体,确保你克隆了该几何体。如果你想访问层,始终保持数据源打开。如果你违反了这些规则之一,Python 将崩溃并燃烧。
第六章 使用 OGR 操作几何体
本章涵盖
-
从零开始创建点、线和多边形
-
编辑现有几何体
到目前为止,我们讨论了如何使用 OGR 读取和写入矢量数据集以及如何编辑属性值,但你还没有以任何方式操作几何体。如果你想创建自己的数据而不是使用别人的,你需要知道如何与实际的几何体一起工作。例如,如果你有一系列来自徒步或骑自行车旅行的 GPS 坐标时间序列,你可以创建一条代表你走过的路线的线。你甚至可以将 GPS 位置的时间戳与你在照片上的时间戳进行比较,以创建一个点数据集,显示你停下来拍照的位置。
你甚至可能需要了解如何操作几何体以更好地显示现有数据。例如,假设你想使用你的照片点创建地图并将它们链接到实际的照片。某些位置可能在同一地点有多个照片。你可以用多种方式处理这种情况,但一种方法是将每个点稍微向不同方向偏移,使其看起来像是一组点而不是一个点。但要做到这一点,你需要知道如何操作点几何体本身。
你还可以操作和组合几何体以创建新的几何体。例如,如果你想从一个河流数据集中创建一个简单的河岸区域地图,并且你假设河岸区域在河流两侧延伸一米,你可以创建一个围绕每条河流的多边形,多边形的边缘在水的每侧向外延伸一米。如果两条河流汇合,那么这些多边形将在它们的汇合处重叠,你可以使用并集操作将重叠的多边形合并成一个。你将在接下来的两个章节中学习如何做所有这些,以及更多。
然而,在你能够做任何这些之前,你需要熟悉不同类型的几何体。
6.1. 几何体简介
你可以与几种几何形状一起工作,点是最简单的。所有其他类型都是由通过直线段连接的点组成的,而这些点存储坐标值,因此可以将点视为几何形状的基本构建块。用于构建其他几何形状的点称为 顶点,如果需要,每个几何形状可以有数千个顶点。例如,线几何形状是由通过直线段连接的有序点集合,每个需要改变方向的线段位置都有一个顶点。代表短死胡同街道的线可能不需要很多顶点,但代表亚马逊河的详细线则需要数千个。多边形与线 somewhat 类似,但它们是封闭的,这意味着第一个和最后一个顶点是相同的,并且它们围成一个特定的区域。你将从创建和编辑点开始,随着学习的深入,逐步过渡到更复杂的几何形状。
定义
一个顶点是一个几何形状中两条线段相交的点。顶点保存每个线段末端的坐标。
尽管许多几何形状仅存在于二维(2D)笛卡尔坐标系平面中,具有 x 和 y 坐标,但也可以有具有 z 值的三维(3D)几何对象。这些 z 值通常用于表示高程,但也可以用于其他数据,例如最大年温度。技术上,这些几何形状在 OGR 中被认为是 2.5D 而不是 3D,因为 OGR 在执行空间操作时不考虑 z 值。需要注意的一点是,尽管你可以将 z 值添加到 2D 几何形状中,但在将数据写入文件时,它们将被忽略。
注意
只有 x 和 y 坐标的几何形状被认为是二维的。在 OGR 中,具有附加 z 坐标的几何形状被认为是 2.5D 而不是 3D,因为当执行空间操作时,z 值不被考虑。
在开始时,与简单几何形状一起工作可能最容易,因此你将学习通过重新创建图 6.1 中显示的虚构庭院来创建不同的几何类型。当你查看这个图时,如果你发挥想象力,希望你能想象出一个中间有房子的庭院,东边有矩形花园床,北边有人行道(实线),石子小径(虚线),篝火(星号),以及户外水龙头(圆形)。虽然你将在二维空间中创建这个场景,但同样的概念也适用于 2.5D 几何形状。
图 6.1. 你将在本章中创建其几何形状的虚构庭院。坐标以米为单位。

虽然图 6.1 中显示的形状很简单,但概念与处理复杂几何形状时完全相同。你可以将在这里学到的材料应用到现实世界的场景中。
6.2. 处理点
点由一个东西向的 x 坐标、一个南北向的 y 坐标,有时还有一个用于高度的垂直 z 坐标组成。你可能熟悉 x 坐标被称为经度,y 坐标被称为纬度。当使用地理坐标系统时,这些术语是合适的,其中纬度范围从-90 到 90,经度在-180 和 180 之间。如果坐标已经被投影到笛卡尔坐标系中,如 UTM,那么常见的术语是 x 坐标的东西方向和 y 坐标的南北方向。
点用于表示只有一组坐标的项目。点没有长度、宽度、面积或其他测量值。尽管如此,地图上由点表示的特征会根据比例尺而变化,这些特征在现实生活中可能有面积。例如,法国地图很可能会将巴黎表示为一个单点,而Île-de-France 地区的地图会显示更多细节,巴黎城市边界以多边形表示,而像埃菲尔铁塔这样的主要地标则以点表示。随着比例尺的变化,由点表示的特征面积也会变化,类似于埃菲尔铁塔覆盖的面积远小于巴黎。
6.2.1. 创建和编辑单点
观察 yard 图,你可以看到篝火坑是表示为单点的理想候选,让我们来构建它。图 6.2 显示了该区域的特写,以便你可以看到坐标。
图 6.2. 您可以使用一个单点来表示篝火坑的几何形状,这里显示为一个星形。

除非你有你想要构建的几何形状的文本表示,否则使用 OGR 构建任何类型几何的第一步是使用表 6.1 中的某个常量创建一个空的Geometry对象。请在一个交互式窗口中这样做,以便你可以立即得到结果:
>>> firepit = ogr.Geometry(ogr.wkbPoint)
表 6.1. 表示几何类型的 OGR 常量
| 几何类型 | 2D 常量 | 2.5D 常量 |
|---|---|---|
| Point | wkbPoint | wkbPoint25D |
| Multipoint | wkbMultiPoint | wkbMultiPoint25D |
| Line | wkbLineString | wkbLineString25D |
| Multiline | wkbMultiLineString | wkbMultiLineString25D |
| Polygon ring | wkbLinearRing | n/a |
| Polygon | wkbPolygon | wkbPolygon25D |
| Multipolygon | wkbMultiPolygon | wkbMultiPolygon25D |
| Geometry collection | wkbGeometryCollection | wkbGeometryCollection25D |
一旦你有了几何形状,你就可以开始添加顶点。点只有一个顶点,你可以使用AddPoint函数添加。这个函数需要 x、y 和一个可选的 z。
>>> firepit.AddPoint(59.5, 11.5)
就这样!你现在有一个完全功能化的点对象,其南北向为 11.5,东西向为 59.5。如果需要,可以使用GetX、GetY和GetZ检索坐标:
x, y = firepit.GetX(), firepit.GetY()
记住,在 Python 中你可以一次设置多个变量,所以 x 被分配了 GetX 的结果,而 y 获得了 GetY 的结果。
如果你想要验证一切看起来是否正常,可以将几何对象打印为 WKT 格式,尽管对于除点以外的几何类型,这可能会很快变得很丑陋。
>>> print(firepit)
POINT (59.5 11.5 0)
注意,WKT 显示 z 值为 0,但你创建了一个 2D 点。这不会造成任何伤害,所以没有必要担心。你甚至可以自己设置 z 值,尽管在将几何体写入文件时它会被忽略。
除非你想看到坐标值,否则在创建几何体时可视化你的几何体的一种更简单的方法是使用我们在第三章[中介绍的 VectorPlotter 类],尽管对于单个点来说这很无聊:
>>> vp.plot(firepit, 'bo')
如果你后来意识到你的 GPS 略有偏差,y 坐标是 13 而不是 11.5 会怎么办?解决这个问题的最简单方法是再次调用 AddPoint,但使用正确的坐标。你可以通过使用不同的标记绘制新的几何体(图 6.3)或通过打印 WKT 来验证结果:
>>> firepit.AddPoint(59.5, 13)
>>> vp.plot(firepit, 'rs')
>>> print(firepit)
POINT (59.5 13.0 0)
图 6.3. 原始和编辑后的火坑几何体。编辑后的有一个正方形标记。

为什么这个操作没有像 AddPoint 名称暗示的那样向几何体添加第二组坐标?点是一个特殊情况,因为它们只允许一组坐标,所以任何现有的坐标都会被覆盖。你将在后面看到 AddPoint 在应用于其他几何类型时具有不同的行为。
如果你喜欢让生活变得稍微复杂一些,或者想要与其他几何类型编辑顶点时保持一致性,你可以使用 SetPoint(point, x, y, [z]) 代替,其中 point 是要编辑的顶点的索引。因为点几何体只包含一个顶点,所以在处理点时此参数始终为零:
firepit.SetPoint(0, 59.5, 13)
要创建一个 2.5D 点,在创建时指定 2.5D 类型,然后提供与 x 和 y 一起的 z 坐标:
firepit = ogr.Geometry(ogr.wkbPoint25D)
firepit.AddPoint(59.5, 11.5, 2)
除了添加一个第三维坐标值之外,使用 2.5D 点与使用 2D 点的工作方式相同。
6.2.2. 创建和编辑多点:将多个点作为一个几何体
多点几何体包含一个或多个点在一个单独的对象中。这意味着多个点可以附加到单个要素上,而不是每个点都需要一个单独的要素。例如,包含多个点的数据集可能是城市边界内所有消防栓的位置,其中每个消防栓被视为一个独特的要素。也许你还想在私人院子里绘制户外水龙头。在这种情况下,你可能会将单个院子里所有的水龙头视为一个多点项,这样你就有了一个院子一个要素。实际上,你将使用院子示例。图 6.4[中]显示了三个水龙头,你将使用三个顶点构建一个多点对象来表示它们。
图 6.4。你可以使用多点几何图形来保存水龙头几何图形,这里显示为点。

要创建一个多点几何图形,你需要创建至少两个几何图形。你需要至少一个点对象,以及一个多点对象来保存这些点。参考表 6.1,你可以看到多点对象的正确 OGR 常量是wkbMultiPoint。你将点创建得与之前完全一样,然后将它们添加到你的多点几何图形中。这里有一种方法,使用从图 6.4 获得的坐标来完成此操作:

注意,每次重用相同的点几何图形是可以的。当调用AddGeometry时,会向多点添加点对象的副本,因此可以在不影响已经添加到多点中的坐标的情况下稍后编辑原始点。当然,你可以为每个顶点创建一个新的点对象,但重用几何图形可以节省一点开销。
再次,你可以绘制几何图形并打印 WKT 来查看其外观:

对于多点对象,WKT 字符串使用逗号分隔集合中的每个坐标。与常规点对象一样,单个点的 x、y 和 z 坐标由空格分隔。注意,顶点的列表顺序与添加它们的顺序相同。如果你需要稍后访问它们,这一点非常重要——你可以始终确信你得到的是哪个点,因为它们的顺序不会改变。
你可以通过传递所需点的索引到GetGeometryRef来从多点几何图形中获取一个特定点。第一个添加的点索引为 0,第二个为索引 1,依此类推。一旦你有一个单独的点,你可以像编辑单个点一样编辑它。因为GetGeometryRef返回的是多点内部点的引用而不是副本,所以当点被更改时,多点会自动更新。例如,这将获取第二个水龙头并编辑其坐标:
faucets.GetGeometryRef(1).AddPoint(75, 32)
你还可以找出一个多点对象中有多少个点,这在你需要遍历所有点时很有用。例如,要将所有水龙头向东移动两米,你需要遍历这些点,并将每个 x 坐标加 2,同时保持 y 坐标不变。结果在图 6.5 中显示。
图 6.5。原始和编辑后的水龙头多点几何图形。编辑后的几何图形使用正方形标记。

>>> for i in range(faucets.GetGeometryCount()):
... pt = faucets.GetGeometryRef(i)
... pt.AddPoint(pt.GetX() + 2, pt.GetY())
...
>>> vp.plot(faucets, 'rs')
>>> vp.zoom(-5)
正如你在本章的其余部分将看到的,直接使用其他几何类型是在你已经学过的点概念的基础上构建的。
6.3。处理线
如前所述,线是一系列顶点,或点,通过直线段连接。图 6.6 显示了一条线和其顶点,尽管通常在绘制线时看不到顶点的标记。线不能改变方向,无论多小,除非有一个顶点来结束一个段并开始另一个。因此,看起来像平滑曲线的线实际上是由许多短直线段组成,所有这些段都通过顶点连接在一起。
图 6.6. 一条线和连接每个线段的顶点

添加更多的顶点,因此更多的更短的线段,可以让你更好地控制线的形状。想想你是如何用一系列直线来绘制大不列颠的海岸线的。正如你从图 6.7 中可以看到的,使用更短的线可以大大提高精度。这个概念适用于任何线几何。需要更多的细节,就需要添加更多的顶点。记住,你拥有的顶点越多,几何对象就越复杂,处理所需的时间就越长,所以不要添加不必要的顶点。实际上,如果你打算在网络上提供数据,你可能想要简化几何形状,以便它们使用更少的顶点。如果你需要这样做,请参阅附录 C 中的Simplify函数。(附录 C 至 E 可在 Manning Publications 网站上在线获取,网址为www.manning.com/books/geoprocessing-with-python。)
图 6.7. 实线更紧密地遵循大不列颠的海岸线,因为它比虚线有更多的顶点,因此有更多的更短的线段。通过使用更多的顶点可以获得线和多边形的更多细节。

线可以用来表示线性特征,如道路、溪流或管道。如果你想显示一个岛屿的海岸线,例如图 6.7 中的示例,那么线是一个不错的选择,但如果你想表示整个岛屿,多边形是一个更好的选择。你将使用一个简单的线对象来模拟围绕虚构庭院和形状奇特的停车带的边界(图 6.8)。
图 6.8. 你可以使用线来保持人行道几何形状,这里显示为粗绿色的线。

你将要为人行道构建的线包含少量顶点,但处理更长和更复杂的线的技巧是完全相同的。
6.3.1. 创建和编辑单一线
与点一样,创建线几何的第一步是创建一个空的Geometry对象,然后添加顶点。虽然添加坐标时你遍历线的方向并不重要,但顶点必须按顺序添加。尝试创建图 6.8 中显示的人行道线,从西向东:

记得 AddPoint 是如何覆盖点对象中现有坐标的吗?这里不会发生这种情况,因为线由许多顶点组成,而不是只有一个,所以会在线的末尾添加一个新的顶点而不是覆盖唯一允许的点。
再次,你可以通过绘制几何体或打印 WKT 来验证一切是否按预期工作:
>>> vp.plot(sidewalk, 'b-')
>>> print(sidewalk)
LINESTRING (54 37 0,62.0 35.5 0,70.5 38.0 0,74.5 41.5 0)
如前所述,顶点的坐标由空格分隔,单个顶点由逗号分隔。因为你知道顶点总是按照它们被添加到线上的顺序排列,所以你可以使用 SetPoint 来更改人行道中最后一个顶点(索引为 3 的顶点)的 x 坐标:
sidewalk.SetPoint(3, 76, 41.5)
你可以找出一条线包含多少个顶点,并在必要时遍历所有顶点。例如,如果你突然意识到人行道向南多了一米,你可以通过遍历所有顶点并将每个 y 坐标加一来将其向北微调(图 6.9):
>>> for i in range(sidewalk.GetPointCount()):
... sidewalk.SetPoint(i, sidewalk.GetX(i), sidewalk.GetY(i) + 1)
...
>>> vp.plot(sidewalk, 'r--')
图 6.9. 原始和编辑过的人行道线几何体。编辑过的是用虚线表示的。

注意,你再次使用了 GetX 和 GetY,但现在你需要提供你想要的顶点的索引。你也可能想知道为什么你使用 GetPointCount 而不是像处理多点时那样使用 GetGeometryCount,这是一个很好的问题。原因是 GetGeometryCount 告诉你有多少个单独的几何体对象组合成一个多几何体,如果对象不是多几何体,则返回零。另一方面,GetPointCount 函数返回几何体中的顶点数量,对于多几何体返回零,因为它们由其他几何体而不是顶点组成。
小贴士
使用 GetGeometryCount 来确定一个几何体集合(例如多几何体或多边形)中包含的几何体数量,并使用 GetPointCount 来确定单个几何体中的顶点数量。GetGeometryCount 函数对于单个几何体始终返回零,而 GetPointCount 对于几何体集合始终返回零。
如果你后来意识到人行道的形状完全错误,因为它缺少一个顶点怎么办?如果你仍然有每个顶点的坐标,那么最简单的方法可能是使用所有顶点创建一个新的人行道几何形状。但是,如果你的景观设计师只给了你缺失顶点的坐标,并告诉你它需要插入到第二个和第三个顶点之间,如图图 6.10 所示,你需要在你的线中插入它。你可以通过多种方式解决这个问题,但我认为最简单的方法是获取所有顶点的列表,将一组新的坐标插入到列表中,然后使用该列表创建一个新的几何形状。你可以使用GetPoints获取线中的顶点列表,其中每个顶点都是以包含 x、y 和 z 坐标的元组形式存在。以下是原始人行道的该列表的示例:
>>> print(sidewalk.GetPoints())
[(54.0, 37.0, 0.0), (62.0, 35.5, 0.0), (70.5, 38.0, 0.0),
(74.5, 41.5, 0.0)]
图 6.10. 虚线显示了通过在现有的第二个和第三个顶点之间插入新的顶点来对人行道进行的修改。

列表的一个方便的特性是,你可以使用list[i:i]语法轻松地在第i个位置插入项目。以下示例获取人行道顶点的列表,然后在第二个和第三个顶点之间插入包含新 x 和 y 坐标的元组:
>>> vertices = sidewalk.GetPoints()
>>> vertices[2:2] = [(66.5, 35)]
>>> print(vertices)
[(54.0, 37.0, 0.0), (62.0, 35.5, 0.0), (66.5, 35),
(70.5, 38.0, 0.0), (74.5, 41.5, 0.0)]
你可以看到原始顶点仍然存在,但新的顶点已经插入到索引 2 的位置。因为这个顶点没有 z 坐标,因为在插入的元组中没有提供。原始坐标确实有 z 值并不重要,因为这是一个二维几何形状,它们都应该为零。
现在你有一个元组列表,但如何将其转换为线几何形状,就像图 6.11 中那样?最简单的方法是利用 Python 的*运算符将元组展开成单独的参数,并依次传递给AddPoint:
>>> new_sidewalk = ogr.Geometry(ogr.wkbLineString)
>>> for vertex in vertices:
... new_sidewalk.AddPoint(*vertex)
...
>>> vp.plot(new_sidewalk, 'g:')
图 6.11. 原始的人行道线几何形状以及中间插入另一个顶点的情况

Python 的*运算符
*运算符将元组或列表的内容解包成单独的项,以便可以将它们作为参数传递给函数。以下是一个例子:

使用*运算符将vertex解包成两个参数,这些参数成功传递给了AddPoint。忘记使用*运算符只会传递一个参数,即一个元组。但是AddPoint至少需要两个参数,一个 x 和一个 y,所以它失败了。
如果需要,这很容易扩展到更多的编辑。例如,如果你想在现有的第 5 个、第 11 个、第 19 个和第 26 个顶点之后添加新的点,如图图 6.12 中的虚线所示?当你查看以下代码时,看看你是否能弄清楚为什么你想要先在线的末尾添加顶点:

图 6.12. 将多个顶点插入到线几何体中的结果。原始图以实线显示,编辑后的图以虚线绘制。

一旦将项目插入到列表中,则后续项目的索引就会改变。如果您首先在第五个顶点之后插入点,则原始的第十一个顶点现在将具有 13 的索引,因为列表中之前已经添加了两个点。您必须跟踪您插入了多少个项目,以便正确获取后续索引。这当然是可以做到的,但为什么还要麻烦,如果您可以通过反向工作来避免整个问题呢?
提示
如果您需要在列表中插入或删除多个项目(无论列表中包含顶点还是其他内容),您会发现如果从末尾开始并反向工作,生活会更轻松,这样您就不会无意中更改您仍然需要使用的索引。
如果您不想创建新的线几何体,您可以修改原始的线。这向sidewalk线添加了一个顶点,而不创建副本:
vertices = sidewalk.GetPoints()
vertices[2:2] = [(66.5, 35)]
for i in range(len(vertices)):
sidewalk.SetPoint(i, *vertices[i])
但这使用SetPoint编辑了五个顶点,而原始的sidewalk只有四个。您怎么可能改变一个不存在的顶点呢?结果证明,SetPoint会在请求的索引处添加一个顶点,以及任何缺失的顶点。例如,如果线有十个顶点(因此最高索引是 9),您使用SetPoint创建一个索引为 15 的顶点,它还会创建索引为 10 到 14 的顶点。但请注意,它添加的任何填充顶点都初始化为(0, 0),这可能不是您想要的。
从线创建点
有时您需要将线的顶点作为单独的点获取。到这时,您已经知道如何创建点以及如何操作单个线顶点,因此将顶点转换为点不应该太难。您需要做的就是遍历线顶点,获取坐标,并使用这些坐标创建一个点。以下列表显示了一个执行此操作的函数。
列表 6.1. 从线层创建点层的函数

此函数接受数据源、现有线层的名称以及新点层的名称。它创建点层并将所有属性字段从线复制到点层。然后它遍历所有线要素并为每个顶点创建一个点要素,该点要素还包含来自其来源线的相同属性值。
6.3.2. 创建和编辑多线:将多条线作为单一几何体
与多点类似,多行对象包含一条或多条被视为单一对象的线。编织河流中的渠道集合是这种几何类型的良好候选。如图 6.13 图所示,您还可以将穿过庭院的石路视为多行对象。
图 6.13。你可以使用多线来保存花园路径几何形状,这里以虚线表示。

与任何多几何形状一样,你需要单独创建每个组件,然后将其添加到主几何形状中,因此你至少需要一个常规线对象以及你的多线。以下代码显示了如何从图 6.13 创建多个路径:

这与创建多点类似。你首先需要创建构成路径的三条独立的线几何形状。创建这些之后,你创建一个多线几何形状,并按顺序添加路径。无论你是等到所有单独的线都创建完毕然后再创建多线,还是一开始就创建多线并在进行中添加单独的线,都可以。你甚至可以重复使用每个路径的一个线对象,但你需要在其顶点添加后立即将路径添加到多线中,并在开始下一个路径之前对路径几何形状调用Empty以清除旧顶点。
让我们来看看你新多线的内部结构:
>>> vp.plot(paths)
>>> print(paths)
MULTILINESTRING ((61.5 29.0 0,63 20 0,62.5 16.0 0,60 13 0),
(60.5 12.0 0,68.5 13.5 0),(69.5 33.0 0,80 33 0,86.5 22.5 0))
每个内部线都在它自己的括号内,并且它们的顺序与它们添加到多线中的顺序相同。同样,OGR 保留这个顺序非常重要,这样你总是知道哪条线是哪条。
要编辑已添加到多线的顶点,你首先需要获取你想要编辑的单条线,就像你处理多点时做的那样。一旦你有了它,你可以像编辑常规线一样编辑顶点。要编辑添加到多线中的第一条路径的第二个顶点,你可以这样做:
paths.GetGeometryRef(0).SetPoint(1, 63, 22)
你可以使用你已经学到的关于获取内部几何形状和编辑线的概念,将整个多线向东移动两个单位,向南移动三个单位,结果如图 6.14 所示:

图 6.14。原始和编辑后的路径多线几何形状。编辑后的一个是用虚线绘制的。

希望到这一点你感觉舒适地构建和编辑线,你将在下一节中看到,使用多边形的工作方式仅稍微复杂一些。
6.4. 使用多边形
多边形用于表示具有面积的事物,与点或线不同。城市边界和湖泊是两种可以建模为多边形的数据示例。与线一样,多边形不是由顶点列表组成,而是由环组成。这是因为多边形可以有洞,就像甜甜圈一样,需要单独的环来表示外部多边形和每个洞。一个没有洞的简单多边形仍然由一个环组成。与线一样,环由一系列通过直线段连接的顶点组成,但第一个和最后一个顶点是相同的,这样它们就形成了一个封闭的环。
与线一样,环顶点需要按顺序添加,但你还有其他考虑因素。组成多边形周界的线段不应接触或交叉,如图 6.15 所示。OGR 将允许你创建这样的多边形,但对其进行的计算可能会出错,即使它们没有错误地运行。你可以通过在几何形状上调用IsValid来检查这类问题,如果你正在构建自己的几何形状,你应该养成这种习惯。请注意,没有宽度——看起来像线——的多边形也是无效的。
图 6.15。左边的多边形是有效的,但其他两个不是,因为线段相交并分割了多边形。

回到院落示例,让我们首先创建整个院落边界的多边形(图 6.16)。
图 6.16。你可以使用多边形来保存院落边界,这里显示为粗实线。

再次强调,你将在示例中使用简单的多边形,但你可以轻松地将你的新知识应用到更复杂的几何形状中。
6.4.1. 创建和编辑单个多边形
多边形就像是由一组几何形状组成的多几何形状。所有的多边形都是由环组成的,而环又是由顶点组成的。一个简单的多边形只有一个环,但你仍然需要创建一个环对象并将其添加到多边形中。与线一样,使用AddPoint向环中添加顶点。顶点需要按顺序添加,但围绕周界的方向可以根据你想要使用的存储数据格式而变化。例如,shapefiles 指定外环是顺时针顺序,但 GeoJSON 没有指定顺序。由于这样的细节,了解你打算使用的格式可能是个好主意。但无论方向如何,第一个和最后一个顶点必须具有相同的坐标,以便它们闭合环。为此,你可以确保最后一个添加的顶点与第一个具有相同的坐标,或者你可以在添加所有顶点后对环或多边形调用CloseRings。后一种方法就是在这里创建图 6.16 中显示的院落轮廓所使用的方法。示例从左上角的顶点开始,以逆时针方向遍历周界。

你可以通过绘制几何形状并打印 WKT 来确保一切看起来正常:
>>> vp.plot(yard, fill=False, edgecolor='blue')
>>> print(yard)
POLYGON ((58.0 38.5 0,53 6 0,99.5 19.0 0,73 42 0,58.0 38.5 0))
WKT 包含环的所有坐标,但请注意,坐标列表位于另一组括号内。这是因为顶点构成了多边形内的一个环。你将在后面看到,多边形内可以有多个环,这就是为什么环需要用自己的一组括号来界定。
如果你调用GetPointCount在yard上,响应将是零,因为顶点属于多边形内部的环。这与多几何形状不会承认有顶点,但会承认包含其他几何形状的方式相似。如果你用GetGeometryCount查询yard变量,它会声称有一个几何形状,而这个几何形状是一个环。因此,要编辑多边形的顶点,你需要首先获取环,然后以编辑线条相同的方式编辑环。这个例子获取环并将其向西移动五个地图单位,这会自动移动整个多边形,如图 6.17 所示:
>>> ring = yard.GetGeometryRef(0)
>>> for i in range(ring.GetPointCount()):
... ring.SetPoint(i, ring.GetX(i) - 5, ring.GetY(i))
...
>>> vp.plot(yard, fill=False, ec='red', linestyle='dashed')
图 6.17. 原始和编辑后的庭院多边形几何形状。原始图是用实线绘制的。

你可以使用与线条相同的方法向多边形环中插入顶点。例如,你可以通过获取环并将第三个顶点替换为两个不同的顶点来从庭院中裁剪掉一个尖锐的角(图 6.18):
>>> ring = yard.GetGeometryRef(0)
>>> vertices = ring.GetPoints()
>>> vertices[2:3] = ((90, 16), (90, 27))
>>> for i in range(len(vertices)):
... ring.SetPoint(i, *vertices[i])
...
>>> vp.plot(yard, fill=False, ec='black', ls='dotted', linewidth=3)
图 6.18. 原始庭院几何形状和其中一个第三个顶点被两个其他顶点替换后的形状。

警告
创建具有相同起始和结束顶点的线字符串不会创建可以用来构建多边形的环。相反,它将是一条恰好停止在它开始的地方的线。它仍然没有面积、周长或任何特定于多边形的其他属性。
从多边形创建线条
有时你需要将多边形转换为线条。为此,你需要从多边形内部创建每条环的线条。我尝试将环复制到线要素中,但以下列表显示了如何通过从环创建线条来完成此操作。类似于列表 6.1 中的line_to_point_layer函数,此函数需要一个数据源、现有多边形层的名称以及新线层的名称。它创建一个具有与多边形层相同属性的新线层,然后对于每个多边形要素,将每个环复制到线条中,并在线层中插入一个新要素。
列表 6.2. 从多边形层创建线层的函数

在创建一个新的层来存储线条之后,函数开始遍历原始层中的多边形,并为多边形中的每个环创建一条新线。为此,每次它找到一个环时,它都会创建一个空的线对象,然后遍历环的顶点。每个环顶点的坐标用于在线条中创建一个新的顶点,因此你得到一个包含与环相同所有顶点的线条。然而,即使第一个和最后一个顶点相同,线条也不会闭合。如果你绘制这条线,它看起来像一个多边形,但它没有面积或任何特定于多边形的其他概念。
6.4.2. 创建和编辑多边形:将多个多边形作为一个几何形状
如果你读到这儿,你可以猜到多边形是由一个或多个单独的多边形组成的几何形状。一个典型的例子是夏威夷群岛。这个群岛组成了夏威夷州,通常在覆盖美国的数据集中表示为一个几何形状,但它显然由几个岛屿组成。岛屿的集合组成一个州,就像多边形的集合组成一个多边形一样。一个例子显示在图 6.19 中。
图 6.19。你可以使用多边形来包含花园箱,这里以两个矩形显示。
![06fig19.jpg]
你可能也能猜出如何创建多边形,因为你不需要了解任何新知识。你创建单独的多边形,并将它们添加到多边形中,这就是全部内容。例如,以下列表显示了如何将图 6.19 中的花园箱视为由两个单独的升高床组成的多边形。
列表 6.3。创建多边形
![125fig01_alt.jpg]
让我们看看这个多边形的 WKT:
>>> vp.plot(gardens, fill=False, ec='blue')
>>> print(gardens)
MULTIPOLYGON (((87.5 25.5 0,89.0 25.5 0,89 24 0,87.5 24.0 0,87.5 25.5 0)),
((89 23 0,92 23 0,92 22 0,89 22 0,89 23 0)))
这里你有两个多边形在多边形内部,每个都在它自己的括号内,而且每个都包含另一个括号中的一环。再次强调,一切都是按照你添加它们的顺序排列的。
编辑多边形与之前看到的方法类似,尽管它多了一步,因为在你能够编辑顶点之前,你需要获取每个内部多边形,然后从该多边形中获取环。图 6.20 显示了将花园箱向东移动一个地图单位,向北移动半个单位的结果。
图 6.20。原始和编辑过的花园箱多边形几何形状。编辑过的用虚线绘制。
![06fig20.jpg]
>>> for i in range(gardens.GetGeometryCount()):
... ring = gardens.GetGeometryRef(i).GetGeometryRef(0)
... for j in range(ring.GetPointCount()):
... ring.SetPoint(j, ring.GetX(j) + 1, ring.GetY(j) + 0.5)
...
>>> vp.plot(gardens, fill=False, ec='red', ls='dashed')
现在你已经知道如何处理单几何和多几何,但你仍然有特殊的多边形带洞的情况。继续阅读以了解这些与多边形的不同之处。
6.4.3。创建和编辑带洞的多边形:甜甜圈
那么关于内部有洞的多边形,比如甜甜圈呢?这些与多边形不同,因为洞是多边形的缺失,而不是第二个多边形。这就是为什么多边形需要由环组成。一个环定义了甜甜圈的外边缘,另一个环则勾勒出洞。你需要首先将外环添加到多边形中,然后后续的环定义几何中的洞。为了说明如何做到这一点,尝试从庭院多边形中切出房屋(图 6.21)。
图 6.21。你可以使用带洞的多边形作为庭院边界,中间切出房屋,这里以粗实线显示。
![06fig21.jpg]
列表 6.4。创建带洞的多边形
![126fig01_alt.jpg]
很可能,制作甜甜圈比你预期的要容易。现在如果你看一下 WKT,你会看到在多边形内部显示了两个环:
>>> vp.plot(yard, 'yellow')
>>> print(yard)
POLYGON ((58.0 38.5 0,53 6 0,99.5 19.0 0,73 42 0,58.0 38.5 0),
(67.5 29.0 0,69.0 25.5 0,64 23 0,69 15 0,82.5 22.0 0,76.0 31.5 0,
67.5 29.0 0))
在使用多边形时,会考虑孔洞。例如,院子多边形的面积等于减去房屋面积后的地块面积。在下一节中展示的空间分析工具使用时,多边形中的孔洞也不被视为几何体的一部分。
当编辑此类多边形时,唯一的区别是您需要遍历每个环,而不是假设只有一个环(图 6.22)。实际上,您永远不应该假设只有一个环存在,因为这个假设可能会在以后回来困扰您。
图 6.22. 原始和编辑后的院子多边形几何体,房屋已被裁剪。原始的是填充的,编辑的是网状的。

>>> for i in range(yard.GetGeometryCount()):
... ring = yard.GetGeometryRef(i)
... for j in range(ring.GetPointCount()):
... ring.SetPoint(j, ring.GetX(j) - 5, ring.GetY(j))
...
>>> vp.plot(yard, fill=False, hatch='x', color='blue')
使用其他模块处理几何体
现在您已经了解了如何使用 OGR 处理几何体,其他几何体库,如 Fiona,应该很容易理解。线仍然是由有序顶点集合创建的,多边形仍然由环组成。关于几何体的基本理论没有变化,尽管访问它们的方法有所改变。
例如,Fiona 是一个基于 OGR 的读取和写入矢量数据的库。Fiona 不使用特殊的几何类型,而是使用 Python 列表来存储顶点。列表填充了包含顶点坐标的元组。例如,一个环是一个元组列表,一个多边形是一个环的列表。一个只有一个环的多边形是一个包含另一个包含顶点元组的列表。Fiona 的用户手册非常优秀,可在网上找到,地址为 toblerity.org/fiona/manual.html。
Shapely 是另一个专为处理几何体而设计的出色模块,但它不用于读取和写入数据。与 Fiona 不同,它确实有专门用于几何体的数据类型,这就是为什么它可以进行空间分析,而 Fiona 则不能。尽管它有自己的数据类型,但基本思想仍然是相同的。Shapely 的详细用户手册可在网上找到,地址为 toblerity.org/shapely/manual.html。
6.5. 概述
-
几何体由顶点集合组成。在直线和多边形的情况下,顶点通过线段连接形成形状。
-
多几何体是由多个几何体组合而成的。这使得像夏威夷这样的特征可以用单个几何体对象来表示。
-
OGR 中的几何体是 2D 或 2.5D。2.5D 几何体有 z 值,但在分析过程中被忽略,这就是为什么它们不被视为 3D 的原因。
-
所有多边形几何体都由一个或多个环组成。
第七章. 使用 OGR 进行矢量分析
本章涵盖
-
判断几何体是否共享空间位置
-
几何体之间的邻近关系
现在你已经知道了如何访问现有数据以及如何从头开始构建自己的几何体,但我认为这些是通往更有趣的任务——空间分析的门户。没有分析能力,空间数据仅用于制作地图。优秀的制图对于许多事情都是必不可少的,但我想象即使制图员也会感到无聊,如果不断从各种分析中创建新的数据集。此外,空间分析可以回答与几乎所有学科相关的无数问题。事实上,你更有可能使用本章中描述的分析功能生成新的数据,而不是像之前那样逐个顶点地创建几何体。
使用矢量数据进行空间分析归结为观察两个或更多几何体之间的空间关系。可能的研究范围从极其简单的,例如两点之间的距离,到更复杂的算法,如网络分析。你是否曾经好奇某些地图网站是如何从点 A 到点 B 提供各种路线选项,甚至提供旅行时间?这就是网络分析。有时我发现一个简单的练习很有趣,那就是比较我所徒步的距离与起点和终点之间的直线距离,因为在这两种距离在山地地形中可能会有很大的差异。在我的生活中,这个特定的例子可能没有太大的用途,除了满足我的好奇心之外,但对于需要知道实际距离的搜救队伍来说,这是重要的信息。还有许多其他重要的问题等待使用空间分析技术来解答。
例如,生物学家可以使用从 GPS 项圈下载的信息来研究动物如何使用各种栖息地类型或它们对道路或其他人造特征的反应。企业使用空间数据来帮助确定新商店或工厂的最佳位置。公用事业公司可以使用此类数据来选择安装管道或输电线路的最佳路线,而采矿公司则使用地理信息来确定可能富含资源的地区。如果你正在阅读这本书,你很可能有特定的分析类型在心中,而且它可能完全不同于之前提到的任何例子。空间分析无处不在,实际上,你在选择居住地或选择上班路线时,每天都在使用这些类型的分析。OGR 为矢量分析提供了一个良好的基础,尽管更复杂的算法留给了你自己去实现,这些算法可能对你来说很有趣。本节将介绍构成这个基础的基本工具。
7.1. 叠加工具:什么在什么之上?
地理分析中的一个基本问题是哪些特征出现在同一地点。某些实体,如国家,不会出现在同一位置,尽管它们可能共享边界。其他类型的区域,如个别熊的栖息地,可以轻易重叠,就像不一定相关的边界,如湿地和土地所有权。许多类型的查询都关注这种重叠概念。例如,保险公司想在设定保险费之前知道一块土地是否位于洪泛区,甚至决定是否要投保。一家寻找土地建造工厂的企业想知道哪些待售地块位于适当的市政土地利用区内。如果你正在制作斯德哥尔摩的地图,你将想知道哪些道路、铁路和公园等设施位于城市范围内。
存在哪些重叠工具?一些测试某些条件,例如Intersects,它告诉你两个几何形状是否共享任何共同的空间。例如,在图 7.1 中,线 L2 与线 L3 相交,并与多边形 L3 相交。多边形 P2 和 P4 也相交。你可以使用Touches找出两个几何形状是否触碰边缘,但实际上并不共享任何区域,这也是线 L2 和 L3 的情况,但不是 L2 和 P3,因为它们不仅仅是触碰。那么,如何发现一个几何形状是否完全包含在另一个几何形状内呢?你可以使用Contains或Within来测试这一点。多边形 P5 包含在多边形 P1 内,P1 包含 P5。参见表 7.1 以获取可用操作的列表,以及每个操作从图 7.1 中的示例。请注意,虽然这些函数适用于多边形,但它们不适用于线性环。所有函数都返回True或False。更多信息可以在附录 C 中找到。(附录 C 至 E 可在 Manning Publications 网站上在线获取,网址为www.manning.com/books/geoprocessing-with-python。)
图 7.1. 用于获取表 7.1 和图 7.2 中显示的叠加操作结果的几何形状。

表 7.1. 测试几何形状之间关系的函数。这些函数都返回True或False
| 操作 | 图 7.1 中的示例 |
|---|---|
| 相交 | 多边形 P2 和 P4 相交。线 L3 和点 B 相交。点 A 和多边形 P2 相交。线 L2 和线 L3 相交。线 L2 和多边形 P3 相交。 |
| 触碰 | 多边形 P2 和点 A 触碰。多边形 P5 和点 D 不触碰。线 L2 和线 L3 触碰。线 L1 和线 L3 不触碰。 |
| 交叉 | 线 L1 和线 L3 交叉。线 L2 和线 L3 不相交。 |
| 包含 | 线 L1 包含在多边形 P2 内。线 L3 不包含在多边形 P2 内。 |
| 包含 | 多边形 P1 包含多边形 P5。多边形 P2 不包含多边形 P4。 |
| 重叠 | 多边形 P2 和 P4 重叠。多边形 P1 和 P5 不重叠。 |
| 不相交 | 多边形 P1 和线 L1 不相交。多边形 P1 和 P4 不相交。 |
几个函数基于现有几何形状的空间关系创建新的几何形状。例如,您可以使用 Intersection 获取一个新几何形状,它仅表示两个其他几何形状共有的区域。在图 7.1 中,L1 和 L3 的交集是一个单点;L2 和 P3 的交集是 L2 上的一个短段;P2 和 P4 的交集如图 7.2 所示。您可能会使用 Intersection 创建新的数据集,其中只包含在之前提到的地图中找到的斯德哥尔摩边界内的特征。
图 7.2. 在图 7.1 中的 P2 和 P4 几何形状上进行的几个叠加操作的结果,以带有深色轮廓的阴影区域表示。

您可以使用 Union 将两个现有几何形状的面积合并成一个,如果输入的几何形状是不同类型,则可能返回一个几何集合。您可以将几何集合视为多几何形状,但各部分不需要都是同一种几何形状。例如,L2 和 P3 的并集是一个包含一个多边形和两条线的几何集合,如图 7.3 所示。L2 与 P3 相交的部分不再作为线存在,而是其占据的空间包含在多边形中。P2 和 P4 的并集是一个单一的多边形,如图 7.2 所示。如果您收到一个道路数据集,其中道路根据速度限制的变化被分割成段,这对于分析旅行时间可能是必需的,但您希望每条道路都是一个单独的特征,以便更容易在地图中使用,那么您可能会使用此功能。
图 7.3. 通过将 L2 和 P3 并联创建的几何集合的三个部分。

还可以从几何形状中裁剪出交集,这样您就只剩下不与第二个几何形状相交的几何形状的部分。与 Intersection 和 Union 不同,Difference 的结果取决于函数被调用在哪个几何形状上以及传递给它的哪个几何形状。这也在图 7.2 中得到了说明。
还有SymDifference,它返回两个几何图形的并集,并从中减去交集。如果你正在查看两只不同山狮的活动范围或领地,你可能想知道第一只猫使用的区域,而第二只猫没有使用,或者反之。你可以使用Difference来获取这些信息。你可以使用SymDifference来确定两只狮子都未使用的区域。Intersection会给你共享的领地,而Union会提供合并后的领地。每种类型的信息都可能对山狮研究者有用,但以不同的方式。事实上,正是类似这样的研究,尽管是在一种受威胁的蜥蜴物种上,而且比这个简化的例子要复杂得多,让我最初对 GIS 产生了兴趣!
让我们来看一个具体的例子。图 7.4 可能会让你想起我们在第三章中讨论的新奥尔良边界内的湿地。你将看到两种不同的使用交集来确定新奥尔良湿地百分比的方法。但在那之前,进行一些与数据相关的互动练习来可视化所发生的情况将会有所帮助。打开包含湖泊、河流、运河和湿地等要素的美国水域形状文件,并绘制一个代表新奥尔良附近湿地的特定要素。这个形状文件大约有 27,000 个要素,所以除非你愿意等上一整天,否则不要尝试绘制整个文件。
图 7.4。显示城市边界、水域和湿地的简单新奥尔良地图

>>> water_ds = ogr.Open(r'D:\osgeopy-data\US\wtrbdyp010.shp')
>>> water_lyr = water_ds.GetLayer(0)
>>> water_lyr.SetAttributeFilter('WaterbdyID = 1011327')
>>> marsh_feat = water_lyr.GetNextFeature()
>>> marsh_geom = marsh_feat.geometry().Clone()
>>> vp.plot(marsh_geom, 'b')
你现在应该能看到类似于图 7.5 的图像,但没有城市边界。添加新奥尔良的边界以提供一些背景信息:
>>> nola_ds = ogr.Open(r'D:\osgeopy-data\Louisiana\NOLA.shp')
>>> nola_lyr = nola_ds.GetLayer(0)
>>> nola_feat = nola_lyr.GetNextFeature()
>>> nola_geom = nola_feat.geometry().Clone()
>>> vp.plot(nola_geom, fill=False, ec='red', ls='dashed', lw=3)
图 7.5。新奥尔良城市边界以虚线形式叠加在来自美国水域数据集的单个但较大的湿地多边形上。

现在你有两个多边形,一个代表新奥尔良,另一个代表部分包含在新奥尔良边界内的湿地。现在将这两个几何图形相交:
>>> intersection = marsh_geom.Intersection (nola_geom)
>>> vp.plot(intersection, 'yellow', hatch='x')
从图 7.6 中可以看出,交集几何图形由同时包含在城市边界和湿地多边形内的区域组成。你如何利用这一点来计算新奥尔良有多少是湿地?嗯,如果你将城市边界与所有与之重叠的湿地多边形相交,那么你最终会得到代表边界内湿地的多个多边形。你只需要将它们的面积相加,然后除以新奥尔良几何图形的面积。假设水域数据集中除湖泊之外的所有内容都是湿地,并尝试这样做:

图 7.6。新奥尔良边界与湿地相交的结果显示在阴影区域内。

你首先需要做的是更改水体上的属性过滤器,以便忽略湖泊,特别是庞查特雷恩湖。然后你使用空间过滤器去除所有不在新奥尔良附近的功能,这几乎去除了 shapefile 中的所有内容。这一步在技术上不是必需的,但它可以显著加快处理时间,因为你可以忽略大部分数据集。然后你遍历剩余的水体,将每个水体与新奥尔良几何体相交,并将相交区域加到累计总和中。当循环完成后,你所需要做的就是除以新奥尔良的面积,以得到你的答案。
小贴士
使用空间或属性过滤器过滤掉不需要的功能可以显著减少你的处理时间。
然而,如果你想要使用图层而不是单独的几何体来工作,你有一个更简单的方法。在这种情况下,OGR 会为你遍历图层中的几何体。让我们将新奥尔良边界与水层相交,以获取两个图层共有的区域:

和之前一样,你将水体限制为非湖泊,但你不执行空间过滤器,因为图层相交处理了这一点。然而,图层相交需要一个空图层,所以你需要额外的步骤来创建它。因为你没有理由保存图层,所以你使用内存驱动来创建数据源和图层。这个驱动器不会将任何内容写入磁盘,所以它是临时数据的良好选择。一旦你有了空图层,你将其传递给图层Intersection函数,该函数用nola_lyr和water_lyr的交集填充它。
一旦你有了相交区域,你可以使用 SQL 语句来汇总temp_lyr中所有几何体的面积。记住,ExecuteSQL返回一个新的图层对象,所以你需要从中获取第一个特征来访问SUM函数的结果:
>>> sql = 'SELECT SUM(OGR_GEOM_AREA) AS area FROM temp'
>>> lyr = temp_ds.ExecuteSQL (sql)
>>> pcnt = lyr.GetFeature(0).GetField('area') / nola_geom.GetArea()
>>> print('{:.1%} of New Orleans is wetland'.format(pcnt))
28.7% of New Orleans is wetland
另一个重要的细节是,那些在整图层上操作而不是在单独几何体上操作的函数会保留输入图层的属性值。如果你仍然需要每个功能的信息,这会很有用。在这种情况下你不需要它,但想想山狮活动范围示例,但假设有更多的猫。研究人员几乎肯定想知道哪两只美洲狮共享了相同的栖息地,而图层相交会保留这些信息,前提是它们在原始属性表中。
7.2. 临近工具:事物之间有多远?
在分析地理特征时,另一个常见问题是确定它们彼此之间的距离。例如,许多市政当局都有关于在教堂或学校一定距离内允许经营的业务类型的法规,而接近大型客户群在考虑商业地点时也是一个重要因素。或者,一个试图确定道路如何影响各种鸟类选择筑巢地点的鸟类学家呢?他需要测量每个巢穴与最近道路之间的距离,作为他研究的一部分。
OGR 包含两个邻近性工具,一个用于测量几何形状之间的距离,另一个用于创建缓冲多边形。缓冲区是从原始几何形状向外延伸一定距离的多边形。图 7.7 显示了第六章中的院子几何形状及其周围的缓冲区,尽管它们不是真正的院子配置,这样您可以更好地看到缓冲区。您可以使用缓冲区来可视化您所在位置附近哪些业务在步行范围内,或者确保您不会在现有业务一定距离内建造比萨饼店。您还可以对溪流几何形状进行缓冲,以了解其周围的河岸区域,或者显示牛不允许放牧以避免破坏生态系统的区域。
图 7.7. 从虚构的院子中展示的几何形状以及缓冲几何形状。注意,当单个缓冲区重叠时,多几何形状的缓冲区变成一个单边形。

小贴士
未投影的数据集(使用纬度和经度)在许多情况下可以用于显示数据,但在分析时可能是一个较差的选择。想想地球仪上经线在两极汇聚的情况。在 40°纬度上,一度经线比赤道上的短,这使得在不同纬度上比较距离变得极其困难。您最好将数据转换为具有恒定测量单位的不同坐标系。
作为缓冲示例,让我们计算一下美国有多少城市在火山 10 英里范围内。我们将使用具有阿尔伯斯投影的数据库,这样地图单位就是米而不是十进制度。我们还将使用这个例子来强调在进行分析时可能出现的潜在错误来源。分析的第一步将是将火山数据集缓冲 16,000 米,这大约相当于 10 英里。由于整个图层上没有缓冲功能,您需要单独对每个火山点进行缓冲,并将其添加到临时图层中。完成这些后,您可以将缓冲图层与城市图层相交,以获取位于该 10 英里半径内的城市数量。所有这些都在下面的列表中展示。
列表 7.1. 确定火山附近城市数量的一个有缺陷的方法

从这个结论中,你可以得出结论,美国有 83 个城市位于火山 10 英里范围内。但为了保险起见,尝试使用列表 7.2 中显示的略有不同的方法做同样的事情。这次你将把缓冲区添加到一个多边形而不是临时层中。一个名为UnionCascaded的函数可以有效地将多边形中的所有多边形合并在一起;你将使用这个函数从所有火山缓冲区创建一个多边形,然后将其作为空间过滤器应用于城市层。
列表 7.2. 确定火山附近城市数量的更好方法

嘿,不知怎么的,在过去的几分钟里你失去了五个城市,这有点令人不安。发生了什么事?在第一个例子中,每当一个城市落在火山缓冲区中时,输出中都会包含该城市的副本。这意味着如果一个城市位于多个火山内 16,000 米范围内,它将被包含多次。一些城市就是这样发生的,这就是为什么交集方法得出的计数是错误的,并且高于空间过滤器方法。这是一个很好的例子,说明了为什么你应该始终仔细思考你的方法论,因为“显而易见”的解决方案可能是错误的,并给出错误的结果。
小贴士
当你需要将许多几何形状合并在一起时,请使用UnionCascaded。它将比逐个连接它们快得多。
我们将来看最后一个示例。也许你想知道某个城市与某个火山之间的距离。首先你需要做的是获取感兴趣的城市和火山的几何形状:
>>> volcano_lyr.SetAttributeFilter("NAME = 'Rainier'")
>>> feat = volcano_lyr.GetNextFeature()
>>> rainier = feat.geometry().Clone()
>>> cities_lyr.SetAttributeFilter("NAME = 'Seattle'")
>>> feat = cities_lyr.GetNextFeature()
>>> seattle = feat.geometry().Clone()
一旦你有了几何形状,你可以使用Distance函数来询问它们之间的距离:
>>> meters = round(rainier.Distance(seattle))
>>> miles = meters / 1600
>>> print('{} meters ({} miles)'.format(meters, miles))
92656 meters (57.91 miles)
西雅图市大约距离雷尼尔山 58 英里,雷尼尔山被认为是一座活跃的火山。当然,如果你使用实际的市界而不是一个点,你会得到不同的答案,但我怀疑如果山爆发了,西雅图的好人们不会欣赏这种区别。
2.5D 几何形状
你可能还记得上一章中,在 OGR 中,具有 z 值的几何形状被认为是 2.5D,因为在执行空间操作时不会使用 z 值。为了说明这一点,让我们看看两点之间的距离:
>>> pt1_2d = ogr.Geometry(ogr.wkbPoint)
>>> pt1_2d.AddPoint(15, 15)
>>> pt2_2d = ogr.Geometry(ogr.wkbPoint)
>>> pt2_2d.AddPoint(15, 19)
>>> print(pt1_2d.Distance(pt2_2d))
4.0
这返回了预期的 4 个单位的距离。现在尝试用 2.5D 点做同样的事情:
>>> pt1_25d = ogr.Geometry(ogr.wkbPoint25D)
>>> pt1_25d.AddPoint(15, 15, 0)
>>> pt2_25d = ogr.Geometry(ogr.wkbPoint25D)
>>> pt2_25d.AddPoint(15, 19, 3)
>>> print(pt1_25d.Distance(pt2_25d))
4.0
这也返回了 4 个单位的距离,但考虑到海拔值,实际距离是 5 个单位。显然,z 值在计算中没有被使用。那么,我们来看一个面积示例?这个多边形的每一边长为 10 个单位,因此它应该有一个面积为 100:
>>> ring = ogr.Geometry(ogr.wkbLinearRing)
>>> ring.AddPoint(10, 10)
>>> ring.AddPoint(10, 20)
>>> ring.AddPoint(20, 20)
>>> ring.AddPoint(20, 10)
>>> poly_2d = ogr.Geometry(ogr.wkbPolygon)
>>> poly_2d.AddGeometry(ring)
>>> poly_2d.CloseRings()
>>> print(poly_2d.GetArea())
100.0
你在那里得到了预期的结果,但尝试将最右边的边缘移动到一个更高的海拔,这样矩形就会位于 3D 平面上:
>>> ring = ogr.Geometry(ogr.wkbLinearRing)
>>> ring.AddPoint(10, 10, 0)
>>> ring.AddPoint(10, 20, 0)
>>> ring.AddPoint(20, 20, 10)
>>> ring.AddPoint(20, 10, 10)
>>> poly_25d = ogr.Geometry(ogr.wkbPolygon25D)
>>> poly_25d.AddGeometry(ring)
>>> poly_25d.CloseRings()
>>> print(poly_25d.GetArea())
100.0
这个新的矩形也声称面积是 100,但现实中面积更接近 141。
叠加操作也会忽略高程值。例如,如果考虑了高程,pt1_2d 将包含在二维多边形中,但不会包含在 2.5D 多边形中,而这并不是我们所看到的:
>>> print(poly_2d.Contains(pt1_2d))
True
>>> print(poly_25d.Contains(pt1_2d))
True
现在你已经了解了使用矢量数据进行空间分析的基本知识。你可能不需要做比这里更复杂的事情,但如果你需要,这些工具是开始的基础。
7.3. 示例:定位适合风力农场的区域
让我们进行一个简单的分析,以寻找加利福尼亚州帝国县适合风力农场的位置。美国国家可再生能源实验室提供了一个风力数据集,该数据集显示了基于风速和丰富度以及地形等地理因素的美国哪些地区适合风力农场(图 7.8)。区域按 1 到 7 的等级评分,其中 3 及以上通常被认为是合适的。我们将结合人口普查数据,以定位具有适当风力评分且人口密度小于每平方公里 0.5 人的区域。
图 7.8. 加利福尼亚州帝国县的人口普查和风速数据。阴影越深,风力农场的风力条件越好。虚线区域显示人口密度小于 0.5/km2 的人口普查区域。

人口普查数据集包含每个人口普查区域的居民人数,但没有人口密度属性。然而,你可以根据区域面积和人口来计算它,所以首先要做的是添加一个包含该信息的字段:
census_fn = r'D:\osgeopy-data\California\ca_census_albers.shp'
census_ds = ogr.Open(census_fn, True)
census_lyr = census_ds.GetLayer()
density_field = ogr.FieldDefn('popsqkm', ogr.OFTReal)
census_lyr.CreateField(density_field)
for row in census_lyr:
pop = row.GetField('HD01_S001')
sqkm = row.geometry().GetArea() / 1000000
row.SetField('popsqkm', pop / sqkm)
census_lyr.SetFeature(row)
你打开人口普查形状文件进行编辑,并添加一个浮点字段。然后,你遍历每一行并计算人口密度。该数据集的地图单位是米,因此几何形状的面积是平方米,但你通过除以 1,000,000 将其转换为平方公里。你从HD01_S001字段中获取区域人口,并将其除以计算出的面积以获得每平方公里的人口数。
现在获取帝国县的几何形状,以便你可以用它来对分析进行空间限制。在克隆几何形状后,你不需要保持县数据源打开。
county_fn = r'D:\osgeopy-data\US\countyp010.shp'
county_ds = ogr.Open(county_fn)
county_lyr = county_ds.GetLayer()
county_lyr.SetAttributeFilter("COUNTY ='Imperial County'")
county_row = next(county_lyr)
county_geom = county_row.geometry().Clone()
del county_ds
但存在一个问题。县数据使用的是纬度和经度坐标,而人口普查和风力数据集使用的是米。你将在下一章学习如何处理这些不同的空间参考系统,但在此期间,请相信我,这段代码会将县几何形状转换为正确的坐标系:
county_geom.TransformTo(census_lyr.GetSpatialRef())
census_lyr.SetSpatialFilter(county_geom)
census_lyr.SetAttributeFilter('popsqkm < 0.5')
一旦几何形状被转换,你就可以用它来对人口普查区域数据设置空间过滤器,这样你只会考虑位于该州正确部分的人口普查区域。你还可以设置属性过滤器,进一步限制区域为那些人口密度低的区域。
现在打开风力数据集,并使用属性过滤器将其限制为评分 3 或更好的区域:
wind_fn = r'D:\osgeopy-data\California\california_50m_wind_albers.shp'
wind_ds = ogr.Open(wind_fn)
wind_lyr = wind_ds.GetLayer()
wind_lyr.SetAttributeFilter('WPC >= 3')
在开始任何分析之前创建一个数据源来存放结果是有意义的,所以现在就让我们这么做。创建一个新的 shapefile,使其使用与风速数据相同的空间参考系统,然后添加风速评级和人口密度的字段。你还可以使用图层定义来创建一个空的特征,以便稍后插入数据。
out_fn = r'D:\osgeopy-data\California\wind_farm.shp'
out_ds = ogr.GetDriverByName('ESRI Shapefile').CreateDataSource(out_fn)
out_lyr = out_ds.CreateLayer(
'wind_farm', wind_lyr.GetSpatialRef(), ogr.wkbPolygon)
out_lyr.CreateField(ogr.FieldDefn('wind', ogr.OFTInteger))
out_lyr.CreateField(ogr.FieldDefn('popsqkm', ogr.OFTReal))
out_row = ogr.Feature(out_lyr.GetLayerDefn())
你终于准备好寻找可能的风力发电场位置了。在下一个列表中,你将遍历人口普查区,将它们与合适的风力多边形相交,并将结果放入你的新 shapefile 中。
列表 7.3. 人口普查和风速数据相交

然而,你有一个额外的步骤来得到你想要的结果。不幸的是,人口普查和县边界并不完全对齐(图 7.9),这意味着由于这个数据错误,几乎与县重叠的人口普查区将被用来选择风力多边形,尽管你不需要它。处理这个问题的一种方法是将人口普查和县多边形相交,这样你只使用位于县多边形内部的人口普查多边形部分(例如,图 7.9 中的小条带)。一旦找到这个交集,然后你可以使用空间过滤器来选择它包含或重叠的风力多边形。
图 7.9. 粗实的人口普查区边界与虚线的县边界并不完全对齐。

在设置空间过滤器后,你遍历选定的风力多边形,并将每个多边形与人口普查多边形相交。这会丢弃那些风力不足的部分人口普查区或人口密度过高的适宜风力区域。即使空间过滤器发生变化,属性过滤器仍然有效,所以这始终限于适宜的风力多边形。你将每个这些交集多边形以及风速类别和人口密度属性添加到新的数据集中。
图 7.10 放大了部分结果。你很接近了,但有一个大多边形而不是许多小多边形会更好。这将失去关于风速适宜类别和人口密度的信息,但在这个阶段,你知道所有的多边形都是合适的,无论如何。
图 7.10. 根据我们的分析,适合风力发电场的位置。阴影越深,风速评级越高。

将小多边形合并成一个大多边形最快的方法是使用UnionCascaded函数,该函数要求要连接的多边形都包含在一个单一的多边形中。只有当你将单个多边形添加到多边形中时,它才能正确工作。然而,如果你添加一个多边形,那么你稍后将会得到错误的结果,所以你需要将你之前创建的任何多边形分解,并单独添加每个多边形。以下列表显示了此过程。
列表 7.4. 将小多边形合并成大多边形


在将所有多边形合并成一个大的多边形之后,你将遍历它们,将它们拆分成单独的多边形,并将它们添加到新的 shapefile 中。那些不足以容纳风力发电场的小岛屿可以被丢弃,因此你只保留面积至少为一平方公里的多边形。结果如图 7.11 所示,你可以看到一些原本独立的小多边形现在消失了。
图 7.11. 将图 7.10 中的小多边形合并在一起并丢弃小岛屿多边形的结果

这样的数据集,只有大多边形,可能比有多个小多边形的数据集更容易处理,只要你不需要合并它们所丢失的信息。
7.4. 示例:动物追踪数据
网站www.movebank.org/拥有全球各地动物追踪数据的数据库。我下载了加拉帕戈斯信天翁的 GPS 位置数据作为一个 CSV 文件,但让我们将其转换为 shapefile,然后对数据进行一些操作。你可以使用位置-长和位置-宽列中的 x 和 y 坐标来创建一个点,并将该点以及个体-本地标识符和时间戳列作为属性复制。shapefile 格式不支持真正的日期/时间字段,所以你将时间戳信息作为字符串保留。下面的列表显示了这段代码。
列表 7.5. 从.csv 文件创建 shapefile


不幸的是,如果你绘制这个新的 shapefile 或者在一个 GIS 中打开它,你会在非洲附近看到一些坏点(图 7.12)。这些点的数据收集肯定出现了错误,因此它们的纬度和经度值被设置为 0。让我们将它们删除。
图 7.12. 非洲而不是南美洲的一些坏 GPS 位置

因为你知道坏点的坐标是(0, 0),你可以设置一个空间过滤器来选择这些点,然后逐个删除它们:
shp_ds = ogr.Open(shp_fn, True)
shp_lyr = shp_ds.GetLayer()
shp_lyr.SetSpatialFilterRect(-1, -1, 1, 1)
for shp_row in shp_lyr:
shp_lyr.DeleteFeature(shp_row.GetFID())
shp_lyr.SetSpatialFilter(None)
shp_ds.ExecuteSQL ('REPACK ' + shp_lyr.GetName())
shp_ds.ExecuteSQL ('RECOMPUTE EXTENT ON ' + shp_lyr.GetName())
del shp_ds
不要忘记使用REPACK永久删除点,并使用RECOMPUTE EXTENT重新计算 shapefile 的空间范围。现在所有点都在加拉帕戈斯群岛和南美洲之间,如图 7.13 所示。
图 7.13. 加拉帕戈斯信天翁的 GPS 位置

现在坏点已经去除了,你可以考虑进行一些分析。我认为首先应该用动物 GPS 跟踪数据做的是看看它们移动了多远,以及它们使用的区域。不幸的是,以度为单位的地纬度/经度数据并不理想,但这是这些点使用的坐标系统。因为你将在下一章学习如何处理空间参考和坐标系统,所以让我们看看如何使用 ogr2ogr 命令行工具在坐标系统之间进行转换。记住,你需要从终端窗口或命令提示符中运行此命令,而不是从 Python 中运行。你还需要确保你位于 albatross_dd 形状文件所在的同一文件夹中。
你需要将坐标转换为使用米而不是度作为度量单位的系统。米更容易理解(大多数人可能很难很好地想象半度),而且它们是恒定的,与随着纬度变化的度不同。你将使用的系统称为兰伯特等角圆锥投影,你将使用其针对南美洲的特定变体。在 -t_srs 和 +no_defs 之间的这部分命令定义了坐标系统。输出将是一个名为 albatross_lambert.shp 的形状文件。
ogr2ogr -f "ESRI Shapefile" -t_srs "+proj=lcc +lat_1=-5 +lat_2=-42
+lat_0=-32 +lon_0=-60 +x_0=0 +y_0=0 +ellps=aust_SA +units=m +no_defs"
albatross_lambert.shp albatross_dd.shp
现在你有一个使用米的形状文件,让我们计算每个位置之间的距离。为此,你需要选择单个鸟的点,所以让我们编写一个函数,该函数将从一个属性列中获取唯一值。你可以使用以下列表中的函数在后续列表中获取 tag_id 值。
列表 7.6. 从属性列获取唯一值的函数
def get_unique(datasource, layer_name, field_name):
sql = 'SELECT DISTINCT {0} FROM {1}'.format(field_name, layer_name)
lyr = datasource.ExecuteSQL (sql)
values = []
for row in lyr:
values.append(row.GetField(field_name))
datasource.ReleaseResultSet(lyr)
return values
为了计算距离,你需要按顺序遍历每只鸟的点,然后计算每个位置与上一个位置之间的距离,所以你需要在你循环时跟踪上一个点。点应该在原始 .csv 文件中按正确顺序排列,这意味着它们也在你创建的形状文件中按顺序排列,但你会添加代码来检查,以防万一。如果它发现顺序有误,它将退出,这样你就可以纠正问题。下面的列表显示了这个过程。
列表 7.7. 计算相邻点之间的距离

在开始循环之前,你保存了第一个位置的戳记和点几何形状,以便你可以用它来计算它与第二个特征之间的距离。这增加了当前特征的计数,因此循环从第二个特征而不是第一个特征开始,你在第一次迭代中计算第一和第二个点之间的距离。在保存距离后,你将当前特征的戳记和几何形状存储在你的“上一个”变量中,这样在下一次循环中你将拥有这些信息。如果你没有存储当前值,你将始终计算到第一个点的距离,因为那是最初存储在 previous_pt 中的那个点。
现在很容易获取距离信息。例如,你可以使用 SQL 找出哪只鸟在 GPS 修正之间有最长的距离:
ds = ogr.Open(r'D:\osgeopy-data\Galapagos')
for tag_id in get_unique(ds, 'albatross_lambert', 'tag_id'):
sql = """SELECT MAX(distance) FROM albatross_lambert
WHERE tag_id = '{0}'""".format(tag_id)
lyr = ds.ExecuteSQL (sql)
for row in lyr:
print '{0}: {1}'.format(tag_id, row.GetField(0))
输出的前几行看起来像这样:
4264-84830852: 106053.530233
4266-84831108: 167097.198703
1103-1103: 69342.7642097
如果以后你想知道从一个点到下一个点的最大旅行速度怎么办?你有了距离,但你需要知道 GPS 读取之间的时间量来计算速度。时间戳字段是一个字符串,而不是日期/时间,这虽然是一个小问题,但很容易解决。幸运的是,只要你告诉它字符串的格式,你就可以从字符串创建 Python datetime 对象。你的数据集中的时间戳看起来像这样:
timestamp = '2008-05-31 13:30:02.001'
你可以使用docs.python.org/2/library/datetime.html#strftime-strptime-behavior中的信息创建一个匹配的格式字符串,然后使用strptime函数将字符串转换为日期时间:
date_format = '%Y-%m-%d %H:%M:%S.%f'
my_date = datetime.strptime(timestamp, date_format)
以下列表显示了如何使用这些信息来找到每个位置之间的最大旅行速度。这不会完全准确,因为一只鸟在读取之间一直在飞行的可能性可能很小,但至少这是一个开始。
列表 7.8. 从位置和经过时间找到最大速度

与寻找距离一样,你需要跟踪前一个点,以便找到 GPS 修正之间的时间长度。得到这些信息后,你将距离除以读取之间的小时数,以获得每小时的米数速度。
现在我们来看看每只鸟使用的区域。有复杂的方法可以确定动物的活动范围,但我们将使用凸包多边形,因为它们简单,并且 OGR 内置了这些功能。为此,你需要将每只鸟的点放入一个多点几何体中,然后可以使用它来创建凸包多边形,如下面的列表所示。
列表 7.9. 为每只鸟创建凸包多边形


结果显示在图 7.14 中,其中一只鸟的多边形已填充,其余的都是空心的。也许是我自己的问题,但那些大多边形并没有告诉我太多。我想看到每只鸟在岛屿和大陆周围使用的区域,但不需要中间的海洋区域。实际上,比较不同访问群岛或大陆之间使用的区域可能很有趣。
图 7.14. 每只鸟的范围。ID 为 4264-84830852 的鸟的多边形已填充,但其余的都是空心的。

我可以想到几种不同的方法来将点分离到大陆或岛屿的不同访问中,但我们只看其中一种。列表 7.10 通过忽略所有距离陆地超过 100 公里的位置来实现,每次鸟类穿越海洋中间的想象中的垂直线时,就会创建一组新的点,从而将两个地理区域分开。为了节省空间,创建新的多边形形状文件的代码被省略了,只展示了创建多边形的代码。
列表 7.10. 创建按地理区域分隔的凸包多边形


在这个例子中,你需要一个陆地数据集,以便你可以判断哪些点位于陆地 100 公里范围内。在获取陆地多边形后,你将其缓冲 100,000 米,这相当于 100 公里。在遍历点时,你首先检查点是否位于陆地缓冲区内部。如果不是,那么你继续到下一个点而无需做任何事情。如果一个点位于缓冲区内部,因此位于陆地 100 公里范围内,你检查点位于想象中的线的哪一侧,并设置一个位置变量来跟踪点是在岛屿还是大陆上。如果位置自上次查看的点以来已改变,并且你遇到了至少三个位置(构成多边形所需的最小数量),那么你使用收集到的点创建一个新的凸包多边形。在检查点的数量并可能创建多边形后,你创建一个新的多点对象来存储下一组点。如果你还没有创建一个新的多点对象,那么你的下一个凸包将使用你迄今为止保存的所有位置,但现在你想要在一个不同的地理区域内重新开始。当你遍历特定动物的点完成时,自上次位置改变以来的点仍然需要转换为多边形,因此你需要一段代码来处理这些最后的点。
一个鸟类的结果在图 7.15 中展示。这是与图 7.14 中阴影范围相同的动物,因此你可以看到计算范围之间的差异。
图 7.15. 鸟 4264-84830852 的特定区域范围。与图 7.14 中显示的同一动物的较大多边形进行比较。

我不知道你们,但我很好奇同一个区域在个体对岛屿或大陆的单独访问中使用了多少——它们是否总是出现在相同的位置,或者它们会稍微改变一下?一个简单的方法,它忽略了某些多边形可能是由一天的数据创建,而其他多边形则是由一周或两周的数据创建的事实,可能是查看公共区域与总面积的比率(如果你是信天翁生物学家,请不要对我的想法过于皱眉)。图 7.16 显示了鸟类访问岛屿时这两个比率之间的差异。
图 7.16. 四个不同访问岛屿时,鸟类 1163-1163 使用的区域轮廓,阴影区域显示了这些多边形并集和交集操作的结果。

让我们看看如何计算这个比率,然后我们将不再关注信天翁。以下列表展示了如何计算图 7.16 中所示鸟类的比率,但你也可以轻松地修改代码来计算所有鸟类的这个值。
列表 7.11. 计算所有岛屿访问中使用的总面积百分比
ds = ogr.Open(r'D:\osgeopy-data\Galapagos')
lyr = ds.GetLayerByName('albatross_ranges2')
lyr.SetAttributeFilter("tag_id = '1163-1163' and location = 'island'")
row = next(lyr)
all_areas = row.geometry().Clone()
common_areas = row.geometry().Clone()
for row in lyr:
all_areas = all_areas.Union(row.geometry())
common_areas = common_areas.Intersection(row.geometry())
percent = common_areas.GetArea() / all_areas.GetArea() * 100
print('Percent of all area used in every visit: {0}'.format(percent))
输出看起来像这样:
Percent of all area used in every visit: 25.1565197202
看起来这只鸟每次访问岛屿时使用了其总范围的四分之一,但从图 7.16 中可以看出,这个区域位于范围的中间。下一步可能就是看看创建每个多边形使用了多少点——也许较大的多边形更大是因为它们有更多的点,而不是因为鸟每次都改变了它的习惯。
7.5. 摘要
-
重叠工具告诉你几何形状之间的空间关系,例如它们是否在空间中相交。
-
临近工具用于确定几何形状之间的距离或围绕它们创建缓冲区。
-
就像任何类型的分析一样,在创建你的工作流程时,仔细考虑你的方法和假设是很重要的。
第八章. 使用空间参考系统
本章涵盖
-
理解空间参考系统
-
使用 OSR 转换数据
-
使用 pyproj 转换数据
-
使用 pyproj 进行大圆计算
大多数人熟悉使用纬度和经度来指定地球表面位置的概念。你会不会惊讶地发现,还有许多其他坐标系也被使用,并且不同的空间参考系统用于不同的目的?更复杂的是,地球不是一个完美的球体,使用了多个称为基准面的模型来表示地球的形状。鉴于这一点,任何系统(包括纬度和经度)的坐标都不是绝对的——一组坐标可以指定一个略微不同的位置,这取决于使用的基准面。
由于存在许多坐标系,因此你的数据很可能不会使用你需要的那个,因此能够在它们之间转换数据的能力至关重要。不仅如此,如果你不知道它们目前使用的是哪个系统,就无法将数据从一个空间参考系统转换到另一个,因此你必须确保这些信息得到记录,否则可能会使你的数据变得不可用。为了有效地处理坐标系,你需要了解为什么存在如此多的坐标系,以及如何为你的目的选择一个合适的坐标系,因此我们将从背景信息开始,然后转向数据转换。
8.1. 空间参考系统简介
空间参考系统由三个组成部分——坐标系、基准面和投影——所有这些都影响一组坐标在地球上的位置。简而言之,基准面用于表示地球的曲率,而投影将坐标从三维地球转换到二维地图。不同的投影适用于不同的目的,例如网络地图、精确测量距离或计算面积。然而,这还远不止这些,理解基准面和投影所起的作用非常重要。让我们回顾一下如何在地球上表示坐标。纬度和经度分别是赤道和本初子午线的度数距离。纬度值范围从-90 到 90,正值位于赤道以北。经度范围从-180 到 180,正值位于格林威治本初子午线以东(如图 8.1 所示)。在球面上使用度数是完美的,尽管地球不是一个完美的球体,但足够接近,可以方便地指定地球上精确的位置。
图 8.1. 每隔 30°的纬度和经度线。正纬度值位于赤道以北,正经度位于本初子午线以东。

定义
本初子午线是通过伦敦格林尼治皇家天文台的经线。自 1884 年以来,这已被世界上的许多地区认可为参考子午线。赤道是距离南北两极等距离的纬线。
指定经纬度的方法
存在多种方法来指定经纬度坐标。例如,以下都是等效的:
-
十进制度数(DD) —37.8197° N, 122.4786° W
-
十进制度分(DM) —37° 49.182′ N, 122° 28.716′ W
-
度分秒(DMS) —37° 49′ 11″ N, 122° 28′ 43″ W
这些不同的符号基于这样一个事实,即角度被分成分钟,其中角度的一个度由 60 分钟组成,每个分钟由 60 秒组成。因为纬度和经度是度数测量,它们也被分成分钟和秒。要从十进制度数中获取十进制度分,将 DD 值的小数部分乘以 60,例如,60 × 0.8197 = 49.182。因此,37.8197 度等于 37 度和 49.182 分钟。同样,您可以将分钟值的小数部分乘以 60 来获取秒。因为 60 × 0.182 = 10.92,现在您有 37 度,49 分钟,大约 11 秒。
要进行相反方向的转换,将 DMS 转换为 DD,将分钟除以 60,将秒除以 3600,并将这些结果加到小时值上,如下所示(注意舍入误差):

此外,如果未指定方向,南和西的值用负数表示。例如,-122.4786° 等同于 122.4786° W。
要在您的 Python 代码中使用经纬度值,您需要确保它们使用十进制度数格式,并使用正负值而不是 N、S、E 或 W 来指定方向。
然而,由于地球不是一个完美的球体,甚至不是一个完美的椭圆体,这引发了一个复杂的问题。您可能在上几何课时学过,但很快就会忘记,简单的方程可以模拟椭圆体的形状,包括球体。但这些方程假设了一个完美的几何形状,有一个光滑的表面,没有突出和凹陷。如果有一个星球能够完美地形成,那将是一件相当了不起的事情,但我们的地球显然不是。您见过一个磨损的球,比如排球,它已经形成了一个弱点,并且有一个在新球时并不存在的凸起吗?地球不仅有山脉和山谷,而且它有点像那个排球,这确实使得用一组简单的方程描述其表面变得更加复杂。
由于地球表面的这些异常,以及测量精度不同,地球椭球体有多个模型。这些模型被称为基准,每个空间参考系统都基于其中之一。一个广泛使用的全球基准,即世界大地测量系统(World Geodetic System),最后一次修订是在 1984 年。这个基准,简称为 WGS84,用于全球覆盖的数据,包括全球定位系统(GPS)。大多数基准都是为了在更局部化的区域,如大陆甚至更小的区域,模拟地球的曲率。为某一区域设计的基准在其他地方可能效果不佳。例如,1983 年的北美基准(NAD83)不应用于欧洲。
根据所使用的基准数据不同,同一组纬度和经度坐标可以指代略微不同的位置,因为底层的椭球体形状不同。有时,使用两个不同基准数据的坐标之间的差异可以忽略不计,但有时它可能达到数百米。正因为如此,你总是需要知道你的地理数据基于哪个基准。
到目前为止,我们只讨论了三维椭球体,但大多数情况下你真正想要的是二维地图,因为它们对于大多数目的来说更方便。毕竟,很难将地球仪折叠起来放入口袋,或者将其嵌入书中!制图者如何从三维转换到二维?解决这个问题的方法之一是所谓的中断地图,就像图 8.2 中所示的那样。你可能以前见过这些,也许你甚至剪下过一张并弯曲纸张来制作一个地球仪。这有点酷,但在二维形式中,如果陆地不被分割成块并由浪费的空间隔开,地图将更容易使用。这就是投影的作用所在。正如其名所示,它们用于将位置数据投影或转换到不同的坐标系。这些地图投影使用笛卡尔坐标系,因此位置由基于两个垂直轴的 x,y 坐标对指定,就像散点图或线图。棘手的部分是将球体上的坐标转换为二维平面。
图 8.2. 一个中断地图

实际上,存在许多实现这一目标的方法,它们都有各自的优点和缺点。想想将图 8.2 中显示的间断地图的不同部分拉伸,使地图成为一个没有缺口的单个矩形。地理特征显然会变形,尤其是在你必须拉伸得更远的极地附近。无论你如何将地理数据投影到二维,你都会得到扭曲,但扭曲的类型取决于你如何进行转换。根据你计划使用数据的目的,某些类型的扭曲可能是可接受的,而其他类型的则不可接受。图 8.3 展示了将一张纸围绕地球包裹并用于将地理数据转换为 2D 的几种方法。即使有这里展示的方法,纸张的角度也可以改变以获得不同的效果。
图 8.3. 将一张纸围绕地球包裹的两种不同方式,可用于将地理数据投影到二维表面上。左侧的例子是圆柱形,右侧的例子是圆锥形。

某些投影,称为正形投影,可以保持局部形状。例如,玻利维亚和秘鲁边界的的的喀喀湖的形状在地球仪和二维地图之间不会改变。然而,没有任何数学技巧可以保持像整个欧亚大陆这样的大区域的形状。墨卡托投影,包括通用横轴墨卡托(UTM),是正形投影的例子。其他称为等面积投影的投影,保持面积相同,因此格陵兰岛的测量面积不会改变,尽管形状可能会变化。兰伯特等面积投影和加尔-彼得斯投影是这类投影的两个例子。等距投影,如方位等距投影,保持距离和比例尺相同,但仅适用于地图的某一部分,如赤道。你离这条真实线越远,扭曲就越大。图 8.4 展示了不同投影的示例。
图 8.4. 不同类型投影的示例

小贴士
存在多个术语用于使用经纬度坐标值的数据。你可能看到它们被称为具有地理投影,或者看到它们被称为未投影或地理数据。
你为什么应该关心所有这些差异呢?根据你的目的,可能你并不关心。如果我要制作我居住的小镇地图,我可能不会担心这个问题。但如果我要制作我居住的州地图,我可能会关心它看起来是短而胖还是稍微高而瘦,如图 8.5 所示。如果你更关心测量而不是外观呢?让我们考虑一个戏剧性的例子,并思考如果你需要比较哥伦比亚和智利的森林面积会发生什么。坚持使用经纬度是不行的,因为经线在两极会聚,所以一度经线并不代表一个恒定的距离。实际上,一度经线在赤道上的长度大约是 111 公里,但在 45 度纬度上只有大约 79 公里。尽管由于地球不是完美的球体,纬度距离可能会有轻微的变化,但它通常在每度 111 公里左右。因此,每边 100 公里的正方形在哥伦比亚的测量值约为 0.8 平方度,但在智利南端则更接近 0.5 平方度。显然,使用经纬度来比较两个国家的森林面积会给出不准确的结果。相反,你可能会选择一个合适的等面积投影来完成这个目的。
图 8.5. 使用地理坐标(纬度/经度)在左侧显示犹他州的状态,右侧显示 UTM 区 12N。两个示例都使用了 NAD83 基准。

投影与特定的基准无关,因此仅知道数据的投影是不够的。你还需要知道基准,这两个的结合定义了空间参考系统。例如,我得到的犹他州的大部分数据使用 UTM 投影和 NAD83 基准,但我不能安全地假设我收到的所有 UTM 数据都使用 NAD83。它可能很容易是 NAD27 或 WGS84,所以除非我知道投影和基准,否则我没有完整的空间参考系统。如果你不知道这两个组成部分,你可能会将数据映射到错误的位置。我认识一些人,他们无意中将他们的 GPS 设置为显示不寻常的空间参考系统坐标,然后通过写下屏幕上显示的坐标来收集数据。不幸的是,他们的数据因此变得不可用,因为他们不知道 GPS 当时被设置为显示哪个空间参考系统。另一方面,我也认识一些人,他们的数据缺乏空间参考信息,但幸运的是,数据在一个常见的系统中,他们找到了解决方案。如果你正在收集数据,请简化你的生活,在过程的开始就注意这个关键信息,无论它看起来多么无聊。
小贴士
如果您收集地理数据,从开始就知道您的坐标使用什么投影和基准至关重要。如果您不重视这一点,那么您的数据可能最终变得无用,没有人希望这样。
8.2. 使用 OSR 进行空间参考
因为空间参考系统(SRS)非常重要,大多数矢量数据格式都提供了一种方法来存储与数据一起的信息,并且您需要知道如何处理它。在使用空间数据时,一个常见的任务是将数据集从一种空间参考系统转换为另一种,以便它可以与其他数据集一起使用或用于特定分析。例如,上一章中讨论的分析技术只有在几何体都使用相同的 SRS 时才有效。您可能需要在不同 SRS 之间转换的另一个原因是,如果您正在使用需要 Web Mercator 投影来显示数据的在线地图解决方案。
警告
许多 GIS 软件包会即时投影,这意味着它们在显示数据时会自动将数据转换为不同的 SRS。例如,如果您加载了一个使用 Albers 等面积投影的数据集,地图将使用该投影绘制。但是,如果您随后加载第二个使用 UTM 的数据文件,它将被转换为 Albers,以便可以正确地与第一个一起显示。当然,这仅发生在两个数据集都存储了 SRS 信息的情况下,因为没有这些信息,软件不知道该怎么做。此外,这个过程仅更改内存中的内容,不会以任何方式更改存储在磁盘上的内容。尽管这种行为在您使用 GIS 时可能很有帮助,但有时它会导致人们假设数据集使用相同的 SRS,而实际上它们并不相同。
osgeo包包含一个名为 OSR(OGR 空间参考的简称)的模块,用于处理 SRS。本节将向您展示如何使用 OSR 将 SRS 信息分配给您的数据,以便 GIS 软件(包括 OSR)知道如何处理它。您还将学习如何在不同 SRS 之间转换数据,以便将数据转换为特定项目所需的任何 SRS。
8.2.1. 空间参考对象
要与空间参考系统一起工作,你需要一个SpatialReference对象来表示它。如果你已经有一个使用你想要的 SRS 的图层,那么你可以使用GetSpatialRef函数从它那里获取 SRS。一个类似的功能,GetSpatial-Reference,将从几何体中获取 SRS。这两个函数如果图层或几何体没有存储 SRS,都将返回None。
让我们来看看这些 SRS 对象中包含的信息。也许最简单的方法是将其打印出来,这将显示一个格式良好的 SRS 描述,以 WKT 格式呈现,并且不需要导入 OSR 模块。states_48形状文件使用地理坐标系统,或未投影坐标系统,以及 1983 年北美地理基准(NAD83):
>>> ds = ogr.Open(r'D:\osgeopy-data\US\states_48.shp')
>>> print(ds.GetLayer().GetSpatialRef())
GEOGCS["GCS_North_American_1983",
DATUM["North_American_Datum_1983",
SPHEROID["GRS_1980",6378137.0,298.257222101]],
PRIMEM["Greenwich",0.0],
UNIT["Degree",0.0174532925199433]]
你可以判断这不是一个投影 SRS,因为它没有PROJCS条目,只有一个GEOGCS条目。如果你在查看一个投影 SRS,会有更多描述坐标系参数的信息,例如图 8.6 中显示的 UTM 示例。
图 8.6. NAD83 UTM Zone 12N 空间参考系统的已知文本

WKT 并不是 SRS 的唯一字符串表示。我喜欢 PROJ.4 字符串,因为它们特别简洁。例如,这是图 8.6 中显示的 UTM SRS 的 PROJ.4 字符串:
'+proj=utm +zone=12 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs '
The PROJ.4 Cartographic Projections Library 是一个流行的开源库,用于在投影之间转换数据,你可以在trac.osgeo.org/proj/上阅读关于 PROJ.4 定义的详细信息。参见附录 D,了解你可以使用的其他函数,以获取空间参考系统的文本表示。(附录 C 至 E 可在 Manning Publications 网站上在线获取,网址为www.manning.com/books/geoprocessing-with-python。)其中一些结果可能比较冗长;尝试使用Export-ToXML来了解我的意思。
定义
空间参考系统标识符(SRIDs)用于唯一标识 GIS 中的每个空间参考系统、基准以及几个其他相关项目。软件可以使用自己的 ID 集,也可以使用一个公共集,如 EPSG(欧洲石油调查组)代码。这些是 WKT 示例中的AUTHORITY条目。
幸运的是,你不需要打印任何东西来发现一个 SRS 是地理的还是有投影的,因为有两个方便的函数叫做IsGeographic和IsProjected可以提供这些信息。你也可以获取关于 SRS 的其他信息,尽管这样做你需要知道 SRS 的结构。回到图 8.6 查看 WKT。你可以使用GetAttrValue函数来获取与每个关键词(如PROJCS或DATUM)第一次出现相对应的文本,其中关键词不区分大小写。假设utm_sr变量包含了图 8.6 中的 SRS,你可以这样获取投影名称:
>>> utm_sr.GetAttrValue('PROJCS')
'NAD83 / UTM zone 12N'
在 UTM SRS 中有几个AUTHORITY条目。你认为GetAttrValue会返回哪一个?让我们试一试看看:
>>> utm_sr.GetAttrValue('AUTHORITY')
'EPSG'
这并没有告诉你太多,因为每个的第一个值恰好是'EPSG'。GetAttrValue的一个可选参数允许你指定使用其索引返回哪个子项。字符串'EPSG'是第一个子项,但第二个是一个数字,所以尝试获取它:
>>> utm_sr.GetAttrValue('AUTHORITY', 1)
'26912'
为什么它返回的是图 8.6 中显示的最后一个而不是第一个?这是因为项目在 SRS 中嵌套在一起,而这个项目是最少嵌套的,所以它是函数返回的第一个。
如果GetAttrValue只获取带有给定关键字的第一个出现的项,那么如何获取其他项?要获取权威代码或 SRID,将您感兴趣的 SRID 的关键字传递给GetAuthorityCode:
>>> utm_sr.GetAuthorityCode('DATUM')
'6269'
您可以使用GetProjParm并使用附录 D 中的SRS_PP常量之一作为参数来获取具有PARAMETER键的值:
>>> utm_sr.GetProjParm(osr.SRS_PP_FALSE_EASTING)
500000.0
许多其他函数可用于从 SRS 获取信息,其中一些仅适用于某些类型的 SRS。请参阅附录 D 以获取完整列表。
8.2.2。创建空间参考对象
由于您不能总是从图层或几何体中获得适当的空间参考对象,您可能需要创建自己的。因为我喜欢简短的表达方式,我最喜欢的两种方法是:如果存在我想要使用的 SRS 的标准 EPSG 代码,就使用它;或者使用 PROJ.4 字符串。正如您在 UTM 示例中看到的,NAD83 UTM 12N 的 EPSG 代码是 26912,您可以在导入 OGR 并创建一个空的SpatialReference对象后将其传递给ImportFromEPSG函数:
>>> from osgeo import osr
>>> sr = osr.SpatialReference()
>>> sr.ImportFromEPSG(26912)
0
>>> sr.GetAttrValue('PROJCS')
'NAD83 / UTM zone 12N'
您创建的 SRS 与之前提到的 UTM 示例等效。ImportFromEPSG返回的零表示 SRS 已成功导入。有趣的是,如果您使用之前看到的 PROJ.4 字符串:
>>> sr = osr.SpatialReference()
>>> sr.ImportFromProj4('''+proj=utm +zone=12 +ellps=GRS80
... +towgs84=0,0,0,0,0,0,0 +units=m +no_defs ''')
0
>>>
>>> sr.GetAttrValue('PROJCS')
'UTM Zone 12, Northern Hemisphere'
基准名称不再是 SRS 名称的一部分,因为基准在 PROJ.4 字符串中未指定。然而,NAD83 基准使用的 GRS80 椭球体是字符串的一部分,因此所需信息仍然存在(如果您想证明这一点,请打印 WKT 并比较SPHEROID值与图 8.6 中的值)。要包含基准,请将+datum=NAD83添加到 PROJ.4 表示中。
小贴士
您可以在www.spatialreference.org查找 EPSG 代码、WKT、PROJ.4 字符串以及 SRS 的几种其他表示形式。
存在多种不同的函数用于导出 SRS 信息,同样也有多种方法可以将这些信息导入到空间参考对象中,包括从诸如www.spatialreference.org(再次,见附录 D)上的定义等 URL 导入信息。您还可以从 WKT 字符串创建空间参考对象,而无需使用导入函数之一:
>>> wkt = '''GEOGCS["GCS_North_American_1983",
... DATUM["North_American_Datum_1983",
... SPHEROID["GRS_1980",6378137.0,298.257222101]],
... PRIMEM["Greenwich",0.0],
... UNIT["Degree",0.0174532925199433]]'''
>>>
>>> sr = osr.SpatialReference(wkt)
您也可以自己构建空间参考对象,并且有几个特定于投影的函数可以帮助您完成这项工作。让我们从 UTM 扩展到构建美国地质调查局用于下 48 个州的 Albers 圆锥等面积 SRS(图 8.7)。Albers 的特定投影函数如下所示:
SetACEA(stdp1, stdp2, clat, clong, fe, fn)
图 8.7。使用地理坐标和 Albers 等面积投影显示了下 48 个州。

参数顺序为标准平行 1、标准平行 2、中心纬度、中心经度、虚假东西向和虚假南北向。您可以使用此方法构建 USGS Albers 空间参考:
>>> sr = osr.SpatialReference()
>>> sr.SetProjCS('USGS Albers')
>>> sr.SetWellKnownGeogCS('NAD83')
>>> sr.SetACEA(29.5, 45.5, 23, -96, 0, 0)
>>> sr.Fixup()
>>> sr.Validate()
0
创建一个空的 SRS 后,你首先需要给它设置一个名称,然后指定一个基准,最后提供 Albers 投影所需的相关参数。调用Fixup会为缺失的参数添加默认值,并重新排序项目以匹配标准。最后,你需要调用Validate来确保你没有忘记任何东西。在这种情况下,Validate返回零,这意味着一切正常(实际上,这个例子中的许多其他函数也返回了零,但我出于空间考虑省略了返回值)。尝试省略基准并查看调用Validate时会发生什么。在这种情况下,它应该返回 5,这意味着 SRS 已损坏。这是因为 SRS 需要一个基准或椭球体,如果你省略了SetWellKnownGeogCS的调用,这两个都没有指定。如果你通过osr.UseExceptions (True)打开异常处理,那么Validate将抛出一个异常而不是返回一个数字。
8.2.3. 将 SRS 分配给数据
在可能的情况下,将 SRS 信息附加到你的数据集上是个好主意,这样你总是知道它使用的是哪种坐标系。你可以将 SRS 分配给图层和单个几何形状,尽管图层中的所有几何形状共享相同的 SRS。数据源不能分配 SRS,因为单个图层允许有不同的空间参考系统。
你还记得你在第三章中创建新图层的时候吗?CreateLayer函数的一个参数是空间参考对象。这个参数的默认值是None,因为 OGR 无法自己确定数据使用的是哪种 SRS。如果你有一个空间参考对象,你需要在创建新图层时提供它,因为你没有添加 SRS 到现有图层的函数。

现在,新的县形状文件以及其中包含的所有几何形状都将知道它们使用的是 UTM SRS(EPSG 26912)。当然,你必须使用 UTM 坐标创建几何形状。将 SRS 分配给图层并不会神奇地将所有数据转换为该坐标系。它只是提供信息,所以如果你分配了一个不同于你使用的 SRS,你基本上是在撒谎并造成混淆,因为没有人知道如何处理这些数据。
如果你正在处理单个几何形状而不是图层,你可能想要为一个几何形状分配一个 SRS。你可以使用AssignSpatialReference方法来完成这个操作:
geom.AssignSpatialReference(sr)
再次强调,这并不强迫几何形状使用分配的空间参考,而是提供了关于 SRS 的信息,无论是对是错。
8.2.4. 几何形状的重投影
如果你需要你的数据使用与它们当前使用的不同 SRS(空间参考系统),你需要将它们重新投影到新的 SRS。我通常在从某处获得新的数据集但该数据集不使用我通常使用的 SRS 时这样做。如果我想使用新数据与现有文件一起使用,我需要将其投影,以便 SRSs 匹配。最近在使用需要使用 Web Mercator 坐标系的软件时,我也需要这样做,但我的原始数据使用的是 UTM。
你有两种不同的方式来投影一个几何形状。一种假设几何形状已经分配了一个 SRS,另一种则没有。我们将探讨这两种方法,但首先让我们获取一些可以工作的数据。这本书的数据有一个名为 ne_110m_land_1p.shp 的 shapefile,它包含一个多边形,表示世界陆地,而 ospybook 模块有一个名为get_shp_geom的函数,可以从 shapefile 中提取第一个几何形状。你可以使用这些来获取全球多边形,为了保险起见,为什么不也创建一个包含埃菲尔铁塔经纬度的点呢?

由于 WGS84 非常常见,OSR 模块有一个包含该地理坐标系统 WKT(Well-Known Text)的常量,你在这里使用它来向tower几何形状添加 SRS。world几何形状也与 WGS84 SRS 相关联,因为来自 shapefile 的它。如果你绘制多边形,你应该看到类似于图 8.8 的东西。
图 8.8. 使用地理坐标绘制的世界陆地

因为两个几何形状都知道它们的 SRS,你可以使用它们的TransformTo函数重新投影它们,你只需要提供目标 SRS。我们将使用这个方法将它们都转换成 Web Mercator 投影。然而,某些点,如北极和南极,可能无法成功重新投影。当将world几何形状转换为 Web Mercator 时就是这种情况,因此你还需要使用内置模块设置一个环境变量,告诉它跳过这些点是可以的。如果没有这个,世界几何形状将无法成功转换,你将得到一个错误消息,说“ERROR 1:完全重新投影失败,但如果定义 OGR_ENABLE_PARTIAL_REPROJECTION 配置选项为 TRUE,则部分重新投影是可能的。”你可以通过导入gdal模块并使用其SetConfigOption方法来修复这个问题:

如你所见,埃菲尔铁塔的坐标不再在经纬度值范围内,当绘制时,世界几何形状应该看起来像图 8.9。注意,世界和铁塔几何形状本身被更改,而不是返回新的几何形状,这与我们之前查看的许多其他函数的行为不同。
图 8.9. 使用 Web Mercator 坐标绘制的世界陆地

如果你在一个没有分配空间参考(SRS)的几何体上使用TransformTo,它将不会改变,并且你会得到一个错误代码 6。然而,如果你知道它的 SRS,你仍然可以转换它。为此,你需要一个CoordinateTransformation对象,你可以通过使用源和目标空间参考来创建它。例如,让我们假设world几何体没有空间参考数据,并使用这种技术将其从 Web Mercator 转换为 Gall-Peters。这次你将使用Transform函数,它需要一个CoordinateTransformation对象:
>>> peters_sr = osr.SpatialReference()
>>> peters_sr.ImportFromProj4("""+proj=cea +lon_0=0 +x_0=0 +y_0=0
... +lat_ts=45 +ellps=WGS84 +datum=WGS84
... +units=m +no_defs""")
>>>
>>> ct = osr.CoordinateTransformation(web_mercator_sr, peters_sr)
>>> world.Transform(ct)
>>> vp.plot(world)
现在你的图表应该看起来像图 8.10。如果你想要反向操作,从 Gall-Peters 转换到 Web Mercator,你需要在创建坐标转换时交换参数的顺序。
图 8.10.使用 Gall-Peters 坐标绘制的世界陆地

更改基准
有时你可能还需要更改数据集使用的基准数据。例如,有时我会收到使用 NAD27 基准数据的资料,然后我需要将其转换为 NAD83,以便与我的其他数据匹配。如果存在必要的信息,TransformTo和Transform函数可以在基准之间进行转换。
由于并非总是存在用于在基准之间进行转换的数学方程,因此 GIS 经常使用称为网格偏移文件的数据文件来帮助转换。这些文件包含将坐标从一个基准准确转换到另一个基准所需的信息。如果 OSR 模块能在你的系统上找到它们,它将使用适当的文件进行基准转换。不过,你必须确保你的源和目标空间参考都包含基准信息。图 8.11 展示了两个使用相同投影和椭球体的空间参考示例,但其中一个包含基准信息,而另一个则没有。尽管两者都是有效的空间参考,但只有包含基准的那个才能用于基准转换。有关将网格偏移文件提供给 OSR 的更多信息,请参阅附录 D。
图 8.11.使用相同椭球体但只有一个指定基准的两个空间参考示例

如果你没有用于数据转换的适当网格偏移文件,你可以为源和目标空间参考设置towgs84参数。这些参数描述了从特定基准到 WGS84 基准的大致转换。如果你不知道你需要哪些参数,你可以在www.epsg-registry.org上查找。确保你将搜索类型设置为坐标转换,选择一个地理区域,并输入你感兴趣的基准名称。我在美国搜索了nad27,然后选择了nad27 to WGS 84 (4)结果,因为它被描述为适用于下 48 个州。这给了我 x,y 和 z 平移值分别为-8,160 和 176。一旦你有了适当的参数,你可以使用SetTOWGS84将它们添加到你的空间参考中:
sr = osr.SpatialReference()
sr.SetWellKnownGeogCS('NAD27')
sr.SetTOWGS84(-8, 160, 176)
如果你能获取到网格偏移文件,那么依赖网格偏移文件会更容易。
8.2.5. 重新投影整个层
目前还没有一个函数可以一次性投影整个层,但这并不难做。在创建新层之后,你需要遍历原始层中的每个要素,获取几何形状并进行转换,然后将包含转换几何形状的要素插入到新层中。很可能,你也会想要保留所有的属性字段,因此你需要在创建新层时复制原始层的字段定义。以下列表展示了这样一个简单示例,它假设新层尚不存在,并且正在重新投影的层包含点几何形状。
列表 8.1. 投影点层

列表中这段代码首先创建一个输出空间参考。然后它打开一个用于写入的数据源,并获取现有层以进行重新投影。接下来,创建一个新层,并将输入层的字段定义复制到输出层。如果你不这样做,那么你无法将属性值复制到新层中。新层准备就绪后,然后遍历原始层中的每个要素,并为每个要素获取其几何形状,使用列表开头创建的空间参考进行转换。请注意,你没有提供输入空间参考,而是假设输入层与其关联了一个空间参考。在转换几何形状后,你将其添加到一个新要素中,将所有属性值复制到这个要素中,然后使用它将数据插入到新层中。
8.3. 在 pyproj 中使用空间参考
如前所述,PROJ.4 地图投影库是一个用于在 SRS 之间转换数据的 C 库。它被包括 OSR 在内的各种开源项目使用。然而,您不需要安装 GDAL 和 OGR 的所有内容来利用 Python 中的 PROJ.4,因为 pyproj 模块为 PROJ.4 提供了一个 Python 包装器。与 OSR 一样,这个模块不是处理几何形状,而是处理坐标值的列表,这些坐标值可以作为 Python 列表、元组、数组、NumPy 数组或标量(NumPy 是一个用于处理大型数组的 Python 模块,您将在第十一章中了解更多关于它的信息)提供。如果您有一组坐标在文本文件中,pyproj 模块中的函数将是一个将它们转换为其他坐标系统的理想方式。
提示
您可以在 code.google.com/p/pyproj/ 找到 pyproj 模块的在线文档和下载。
8.3.1. 在空间参考系统之间转换坐标
使用 pyproj 在空间参考系统之间转换坐标有几种不同的方法。您可以使用 Proj 类在地理坐标和投影坐标之间进行转换,或者使用模块级别的 transform 函数在两个空间参考系统之间进行转换。让我们从将埃菲尔铁塔的坐标从纬度和经度转换为 UTM 区 31N 开始。首先需要做的是使用 PROJ.4 字符串初始化一个 Proj 对象,并指定 UTM 空间参考系统,然后使用它来转换坐标。语法可能看起来有点奇怪,因为您不需要在 Proj 对象上调用特定的函数来完成转换:

在这里,您向 utm_proj 传递单个 x 和单个 y 坐标,然后它返回一个 x 和一个 y。您也可以传递 x 值的列表和 y 值的列表(其中 x[i] 和 y[i] 是一个坐标对),然后您将返回两个列表。
要进行相反方向的转换,即从投影坐标到地理坐标,将可选的 inverse 参数设置为 True 并传入 UTM 坐标。如果您使用刚刚计算出的 UTM 坐标,您将得到原始的纬度和经度值,但会有轻微的四舍五入误差:
>>> x1, y1 = utm_proj(x, y, inverse=True)
>>> print(x1, y1)
2.294693999999985 48.85809299999999
初始化 Proj 对象
您可以使用 PROJ.4 字符串、与 PROJ.4 字符串中的参数相对应的参数或 EPSG 代码来初始化 Proj 对象。例如,这些都是等效的:
p = pyproj.Proj('+proj=utm +zone=31 +ellps=WGS84')
p = pyproj.Proj(proj='utm', zone=31, ellps='WGS84')
p = pyproj.Proj(init='epsg:32631')
如果你需要在不同投影坐标系统之间进行转换,那么使用 pyproj 的transform函数会更容易。此外,如果你想在大地基准之间进行转换,也必须使用transform。让我们使用纽约市自由女神像的 UTM 坐标来比较 WGS84 和 NAD27 大地基准之间的差异。transform函数需要四个必需参数:源 SRS、目标 SRS、x 和 y,其中空间参考信息包含在Proj对象中。此示例将坐标从 WGS84 大地基准转换为 NAD27。这两组坐标都使用 UTM Zone 18N 投影。

比较输入和输出数字,看起来这两个大地基准在东西方向上相差大约 30 米,但在纽约市,南北方向的差异超过 200 米。如图图 8.12 所示,如果将 NAD27 坐标当作 WGS84 坐标使用,自由女神像就会被放在水中而不是在自由岛上。
图 8.12. 黑色圆点显示了如果将 NAD27 坐标当作 WGS84 坐标处理,自由女神像将被放置的位置。

这个例子很好地说明了为什么你应该始终了解你的大地基准。
8.3.2. 大圆计算
地球上两点之间的最短距离被称为大圆距离。由于旅行者不喜欢走不必要的远路,这些距离在几个世纪以来对导航一直很重要。你可以使用 pyproj 来获取两组经纬度坐标之间的距离,以及它们之间大圆线的起始和终止方位角。为了说明如何做到这一点,让我们看看洛杉矶和柏林之间的距离(图 8.13)。你需要做的第一件事是实例化一个Geod类的对象,并指定你想要使用的椭球体。有关可选列表,请参阅前面提到的 pyproj 网站。一旦你有了Geod对象,将起始和结束坐标以十进制度数传递给它的inv函数,以获取正向方位角、反向方位角和距离:
>>> la_lat, la_lon = 34.0500, -118.2500
>>> berlin_lat, berlin_lon = 52.5167, 13.3833
>>> geod = pyproj.Geod(ellps='WGS84')
>>> forward, back, dist = geod.inv(la_lon, la_lat, berlin_lon, berlin_lat)
>>> print('forward: {}\nback: {}\ndist: {}'.format(forward, back, dist))
forward: 27.23284045673668
back: -38.49148498662066
dist: 9331934.878166698
图 8.13. 洛杉矶和柏林之间的大圆路径

这些结果究竟意味着什么?如果你从洛杉矶(传递给inv的第一个坐标集)出发,以 27.2328 度的方位角前进,旅行 9,331,935 米,你会发现自己到了柏林。或者,如果你想反方向旅行,从柏林以-38.4915°的方位角出发,走同样的距离,你将到达洛杉矶。
你也可以找出如果你沿着某个方位角走一段距离,最终会到达哪里。为此,将起始坐标、方位角和以米为单位距离传递给 fwd 函数。这将返回结束时的经度、纬度和返回起点的方位角。例如,如果你输入柏林的坐标、反向方位角和刚才得到的距离,它应该会输出洛杉矶的坐标,以及从洛杉矶到柏林的方位角:
>>> x, y, bearing = geod.fwd(berlin_lon, berlin_lat, back, dist)
>>> print('{}, {}\n{}'.format(x, y, bearing))
-118.25000000000001, 34.05000000000002
27.23284045673668
你也可以通过传递起始和结束坐标以及所需点的数量给 npts 函数,来获取大圆线上等间距坐标的列表:
>>> coords = geod.npts(la_lon, la_lat, berlin_lon, berlin_lat, 100)
>>> for i in range(3):
... print(coords[i])
...
(-117.78803196383676, 34.78972514500416)
(-117.31774994946879, 35.52757560403803)
(-116.83878951054419, 36.2634683783333)
我使用 npts 函数生成了用于绘制洛杉矶和柏林之间大圆路径的点,这些点在图 8.13 中展示。
8.4. 摘要
-
存在几种主要的地图投影类型,每种都用于保留数据的一个特定属性。确保你选择一个适合你使用的投影。
-
总是要确保你知道你的数据集的投影和基准。
-
如果你不知道当前使用的是哪个空间参考系统,你不能将数据转换到另一个空间参考系统。
-
你可以使用 OSR 或 pyproj 在空间参考系统之间转换数据。
-
使用 pyproj 进行大圆计算。
第九章. 读取和写入栅格数据
本章涵盖
-
理解栅格数据基础知识
-
介绍 GDAL
-
读取和写入栅格数据
-
重采样数据
如果你有一个由连续数据(如高程或温度)组成的地理数据集,那么它很可能是一个栅格数据集。光谱数据,如航空照片和卫星图像,也是以这种方式存储的。这些类型的数据库不假设对象之间存在严格的边界,就像矢量数据库那样。想想数字照片,每个像素可以比相邻像素略有不同的颜色。像素值可以连续变化这一事实,使得照片看起来比只有几种颜色可选要好得多。这种特性也使得栅格适合于连续变化的数据,如高程。
与矢量数据集相比,处理栅格数据集有所不同。你拥有的是像素集合,这本质上是一个大型的二维或三维数字数组。栅格数据集由波段组成,而不是层,每个波段都是一个二维数组。波段的集合成为一个三维数组。这是一种不同的空间数据处理方式,如果你有数学恐惧症,这个描述可能会让你觉得它很可怕。但很快你就会看到,其实并不难。
在本章中,你将学习栅格数据的基本理论,包括保持其可管理大小的技巧。然后,你将了解如何使用 Python 和 GDAL 将这些数据集读入内存,以及如何将它们写回磁盘。最简单的情况是一次性读取和写入整个数据集,但有时你不需要整个空间范围,有时内存量是限制因素,因此你还将学习如何处理数据的空间子集。在读取或写入时,还可以更改像素大小,你将看到如何做到这一点。
9.1. 栅格数据简介
如前所述,栅格数据集可以存储几乎任何类型的数据。但这并不意味着总是使用栅格是一个好主意。那些可以被看作点、线或多边形的对象通常最好保持为矢量。例如,国家边界非常适合使用多边形矢量数据集。同样的数据也可以存储在栅格中,但会占用更多的磁盘空间,而且边界线不会是平滑的。你也不能使用矢量数据分析函数,如缓冲区和相交。虽然使用栅格技术仍然可以实现这些功能,但在此情况下坚持使用矢量会更好。
当值连续变化而不是在明确界定的边界上变化时,栅格是一个完美的选择。这包括常见的数据集,如高程、坡度、方位、降水量、温度和卫星数据,但它还可以包括许多其他事物。这可能包括蒸散量、道路距离、土壤湿度或你可能需要作为连续变量建模的任何其他事物。有时你需要将通常的矢量数据表示为栅格。例如,河流和溪流是矢量数据的良好候选者,但建模水流累积或地下水流动(例如,追踪水源中污染物流动)则需要栅格数据。
此外,栅格数据集不必包含连续数据。事实上,我看到许多由分类数据(如土地覆盖类型)组成的栅格。其中一个原因是栅格被用于最初产生这些数据集的模型中。例如,土地覆盖模型通常使用卫星图像的可见光和不可见光波长,以及辅助数据(如高程)。由于输入是栅格,模型输出是栅格,因此保持这种形式是有意义的。这也使得数据作为其他基于栅格的模型的输入变得容易使用。
其他例子包括视域分析,这种分析在确定从某个位置可以看到什么时会考虑拓扑结构。也许滑雪胜地会使用这种方法来决定餐厅的最佳位置,以便获得最佳景观,我知道有一个案例中这种分析被用来确定地面鸟巢是否对停在电线上的鹰可见(未发表,但可参见 Hovick 等人^([1])的相关研究)。说到野生动物,栅格数据也可以用于栖息地建模,这可能纯粹是为了知识,或者为了帮助选择保护区。你可能认为高程是一个相当静态的数据集,但我知道一些研究人员使用地面激光(以激光雷达系统的形式)来创建河床的高程模型,然后在洪水事件之后做同样的事情,以便他们可以比较前后高程剖面(Schaffrath 等人^([2]))。实际上,可能性是无限的。
¹
Hovick, T. J., Elmore, R. D., Dahlgren, D. K., Fuhlendorf, S. D., Engle, D. M. 2014. 评论:人类结构对野生动物的负面影响证据:关于松鸡生存和行为的评论。应用生态学杂志,51: 1680–1689. DOI: 10.1111/1365-2664.12331.
²
Schaffrath, K. R., P. Belmont, 和 J.M Wheaton. 2015. 地貌尺度地貌变化检测:量化空间变量不确定性并规避遗产数据问题。地貌学,250: 334-348. DOI: 10.1016/j.geomorph.2015.09.020.
现在让我们谈谈栅格数据集的细节。你可能能想象一个数字照片是一个像素的二维数组。实际上,这就是我们讨论照片维度时所谈论的——那个二维数组中的行数和列数。这就是栅格数据集,只不过它们并不限于二维。它们可以有一个以波段形式存在的第三维度。数字照片也有多个波段,尽管你通常不会这样想。但它们分别对应于红光、绿光和蓝光波长。如果你曾经创建过网页并使用十六进制符号指定颜色,那么你对此概念就很熟悉,其中前两个数字对应于红色,后两个对应于绿色,最后两个对应于蓝色,或者 RGB 符号,其中为这些颜色中的每一个提供单独的数字。
此外,就像照片一样,数据集中的每个波段都有相同的行数和列数,因此一个波段的像素与另一个波段的像素在相同的空间位置上。如果照片中每个单独颜色的像素没有正确对齐,我想结果看起来会相当模糊。
显然,并非所有栅格数据集都是照片,因此像素值不必与颜色相对应。例如,在数字高程模型(DEM)中的像素值对应于高程值。通常,DEM 数据集只包含一个波段,因为高程是创建有用数据集所需唯一的值。图 9.1 显示了犹他州的单波段栅格土地覆盖图,其中每个唯一的像素值代表不同的土地覆盖分类。此数据集包含离散数据,而不是连续数据。
图 9.1. 土地覆盖分类图,其中每个唯一的像素值对应于特定的土地覆盖分类

注意:打印书籍读者:彩色图形
本书中的许多图形以彩色查看最佳。电子书版本显示彩色图形,因此在阅读时应参考。要获取免费电子书(PDF、ePub 和 Kindle 格式),请访问www.manning.com/books/geoprocessing-with-python注册您的印刷版书籍。
卫星图像,另一方面,包含了各种波长的测量,其中许多波长肉眼是看不见的。虽然图像可能包含对应可见红、绿和蓝波长的波段,但也可能有红外或热辐射的波段。如图 9.2 所示图 9.2 中的假彩色图像是通过显示红外波段与可见光一起创建的。此图展示了卫星图像作为栅格数据的另一种用途。左侧的图像是使用可见光波长创建的,就像传统照片一样。同时,相机还捕捉到了近红外波长作为另一个波段,并与可见的红、绿波段一起用于创建右侧的假彩色图像。近红外波段对生长的植被来说更亮,显示为红色,因此红色区域是植被。这种波段组合对于监测植被很有用。体育场内的草坪和体育场外的练习场在自然色彩图像中都是绿色,看起来像草地,但在假彩色图像中,体育场内的草坪是深灰色,所以它必须是人工的。然而,体育场外的练习场是鲜红色,所以它们必须是草地。
图 9.2. 麦斯威尔体育场(新英格兰爱国者队的主场)在马萨诸塞州福克斯伯勒的两张图像。在自然色彩图像的左侧,体育场内的草坪看起来像草地,而在假彩色图像的右侧看起来是灰色,这清楚地表明它实际上是人工的。另一方面,练习场在自然色彩图像的左侧也看起来像草地,但在右侧的图像中是红色,这表明它们确实是草地。

让我们再拿照片做一个比较。虽然你用手机拍摄的照片可能带有地理标签,意味着图像中的元数据指定了你拍照时的位置,但每个像素并不对应地面上的一个特定位置。对于你拍摄的大多数照片来说,这甚至都不合理,但如果你是从飞机上拍摄的,直视下方呢?如果你有适当的空间信息,这样的照片可以叠加到地图上。仅有地理标签的坐标是不够的,即使你知道坐标对应照片的哪个部分,比如角落或中心。例如,想象一下,如果你在地面相对较近的塞斯纳飞机上拍照,而不是在更高的 747 飞机上拍照,你的照片看起来会有多不同。在第二种情况下,即使照片是用相同的相机拍摄的,并且有相同行和列的数量,照片覆盖的区域也会更大。区别在于像素大小或单个像素覆盖的地面面积。从塞斯纳飞机上拍摄的照片中的每个像素覆盖的面积会比 747 照片中的像素小。如果你知道它们各自覆盖了多少面积,以及其中一个的坐标,你就可以找出如何拉伸照片,使其叠加到地图上。这是假设你的相机正好直指下方,所以像素没有向一侧倾斜。这也假设你将事物完美对齐,使得照片的顶部正好朝北,尽管你可以旋转图像来补偿这一点。
了解像素大小对于你想要将照片叠加到地图上非常重要,但你显然还需要相应的坐标。对于矢量数据,知道空间参考系统就足够了,因为每个要素的坐标都存储在顶点中。给定空间参考系统(SRS),每个顶点都可以放置在正确的位置,通过它们之间画线,你就得到了你的几何形状。栅格数据集不使用顶点,而是通常使用一组坐标,即像素大小,以及数据集旋转的量来确定图像其余部分的坐标。这被称为仿射变换,是地理参考栅格数据集的常见方法,尽管它不是唯一的方法。这组坐标通常是指图像的左上角,被称为原点。对于简单(且常见)的情况,即数据集的顶部朝北的栅格,你只需要这些坐标和像素大小就可以找到图像中任何像素的坐标。你所需要做的就是计算出原点的偏移量,将这些偏移量乘以像素大小,然后加到原点坐标上。图 9.3 展示了如何获取第五列和第四行像素的左上坐标。第一行和第一列的偏移量为 0,所以在这种情况下,你的偏移量是 4 和 3。要获取经度坐标,将像素宽度乘以 4,得到跨越这四列的距离。然后将这个距离加到原点的经度坐标上,就完成了。你可以用同样的方法获取纬度坐标,但使用的是行偏移量而不是列。正如你所看到的,确保原点坐标正确和使用正确的 SRS 极其重要,否则你无法计算数据集任何部分的坐标。
图 9.3. 一个示例,展示如何获取第五列和第四行像素的左上坐标

如你所注意到的,照片中包含的像素越多,该照片所需的磁盘空间就越多。同样,栅格数据集可以在磁盘和 RAM 中占用大量空间,因此你应该确保你没有使用比必要的更小的像素或更大的数据类型。例如,如果你的数据只精确到 10 米,那么拥有 1 米像素是没有意义的,因为你的 10 米块内的较小像素都将具有相同的值。作为比较,你可能见过比高端数码单反相机像素更多的紧凑型数码相机。尽管如此,单反相机仍然能拍出更好的照片,因为收集了更高质量的数据。更多的像素不能替代高质量数据或有效分辨率。它们不仅不能提高你的数据,所有这些额外的像素还会极大地增加文件的大小。行和列的数量加倍并不会使图像的大小加倍。相反,它会使其四倍增加!例如,一个 250 行 250 列的图像将有 250 x 250 = 62,500 像素,而一个 500 行 500 列的图像将有 250,000 像素。
你为数据选择的数据类型在存储空间方面也很重要。例如,如果你的像素值都落在 0 到 254 的范围内,那么你应该使用字节数据类型(254 是字节能持有的最大值)。在这种情况下,每个像素将占用一个字节,即 8 位磁盘空间,无论其值如何,除非你使用压缩。如果你将相同的数据存储为 32 位整数,每个像素将占用之前四倍的空间。你会占用四倍多的内存,但没有任何好处。对于小数据集,这可能不是很重要,但对于大数据集来说,这确实很重要。
如果栅格数据可以如此之大,并且处理需要一段时间,那么它们如何在合理的时间内绘制到你的屏幕上呢?这就是概览层发挥作用的地方。你可能也听说过它们被称为金字塔层或低分辨率数据集(因此,几种类型的概览都有.rrd 扩展名)。概览层是低分辨率层——它们是覆盖与原始图相同的区域的栅格,但被重采样到更大的像素大小。栅格数据集可以有许多不同的概览,每个概览都有不同的分辨率。当你放大并查看整个图像时,会绘制出粗分辨率层。因为像素很大,所以该层不需要太多内存,并且可以快速绘制,但你在这个缩放级别上无法看出差异。当你放大时,会绘制出更高分辨率的层,但只需要加载和显示你正在查看的部分。如果你放大足够多,你会看到原始像素,但由于你只查看图像的一小部分,它仍然可以快速绘制。
图 9.4 展示了这是如何工作的。每个后续的概览层像素大小是前一个的两倍。当查看整个图像时,所有分辨率看起来都一样,但当你放大到更小的区域时,你可以看到差异。图中的左上角图像是圣地亚哥港的全分辨率(1 x 1 米像素)图像。你可以看到单个汽车、船只和树木。中间顶部的图像是第一组概览,像素边长为 2 米。右上角的图像像素边长为 4 x 4 米。在底部行,像素边长为 8、16 和 32 米。如果你放大到可以看到整个圣地亚哥,你无法区分第一张和最后一张图像,但最后一张图像是全分辨率图像的一小部分,并且绘制得更快。一个很好的经验法则是创建分辨率逐渐降低的概览层,直到最粗糙的一层在一个维度上不超过 256 像素。
图 9.4 示例展示了每个概览图都比前一个概览图分辨率更低。左上角的图像(A)是全分辨率,每个后续图像使用的像素大小是前一个图像的两倍。最后一个图像(F)只有在放大很多倍时才看起来不错,但在这个比例下它比全图绘制得快得多。

顺便提一下,无处不在的在线地图服务使用相同的技巧来显示航空摄影,只不过这些降低分辨率的层是以单个瓦片集合的形式存储的。你的浏览器下载覆盖你正在查看区域的所需瓦片,当你放大时,你会得到更高分辨率且覆盖面积更小的瓦片,直到最终分辨率足够高,你可以看到你的房子或汽车。
影响栅格数据集访问速度的另一个方面是它们在磁盘上的存储方式。栅格由块组成,这与数据在磁盘上的排列方式有关。正如你所预期的,每种格式都以不同的方式做这件事。(如果不是这样,它们实际上就不会是不同的格式,对吧?)像素块在磁盘上物理上相邻存储,因此可以一起高效地访问。可能同一图像的其他块存储在磁盘的另一部分;这是通过磁盘碎片整理来解决的那种问题。物理上靠近的数据访问速度更快,就像你从同一书架上而不是从两个不同的书架上取两本书一样快。如果你需要读取或写入数据,使用块是最有效率的。例如,GeoTIFFs 有瓦片和未瓦片两种格式。未瓦片的 GeoTIFFs 将每一行的像素存储为一个块,但瓦片式的使用的是像素的正方形集合,256 x 256 是一个常见的块(或瓦片)大小。从瓦片 GeoTIFF 中读取数据时,以对应块的方形块读取更快,但处理整个行时,未瓦片的 GeoTIFF 更快。
你可能也在想关于数据压缩的问题,因为这在数字照片的.jpeg 格式中经常使用,并且可以显著减小文件的大小。这当然是可以的,并且根据数据格式,有多种压缩类型可用。你可能听说过有损与无损压缩。有损压缩在压缩数据的过程中会丢失信息。当保存.jpeg 文件时,你可能注意到了压缩质量选项。质量越高,你损失的数据越少,最终图像看起来越好。然而,.png 格式是无损的,这就是为什么在保存这类文件时不会询问压缩质量。这并不意味着数据不能被压缩。这意味着在压缩过程中你不会丢失任何数据,并且图像可以被完美地重建为原始未压缩的数据集。如果你计划压缩数据但还需要对其进行分析,请确保选择一个无损算法。否则,你的分析将不会在实际的像素值上操作,因为其中一些已经丢失了。GeoTIFF 是一种流行的无损格式。
如果你打算使用栅格数据,还有一个重要的概念你需要理解,那就是重采样方法之间的区别。当栅格被重采样到不同的单元格大小或重新投影到另一个空间参考系统时,像素之间没有一对一的映射,因此需要计算新的像素值。最简单、最快的方法是称为最近邻的方法,即使用最接近新像素的旧像素的值。另一种可能的算法,如图 9.5 所示,是取四个最近像素的平均值。还有其他几种方法使用多个输入像素,例如双线性,它使用四个最近输入像素的加权平均值。
图 9.5. 简单重采样示例,其中使用四个像素的平均值来计算覆盖与四个较小像素相同范围的新的像素值。

在处理包含离散值的集合数据时,例如图 9.1 中的土地覆盖分类,你应该始终使用最近邻重采样。否则,你可能会得到一个与分类不对应的价值,或者是一个表示完全无关分类的数字。另一方面,连续数据非常适合其他重采样方法。例如,取平均海拔值是很有意义的,而且使用这种方法得到的输出会比使用最近邻方法更平滑。
9.2. GDAL 简介
既然理论部分已经讲完,让我们学习如何使用 GDAL 来处理这些数据集。栅格数据存在多种不同的文件格式,GDAL 是一个极为流行且健壮的库,用于读写其中许多格式。GDAL 库是开源的,但拥有宽松的许可证,因此许多商业软件包也使用它。不幸的是,它们并不一定使用它来读取尽可能多的格式,所以我经常有学生和同事问我能否将他们的数据转换成他们软件可以读取的格式。每次有人问我这个问题,我都会指向 GDAL 及其命令行工具。他们通常对免费软件能做什么感到惊讶,如果他们知道如何编写自己的代码,他们可以做更多的事情。
GDAL 库因其能够读写众多不同格式而广为人知,但它还包含一些数据处理功能,例如邻近度分析。在许多情况下,你仍然需要编写自己的处理代码,但对于许多类型的分析来说,这相对容易。有一个名为 NumPy 的 Python 模块,专为处理大量数据数组而设计,你可以使用 GDAL 将数据直接读入 NumPy 数组。然而,在你需要以任何方式操作数据之后,使用 NumPy 或与之兼容的其他模块,你可以将数组写回磁盘作为一个栅格数据集。这是一个相当无痛苦的过程。你将在第十一章 chapter 11 中学习如何直接与 NumPy 数组工作。
我只经常使用一小部分栅格格式,我想大多数这本书的读者也是这样,但尽管如此,GDAL 有超过 100 种不同的格式驱动程序可用,可以在www.gdal.org/formats_list.html上在线找到。每个驱动程序都处理读取和写入特定的数据格式。你可能不会在 GDAL 的版本中找到所有这些驱动程序,但它们确实存在。如果你需要一个特定的驱动程序,找不到带有该特定驱动程序的预编译 GDAL 二进制文件,你总是可以编译自己的定制版本的 GDAL(尽管这可能很棘手,尤其是如果你没有这方面的经验)。然而,并非所有驱动程序都支持相同的操作。虽然许多支持读取和写入,但有些是只读的,还有一些不允许你修改现有的数据集,尽管你可以创建新的数据集。假设驱动程序支持你打算进行的操作,你将以相同的方式使用所有驱动程序。我的大多数示例将使用 GeoTIFFs,但它们也可以与其他格式很好地工作。
GDAL 数据集的基本结构如图图 9.6 所示,与您对栅格数据集的一般了解相匹配。每个数据集包含一个或多个波段,这些波段反过来包含像素数据和可能的概览。地理参考信息包含在数据集中,因为所有波段都使用相同的信息进行此操作。
图 9.6. GDAL 数据集的基本结构。每个数据集包含一个或多个波段,这些波段反过来包含像素数据和可能的概览。地理参考信息包含在数据集中,因为所有波段都使用相同的信息进行此操作。

为了说明如何使用 GDAL 读取和写入栅格数据,让我们从一个示例开始,该示例将三个单独的 Landsat 波段合并成一个堆叠的图像,如图图 9.7 所示。Landsat 项目是美国地质调查局(USGS)和国家航空航天局(NASA)的联合倡议,自 1972 年以来一直在全球范围内收集中分辨率卫星影像。Landsat 影像由 USGS 作为 GeoTIFF 集合分发,每个收集到的波段一个。除了波段 6(热红外)和 8(全色)之外,每个波段都具有 30 米的分辨率,并且由于它们来自相同的 Landsat 场景,因此具有相同的尺寸。这使得事情变得简单,因为波段直接堆叠在一起,无需调整。代码清单 9.1 展示了如何创建具有相同尺寸的三波段数据集,然后将波段 3、2 和 1 复制到其中。这三个波段分别对应于可见光的红色、绿色和蓝色波长,因此按照这个顺序将产生一个 RGB(红色、绿色、蓝色)图像,看起来几乎与您自己的眼睛看到的一样。图 9.7 显示了黑白中的单个红色、蓝色和绿色波段,以及由此产生的三波段自然色彩图像。如果您在 GIS 中预览您的图像,它们可能看起来与此类似,因为 GIS 很可能会拉伸它们。如果您在其他地方查看它们,与这个相比,它们可能会显得褪色。我选择包含拉伸的图像,因为在打印页面上您几乎看不到任何细节。让我们看一下以下代码。
图 9.7. 黑白显示红色(A)、绿色(B)和蓝色(C)Landsat 波段,您可以看到它们彼此之间略有不同。部分 D 显示了这三个波段堆叠成一个 RGB 图像,就像代码清单 9.1 中创建的那样。

代码清单 9.1. 将单个栅格波段堆叠成一个图像


这里发生了什么?好吧,你首先做的事情是导入 gdal 模块。然后你设置你的当前目录并指定哪个文件对应于哪个 Landsat 波段。然后通过传递文件名到 gdal.Open 打开包含第一个波段的 GeoTIFF 文件。你还在数据集中获取了第一个和唯一的波段句柄,尽管你还没有读取任何数据。注意,你使用索引 1 而不是 0 来获取第一个波段。当你使用 GetRasterBand 时,波段编号总是从 1 开始,尽管我经常忘记并使用 0,然后不得不修复我的错误。无论如何,在创建输出图像之前你需要这个波段对象,因为它包含你需要的信息。
小贴士
记住波段索引从 1 开始而不是 0。
接下来,你创建一个新的数据集以将像素数据复制进去。你必须使用驱动程序对象来创建新数据集,所以你找到 GeoTIFF 驱动程序然后使用它的 Create 函数。这是该函数的完整签名:
driver.Create(filename, xsize, ysize, [bands], [data_type], [options])
-
filename是要创建的数据集的路径。 -
xsize是新数据集中的列数。 -
ysize是新数据集中的行数。 -
bands是新数据集中的波段数。默认值为 1。 -
data_type是新数据集中存储的数据类型。默认值为GDT_Byte。 -
options是创建选项字符串的列表。可能的值取决于正在创建的数据集类型。
由于你使用了 GeoTIFF 驱动程序,无论你给出什么文件扩展名,输出文件都将是一个 GeoTIFF。然而,扩展名不会自动添加,因此你需要提供它。在这种情况下,你将其命名为 nat_color.tif 并将其保存在 D:\osgeopy-data\Landsat\Washington 文件夹中,因为这是当前通过 os.chdir 设置的文件夹。在创建新数据集时,你还必须提供列数和行数,因此你使用 XSize 和 YSize 属性分别从输入波段获取这些信息。Open 函数的下一个参数是波段数,你希望这个新的栅格有三个波段。下一个可选参数是数据类型,它必须是 表 9.1 中的值之一。你可以从输入波段获取此信息,尽管在这种情况下你可以忽略它,因为这些图像使用默认类型 GDT_Byte。你也可以提供特定格式的创建选项,但在这里你没有这样做。因为每个格式都有自己的选项,你需要查阅 www.gdal.org/formats_list.html 来了解你感兴趣的格式。
表 9.1. GDAL 数据类型常量
| 常量 | 数据类型 |
|---|---|
| GDT_Unknown | 未知 |
| GDT_Byte | 无符号 8 位整数(字节) |
| GDT_UInt16 | 无符号 16 位整数 |
| GDT_Int16 | 有符号 16 位整数 |
| GDT_UInt32 | 无符号 32 位整数 |
| GDT_Int32 | 有符号 32 位整数 |
| GDT_Float32 | 32 位浮点数 |
| GDT_Float64 | 64 位浮点数 |
| GDT_CInt16 | 16 位复数整数 |
| GDT_CInt32 | 32 位复数整数 |
| GDT_CFloat32 | 32 位复数浮点数 |
| GDT_CFloat64 | 64 位复数浮点数 |
| GDT_TypeCount | 可用数据类型数量 |
在此阶段,您有一个空的三波段数据集,但您可能希望知道它使用什么 SRS 以及它在地球上的位置。接下来的两行处理这些细节,并且它们在这里重复:
out_ds.SetProjection(in_ds.GetProjection())
out_ds.SetGeoTransform(in_ds.GetGeoTransform())
您从输入数据集中获取投影(SRS)并将其复制到新数据集中,然后对地理变换也进行相同的操作。地理变换很重要,因为它提供了原点坐标和像素大小,如果图像不是面向北方的顶部,则还包括旋转值。正如您之前所学的,原点和像素大小在将数据集放置在正确的空间位置时非常重要。尽管您在添加像素值之前不必添加投影和地理变换信息,但我更喜欢在创建新数据集时立即处理这些信息。
在设置完数据集后,是时候添加像素值了。因为您已经有了 Landsat 波段 1 的 GeoTIFF 的波段对象,您可以从其中读取像素值到一个 NumPy 数组中。如果您没有为ReadAsArray提供任何参数,则所有像素值都返回为一个与栅格本身具有相同维度的二维数组。此时,您的in_data变量包含一个像素值的二维数组:
in_data = in_band.ReadAsArray()
现在,由于 Landsat 图像的波段 1 是蓝色波段,您需要将其放入输出图像的第三波段,以获得 RGB 顺序的波段。接下来,您需要从out_ds获取第三波段,然后使用WriteArray将in_data数组中的值复制到新数据集的第三波段:
out_band = out_ds.GetRasterBand(3)
out_band.WriteArray(in_data)
您仍然需要将绿色和红色 Landsat 波段添加到数据集中,因此您打开第二个波段的 GeoTIFF。请注意,这次您并没有从数据集中获取波段对象,因为您将直接从数据集本身读取像素数据。由于第二个 Landsat 波段是绿色波段,因此您然后获取到堆叠数据集中第二个(绿色)波段的处理句柄,并将 Landsat 文件中的数据复制到堆叠数据集中:
in_ds = gdal.Open(band2_fn)
out_band = out_ds.GetRasterBand(2)
out_band.WriteArray(in_ds.ReadAsArray())
当您对一个数据集调用ReadAsArray时,如果您正在读取的数据集具有多个波段,您将得到一个三维数组。因为 Landsat 文件只有一个波段,所以对数据集的ReadAsArray调用返回与从波段对象获得的相同二维数组。这次您不是将数据保存到中间变量中,而是直接将其发送到输出波段。然后您对红色像素值做同样的事情,但将其压缩到更少的代码中。结果是相同的:
out_ds.GetRasterBand(1).WriteArray(gdal.Open(band3_fn).ReadAsArray())
在接下来的代码片段中,你计算数据集中每个波段上的统计数据。这并不是严格必要的,但它使某些软件更容易以良好的方式显示它。这些统计数据包括平均值、最小值、最大值和标准差。GIS 可以使用这些信息在屏幕上拉伸数据,使其看起来更好。你将在后面的章节中看到如何手动拉伸数据的示例。在计算统计数据之前,你必须确保数据已经写入磁盘而不是仅缓存在内存中,这就是调用FlushCache的作用。然后你遍历波段,并计算每个波段的统计数据。将False传递给此函数告诉它你想要实际的统计数据而不是估计值,它可能从概览层(目前还不存在)或从采样像素子集中获得。如果估计值是可以接受的,那么你可以传递True;这也会使计算更快,因为不需要检查每个像素:
out_ds.FlushCache()
for i in range(1, 4):
out_ds.GetRasterBand(i).ComputeStatistics(False)
你最后要做的事情是为数据集构建概览层。因为这些像素值是连续数据,所以你使用平均插值而不是默认的最邻近插值。你还指定了五个概览级别来构建。碰巧五个级别是你需要为这个特定图像获取 256 像素大小瓦片所需的所有级别:
out_ds.BuildOverviews('average', [2, 4, 8, 16, 32])
哦,别忘了删除输出数据集。当变量超出作用域时,这会自动发生,但如果你使用的是交互式 Python 环境,脚本运行结束后可能不会这样。当我的学生做作业时,这经常发生。他们没有刷新缓存或删除变量,而且当脚本结束时,他们的 IDE 没有释放数据集对象,所以他们最终得到一个空白的图像,却不知道为什么。
用于处理栅格数据的其他模块
如果你想要尝试一个使用更多“Pythonic”语法但仍然利用 GDAL 功能的模块,请查看rasterio。此模块依赖于 GDAL 并在内部使用它来读取和写入数据,但它试图使处理栅格数据的过程变得更容易一些。
另一个可能引起你兴趣的模块是 imageio。这个模块是用纯 Python 编写的,不依赖于 GDAL。它不专注于地理空间数据,但可以读取和写入许多不同的栅格格式,包括视频格式。你可以在imageio.github.io/了解更多关于它的信息。
9.3. 读取部分 数据集
在列表 9.1 中,你一次读取和写入整个数据带。然而,如果你需要,你可以将其分成块。这可能是因为你最初只需要数据的空间子集,或者可能你没有足够的 RAM 一次性存储所有内容。让我们看看如何访问子集而不是整个图像。
ReadAsArray 函数有几个可选参数,尽管它们取决于你是在使用数据集还是波段。
这是波段版本的签名:
band.ReadAsArray([xoff], [yoff], [win_xsize], [win_ysize], [buf_xsize],
[buf_ysize], [buf_obj])
-
xoff是开始读取的列。默认值是 0。 -
yoff是开始读取的行。默认值是 0。 -
win_xsize是要读取的列数。默认情况下,读取所有列。 -
win_ysize是要读取的行数。默认情况下,读取所有行。 -
buf_xsize是输出数组中的列数。默认情况下,使用win_xsize值。如果此值与win_xsize不同,则将进行数据重采样。 -
buf_ysize是输出数组中的行数。默认情况下,使用win_ysize值。如果此值与win_ysize不同,则将进行数据重采样。 -
buf_obj是一个 NumPy 数组,用于将数据放入其中而不是创建新数组。如果需要,数据将重采样以适应此数组。值也将转换为此数组的数据类型。
xoff 和 yoff 参数分别指定开始读取的列和行偏移量。默认情况下,从第一行和第一列开始读取。win_xsize 和 win_ysize 参数指示要读取的行和列数,默认情况下读取所有行。buf_xsize 和 buf_ysize 参数允许你指定输出数组的大小。如果这些值与 win_xsize 和 win_ysize 值不同,则数据在读取时将进行重采样以匹配输出数组大小。buf_obj 参数是一个 NumPy 数组,数据将存储在此数组中而不是创建新数组。像素数据类型将更改为与此数组的数据类型匹配。如果你提供的 buf_xsize 和 buf_ysize 值与数组的维度不匹配,则会出错,但在此情况下提供大小也没有必要,因为它们可以从数组本身确定。
例如,要读取从第 6000 行和第 1400 列开始的 3 行和 6 列,如 图 9.8 所示,你可以执行以下操作:
data = band.ReadAsArray(1400, 6000, 6, 3)
使用 ReadAsArray(1400, 6000, 6, 3) 读取从第 6000 行和第 1400 列开始的 3 行和 6 列。

如果你需要以浮点数而不是字节的形式获取像素值,你可以在读取后使用 NumPy 进行转换,如下所示:
data = band.ReadAsArray(1400, 6000, 6, 3).astype(float)
或者,你可以在读取数据时让 GDAL 帮你进行转换。要使用此方法,你创建一个浮点数组,然后将它作为 buf_obj 参数传递给 ReadAsArray。确保你创建的数组与正在读取的数据具有相同的维度。
import numpy as np
data = np.empty((3, 6), dtype=float)
band.ReadAsArray(1400, 6000, 6, 3, buf_obj=data)
NumPy 的empty函数创建一个未初始化任何值的数组,因此它包含垃圾数据,直到你以某种方式填充它。函数的第一个参数是一个包含要创建的数组维度的元组。如果是一个二维数组,元组包含行数和列数。dtype参数是可选的,它指定数组将持有的数据类型。如果没有提供,数组将包含浮点数。
要将数据数组写入其他数据集的特定位置,请将偏移量传递给WriteArray。它将从你提供的偏移量开始,将你传递给函数的数组中的所有数据写出来。
band2.WriteArray(data, 1400, 6000)
在读取部分数据集时,有一件重要的事情要记住,你必须确保不要尝试读取比存在更多的数据,否则你会得到一个错误。例如,如果一个图像有 100 行,而你要求它从偏移量 75 开始读取 30 行,那么它就会超出图像的末尾并失败。如果你传递给WriteArray的数组太大而无法适应栅格,给定你的起始偏移量,也会出现类似的问题。
超出范围的访问窗口错误信息
以下信息意味着我尝试从 testio.tif 的波段 1 读取一个 30x30 的数组,从列 0 和行 75 开始。问题是 testio.tif 只有 100 行和 100 列,所以如果我从 75 开始,就没有 30 行可以读取。
“错误 5:testio.tif,波段 1:在 RasterIO()中访问窗口超出范围。请求的(0,75)大小为 30x30,在 100x100 的栅格上。”
你如何使用这些信息来处理一个无法装入 RAM 的大数据集呢?嗯,一种方法是一次处理一个数据块。记住,栅格数据在磁盘上以块的形式存储数据。因为一个块中的数据在磁盘上是存储在一起的,所以以这些块为单位处理图像是高效的。
基本思想在图 9.9 中展示。你从第一行的第一个块开始,然后要么在 x 方向要么在 y 方向移动到下一个块(这个例子使用后者)。每次跳转到下一个块时,你需要确保确实有足够的数据来读取一个完整的块。例如,如果块大小是 64 行,你需要检查至少还有 64 行是你还没有读取的。如果没有,那么你只能读取剩下的所有行,如果你尝试读取更多,你会得到一个错误。一旦你到达末尾,你移动到下一列的块并重新开始,逐行处理。同样,你总是需要确保不要尝试读取比栅格中存在的列更多的列。
图 9.9。按块读取和写入栅格的过程

列表 9.2 展示了如何将数字高程模型从米转换为英尺,一次处理一个块。这是一个小型数据集,所以在现实世界中你可能不需要这样分割,但你将以相同的方式处理大型数据集。这也展示了处理栅格中的NoData值的例子,这些像素被认为是具有空值的。像素必须有值,但可以指定一个特定的值作为NoData,因此被忽略。
列表 9.2. 通过块处理栅格


你可能已经能猜出这个例子开始时发生了什么。你打开数据集并获取有关波段的信息,包括其块的大小和NoData值。在创建输出数据集后,你开始按水平方向(x)遍历块。你从列 0 开始,直到最后一个列,用索引xsize表示。转折点是每次通过循环时,你将x增加一个块中的列数(range的第三个参数是增加的量),因此你从一块的开始跳到下一块的开始。然后你将需要读取的列数存储在一个名为cols的变量中。如果有足够的列可以读取一个完整的块,这个变量就设置为块中的列数。但如果列数不足,就像在图 9.10 中x等于 10 时的情况,就使用剩余的列数(图中为三列)代替。你需要这样做,因为如果你尝试读取比实际存在的行或列更多的数据,你会得到一个错误。
图 9.10. 一个具有五行五列块大小的示例图像。交替的块被阴影覆盖,以便于观察。左上角的像素偏移为 0,0。

在计算要读取的列数之后,你重复这个过程来读取行数。如图 9.10 所示,在第二次循环的前两次迭代中,你读取了五行,但在第三次迭代时只剩下了一行需要读取。在处理完所有行的前五列之后,你进入外层循环的下一个迭代,并处理下一组五列,然后是最后的三列。
一旦你确定了要读取的行数和列数,你就可以将这些数字,连同当前的行和列偏移量,传递给ReadAsArray以获取一个数据块的数据:
data = in_band.ReadAsArray(x, y, cols, rows)
下一步是将值从米转换为英尺。你使用 NumPy 的 where 函数来帮助完成这项工作。这个函数就像一个 if-else 语句。第一个参数是要检查的条件,在这种情况下是像素值是否等于 NoData 值。第二个参数是在条件为真时的输出值。如果传入的像素是 NoData,你也输出 NoData。第三个参数是在条件为假时的输出值,因此你在这里通过乘以 3.28084 将值转换为英尺:
data = np.where(data == nodata, nodata, data * 3.28084)
在将有效像素转换为英尺后,你将数据传递给 WriteArray,使用当前的行和列偏移量,然后再继续到下一个块:
out_band.WriteArray(data, x, y)
在处理完所有块后,你计算统计数据并构建预览。为了从统计数据计算中排除 NoData 像素,你必须在调用 ComputeStatistics 之前告诉波段哪个值代表 NoData。你可能想在你循环内部计算统计数据,但你想让统计数据基于波段中的所有像素,因此你需要等待所有像素值都被计算出来。
显然,这种方法通过循环块比一次性读取和写入整个波段更复杂,但如果你的 RAM 不足,它非常有价值。
9.3.1. 使用实际世界坐标
一直以来,我们在决定从何处开始读取或写入数据时,只考虑了像素偏移,但大多数情况下,你会有实际世界的坐标。幸运的是,只要你的坐标使用与栅格相同的 SRS,两者之间的转换就很容易。你之前已经看到了如何计算单个像素的坐标,现在你需要进行相反的过程。所需的所有数据,包括原始坐标、像素大小和旋转值,都存储在你一直在数据集之间复制的地理变换中。地理变换是一个包含 表 9.2 中所示六个值的元组。旋转值通常是 0;实际上,我想不起来曾经使用过不是北向上的图像,但它们确实存在。
表 9.2. GeoTransform 项
| Index | 描述 |
|---|---|
| 0 | 原始 x 坐标 |
| 1 | 像素宽度 |
| 2 | x 像素旋转(如果图像为北向上,则为 0°) |
| 3 | 原始 y 坐标 |
| 4 | y 像素旋转(如果图像为北向上,则为 0°) |
| 5 | 像素高度(负值) |
你可以使用这些信息来自己应用仿射变换,但 GDAL 提供了一个名为ApplyGeoTransform的函数,它为你完成这项工作,该函数接受一个地理变换、一个 x 值和一个 y 值。当与数据集的地理变换一起使用时,此函数将图像坐标(偏移量)转换为实际坐标。但你现在感兴趣的是相反的方向,所以你需要获取数据集地理变换的逆变换。幸运的是,存在一个函数可以做到这一点,但根据你使用的 GDAL 版本,你使用它的方式不同。如果你使用 GDAL 1.x,InvGeoTransform函数返回一个成功标志和一个可以用于相反方向的新地理变换:
gt = ds.GetGeoTransform()
success, inv_gt = gdal.InvGeoTransform(gt)
如果一切顺利,则成功标志将为 1,但如果仿射变换无法反转,则返回 0。因此,如果你不确定地理变换是否可以反转,你应该在继续之前检查成功标志的值。
如果你使用 GDAL 2.x,那么InvGeoTransform函数只返回一个项目:如果可以计算,则返回地理变换,否则返回None。在这种情况下,你需要确保返回的值不等于None:
inv_gt = gdal.InvGeoTransform(gt)
现在你有了逆地理变换,你可以使用它将实际坐标转换为图像坐标。例如,假设你需要坐标 465200,5296000 处的像素值。以下代码可以获取它,假设栅格覆盖了该位置:
offsets = gdal.ApplyGeoTransform(inv_gt, 465200, 5296000)
xoff, yoff = map(int, offsets)
value = band.ReadAsArray(xoff, yoff, 1, 1)[0,0]
ApplyGeoTransform函数返回一个 x 值和一个 y 值作为浮点数,但你需要整数偏移量来传递给ReadAsArray。如果你忘记将偏移量转换为整数,你会得到一个错误。在得到整数后,你从这些偏移量开始读取一行和一列。你可能认为这将返回一个数字,但并非如此。记住,ReadAsArray返回一个二维数组,即使只读取一行和/或一列也是如此。要获取实际的像素值,你仍然需要在数组的第一个行和第一个列(位置[0,0])中获取该值。
如果你需要在不同位置采样像素值,这种方法非常低效。在这种情况下,你最好读取整个波段,然后从该数组中提取适当的值。这是因为读写操作很昂贵,所以为每个点进行新的读取操作比为整个波段进行一次大读取操作要慢得多。使用这种方法获取相同像素值的代码可能如下所示:
data = band.ReadAsArray()
x, y = map(int, gdal.ApplyGeoTransform(inv_gt, 465200, 5296000))
value = data[yoff, xoff]
显然,你不会为每个点都读取整个波段;你只会做一次,然后为每个点重复最后两行。请注意,在从 NumPy 数组中提取像素值时,行和列的偏移量是相反的,因为 NumPy 希望偏移量为[row, column],而不是[x, y](这与[column, row]相同)。
小贴士
使用 [行, 列] 偏移量来表示 NumPy 数组。这与你习惯使用的 GDAL 方式相反。
如果你想提取空间子集并将其保存到新图像中,那么在真实世界坐标和偏移量之间进行转换的能力也很重要,因为你需要更改地理变换中的原点坐标。比如说,你想要从之前创建的自然色 Landsat 图像中提取瓦肖岛(图 9.11),并且你得到了感兴趣区域的左上角和右下角坐标。你需要将这些坐标转换为像素偏移量,以便知道要读取哪些数据,但很可能这些边界坐标并不完全对应于像素边界,因此你还需要找到你提取的子集的真正左上角坐标。下面的列表展示了这个例子。
图 9.11. 列表 9.3 的目标是提取之前创建的自然色 Landsat 图像中的瓦肖岛。

列表 9.3. 提取并保存图像的子集


你在这个例子中之前已经看到过所有这些内容,但你还没有看到它们以这种方式组合在一起。重要的部分是你在脚本顶部(在现实生活中你可能不希望硬编码坐标,但这对示例来说是可以的)根据坐标计算瓦肖岛左上角和右下角的偏移量。然后你从右下角的偏移量中减去左上角的偏移量,以得到要提取的总行数和列数。
一旦你有了这些基本信息,你将创建一个具有这些新尺寸的输出图像,而不是原始图像的尺寸。投影信息保持不变,但你需要修改地理变换以反映子集的左上角坐标。你不能使用你计算出的左上角坐标,因为它们可能位于某个像素的中间,但你需要像素角落的坐标。请注意,你使用原始地理变换来完成这个任务,而不是反转的地理变换,因为你是将偏移量转换为真实世界坐标。然后,因为地理变换是以元组的形式返回的,你必须在插入新的左上角坐标之前将其转换为列表。
在完成所有这些准备工作之后,你将数据从原始图像复制到新图像中。你从左上角的偏移量开始读取,并获取之前计算出的列数和行数。在写入数据时提供偏移量没有理由,因为新图像将只包含子集,所以你想要从原点开始写入。
你可能还会计算统计数据并构建预览,但这些步骤并不是绝对必要的,所以为了节省空间,我省略了它们。
9.3.2. 重采样数据
ReadAsArray 函数的一个优点是,你可以用它来在读取数据时进行重采样,无论是通过指定输出缓冲区大小还是传递现有的缓冲区数组。提醒一下,这个函数的签名看起来是这样的:
band.ReadAsArray([xoff], [yoff], [win_xsize], [win_ysize], [buf_xsize],
[buf_ysize], [buf_obj])
win 参数指定从波段中读取的行数和列数,而 buf 参数指定将那些像素值放入的数组的大小。一个比原始数据维度更大的数组将重采样到更小的像素,而一个比原始数据维度小的数组将使用最近邻插值重采样到更大的像素。
重采样到更小的像素
要将数据重采样到更精细的分辨率,提供一个比正在读取的数据更大的数组,这样像素值就需要重复以填充目标数组。例如,这将创建每个像素四个像素,实际上将像素大小减半,如图 9.12 所示:
band.ReadAsArray(1400, 6000, 3, 2, 6, 4)
图 9.12. 当行数和列数翻倍时,像素值重复四次。

这之所以有效,是因为你从波段中读取了三列和两行,但将这些数据放入一个六列四行的数组中,所以每一行和每一列都被复制以填充输出数组。
这都很好,但如果你需要将数据写入新的图像,如何处理新的单元格大小?这很简单,因为你只需要修改地理变换,使其指定更小的像素大小。看看以下列表,看看你如何将整个图像重采样到更小的像素大小。
列表 9.4. 将图像重采样到更小的像素大小


这个例子有几个需要注意的重要事项。首先,在创建新的数据集时,你将行数和列数翻倍,并将这些相同的数字作为参数传递给 ReadAsArray。这确保了你的输入数据维度与输出数据维度相匹配,并且也会导致数据被重采样到更大的维度。你本可以使用现有的数组作为 buf_obj 参数的值来获得相同的结果。你也可以提供 win_xsize 和 win_ysize 参数,但它们的默认值是原始的行数和列数,这正是你想要的。
你还可以编辑地理变换来反映更小的像素大小。地理变换中的第二项是像素宽度,第六项是像素高度,所以你需要将每个值除以二并覆盖原始值。因为这个图像仍然覆盖了与原始图像相同的空间范围,所以你不需要更改其他任何值。一旦完成编辑,你将地理变换设置到新的数据集上。幸运的是,编辑地理变换不会改变原始图像的地理变换,因为元组没有链接到数据集,所以你不会引入任何复杂性。
如果你没有改变像素大小,而是将原始地理变换复制到新的数据集中,你的输出将看起来像图 9.13 中显示的较大图像。你可能还记得,栅格的空间范围是由原点坐标和像素大小确定的。左上角的坐标将是相同的,但错误的像素大小会导致图像在每个方向上覆盖两倍的距离。在这种情况下,华盛顿州西北部的卫星图像看起来会延伸到华盛顿州东部和向南到俄勒冈州,这显然是不正确的。现在应该很清楚准确的地理变换有多么重要。如果一个栅格在 GIS 中打开时看起来位置或大小不正确,那么一个不正确的地理变换很可能是罪魁祸首,同样,一个错误的空间参考系统也是如此。
图 9.13. 这说明了在不改变地理变换大小的情况下重采样到更小像素尺寸的结果。左上角的较小图像是正确的。较大的图像是通过使用输入图像的未编辑地理变换创建的。

重采样到更大的像素
在读取数据时,你也可以通过提供一个较小的缓冲区数组来重采样到一个更粗糙的分辨率。在这种情况下,一个像素代替了几个单元格,并使用最近邻插值来确定使用哪个值(图 9.14)。以下示例将四个像素替换为一个:
data = np.empty((2, 3), np.int)
band.ReadAsArray(1400, 6000, 6, 4, buf_obj=data)
图 9.14. 在重采样到更小的维度时,使用最近邻插值来选择像素值。在这种情况下,每个四像素块的右下角像素值用于输出。

在这里,事先创建了一个具有三列和两行的空整数 NumPy 数组,然后将其作为参数传递给ReadAsArray。从图像中请求的六列和四行被重采样以适应这个较小的数组。顺便说一下,在这种情况下,你不需要捕获ReadAsArray的返回值,因为你已经有data变量了。但不仅data变量会自动填充,它还会从函数中返回,所以如果你想这样做,你可以通过这种方式获取它,但这是不必要的。
虽然这种技术通常使用最近邻插值进行重采样,但如果你有请求分辨率的概览层,那么将使用该概览层。如果适当的概览是通过平均插值构建的,那么使用ReadAsArray时你会得到这个结果,而不是最近邻。
与重采样到更小的像素类似,在将数据写回到另一个数据集时,你需要更改 geotransform 中的像素大小。唯一的区别在于,在这种情况下,你想要减少行和列的数量并增加像素大小。你可以修改列表 9.4,通过将行和列除以 2 而不是乘以 2 来重采样到更粗的像素,而不是乘以像素大小。你也可以通过构建更少的概述层来避免这个问题。除此之外,技术是相同的。如果你忘记更改像素大小,那么最终你会得到一个压缩到过小区域而不是像图 9.13 中显示的过大区域的图像。
9.4. 字节序列
如果你已经查阅了附录 E,你可能已经注意到ReadAsArray和WriteArray并不是 GDAL 读取和写入数据的唯一方式。(附录 C 至 E 可在 Manning Publications 网站上在线获取,网址为www.manning.com/books/geoprocessing-with-python。)你还可以将数据读入一个 Python 字节序列中,这类似于由对应于数值像素值的 ASCII 码组成的字符串。与字符串不同,字节序列不能被修改,尽管你将在本节中学习如何绕过这一点。这比转换为 NumPy 数组要快一些,但我更喜欢得到一个数组,这样我就可以进行数学操作。但如果你想使用字节而不是数组,或者不需要操作数据,ReadRaster的参数与ReadAsArray的参数相似。以下是ReadRaster数据集版本的签名:
ReadRaster([xoff], [yoff], [xsize], [ysize], [buf_xsize], [buf_ysize],
[buf_type], [band_list], [buf_pixel_space], [buf_line_space],
[buf_band_space])
-
xoff是开始读取的列。默认值为 0。 -
yoff是开始读取的行。默认值为 0。 -
xsize是要读取的列数。默认情况下读取所有列。 -
ysize是要读取的行数。默认情况下读取所有行。 -
buf_xsize是返回序列中的列数。默认情况下使用xsize值。如果这个值与xsize不同,数据将被重采样。 -
buf_ysize是返回序列中的行数。默认情况下使用ysize值。如果这个值与ysize不同,数据将被重采样。 -
buf_type是返回序列的目标 GDAL 数据类型。默认情况下与原始数据相同。 -
band_list是要读取的波段索引列表。默认情况下读取所有波段。 -
buf_pixel_space是序列中像素之间的字节偏移量。默认情况下是buf_type的大小。 -
buf_line_space是序列中各行之间的字节偏移量。默认情况下是buf_type的大小乘以xsize。 -
buf_band_space是序列中各波段之间的字节偏移量。默认情况下是buf_line_space的大小乘以ysize。
前六个参数与 ReadAsArray 相同。buf_type 参数是来自 表 9.1 的 GDAL 数据类型常量,用于指定字节序列使用的数据类型。这可以用于在读取时更改数据类型。例如,如果栅格是 byte 类型,但你为该参数提供了 GDT_float32,则生成的字节字符串将表示像素值作为浮点数而不是字节。你也可以提供要读取的波段列表,并将按你指定的顺序返回。你甚至可以将波段多次包含在内,尽管我不确定你为什么想要这样做。最后三个参数更改返回的字节字符串中数据的间距,可以用于处理不寻常的交错数据集,但很可能你永远不会需要这些。ReadRaster 的波段版本参数与这些参数相同,除了缺少 band_list 和 buf_band_space。
无论如何,如果你要打印出对 ReadRaster 的调用结果,结果可能类似于 b'\x1c\x1d\x1c\x1e',这对我来说并没有太多意义。然而,你可以通过索引访问元素,这些元素看起来会更熟悉。字节字符串是不可变的,这意味着它们不能改变,但如果你需要编辑值,可以将它们转换为字节数组。以下交互会话展示了这一示例:
>>> data = ds.ReadRaster(1400, 6000, 2, 2, band_list=[1])
>>> data
b'\x1c\x1d\x1c\x1e'
>>> data[0]
28
>>> bytearray_data = bytearray(data)
>>> bytearray_data[0] = 50
>>> bytearray_data[0]
50
你还可以使用内置的 struct 模块将字节字符串转换为元组。在这里,你需要提供一个格式字符串,该字符串指定了字符串中包含的类型和元素数量。在这个例子中,你使用了一个如“BBBB”的格式字符串来指定四个字节。有关其他格式的信息,请参阅 Python struct 文档。
tuple_data = struct.unpack('B' * 4, data)
如果你想要将字节字符串转换为 NumPy 数组,可以使用 unpack 的元组,或者使用 NumPy 的 fromstring 函数直接转换字节字符串(尽管如果你想要一个 NumPy 数组,也许你应该使用 ReadAsArray)。与使用 unpack 一样,你必须提供序列在转换为 NumPy 数组时使用的数据类型。这两种方法都返回一维数组,所以如果需要,你必须将其重塑为多维数组。这里展示了这些操作的示例:
numpy_data1 = np.array(tuple_data)
numpy_data2 = np.fromstring(data, np.int8)
reshaped_data = np.reshape(numpy_data2, (2,2))
从字节字符串写入数据的参数与读取的参数类似,尽管前五个参数是必需的而不是可选的:
def WriteRaster(xoff, yoff, xsize, ysize, buf_string, [buf_xsize],
[buf_ysize], [buf_type], [band_list], [buf_pixel_space],
[buf_line_space], [buf_band_space])
-
xoff是开始写入的列。 -
yoff是开始写入的行。 -
xsize表示要写入的列数。 -
ysize表示要写入的行数。 -
buf_string是要写入的字节序列。 -
buf_xsize是字节序列中的列数。默认情况下使用xsize值。如果此值与xsize不同,则将重新采样数据。 -
buf_ysize是字节序列中的行数。默认情况下使用ysize值。如果此值与ysize不同,则将重新采样数据。 -
buf_type是字节序列的 GDAL 数据类型。默认值与要写入的数据集相同。 -
band_list是要写入的波段索引列表。默认是写入所有波段。 -
buf_pixel_space是字节序列中像素之间的字节偏移量。默认值是buf_type的大小。 -
buf_line_space是字节序列中行之间的字节偏移量。默认值是buf_type的大小乘以xsize。 -
buf_band_space是字节序列中波段之间的字节偏移量。默认值是buf_line_space的大小乘以ysize。
再次强调,波段版本是相同的,只是band_list和buf_band_space参数不存在。
你可以编写一个名为data的字节序列,包含六列和四行,输出到类似这样的数据集中:
ds.WriteRaster(1400, 6000, 6, 4, data, band_list=[1])
让我们尝试使用字节而不是 NumPy 数组来将图像重采样到更大的像素大小。
列表 9.5. 使用字节序列重采样图像到更大的像素大小


在许多方面,这个例子与列表 9.4 类似,除了输出行和列的数量减半,而不是加倍,像素大小加倍,而不是减半。请注意,在这种情况下,你需要确保行和列的数量是整数,因为除法的结果可能是浮点数,而数据集创建函数不喜欢这种情况。
有趣的部分在于读取和写入数据。因为默认情况下所有行、列和波段都会被读取,所以你不必对那些做任何事情。但是,因为你希望数据被重采样到一半的行和列,所以你需要使用buf_xsize和buf_ysize参数传递这些较小的数字。这会导致数据在 GDAL 将其读入字节序列时被重采样。然后,你将数据写入到新的数据集中,从第一行和第一列开始。你还必须告诉WriteRaster字节序列中包含多少行和列,因为与 NumPy 数组不同,这并不明显。一个 32 字节长的字节序列可能包含一个 32 位整数,也可能包含四个 8 位整数。尽管Write-Raster可以找出序列中有多少字节,但它不知道如何将这些字节转换为像素值,直到你告诉它应该有多少个值。
9.5. 子数据集
几种类型的数据集可以包含其他数据集,每个数据集又包含波段(图 9.15)。一个例子是美国地质调查局分发的 MODIS 影像,它以分层数据格式(HDF)文件的形式提供。如果你的数据集包含子数据集,你可以使用GetSubDatasets函数获取它们的列表,然后使用该信息打开你想要的数据集。
图 9.15. 几种数据集包括子数据集。这些子数据集的结构与正常数据集类似,并包含自己的波段和地理参考信息。

作为示例,让我们打开一个包含在 MODIS 文件中的子数据集。请注意,HDF 驱动程序默认不包含在 GDAL 中,因此如果您的 GDAL 版本不包含 HDF 支持,此示例将无法为您工作。假设您可以处理 HDF 文件,第一步是将 HDF 文件作为数据集打开:
ds = gdal.Open('MYD13Q1.A2014313.h20v11.005.2014330092746.hdf')
现在,您可以获取包含在此公开数据集中的子数据集列表。GetSub-Datasets 方法返回一个元组列表,每个子数据集一个元组。每个元组包含子数据集的名称和描述,顺序如下。以下代码片段获取此列表,然后打印出每个子数据集的名称和描述:
subdatasets = ds.GetSubDatasets()
print('Number of subdatasets: {}'.format(len(subdatasets)))
for sd in subdatasets:
print('Name: {0}\nDescription:{1}\n'.format(*sd))
输出的前几行看起来像这样,显示了第一个子数据集的信息,即 NDVI(归一化植被指数),但还有 11 个未显示:
Number of subdatasets: 12
Name: HDF4_EOS:EOS_GRID:"MYD13Q1.A2014313.h20v11.005.2014330092746.hdf":
MODIS_Grid_16DAY_250m_500m_VI:250m 16 days NDVI
Description:[4800x4800] 250m 16 days NDVI MODIS_Grid_16DAY_250m_500m_VI
(16-bit integer)
要打开子数据集,将它的名称传递给 gdalOpen。例如,这获取与第一个子数据集对应的元组,从元组中获取第一个元素(名称),然后使用它来打开子数据集:
ndvi_ds = gdal.Open(subdatasets[0][0])
同样,您可以使用 subdatasets[4][0] 来打开第五个子数据集。一旦以这种方式打开了一个子数据集,它就可以像任何其他数据集一样处理。例如,您可以使用 ndvi_ds.GetRasterBand(1) 获取 NDVI 子数据集的第一波段。
9.6. Web 地图服务
让我们快速了解一下 Web 地图服务,这些服务用于在网络上提供图像,例如底图。我们将尝试一个 OGC Web 地图服务,该服务根据您的请求创建图像,但您还有其他访问底图的方法。例如,OpenStreetMap 和 Google 都使用预渲染的瓦片图像。要使用这些,您需要知道您想要的瓦片,并且不会即时渲染(好吧,可能会,这取决于服务器上图像的缓存方式,但理念是瓦片已经存在,因此它们提供了快速访问)。
GDAL 允许您使用 XML 文件来指定地图服务的参数,所有可能性都在www.gdal.org/frmt_wms.html上进行了文档记录。以下列表显示了描述美国国家地图影像底图的 XML:
列表 9.6. 描述 Web 地图服务的 XML
<GDAL_WMS>
<Service name="WMS">
<Version>1.3.0</Version>
<ServerURL>http://raster.nationalmap.gov/arcgis/services/
Orthoimagery/USGS_EROS_Ortho_1Foot/ImageServer/WMSServer?
</ServerURL>
<CRS>CRS:84</CRS>
<ImageFormat>image/png</ImageFormat>
<Layers>0</Layers>
</Service>
<DataWindow>
<UpperLeftX>-74.054444</UpperLeftX>
<UpperLeftY>40.699167</UpperLeftY>
<LowerRightX>-74.034444</LowerRightX>
<LowerRightY>40.679167</LowerRightY>
<SizeX>300</SizeX>
<SizeY>300</SizeY>
</DataWindow>
<BandsCount>4</BandsCount>
</GDAL_WMS>
您需要了解有关服务的一些信息来创建 XML 规范。OGC Web 地图服务允许您使用 GetCapabilities 请求请求有关它们的信息。如果您不知道服务的基准 URL,您就无能为力了,但假设您知道它,将“?request=GetCapabilities&service=WMS”附加到末尾,并在浏览器中查看结果。例如,列表 9.6 中定义的服务 URL 是raster.nationalmap.gov/arcgis/services/Orthoimagery/USGS_EROS_Ortho_1Foot/ImageServer/WMSServer?request=GetCapabilities&service=WMS。
这有很多信息,但我们将重点关注 XML 服务部分中重要的几个部分。查看输出的第一行:
<WMS_Capabilities xmlns=http://www.opengis.net/wms
version="1.3.0"
xsi:schemaLocation="http://www.opengis.net/wms
http://schemas.opengis.net/wms/1.3.0/capabilities_1_3_0.xsd">
该行的一部分指定了 WMS 版本为 1.3.0。将此信息添加到 XML 的 Version 部分。现在查看 GetCapabilities 结果,直到您找到 GetMap 部分。它的第一部分看起来像这样:
<GetMap>
<Format>image/tiff</Format>
<Format>image/png</Format>
<Format>image/png24</Format>
<Format>image/png32</Format>
<Format>image/bmp</Format>
<Format>image/jpeg</Format>
<Format>image/svg</Format>
<Format>image/bil</Format>
这些是服务可以提供的格式,您应该在 XML 的 ImageFormat 部分包含其中之一。现在在 GetCapabilities 输出中查找 Layer 部分。以下是该部分的几行内容:
<Layer>
<Name>0</Name>
<Title>USGS_EROS_Ortho_1Foot</Title>
<Abstract>
The USGS_EROS_Ortho_1Foot service from The National Map contains 1
foot orthoimagery, and is viewable at all scales.
</Abstract>
我们想使用名为 USGS_EROS_Ortho_1Foot 的图层,但重要的是 Name 值。在这种情况下,名称是“0”,这不是很描述性,但这是您需要添加到 XML 的 Layer 部分的。如果您继续查看能力的 Layer 部分,您将看到一个包含 CRS 值的冗长列表,这些是服务支持的坐标系统。以下是前几个:
<CRS>CRS:84</CRS>
<CRS>EPSG:4326</CRS>
<CRS>EPSG:3857</CRS>
您已经猜到了。为您的输出选择其中一个,并将其添加到 XML 的 CRS 部分。
现在服务已在您的 XML 中定义,您需要指定要检索的地理范围。您可以通过 DataWindow 部分来完成此操作。Upper-LeftX、UpperLeftY、LowerRightX 和 LowerRightY 分别代表最小 x 值、最大 y 值、最大 x 值和最小 y 值。SizeX 和 SizeY 参数指定输出图像的列数和行数。
一旦您保存了 XML,将文件名传递给 GDAL 的Open函数,如果一切配置正确,它将作为一个数据集打开。此时,您可以获取波段并将数据读入数组,或者您可以使用CreateCopy将图像保存到本地文件。例如,此代码片段使用列表 9.6 中的 XML 保存纽约港自由岛的本地图像(图 9.16):
ds = gdal.Open('listing9_6.xml')
gdal.GetDriverByName('PNG').CreateCopy(r'D:\Temp\liberty.png', ds)
图 9.16. 使用 OGC 网络地图服务获取的纽约港自由岛的图像

如果您需要请求具有不同空间范围或其他经常变化的参数的图像,创建一个 XML 模板并在需要时使用所需值进行格式化是有意义的。
9.7. 摘要
-
栅格数据集非常适合没有尖锐边界的连续数据,例如高程、降水量或卫星图像。
-
为了节省磁盘空间,不要使用比必要的更小的像素大小或更大的数据类型。
-
如果您需要使用数据进行分析,请确保使用无损压缩算法或完全不进行压缩。
-
使用概述来快速显示栅格数据。
-
对于非连续的栅格数据,始终使用最近邻重采样,因为其他方法会导致新的像素值与原始值不对应。
-
为了获得最佳性能,尽可能减少读写调用,但不要尝试在内存中保留比您 RAM 更多的数据。
-
如果您更改空间范围或像素大小,不要忘记编辑地理变换。
-
不要尝试读取或写入图像的边缘。
-
使用缓冲参数在读取或写入时重新采样数据。
-
如果您想使用 NumPy 在内存中操作数据,请使用
ReadAsArray,但如果您只需要复制数据,则ReadRaster会稍微快一些。
第十章. 使用栅格数据
本章涵盖
-
使用地面控制点进行地理配准
-
处理属性、直方图和颜色表
-
使用 GDAL 虚拟格式
-
重投影栅格
-
使用 GDAL 错误处理
在上一章中,你学习了栅格处理的基础知识,例如如何读取和写入数据以及如何处理单个波段,以及栅格如何使用地理变换来定位到现实世界。这是一个很好的第一步,但如果你有一张旧航拍照片或扫描的纸质地图,你想将其转换为地理数据集怎么办?你可能想这么做是因为这很有趣,或者你可能想使用这些数据以及更新的图像进行变化分析。要做到这一点,你必须将旧数据叠加到新数据上。你可以使用地面控制点来完成这项工作,这些控制点本质上是一组具有已知位置的点。本章将教你如何使用这些点。
你还将学习如何处理栅格属性表。虽然我们之前查看的大多数栅格示例都是连续数据,例如卫星图像,但栅格数据集也可以包含主题数据。在这种情况下,每个唯一的像素值对应于某种分类,例如植被或土壤类型。像素值是数字的,但你如何知道每个值代表什么?例如,图 10.1 中显示的土地覆盖分类图有 125 个不同的类别。我当然不可能记住每个值代表什么;76 对我来说并不像“山地盆地半荒漠草原”那样有意义。幸运的是,可以将此类信息存储在栅格属性表中。
图 10.1. 一个土地覆盖分类图,其中每个唯一的像素值对应于特定的土地覆盖分类

注意:印刷版读者:彩色图形
本书中的许多图形最好以彩色查看。电子书版本显示了彩色图形,因此在阅读时应参考。要获取免费 PDF、ePub 和 Kindle 格式的电子书,请访问www.manning.com/books/geoprocessing-with-python注册您的印刷版书籍。
再看看图 10.1。它使用一组固定的颜色来显示每个土地覆盖类别。水总是蓝色(如果你在黑白模式下查看,几乎是黑色)而大盐湖西边的荒地总是淡黄色(在黑白模式下是非常浅的灰色)。虽然固定的颜色对于数据分析来说并非必需,但在查看数据集时拥有它们是很方便的。你之前已经看到如何使用红、绿、蓝波段来绘制图像,但这个数据集只有一个包含分类值的波段。它没有 RGB 波段,而是有一个所谓的颜色表,该表指定了每个唯一的像素值应该绘制成什么颜色。
这些只是栅格数据集其他组件的几个例子。你将在本章中学习如何处理这些以及其他组件。你还将学习处理 GDAL 中错误的技巧。
10.1. 地面控制点
你已经学习了如何使用左上角坐标和像素大小来使用地理变换(geotransforms)对图像进行地理配准。然而,你并不总是拥有这些信息。例如,如果你发现了一张 1969 年的旧航空照片并将其扫描,你会得到一个数字图像,但你无法将其加载到 GIS 中并看到它在正确位置上显示。你的扫描仪创建了一个数字图像,但它没有将其与任何地理信息关联。然而,只要你知道照片是关于哪个区域的,并且可以识别几个位置,一切就不会失去。这些位置被称为地面控制点(GCPs),即你知道其实际世界坐标的点。如果你可以关联图像周围的像素与实际坐标,那么图像可以被扭曲以覆盖在地图上,如图 10.2 所示。这种方法不像地理变换那样常用,但在某些情况下是必要的。此外,一旦以这种方式对图像进行地理配准,就可以计算地理变换,以便在需要时使用。你应该意识到,由于图像将被拉伸、扭曲和/或旋转,以便地面控制点与真实坐标重叠,像素大小和栅格维度在过程中可能会发生变化。
图 10.2. 使用四个已知位置扭曲图像以正确地适合地图的示例。图 A 显示了一个带有重叠点的航空照片,图 B 显示了一个带有相同点的地形图,图 C 显示了照片被拉伸以使点与地形图匹配。

应该很明显,固定地标是很好的地面控制点(GCPs),因为它们是最容易定位并获得真实坐标的东西。例如,如果你有一张包含高速公路的航空照片,使用高速公路上的汽车是不行的,因为你可能不知道照片拍摄时的确切位置(如果你知道,那么当然可以使用它)。然而,出口匝道是一个不错的选择,因为它不会移动,在照片中很容易看到,而且获取坐标也不困难。
根据您用于扭曲图像的变换类型,您需要不同数量的 GCPs。一个常用的算法,即一次多项式,将线性方程拟合到图像的 x 坐标,以便 GCP 图像坐标尽可能接近您提供的真实 GCP 坐标。同样也适用于 y 坐标。此方法至少需要三个点。如果您的坐标是精确的,那么理论上您不需要更多的点,但这种情况可能并不常见,并且您可以通过在图像周围均匀分布几个额外的点来获得更好的结果。如果您的图像需要缩放或旋转,如图 10.3A 所示,则此算法效果良好。如果您的图像需要弯曲,如图 10.3B 所示(形状变化),那么您最好使用更高阶的多项式,例如二次或三次方程,并使用更多的 GCPs。
图 10.3. 缩放和旋转的栅格(A)和形状发生变化的栅格(B)

多项式变换可能会将您的几个地面控制点(GCPs)略微移动,以最小化图像中的误差,如图 10.4A 所示。如果您想消除 GCP 周围的误差,并且愿意接受图像其他部分的更大误差,如图 10.4B 所示,您可以使用一种spline方法。样条曲线不使用单个方程,而是针对数据的不同部分使用不同的方程,因此可以精确地拟合提供的点。然而,这可能会导致图像的其他部分以奇怪的方式变形。您可以使用 GDAL 附带的各种插值方法与 gdalwarp 工具一起使用,但您将只看到如何使用 Python 中的首次(线性)多项式。
图 10.4. 不同的误差分布。三角形是 GCPs;圆圈是随机点。实心形状是真实位置;空心形状是点在扭曲栅格中最终到达的位置。

如何使用地面控制点(GCPs)?首先你需要的是特定像素偏移量的已知坐标。你可以通过艰难的方式获取这些信息,例如在图像或照片处理软件中打开你的栅格数据,并使用它来确定像素偏移量。然而,一种更简单的方法是使用 QGIS 地理配准插件。这允许你在你的图像和已经地理配准的地图上的一个位置点击,它将告诉你像素偏移量和相应的真实世界坐标。它甚至可以导出必要的gdalwarp命令来为你进行地理配准。但你是来学习如何用 Python 做同样工作的,所以让我们看看图 10.2 中的示例。这张小区域的航空照片显示了几条道路和几个大型水处理池塘。我已经确定了四个位置的坐标,如表 10.1 和图中的点所示。我选择了可以在图 10.2A 中的图像和图 10.2B 中的地形图上识别的点。获取点坐标是这个过程中最困难的部分,但这是你必须手动完成的事情。在这种情况下,地形图已经进行了地理配准,所以我可以从地图上确定坐标。
表 10.1. 用于地理配准图 10.2 中显示的航空照片的像素偏移量和坐标
| 照片列 | 照片行 | 经度 | 纬度 |
|---|---|---|---|
| 1078 | 648 | -111.931075 | 41.745836 |
| 3531 | 295 | -111.901655 | 41.749269 |
| 3722 | 1334 | -111.899180 | 41.739882 |
| 1102 | 2548 | -111.930510 | 41.728719 |
以下列表显示了如何将这些地面控制点附加到照片上。
列表 10.1. 向栅格添加地面控制点

在向栅格添加 GCPs 时,确保你打开数据集以进行更新,就像你在这里做的那样。你还需要已知坐标的空间参考系统;在这种情况下,它们使用 WGS84 大地基准,但未投影(经纬度)。最后你需要的是 GCPs 的列表,你可以使用这里显示的 GCP 构造函数创建每个 GCP:
gdal.GCP([x], [y], [z], [pixel], [line], [info], [id])
-
x、y和z是与点相对应的真实世界坐标。所有这些都是可选的,默认值为 0,尽管你可能不希望 x 和 y 的值是 0。 -
pixel是具有已知坐标的像素的列偏移量。这是可选的,默认值为 0。 -
line是具有已知坐标的像素的行偏移量。这是可选的,默认值为 0。 -
info和id是两个可选的字符串,用于识别 GCP,但根据我的经验,它们不会传递到图像中。然而,我很少使用 GCPs,所以也许有情况它们确实会传递。默认值是一个空字符串。
在列表 10.1 中,你使用表 10.1 中的信息创建了一个包含四个 GCPs(地面控制点)的列表,然后使用SetGCPs将这些 GCPs 附加到数据集上。此函数需要一个 GCPs 列表和一个包含真实世界坐标投影信息的 WKT 字符串。
现在你已经添加了 GCPs,理解它们的软件可以显示你的图像在正确的位置。如果你不需要知道用于地理参考图像的 GCPs,而更愿意使用更常见的地理变换方法进行地理参考,你可以从 GCPs 创建一个地理变换,并将其设置在数据集上而不是附加 GCPs。要使用一阶变换创建地理变换,将你的 GCPs 列表传递给GCPsToGeoTransform。然后确保你在数据集上设置地理变换和投影信息:
ds.SetProjection(sr.ExportToWkt())
ds.SetGeoTransform(gdal.GCPsToGeoTransform(gcps))
如果你不想转换你的 GCPs 到地理变换,你也可以不这么做。
10.2. 将像素坐标转换为另一图像
正如你在上一章中学到的,函数可以帮助你在真实世界坐标和像素偏移之间进行转换。此外,可以使用Transformer类进行此操作或在不同栅格的偏移之间进行转换。你可能想要这样做的一个例子是当你拼接栅格时,因为每个输入图像都放在拼图中不同的部分。为了说明这一点,让我们将一些关于科德角的高分辨率正射影像组合成一个栅格。
要组合图像,有必要知道输出拼贴的范围。找到这个范围的唯一方法是通过获取每个输入栅格的范围并计算整体的最小和最大坐标(图 10.5)。为了使这个过程更容易一些,你将创建一个获取栅格范围的函数。它使用地理变换来获取左上角坐标,然后使用像素大小和栅格尺寸来计算右下角坐标:
def get_extent(fn):
'''Returns min_x, max_y, max_x, min_y'''
ds = gdal.Open(fn)
gt = ds.GetGeoTransform()
return (gt[0], gt[3], gt[0] + gt[1] * ds.RasterXSize,
gt[3] + gt[5] * ds.RasterYSize)
图 10.5. 虚线显示将要拼接的六个栅格的足迹。实线外线是输出栅格的足迹。

你可以在下面的列表中看到如何使用此函数来帮助找到输出范围。一旦你知道了范围,你就可以计算输出尺寸并创建栅格。然后你就可以开始从每个文件中复制数据了。
列表 10.2. 将多个图像拼接在一起


在列表 10.2 中,你首先需要遍历所有输入文件,并使用它们的范围来计算最终拼贴图的范围,然后计算输出图的行数和列数。你是通过获取每个方向上的最小值和最大值之间的距离,并将其除以像素大小来做到这一点的。你确保使用ceil函数将任何部分数值向上舍入到下一个整数,以避免意外裁剪边缘。然后,你使用这些维度创建一个新的数据集。你仍然需要创建一个合适的地理变换,但这很容易通过从一个输入文件复制一个并更改你计算出的左上角坐标来实现。
到目前为止,你已经有一个适当大小的空栅格,现在是时候开始复制数据了。这就是转换器发挥作用的地方。对于每个输入数据集,你在该数据集和输出拼贴图之间创建一个转换器。第三个参数是转换器选项,但在这里你并没有使用它们。一旦有了转换器,你就可以轻松地使用TransformPoint计算与输入栅格左上角相对应的拼贴图的正确像素偏移量:
TransformPoint(bDstToSrc, x, y, [z])
-
bDstToSrc是一个标志,指定你是否想从目标栅格到源栅格或相反计算偏移量。使用True从目标到源,使用False从另一方向。 -
x、y和z是你想要转换的坐标或偏移量。z是可选的,默认为 0。
你想要根据源计算目标栅格(当你创建转换器时提供的第二个)中的偏移量,因此对于第一个参数使用False。x和y参数都是 0,因为你想得到与输入的第一行和第一列相对应的偏移量。该函数返回一个包含成功标志和包含请求坐标的元组的列表,但坐标是浮点数,所以你需要将它们转换为整数。最后,你从输入栅格中读取数据,并使用你刚刚计算出的偏移量将其写入拼贴图。然后,你继续处理下一个输入数据集。
结果拼贴图显示在图 10.6 中。你可以看到图像之间的颜色平衡并不完美。还有一点需要注意,如果输入栅格重叠,则重叠区域中的像素值将被覆盖最后覆盖重叠的栅格。栅格组合的顺序可能对你很重要,以确保你得到正确的像素值。或者,你可以实现一个更复杂的算法来平均像素值或以其他方式处理它们。
图 10.6. 马萨诸塞州科德角六张航空照片的简单拼贴

你也可以通过不提供其中一个数据集来在像素偏移和真实世界坐标之间转换坐标。例如,这将获取第 1078 列和第 648 行的像素的真实世界坐标,假设数据集有一个有效的地理变换:
trans = gdal.Transformer(out_ds, None, [])
success, xyz = trans.TransformPoint(0, 1078, 648)
我更喜欢使用ApplyGeoTransform,正如你在上一章中看到的,但你应该使用对你来说最有意义的任何一个。
10.3. 颜色表
在主题栅格中,像素值代表分类,如植被类型,而不是照片中的颜色信息。如果你想控制这些数据集的显示方式,那么你需要一个颜色表。之前在图 10.1 中展示的犹他州植被类型地图使用了颜色表,这样无论你在 QGIS、ArcMap 还是 Windows 图片查看器中打开它,图像看起来都一样。颜色表仅适用于整数类型栅格,而且我只在像素值为 255 以下(适合一个字节的值)的情况下成功实现了颜色映射。
要了解颜色表是如何工作的,让我们为已经按范围分类的高程数据集创建一个。这个文件已经为你创建好了,位于书中的数据瑞士文件夹中。它叫做 dem_class.tif,高程值已经被分类为五个不同的范围,因此像素值范围从 0 到 5,0 被设置为NoData。如果你在类似 Windows 图片查看器这样的软件中查看这个文件,你只会看到一个黑色矩形,因为这就是它解释如此小的像素值的方式。如果你在 QGIS 或另一个 GIS 软件包中打开它,软件很可能会自动拉伸数据,这样你就会看到类似图 10.7 的内容。
图 10.7. 瑞士数字高程模型,已按五个高程范围分类,然后拉伸,使小像素值彼此不同

如果你给这张图片添加一个颜色图,那么它将正确绘制而不会拉伸,你可以指定用于每个高程范围的颜色。让我们试试。
列表 10.3. 向栅格添加颜色图

列表中的前一部分与颜色表无关;它是在创建图像的副本,这样原始图像就不会被修改。有趣的部分是当您创建一个名为colors的空颜色表并将其添加颜色。SetColorEntry的第一个参数是要设置颜色的像素值,第二个参数是包含颜色红色、绿色和蓝色值的元组或列表。您为像素值 1 到 5 设置颜色。因为这是一个字节数据集,所以有 255 个可能的像素值,颜色表包含您未更改的值的零(黑色)。最后,您将颜色图添加到波段,并设置颜色解释为调色板,尽管这一步不是必要的,因为 GDAL 会自动识别。现在您的图像看起来像图 10.8,尽管不理解NoData设置的软件会绘制黑色背景。
图 10.8. 瑞士的数字高程模型,已被分类为五个海拔范围,并应用了颜色图。如果您在网上查看颜色版本,它将看起来与图 10.7 中显示的自动符号学有很大不同。

您还可以编辑现有的颜色表。比如说,您想更改您创建的颜色图,使得最高海拔范围显示为更接近白色的颜色。从波段中抓取颜色表,并更改您感兴趣的条目,在这个例子中是像素值 5:

记得打开数据集以供写入。如果您不这样做,您的更改将不会生效,并且您也不会收到错误消息。您还需要将颜色图重新添加到波段,因为您正在编辑的颜色图不再与波段链接。
10.3.1. 透明度
您是否见过颜色被称作 RGBA 而不是普通的 RGB?A代表第四个值,称为alpha,它用于指定不透明度。alpha 值越高,颜色越不透明。您可以为您的图像添加一个 alpha 波段,然后某些软件包,如 QGIS,将使用它。其他软件,如 ArcMap,在使用颜色表时忽略 alpha 波段。如果您想使用颜色表走这条路,您需要创建包含两个波段的您的数据集,其中第一个波段是您之前的像素值,第二个波段包含 alpha 值。您还需要在创建时指定第二个波段是 alpha 波段,如下所示:
ds = driver.Create('dem_class4.tif', original_ds.RasterXSize,
original_ds.RasterYSize, 2, gdal.GDT_Byte, ['ALPHA=YES'])
然后在您的 alpha 波段中添加 0 到 255 之间的值,其中 0 表示完全透明,255 表示完全不透明。我们将在下一章讨论 NumPy,但这是您如何使用 NumPy 找到第一个波段中所有等于 5 的像素并将它们设置为大约 25%透明的方法:
import numpy as np
data = band1.ReadAsArray()
data = np.where(data == 5, 65, 255)
band2.WriteArray(data)
band2.SetRasterColorInterpretation(gdal.GCI_AlphaBand)
在这里,你使用 NumPy 的 where 函数根据第一个通道的原始数据数组的值创建一个新的数组。它就像一个 if-else 语句,条件是像素值是否等于 5。如果是,那么输出数组中相应的单元格将得到一个值为 65 的值,这大约是 255 的四分之一。如果原始像素的值不是 0,那么输出将得到一个值为 255 的值,这意味着没有透明度。将这个新数组写入第二个通道,并确保你设置了该通道的颜色解释为 alpha。
如果你想要创建一个透明度更易被多种软件理解的图像,那么你可以创建一个四通道图像并放弃使用颜色映射。在这种情况下,你将红色值放在第一个通道,绿色放在第二个通道,蓝色放在第三个通道,而透明度值放在第四个通道。这种方法的缺点是,你的数据集大小至少会增加一倍,因为它会有两倍的通道数。可能还会有另一个通道来存储原始像素值,而不是只有颜色信息。否则,你将丢失关于像素分类的信息,例如土地覆盖类型。
10.4. 直方图
有时你需要像素值的频率直方图。一个例子是计算植被分类中每种植被类型的面积。例如,如果你知道有多少像素被分类为松树-刺柏,那么你可以将这个数字乘以像素面积(即像素宽度乘以像素高度)来得到松树-刺柏的总英亩数。
获取直方图的最简单方法是使用通道上的 GetHistogram 函数。你可以指定你想要使用的确切箱数,但默认情况下是使用 256 个。第一个箱包括介于 -0.5 和 0.5 之间的值,第二个箱从 0.5 到 1.5,以此类推。所以如果你有字节数据,这个直方图将每个可能的像素值(0,1,2 等)对应一个箱。如果栅格中没有现有的直方图数据,那么这个函数默认会计算一个近似值,但你也可以请求一个精确值。这个函数看起来是这样的:
GetHistogram([min], [max], [buckets], [include_out_of_range], [approx_ok],
[callback], [callback_data])
-
min是包含在直方图中的最小像素值。默认值是 0.5。 -
max是包含在直方图中的最大像素值。默认值是 255.5。 -
buckets是你想要的箱数。箱的大小是通过将max和min之间的差值除以buckets来确定的。默认值是 256。 -
include_out_of_range表示是否将低于最小值的像素值合并到最小箱中,以及将高于最大值的像素值合并到最大箱中。默认值是False。如果你想启用此行为,请使用True。 -
approx_ok表示是否可以使用近似数字,无论是通过查看概览还是仅采样像素子集。这样函数将运行得更快。默认值是True。如果你想得到精确计数,请使用False。 -
callback是一个在计算直方图期间周期性调用的函数。这在处理大型数据集时显示进度非常有用。默认值是 0,这意味着你不想使用回调函数。 -
callback_data是如果你使用回调函数时传递给回调函数的数据。默认值是None。
这个代码片段显示了近似值和精确值之间的差异,使用的是我们之前查看的分类高程栅格:
os.chdir(r'D:\osgeopy-data\Switzerland')
ds = gdal.Open('dem_class2.tif')
band = ds.GetRasterBand(1)
approximate_hist = band.GetHistogram()
exact_hist = band.GetHistogram(approx_ok=False)
print('Approximate:', approximate_hist[:7], sum(approximate_hist))
print('Exact:', exact_hist[:7], sum(exact_hist))
直方图由按区间顺序排列的计数列表组成。在这种情况下,第一个计数对应于像素值 0,第二个对应于像素值 1,依此类推。这里你只打印前七个条目,因为剩下的 249 个条目在这个数据集中都是 0。结果在这里和 图 10.9 中显示:
Approximate: [0, 6564, 3441, 3531, 2321, 802, 0] 16659
Exact: [0, 27213, 12986, 13642, 10632, 5414, 0] 69887
图 10.9. 从分类高程栅格生成的近似和精确直方图

注意到,包括总和在内的数字对于近似直方图要小得多。因此,如果你需要编制面积表,近似数字不是最佳选择,但如果你想要相对频率,它们可能工作得很好。也请注意,没有像素值为 0 的计数。这是因为 0 被设置为 NoData,所以它被忽略。
GDAL 将这些直方图存储在与栅格一起的 XML 文件中。如果你运行了此代码,现在在你的瑞士文件夹中应该有一个名为 dem_class2.tif.aux.xml 的文件。如果你打开它并查看,你会看到两组直方图数据。只要你不删除该文件,那些特定的直方图就不需要再次计算,因为 GDAL 可以从 XML 文件中读取信息。
你也可以将某个特定的区间方案设置为图像的默认值。例如,假设你想要将像素值 1 和 2 合并在一起,3 和 4 合并在一起,而将 5 独立出来。你可以这样做:
hist = band.GetHistogram(0.5, 6.5, 3, approx_ok=False)
band.SetDefaultHistogram(1, 6, hist)
在这个例子中,你创建了一个包含像素值 1 到 6 的三个区间的直方图。为什么是 6 而不是 5,当实际数据值只到 5 时?区间被创建为相等的大小,所以如果在 1 和 5 之间有三个区间,那么断点就会在错误的位置。示例中的断点将是 2.5 和 4.5,但如果你使用 5 而不是 6 的范围,它们将是 2.2 和 3.8。在这种情况下,值为 4 和 5 的像素将被合并在一起,而值为 3 的像素将单独存在,这不是期望的结果 (图 10.10)。
图 10.10. 在 0.5 和 6.5(A)以及 0.5 和 5.5(B)之间创建三个箱的结果。在情况 A 中,值 1 和 2 共享一个箱,值 3 和 4 共享一个箱,值 5 有一个单独的箱(没有值为 6 的像素)。然而,在情况 B 中,值 3 是唯一一个不共享箱的值。

一旦计算了直方图,您就可以将其设置为默认值。SetDefault-Histogram函数需要一个最小像素值、最大值,然后是计数列表。一旦设置了默认值,您就可以使用GetDefaultHistogram来获取特定的那个。当GetHistogram返回一个计数列表时,GetDefaultHistogram返回一个包含最小像素值、最大像素值、箱数和计数列表的元组:
min_val, max_val, n, hist = band.GetDefaultHistogram()
print(hist)
[40199, 24274, 5414]
当您调用GetHistogram时,您提供最小值、最大值和要使用的箱数,因此该函数不需要返回该信息,因为您已经知道了。当您调用GetDefaultHistogram时返回这些值,因为您可能不知道用于创建默认直方图的值。
10.5. 属性表
整数栅格数据集可以具有属性表,尽管根据我的经验,它们拥有的字段数量远不及矢量属性表。在表中,与单个要素对应的记录,每个栅格属性表的记录对应于特定的像素值。例如,所有值为 56 的像素将在属性表中共享相同的记录,因为每个像素不代表单个要素,而是具有相同值的多个像素应该代表相同的事物,无论是某种颜色、高程、土地利用分类还是其他事物。
对于许多栅格,属性表甚至没有意义。例如,我想不出要附加到航空照片中各种像素值的属性。事实上,当您想要包含有关每个类别的信息时,栅格属性表对于分类数据(如土地覆盖或土壤类型)最有意义。
让我们为我们在工作中使用的分类高程栅格创建一个属性表。表 10.2 显示了用于创建数据集的高程类别,这些信息存储起来将非常有用。
表 10.2. 分类高程栅格的像素值及其对应的高程范围
| 像素值 | 高程范围(米) |
|---|---|
| 1 | 0 – 800 |
| 2 | 800 – 1300 |
| 3 | 1300 – 2000 |
| 4 | 2000 – 2600 |
| 5 | 2600+ |
我们将使用以下列表中的代码将表 10.2 中的信息以及直方图计数添加到栅格的属性表中。
列表 10.4. 向栅格添加属性表

当你创建一个新的栅格属性表时,你需要定义它将拥有的列。当你添加列时,你提供三条信息。第一条是列名。第二条是数据类型,可以是GFT_Integer、GFT_Real或GFT_String之一。最后一件事情是使用附录 E 中的 GFU 常量之一来指定列的用途。(附录 C 至 E 可在 Manning Publications 网站上在线获取,网址为www.manning.com/books/geoprocessing-with-python。)我使用的桌面软件要么根本不支持栅格属性表(QGIS),要么不把它们看作特别的东西(ArcGIS),但可能存在支持这些功能的软件。因此,我通常只对这里使用的类型感兴趣。你为包含像素值的列使用了GFU_Name,为直方图计数使用了GFU_PixelCount,为描述使用了GFU_Generic。你还告诉它将有六行。
下一步是添加数据。你知道像素值范围从 0 到 5,所以你使用range函数来获取包含这些数字的列表,然后将该列表添加到第一列,即像素值列。WriteArray的第一个参数是要放入列中的数据,第二个参数是你想要添加数据的列的索引。因为你已经指定了有六行,如果你提供了一个包含超过六个项目的列表,你会得到一个错误,提示值过多。不过,你不必一次性写入所有行。如果你只提供了四个值,它就会填充该列的前四行。可选的第三个参数告诉它从哪一行开始写入数据,因此你可以通过传递 4 作为最后一个参数来添加剩余的数据,这样它就会从正确的行开始写入。
你使用相同的方法将直方图计数添加到第二列,除了这次值的列表来自GetHistogram函数。记住,在计算直方图时,0 值会被忽略,但你可能想在属性表中看到零的个数。获取包含这些值的直方图的一种方法是将NoData设置为无效值,然后计算一个之前未计算过的直方图。它需要是一个新的直方图,因为如果这个特定的直方图已经被计算过,那么 GDAL 会从你之前看到的 XML 文件中提取信息,而零仍然不会被计算。这就是为什么你在创建属性表之前将NoData设置为-1,然后使用一组你尚未使用的参数来检索直方图。方便的是,你告诉它你想要六个桶,这正好与表中的行数相同。
然后你设置高程范围。你可以创建一个包含这些描述的列表,但相反,你通过指定行、然后是列,最后是放入表中的值来逐个添加每一个。你不必为索引为 0 的第一行添加描述,因为没有太多关于NoData可以说的。
一旦你将所有数据添加到表中,你使用SetDefaultRAT将其添加到波段中,然后确保将NoData值恢复到你的波段中。图 10.11 显示了在 ArcGIS 中这个栅格属性表的截图。不幸的是,你无法使用 QGIS 查看结果。
图 10.11. 分类高程光栅的栅格属性表

10.6. 虚拟光栅格式
GDAL 虚拟格式(VRT)不是你可以添加到光栅上的另一个属性,比如属性或颜色表,但它是一个有用的格式,允许你使用 XML 文件定义数据集。虚拟光栅数据集使用其他数据集来存储数据,但 XML 描述了如何从这些其他文件中提取数据。VRT 可以用来分割数据,修改如投影等属性,甚至可以将多个数据集合并成一个。在这些情况下,原始数据集不会被更改,但修改是在软件读取数据时在内存中的数据上进行的。
例如,假设你有一个覆盖了广大空间区域的光栅数据集,但你需要在原始光栅的不同空间子集上运行不同的分析。你可以裁剪出你需要的区域,或者你可以为这些子集中的每一个定义一个 VRT,而不必在磁盘上创建分割后的光栅。对于比这里看到的更多关于 VRT 的信息,请查看www.gdal.org/gdal_vrttut.html。
在我们查看使用 VRT 操作数据之前,让我们看看一个非常简单的例子(在 Landsat/Washington 数据文件夹中称为 simple_example.vrt)。这个 XML 定义了一个具有一个波段的 VRT 数据集,而这个波段是你在上一章中创建的自然色 GeoTIFF 中的蓝色波段。
列表 10.5. 定义一个具有一个波段的 VRT 数据集的 XML

此 XML 包含一般数据集信息,例如行数和列数、空间参考系统以及地理变换。SRS 需要使用 WKT,这将占用很多空间,所以我选择在示例中截断它(第三行)。在现实生活中,你需要整个 SRS 字符串。还有一个 VRTRasterBand 元素用于数据集中的每个波段,在本例中只有一个。这包含数据类型、波段号、行数和列数以及加载数据所需的信息。这是一个简单的情况,因此只需要文件名和波段号。你想要蓝色波段,这是 nat_color.tif 中的第三个波段。relativeToVRT 属性告诉它数据文件的路径是否相对于 VRT 文件的位置。如果你想使用绝对文件名,这里使用 0。在这个特定的情况下,图像文件和 VRT 文件在同一个目录中,但如果你要移动 VRT 文件而不移动图像文件,VRT 将无法加载数据。
从 Python 创建 VRT 数据集可能有点棘手,因为你需要自己提供部分 XML。最基本的一个例子是提供源文件名和波段号。你可以设置一个类似于以下的 XML 模板,然后在添加波段到 VRT 时使用它:
xml = '''
<SimpleSource>
<SourceFilename>{0}</SourceFilename>
<SourceBand>1</SourceBand>
</SimpleSource>
'''
此代码段假设你将始终使用源栅格的第一个波段。这是因为你将使用它来定义一个自然色栅格,而不像上一章那样复制任何数据,而且每个输入栅格只有一个波段。大多数事情在 VRT 中与其他数据集类型的工作方式相同,因此创建新数据集的方式与之前相同。即使没有数据被复制,你仍然需要确保创建的数据集与原始数据集具有相同的尺寸:
os.chdir(r'D:\osgeopy-data\Landsat\Washington')
tmp_ds = gdal.Open('p047r027_7t20000730_z10_nn30.tif')
driver = gdal.GetDriverByName('vrt')
ds = driver.Create('nat_color.vrt', tmp_ds.RasterXSize,
tmp_ds.RasterYSize, 3)
ds.SetProjection(tmp_ds.GetProjection())
ds.SetGeoTransform(tmp_ds.GetGeoTransform())
现在,你可以开始添加三个输入栅格的链接。对于每一个,你需要创建一个包含一个条目的字典,其中键是 'source_0',值是包含文件名的 XML 字符串。然后你将这个字典作为 'vrt_sources' 域中波段的元数据添加。为所有三个波段重复此过程。
metadata = {'source_0': xml.format('p047r027_7t20000730_z10_nn30.tif')}
ds.GetRasterBand(1).SetMetadata(metadata, 'vrt_sources')
metadata = {'source_0': xml.format('p047r027_7t20000730_z10_nn20.tif')}
ds.GetRasterBand(2).SetMetadata(metadata, 'vrt_sources')
metadata = {'source_0': xml.format('p047r027_7t20000730_z10_nn10.tif')}
ds.GetRasterBand(3).SetMetadata(metadata, 'vrt_sources')
现在,你可以使用 QGIS 打开 VRT 数据集,你会看到一个三波段图像,如图 10.12 所示。[#ch10fig12]。与之前创建的 GeoTIFF 不同,这个不会在常规图像处理软件中打开。
图 10.12. 由三个单波段图像创建的堆叠 VRT。当以黑白打印时,看起来并不起眼,但你可以在线查看彩色版本,它看起来就像自然色(就像我们的眼睛看到的那样)。

10.6.1. 子集
我之前提到过,你可以使用 VRT 来子集图像而不创建另一个子集图像。创建空数据集的过程与上一章子集时所做的类似。你仍然需要确定行和列的数量以及新的地理变换,然后使用这些信息来创建新的数据集。这就是以下列表的第一部分所做的事情,除了它使用 VRT 驱动程序。然而,在创建数据集之后,情况会有所变化,因为在这个例子中,你需要为每个栅格波段创建适当的 XML 并将其插入到 VRT 中。这个过程将在代码列表之后进行解释。
列表 10.6. 使用 VRT 子集栅格


如代码列表之前所述,你必须创建 XML 来使用 VRT 子集你的栅格数据。这个 XML 比刚才使用的 XML 稍微复杂一些,因为它还包括源和目标范围元素。源和目标行和列的数量相同,并且是计算出的数字。源偏移量是你计算出的与感兴趣区域左上角相对应的偏移量,而目标偏移量都是 0,因为你填充了整个输出图像:
<SrcRect xOff="{xoff}" yOff="{yoff}" xSize="{cols}" ySize="{rows}" />
<DstRect xOff="0" yOff="0" xSize="{cols}" ySize="{rows}" />
这次你在 XML 中使用命名占位符来使其更容易看出内容的位置。为了格式化这个字符串,你需要一个与占位符具有相同键的字典。你可以创建这个字典一次,然后在向 VRT 添加新波段时更改波段号(因为你每次使用的是来自三波段输入图像的不同波段):
data['band'] = 2
meta = {'source_0': xml.format(**data)}
ds.GetRasterBand(2).SetMetadata(meta, 'vrt_sources')
如果一切顺利,当你用 QGIS 打开 VRT 时,它将看起来像图 10.13,并且它将完美地覆盖原始图像。
图 10.13. 子集化的 VRT

10.6.2. 创建麻烦的格式
并非所有栅格格式都允许你创建和操作多波段图像。例如,如果你在上一章尝试创建一个自然色 JPEG 而不是 TIFF,你会遇到问题,因为 JPEG 驱动程序不允许你创建多波段图像并向波段添加数据。如果你想要 JPEG 输出,这确实是个问题!幸运的是,VRT 可以救你于水火。你所需要做的就是创建一个定义所需输出的 VRT,然后使用 JPEG(或任何格式)驱动程序的CreateCopy函数。例如,要创建瓦森岛的 JPEG,打开上一节中创建的 VRT,然后将其复制到 JPEG:
ds = gdal.Open('vashon.vrt')
gdal.GetDriverByName('jpeg').CreateCopy('vashon.jpg', ds)
如果你更愿意创建一个中间的 TIFF 文件而不是 VRT,那么请直接进行,然后将 TIFF 文件复制为 JPEG。使用 VRT 的优势在于你不会在磁盘上创建可能很大的中间文件。
10.6.3. 重投影图像
记得在第八章中提到过重新投影矢量数据吗?栅格数据也可以进行重新投影,但比矢量数据更复杂。对于矢量数据,您需要每个顶点的新坐标,然后就可以开始了,但对于栅格数据,您需要处理单元格弯曲和移动的事实,并且不存在从旧单元格位置到新单元格位置的一对一映射(见图 10.14)。确定新单元格像素值的最简单方法是使用映射到输出单元格最近的输入单元格的值。这被称为最近邻,是速度最快的方法,通常您会希望用于分类数据。所有其他方法(除模态外)都会更改您的类别,这对于分类数据来说绝对不是您想要的。然而,如果使用最近邻,连续数据栅格通常看起来不会很好。对于这些,我通常使用双线性插值或立方卷积,它们使用周围像素的平均值。然而,还有其他一些方法可能更适合您的特定数据。
图 10.14. 当栅格投影时像素移动的示例。三角形和圆形是两个不同栅格的像素中心点。三角形是从圆形来源的栅格重新投影版本创建的。请注意,尺寸甚至不同。

我认为除了使用 GDAL 中的 gdalwarp 工具之外,重新投影栅格最简单的方法是使用 VRT。有一个方便的函数,当您提供空间参考信息时,它会为您创建一个重新投影的 VRT 数据集。它看起来像这样:
AutoCreateWarpedVRT(src_ds, [src_wkt], [dst_wkt], [eResampleAlg],
[maxerror])
-
src_ds是您想要重新投影的数据集。 -
src_wkt是源空间参考系统的 WKT 表示。默认为None,在这种情况下它将使用源栅格的 SRS 信息。如果这个栅格没有 SRS 信息,那么您需要在这里提供它。如果您使用None让您感到不安,您也可以在这里提供它。 -
dst_wkt是所需空间参考系统的 WKT 表示。默认为None,在这种情况下不会发生重新投影。 -
eRasampleAlg是 表 10.3 中的重采样方法之一。默认为GRA_NearestNeighbour。表 10.3. 重采样方法
Constant 描述 GRA_NearestNeighbour 最近的像素 GRA_Bilinear 4 像素的加权距离平均值 GRA_Cubic 16 像素的平均值 GRA_CubicSpline 16 像素的立方 B 样条 GRA_Lanczos Lanczos 窗口 sinc,具有 36 像素 GRA_Average 平均值 GRA_Mode 最常见的值 -
maxerror是您想要允许的最大误差量,以像素为单位。默认为 0,表示精确计算。
AutoCreateWarpedVRT 函数不会在磁盘上创建 VRT 文件,而是返回一个数据集对象,您可以使用 CreateCopy 将其保存为其他格式。以下示例使用具有 UTM 空间参考的自然色 Landsat 图像,创建一个目标 SRS 为未投影 WGS84 的扭曲 VRT,并将 VRT 复制到 GeoTIFF:
srs = osr.SpatialReference()
srs.SetWellKnownGeogCS('WGS84')
os.chdir(r'D:\osgeopy-data\Landsat\Washington')
old_ds = gdal.Open('nat_color.tif')
vrt_ds = gdal.AutoCreateWarpedVRT(old_ds, None, srs.ExportToWkt(),
gdal.GRA_Bilinear)
gdal.GetDriverByName('gtiff').CreateCopy('nat_color_wgs84.tif', vrt_ds)
此输出的外观类似于 图 10.15。
图 10.15. 使用 UTM 空间参考的原始 Landsat 图像,以及使用未投影经纬度坐标的新图像

10.7. 回调函数
通常您希望有一个指示您的过程将花费多长时间或它已经进行到什么程度。如果我正在批量处理多个文件,每个文件都需要很长时间,我有时会让我的代码打印出一条消息,告诉我它目前正在处理哪个文件。如果我想查看 GDAL 函数(如计算统计数据或扭曲图像)的进度,这不是一个有用的技术,因为一旦我调用函数,这部分处理就不再在我手中。幸运的是,GDAL 开发者想到了这一点,许多函数都接受回调函数作为参数(实际上,您在 Get-Histogram 的签名中看到了这一点)。回调函数是传递给另一个函数并从传递给它的函数中调用的函数(图 10.16)。
图 10.16. 回调函数是作为参数传递给第二个函数的函数,稍后会被调用。

在 GDAL 的情况下,回调函数被设计用来显示进度,因此它们在过程运行时会频繁调用。甚至有一个预定义的函数可供您使用,它会在每 2.5% 的进度处打印一个百分比或点。到过程完成时,此函数的输出如下所示:
0...10...20...30...40...50...60...70...80...90...100 - done.
要利用这一点,只需将 gdal.TermProgress_nocb 传递给任何接受回调函数作为参数的函数。此示例会在计算统计数据时打印进度信息:
band.ComputeStatistics(False, gdal.TermProgress_nocb)
小贴士
OGR 层上的某些方法,如 Intersection 和 Union,也接受回调参数。要使用它,导入 GDAL 并将 gdal.TermProgress_nocb 传递,就像传递给 GDAL 函数一样。您还可以使用回调来跟踪您矢量处理函数的进度,使用这里显示的技术。
您还可以使用此函数来打印出您自己的函数的进度。不是将 TermProgress_nocb 函数传递给另一个函数,而是自己调用它并使用适当的百分比。例如,如果我想在批量处理期间使用此功能代替打印文件名,我可以这样做:
for i in range(len(list_of_files)):
process_file(list_of_files[i])
gdal.TermProgress_nocb(i / float(len(list_of_files)))
gdal.TermProgress_nocb(100)
这假设 list_of_files 变量是所有要处理的文件的列表,并且 process_file 函数对文件做了些处理。每次我开始处理一个新的文件时,我会根据总文件数来确定我的进度,并将这个值传递给 TermProgress_nocb,这样我就可以得到一个可视化的进度指示。进度函数也在循环结束后被调用,以便整理事情。否则,如果最后传递的百分比不是 100%,你最终会得到这样的输出,其中最后的部分缺失:
0...10...20...30...40...50...60...70...80...90..
你可能不在乎这一点,但如果其他人正在运行你的代码,他们可能更喜欢知道事情已经完成。
注意
可能你的 GDAL 版本中,进度函数被命名为 TermProgress。
你也可以编写自己的回调函数,如果你希望进度信息看起来不同。你定义的函数需要三个不同的参数。第一个是介于 0 和 1 之间的进度百分比,第二个是一个字符串,第三个是你想要的任何内容。如果 GDAL 的某个函数调用了你的回调,它将通过第二个参数传递一个指定它在做什么的字符串。你通过传递回调函数来提供第三个参数。最好的解释方法是通过示例,所以下面的列表是一个示例,允许用户通过传递介于 0 和 1 之间的另一个数字作为 progressArg 参数来指定打印进度指示点的时间间隔。消息字符串也会在开始时打印一次。
列表 10.7. 使用回调函数

这里有一个你可能之前没有见过的技巧。通常,当你在一个函数内部声明一个变量时,当函数结束时,它会消失,对吧?这里你将变量附加到函数作为一个属性,所以它保留下来,可以在下次函数调用时使用。Python 的 hasattr 函数检查一个对象是否有一个具有给定名称的属性,你检查 my_progress 函数是否有名为 last_progress 的属性。如果没有,那么你假设这是第一次调用该函数,并打印消息参数并创建 last_progress 属性。下次函数被调用时,该属性将存在,所以不会打印消息。你还会使用该属性来跟踪到目前为止已经打印了多少个点。
接下来,你检查进程是否完成。如果是,那么你打印 done 并删除 last_progress 属性。如果你不删除该属性,那么你无法在同一个脚本中再次使用这个函数,因为它会一直认为它已经完成,而不会做任何事情。
如果进程没有完成,这在大多数情况下都是如此,你使用 divmod(它返回一个商和一个余数作为元组)来确定应该打印多少个点。因为如果进程运行得很快,可能需要打印多个点,所以你需要继续打印并递增 last_progress,直到它等于所需的点数。
这个函数使用 sys.stdout.write 而不是 print,因为在 Python 2 和 3 中 print 的工作方式略有不同,因此你需要以不同的方式调用它,以便在点后面不打印换行符。使用 write 解决了这个问题,因为它除非你请求,否则不会打印换行符。然而,你需要调用 flush 来确保点立即显示。如果点直到处理完成才打印,进度函数就没有什么用了。
现在你有了进度函数,你怎么使用它?正好和 TermProgress_nocb 一样使用,只是你需要包含一个额外的参数,指定你希望指示器打印的频率(默认值 0.02 仅在你自己调用函数时得到尊重,如果是由其他东西调用则不尊重)。以下是一个示例:
band.ComputeStatistics(False, my_progress, 0.05)
Compute Statistics...................done
这是一个简单的例子,但如果你想做更复杂的事情,同样的概念也适用。例如,如果你有一个运行时间异常长的进程,并且希望在进程的某些点上通过电子邮件接收通知,你可以做到这一点。
10.8. 异常和错误处理器
与 OGR 一样,你可以让 GDAL 在遇到问题时抛出异常。为此,只需调用 UseExceptions,并且你可以通过调用 Dont-UseExceptions 来关闭异常。通常,你需要确保可能失败的操作确实已经执行,例如打开文件。如果你不检查并且文件没有打开,那么当脚本尝试使用数据集时,它将崩溃。这取决于你正在做什么,可能没问题,但也可能不行。以下是一个简单的批量计算统计数据的示例:
file_list = ['dem_class.tif', 'dem_class2.tiff', 'dem_class3.tif']
for fn in file_list:
ds = gdal.Open(fn)
ds.GetRasterBand(1).ComputeStatistics(False)
这很好,但第二个文件名末尾多了一个“f”。当脚本尝试获取波段时,会抛出以下错误并崩溃,并且最后一个文件永远不会被查看:
ERROR 4: `dem_class2.tiff' does not exist in the file system,
and is not recognised as a supported dataset name.
Traceback (most recent call last):
File "D:\ errors.py", line 28, in <module>
ds.GetRasterBand(1).ComputeStatistics(False)
AttributeError: 'NoneType' object has no attribute 'GetRasterBand'
你有几种方法可以解决这个问题。你可能让代码检查数据集是否成功打开,如果没有,打印一条消息,让用户知道文件被跳过了。这样就不会有任何东西崩溃,并且会为最后一个文件计算统计数据:
for fn in file_list:
ds = gdal.Open(fn)
if ds is None:
print('Could not compute stats for ' + fn)
else:
ds.GetRasterBand(1).ComputeStatistics(False)
处理错误的一个更干净的方法是使用 try/except 块。如果存在多个可能失败的点,你不必检查每个点是否成功。相反,将整个内容包裹在一个 try 块中,并在 except 子句中处理所有错误:
gdal.UseExceptions()
for fn in file_list:
try:
ds = gdal.Open(fn)
ds.GetRasterBand(1).ComputeStatistics(False)
except:
print('Could not compute stats for ' + fn)
print(gdal.GetLastErrorMsg())
尽管在这种情况下遇到了错误,但错误信息不会自动打印。如果你需要它,你可以使用GetLastErrorMsg函数获取错误信息。在处理完except子句后,循环将继续处理列表中的下一个文件名。
GDAL 还有一个错误处理器的概念,当 GDAL 函数遇到错误时会调用它。默认的错误处理器会打印出你刚才看到的错误信息。如果你出于某种原因不希望打印这些信息,你可以使用内置的静默错误处理器来关闭它们。为此,在运行你希望静默的代码之前启用该处理器。PushErrorHandler函数将使处理器变为活动状态,直到你调用PopErrorHandler,这将恢复原始处理器:
gdal.PushErrorHandler('CPLQuietErrorHandler')
# do stuff
gdal.PopErrorHandler()
你还可以使用SetErrorHandler来启用处理器,但一旦启用,它就会一直有效,直到你向SetErrorHandler传递不同的处理器:
gdal.SetErrorHandler('CPLQuietErrorHandler')
# do stuff
gdal.SetErrorHandler('CPLDefaultErrorHandler')
虽然错误处理器并不限于 GDAL 函数,但如果你愿意,你也可以自己调用它们。比如说,你有一个函数,它接受两个数据集,但它们需要共享一个空间参考系统才能使你的逻辑工作。你可以通过使用Error函数来调用当前有效的任何错误处理器,该函数接受三个参数。第一个是一个错误类,第二个是一个错误数字,两者都来自表 10.4。第三个参数是错误信息。
表 10.4. 可能的错误类和数字
| 错误类 | 错误数字(如果你更喜欢类型) |
|---|---|
| CE_None | CPLE_None |
| CE_Debug | CPLE_AppDefined |
| CE_Warning | CPLE_OutOfMemory |
| CE_Failure | CPLE_FileIO |
| CE_Fatal | CPLE_OpenFailed |
| CPLE_IllegalArg | |
| CPLE_NotSupported | |
| CPLE_AssertionFailed | |
| CPLE_NoWriteAccess | |
| CPLE_UserInterrupt |
def do_something(ds1, ds2):
if ds1.GetProjection() != ds2.GetProjection():
gdal.Error(gdal.CE_Failure, gdal.CPLE_AppDefined,
'Datasets must have the same SRS')
return False
# now do your stuff
你可能想知道,为什么你会选择这样做而不是打印错误信息并从函数中返回。你当然可以这样做,但这样会给你在未来的更多灵活性。如果这个函数是你在不同情况下重复使用的模块的一部分,你可能希望根据你的应用程序以不同的方式处理错误。这为你提供了这种能力,因为你只需要更改你的错误处理程序,而不是找到并更改所有的print语句(你可能还需要更改以适应另一个应用程序)。这也使得你的函数以与 GDAL 相同的方式处理错误。如果UseExceptions有效,那么在调用Error函数时,将不会打印错误信息,而是抛出一个异常,该异常可以在try/except块中被捕获。
你也可以编写自己的错误处理器。你可能这么做是为了将错误信息记录到文件或数据库中,或者我想你可能也尝试以某种方式解决错误。如果你选择编写自己的函数,它需要接受传递给Error函数的相同三个参数。这里有一个简单示例,展示了如何使用 Python 日志模块记录错误类、原因和消息:
def log_error_handler(err_class, err_no, msg):
logging.error('{} - {}: {}'.format(
pb.get_gdal_constant_name('CE', err_class),
pb.get_gdal_constant_name('CPLE', err_no),
msg))
使用 ospybook 模块获取常量名称
ospybook 模块包含一个函数,可以帮助你获取 GDAL 常量的可读形式。传递给它对应于你想要查找的 GDAL 常量类型的区分大小写的前缀和要查找的数值。该函数返回常量的名称作为字符串。
>>> import ospybook as pb
>>> print(pb.get_gdal_constant_name('GDT', 5))
GDT_Int32
你可以使用这样的函数轻松地将错误信息发送到不同的地方。如果你想看到屏幕上的消息,你只需要导入日志模块并设置你的错误处理器:
import logging
gdal.PushErrorHandler(log_error_handler)
要将消息发送到文件,你需要添加配置日志记录器的步骤,如下所示:
import logging
logging.basicConfig(filename='d:/temp/log.txt')
现在如果你在具有不同空间参考的两个数据集上调用你的do_something函数,就会在 log.txt 中添加如下一行:
ERROR:root:CE_Failure - CPLE_AppDefined: Datasets must have the same SRS
通常,当你通过调用UseExceptions来开启异常时,错误处理器会被关闭,并且当遇到错误时会引发异常。这就是为什么在启用异常时错误信息不会自动打印。如果你想使用你的新log_error_handler函数记录错误信息,但同时也想使用异常,你可以在启用异常之后启用错误处理器,然后你应该会得到两种行为。
10.9. 摘要
-
如果没有栅格数据集的地理变换信息,请使用称为地面控制点(GCP)的已知位置。你可以从 GCP 创建地理变换。
-
将栅格属性表添加到你的主题数据集中,以便你知道每个像素值代表什么。
-
如果你想让主题数据集始终以相同的颜色绘制,请为其添加一个颜色表。
-
你可以使用虚拟栅格文件来操作数据,而无需在磁盘上创建新文件。
-
重新投影栅格最简单的方法是使用 VRT,然后将其复制到所需的格式。
-
使用回调函数提供长时间运行进程的进度信息是个好主意。
第十一章. 使用 NumPy 和 SciPy 进行局部、焦点和区域地图代数计算
本章涵盖
-
使用 NumPy 操作数据
-
使用 NumPy 和 SciPy 进行局部、焦点和区域地图代数计算
-
使用 GDAL 进行全局地图代数计算
-
重采样数据
你已经看到了如何读取和写入栅格数据,但你仍然不知道如何操作像素值来进行任何分析。航空照片是很好的基础地图,但许多类型的栅格数据集都用于科学数据分析。例如,你将在下一章看到几个土地覆盖分类的例子。如果你想创建自己的土地覆盖模型,你可能需要收集卫星图像、高程数据和气候数据,如平均降水量或温度,这些都是通常的栅格数据集。如果你想将矢量数据(如土壤类型)用于模型,你需要首先将其转换为栅格,这样你就可以与你的栅格数据集一起使用。然后,你可以使用本章介绍的技术从你的高程数据中推导出坡度和方位,并将所有数据集组合起来创建土地覆盖模型。
在本章中,你将学习几种操作栅格数据的技术。例如,你将学习如何对两个或多个栅格进行逐像素的计算。你还将看到如何使用一组相邻像素来得出新的值。这就是当你平滑或锐化数字照片时发生的事情。其他计算使用栅格中的所有像素,或者根据某些共同值将它们划分。这些都有不同的用途,你将看到所有这些的例子。
如果你计划使用 Python 处理大型栅格数据集,你需要熟悉 SciPy 项目,这是一个为科学计算设计的 Python 模块集合。NumPy、SciPy 和 matplotlib 模块都是这个项目的一部分。NumPy 被设计来处理大量数据数组,这对于栅格数据来说非常完美,因为波段本质上是一个像素值的二维数组。SciPy 包含几种科学分析例程,并使用 NumPy 来存储数据。我们将在下一章中查看这两个模块,以及几个其他模块。Matplotlib 是一个绘图模块,也是 SciPy 项目的一部分,我们将在最后一章中查看它。
11.1. NumPy 简介
关于 NumPy 已经写了许多本书,但我们将简要地看看如何创建数组并访问特定值。当你使用 GDAL 的ReadAsArray函数时,数据会自动放入一个 NumPy 数组中。一旦在那里,你可以以许多不同的方式操作你的数据。本章的大部分内容讨论了地图代数,它涉及一个或多个数组上的计算,如果你不理解使用 NumPy 数组的基本知识,那么许多例子将不会很有意义。对于更深入的信息,请参阅另一本书或参考在线的优秀文档www.numpy.org。
在 NumPy 数组中访问单个单元格值与在 Python 列表中访问值非常相似,除了它们为数组的每个维度都有一个索引。例如,如果你有一个二维数组,你需要提供行和列偏移量来指定特定的单元格。让我们从 Python 交互会话内部查看这个和其他基本概念。
小贴士
按照惯例,当将numpy模块导入 Python 脚本时,将其重命名为np。你不必遵循这一做法,但你将发现许多示例,包括 NumPy 网站上的示例,都是这样做的。
首先使用arange函数创建一个示例数组,该函数返回包含一系列数字的数组。因为这个数组只有一个维度,所以你可以使用单个索引来访问元素。你也可以通过提供用冒号分隔的起始和结束索引来获取数组的某个部分,或称为切片。
>>> import numpy as np
>>> a = np.arange(12)
>>> a
array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
>>> a[1]
1
>>> a[1:5]
array([1, 2, 3, 4])
只要数组的元素总数不变,你就可以将其重塑为不同的维度。例如,你创建的数组包含 12 个元素,因此它可以被重塑为具有三行四列的二维数组,因为这也包含 12 个元素。然而,它不能被重塑为四行四列的数组,因为这需要 16 个元素。二维数组需要按照顺序提供行和列索引来访问其元素:
>>> a = np.reshape(a, (3,4))
>>> a
array([[ 0, 1, 2, 3],
[ 4, 5, 6, 7],
[ 8, 9, 10, 11]])
>>> a[1,2]
6
小贴士
在函数中指定数组形状时,请确保以元组的形式传递维度,而不是作为单独的值。
如果你只提供一个索引n,它返回整个n^(th)行。你可以通过使用冒号作为行索引来获取整个列,这与0:n相同,其中n是行数。你可以这样检索整个第二行或第三列:
>>> a[1]
array([4, 5, 6, 7])
>>> a[:,2]
array([ 2, 6, 10])
你可以通过提供两个维度的起始和结束索引来访问二维切片。同样,在冒号的左侧不提供起始索引与使用 0 相同,如果你不提供结束索引,则在该维度上获取剩余的所有值。你也可以使用负数来省略末尾的行或列。
>>> a[1:,1:3]
array([[ 5, 6],
[ 9, 10]])
>>> a[2,:-1]
array([ 8, 9, 10])
尽管与 NumPy 数组一起工作不仅仅是访问单元格值。你需要使用多个数组一起实现许多类型的分析。如果两个或更多数组具有相同的维度,你可以在它们上执行数学和逻辑运算。这些操作是基于单元格进行的,所以例如,如果你添加两个数组,第一个数组中的[n, m]单元格将添加到第二个数组中的[n, m]单元格。相同的规则适用于乘法等操作;如果你想要数学矩阵代数行为,请使用numpy.linalg子模块。
>>> a = np.array([[1, 3, 4], [2, 7, 6]])
>>> b = np.array([[5, 2, 9], [3, 6, 4]])
>>> a
array([[1, 3, 4],
[2, 7, 6]])
>>> b
array([[5, 2, 9],
[3, 6, 4]])
>>> a + b
array([[ 6, 5, 13],
[ 5, 13, 10]])
>>> a > b
array([[False, True, False],
[False, True, True]], dtype=bool)
存在许多用于处理数组的函数,包括一个与 if-else 语句非常相似的函数。例如,你可以根据两个现有数组的比较创建具有特定值的数组,如下所示:
>>> np.where(a > b, 10, 5)
array([[ 5, 10, 5],
[ 5, 10, 10]])
where函数的第一个参数是要检查的条件,第二个参数是在条件为真时使用的值,第三个是在条件不为真时使用的值。这些值也可以是数组,只要它们与条件数组的大小相同。例如,你可以这样获取每个位置的两个值中的较大值:
>>> np.where(a > b, a, b)
array([[5, 3, 9],
[3, 7, 6]])
现在你已经看到了这些例子,让我们看看另一种从数组中提取数据的方法。你不仅限于一个值或连续数据的切片,因为你还可以使用一个索引列表。例如,创建一个包含 0 到 20 之间 12 个随机整数的数组,然后按顺序提取第九、第一和第四个值:
>>> a = np.random.randint(0, 20, 12)
>>> a
array([16, 16, 18, 1, 14, 2, 18, 19, 2, 16, 10, 8])
>>> a[[8, 0, 3]]
array([ 2, 16, 1])
如果数组是多维的,你需要提供一个列表的列表,每个维度都有一个内部列表。如果你想从一个二维数组中提取三个值,你需要提供一个包含两个其他列表的列表。这些列表中的第一个将包含三个行偏移量,第二个将包含三个列偏移量。你将在下一章中用这个技术轻松地从一系列位置采样像素值。尝试将随机数数组转换为二维,看看它是如何工作的:
>>> a = np.reshape(a, (3, 4))
>>> a
array([[16, 16, 18, 1],
[14, 2, 18, 19],
[ 2, 16, 10, 8]])
>>> a[[2, 0, 0], [0, 0, 3]]
array([ 2, 16, 1])
你也可以使用一个与你的数据数组大小相同的布尔值数组,返回的数组将只包含对应于True的值。以下是一个使用相同数组a的例子:
>>> b
array([[False, False, True, False],
[False, True, False, False],
[ True, True, True, False]], dtype=bool)
>>> a[b]
array([18, 2, 2, 16, 10])
这有什么用呢?比如说,你想要获取大于五的像素的平均值,但使用where来选择感兴趣的值是不行的,因为你仍然需要将不匹配的值设置为某个值,这会破坏你的平均值计算。使用布尔索引可以解决这个问题。
>>> np.mean(a[a>5])
15.0
有时候你需要从头开始创建一个新的数组。如果单元格需要初始化为某个特定值,你可以使用zeros或ones函数。这些函数默认返回浮点数数组,但如果你需要,可以指定数据类型。如果你需要不同的数字,你可以创建一个全为 1 的数组,并将其乘以所需的数字:
>>> np.zeros((3,2))
array([[ 0., 0.],
[ 0., 0.],
[ 0., 0.]])
>>> np.ones((2,3), np.int)
array([[1, 1, 1],
[1, 1, 1]])
>>> np.ones((2,3), np.int) * 5
array([[5, 5, 5],
[5, 5, 5]])
你可能已经注意到,在ones示例中,np.int被作为第二个参数提供。数组默认创建为浮点数,但如果你需要,可以指定不同的数据类型。这个例子没有指定它应该是 32 位还是 64 位整数,结果是系统相关的。为了确保你得到 64 位整数,请使用np.int64。可以在docs.scipy.org/doc/numpy/user/basics.types.html找到可用的 NumPy 数据类型列表。
小贴士
NumPy 数据类型和 GDAL 数据类型不是同一回事,你不能互换使用它们。
如果您需要一个不需要初始化为特定值的空数组,可以使用empty函数。这比初始化数组更快,但请确保最终填充所有单元格以包含真实数据,因为未填充的单元格将包含垃圾数据,就像这里所示:
>>> np.empty((2,2))
array([[ 2.50516998e-315, 2.50377043e-315],
[ 1.53313748e-316, 0.00000000e+000]])
在阅读本章内容的过程中,您将看到更多 NumPy 函数和用于处理数组的技术的示例。
11.2. 地图代数
地图代数是一种使用您已经熟悉的代数运算来操作栅格数据集的方法,例如加法和减法。然而,在这种情况下,使用的是两个或多个栅格而不是数字。您可以使用这些技术以多种不同的方式处理栅格数据,从简单到复杂。您可以对数据集进行润色,使其在地图上看起来更好,或者您可以从一个或多个其他数据集中创建全新的数据集。
存在四种主要的地图代数类型,所有这些类型都适用于不同类型的分析。上一节中的几个数组示例展示了局部分析,其中每个操作都针对单个像素进行。这就是您将两个数组相加时发生的情况。焦点分析使用一些周围的像素,区域操作针对具有相同值的像素,而全局分析针对整个数组。我们将查看所有这些示例。
在深入细节之前,让我们编写一个函数,以便稍后可以节省输入,如列表 11.1 所示。该函数将创建一个与现有数据集具有相同维度、地理变换和投影的输出 GeoTIFF。该函数需要五个参数:现有数据集、新数据集的文件名、包含要写入新图像的数据的 NumPy 数组、输出数据类型以及可选的NoData值。
列表 11.1. 保存新栅格的函数
def make_raster(in_ds, fn, data, data_type, nodata=None):
"""Create a one-band GeoTIFF.
in_ds - datasource to copy projection and geotransform from
fn - path to the file to create
data - NumPy array containing data to write
data_type - output data type
nodata - optional NoData value
"""
driver = gdal.GetDriverByName('GTiff')
out_ds = driver.Create(
fn, in_ds.RasterXSize, in_ds.RasterYSize, 1, data_type)
out_ds.SetProjection(in_ds.GetProjection())
out_ds.SetGeoTransform(in_ds.GetGeoTransform())
out_band = out_ds.GetRasterBand(1)
if nodata is not None:
out_band.SetNoDataValue(nodata)
out_band.WriteArray(data)
out_band.FlushCache()
out_band.ComputeStatistics(False)
return out_ds
这段代码没有新内容。它所做的只是使用现有数据集和提供的数据类型信息创建一个新的栅格,将数据写入这个新的栅格,并计算统计数据。然后返回新的数据集。所有这些代码都需要在章节的其余部分列表中,但这个函数将简化为一行。为了方便起见,它已经包含在 ospybook 模块中。
11.2.1. 局部分析
本地地图代数操作可能是最容易理解和执行的。它们作用于大小相同的两个或多个数组,并将代数方程应用于每一组像素位置。图 11.1 展示了将两个二维数组相加的局部计算示例。这是一个简单的示例,但如果需要,操作可以更加复杂。
图 11.1. 本地地图代数计算是基于像素逐个进行的,因此该方程适用于落在相同空间位置的像素。

将两个栅格相加可能一开始看起来没有用,但实际上是有用的。例如,我记得多年前帮助过一个项目,该项目使用这种技术对土地进行保护工作排名。创建了几个输入栅格,根据单个变量(如到河岸地区的距离)对位置进行排名。距离水较近的区域排名最高,其他距离区间有不同的排名。另一个输入栅格根据生物多样性对区域进行排名,另一个根据到现有发展的距离进行排名。有六个或七个这样的数据集,所有这些数据集都有少量排名类别。它们被加在一起以找到整体排名最高的位置,因此对于保护工作来说是最重要的。然后,这个简单的模型被转换成了一个交互式在线工具,允许人们更改不同变量的排名。如果用户为某个变量选择了不同的排名结构,就会创建一个新的栅格来反映这些优先级,并将适当的栅格相加以获得新的整体重要性地图。这为规划者提供了一个简单的工具,用于探索不同的场景,而无需了解任何关于 GIS 的知识。我确信现在网上存在许多更复杂的模型,但这是在线地图变得普遍之前的那些日子。
本地分析也可以用于许多其他事情。使用多光谱影像的另一个常见任务是计算各种指数,例如区分已烧毁和未烧毁的土地或测量植被中的含氮量。让我们看看用于测量“绿色度”的指数,即归一化植被指数(NDVI)。NDVI 是一个简单的指数,它使用红色和近红外波长来产生一个范围从-1 到 1 的数字。生长的植物使用红色波长进行光合作用,但反射近红外辐射,因此这两个测量值的高比率可以指示光合作用活动和健康的植被。
注意:打印书籍读者:彩色图形
本书中的许多图形最好以彩色查看。电子书版本显示彩色图形,因此在阅读时应参考。要获取免费电子书(PDF、ePub 和 Kindle 格式),请访问www.manning.com/books/geoprocessing-with-python注册您的印刷版书籍。
记得第九章中提到的近红外彩色合成图像,它清楚地显示了体育场草坪是人工的吗?让我们简要回顾一下图 11.2。在这里,您可以看到自然色彩、红色波段、近红外波段、近红外合成和 NDVI 图像。红色、近红外和 NDVI 图像是单波段,其中较亮的区域具有更高的值。请注意,植被在红色波段图像中是暗的,但在近红外波段中是亮的。植被吸收红光并反射近红外光,这些像素值测量反射回传感器的量,就像我们的眼睛看到反射回来的东西一样。彩色红外合成仅是一种视觉图像。我们的眼睛可以看到植被看起来是红色的(除非你在阅读这本书的黑白副本,在这种情况下它是灰色,看起来与自然色彩图像没有太大区别),但这在您想要在分析中使用数据时并不有用。这就是 NDVI 发挥作用的地方。NDVI 图像中的练习场是明亮的,这意味着它们具有高值,代表生长的植被。体育场场地是暗的,这使得很容易确定它是人工的。
图 11.2. 以不同方式查看同一图像的不同示例。图像 A 是由红色、绿色和蓝色波长组成的自然色彩图像,而 B 和 C 是单波段,其中明亮区域具有更高的像素值。图像 D 是一种视觉表示,让人们可以看到什么是植被,什么不是,但并不帮助数据分析。然而,图像 E 是一个单一的 NDVI 波段,其中较高的值表示生长的植被。并非所有差异在黑白中都一目了然,但该图的彩色版本可在网上找到。

在以下示例中,您将计算图 11.2E 中显示的 NDVI。公式很简单:

您可以使用 NumPy 将此方程应用于两个数组,其中一个包含红色值,另一个包含近红外值。您已经知道如何将数据读入 NumPy 数组,因为您在第九章中已经这样做过了。然而,存在一个潜在问题。红色和近红外像素都可能有 0 值,在这种情况下,分母也是 0,我们都知道不能除以 0。在我们使用的示例数据中,这种情况可能不存在,但如果你使用卫星图像,你可能会在边缘附近有大量的 0 值,这变得很重要。
你有几种处理这个问题的方法,而且根据你与谁交谈,你可能会得到不同的建议。第一种方法是像没有问题一样继续进行,尽管我不建议这种方法。默认情况下,NumPy 会警告你遇到了错误,但其余的计算将正常进行(然而,你可以更改这种行为,所以如果你的设置不同,那么事情可能会崩溃)。然而,对于无法计算像素,输出将包含无效的数字,所以你必须以某种方式处理这个问题。如果方程的分子和分母都等于 0,则输出是np.nan值,表示不是一个数字。如果只有分母是 0,则输出设置为np.inf值,表示无穷大。如果你在数据集中留下这些值,不仅会影响你的统计计算,而且不同的软件将按不同的方式处理这些值。出于这些原因,你可能希望将无效像素设置为NoData,以便标准化。你可以通过检查像素是否等于这两个值之一,并用另一个数字替换它们来实现这一点,如下所示:
ndvi = (nir - red) / (nir + red)
ndvi = np.where(np.isnan(ndvi), -99, ndvi)
ndvi = np.where(np.isinf(ndvi), -99, ndvi)
然后你也会将输出波段中的NoData值设置为你在该情况下使用的任何数字,例如-99。我喜欢使用-99,因为它不是我在用例中有效的数字,而且对我来说很容易记住,但软件包往往使用更大的数字。
你可能会认为你可以通过只对没有 0 作为分母的像素进行计算来解决这个问题,如下所示:
ndvi = np.where(nir + red > 0, (nir - red) / (nir + red), -99)
这将比第一个例子运行得更快,但你仍然会遇到除法错误并可能崩溃。至少你不必检查nan或inf,因为所有进行除以 0 的地方在计算过程中都会被分配为-99。
小贴士
使用numpy.seterr函数更改 NumPy 遇到浮点错误时的行为。
解决除以 0 问题的更好方法是使用掩码数组,这允许你在计算过程中完全忽略某些像素。这将消除除法错误,并明确指出你正在忽略哪些像素。想法是屏蔽掉你想要忽略的像素,进行计算,然后用你的NoData值填充缺失的像素。查看以下列表以获取此操作的示例。
列表 11.2. 计算 NAIP 图像的 NDVI

此示例使用与图 11.2 中所示相同的输入图像。此数据集来自美国农业部国家农业影像计划(NAIP)。这些航空图像定期获取,不同州在不同年份进行处理。尽管始终收集可见的红、绿和蓝波段,以便图像为自然色,但有时也会收集第四个近红外波段。这里使用的图像就是这样。第一个波段是红光,第四个是近红外,这就是为什么你一开始就读取这两个波段。因为输出是浮点数,所以你想要确保使用浮点数进行数学运算,所以在将其读入 NumPy 数组时,将红波段从字节转换为浮点数。
一旦数据存储在内存中,你就在两个数组之和为 0 的所有位置屏蔽掉红数组。虽然你也可以屏蔽近红外数据,但不需要,因为如果一个数组有屏蔽值,那么该像素上就不会进行任何计算,所以其他输入数组有什么值都无关紧要。如果你更愿意创建一个单独的屏蔽,因为你想要将其应用于多个数组,你可以这样做:
mask = np.ma.equal(nir + red, 0)
red = np.ma.masked_array(red, mask)
一旦屏蔽掉坏像素,你将 NDVI 方程应用于两个数组,以创建一个包含大多数像素有效 NDVI 值的第三个数组,但屏蔽掉坏像素且不包含任何值。你希望你的输出图像在这些位置有NoData,所以用-99 填充这些像素,然后确保稍后设置-99 为波段的NoData值。剩下要做的就是将新的 NDVI 数组保存到与原始 NAIP 图像相同投影和地理变换的新数据集中。
11.2.2. 聚焦分析
聚焦分析使用围绕目标像素的像素来计算一个值。对于输出中的给定单元格,该值是基于输入数据集中相应单元格及其邻居的单元格计算的。这也被称为移动窗口分析,因为你可以将其视为“窗口”的单元格,依次以每个像素为中心。一旦计算了目标像素的值,窗口就移动到下一个像素。图 11.3 显示了 3 x 3 窗口如何“移动”穿过图像。暗像素的输出值是使用九个周围浅色阴影输入像素计算的。这些类型的操作常用于平滑数据和去除随机噪声。实际上,你可能已经使用过类似的过滤器来修饰你自己的数码照片。聚焦分析还可以用于需要周围像素输入的任何其他事情,例如计算高程数据集的坡度和方位。
图 11.3. 在 3 x 3 移动窗口分析中,每个暗像素的输出值是通过使用九个周围浅色阴影输入像素计算得出的。

图 11.4 显示了一个计算 3 x 3 移动窗口平均值的平滑滤波器示例。每个输出像素的值是输入中九个周围像素的平均值。例外情况是如果目标像素在边缘,那么就没有完整的九个周围像素。在这个特定例子中,使用可用像素的平均值,但你有很多处理边缘问题的方法。在图中,输入(左)光栅中的阴影区域显示了用于计算右侧光栅中相应阴影单元格输出值的单元格。
图 11.4. 一个 3 x 3 的移动窗口,计算九个周围像素(或更少,如果目标像素在边缘)的平均值。阴影区域对应于产生一个输出像素值的输入像素窗口。

如果从图 11.4 的输入数据数组称为indata,结果称为outdata,那么图中上方的阴影输出像素的计算方式如下:
outdata[2,2] = (indata[1,1] + indata[1,2] + indata[1,3] +
indata[2,1] + indata[2,2] + indata[2,3] +
indata[3,1] + indata[3,2] + indata[3,3]) / 9
这是九个周围像素的平均值。幸运的是,你有更短的方式来写同样的事情:
outdata[2,2] = np.mean(indata[1:4, 1:4])
使用这些信息,你可能想遍历光栅的行和列来实现这样一个移动窗口,尤其是如果你有 C 语言等语言的背景。为了简化并消除没有九个输入像素的特殊情况,你可能扔掉外部的行和列,然后你的代码将类似于以下内容:

这个例子会运行,但会非常慢,除非你的光栅很小,否则你会等待很长时间才能得到输出。如果你绝对不需要这样做,在 NumPy 数组上实现这样的循环是个坏主意。你最好使用数组切片,这样你的处理速度将接近你用 C 语言可以得到的速度。为此,你需要创建九个切片,每个切片对应于九个输入像素中的一个,如图 11.5 和 11.6 所示。第一个图显示了一个有六行六列的小光栅,浅阴影单元格对应于每个示例下方文本指定的切片。深轮廓定义了围绕索引[2, 2]单元格的 3 x 3 窗口。
图 11.5. 用于 3 x 3 移动窗口的切片。每个示例显示相同输入数据,但浅阴影单元格是示例下方文本定义的切片。深轮廓定义了围绕像素[2, 2]的 3 x 3 窗口。较深的阴影单元格都在相应切片中的索引[1, 1]内。

图 11.6. 在图 11.5 中创建的各个切片,以及它们的总和和平均值。每个切片中的索引[1, 1]的阴影单元格与图 11.5 中轮廓窗口中的单元格相同,因此平均切片等同于平均窗口中的单元格。

图 11.6 展示了这些相同的切片,其中索引为 [1, 1] 的细胞被突出显示。将这些突出像素的值与 图 11.5 中暗轮廓内像素的值进行比较。它们的值是相同的,因此如果你取切片的平均值,索引 [1, 1] 的值将是 图 11.5 中轮廓的九个像素的平均值。实际上,输出包含原始数据集中所有完整的 3 x 3 窗口的平均值。再次强调,为了简化,结果数据中排除了边缘行和列。对于更大的移动窗口,你需要从边缘裁剪更多的行和列。例如,一个 5 x 5 的窗口会在每边裁剪掉两个像素而不是一个。
你可以创建所有九个切片,将它们相加,然后除以 9,就像这样:
outdata = np.zeros(indata.shape, np.float32)
outdata[1:rows-1, 1:cols-1] = (
indata[0:-2, 0:-2] + indata[0:-2, 1:-1] + indata[0:-2, 2:] +
indata[1:-1, 0:-2] + indata[1:-1, 1:-1] + indata[1:-1, 2:] +
indata[2: , 0:-2] + indata[2: , 1:-1] + indata[2: , 2:]) / 9
这看起来很麻烦,但同样,我有一个更简单的方法来做这件事。如果切片都被堆叠成一个三维数组,那么你可以使用 mean 函数,这肯定会更简单。dstack 函数会将切片堆叠在一起,这正是你所需要的。但你还必须获取所有的切片,以便将它们传递给 dstack。你可以再次输入所有内容,但这并不比之前更容易。相反,你可以使用循环来获取每个切片并将其添加到一个列表中。为此,你需要遍历三行和三列。假设你使用的是索引 0-2,当前索引可以用作切片的起始行或列。你知道当切片从行 0 开始时,它需要结束在行数减去 2 的位置。如果你将起始索引加 1,那么你也需要将结束索引加 1。因此,你可以通过将起始索引加上行数减去 2 来找到结束索引:
slices = []
for i in range(3):
for j in range(3):
slices.append(indata[i:rows-2+i, j:cols-2+j])
不仅这需要更少的输入,而且它更容易扩展到更大的窗口,就像你很快就会看到的那样。但现在你有了切片的列表,你可以使用 dstack 函数在第三维上堆叠它们,这将返回一个三维数组,可以用来计算平均值:
stacked = np.dstack(slices)
outdata = np.zeros(indata.shape, np.float32)
outdata[1:-1, 1:-1] = np.mean(stacked, 2)
默认情况下,mean 函数返回数组中所有像素的平均值,但你想计算单个空间位置中像素集的平均值,就像局部分析一样。为此,告诉函数你希望计算平均值的哪个轴。第三维是轴 2,所以如果你指定这个轴,你将得到一个与堆叠数组具有相同行和列数的数组,每个单元格的值是该位置九个切片的平均值。然而,切片比原始数据集少两行和两列,因此你创建一个与原始数据大小相同的零填充数组,然后将包含平均值数组的数组插入其中,每边裁剪掉一行和一列。
这种获取九个切片的方法可以很容易地推广成一个函数,该函数可以返回你想要的任何大小的切片(嗯,只要维度是奇数——偶数效果不好,因为没有中间单元格)。这个函数位于 ospybook 模块中,将在下一个列表中展示。
列表 11.3. 从数组中获取任何大小的切片的函数

现在你可以使用到目前为止所学的一切来对一个高程数据集运行平均平滑滤波器。图 11.7 显示了珠穆朗玛峰周围地区的 DEM。由于某种原因,一条接缝线正好穿过中间,北部看起来比南部好。我想也许平滑数据集会使接缝线不那么明显。不管是否真的做到了,这是否值得讨论,但平滑后的图像确实与原始图像不同。这在图像的北部尤其明显,在平滑版本中,等高线不那么明显。下面的列表显示了应用该滤波器的代码。
图 11.7. 珠穆朗玛峰周围地区的数字高程模型,以及使用 3 x 3 移动平均滤波器平滑后的版本

列表 11.4. 平滑高程数据集

虽然达到这个目标花了一些时间,但过滤代码最终变得很简单。你使用make_slices函数创建九个切片,将它们堆叠成一个三维数组,然后使用mean函数计算第三维的平均值。因为切片比原始数据小,所以你将结果放入一个已经初始化为NoData值的正确大小的数组中间。只要记得将这个值传递给make_raster函数,就可以确保忽略的边缘被设置为NoData。
没有任何东西阻止你将更复杂的函数应用到组成移动窗口的单元格上。实际上,这正是许多分析所希望做的。一个例子是从高程模型计算斜率。有几个算法可以计算斜率,其中一个在图 11.8 中展示。
图 11.8. 从周围单元格的高程值计算单元格 e 的斜率的算法

下一个列表显示了使用这些方程计算珠穆朗玛峰 DEM 斜率的代码。请注意,为了使此算法正常工作,高程单位必须与水平单位相同。例如,如果你的数据集使用 UTM 投影,那么坐标以米为单位表示,因此高程值也必须是米。
列表 11.5. 从 DEM 计算斜率


这比平滑示例要复杂一些,但还不算太糟糕。大部分工作都是计算斜率。不过,你需要知道切片在slices列表中的存储顺序,以便在方程的每一部分使用正确的切片。make_slices函数会按照从左到右和从上到下的顺序返回它们,换句话说,如果参考图 11.8,则是按字母顺序。与平滑示例不同,在这种情况下,你不需要将切片堆叠成三维数组,因为需要在斜率方程中单独引用它们。再次强调,确保边缘设置为NoData值。输出看起来就像图 11.9 中所示的那样。
图 11.9. 原始珠穆朗玛峰 DEM 及其派生的坡度栅格

使用 SciPy 进行焦点分析
SciPy 是一个多功能的 Python 模块,旨在用于科学数据分析,它使用 NumPy 数组来存储大量数据。它包含子模块,如插值、傅里叶变换、线性代数、统计学、信号处理和图像处理等。多维图像处理子模块包含可以执行与使用 NumPy 相同的操作的过滤函数。使用 SciPy 可能比使用 NumPy 更容易,但希望你现在对使用 NumPy 数组的工作已经足够了解,可以找出如何解决你可能遇到的其他问题。
使用 SciPy 的一个优点是它会通过填充边缘周围的额外单元格来为你处理边缘问题,这样就可以在所有单元格上执行计算。它有几种不同的方法来填充这些额外像素,你可以决定使用哪一种。默认模式是“反射”模式,它会以相反的顺序重复边缘附近的值。你也可以使用最近的值、你选择的常数值或一些其他值重复方法。
SciPy 中内置的一个过滤器是均匀滤波器,它是一个与列表 11.4 中的平滑滤波器工作方式相同的平滑滤波器。接下来的列表显示了如何使用它。
列表 11.6. 使用 SciPy 的平滑滤波器

如您所见,运行实际滤波器只需要一行代码,其余的都是处理读取和写入数据。uniform_filter函数的唯一必需参数是包含要平滑数据的 NumPy 数组,但你还有几个可选参数。你在这里使用了其中两个。size参数指定了要使用的移动窗口的大小,实际上你在这里并不需要使用它,因为默认值已经是 3。你还使用mode参数来更改处理边缘的方法,以便使用最近的像素值来填充边缘。
存在其他的内置过滤器,包括最小值、最大值和中值。但对于更复杂的情况,比如斜率计算,该怎么办呢?你所需要做的就是创建一个执行你想要计算的功能的函数,然后将它传递给一个通用过滤器函数,如下面的列表所示。
列表 11.7. 使用 SciPy 计算斜率


你首先需要编写一个名为 slope 的自定义过滤器函数,它包含与之前完全相同的数学公式,因此应该看起来很熟悉。你的过滤器函数的第一个参数必须是一个一维数据数组,该数组将用于计算。方便的是,单元格值将与你在之前的 make_slices 函数中使用的顺序相同。第一个值是左上像素,第二个是上中像素,以此类推,直到结束于右下像素。如果你的函数需要,它也可以接受额外的参数,但这不是自定义过滤器的必需要求。在这种情况下,你需要知道斜率计算所需的像素尺寸,因此你的函数还需要像素宽度和高度。
一旦你有了自定义的过滤器函数,你可以在调用 SciPy 的 generic_filter 函数时将其作为参数提供。generic_filter 的第一个参数是包含要过滤数据的 NumPy 数组,第二个是使用的过滤器函数。这些是唯一必需的参数,但同样,你也可以使用可选参数。在这种情况下,你指定了一个 3 x 3 的移动窗口,但也可以使用不同的大小,甚至可以使用布尔数组来精确指示要传递给过滤器的哪些单元格值。你可以在 SciPy 文档中了解更多相关信息。再次强调,你改变了处理数组边界的默认方法,最后,你提供了一个包含你的函数所需的额外参数的元组。generic_filter 函数将适当的像素值传递给你的函数以计算每个输出单元格的值。
分解焦点分析
如果您想进行移动窗口分析,但没有足够的 RAM 来在内存中存储所有内容,怎么办?您可以拆分图像为块,但不是处理离散的数据集,而是让它们相互重叠。图 11.10 展示了使用步长参数为 5 一次读取多行的一个示例。然而,由于重叠,每次读取的行数超过五行。深色轮廓显示了被处理的行,而阴影区域是那一迭代中获得有效数据的单元格。因为这是一个 3 x 3 的窗口,所以每边都有一个空行和列。想法是在每个块的顶部和底部各添加一行,以便每行最终都有有效数据。第一次通过时,底部添加了一行额外的数据,因此处理了六行而不是五行。第二次通过时,处理了两行额外的数据。为了在顶部获得额外的一行,起始偏移量被移动到一个更早的行。例如,对于步长值为 5,第二次迭代通常从第 5 行开始读取,但在这个例子中,它从第 4 行开始。第三次通过类似,但可用的行数有限,因此只读取了四行。您可以看到,除了顶部和底部之外的所有行最终都获得了有效数据。
图 11.10. 将图像拆分为重叠的块。粗轮廓显示了从磁盘读取的单元格,而阴影单元格是获得有效数据并写入输出的单元格。

尽管 Everest 数据集很小,但请暂时假设它太大,无法一次性处理,但您有足够的 RAM 来一次处理大约 100 行。以下列表显示了您如何做到这一点。
列表 11.8. 分块进行焦点分析


这大部分与您之前编写的代码相似。请记住,如果可能的话,您将读取两行额外的数据,因此在检查是否有足够的行来读取整个块时,需要考虑这些额外的行。您还确保在第一次迭代中不要尝试使用-1 作为行偏移。一旦确定要抓取的行数,您就按之前的方式读取并处理它们。但是,对于除了第一个块之外的所有块,您确保不要将处理后的数据的第一行写入输出。如果您这样做了,它将覆盖之前迭代中写入的好数据。因为您正在忽略这一行,所以您还必须增加用于写入的行偏移量。
11.2.3. 区域分析
区域分析作用于具有特定值或属于同一区域的单元格。区域通常由一个栅格定义,分析使用来自第二个栅格的值进行。例如,如果你有一个显示土地所有权类别(如联邦、州和私人)的栅格,以及一个显示土地覆盖的第二个栅格,你可以使用所有权类别作为区域来确定每个土地覆盖类型在每个所有权类别中的面积,如图 11.11 所示。
图 11.11. 使用所有权和土地覆盖类型进行区域分析的示例。每个土地覆盖类型的像素数按所有权区域计数。

首先,让我们看看如何使用 NumPy 来完成这项工作,然后是一个更灵活的方法,使用 SciPy。你在这里想要的基本上是一个二维直方图。常规直方图给你每个区间的项目数,但在这个例子中,你想要将区域视为一组区间,将土地覆盖视为另一组区间,然后获取每个区域和土地覆盖区间组合的计数。你可以定义区间的几种方法,包括让 NumPy 为你做,但在这里,你会看到如何自己完成。一组区间由一个包含区间边界的数组定义。数组中的第一个数字是第一个区间的下限,第二个数字是第一个区间的上限(非包含),也是第二个区间的下限(包含),依此类推。数组中的最后一个数字是最后一个区间的上限。获取此特定用例的区间的简单方法是从数据集中获取唯一值。NumPy 的unique函数按排序顺序返回这些值。因为下限是包含的,所以这个数字列表将创建与数据集值对应的区间的下限。剩下要做的就是向末尾添加一个更大的数字来形成最后一个区间的上限。以下函数为你创建这个区间边界的数组:
def get_bins(data):
"""Return bin edges for all unique values in data. """
bins = np.unique(data)
return np.append(bins[~np.isnan(bins)], max(bins) + 1)
现在你已经知道了如何定义区间,让我们看看如何使用 NumPy 的histogram2d函数来获取计数。此函数的两个必需参数是包含要分箱的值的两个数组,其中一个可选参数允许你指定你想要使用的区间。如果图 11.11 中显示的右上角数据集的值在一个名为zones的数组中,而右上角数据集的值在一个名为landcover的数组中,那么你可以这样得到一个双向直方图:
>>> hist, zone_bins, landcover_bins = np.histogram2d(
... zones.flatten(), landcover.flatten(),
... [get_bins(zones), get_bins(landcover)])
>>> hist
array([[ 3., 1., 10., 4., 4.],
[ 7., 5., 15., 8., 20.],
[ 2., 0., 0., 7., 14.]])
注意这里的一些事项。首先,histogram2d 函数需要数据数组为一维的,而 flatten 函数负责处理这个细节。histogram2d 函数返回三个值:一个二维直方图和两组 bins,每组对应一个输入数组。直方图的行对应于传入的第一个数组的 bins,列对应于第二个。在这种情况下,这两个 bins 输出将正好是你传入的值,但如果你没有明确定义你的 bins,那么这两个返回值将告诉你用于计算的 bins 是什么。
如果你安装了 SciPy,你可以使用一个更通用的 SciPy 函数stats.binned_statistic_2d来完成相同的事情。下面的列表展示了如何使用这个函数来计算每个 ecoregion 区域中每个 landcover 类像素的数量,如图 11.12 所示。
图 11.12. 在 SWReGAP 土地覆盖分类上绘制 ecoregion 边界

列表 11.9. 使用 SciPy 进行区域分析


你首先需要将 ecoregion 和 landcover 数据集作为展平的一维数组读入(因为这个函数,就像histogram2d,需要单维数组),并且计算每个数据集所需的 bins。然后,你使用binned_statistic_2d函数创建你的直方图。前两个参数与histogram2d函数相同,即将要分组的两个数据集。与histogram2d不同,这个函数不仅可以计数出现次数,还可以计算统计数据,因此它还需要一个包含要计算统计数据的值的第三个数组。由于你计算像素的数量,在这种情况下,使用 landcover 或 ecoregions 无关紧要,但这里使用 landcover。函数的下一个参数指定你想要计算哪个统计数据,在这种情况下是“count”(其他选项是平均值、中位数、总和或你提供的自定义函数)。但你可以通过传递一个包含高程数据集的第三个参数和“mean”作为第四个参数来计算每个 ecoregion 和 landcover 组合的平均高程。无论如何,你提供 bin 边界作为最后一个参数,就像你之前做的那样。这会返回与直方图函数相同的输出,以及一个额外的输出,指示数据值落在哪个 bins 中。
这次你野心勃勃,还给你的直方图添加了几个箱标签。因为不同的土地覆盖是列,所以你使用这些箱作为列标签。记住,箱数组中的最后一个项目是上限,不需要用来标记你的箱。你将土地覆盖箱数组中除了最后一个数字之外的所有数字插入到直方图的第一行。insert 函数需要插入的数据,插入的位置索引,要插入的数据,以及轴,其中 0 表示一行。你使用相同的方法将生态区箱插入为行标签,除了你必须在现在为土地覆盖标签的第一行添加一个占位符。你在开始处插入 0,然后使用轴 1 在你的直方图开始处插入一列。
如果你想要知道面积而不是像素计数,你可以在添加标签行和列之前将直方图数组乘以像素的面积。
一旦你的表格完成,你就将其写入一个文本文件。savetxt 函数的 fmt 参数指定了你在输出中希望数字的格式。如果没有这个参数,你可能会得到科学记数法;这并没有什么问题,但在这里你指定了整数。% 是格式字符串的第一个字符,1 表示你希望至少打印出一个数字,.0 表示小数点后没有数字,而 f 表示它将处理一个浮点数。有关格式字符串的更多信息,请参阅 NumPy 文档中关于 savetxt 的部分,网址为 docs.scipy.org/doc/numpy/reference/generated/numpy.savetxt.html。
你的输出应该类似于 图 11.13。
图 11.13. 直方图输出的前几列。第一列指定了生态区(255 是 NoData 单元),而最上面一行指定了土地覆盖类别。所有其他值都是单元格计数。

如果你想知道每个生态区中最常见的土地覆盖类型,但不关心计数,你可以使用一维的 binned_statistic 函数,因为你只需要对一个生态区进行分箱。不幸的是,模式不是支持的统计类型之一,但你可以提供自己的统计函数。你只需要编写一个简单的函数,该函数返回模式,然后将它传递给 binned_statistic,如下所示:
def my_mode(data):
return scipy.stats.mode(data)[0]
modes, bins, bn = scipy.stats.binned_statistic(
eco_data, lc_data, my_mode, eco_bins)
你可以使用这种技术来计算你想要的信息,只要它能从一维数组中计算出来。
11.2.4. 全球分析
全局函数在整个图像上工作,例如邻近分析或成本距离。邻近分析确定每个单元格到最近标记为源单元格的欧几里得距离,而成本距离确定每个单元格与最近源单元格之间旅行的最低成本,该成本由成本表面确定。例如,如果你在山脉之间行走,最简单、因此成本最低的路径可能不是最短的。在这种情况下,成本表面可能来自高程和坡度栅格,而山口上的单元格的成本会比陡峭的山脊上的单元格低。
GDAL 内置了全局分析函数,你将看到如何使用其中几个函数来确定爱达荷州弗兰克·奇奇-无回报河荒野区域内区域到最近道路的距离(图 11.14)。你将从州级道路形状文件和一个显示荒野边界形状文件开始。这个荒野区域由几个多边形组成,所以你需要选择它们,获取所选多边形的范围,并使用该矩形选择你感兴趣的路线。然后你可以使用 GDAL 创建一个栅格,其中包含有道路的地方为 1,其他地方为 0。这将被用作源图层,以确定从道路到每个其他像素的距离。下面的列表显示了如何完成所有这些。列表很长,但这是因为数据尚未预处理。
图 11.14. 用于计算荒野区域内平均道路距离的数据。A: 深色阴影区域是感兴趣的荒野区域,线条是道路(道路数据集有瑕疵,但在示例中将被视为真实数据)。B: 道路在栅格化后不再是平滑的线条。C: 邻近分析的结果,其中明亮区域离道路更远。D: 移除了非荒野区域的邻近数据集。

列表 11.10. 邻近分析


由于你使用的是州级数据集,所以你首先要做的是找到你感兴趣的荒野区域的范围。为此,你打开荒野形状文件,并设置一个属性过滤器,选择所有 WILD_NM 属性值等于 'Frank Church – RONR' 的记录。由于层的 GetExtent 函数即使在应用了过滤器的情况下也返回整个层的范围,你必须想出一个不同的方法来获取边界坐标。解决方案是制作一个列表,列出所选每个多边形的范围,然后从该列表中找到最小和最大的坐标。创建多边形范围列表是足够简单的:
envelopes = [row.geometry().GetEnvelope() for row in wild_lyr]
这个列表中的每个元组包含最小和最大的 x 值,以及最小和最大的 y 值,顺序如下。现在如果你将这些元组组合在一起,你最终会得到四个列表,每个列表分别对应最小和最大的 x 和 y。从那里,提取每个列表中的最极端值就变得简单了:
coords = list(zip(*envelopes))
minx, maxx = min(coords[0]), max(coords[1])
miny, maxy = min(coords[2]), max(coords[3])
现在你已经得到了荒野范围的边界坐标,你可以在道路层上设置一个空间过滤器,以选择仅在该矩形内的道路:
road_lyr.SetSpatialFilterRect(minx, miny, maxx, maxy)
在获取到感兴趣的路线后,你将它们转换成栅格波段,用于你的邻近度分析。栅格波段必须已经存在,然后将矢量要素烧录到其中。你根据在脚本早期选择的单元格大小来确定行和列的数量。较小的单元格大小会导致更精确的距离,但也会大大增加处理时间。对于这个例子,十米是一个合理的大小。
cols = int((maxx - minx) / cellsize)
rows = int((maxy - miny) / cellsize)
现在你有了创建新栅格数据集所需的所有信息,于是你创建了它。它需要一个地理变换,以便将道路烧录到正确的位置,因此你从边界坐标和行数和列数中构建一个地理变换。你还从道路层复制了空间参考。记住,层的 GetSpatialRef 函数返回一个空间参考对象,但栅格数据集的 SetProjection 函数需要一个 WKT 字符串,这就是为什么你必须将层的空间参考作为字符串获取:
road_ds.SetProjection(road_lyr.GetSpatialRef().ExportToWkt())
road_ds.SetGeoTransform((minx, cellsize, 0, maxy, 0, -cellsize))
现在你可以使用以下函数最终将道路烧录成栅格波段:
RasterizeLayer(dataset, bands, layer, [transformer], [transformArg],
[burn_values], [options], [callback], [callback_data])
-
dataset是包含要烧录到其中的波段(s)的栅格数据集。 -
bands是要烧录数据的波段列表,其中第一个的索引为 1。 -
layer是将要素烧录到栅格波段中的 OGR 层。 -
transformer是一个 GDAL 转换器对象,用于将地图坐标转换为像素偏移。如果没有提供,则函数将使用地理变换创建自己的转换器。 -
transformArg是转换器的回调数据。 -
burn_values是要烧录到栅格中的值的列表。如果提供了此参数,它必须与波段长度相同。对于字节数组,默认值为 255。 -
options是一个 key=value 字符串的列表。有关可能的列表,请参阅附录 E。(附录 C 至 E 可在 Manning Publications 网站上在线获取,网址为www.manning.com/books/geoprocessing-with-python。) -
callback是用于报告烧录进度的回调函数。 -
callback_data是传递给回调函数的数据。
你使用此函数将 1 的值烧录到所有曾经有道路的地方:
gdal.RasterizeLayer(
road_ds, [1], road_lyr, burn_values=[1], callback=gdal.TermProgress)
如果您将此栅格加载到 GIS 中,直到您开始放大,它看起来不会太多。这是因为像素大小非常小,您需要放大才能看到其中许多。当您放大时,您会看到道路是块状的,就像图 11.15 中的那样。这是它们现在被表示为像素而不是平滑矢量线的结果。
图 11.15. 栅格化的道路

一旦您有了道路的栅格表示,您就几乎准备好使用 ComputeProximity 函数来计算它们的邻近度:
ComputeProximity(
srcBand, proximityBand, [options], [callback], [callback_data])
-
srcBand是包含要计算其邻近度的特征的栅格带。默认情况下,任何非零像素都被视为特征。 -
proximityBand是存储邻近度测量的栅格带。 -
options是一个包含 key=value 字符串的列表。有关可能的列表,请参阅附录 E。 -
callback是用于报告进度的回调函数。 -
callback_data是要传递给回调函数的数据。
与 RasterizeLayer 函数类似,ComputeProximity 函数要求输出栅格带已经存在。您创建一个新的数据集,并从您的道路栅格中复制空间参考信息和地理变换,然后使用地图距离而不是默认的像素距离来计算邻近度:
gdal.ComputeProximity(
road_ds.GetRasterBand(1), prox_ds.GetRasterBand(1),
['DISTUNITS=GEO'], gdal.TermProgress)
尽管您现在有一个邻近度栅格,但您只想获取荒野区域内的统计数据。您可以使用区域分析来计算这些信息,或者您可以完全删除非荒野数据。但无论如何,都需要栅格化荒野多边形,所以您以类似道路的方式执行此操作,只是您使用 MEM 驱动程序将数据集存储在内存中而不是写入磁盘。然后您将荒野和邻近度数据都读入 NumPy 数组,并将所有邻近度值更改为 -99,如果荒野值为 0,这表示像素不在荒野多边形内。
prox_data[wild_data == 0] = -99
一旦您将数据保存回磁盘,您就可以正确地计算统计数据,并且您还有一个很好的邻近度数据集供以后使用。图 11.16 显示了该数据集的一部分,放大到足够远以了解其工作原理。像素越亮,距离道路的距离就越长。
图 11.16. 带有道路绘制的邻近度数据集的小部分。亮度较高的区域表示距离道路较远。

11.3. 重采样数据
在第九章中,你学习了如何通过改变存储数据的数组大小来对数据进行重采样。然而,其他重采样方法可以让你对结果有更多的控制。一种简单的方法是使用切片来保持特定间隔的像素值,并丢弃其间的所有值。为此,在指定切片时提供一个步长值。步长值为 2 表示 NumPy 保留每个第二个值,3 表示保留每个第三个值,依此类推。以下示例展示了如何保留每隔一个单元格,从而将行和列减半:
>>> data = np.reshape(np.arange(24), (4, 6))
>>> data
array([[ 0, 1, 2, 3, 4, 5],
[ 6, 7, 8, 9, 10, 11],
[12, 13, 14, 15, 16, 17],
[18, 19, 20, 21, 22, 23]])
>>> data[::2, ::2]
array([[ 0, 2, 4],
[12, 14, 16]])
对于这个示例,data[0:4:2, 0:6:2]提供的结果与data[::2, ::2]相同。不提供第一个冒号周围的起始和停止索引意味着你想要从开始到结束。第二个冒号之后的数字是步长索引。如果你想从第二行和第二列而不是第一行和第一列开始,你可以这样做:
>>> data[1::2, 1::2]
array([[ 7, 9, 11],
[19, 21, 23]])
这应该看起来很熟悉,因为结果与从文件中读取数据到不同大小的数组时自动重采样的结果相似。
你也可以增加数组的大小,这是减小像素大小的常用方法。为此,使用 NumPy 的repeat函数,该函数需要一个数据数组、重复每个值的次数以及要使用的轴。如果你不提供轴,则数组会被展平为一维。轴为 0 表示重复行,值为 1 表示重复列,如下所示:
>>> np.repeat(data, 2, 1)
array([[ 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5],
[ 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11],
[12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17],
[18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23]])
注意到每一列都是重复两次吗?为了使每个值重复四次(这样行和列都加倍),请在行和列上各调用一次repeat函数:
>>> np.repeat(np.repeat(data, 2, 0), 2, 1)
array([[ 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5],
[ 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5],
[ 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11],
[ 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11],
[12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17],
[12, 12, 13, 13, 14, 14, 15, 15, 16, 16, 17, 17],
[18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23],
[18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23]])
但让我们看看一些更有趣的东西。你还可以使用多个切片来应用自定义算法,而不是使用单个像素值。例如,如果你想将重采样到原始大小的四倍像素(长度和宽度都是两倍),你可以取这四个像素值的平均值,并将其用作新值。图 11.17 展示了这个示例。
图 11.17. 增加单元格大小,并使用输入像素的平均值作为输出值

在图 11.17 的情况下,你需要四个数字来计算输出值。要使用切片完成相同的事情,你需要四个切片,每个切片对应于四个输入值中的一个。然而,与用于移动窗口的切片不同,这些切片将比原始数组小得多。相反,它们将与输出数组具有相同的大小,并且每个切片将包含每个输出单元格的一个值,如图 11.18 所示。该图显示了原始数据,但构成每个切片的单元格被突出显示。一个切片将包含用于计算输出的每组四个像素中的左上像素。另一个切片将包含每个右上像素,依此类推。在这个例子中,每个切片都有三行三列,这与输出的大小相同。如果你按像素逐个对这些切片取平均值,你最终会得到图 11.17 中显示的值。例如,左上角将有一个值为(3 + 5 + 4 + 5) / 4 = 17 / 4 = 4.25。
图 11.18。用于通过输入值的平均值进行重采样的切片。阴影单元格是用于创建每个较小切片的单元格。较小的数组被平均在一起以得到最终结果。

让我们来看看如何用代码来实现这一点。再次强调,如果你编写一个函数来创建所需的切片,你的生活将会更加轻松。下面的列表显示了这样一个函数,它根据原始数据和窗口大小(例如,来自图 11.18 的示例中的 2 x 2)返回切片。
列表 11.11。创建阶梯式切片的函数
def make_resample_slices(data, win_size):
"""Return a list of resampled slices given a window size.
data - two-dimensional array to get slices from
win_size - tuple of (rows, columns) for the input window
"""
row = int(data.shape[0] / win_size[0]) * win_size[0]
col = int(data.shape[1] / win_size[1]) * win_size[1]
slices = []
for i in range(win_size[0]):
for j in range(win_size[1]):
slices.append(data[i:row:win_size[0], j:col:win_size[1]])
return slices
这个函数首先计算将使用的最后一行和列。这是必要的,因为原始数据可能不能被你想要的窗口大小整除。例如,在图 11.19 中,输入数组有五行五列。该图显示了用于取四个像素平均的两个所需切片,但第二个比第一个小。如果你尝试一起使用这两个切片,你会得到一个错误,因为它们的大小不同。列表 11.11 中的函数通过截断第五行和列来处理这个问题,这样切片就是从四行四列创建的。为此,总行数或列数被除以窗口中的行数或列数。对于图 11.19 中的例子,这将是两个方向上的 2,所以数据可以每个方向放入两个完整的窗口。将这个数乘以窗口大小,得到要使用的总行数或列数,在例子中是 4。这个数字用于对切片设置上限。
图 11.19。当要重采样的数组的维度不能被窗口的维度(在本例中为 2 x 2)整除时,切片的大小不同。这必须被考虑到,以确保切片具有相同的大小。

一旦知道了行数和列数,该函数就会为每个输入位置创建一个切片,使用窗口大小作为步长参数,因此每个窗口只提取一个输入像素。所有切片都作为列表返回,一旦你有了这些,你就可以应用任何你想要的算法,只要你能编写出来。要得到平均值,你可以堆叠切片,然后使用 NumPy 的mean函数,就像你为移动窗口所做的那样。
小贴士
不要忘记在重采样数据时更改 geotransform 以反映新的单元格大小。
如果你的输出单元格大小是原始大小的倍数,这种技术很棒,但在其他情况下它们不起作用。让我们看看一种提取特定像素的方法,这些像素不能用步长参数指定。为此,你需要知道原始像素大小、新像素大小以及原始图像中的行数和列数。通过将新像素宽度除以原始宽度来获取宽度缩放因子,并对像素高度做同样的处理。例如,如果你的原始图像的像素宽度为 10,但你的目标宽度为 25,那么缩放因子是 2.5。新像素的宽度是 2.5 个旧像素。
将缩放因子除以二,以确定新像素的中心距离边缘有 1.25 个旧像素。这个中心点是你想要的,因为你在重采样时使用新像素的中心来确定使用哪些附近的旧像素。为了以原始单元格偏移量的形式获取新像素的中心 x 值,创建一个从中心(1.25)开始并按缩放因子(2.5)递增的数组。你需要确保这个数组达到,但不超出原始数组中的总列数。对行做同样的处理,这样你就有了包含 x 和 y 偏移量的两个数组。图 11.20 显示了几个像素的示例。交替阴影区域是原始像素(大小为 10),粗轮廓表示大小为 25 的像素。点是大像素的中心点,文本显示了这些点的坐标,以较小像素为基准。
图 11.20。将像素大小重采样到不是原始大小倍数的大像素大小。交替阴影区域是原始像素,粗轮廓显示新的像素。点是新像素的中心点。

一旦你有了 x 和 y 偏移量的列表,你可以使用 NumPy 的meshgrid函数来获取两个包含所有可能坐标的新数组,这些坐标是从这些值获得的。例如,如果你的行偏移量是(3, 5),列偏移量是(2, 4),那么可能的组合是[(3, 2), (3, 4), (5, 2), (5, 4)],而meshgrid将返回两个四元素数组,一个用于行偏移量,一个用于列。
下面的列表显示了一个函数,该函数计算缩放因子,创建偏移量数组,然后创建并返回坐标数组。
列表 11.12. 获取旧像素的新偏移量的函数
def get_indices(source_ds, target_width, target_height):
"""Returns x, y lists of all possible resampling offsets.
source_ds - dataset to get offsets from
target_width - target pixel width
target_height - target pixel height (negative)
"""
source_geotransform = source_ds.GetGeoTransform()
source_width = source_geotransform[1]
source_height = source_geotransform[5]
dx = target_width / source_width
dy = target_height / source_height
target_x = np.arange(dx / 2, source_ds.RasterXSize, dx)
target_y = np.arange(dy / 2, source_ds.RasterYSize, dy)
return np.meshgrid(target_x, target_y)
一旦有了坐标,你可以利用偏移量列表可以用来从 NumPy 数组中提取值的事实,以获取位于新像素中心直接下方的原始像素的值。这是最近邻重采样,它使用原始数组中最接近像素的值,并且不进行任何其他处理。为此,你会提取计算出的索引处的值,然后完成它,如下所示:
ds = gdal.Open(fn)
data = ds.ReadAsArray()
x, y = get_indices(ds, 25, -25)
new_data = data[y.astype(int), x.astype(int)]
唯一的技巧是,你需要将索引转换为整数,否则当尝试使用它们来索引数组时,NumPy 会抱怨。
最近邻法简单、快速,并且是适用于分类数据的一少数几种合适的重采样方法之一,但它并不是连续数据的最佳选择。对于这些类型的数据,你通常希望使用几个周围的像素来计算你的新值。你可以使用之前所做的那样进行平均,或者使用几种其他常见的重采样方法之一。这些方法的两个例子是 双线性插值,它对四个最近的像素进行加权平均,以及 立方卷积,它通过 16 个最近的像素拟合一条平滑曲线,并使用该曲线来计算新值。你将编写一个函数,使用你的 get_indices 函数的输出执行双线性插值。图 11.21 中的阴影区域显示了每个中心点使用的四个原始像素。
图 11.21. 阴影区域显示了最接近新像素中心点的四个原始像素。这些是用于计算新像素值的像素。

要获取这四个像素的值,首先需要从计算出的索引中减去 0.5,以便它们对应于输入像素的中心,而不是边缘。然后你需要确定这些坐标两侧的整数,这给出了要使用的偏移量。例如,如果行坐标是 4.25,那么你会使用第 4 行和第 5 行。如果你也对列偏移量做同样的事情,你就有了两行和两列,可以使用这些来获取围绕目标像素的四个输入像素。
一旦有了输入像素值,乘以该像素到目标像素的两个方向上的距离。这是使较近像素比较远像素更重的部分。然后将四个加权值相加以获得最终的输出值。如果你想要更详细的算法解释,你可以在网上找到许多资源。
下面的列表显示了一个执行双线性插值的函数,给定原始数据和新的像素中心偏移量。
列表 11.13. 双线性插值函数

现在要使用双线性插值来重采样栅格,你可以使用你的 get_indices 函数来获取偏移量,然后将这些偏移量传递给 bilinear 函数。保存输出时,别忘了编辑地理变换,如下所示。
列表 11.14. 双线性插值

如果你想要尝试其他类型的插值,scipy.ndimage 包含了多种插值方法。更多信息请参阅 docs.scipy.org/doc/scipy-0.16.1/reference/ndimage.html#module-scipy.ndimage.interpolation。
使用 GDAL 命令行工具进行重采样
这可能是一个提及 GDAL 命令行工具的好时机。目前大约有 30 个这样的工具,偶尔还会添加新的。这些不是 Python 工具;你需要从命令提示符或终端窗口运行它们。让我们看看如何使用 gdalwarp 来重采样图像。这个实用程序旨在在空间参考系统之间转换栅格,但你也可以用它来进行重采样而不改变空间参考。命令行看起来像这样:
gdalwarp -tr 0.02 0.02 -r bilinear everest.tif everest_resampled.tif
–tr 选项用于目标分辨率,在这种情况下表示单元格宽度和高度都应该是 0.02。正如你可能猜到的,-r 代表重采样方法,这里指定为双线性。其他选项包括但不限于最近邻、平均、立方卷积和模式。输入文件是 everest.tif,新文件将命名为 everest_resampled.tif。还有更多选项可用,它们都在 www.gdal.org/gdalwarp.html 上有文档说明。
如果你使用的是 GDAL 2.x 版本,你也可以使用 gdal_translate 工具的相同选项,该工具旨在在不同格式之间转换数据。(我希望我知道我这些年有多少次向人们推荐这个工具!)
虽然这些不是 Python 工具,但你可以使用 subprocess 模块从 Python 中调用它们,该模块将命令发送到操作系统:
import subprocess
args = [
'gdalwarp',
'-tr', '0.02', '0.02',
'-r', 'bilinear',
'everest.tif', 'everest_resample.tif']
result = subprocess.call(args)
如果过程成功完成,result 变量将保持 0,如果不成功则为 1。建议你将命令拆分成一个参数列表,如示例所示,这样 Python 可以处理特殊案例,例如文件名中的空格,但你也可以传递一个字符串,如下所示:
result = subprocess.call('gdalwarp -tr 0.02 0.02 -r bilinear everest.tif
everest_resample.tif')
11.4. 概述
-
如果你需要在 Python 中处理大量数据数组,NumPy 模块是你的答案。
-
使用 SciPy 模块在 NumPy 数组上执行许多不同的科学数据分析。
-
本地地图代数计算基于像素进行,例如计算像素的 NDVI。
-
焦点地图代数计算涉及一个移动窗口,该窗口使用周围的像素来计算输出值,例如计算坡度。
-
区域计算针对同一区域内的像素,例如根据土地所有权计算土地覆盖类型的直方图。
-
全球计算,例如邻近度分析,涉及整个栅格数据集。
第十二章 地图分类
本章涵盖
-
使用
spectral模块进行无监督地图分类 -
使用
scikit-learn模块进行监督式地图分类
栅格数据的一个常见用途是地图分类,这涉及到将像素分类到不同的组中。例如,假设您想创建一个植被土地覆盖数据集。您可能会使用卫星图像、海拔、坡度、地质、降水或其他输入数据来创建您的分类。我们迄今为止探讨的技术将帮助您准备数据集,但您还需要其他东西来对像素进行分类。存在许多不同的分类技术,您使用哪种技术可能取决于您的用例和可用资源。本节绝对不是地图分类的全面介绍,如果您想了解更多信息,请查阅遥感书籍,但至少您会了解可能实现的内容。
您将使用 Landsat 8 图像中的四个波段来查看您能多好地复制您之前看到的 SWReGAP 项目的土地覆盖分类。这些分类包括“大盆地松树-刺柏林地”和“山地盆地盐沼”等分组。尽管这个项目覆盖了美国西南部的五个州,但您将只查看一个 Landsat 场景覆盖的区域(图 12.1)。Landsat 场景包含超过四个波段,但您将只使用三个可见波段(红、绿、蓝,这些组成了自然色图像)和一个热波段。您还将使用这些波段从 30 米像素重新采样到 60 米像素的版本,这样示例运行得更快,您的计算机不太可能遇到内存问题。
图 12.1. 犹他州的 SWReGAP 土地覆盖数据集,上面绘制了 Landsat 场景足迹。Landsat 数据集的红、绿、蓝波段组成了自然色图像,而热波段单独显示。

当需要时,您将使用 SWReGAP 场地数据。但不要期望您的结果能与他们相媲美,因为那个项目涉及多年的工作,亲自访问了成千上万个地点来收集数据,拥有 30 米分辨率的更全面的预测变量集,以及更复杂的建模方法。此外,SWReGAP 数据集包含超过 100 种不同的土地覆盖分类,但这些示例不会产生如此多的类别。然而,您会发现您更简单的模型可以复制几个相同的一般模式。
本节中的示例使用了几个新的 Python 模块:Spectral Python、SciKit-Learn 和 SciKit-Learn Laboratory。请参阅附录 A 以获取安装说明。
12.1. 无监督分类
无监督分类方法根据像素的相似性将像素分组,没有任何用户关于哪些像素属于一起的信息。用户选择感兴趣的独立或预测变量,然后选定的算法完成剩余的工作。但这并不意味着你不需要知道你在分类什么。一旦产生了分类,用户就必须解释它,并决定哪些类型的特征对应于哪些生成的类别,或者它们是否甚至很好地对应。
Spectral Python 模块旨在处理高光谱图像数据,其中 Landsat 数据是一个例子。你将使用 k-means 聚类算法将像素分组到聚类中,然后直观地将你的结果与 SWReGAP 分类进行比较。但首先,让我们编写一个函数,该函数接受一个文件名列表作为参数,从所有文件中读取所有波段,并将数据作为三维 NumPy 数组返回。我们将在接下来的几个列表中使用这个函数,为了方便起见,它位于 ospybook 模块中。
列表 12.1. 堆叠栅格波段的功能
def stack_bands(filenames):
"""Returns a 3D array containing all band data from all files."""
bands = []
for fn in filenames:
ds = gdal.Open(fn)
for i in range(1, ds.RasterCount + 1):
bands.append(ds.GetRasterBand(i).ReadAsArray())
return np.dstack(bands)
现在回到分类问题。k-means 算法从一组初始聚类中心开始,然后根据距离将每个像素分配到聚类中。这个距离是假设像素值是坐标来计算的。例如,如果两个像素值是 25 和 42,那么距离将是 17,无论这些像素在空间上的相对位置如何。
在此过程完成后,聚类中心被用作起点,然后重复此过程,直到达到最大迭代次数或用户定义的停止条件。
运行默认分类非常简单,你将在下面的列表中看到。事实上,这只是一行代码,而示例的大部分内容都是设置和保存输出。此外,通过使用 ospybook 模块中的自定义函数,这段代码也被缩短了。
列表 12.2. 使用 Spectral Python 进行 K-means 聚类

kmeans 函数的唯一必需参数是一个包含预测变量的数组,该数组是 stack_bands 返回的三维数组。你也可以指定所需的输出聚类数、最大迭代次数、几个初始聚类或其他一些东西。然而,对于这个例子,默认的 10 个聚类和 20 次迭代是足够的。如果你需要更多信息,请随时查阅 Spectral Python 在线文档。
假设你得到了与我相同的结果,算法只创建了九个类别而不是十个,但如果它能用给定数据解决它们,它本可以创建十个。我费尽周折试图将结果类别与 SWReGAP 类别相匹配,以便你可以看到视觉比较,尽管诚然,如果你查看的是图 12.2 的颜色版本,这会更好。分类确实不同,但至少东部的山脉与西部的平原和盆地明显分开。如果请求了更多的聚类,匹配可能会更好,因为 SWReGAP 数据包含许多更多的类别。如果你要运行这样的分类,你必须确定每个聚类代表什么,就像我试图将聚类与现有分类相匹配一样。
SWReGAP 土地覆盖数据集和通过无监督分类创建的一个数据集

12.2. 监督分类
与无监督技术不同,监督分类需要用户以训练数据的形式提供输入。训练数据集包含所有与因变量已知值相对应的独立变量。例如,如果你确实知道某个特定像素是农业用地,那么你可以在该位置采样你的输入数据集,如卫星图像,并将这些像素值作为独立变量包括在内。模型使用这些输入数据进行拟合,然后它可以应用于你的完整数据集以获得模型结果的空问表示。
以前,训练数据必须通过亲自访问地点并记录实际分类应该是什么来收集。然而,在这个高分辨率在线图像的时代,在某些情况下,研究人员可以在不出办公室的情况下从图像中确定这些值。这绝对是一个更经济有效的解决方案,尽管它当然不适用于或不可能适用于每种情况。由于准确的训练数据集对于监督分类至关重要,如果可能的话,考虑在野外收集数据。即使可以通过查看图像确定真相,建模过程仍然是必要的,除非你想要手动对每个像素进行分类。
我们将查看使用决策树进行监督分类的一个示例。此类模型由基于模型独立变量的分层条件集组成,并且至少有一条路径通向每个可能的输出。展示了决策树的一个简单(尽管不一定准确)示例。
决策树的一个简单示例

列表 12.3 使用scikit-learn模块创建一个决策树,该决策树基于 Landsat 8 图像的四个波段和 SWReGAP 项目的实际现场数据预测土地覆盖类型。这些地面真实点的位置在图 12.4 中显示。
图 12.4. 用于列表 12.3 的地面真实数据点的位置

你有一个包含图 12.4 中点坐标和表示土地覆盖类别的整数值的文本文件,其内容大致如下:
x y class
377455.171684 4447157.33631 82
372685.109412 4443741.27817 119
372823.111316 4443875.28023 48
这是个不错的开始,但你仍然需要独立变量。你将在文本文件中的坐标处采样 Landsat 波段,以获得类似以下的数据集:
band1 band2 band3 band4 class
136 116 92 233 82
156 129 112 253 119
150 127 109 239 48
这些数据将被用来构建一个模型,然后将其应用于 Landsat 波段的整个范围,以获得包含预测的空间数据集。这个过程在下面的列表中展示。
列表 12.3. 使用 CART 进行地图分类


这个过程比无监督示例稍微复杂一些,但仍然不是那么糟糕。第一个任务是读取文本文件中的坐标和土地覆盖类别。你跳过标题行,然后将前两个值转换为浮点数(因为它们是以字符串形式读取的)并将它们放入一个列表中。完成时,这个列表包含一个列表的列表,其中每个内部列表包含 x 和 y 坐标。你稍后需要这种格式的坐标。你还将土地覆盖类别的整数放入另一个列表中,以供以后使用。
然后你打开一个栅格数据集,以便创建一个转换对象,用于在地图坐标和像素偏移之间进行转换。你使用这个对象和你的坐标列表来获取两个名为cols和rows的列表中的像素偏移。
在将四个卫星波段读入三维数组后,你利用了这样一个事实:你可以将坐标列表作为索引传递,从数组中提取数据,并在一行代码中采样所有点:
sample = data[rows, cols, :]
这在每个提供的行和列偏移处采样 3D 数组,并返回第三维度的每个值,即四个不同的卫星波段。结果是二维数组,其中每行包含四个波段的像素值。
现在你已经拥有了拟合模型所需的所有数据,因此你创建一个新的决策树分类器,使用默认参数(参见scikit-learn文档以了解可选参数的详细信息),然后传递fit方法你的独立变量和已知土地覆盖分类。确保不要更改任何列表的顺序;否则,卫星像素值将不会与相应的土地覆盖值匹配,你的模型将无法正确拟合。
clf = tree.DecisionTreeClassifier(max_depth=5)
clf = clf.fit(sample, classes)
剩下的就是将你的拟合模型应用到像素值的完整集合上。不幸的是,预测变量需要是一个二维数组才能工作,所以你需要重新塑形数组,使其具有大量的行(rows * cols)和四列,每列对应一个波段。你将这个数组传递给predict函数,然后将结果的一维数组重新塑形回二维:
rows, cols, bands = data.shape
data2d = np.reshape(data, (rows * cols, bands))
prediction = clf.predict(data2d)
prediction = np.reshape(prediction, (rows, cols))
处理预测的另一种方法是逐行循环并通过逐个处理。这种方法的一个额外优点是它使用的内存更少。例如,当我尝试一次性在 30 米数据集上运行预测时,我的笔记本电脑崩溃了,但它逐行处理时没有问题。你会这样做:
prediction = np.empty(data.shape[0:2])
for i in range(data.shape[0]):
prediction[i,:] = clf.predict(data[i,:,:])
Landsat 波段在图像边缘有 0 值,但那些像素仍然会被模型赋予一个值。如果所有四个 Landsat 波段在某个位置都包含 0,那么你就知道该单元格没有数据,因此你将预测数据中的这些值也改为 0。你可以使用任何不是有效土地覆盖分类的数字,只要将其设置为NoData值。将预测保存为 GeoTIFF 后,你将颜色表从真实的 SWReGAP 土地覆盖分类复制过来,以便可以直观地比较结果。再次强调,你可以在图 12.5 中看到,这个模型预测了一些相同的一般模式,但结果仍然不同。如果你以彩色查看,你会发现它甚至未能正确预测水!这是一个强烈的迹象,表明模型需要更多的改进。更好的训练数据集或独立变量可能有所帮助。
图 12.5. SWReGAP 土地覆盖数据集和用决策树创建的一个数据集

12.2.1. 准确性评估
准确性评估通常是在此类模型上进行的,以了解它们的好坏程度。因为模型应该能够很好地预测用于构建它的值,所以准确性评估通常使用一组独立的数据来测试模型在不同值上的表现。我已经为此提供了一个单独的数据集,但如果你需要将你的数据分成训练组和评估组,你可能需要查看scikit-learn中的交叉验证工具。一种简单的准确性评估方法是使用混淆矩阵,它根据预测值和观察值来分解结果,这样你可以看到每个分类预测得有多好。虽然你可以从混淆矩阵中计算出正确分类的总百分比,但存在更好的准确性度量方法。其中之一是 Cohen 的 kappa 系数,其范围从-1 到 1,数值越高,预测越好。以下列表展示了如何使用scikit-learn模块构建混淆矩阵和计算 kappa 统计量。
列表 12.4. 混淆矩阵和 kappa 统计量


大部分代码应该看起来很熟悉,因为获取用于准确度评估的数据点与收集模型训练数据相似。区别在于,你不再对卫星图像进行采样,而是对预测输出进行采样,并将这些结果与已知分类进行比较。
一旦你有了每个位置的已知和预测值,计算卡方就很容易了。你只需要将包含真实值的数组和包含预测值的数组传递给kappa函数。同样,值的顺序很重要,因为如果已知值与来自其他位置的预测值进行比较,你的结果将极其不准确。此模型的卡方统计值为 0.24,因此分类略好于随机分类,但绝对没有什么值得炫耀的。事实上,这么低的数字表明分类质量较差。
技术上,你只需要与用于卡方统计的相同输入来创建混淆矩阵,但你还需要创建一个包含唯一分类值的列表,用作标签。类别将按此顺序列在结果矩阵中。创建矩阵后,你将以与之前添加到双向直方图标签相同的方式添加标签。矩阵看起来类似于图 12.6,其中行对应预测值,列对应已知值。例如,预测为类别 22 的 16 个像素被正确预测,但其中两个实际上是类别 5,一个实际上是类别 28。
图 12.6。分类树模型的混淆矩阵的前几行和列。行是预测值,列是实际值,因此有两个像素被预测为类别 22,但实际上是类别 5。

12.3. 概述
-
无监督分类算法根据像素之间的相似性对像素进行分组。
-
监督分类算法使用已标记的真实数据来预测哪些条件组合导致每个类别。
第十三章. 数据可视化
本章涵盖
-
使用 matplotlib 快速绘制矢量数据
-
使用 matplotlib 绘制栅格数据
-
使用 Mapnik 创建地图
如你所注意到的,查看数据的能力是至关重要的。虽然你可以使用桌面 GIS 软件,如 QGIS,但有时在不需要在其他软件中打开的情况下看到你的数据会更好。这就是 ospybook 模块中 VectorPlotter 类背后的想法。其他时候你可能需要创建你数据的图像,比如快速且简单的图表来展示给同事,或者可能是一张更漂亮的地图,用于在线发布或提供给客户。这不是一本关于制图学的书(这很好,因为我在这方面有挑战),所以本章将展示以几种不同的方式显示数据的基本方法,但不会专注于使数据看起来漂亮的技术。你将了解如何使用 matplotlib 和 Mapnik 模块来绘制你的数据。如果你想得到一些漂亮的东西,你可能会选择 Mapnik,但 matplotlib 对于快速可视化来说非常出色。
13.1. Matplotlib
Matplotlib 是一个用于 Python 的一般用途绘图库,可以用于任何你能想到的图形。这个模块非常广泛,就像 NumPy 和 SciPy 一样,关于它已经写了很多本书。如果你对了解可以做什么有一个概述感兴趣,请查看 matplotlib 画廊中的示例,网址为 matplotlib.org/gallery.html。该画廊包含许多令人印象深刻的图表和图形示例,但我们更感兴趣的是空间数据,所以本节将专注于地理数据集的快速且粗略的绘图。实际上,VectorPlotter 类使用 matplotlib,你将学习该类如何绘制矢量数据的基本知识。
Matplotlib 有几个部分,但你与它交互最多以绘制数据的是 pyplot,这就是我们在这里要使用的。在导入时将其重命名为 plt 是一种惯例:
import matplotlib.pyplot as plt
你可以在交互式或非交互式模式下使用 pyplot。在 第三章 到 第七章 中,你从一个交互式控制台使用了一个 VectorPlotter 并立即看到了你图表的变化。这就是 matplotlib 在交互式模式下的工作方式。这种模式对于玩 matplotlib 和学习它是如何工作的来说非常方便。它也适用于交互式地探索数据。
默认情况下,绘图不是交互式的。这很有道理,因为对于没有用户输入就创建图形并保存到磁盘的脚本来说,交互性并不有帮助。然而,每条规则都有例外,你可能发现如果你在使用 pylab 模式下的 IPython 或是像 Spyder 这样的 IDE,那么交互模式默认是开启的。在交互模式下,图形会自动显示给用户,但如果你想在非交互模式下显示图形,那么你必须在将所有图形添加到你的图形中之后调用 plt.show() 方法。这将停止脚本的执行,直到用户关闭图形窗口。你可能想使用交互模式,以便用户可以看到图形的创建过程,但你可能不会有什么好运,因为当脚本结束时,图形窗口会消失。用户可能会在图形创建过程中看到部分图形,但如果脚本在图形完成时立即结束,那么用户可能永远没有机会看到最终产品。
如果你想要开启交互模式,无论是从脚本还是控制台,请使用以下命令:
plt.ion()
你可以使用 plt.ioff() 将交互模式关闭。
13.1.1. 绘制矢量数据
你可能会惊讶地发现绘制矢量数据并不那么困难。毕竟,数据由 x 和 y 坐标组成。首先,你将看到如何使用 plot 函数绘制点、线和多边形,然后你将学习如何绘制形状文件。一旦你能做到这一点,你将学习如何在甜甜圈多边形这种特殊情况下创建孔洞,以便在需要时其他数据可以显示出来。plot 函数有很多选项,其中大多数在这里将被忽略,但你可以在matplotlib.org/api/pyplot_api.html#matplotlib.pyplot.plot找到的在线文档中了解它们。这个函数至少需要 x 和 y 坐标的列表。如果你只提供这些,那么将使用这些坐标和 matplotlib 颜色周期中的一个颜色绘制一条线。例如,以下代码绘制了 y = x² 的线,如图 13.1 所示:
import matplotlib.pyplot as plt
x = range(10)
y = [i * i for i in x]
plt.plot(x, y)
plt.show()
图 13.1. 一个简单的线图

你可以通过提供标记说明来指定颜色并将线条转换为一系列点,如下面的示例所示。在这种情况下,'ro' 表示应该绘制红色圆圈而不是默认的线条。markersize 参数使得点比默认情况下更大。(不要忘记调用 plt.show() 来绘制每个这些图形。)
plt.plot(x, y, 'ro', markersize=10)
此代码的结果显示在图 13.2 中。你也可以通过传递 x 和 y 值而不是值列表来绘制单个点。你可能认为坐标就足够了,但你必须提供一个标记符号,如 'ro',否则它仍然试图画线。因为一个点不足以提供画线的足够信息,你最终会得到一个空白的图。
图 13.2. 一个简单的点图

因为多边形是闭合的线条,你可以用与画线相同的方式画一个空心多边形。确保第一组和最后一组坐标相同,这样多边形才是闭合的。例如,以下代码片段在每个列表的末尾添加一个 0,以便从图 13.1 绘制一条线回到原点。此外,使用名为 lw 的命名参数来改变线宽(lw 是 linewidth 的缩写,你也可以使用)。结果在图 13.3 中显示。
图 13.3. 一个简单的闭合线条图

x = list(range(10))
y = [i * i for i in x]
x.append(0)
y.append(0)
plt.plot(x, y, lw=5)
信不信由你,你现在几乎已经知道了制作简单矢量数据图所需的所有知识,前提是你记得在前面章节中学到的知识。要绘制图层中的特征,打开它,然后对于每个特征,获取几何坐标并像这里一样绘制它们。让我们用全球陆地形状文件来试一试。这个特定数据集很方便,因为所有的几何形状都是简单多边形,你不需要担心多边形。混合中有一个甜甜圈多边形,但现在你可以忽略它,并绘制外环。对于每个特征,从其几何形状中获取第一个环,然后从那里获取坐标。记住,坐标以一对列表的形式出现,所以 zip 函数很有用,因为你可以用它来创建两个单独的 x 和 y 坐标列表。以下列表演示了这种模式,并导致了一个类似于图 13.4A 的图。
图 13.4. 使用闭合线条绘制大陆的两种图。图 A 将轴设置为相等,比例正确,与使用默认轴限制的图 B 不同。

列表 13.1. 绘制简单多边形

列表中注意到的细节尚未提及。为了使你的空间图看起来正确,你需要将轴单位设置为相等。如果你取消注释这一行,你最终会得到一个更类似于图 13.4B 的图。默认情况下,数据会被拟合到可用空间中,以便数据填满整个空间。单个单位覆盖的距离在每个轴上可能不同。如果你仔细观察图的部分 B,你会看到水平轴的范围从 -200 到 200,但垂直轴的范围从 -100 到 100,尽管它们在纸上占用的空间相同。设置轴单位相等可以解决这个问题。
正如您所看到的,绘制简单的多边形并不困难。处理多边形和多边形环增加了代码的复杂性,但仍然是完全相同的过程。在多边形的情况下,您需要遍历多边形中的每个多边形,然后对于每个多边形(无论是否来自多边形),遍历环并绘制每一个。以下列表展示了为 countries 形状文件显示此过程,它为您提供了类似于图 13.5 的图表。
图 13.5. 使用封闭线绘制国家,但考虑到多边形和孔

列表 13.2. 绘制多边形


这个例子将事物分解成几个函数,以便更容易处理。plot_polygon 函数遍历多边形的环,并绘制每一个环。另一个函数 plot_layer 打开数据源,获取由可选的 layer_index 参数指定的层,并遍历所有要素,绘制它们的几何形状。如果几何形状是多边形,它将其传递给 plot_polygon,但如果它是多边形,它将每个多边形部分单独传递给 plot_polygon。这两个函数都允许您使用 **kwargs 传递由 matplotlib plot 函数使用的可选参数(见侧边栏)。
这些函数使绘制形状文件变得容易,因为您只需传递文件名和符号给 plot_layer,设置您的坐标轴为相等,然后显示图表。此列表还展示了如何关闭不需要的刻度标记,如果您不想它们与坐标轴一起绘制。
**在函数中使用 kwargs
与您使用单个星号将列表分解成单独的值,以便作为有序参数传递给函数的方式相同,您可以使用双星号将字典分解为命名参数。例如,如果函数可以接受各种可选参数,您可以创建一个包含您想要使用的参数的字典,其中参数名称作为键,然后将该字典传递给函数,而不是逐个传递每个参数。这种行为对于通过您的函数将参数传递给另一个函数非常有用。
例如,matplotlib 的 plot 函数接受大量可选参数,用于控制输出。使用列表 13.2 中的 plot_polygon 和 plot_layer 函数时,使用这些参数会很方便,但那些函数没有必要担心可选参数。它们只需要在需要时将它们传递给 plot。为此,将一个以 ** 前缀开始的变量作为函数的最后一个参数添加。按照惯例,这个变量被称为 kwargs,但您可以称其为任何您想要的名称。然而,它必须是最后一个参数。然后您可以将其传递给其他函数,最终用户提供的参数会到达预期的函数。
你可能还想绘制除了多边形之外的线条和点,所以创建两个额外的简单函数来绘制这些几何类型,并在plot_layer中添加一些额外的条件语句。以下列表显示了额外的代码,以及图 13.6 的输出示例。
图 13.6. 使用基本线条和点绘制的国家、河流和城市

列表 13.3. 绘制线条和点


这个列表中没有新的概念,只有新的代码。你扩展了plot_layer函数,使其调用正确的线条、多线条、点和多点的函数。然后在列表的末尾,你使用更新的函数再次绘制国家轮廓,但你还添加了主要河流和大型城市。你还利用**kwargs传递城市点的标记大小,这样它们就不会绘制得太大以至于隐藏图中的其他特征。
到目前为止,你在绘图时将多边形当作闭合线条处理。如果你想用颜色填充它们怎么办?你可以通过将你的plot_polygon函数更改为使用 matplotlib 的fill函数而不是plot来实现这一点,如下所示:
def plot_polygon(poly, symbol='w', **kwargs):
"""Plots a polygon using the given symbol."""
for i in range(poly.GetGeometryCount()):
x, y = zip(*poly.GetGeometryRef(i).GetPoints())
plt.fill(x, y, symbol, **kwargs)
现在的symbol参数应该是一个用于填充的颜色,所以使用y代表黄色会导致图 13.7 中的大陆被填充。
图 13.7. 图 13.6 的重复,但闭合线条被填充了颜色

这种方法的唯一问题是,如果多边形内部有孔,它们将被错误地绘制,因为孔将被使用相同的填充颜色绘制。你可以通过只使用填充颜色绘制第一个环,而使用白色绘制后续的环来修复这个问题,但这不会创建一个孔,因为下面的东西不会显示出来。如果你需要真正的孔,你可以使用 matplotlib 的PathPatch,但这比你之前所做的工作要复杂一些。要绘制一个多边形,你不仅需要顶点坐标,还需要一组代码来表示是否绘制线条或将笔移动到该位置。你使用这些信息来创建一个Path,然后从该Path创建一个PathPatch。PathPatch是你要添加填充颜色的对象。一旦有了这个,你还需要将其添加到图中。例如,以下代码绘制了图 13.8 中显示的实心红色三角形:
import matplotlib.pyplot as plt
from matplotlib.path import Path
import matplotlib.patches as patches
coords = [(0, 0), (0.5, 1), (1, 0), (0, 0)]
codes = [Path.MOVETO, Path.LINETO, Path.LINETO, Path.LINETO]
path = Path(coords, codes)
patch = patches.PathPatch(path, facecolor='red')
plt.axes().add_patch(patch)
plt.show()
图 13.8. 简单的补丁多边形

第一段代码是MOVETO,意味着笔应该移动到第一组坐标而不绘制任何东西。如果你已经绘制了其他东西,并且不想将前一个路径的最后一个点与当前路径的第一个点连接起来,这很有意义。LINETO代码对应于你的其余坐标,意味着点将被连接。一旦创建了路径,就可以使用它来创建补丁,补丁可以被填充。需要将补丁添加到图的绘制区域,这被称为坐标轴(它反过来包含 x 轴和 y 轴)。
在补丁上打孔时,创建路径与之前相同,但使用MOVETO代码移动到孔的第一组坐标,然后以与外环相反的方向添加顶点,以指示应创建一个孔。如果外环的坐标是顺时针顺序,则孔的坐标必须是逆时针顺序。例如,你可以这样在你的早期三角形中打孔:

一旦你有了两个列表或 NumPy 数组中的所有坐标和代码,你就可以像之前一样使用它们来创建一个带有孔的补丁,如图 13.8 中所示。以下列表将此过程应用于空间数据,以绘制如图 13.9 所示的世界国家图。
图 13.9:用补丁而不是线条绘制的国家

列表 13.4:将世界国家绘制为补丁


此列表包含一些有用的函数。第一个函数order_coords检查坐标是否按请求的顺序排列,如果不按顺序则重新排列它们。函数中的大部分代码实现了一个确定顺序的算法。一旦确定了顺序,它就会与请求的顺序进行比较,如果它们不同,则反转坐标。
此外,一个名为make_codes的简单函数创建了一个适当长度的LINETO代码列表,第一个被更改为MOVETO,以便可以开始新的路径。
最后一个函数将多边形作为补丁绘制。此函数首先创建一个顺时针顺序的外环坐标列表,以及相应的代码列表。然后它遍历可能存在的任何内环,并为每个内环创建一个逆时针顺序的坐标列表和代码列表。然后它将内环的坐标和代码追加到主列表的末尾。一旦处理完所有环,它就创建一个补丁并将其添加到图中。
代码的主体部分简单地遍历形状文件中的特征,并对每个多边形调用plot_polygon_patch函数,包括多边形内的多边形。在绘制图之前,不要忘记将坐标轴设置为equal,否则 x 轴和 y 轴可能只会从 0 到 1,你最终会看到一个空白的图。
动画
你可以通过动画化你的图表来获得更多的乐趣。要了解如何实现,你将动画化第七章中章节 7 中一只信天翁的移动。让我们首先根据 GPS 数据配置图表的范围:
ds = ogr.Open(r'D:\osgeopy-data\Galapagos')
gps_lyr = ds.GetLayerByName('albatross_lambert')
extent = gps_lyr.GetExtent()
fig = plt.figure()
plt.axis('equal')
plt.xlim(extent[0] - 1000, extent[1] + 1000)
plt.ylim(extent[2] - 1000, extent[3] + 1000)
plt.gca().get_xaxis().set_ticks([])
plt.gca().get_yaxis().set_ticks([])
你获取 GPS 数据层的范围,然后使用它来设置图表的 x 和 y 限制,除了在每个方向上添加 1,000 米以在你要显示的数据周围添加一些缓冲区。你还关闭了刻度标记。你可能想在图表中添加陆地,因为 GPS 位置没有上下文的话并不太有趣。你可以使用你的plot_polygon函数来做这件事:
land_lyr = ds.GetLayerByName('land_lambert')
row = next(land_lyr)
geom = row.geometry()
for i in range(geom.GetGeometryCount()):
plot_polygon(geom.GetGeometryRef(i))
现在,你已经准备好添加动画数据了,但你需要将其存储在某个地方,以便动画例程可以访问它。你有多种方法可以设置它,但在这个例子中,你将把 x,y 坐标对存储在一个列表中,相应的时间戳存储在另一个列表中:
timestamps, coordinates = [], []
gps_lyr.SetAttributeFilter("tag_id = '2131-2131'")
for row in gps_lyr:
timestamps.append(row.GetField('timestamp'))
coordinates.append((row.geometry().GetX(), row.geometry().GetY()))
你遍历带有标签'2131-2131'的动物的所有特征,将时间戳添加到一个列表中,并将包含坐标的元组添加到另一个列表中。你将使用坐标来动画化一个点,并使用时间戳来显示当前时间。你需要初始化点和时间戳注释,让我们来做这件事:
point = plt.plot(None, None, 'o')[0]
label = plt.gca().annotate('', (0.25, 0.95), xycoords='axes fraction')
label.set_animated(True)
在这里,你通过没有坐标的绘图来初始化点。plot函数返回一个对象列表,但在这个例子中,列表中只有一个项目,因为你只绘制了一个点。你从列表中获取那个点图形并将其存储在point变量中。然后你在当前轴上创建一个空的注释对象(gca是“get current axes”的缩写)。将可选的xycoords参数设置为'axes fraction'允许你使用百分比而不是像素或地图坐标来指定注释的位置。注释将位于轴的四分之一处(0.25)并且靠近顶部(0.95)。你还告诉注释它将被动画化,这将使文本变化更加平滑。
现在,你需要编写一个简单的函数,告诉动画将要改变哪些项目,即你的点和标签。如果你在这个函数中不将点坐标设置为None,那么在动画中始终有一个点位于第一个位置,即使另一个点正在移动。
def init():
point.set_data(None, None)
return point, label
你还需要编写最后一个函数,该函数用于移动点并更改标签。这个函数的第一个参数是一个计数器,它自动传递给它,指定当前正在处理的动画迭代次数。其余的参数由你决定。它需要接受将要改变的对象以及任何需要改变它们的数据。就像init函数一样,这个函数必须返回改变的对象。
def update(i, point, label, timestamps, coordinates):
label.set_text(timestamps[i])
point.set_data(coordinates[i][0], coordinates[i][1])
return point, label
该函数使用计数变量i从列表中提取正确的时间戳和坐标。它将标签的文本更改为时间戳,并将点的坐标设置为从 shapefile 中保存的值。然后它返回点和标签,因为它们已经改变。
接下来,让我们使用 matplotlib 中的FuncAnimation函数运行动画。该函数需要两个参数:动画将运行的 matplotlib 图形对象以及告诉动画如何进行的函数。frames参数是计数变量,它可以是值列表,或者在本例中,是您希望动画运行次数的数量。init_func参数是您编写的初始化函数。如果您不提供此参数,则动画的第一个结果将被用于初始化,并且在整个动画过程中保持不变。如果您的动画函数需要除计数器之外的其他参数,您需要使用FuncAnimation的fargs参数提供它们。如果blit为True,则只重新绘制图表中已更改的部分,这将加快速度。interval参数是帧之间的毫秒数,而repeat参数告诉它是否重复动画或运行一次后停止。
import matplotlib.animation as animation
a = animation.FuncAnimation(
fig, update, frames=len(timestamps), init_func=init,
fargs=(point, label, timestamps, coordinates),
interval=25, blit=True, repeat=False)
plt.show()
如果动画可以嵌入到论文中那就太好了,但这是不可能的,所以您需要自己运行代码来看到它的实际效果。您应该注意,在这段代码中没有任何东西会强制使经过的时间保持恒定速度。如果两个连续的 GPS 定位点相隔三天,它们将被视为相隔仅一小时的两个点。解决这个问题的一种方法是将时间戳四舍五入到最近的整点,并确保timestamps和coordinates列表中每小时都有条目。如果没有对应特定时间的坐标,则在列表中放入一个无效值。当您更新动画时,只有当坐标有效时才更新点的位置。下面是一个四舍五入时间戳的函数:
from datetime import datetime, timedelta
def round_timestamp(ts, minutes=60):
ts += timedelta(minutes=minutes/2.0)
ts -= timedelta(
minutes=ts.minute % minutes, seconds=ts.second,
microseconds=ts.microsecond)
return ts
如果您为minutes参数使用默认值 60,函数将四舍五入到最近的整点。在这种情况下,它将 30 分钟加到时间戳上,所以如果原始时间是 11:27:14.01,新时间是 11:57:14.01。然后它计算时间戳分钟值除以您想要四舍五入的分钟数的余数。在这种情况下,这个值是 57,因为 57 不能整除 60,整个值就是余数。然后,将时间戳的秒数和微秒数加到这个值上,所以您有 57:14.01,然后从这个值中减去结果。现在时间戳是 11:00 整,这是最接近 11:27:14.01 的整点。
现在您可以使用四舍五入时间戳了,让我们用数据集的第一组值初始化timestamps和coordinates列表:
gps_lyr.SetAttributeFilter("tag_id = '2131-2131'")
time_format = '%Y-%m-%d %H:%M:%S.%f'
row = next(gps_lyr)
timestamp = datetime.strptime(row.GetField('timestamp'), time_format)
timestamp = round_timestamp(timestamp)
timestamps = [timestamp]
coordinates = [(row.geometry().GetX(), row.geometry().GetY())]
现在,你可以遍历其余的功能并填写你的列表。获取每一行的戳记并与其在 timestamps 列表中的最后一个进行比较。继续向列表中添加新的戳记,直到最后一个等于特征中的戳记,同时,你也可以将一个虚假的坐标集添加到该列表中。当列表中的最后一个戳记等于行的戳记时,循环将停止,这样你就可以用特征的坐标覆盖最后一个虚假坐标集,它们将与正确的戳记匹配。
hour = timedelta(hours=1)
for row in gps_lyr:
timestamp = datetime.strptime(row.GetField('timestamp'), time_format)
timestamp = round_timestamp(timestamp)
while timestamps[-1] < timestamp:
timestamps.append(timestamps[-1] + hour)
coordinates.append((None, None))
coordinates[-1] = (row.geometry().GetX(), row.geometry().GetY())
你唯一需要做的另一件事就是修改你的 update 函数,使其只有在存在有效坐标时才移动点。如果你不这样做,当没有特定时间的坐标时,点将会消失,因为它们会被设置为 None。
def update(i, point, label, timestamps, coordinates):
label.set_text(timestamps[i])
if coordinates[i][0] is not None:
point.set_data(coordinates[i][0], coordinates[i][1])
return point, label
现在,你可以像以前一样运行动画,但时间增量将是恒定的,这更有意义。
如果你安装了适当的软件,你还可以将动画保存为视频文件。例如,我安装了 FFmpeg (www.ffmpeg.org),所以只要 ffmpeg 在我的 PATH 环境变量中,我就可以像这样保存动画:
a.save('d:/temp/albatross.mp4', 'ffmpeg')
如果你没有保存软件但仍然想查看结果,Galapagos 数据文件夹中有一个保存的版本。
13.1.2. 绘制栅格数据
你还可以使用 matplotlib 来绘制栅格数据。制作一个简单的栅格图非常容易,因为你不需要担心坐标,而且恰好有一个用于显示包含在 NumPy 数组中的数据的函数。让我们从一个小的图像开始,使用默认的颜色渐变来绘制它,如图 图 13.10A 所示。
图 13.10. 圣海伦斯山的同一数字高程模型的两个图。图 A 使用默认的颜色渐变(从蓝色渐变到红色),而图 B 使用灰度颜色渐变。

ds = gdal.Open(r'D:\osgeopy-data\Washington\dem\sthelens_utm.tif')
data = ds.GetRasterBand(1).ReadAsArray()
plt.imshow(data)
plt.show()
如你所见,你所要做的就是像以前一样读取栅格数据到一个 NumPy 数组中,然后将该数组传递给 imshow 函数,你就有了一个图表。你可能不喜欢默认的颜色渐变,但你可能找到一个你喜欢的内置渐变。如果没有,你可以创建自己的渐变,尽管你在这里不会学习如何做到这一点。要使用颜色图,将颜色图名称传递给 imshow 作为 cmap 参数,就像这样 (图 13.10B):
plt.imshow(data, cmap='gray')
小贴士
到目前为止,你可以看到 matplotlib 颜色图列表在 wiki.scipy.org/Cookbook/Matplotlib/Show_colormaps。
如果你想绘制一个大图像,你不应该读取整个波段并尝试绘制它。使用金字塔层之一会更好,因为它们占用的内存更少,绘制速度也更快。您需要选择适当的概述级别,以便在不降低性能的情况下获得所需的分辨率。以下是一个从图像中检索概述数据的函数,尽管它没有检查用户是否请求了有效的概述级别。
列表 13.5. 获取概述数据的函数
def get_overview_data(fn, band_index=1, level=-1):
"""Returns an array containing data from an overview.
fn - path to raster file
band_index - band number to get overview for
level - overview level, where 1 is the highest resolution;
the coarsest can be retrieved with -1
"""
ds = gdal.Open(fn)
band = ds.GetRasterBand(band_index)
if level > 0:
ov_band = band.GetOverview(level)
else:
num_ov = band.GetOverviewCount()
ov_band = band.GetOverview(num_ov + level)
return ov_band.ReadAsArray()
该函数要求用户提供栅格文件的路径,以及可选的波段号和概述级别。如果未提供可选参数,它将返回第一波段的最低级概述。尝试使用此函数绘制 Landsat 波段的最低分辨率概述:

如图 13.11A 所示,这导致图像非常暗,在这种情况下,至少很难区分任何东西。如果你没有屏蔽掉等于 0 的像素,它甚至可能看起来更糟。如果没有这一步,你会看到一个矩形,其中所有不属于卫星图像的外围像素都被绘制为黑色。
图 13.11. 同一 Landsat 波段的两个绘图。绘图 A 使用默认设置,但绘图 B 使用拉伸数据以获得更好的对比度。

由于图 13.11A 中的对比度不足,现在是拉伸数据以使其看起来更好的完美时机。一种常见的标准差拉伸方法,它保留均值一个或多个标准差(通常为两个)内的像素值,并将该范围之外的所有值设置为包含的最小或最大值,如图图 13.12 所示。然后,为了绘图,将值拉伸到 0 和 1 之间,因为这是 matplotlib 所希望的。
图 13.12. 数据极端值裁剪的说明,然后所有数据值都在 0 和 1 之间拉伸

要实现这一点,找出所需的均值所需的标准差的最小和最大截断值,然后将它们分别作为vmin和vmax参数传递给imshow。数据将自动为您拉伸,但您需要提供这些裁剪值,如下所示:
mean = np.mean(data)
std_range = np.std(data) * 2
plt.imshow(data, cmap='gray', vmin=mean-std_range, vmax=mean+std_range)
图 13.11B 以这种方式拉伸,显然比未拉伸版本的数据可视化更好。
您还可以将三个波段作为红色、绿色和蓝色绘制,并可选地添加第四个 alpha 波段。在这种情况下,您需要将波段堆叠成一个三维数组,并将其传递给imshow。与单个波段不同,在这种情况下,使用掩码数组过滤掉边缘的零不起作用,所以目前你只能忍受黑色边缘。以下代码片段使用三个波段创建类似于 13.13A 的图像:
os.chdir(r'D:\osgeopy-data\Landsat\Washington')
red_fn = 'p047r027_7t20000730_z10_nn30.tif'
green_fn = 'p047r027_7t20000730_z10_nn20.tif'
blue_fn = 'p047r027_7t20000730_z10_nn10.tif'
red_data = get_overview_data(red_fn)
green_data = get_overview_data(green_fn)
blue_data = get_overview_data(blue_fn)
data = np.dstack((red_data, green_data, blue_data))
plt.imshow(data)
再次强调,那张图片太暗,无法使用。不幸的是,如果你在绘制多个波段,扩展数据会稍微复杂一些,因为使用 vmin 和 vmax 的自动缩放只适用于单个波段。你需要自己规范化数据。以下函数对包含在 NumPy 数组中的数据进行标准差扩展,然后将在 0 到 1 之间缩放结果。
列表 13.6. 扩展和缩放数据的功能
def stretch_data(data, num_stddev):
"""Returns the data with a standard deviation stretch applied.
data - array containing data to stretch
num_stddev - number of standard deviations to use
"""
mean = np.mean(data)
std_range = np.std(data) * 2
new_min = max(mean - std_range, np.min(data))
new_max = min(mean + std_range, np.max(data))
clipped_data = np.clip(data, new_min, new_max)
return clipped_data / (new_max - new_min)
与基于所需标准差数量的均值找到适当的距离不同,这个函数确保使用的值不小于最小值或大于最大数据值。例如,如果你有从 0 到 255 范围的 8 位数据,平均值是 43,标准差是 24,那么从均值减去两个标准差后,下限将是 -5。然而,可能的最小值是 0,你不想使用不可能的值来规范化你的数据,这就是为什么函数会检查以确保边界不会超出潜在值的范围。确定边界后,它们将与 np.clip 函数一起使用,该函数将所有小于 new_min 的值替换为 new_min,并将所有大于 new_max 的值替换为 new_max,就像在图 13.12 中展示的那样。然后,结果数据从 0 到 1 缩放。现在你可以使用这个函数适当地缩放每个波段。
由于你自己在缩放这些数据,你可以利用 alpha 通道来去除边缘的黑色。对于这张特定的图像,你可以假设如果所有三个波段都包含 0,那么像素是边缘。alpha 波段也应该包含 0,这意味着它是完全透明的。其他像素在 alpha 波段中应该有 1,这样它们将以全不透明度绘制。将这个 alpha 波段添加到你的三维堆栈中,如下面的代码片段所示,当你绘制它时,结果将类似于图 13.13B。
图 13.13. 同一张三波段 Landsat 图像的两个绘图。绘图 A 使用默认设置,但绘图 B 使用扩展数据,对比度明显更好。

red_data = stretch_data(get_overview_data(red_fn), 2)
green_data = stretch_data(get_overview_data(green_fn), 2)
blue_data = stretch_data(get_overview_data(blue_fn), 2)
alpha = np.where(red_data + green_data + blue_data > 0, 1, 0)
data = np.dstack((red_data, green_data, blue_data, alpha))
plt.imshow(data)
13.1.3. 绘制 3D 数据
您甚至可以绘制三维数据,例如数字高程模型。为此,您需要一个包含高程数据的数组,以及两个相同大小的数组,包含每个像素的 x 和 y 坐标。后两个数组可以通过将包含可能 x 和 y 值的数组传递给np.meshgrid来创建,这会产生类似于图 13.14 中所示的数据。x 数组中的每个像素包含一个值,指示它在哪一行,而 y 数组中的每个像素指示列。如果您的像素是正方形且您不需要在图表中包含地理参考信息,您可以使用arange获取传递给meshgrid的输入列表,因此获取二维 x 和 y 数组就像这样:
x, y = np.meshgrid(np.arange(band.XSize), np.arange(band.YSize))
图 13.14. meshgrid 输出的说明。部分 A 显示了数组中每个单元格的 x,y 坐标对。输出是两个数组,其中一个包含 x 坐标(部分 B)和另一个包含 y 坐标(部分 C)。

在其他情况下,您可以使用地理变换来计算所需信息,以便 x 和 y 数组包含真实世界的坐标而不是图 13.14 中那样的像素坐标。以下列表显示了使用圣海伦斯山的 DEM 执行此操作的步骤,然后它以 3D 形式绘制数据以获得图 13.15A。
图 13.15. 圣海伦斯山的 3D 图。图 A 使用默认设置,而图 B 则改变了高程和方位角,以及移除了坐标轴。

列表 13.7. 使用meshgrid获取地图坐标

列表的第一个部分将概述数据读入内存,并使用地理变换来计算 DEM 的边界坐标。然后,这些坐标与meshgrid结合使用,以创建绘图所需的 x 和 y 数组。
要创建此图,您首先创建一个 matplotlib 图对象,然后获取其坐标轴对象。您告诉坐标轴使用 3D,然后调用其plot_surface方法以创建图表。此函数需要 x 和 y 数组以及包含高程的数组。您使用名为 gist_earth 的调色板而不是默认调色板,并使用lw=0将线宽设置为 0。如果您不更改线宽,则每个单元格周围都会有轮廓,这在这种情况下看起来不好。顺便说一下,对于您之前的图表,已经自动创建了图和坐标轴,但您不必担心它们。在这里,您需要,因为您需要一个坐标轴句柄来指定 3D 并绘制表面。
如果您想改变观察 3D 图像的视角呢?嗯,您可以将高度设置为 0 到 90 之间,其中 0 是地面水平,90 是直视下方,您还可以将图表旋转 0 到 360 度。图 图 13.15B 是通过将高度设置为 55,旋转图表 60 度,并关闭轴来获得的。为此,在调用 plt.show() 之前添加这两行代码:
ax.view_init(elev=55, azim=60)
plt.axis('off')
您可以通过创建动画使这个过程更加有趣。这比之前的 Albatross 动画简单,因为您只需为每次迭代更改旋转因子。尝试在调用 plt.show 之前添加此代码:
import matplotlib.animation as animation
def animate(i):
ax.view_init(elev=65, azim=i)
anim = animation.FuncAnimation(
fig, animate, frames=range(0, 360, 10), interval=100)
animate 函数改变了观察点,即从哪个角度观察图表。调用 FuncAnimation 设置了 animate 函数被调用 36 次,每次对应 frames 中的一个值。这将导致图表每次旋转 10 度。尽管间隔参数指定了每帧之间有 100 毫秒,但如果您的计算机无法这么快地绘制它,速度会慢一些。这个保存的版本在尼泊尔数据文件夹中。
13.2. Mapnik
您迄今为止制作的图表非常适合可视化数据,但您可能会需要制作看起来更美观或更像真实地图的东西。使用 Python,一个很好的方法是使用 Mapnik,这是一个流行的制图库。实际上,您可能已经看到了使用 Mapnik 创建的地图,却不知道。Mapnik 是为制作网络应用程序的瓦片地图而设计的,据我所知,在 Mapnik 图像上放置如北箭头这样的制图符号并不容易。您可以使用其他图形模块如 Cairo 来实现,但这超出了本介绍的范畴。本节将带您了解使用此模块绘制矢量和栅格数据的基本方法,但如果您想了解更多信息,请访问 mapnik.org。
在我们开始绘制任何东西之前,让我们快速看一下 Mapnik 地图的最小要求,如图 图 13.16 所示。地图有一个或多个图层,以及一个或多个样式。样式指定了数据应该如何绘制。每个样式至少需要一个规则,每个规则至少需要一个符号。规则还可以有过滤器,以便它们只应用于数据的一个子集。每个图层需要一个数据源和至少一个样式。图层样式不是新的样式对象;它们引用属于地图的某个样式。您将在接下来的几个示例中看到这一切是如何工作的。
图 13.16. Mapnik 地图的基本组织结构图。每个地图至少包含一个图层和一个样式。每个图层需要引用至少一种样式。

13.2.1. 绘制矢量数据
您还记得前面章节中的新奥尔良数据吗?如果不记得,您很快就会想起,因为您将在接下来的几个示例中使用它。下面的列表首先从美国人口普查局的 TIGER 水层开始绘制。
列表 13.8. 创建一个简单的 Mapnik 地图

第一步是创建一个 Mapnik 地图对象,但您将其命名为 m 而不是 map,因为 map 是 Python 中的一个保留字。在创建地图时,您需要提供一个大小,因此这个地图将宽度为 800 像素,高度为 600 像素。您可以可选地提供一个空间参考,形式为 Proj.4 字符串或 EPSG 代码;如果您不提供此信息,则默认为 WGS84 纬度/经度。由于新奥尔良的大部分数据使用 NAD83 纬度/经度,因此您决定在这里使用它。您还设置了一个边界框,形式为 (min_x, min_y, max_x, max_y)。如果您不设置边界框,最终将得到一个空地图。
要向地图中添加一个图层,您需要创建一个图层对象并为其提供一个数据源。存在多种数据源类型,用于不同的数据格式,例如 shapefiles、Geo-JSON 和 PostGIS。在这里,您创建一个 shapefile 数据源并将其添加到您命名为 'Tiger' 的图层中。
向地图中添加一个图层还不够。如果您想绘制图层,您还需要提供有关如何符号化的信息。您首先通过从指定浅蓝色的 RGB 值创建一个 Mapnik 颜色对象 (water_color) 来开始这个过程,然后使用它来创建一个用于绘制水层的多边形符号化器。使用此符号化器绘制的多边形将被填充为 RGB 值定义的蓝色。
一旦您有了符号化器,您就创建一个符号化样式。一个样式至少需要一个规则来定义如何绘制某个东西。这个特定的样式很简单,只包含一个规则,而这个规则又只包含您的多边形符号化器。然后您将样式添加到地图中,以便图层可以使用它。请注意,在将样式添加到地图的同时,您还提供了样式的名称;这在以后很重要。
您希望 Tiger 图层使用您创建的样式,因此您将样式也添加到图层中,确保使用与添加到地图时相同的样式名称。样式必须同时添加到图层和地图中,否则它将不起作用。此外,样式必须在将图层添加到地图之前添加到图层中,这就是您接下来要做的。
最后,在所有内容都添加到适当的位置后,您需要将地图保存到文件中。如果一切顺利,您将得到一个如图 13.17 所示的图像 图 13.17。
图 13.17. 使用单个图层和样式规则绘制水文数据的简单图表

尽管那个图很漂亮,但你想要的不仅仅是水体,所以现在也尝试添加沼泽地。这些数据来自一个包含开放水域、冰川、沼泽、干湖、运河和其他特征的国家级水文数据集。实际上,包括这个数据集中的运河和湖泊会使你的地图看起来更好一些,所以你也会包括它们。接下来的列表显示了如何添加这个新的图层,并使用更复杂的样式添加到地图上。这段代码会在保存地图为图像文件之前添加。
列表 13.9. 在样式中使用多个规则

创建这个图层并将其及其样式添加到地图上的方法与之前相同,但这次样式更复杂。首先,你为这个样式添加两个规则而不是一个。让我们看看第一个,称为 water_rule。你使用一个过滤器将这个规则应用于那些"Feature"属性列等于 'Canal' 或 'Lake' 的要素。Mapnik 中的过滤器表达式与你已经使用的 OGR 过滤器表达式类似,但属性名必须用方括号括起来。你为这个规则使用了与 tiger 数据相同的用水体多边形符号化器。
在创建第二个规则之前,你构建了新的符号化器,使用绿色颜色。这次你使用十六进制表示法定义颜色以证明你可以这样做,但如果你想的话也可以再次使用 RGB。然后使用这个颜色创建另一个多边形填充符号,以及一个宽度为 2 像素的线符号。这个线符号化器将用于用与填充相同颜色的线条勾勒多边形。你在这里使用轮廓的原因是因为数据集在形状之间有细微的间隙,没有轮廓填充的话这些间隙是明显的。
现在你已经有了你的符号化器,你为这个图层创建 marsh 规则。首先,你使用一个过滤器使这个规则仅适用于那些"Feature"属性列等于字符串 'Swamp or Marsh' 的要素。然后你添加了之前创建的绿色填充和轮廓符号。
在创建规则之后,你创建一个新的样式并将两个规则都添加到其中。然后你将样式添加到地图和图层中,并将图层添加到地图中。渲染这个地图到文件后,你最终得到一个如图 图 13.18 所示的图形。
图 13.18. 添加了另一个图层,这次使用两个规则来指定同一数据集中的沼泽和开放水域以不同的方式绘制

如果你比较图 13.17 和 13.18,你可能会想知道所有的小水体都到哪里去了。层是按照你添加到地图中的顺序绘制的,所以湿地是在那些小水体之上绘制的。因此,你需要考虑哪些层不应该被覆盖,并据此进行规划。要得到像图 13.19 这样的图形,请将添加层的代码移至脚本末尾,然后反转层的添加顺序,如下所示:
m.layers.append(atlas_lyr)
m.layers.append(tiger_lyr)
图 13.19。与图 13.18 相同的数据,但层的顺序已反转

然而,你的地图仍然不完整,因为你还想添加一些道路和纽奥良的城市边界。下面的列表显示了添加这些内容的代码。
列表 13.10。添加道路和城市轮廓


在这个例子中只添加了几个新内容。第一个是,在创建道路层时指定空间参考。这是必要的,因为这个特定的 shapefile 使用 WGS84 而不是 NAD83。你可以使用 Proj.4 字符串,就像你使用地图空间参考信息那样,但你选择使用 EPSG 代码。注意,你为道路样式使用了两个规则,这样你可以绘制比次要和三级道路稍粗的主干道路。
第二个新概念是,你也可以使用 HTML 命名颜色创建颜色对象。这是你用来创建城市轮廓黑色线条的技术。但你还希望城市轮廓是虚线而不是实线,因此你编辑线条的描边属性使其变为虚线。add_dash的第一个参数是虚线的像素长度,第二个参数是虚线之间的间隙长度。
将所有这些代码添加到脚本中的结果是图 13.20 所示。
图 13.20。为了绘制道路和城市轮廓而添加的线条样式

13.2.2. 将信息存储为 XML
如果你经常使用某些样式或层,你可以将相关信息存储在可以从你的脚本中加载的 XML 文件中。你也可以这样存储整个地图,这意味着你可以使用 XML 创建地图,然后使用 Mapnik 渲染它。如果你想看看这些文件的样子,请将此行代码添加到脚本末尾:
mapnik.save_map(m, 'nola_map.xml')
要将此 XML 文件描述的地图渲染为图像,请编写一个导入 Mapnik 的脚本,然后加载 XML 并保存输出如下:
m = mapnik.Map(400, 300)
m.zoom_to_box(mapnik.Box2d(-90.3, 29.7, -89.5, 30.3))
mapnik.load_map(m, r'd:\temp\nola.xml')
mapnik.render_to_file(m, r'd:\temp\nola.png')
这基本上就是整个脚本。你仍然需要创建一个具有所需大小和边界框的地图对象,但层和样式是从 XML 文件中提取的。
然而,你不必仅使用 XML 中包含的信息,因此你可以使用这种技术来存储常用的层或样式。例如,如果你经常使用国家地图的水文数据集,你可以将其信息存储在一个 XML 文件中,并在你的脚本中加载它。将有关地图层的代码从早期的脚本中提取出来,并使用它来创建一个新的脚本,该脚本保存必要的 XML。以下列表显示了你需要的内容。
列表 13.11. 创建 XML 描述国家地图水文层
import mapnik
m = mapnik.Map(0, 0)
water_rule = mapnik.Rule()
water_rule.filter = mapnik.Expression(
"[Feature]='Canal' or [Feature]='Lake'")
water_rule.symbols.append(
mapnik.PolygonSymbolizer(mapnik.Color(165, 191, 221)))
marsh_rule = mapnik.Rule()
marsh_rule.filter = mapnik.Expression("[Feature]='Swamp or Marsh'")
marsh_color = mapnik.Color('#66AA66')
marsh_rule.symbols.append(mapnik.PolygonSymbolizer(marsh_color))
marsh_rule.symbols.append(mapnik.LineSymbolizer(marsh_color, 2))
atlas_style = mapnik.Style()
atlas_style.rules.append(water_rule)
atlas_style.rules.append(marsh_rule)
m.append_style('atlas', atlas_style)
lyr = mapnik.Layer('National Atlas Hydro',
"+proj=longlat +ellps=GRS80 +datum=NAD83 +no_defs")
lyr.datasource = mapnik.Shapefile(file=r'D:\osgeopy-data\US\wtrbdyp010')
lyr.styles.append('atlas')
m.layers.append(lyr)
mapnik.save_map(m, r'd:\temp\national_atlas_hydro.xml')
此脚本创建用于国家地图层的样式,包括特定于该层属性表的过滤器。它还创建了层并将样式附加到它上。SRS 也添加到层中,因为加载此文件的脚本可能不会使用与该特定层相同的 SRS。样式和层都添加到一个虚拟地图对象中,用于保存信息。地图的大小无关紧要,因为这将由加载 XML 的脚本确定。
生成的 XML 看起来如下列表。
列表 13.12. 描述国家地图水文层的 XML
<?xml version="1.0" encoding="utf-8"?>
<Map srs="+proj=longlat +ellps=WGS84 +datum=WGS84 +no_defs">
<Style name="atlas">
<Rule>
<Filter>
(([Feature]='Canal') or
([Feature]='Lake'))
</Filter>
<PolygonSymbolizer fill="rgb(165,191,221)"/>
</Rule>
<Rule>
<Filter>([Feature]='Swamp or Marsh')</Filter>
<PolygonSymbolizer fill="rgb(102,170,102)"/>
<LineSymbolizer stroke="rgb(102,170,102)" stroke-width="2"/>
</Rule>
</Style>
<Layer name="National Atlas Hydro"
srs="+proj=longlat +ellps=GRS80 +datum=NAD83 +no_defs">
<StyleName>atlas</StyleName>
<Datasource>
<Parameter name="file">D:\osgeopy-data\US\wtrbdyp010</Parameter>
<Parameter name="type">shape</Parameter>
</Datasource>
</Layer>
</Map>
如你所见,XML 很简单,所以你可能甚至想从一开始就使用这种方式定义你的层,而不是编写代码。无论如何,一旦你有了这个文件,你可以删除 列表 13.9 中创建地图层和样式的所有代码(那超过 20 行),然后替换为
m.layers.append(atlas_lyr)
使用以下内容:
mapnik.load_map(m, r'd:\temp\national_atlas_hydro.xml')
显然,如果你在多个地图中使用相同的层,这种技术将简化你的生活,值得研究。
13.2.3. 绘制栅格数据
现在你已经了解了使用 Mapnik 绘制矢量数据的基础,是时候创建一个使用栅格数据的简单图形了。以下列表创建了一个显示圣海伦斯山地形图的图像。
列表 13.13. 绘制栅格

大多数这个例子应该看起来很熟悉,因为它与矢量示例类似。主要区别在于你使用 GDAL 数据源而不是 shapefile,并且使用了一个没有选项的简单栅格符号化器。然而,与 shapefile 示例不同的是,即使它与地图的 SRS 匹配,你也必须指定栅格数据源的 SRS。除此之外,创建规则、样式和层的流程仍然是相同的。输出图形看起来像图 13.21。
图 13.21. 地形图的栅格图

尽管如此,这张图片可能需要一点帮助。使类似这样的东西更具美学吸引力的一种常见技术是将它叠加在 高程阴影 数据集上,以赋予它深度。高程阴影是通过假设光源的高度和角度,并根据数字高程模型确定阴影落在哪里来创建的(图 13.22)。下一个列表显示了如何将来自圣海伦斯山 DEM 的高程阴影放在这个拓扑图下面,以得到类似 13.23 的图像。
图 13.22. 左侧为圣海伦斯山的数字高程模型,右侧为由 DEM 衍生的高程阴影

列表 13.14. 使用高程阴影


在这个例子中,高程层是按照之前添加拓扑层的方式添加的,但这次您对拓扑层的符号化器进行了一项更改。因为您希望拓扑层半透明,以便让高程层显示出来,所以您将不透明度属性更改为 0.6。值为 1.0(默认值)使图层完全不透明,因此高程层几乎可以忽略不计。值为 0 完全透明,因此您只能看到高程层。您可以调整这个值,看看您最喜欢哪种透明度级别,但图 13.23 展示了 0.6 值的效果。
图 13.23. 部分透明的拓扑栅格图,其下方的阴影层提供阴影

13.3. 概述
-
matplotlib 模块是 Python 的一个通用绘图模块,适用于快速可视化数据。
-
您可以使用 matplotlib 交互模式立即看到某个效果。
-
如果您想要比使用 matplotlib 更容易获得的更漂亮的地图和图像,请使用 Mapnik 模块。
-
您可以将 Mapnik 样式和图层存储在 XML 文件中,以便它们易于重复使用。
附录 A. 安装
为了完成本书的学习,你需要安装几个组件。最明显的是 Python 本身,但基本的 Python 发行版并没有包含捆绑的地理处理模块。一些第三方 Python 发行版确实包括这些库,或者至少包括其中的一些,如果你愿意,可以使用它们。尽管我没有使用它测试本书中的示例,但我使用 Anaconda Python(可在 Windows、OS X 和 Linux 上使用)成功教授了课程。www.continuum.io/downloads。我还知道另外三个例子,但尚未测试,它们是 OSGeo4W、Enthought Canopy 和 Python(x,y)。如果你想使用这些发行版之一,请确保首先检查包列表,以确保它包含你感兴趣的 Python 模块。这里提供的说明将指导你如何在不使用这些发行版的情况下获取 Python 和所需的模块,以及如何使用 Anaconda 获取它们。不幸的是,每个人的系统都是不同的,即使他们使用的是相同的操作系统,因此这些说明无法涵盖所有情况。
本书我们将使用的模块列表如下:
-
Python 本身:www.python.org
-
GDAL/OGR,用于读取和写入地理空间数据:www.gdal.org
-
NumPy,基本的 Python 数组处理模块:www.numpy.org
-
Matplotlib,用于图形化绘图:www.matplotlib.org
-
SciPy,科学计算模块:www.scipy.org
-
Pyproj,PROJ.4 地图投影库的 Python 包装器:
code.google.com/p/pyproj/ -
Folium,使用 Python 制作 Leaflet.js 地图:
github.com/python-visualization/folium -
Spectral Python,用于处理高光谱图像数据:
www.spectralpython.net/ -
scikit-learn,用于数据分析:
scikit-learn.org/stable/ -
Mapnik,用于制作美观的地图:
mapnik.org/
Python 有两个主要版本,2.x 和 3.x,它们之间有一些显著的区别,所以它们并不完全可互换。然而,许多代码可以在两者上运行,我已经尽力编写本书中的示例,以便它们可以与任一版本一起使用。2.x 分支的最新版本是 2.7,这个分支将不再有重大发布。3.x 分支正在积极开发中,如果你没有特定的版本要求,我建议选择这个版本,因为正如 Python 网站所说,它是“语言的现实和未来。”然而,如果你需要使用尚未更新以与 Python 3.x 一起工作的第三方模块,你可能被迫使用较旧的 Python 版本。
例如,我同时使用 Python 2.7 和 3.3,但在工作中通常使用 2.7,因为大学广泛使用 ArcGIS 软件,它需要 Python 2.7。由于我的同事和学生几乎总是安装了 ArcGIS,即使他们没有意识到这一点,他们也已经安装了 Python 2.7。帮助他们安装与现有 Python 版本兼容的开源工具是有意义的,这样我就可以在同一个脚本中利用 GDAL 和 ArcGIS,如果我想的话,并且教他们这样做。
在一台计算机上安装多个 Python 版本是可能的,这样你就可以始终为不同的项目选择不同的版本。这也意味着你可以为 Python 的一个版本拥有不同的环境,这意味着你可以拥有不同的工作空间,每个工作空间都安装了不同的模块。这允许你为使用 Python 构建的不同应用程序有不同的配置。尽管本书不会涉及这一点,但如果你对此感兴趣,请参阅 www.virtualenv.org。
Python 附带了一个名为 pip 的命令行实用工具,如果你为 Python 安装了许多额外的模块,你会熟悉它,因为通常这是最简单的方法。pip 实用工具位于你的 Python 安装目录内的 scripts 文件夹中。由于这是一个命令行工具,你需要从终端窗口或命令提示符中使用它。要使用 pip 安装模块,你可以这样做:
pip install module_name
要查看默认 pip 存储库中的模块列表,请参阅 pypi.python.org/pypi。你可以从其他位置安装模块,但如果你使用的是 pip 的新版本,你可能会收到关于存储库不受信任的错误。错误信息将告诉你如何添加到命令中以覆盖此错误,但你应该只在信任你尝试下载模块的位置时这样做。(但你不应该从你不信任的来源下载任何东西,对吧?)有关使用 pip 的更多信息,请参阅 pypi.python.org/pypi/pip。
在线有更多关于安装 GDAL 的信息,请参阅 trac.osgeo.org/gdal/wiki/DownloadingGdalBinaries,但我在这里提供了一些信息。
A.1. Anaconda
Anaconda (www.continuum.io/downloads) 包含 Python 和大量为科学计算设计的模块。其中一些是默认安装的,而许多其他模块可以使用 Anaconda 附带命令行工具进行安装。不幸的是,Anaconda 从版本 2.5 开始不再支持 GDAL,但您仍然可以下载包含 GDAL 的旧版本。这绝对是最简单的方法来设置环境,尽管本书中使用的并非所有模块都包含在内,并且并非 GDAL 中编译了所有可能的特性。当您从 repo.continuum.io/archive/index.html 下载并安装较旧的 Anaconda Python 版本时,您将自动拥有 Python、NumPy、SciPy、matplotlib 和 scikit-learn。GDAL/OGR 和 pyproj 是可选安装,您可以通过打开 Anaconda 命令提示符并输入以下命令来获取它们:
conda install gdal pyproj
关于使用 conda 工具安装和管理包的更多信息,请参阅 conda.pydata.org/docs/using/pkgs.html。您也可以使用 conda 安装不属于 Anaconda 的包。要了解如何操作,请访问 pypi.anaconda.org/ 并搜索您感兴趣的包。或者,您可以使用 pip,这正是我们将在这里安装 folium 和 spectral 的方法:
pip install folium spectral
查看您操作系统的信息,以获取如何获取 Mapnik 并将其设置为与 Python 一起工作的提示。截至本文撰写时,版本 2.2 是最新的,可以从 Mapnik 网站下载二进制文件,但这没关系,我正是使用这个版本进行示例的。
A.2. 非捆绑安装
如果您不想使用 Anaconda 这样的预捆绑模块集,您可以单独安装所有内容。然而,每个系统都是不同的,都有自己的小特性,因此这些只是一般指南。
A.2.1. Linux
我无法提供有关所有 Linux 版本的信息,但以下 Ubuntu 指南可能足以让您开始。此示例使用标准的 Ubuntu 仓库,但这些软件包的最新构建可能可以从 www.launchpad.net/~ubuntugis 获取。
您可以使用 apt-get 安装 GDAL 及其依赖项:
sudo apt-get install gdal-bin libgdal-dev python-gdal
安装大多数其他所需软件包的最简单方法也是使用 apt-get。我认为这应该就可以了:
sudo apt-get install gdal-bin libgdal-dev python-scipy \
python-matplotlib python-pyproj python-scikits-learn libmapnik2.2 \
libmapnik2-dev mapnik-utils python-mapnik2 qgis
您可以使用 pip 安装 spectral python。如果您还没有用于安装 Python 模块的 pip 工具,您应该现在安装它:
sudo apt-get install python-pip
然后安装 Spectral Python:
sudo pip install spectral
A.2.2. Mac OS X
虽然我已经好几年没有使用 Mac 了,但在我拥有它的时候,我发现安装 GDAL 和其他几个地理空间包最简单的方法是利用 William Kyngesburye 维护的 KyngChaos Wiki 资源。这个网站包含预先构建的框架,旨在与 OS X(截至本文撰写时的 Lion、Mountain Lion 和 Mavericks)的新版本系统 Python 一起工作。它们不支持其他版本的 Python,包括你从www.python.org下载和安装的版本。
你可以通过从 KyngChaos Wiki 安装 GDAL 完整框架来获取 GDAL 的所有内容,包括一些 Python 模块。这包括 GDAL/OGR 所需的所有内容,但它不包括 Esri FileGDB 和 MrSID 文件格式的驱动程序等可选插件。如果你需要它们,它们可以在同一下载页面下方的 GDAL 部分下载。
GDAL 完整框架还包括 NumPy 模块,但它不包括本书中讨论的许多其他模块。如果你点击到 wiki 的 Python 模块部分(www.kyngchaos.com/software/python),你会看到 SciPy 和 matplotlib 等未在此处讨论的有用模块的下载。在此网站停留时,你还可以点击 QGIS 的链接(www.kyngchaos.com/software/qgis)并安装它。
你现在应该能够从终端窗口使用 pip 安装剩余的 Python 包:
pip install folium spectral scikit-learn
剩下的包是 Mapnik。请参阅mapnik.org/pages/downloads.html获取预编译的二进制文件(尽管截至本文撰写时不是最新版本),然后参阅pypi.python.org/pypi/mapnik2了解如何获取正确的 Python 绑定集。如果我现在安装,Mapnik 的版本将是 2.2,我会这样安装 Python 绑定:
easy_install -U mapnik2==2.2.0
A.2.3. Windows
如果您使用的是 Windows,您首先需要一份 Python 的副本,因为与许多其他操作系统不同,Windows 并未预装 Python。在 Windows 上使一切正常工作的最简单方法是下载来自 www.python.org 的官方 Python 版本。那里有多个版本可供选择,但如我之前所说,除非您有其他要求,否则我建议使用最新版本。如果您现在很可能有一个 64 位操作系统,您可能希望获取 Python 的 64 位版本(请注意,然而,我不了解 Windows 上有 64 位版本的 Mapnik,所以如果您想在最后一章中使用它,您将需要一个 32 位版本)。32 位版本可以在 64 位操作系统上运行,但性能不会很好。64 位版本的 Python 一定不能在 32 位操作系统上运行。
假设您使用的是来自 www.python.org 的官方 Python 发行版,您可以从 Christoph Gohlke 的优秀资源 www.lfd.uci.edu/~gohlke/pythonlibs 获取本书中使用的所有其他模块,除了 folium 和 mapnik,我建议这样做。Spectral Python 模块列在页面底部的“Misc”部分,称为“spectral”。当从他的网站下载包时,请确保下载与您安装的 Python 版本相对应的包。如果您安装了 64 位版本的 Python,请确保下载所有包的 64 位版本。对于 32 位版本也是如此。
您可以使用 pip 安装 folium:
pip install folium
要安装 Mapnik,从 mapnik.org/pages/downloads.html 下载并解压 Mapnik 的 zip 文件。截至本文撰写时,提供 Windows 二进制文件的最新版本是 2.2,但仅适用于 32 位(它不能与 64 位版本的 Python 一起使用)。解压存档后,假设您将文件提取到 C:\mapnik-v2.2.0,设置以下环境变量:
-
将 C:\mapnik-v2.2.0\bin 和 C:\mapnik-v2.2.0\lib 添加到
PATH -
将 C:\mapnik-v2.2.0\python\2.7\site-packages 添加到
PYTHONPATH(如果尚未存在,则需要创建此环境变量)
安装 GDAL 的另一种选项是从 www.gisinternals.com/ 下载最新版本。如果您这样做,需要设置以下环境变量,假设安装文件夹是 C:\Program Files\GDAL(64 位版本的默认位置):
-
将 C:\Program Files\GDAL 添加到
PATH -
GDAL_DATA= C:\Program Files\GDAL\gdal-data -
GDAL_DRIVER_PATH= C:\Program Files\GDAL\gdalplugins -
PROJ_LIB= C:\Program Files\GDAL\projlib
我已经使用这种方法告诉 Anaconda 在哪里可以找到 GDAL,而不是使用 Anaconda 提供的 GDAL 版本,因为这个版本有更多预编译的选项。我也用它与不包含 GDAL 的新 Anaconda Python 套件一起使用。
A.3. 环境变量
因为这些 Python 模块需要外部库才能工作,你需要确保 Python 可以找到它们。这就是环境变量的作用所在。例如,GDAL 并非真正的 Python 程序,在 GDAL Python 绑定工作之前,你需要 GDAL 程序本身。Python GDAL 模块只是允许你通过 Python 使用真正的 GDAL 程序,但如果真正的 GDAL 库找不到,你将无法这样做。
我猜那些使用 apt-get 的 Linux 用户,所有东西都会放在正确的位置,所以你们不会有问题。我不太确定 OS X,尽管如果 Mapnik 出了问题,我也不会感到惊讶。Gohlke 网站的 Windows 软件包通常将所需的二进制文件放在与 Python 绑定相同的文件夹中,所以你可能不会有问题。但再次强调,Mapnik 仍然可能是个问题。
如果你确实遇到问题,以下是一些可能有助于解决问题的环境变量(如果你不知道如何设置环境变量,快速的网络搜索会告诉你如何为你的操作系统设置环境变量):
-
PATH:应包含 GDAL 和 Mapnik 的安装文件夹;例如:C:\mapnik-v2.2.0\bin;C:\mapnik-v2.2.0\lib;C:\Python33\Lib\site-packages\osgeo;C:\Program Files\<the rest of your PATH> -
GDAL_DATA:将此设置为 GDAL 安装目录中包含大量 .csv 和 .wkt 文件的文件夹。它通常被称为 data 或 gdal-data;例如:C:\Python33\Lib\site-packages\osgeo\data\gdal -
GDAL_DRIVER_PATH:如果你安装了一些可选的 GDAL 驱动程序,请将此设置为包含这些驱动程序的文件夹;例如:C:\Python33\Lib\site-packages\osgeo -
PROJ_LIB:这应该设置为 pyproj 安装目录中包含大量文件(大多数没有扩展名,其中一个将是“epsg”)的文件夹;例如:C:\Python33\Lib\site-packages\pyproj\data -
PYTHONPATH:这包括 Python 在查找模块时会搜索的文件夹。如果你将模块放在非标准位置,那么这些位置需要在这个变量中指定。例如,如果你在 Windows 上安装了 Mapnik,Python 模块不会移动到标准的 Python 位置,所以你需要将 Python 指向 Mapnik site-packages 子文件夹;例如:C:\mapnik-v2.2.0\python\2.7\site-packages
A.4. 源代码和数据
书中示例的源代码可以从 Manning 网站 www.manning.com/books/geoprocessing-with-python 和 GitHub github.com/cgarrard/osgeopy-code 获取。还有一个用于本书的定制 Python 模块,其中包含一些便利函数和一些用于查看数据的简单工具。这些包含在代码下载中的 ospybook-latest.zip 文件中。你可以使用 pip 如下安装它(假设它在我的 C:/temp 文件夹中):
pip install c:/temp/ospybook-latest.zip
如果您下载了本书的源代码,您将拥有本模块的所有代码。使用 pip 将其放置在标准位置,以便 Python 可以找到它。您可以从www.manning.com/books/geoprocessing-with-python和app.box.com/osgeopy下载示例中使用的数据。
A.5. 开发环境
Python 自带了一个交互式环境,您可以从终端窗口或命令提示符中使用它,但大多数人并不是特别喜爱这种方式(我经常用它来玩一些简短的代码片段)。Python 还自带了一个名为 IDLE 的图形界面。这包括一个交互式环境,类似于命令行,但具有语法高亮和代码补全功能,这意味着您可以开始输入,然后按 TAB 键来查看完成代码的选项列表,例如函数或变量名称。它还包含一个文本编辑器,您可以在其中编辑包含 Python 代码的文件,并在 IDLE 内部运行它们。
然而,如果您想要更好的体验,您有很多选择。以下两个例子,都恰好与 Anaconda Python 一起提供,分别是 IPython 和 Spyder。IPython 是一个交互式外壳,但比默认的 Python 交互式环境功能更强大。它具有语法高亮、Tab 补全、系统外壳访问、以“魔法命令”形式存在的别名、宏等功能。您可以在ipython.org/了解 IPython 的相关信息。IPython 的另一个优点是其对笔记本的支持。您可以在笔记本中嵌入文本以及 Python 代码和输出,与他人共享或将其转换为 HTML 等其他格式。有关 Jupyter 笔记本的更多信息,请访问jupyter.org/。Anaconda 还安装了 Spyder,这是一个 Python 的交互式开发环境(IDE)。它使用 IPython,并将代码编辑器、交互式外壳、输出窗口、变量列表和其他信息集成在一个包中。更多信息请访问pythonhosted.org/spyder/。
我必须承认,我大多数时候使用的是文本编辑器而不是 IDE。这有其缺点,比如我没有一个链接在一起的代码编辑器和交互式外壳。例如,使用 Spyder,您可以从文件中运行脚本,脚本中设置的变量随后在交互式外壳中可用。这使得玩数据和探索数据变得非常容易。然而,缺乏集成也可能是一件好事,因为当我从文本编辑器运行脚本时,我的脚本总是从一个空白状态开始。不止一次,我看到学生因为不小心删除了设置变量的行而弄乱代码,但 IDE 记得变量,他们的代码仍然可以运行。嗯,直到他们重启 IDE,然后一切不再工作。
IDE 的另一个优点是它们使遍历和调试代码变得更加容易。Python 内置了一个名为 pdb 的调试器,但您可能会发现使用 IDE 更简单。调试器允许您在代码的行上设置断点,然后如果您运行脚本,它将一直运行直到遇到断点。您可以在那个点检查变量的当前值,也可以逐行执行代码并观察变量如何变化,或者查看代码是否按您预期的执行方式执行(换句话说,您可以检查您的逻辑)。所以如果您确实使用 IDE,明智的做法是阅读其调试器的帮助文档,或者只是编写一些代码,点击调试按钮,看看会发生什么。毕竟,玩耍是最好的学习方式。
在线有长长的 Python IDE 列表,可以在wiki.python.org/moin/IntegratedDevelopmentEnvironments找到。
附录 B. 参考文献
图中使用的数据
括号中的数字对应于本附录第二部分的内容,即“数据引用”。
第一章
1.1: Library of Congress (7)
1.2–1.4: Natural Earth (9)
1.5: USDA NAIP (16)
1.6: PRISM (12)
1.8: Grand Canyon NP (6), USGS Grand Canyon (19)
1.9: Snow (13)
1.11, 1.13: USGS Small-scale data (24)
第三章
3.1: USGS Small-scale data (24), GSHHG (10), OpenStreetMap (11)
3.2: OpenStreetMap (11), USGS NHD (22)
3.3: Natural Earth (9)
3.4: Natural Earth (9), USGS Small-scale data (24)
3.5: USCB TIGER (15), USGS Small-scale data (24), City of New Orleans (2), OpenStreetMap (11)
3.10, 3.13: Natural Earth (9)
3.14: USGS Small-scale data (24)
3.15, 3.17: Natural Earth (9)
第四章
4.2: Natural Earth (9)
4.4: USGS Small-scale data (24), NWS (8)
4.5: USGS Small-scale data (24)
4.7: NWS (8), OpenStreetMap (11)
4.8: NWS (8), Stamen (14)
第五章
5.1–5.9: Natural Earth (9)
5.11: USGS Small-scale data (24)
第六章
6.7: Natural Earth (9)
第七章
7.4: City of New Orleans (2), USGS Small-scale data (24), USCB TIGER (15), OpenStreetMap (11)
7.5, 7.6: USGS Small-scale data (24), City of New Orleans (2)
7.8: NREL Wind data (29), US Census Tract Data (28), USGS Small-scale data (24)
7.9: US Census tract data (28), USGS small-scale data (24)
7.10, 7.11: NREL Wind data (29)
7.12–7.16: Natural Earth (9), Env-DATA (3 and 4)
第八章
8.1–8.4: Natural Earth (9)
8.5: Utah AGRC (27)
8.7: USGS Small-scale data (24)
8.8–8.10: Natural Earth (9)
8.12: USDA NAIP (16)
8.13: Natural Earth (9)
第九章
9.1: USGS GAP (26)
9.2, 9.4: USDA NAIP (16)
9.7, 9.11, 9.13: USGS Landsat (20)
9.16: USGS Ortho (30)
第十章
10.1: USGS GAP (26)
10.2: USGS DOQ (17), USGS TOPO (25)
10.6: USGS DOQ (17)
10.7, 10.8: USGS GTOPO30 (18)
10.12, 10.13, 10.15: USGS Landsat (20)
第十一章
11.2: USDA NAIP (16)
11.7, 11.9: USGS GTOPO30 (18)
11.12: USGS GAP (26), EPA (5)
11.14: USGS Roads (23), BLM Wilderness areas (1)
11.16: USGS Roads (23)
第十二章
12.1: USGS GAP (26), USGS Landsat (20)
12.2: USGS GAP (26)
12.4: USGS GAP (26), USGS Landsat (20)
12.5: USGS GAP (26)
第十三章
13.4–13.7, 13.9: Natural Earth (9)
13.10: USGS NED (21)
13.11, 13.13: USGS Landsat (20)
13.15: USGS NED (21)
13.16: USCB TIGER (15)
13.18, 13.19: USCB TIGER (15), USGS Small-scale data (24)
13.20: USCB TIGER (15), USGS Small-scale data (24), NOLA (2), OpenStreetMap (11)
13.21: USGS TOPO (25)
13.22: USGS NED (21)
13.23: USGS NED (21), USGS TOPO (25)
数据引用
-
土地管理局和森林服务局。荒野地区。
research.idwr.idaho.gov/index.html -
新奥尔良市——信息技术与创新办公室,企业信息团队。
data.nola.gov/Geographic-Base-Layers/NOLA-Boundary/2b2j-u6kh -
Cruz, S.; Proaño, C.B.; Anderson, D.; Huyvaert, K.; Wikelski, M. (2013)。数据来源:环境数据自动轨迹标注系统(Env-DATA):将动物轨迹与环境数据连接起来。Movebank 数据存储库。DOI:10.5441/001/1.3hp3s250。
-
Dodge, S.; Bohrer, G.; Weinzierl, R.; Davidson, S.C.; Kays, R.; Douglas, D.; Cruz, S.; Han, J.; Brandes, D.; Wikelski, M. 2013. 环境数据自动轨迹标注系统(Env-DATA)——将动物轨迹与环境数据连接起来。运动生态学 1:3。DOI:10.1186/2051-3933-1-3。
-
环境保护局。北美三级生态区。
archive.epa.gov/wed/ecoregions/web/html/na_eco.html -
大峡谷国家公园——科学中心 GIS。大峡谷国家公园和亚利桑那州的公路、路线、街道和步道。
catalog.data.gov/dataset/roads-routes-streets-and-trails-of-grand-canyon-national-park-and-arizona -
美国国会图书馆,地理与地图部。
www.loc.gov/item/2005634035/ -
美国国家海洋和大气管理局国家地球物理数据中心,GSHHG,2015 年 10 月,
www.ngdc.noaa.gov/mgg/shorelines/shorelines.html -
OpenStreetMap。地图瓦片由 OpenStreetMap 提供,根据 CC BY 2.0 许可。数据由 OpenStreetMap 提供,根据 ODbL 许可:
www.openstreetmap.org/copyright -
欧里士多德气候组,俄勒冈州立大学,
prism.oregonstate.edu。地图创建于 2015 年 5 月 31 日。版权©2015。 -
Snow, John。
en.wikipedia.org/wiki/File:Snow-cholera-map-1.jpg -
Stamen 设计。地图瓦片由 Stamen 设计提供,根据 CC BY 3.0 许可。数据由 OpenStreetMap 提供,根据 ODbL 许可:
maps.stamen.com/#toner -
美国人口普查局。2006 年 TIGER/Line 形状文件。
www.census.gov/geo/maps-data/data/tiger-line.html -
美国农业服务局航空摄影野外办公室。NAIP 影像。
earthexplorer.usgs.gov/ -
美国地质调查局。数字正射影像图(DOQs)。
earthexplorer.usgs.gov/ -
美国地质调查局. 全球 30 秒高程 (GTOPO30).
earthexplorer.usgs.gov/ -
美国地质调查局. 大峡谷数据存储库.
pubs.usgs.gov/ds/121/grand/grand.html -
美国地质调查局. 兰斯达特影像.
earthexplorer.usgs.gov/ -
美国地质调查局. 国家高程数据集 (NED).
nationalmap.gov/elevation.html -
美国地质调查局,2013,国家水文地理数据库 (NHD).
viewer.nationalmap.gov/viewer/nhd.html?p=nhd -
美国地质调查局. 道路.
research.idwr.idaho.gov/index.html -
美国地质调查局. 小比例尺数据.
nationalmap.gov/small_scale/atlasftp.html -
美国地质调查局. 美国地形图 (TOPO).
nationalmap.gov/ustopo/ -
美国地质调查局国家差距分析计划. 2004. 美国西南部临时数字土地覆盖图. 版本 1.0. 自然资源学院,犹他州立大学 RS/GIS 实验室.
earth.gis.usu.edu/swgap/landcover.html -
犹他州自动化地理参考中心.
gis.utah.gov/data/ -
美国商务部,美国人口普查局,地理司/制图产品处. 2010. 2010 制图边界文件,加利福尼亚州州-县-人口普查区,1:500,000.
www2.census.gov/geo/tiger/GENZ2010/ -
美国国家可再生能源实验室. 风数据.
www.nrel.gov/gis/data_wind.html -
美国地质调查局国家地图的 1 英尺正射影像.
raster.nationalmap.gov/arcgis/rest/services/Orthoimagery/USGS_EROS_Ortho_1Foot/ImageServer


和线
几何形状来表示密西西比河的区别。多边形显示了河岸的细节,而线则没有。
浙公网安备 33010602011771号