Python-3D-深度学习-全-
Python 3D 深度学习(全)
原文:
annas-archive.org/md5/50f42ebb43219296f45561fe52e2a76c
译者:飞龙
第一章:《使用 Python 进行 3D 深度学习》
使用 PyTorch3D 及更多工具,设计并开发您的计算机视觉模型,基于 3D 数据
Xudong Ma
Vishakh Hegde
Lilit Yolyan
伯明翰——孟买
《使用 Python 进行 3D 深度学习》
版权所有 © 2022 Packt 出版公司
保留所有权利。未经出版商事先书面许可,本书的任何部分不得以任何形式或通过任何手段复制、存储在检索系统中或传输,但在批评性文章或评论中引用短暂的摘录除外。
本书在准备过程中已尽力确保所提供信息的准确性。然而,本书中的信息是以无任何明示或暗示的保证出售的。作者、Packt 出版公司及其经销商和分销商对于因本书直接或间接引起的任何损害或被声称引起的损害不承担责任。
Packt 出版公司已尽力通过适当的使用大写字母提供关于本书中提到的所有公司和产品的商标信息。然而,Packt 出版公司不能保证这些信息的准确性。
出版产品经理:Dinesh Chaudhary
内容开发编辑:Joseph Sunil
技术编辑:Rahul Limbachiya
文稿编辑:Safis Editing
项目协调员:Farheen Fathima
校对员:Safis Editing
索引员:Rekha Nair
制作设计师:Ponraj Dhandapani
市场协调员:Shifa Ansari
首次出版:2022 年 11 月
制作参考:1211022
由 Packt 出版有限公司出版
Livery Place
35 Livery Street
伯明翰
英国 B3 2PB。
ISBN 978-1-80324-782-3
“献给我的妻子和家人,感谢他们在每一步的支持和鼓励。” - Vishakh Hegde
“献给我的家人和朋友,他们的爱和支持一直是我最大的动力。” - Lilit Yolyan
贡献者
关于作者
Xudong Ma是 Grabango Inc.(位于加利福尼亚伯克利)的工作人员机器学习工程师。他曾是 Facebook(Meta)Oculus 的高级机器学习工程师,并与 3D PyTorch 团队紧密合作,进行 3D 面部追踪项目。他在计算机视觉、机器学习和深度学习方面有多年经验,并拥有电气与计算机工程学博士学位。
Vishakh Hegde是一名机器学习和计算机视觉研究员。他在该领域拥有超过 7 年的经验,在此期间,他撰写了多篇广受引用的研究论文并申请了专利。他拥有斯坦福大学应用数学和机器学习专业的硕士学位,以及印度理工学院马德拉斯分校的物理学学士和硕士学位。他曾在 Schlumberger 和 Matroid 工作过。他现在是 Ambient.ai 的高级应用科学家,在那里他帮助构建了武器检测系统,该系统已经在多个全球财富 500 强公司中部署。他目前正在利用自己的专业知识和解决商业挑战的热情,在硅谷创办技术初创公司。你可以通过他的网站了解更多关于他的内容。
我要感谢那些突破性研究让我有机会撰写的计算机视觉研究人员。我要感谢评审们的反馈,以及 Packt Publishing 团队给予我创造性发挥的机会。最后,我要感谢我的妻子和家人,在我最需要的时候给予我所有的支持和鼓励。
Lilit Yolyan是 YSU 的机器学习研究员,目前正在攻读博士学位。她的研究重点是利用遥感数据为智慧城市构建计算机视觉解决方案。她在计算机视觉领域有 5 年的经验,并且参与了一个复杂的驾驶员安全解决方案,该解决方案将由多个知名汽车制造公司部署。
关于评审人
Eya Abid是一名专攻深度学习和计算机视觉的工程硕士生。她目前担任 NVIDIA 的 AI 讲师,以及在 CERN 从事量子机器学习的工作。
我要将这项工作献给我的家人、朋友以及在这个过程中帮助过我的每一个人。特别献给 Aymen,我将永远感激他。
Ramesh Sekhar是 Dapster.ai 的首席执行官兼联合创始人,这是一家构建可负担且易于部署的机器人公司的企业,这些机器人可以在仓库中执行最繁重的任务。Ramesh 曾在 Symbol、Motorola 和 Zebra 等公司工作,专注于构建计算机视觉、人工智能和机器人学交汇处的产品。他拥有电气工程学士学位和计算机科学硕士学位。Ramesh 于 2020 年创立了 Dapster.ai。Dapster 的使命是通过执行危险和不健康的任务来构建对人类产生积极影响的机器人。他们的愿景是解锁更好的工作,强化供应链,并更好地应对气候变化带来的挑战。
乌特卡什·斯里瓦斯塔瓦是人工智能/机器学习专业人士、培训师、YouTuber 和博主。他热衷于解决复杂问题,开发机器学习、自然语言处理和计算机视觉算法。他的职业生涯始于他的博客(datamahadev.com)和 YouTube 频道(datamahadev),之后在古吉拉特邦的一所机构担任高级数据科学培训师。此外,他还曾培训并辅导了 1,000 多名在职专业人士和学生,涵盖人工智能/机器学习领域。乌特卡什已完成 40 多个自由职业的数据科学与分析、AI/ML、Python 开发和 SQL 培训与开发项目。他来自卢克瑙,目前定居在印度班加罗尔,担任德勤 USI 咨询公司的分析师。
我要感谢我的母亲,Rupam Srivastava 女士,在我经历的困难与奋斗中,给予我持续的指导和支持。也感谢至尊 Para-Brahman。
梅森·麦高夫是 Lowe's 创新实验室的高级研发工程师和计算机视觉专家。他对成像充满热情,已经花费超过十年时间解决广泛行业和学术领域中的计算机视觉问题,包括地质学、生物信息学、游戏开发和零售。最近,他正在探索数字双胞胎和 3D 扫描在零售店中的应用。
我要感谢 Andy Lykos、Joseph Canzano、Alexander Arango、Oleg Alexander、Erin Clark 以及我的家人对我的支持。
目录
前言
第一部分:3D 数据处理基础
1
介绍 3D 数据处理
技术要求
设置开发环境
3D 数据表示
理解点云表示
理解网格表示
理解体素表示
3D 数据文件格式 – Ply 文件
3D 数据文件格式 – OBJ 文件
理解 3D 坐标系统
理解摄像机模型
摄像机模型和坐标系统的编码
总结
2
介绍 3D 计算机视觉和几何学
技术要求
探索渲染、光栅化和阴影处理的基本概念
理解重心坐标
光源模型
理解兰伯特光照模型
理解 Phong 光照模型
3D 渲染的编码练习
使用 PyTorch3D 异构批量和 PyTorch 优化器
异构小批量的编码练习
理解变换与旋转
变换和旋转的编码练习
总结
第二部分:使用 PyTorch3D 的 3D 深度学习
3
将可变形网格模型拟合到原始点云
技术要求
将网格拟合到点云——问题
将可变形网格拟合问题公式化为优化问题
正则化的损失函数
网格拉普拉斯平滑损失
网格法线一致性损失
网格边缘损失
实现 PyTorch3D 网格拟合
不使用任何正则化损失函数的实验
仅使用网格边缘损失的实验
总结
4
通过可微分渲染学习物体姿态检测与跟踪
技术要求
为什么我们希望使用可微分渲染
如何使渲染可微分
使用可微分渲染可以解决的问题
物体姿态估计问题
如何编码实现
物体姿态估计示例:轮廓拟合和纹理拟合
总结
5
理解可微分体积渲染
技术要求
体积渲染概述
理解光线采样
使用体积采样
探索光线行进器
可微分体积渲染
从多视角图像重建 3D 模型
总结
6
探索神经辐射场(NeRF)
技术要求
理解 NeRF
什么是辐射场?
用神经网络表示辐射场
训练 NeRF 模型
理解 NeRF 模型架构
理解使用辐射场的体积渲染
将射线投射到场景中
累积射线的颜色
总结
第三部分:使用 PyTorch3D 的最先进 3D 深度学习
7
探索可控神经特征场
技术要求
理解基于 GAN 的图像合成
介绍组合式 3D 感知图像合成
生成特征场
将特征场映射到图像
探索可控场景生成
探索可控汽车生成
探索可控面部生成
训练 GIRAFFE 模型
Frechet 起始距离
训练模型
总结
8
3D 人体建模
技术要求
制定 3D 建模问题
定义良好的表示
理解线性混合蒙皮技术
理解 SMPL 模型
定义 SMPL 模型
使用 SMPL 模型
使用 SMPLify 估计 3D 人体姿态和形状
定义优化目标函数
探索 SMPLify
运行代码
探索代码
总结
9
使用 SynSin 进行端到端视图合成
技术要求
视图合成概述
SynSin 网络架构
空间特征与深度网络
神经点云渲染器
细化模块与鉴别器
实践模型训练与测试
总结
10
Mesh R-CNN
技术要求
网格与体素概述
Mesh R-CNN 架构
图卷积
网格预测器
PyTorch 下的 Mesh R-CNN 演示
演示
总结
索引
您可能喜欢的其他书籍
前言
从事 3D 计算机视觉开发的人员可以通过这本实践指南,将他们的知识应用于 3D 深度学习。书中提供了一个动手实现的方式,并配有相关方法论,让你迅速上手并有效工作。
本书通过详细的步骤讲解基础概念、实践示例和自我评估问题,帮助你从探索最前沿的 3D 深度学习开始。
你将学习如何使用 PyTorch3D 处理基本的 3D 网格和点云数据,例如加载和保存 PLY 和 OBJ 文件、使用透视相机模型或正交相机模型将 3D 点投影到相机坐标系,并将点云和网格渲染成图像等。你还将学习如何实现某些最前沿的 3D 深度学习算法,如微分渲染、NeRF、SynSin 和 Mesh R-CNN,因为使用 PyTorch3D 库进行这些深度学习模型的编程将变得更加容易。
到本书结束时,你将能够实现自己的 3D 深度学习模型。
本书适合谁阅读
本书适合初学者和中级机器学习从业者、数据科学家、机器学习工程师和深度学习工程师,他们希望掌握使用 3D 数据的计算机视觉技术。
本书的内容
第一章,介绍 3D 数据处理,将介绍 3D 数据的基础知识,例如 3D 数据是如何存储的,以及网格和点云、世界坐标系和相机坐标系的基本概念。它还会向我们展示什么是 NDC(一个常用的坐标系),如何在不同坐标系之间进行转换,透视相机和正交相机,以及应使用哪些相机模型。
第二章,介绍 3D 计算机视觉与几何学,将向我们展示计算机图形学中的基本概念,如渲染和着色。我们将学习一些在后续章节中需要的基础概念,包括 3D 几何变换、PyTorch 张量和优化。
第三章,将可变形网格模型拟合到原始点云,将展示一个使用可变形 3D 模型拟合噪声 3D 观测值的动手项目,利用我们在前几章中学到的所有知识。我们将探讨常用的代价函数,为什么这些代价函数很重要,以及它们通常在何时使用。最后,我们将探讨一个具体的例子,说明哪些代价函数被选用于哪些任务,以及如何设置优化循环以获得我们想要的结果。
第四章,通过可微分渲染学习物体姿态检测与跟踪,将讲解可微分渲染的基本概念。它将帮助你理解基本概念,并了解何时可以应用这些技术来解决自己的问题。
第五章,理解可微分体积渲染,将通过一个实际项目介绍如何使用可微分渲染从单张图片和已知的 3D 网格模型估计相机位置。我们将学习如何实际使用 PyTorch3D 来设置相机、渲染和着色器。我们还将亲手体验如何使用不同的代价函数来获得优化结果。
第六章,探索神经辐射场(NeRF),将提供一个实际项目,使用可微分渲染从多张图片和纹理模型估计 3D 网格模型。
第七章,探索可控神经特征场,将介绍一个视图合成的重要算法,即 NeRF。我们将了解它的原理、如何使用以及它的价值所在。
第八章,3D 人体建模,将探讨使用 SMPL 算法进行 3D 人体拟合。
第九章,使用 SynSin 进行端到端视图合成,将介绍 SynSin,这是一个最先进的深度学习图像合成模型。
第十章,Mesh R-CNN,将介绍 Mesh R-CNN,这是另一种最先进的方法,用于从单张输入图像预测 3D 体素模型。
为了最大限度地从本书中受益
本书中涵盖的软件/硬件 | 操作系统要求 |
---|---|
Python 3.6+ | Windows、macOS 或 Linux |
如果你使用的是本书的电子版,我们建议你自己输入代码或访问 本书的 GitHub 代码库(下一节中会提供链接)。这样可以帮助你避免因复制粘贴代码而可能出现的错误。
请参考以下论文:
第六章**: arxiv.org/abs/2003.08934
, github.com/yenchenlin/nerf-pytorch
第七章**: m-niemeyer.github.io/project-pages/giraffe/index.xhtml
,arxiv.org/abs/2011.12100
第八章**: smpl.is.tue.mpg.de/
, smplify.is.tue.mpg.de/, https://smpl-x.is.tue.mpg.de/
第九章**: arxiv.org/pdf/1912.08804.pdf
第十章**: arxiv.org/abs/1703.06870
, arxiv.org/abs/1906.02739
下载示例代码文件
你可以从 GitHub 上下载本书的示例代码文件,网址为github.com/PacktPublishing/3D-Deep-Learning-with-Python
。如果代码有更新,将在 GitHub 仓库中更新。
我们还提供来自丰富图书和视频目录中其他代码包,网址为github.com/PacktPublishing/
。请查看!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书使用的截图和图表的彩色图像。您可以从这里下载:packt.link/WJr0Q
。
使用的约定
本书中使用了许多文本约定。
文本中的代码
: 指示文本中的代码字词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 句柄。例如:“接下来,我们需要更新./``options/options.py
文件”
代码块如下所示:
elif opt.dataset == 'kitti':
opt.min_z = 1.0
opt.max_z = 50.0
opt.train_data_path = (
'./DATA/dataset_kitti/'
)
from data.kitti import KITTIDataLoader
return KITTIDataLoader
当我们希望引起您对代码块特定部分的注意时,相关行或项目将以粗体显示:
wget https://dl.fbaipublicfiles.com/synsin/checkpoints/realestate/synsin.pth
任何命令行输入或输出均如下所示:
bash ./download_models.sh
粗体: 表示新术语、重要词汇或屏幕上显示的词语。例如:“细化模块(g)从神经点云渲染器获取输入,然后输出最终重建的图像。”
提示或重要说明
如此显示。
联系我们
我们时刻欢迎读者的反馈。
常规反馈: 如果您对本书的任何方面有疑问,请发送电子邮件至customercare@packtpub.com,并在邮件主题中提到书名。
勘误: 尽管我们已经非常注意确保内容的准确性,但错误是不可避免的。如果您在本书中发现错误,请向我们报告。请访问www.packtpub.com/support/errata并填写表格。
盗版: 如果您在互联网上发现我们作品的任何非法副本,请提供地址或网站名称,我们将不胜感激。请联系我们,网址为copyrigh@packt.com,并附上链接。
如果您有兴趣成为作者: 如果您在某个专题上有专长,并且有意撰写或为书籍做贡献,请访问authors.packtpub.com。
分享您的想法
阅读完《Python 与 3D 深度学习》后,我们非常希望听到您的想法!请点击这里直接进入亚马逊评论页面并分享您的反馈。
您的评论对我们和技术社区至关重要,将帮助我们确保提供优质的内容。
下载本书的免费 PDF 版本
感谢您购买本书!
您喜欢随时随地阅读,但无法将纸质书籍带到处吗?
您的电子书购买是否与您选择的设备不兼容?
不用担心,现在每本 Packt 图书都附带无 DRM 保护的 PDF 版本,您可以免费获得。
在任何地方、任何设备上阅读。搜索、复制并将代码从您最喜欢的技术书籍直接粘贴到您的应用程序中。
优惠不仅仅如此,您还可以独享折扣、新闻通讯以及每天直接发送到您邮箱的精彩免费内容
按照这些简单步骤获取相关福利:
- 扫描二维码或访问下面的链接
packt.link/free-ebook/9781803247823
-
提交您的购买凭证
-
就这样!我们将直接把您的免费 PDF 和其他福利发送到您的电子邮箱。
第一部分:3D 数据处理基础
本书的第一部分将定义数据和图像处理的最基本概念,因为这些概念对我们后续的讨论至关重要。此部分内容使本书内容自成一体,读者无需阅读其他书籍即可开始学习 PyTorch3D。
本部分包括以下章节:
-
第一章,介绍 3D 数据处理
-
第二章,介绍 3D 计算机视觉与几何学
第二章:引入 3D 数据处理
在本章中,我们将讨论一些对于 3D 深度学习非常基础的概念,并且这些概念将在后续章节中频繁使用。我们将从学习最常用的 3D 数据格式开始,了解我们如何操作它们并将它们转换为不同的格式。我们将首先设置我们的开发环境并安装所有必要的软件包,包括 Anaconda、Python、PyTorch 和 PyTorch3D。接着,我们将讨论最常用的 3D 数据表示方式——例如,点云、网格和体素。然后我们将介绍 3D 数据文件格式,如 PLY 和 OBJ 文件。接下来,我们将讨论 3D 坐标系统。最后,我们将讨论相机模型,这主要与 3D 数据如何映射到 2D 图像相关。
在阅读完本章后,您将能够通过检查输出数据文件轻松调试 3D 深度学习算法。通过对坐标系统和相机模型的深入理解,您将能够在此基础上进一步学习更高级的 3D 深度学习主题。
在本章中,我们将涵盖以下主要主题:
-
设置开发环境并安装 Anaconda、PyTorch 和 PyTorch3D
-
3D 数据表示
-
3D 数据格式——PLY 和 OBJ 文件
-
3D 坐标系统及其之间的转换
-
相机模型——透视相机和正交相机
技术要求
为了运行本书中的示例代码,您需要一台理想情况下配备 GPU 的计算机。然而,仅使用 CPU 也可以运行这些代码片段。
推荐的计算机配置包括以下内容:
-
至少配备 8GB 内存的 GPU,如 GTX 系列或 RTX 系列
-
Python 3
-
PyTorch 库和 PyTorch3D 库
本章的代码片段可以在 github.com/PacktPublishing/3D-Deep-Learning-with-Python
上找到。
设置开发环境
首先,让我们为本书中的所有编码练习设置一个开发环境。我们建议使用 Linux 机器来运行本书中的所有 Python 代码示例:
-
我们将首先设置 Anaconda。Anaconda 是一个广泛使用的 Python 发行版,捆绑了强大的 CPython 实现。使用 Anaconda 的一个优势是它的包管理系统,使用户能够轻松创建虚拟环境。Anaconda 的个人版对于个人使用者、学生和研究人员是免费的。要安装 Anaconda,我们建议访问 anaconda.com 网站获取详细的安装说明。通常,安装 Anaconda 最简单的方法是运行从其网站下载的脚本。设置好 Anaconda 后,运行以下命令创建一个 Python 3.7 的虚拟环境:
$ conda create -n python3d python=3.7
这个命令将创建一个 Python 3.7 版本的虚拟环境。为了使用这个虚拟环境,我们需要先激活它,方法是运行以下命令:
-
使用以下命令激活新创建的虚拟环境:
$ source activate python3d
-
安装 PyTorch。有关安装 PyTorch 的详细说明,可以在其网页 www.pytorch.org/get-started/locally/ 上找到。例如,我将在我的 Ubuntu 桌面上安装 PyTorch 1.9.1,CUDA 版本为 11.1,命令如下:
$ conda install pytorch torchvision torchaudio cudatoolkit-11.1 -c pytorch -c nvidia
-
安装 PyTorch3D。PyTorch3D 是 Facebook AI Research 最近发布的一个开源 Python 库,用于 3D 计算机视觉。PyTorch3D 提供了许多实用功能,可以轻松操作 3D 数据。它的设计考虑了深度学习,几乎所有的 3D 数据都可以通过小批量处理,例如相机、点云和网格。PyTorch3D 的另一个关键特点是实现了一种非常重要的 3D 深度学习技术,称为 可微渲染。然而,PyTorch3D 作为 3D 深度学习库的最大优势是它与 PyTorch 的紧密结合。
PyTorch3D 可能需要一些依赖库,如何安装这些依赖库的详细说明可以在 PyTorch3D GitHub 首页找到,网址是 github.com/facebookresearch/pytorch3d。在按照网站上的说明安装了所有依赖项后,安装 PyTorch3D 可以通过运行以下命令轻松完成:
$ conda install pytorch3d -c pytorch3d
现在我们已经设置好了开发环境,让我们开始学习数据表示。
3D 数据表示
在本节中,我们将学习最常用的 3D 数据表示方法。选择数据表示是许多 3D 深度学习系统中的一个特别重要的设计决策。例如,点云没有类似网格的结构,因此通常不能直接对其进行卷积操作。体素表示有类似网格的结构;然而,它们往往消耗大量计算机内存。在本节中,我们将更详细地讨论这些 3D 表示的优缺点。广泛使用的 3D 数据表示通常包括点云、网格和体素。
理解点云表示
3D 点云是 3D 物体的一种非常直观的表示方式,其中每个点云只是由多个 3D 点组成,每个 3D 点由一个三维元组(x、y 或 z)表示。许多深度摄像头的原始测量数据通常是 3D 点云。
从深度学习的角度来看,3D 点云是无序且不规则的数据类型。与常规图像不同,我们可以为每个像素定义邻近像素,而在点云中,每个点的邻近点没有清晰且规则的定义——也就是说,卷积通常不能应用于点云。因此,需要使用特殊类型的深度学习模型来处理点云,比如 PointNet:arxiv.org/abs/1612.00593
。
对于点云作为 3D 深度学习训练数据的另一个问题是异构数据问题——即,对于一个训练数据集,不同的点云可能包含不同数量的 3D 点。解决这种异构数据问题的一种方法是强制所有点云具有相同数量的点。然而,这并不总是可行的——例如,深度相机返回的点数可能会随着帧的变化而不同。
异构数据可能会给小批量梯度下降训练深度学习模型带来一些困难。大多数深度学习框架假设每个小批量包含相同大小和维度的训练样本。这样的同质数据是首选,因为它可以通过现代并行处理硬件(如 GPU)最有效地处理。高效处理异构小批量数据需要额外的工作。幸运的是,PyTorch3D 提供了许多高效处理异构小批量数据的方法,这对于 3D 深度学习至关重要。
理解网格表示法
网格是另一种广泛使用的 3D 数据表示形式。就像点云中的点一样,每个网格包含一组称为顶点的 3D 点。此外,每个网格还包含一组称为面片的多边形,这些面片是在顶点上定义的。
在大多数数据驱动的应用中,网格是深度相机原始测量结果的后处理结果。通常,它们是在 3D 资产设计过程中手动创建的。与点云相比,网格包含额外的几何信息,编码拓扑,并且具有表面法线信息。这些额外的信息在训练学习模型时尤其有用。例如,图卷积神经网络通常将网格视为图形,并使用顶点邻域信息定义卷积操作。
就像点云一样,网格也有类似的异构数据问题。同样,PyTorch3D 提供了处理网格数据异构小批量的高效方法,这使得 3D 深度学习变得高效。
理解体素表示法
另一种重要的 3D 数据表示法是体素表示法。体素是 3D 计算机视觉中像素的对应物。像素是通过将 2D 矩形划分为更小的矩形来定义的,每个小矩形就是一个像素。类似地,体素是通过将 3D 立方体划分为更小的立方体来定义的,每个立方体称为一个体素。这个过程如下图所示:
图 1.1 – 体素表示法是 2D 像素表示法的 3D 对应物,其中一个立方体空间被划分成小的体积元素
体素表示法通常使用截断有符号距离函数(TSDFs)来表示 3D 表面。有符号距离函数(SDF)可以在每个体素上定义为体素中心到表面最近点的(有符号)距离。SDF 中的正号表示体素中心在物体外部。TSDF 和 SDF 之间的唯一区别在于 TSDF 的值是截断的,因此 TSDF 的值始终范围在-1 到+1 之间。
与点云和网格不同,体素表示法是有序且规则的。这个特性类似于图像中的像素,使得在深度学习模型中可以使用卷积滤波器。体素表示法的一个潜在缺点是它通常需要更多的计算机内存,但通过使用诸如哈希等技术可以减少内存需求。尽管如此,体素表示法仍然是一个重要的 3D 数据表示法。
除了这里提到的 3D 数据表示方式,还有其他的 3D 数据表示方式。例如,多视角表示法使用从不同视点拍摄的多张图像来表示一个 3D 场景。RGB-D 表示法使用一个额外的深度通道来表示一个 3D 场景。然而,在本书中,我们不会深入探讨这些 3D 表示法。现在我们已经了解了 3D 数据表示的基础知识,接下来我们将深入了解几种常用的点云和网格文件格式。
3D 数据文件格式 – Ply 文件
PLY 文件格式是由斯坦福大学的一组研究人员在 1990 年代中期开发的。此后,它已发展成为最广泛使用的 3D 数据文件格式之一。该文件格式有 ASCII 版本和二进制版本。在需要文件大小和处理效率的情况下,二进制版本更受偏好。ASCII 版本则使得调试变得相对容易。接下来,我们将讨论 PLY 文件的基本格式,以及如何使用 Open3D 和 PyTorch3D 加载并可视化 PLY 文件中的 3D 数据。
在本节中,我们将讨论两种最常用的数据文件格式来表示点云和网格,即 PLY 文件格式和 OBJ 文件格式。我们将讨论这些格式以及如何使用 PyTorch3D 加载和保存这些文件格式。PyTorch3D 提供了优秀的实用函数,因此使用这些实用函数可以高效且轻松地加载和保存这些文件格式。
一个示例,cube.ply
文件,在以下代码片段中显示:
ply
format ascii 1.0
comment created for the book 3D Deep Learning with Python
element vertex 8
property float32 x
property float32 y
property float32 z
element face 12
property list uint8 int32 vertex_indices
end_header
-1 -1 -1
1 -1 -1
1 1 -1
-1 1 -1
-1 -1 1
1 -1 1
1 1 1
-1 1 1
3 0 1 2
3 5 4 7
3 6 2 1
3 3 7 4
3 7 3 2
3 5 1 0
3 0 2 3
3 5 7 6
3 6 1 5
3 3 4 0
3 7 2 6
3 5 0 4
正如所见,每个 PLY 文件包含一个头部和一个数据部分。每个 ASCII PLY 文件的第一行始终为ply
,表示这是一个 PLY 文件。第二行format ascii 1.0
表示文件是 ASCII 类型,并带有版本号 1.0。以comment
开头的任何行将被视为注释行,因此加载 PLY 文件时计算机会忽略comment
后的内容。element vertex 8
行表示 PLY 文件中第一种数据类型是顶点,我们有八个顶点。property float32 x
表示每个顶点具有名为x
的float32
类型属性。同样,每个顶点还具有y
和z
属性。在这里,每个顶点都是一个 3D 点。element face 12 line
行表示该 PLY 文件的第二种数据类型是face
,我们有 12 个面。property list unit8 int32 vertex_indices
显示每个面将是一个顶点索引列表。PLY 文件的头部部分总是以end_header
行结束。
PLY 文件数据部分的第一部分由八行组成,每行记录一个顶点。每行中的三个数字表示顶点的三个x
、y
和z
属性。例如,三个数字-1, -1, -1 指定该顶点具有x
坐标为-1
,y
坐标为-1
和z
坐标为-1
。
PLY 文件的数据部分的第二部分由 12 行组成,每行记录一个面的数据。序列中的第一个数字表示面有多少个顶点,接下来的数字是顶点的索引。顶点的索引由 PLY 文件中声明顶点的顺序确定。
我们可以使用 Open3D 和 PyTorch3D 打开前述文件。Open3D 是一个非常方便用于可视化 3D 数据的 Python 包,而 PyTorch3D 则适用于将这些数据用于深度学习模型。以下是一个代码片段,ply_example1.py
,用于可视化cube.ply
文件中的网格,并将顶点和网格加载为 PyTorch 张量:
import open3d
from pytorch3d.io import load_ply
mesh_file = "cube.ply"
print('visualizing the mesh using open3D')
mesh = open3d.io.read_triangle_mesh(mesh_file)
open3d.visualization.draw_geometries([mesh],
mesh_show_wireframe = True,
mesh_show_back_face = True)
print("Loading the same file with PyTorch3D")
vertices, faces = load_ply(mesh_file)
print('Type of vertices = ', type(vertices))
print("type of faces = ", type(faces))
print('vertices = ', vertices)
print('faces = ', faces)
在前面的 Python 代码片段中,cube.ply
网格文件首先通过open3d
库使用read_triangle_mesh
函数打开,并将所有 3D 数据读取到网格变量中。然后,可以使用 Open3D 库的draw_geometries
函数来可视化该网格。当你运行这个函数时,Open3D 库将弹出一个窗口进行交互式可视化——也就是说,你可以使用鼠标进行旋转、缩放和拉近/远离网格。正如你猜到的,cube.ply
文件定义了一个具有八个顶点和六个面的立方体网格,每个面由两个三角形组成。
我们还可以使用PyTorch3D
库来加载相同的网格。不过,这一次,我们将获得几个 PyTorch 张量——例如,一个张量表示顶点,一个张量表示面。这些张量可以直接输入到任何 PyTorch 深度学习模型中。在这个示例中,load_ply
函数返回一个元组,其中包含顶点和面的信息,这些信息通常采用 PyTorch 张量的格式。当你运行这个ply_example1.py
代码片段时,返回的顶点应该是一个形状为[8, 3]
的 PyTorch 张量——也就是说,有八个顶点,每个顶点有三个坐标。同样,返回的面应该是一个形状为[12, 3]
的 PyTorch 张量——即有 12 个面,每个面有 3 个顶点索引。
在下面的代码片段中,我们展示了另一个parallel_plane_mono.ply
文件的示例,该文件也可以从我们的 GitHub 仓库下载。这个示例中的网格与cube_is.ply
文件中的网格的唯一区别在于面数的不同。在这里,我们有四个面,它们组成了两个平行平面,而不是六个立方体的面:
ply
format ascii 1.0
comment created for the book 3D Deep Learning with Python
element vertex 8
property float32 x
property float32 y
property float32 z
element face 4
property list uint8 int32 vertex_indices
end_header
-1 -1 -1
1 -1 -1
1 1 -1
-1 1 -1
-1 -1 1
1 -1 1
1 1 1
-1 1 1
3 0 1 2
3 0 2 3
3 5 4 7
3 5 7 6
该网格可以通过以下ply_example2.py
进行交互式可视化:
-
首先,我们导入所有需要的 Python 库:
import open3d from pytorch3d.io import load_ply
-
我们使用
open3d
来加载网格:mesh_file = "parallel_plane_mono.ply" print('visualizing the mesh using open3D') mesh = open3d.io.read_triangle_mesh(mesh_file)
-
我们使用
draw_geometries
来打开一个窗口,以便与网格进行交互式可视化:open3d.visualization.draw_geometries([mesh], mesh_show_wireframe = True, mesh_show_back_face = True)
-
我们使用
pytorch3d
来打开相同的网格:print("Loading the same file with PyTorch3D") vertices, faces = load_ply(mesh_file)
-
我们可以打印出加载的顶点和面的信息。实际上,它们只是普通的 PyTorch3D 张量:
print('Type of vertices = ', type(vertices), ", type of faces = ", type(faces)) print('vertices = ', vertices) print('faces = ', faces)
对于每个顶点,我们还可以定义除x、y和z坐标之外的属性。例如,我们还可以为每个顶点定义颜色。下面是parallel_plane_color.ply
的示例:
ply
format ascii 1.0
comment created for the book 3D Deep Learning with Python
element vertex 8
property float32 x
property float32 y
property float32 z
property uchar red
property uchar green
property uchar blue
element face 4
property list uint8 int32 vertex_indices
end_header
-1 -1 -1 255 0 0
1 -1 -1 255 0 0
1 1 -1 255 0 0
-1 1 -1 255 0 0
-1 -1 1 0 0 255
1 -1 1 0 0 255
1 1 1 0 0 255
-1 1 1 0 0 255
3 0 1 2
3 0 2 3
3 5 4 7
3 5 7 6
请注意,在前面的示例中,除了x、y和z外,我们还为每个顶点定义了一些额外的属性——即红色、绿色和蓝色属性,所有这些属性的类型都是uchar
数据类型。现在,每个顶点的记录就是一行六个数字。前三个是x、y和z坐标,接下来的三个数字是 RGB 值。
可以通过使用ply_example3.py
来可视化网格,如下所示:
import open3d
from pytorch3d.io import load_ply
mesh_file = "parallel_plane_color.ply"
print('visualizing the mesh using open3D')
mesh = open3d.io.read_triangle_mesh(mesh_file)
open3d.visualization.draw_geometries([mesh],
mesh_show_wireframe = True,
mesh_show_back_face = True)
print("Loading the same file with PyTorch3D")
vertices, faces = load_ply(mesh_file)
print('Type of vertices = ', type(vertices), ", type of faces = ", type(faces))
print('vertices = ', vertices)
print('faces = ', faces)
我们还提供了cow.ply
,这是一个真实世界的 3D 网格示例。读者可以使用ply_example4.py
来可视化该网格。
到现在为止,我们已经讨论了 PLY 文件格式的基本元素,例如顶点和面。接下来,我们将讨论 OBJ 3D 数据格式。
3D 数据文件格式 - OBJ 文件
本节我们将讨论另一种广泛使用的 3D 数据文件格式——OBJ 文件格式。OBJ 文件格式最初由 Wavefront Technologies Inc. 开发。与 PLY 文件格式类似,OBJ 格式也有 ASCII 版本和二进制版本。二进制版本是专有且未公开文档的。因此,本节我们将讨论 ASCII 版本。
像前一节一样,我们将通过查看示例来学习文件格式。第一个示例 cube.obj
如下所示。如你所料,OBJ 文件定义了一个立方体的网格。
第一行 mtlib ./cube.mtl
声明了配套的 材质模板库 (MTL) 文件。MTL 文件描述了表面阴影属性,这将在下一个代码片段中进行解释。
对于 o cube
行,起始字母 o
表明此行定义了一个物体,物体的名称是 cube
。任何以 #
开头的行都是注释行——即计算机会忽略该行的其余部分。每一行以 v
开头,表示该行定义了一个顶点。例如,v -0.5 -0.5 0.5
定义了一个顶点,其 x 坐标为 0.5,y 坐标为 0.5,z 坐标为 0.5。每一行以 f
开头时,f
表示该行包含一个面的定义。例如,f 1 2 3
行定义了一个面,其三个顶点为索引为 1、2 和 3 的顶点。
usemtl Door
行声明了在此行之后声明的表面应使用在 MTL 文件中定义的材质属性进行阴影渲染,材质名称为 Door
:
mtllib ./cube.mtl
o cube
# Vertex list
v -0.5 -0.5 0.5
v -0.5 -0.5 -0.5
v -0.5 0.5 -0.5
v -0.5 0.5 0.5
v 0.5 -0.5 0.5
v 0.5 -0.5 -0.5
v 0.5 0.5 -0.5
v 0.5 0.5 0.5
# Point/Line/Face list
usemtl Door
f 1 2 3
f 6 5 8
f 7 3 2
f 4 8 5
f 8 4 3
f 6 2 1
f 1 3 4
f 6 8 7
f 7 2 6
f 4 5 1
f 8 3 7
f 6 1 5
配套的 cube.mtl
MTL 文件如下所示。该文件定义了一个名为 Door
的材质属性:
newmtl Door
Ka 0.8 0.6 0.4
Kd 0.8 0.6 0.4
Ks 0.9 0.9 0.9
d 1.0
Ns 0.0
illum 2
除了 map_Kd
以外,我们不会详细讨论这些材质属性。如果你感兴趣,可以参考标准的计算机图形学教材,如 Computer Graphics: Principles and Practice。我们将以下列出这些属性的一些简要描述,仅供完整性参考:
-
Ka
:指定环境光颜色 -
Kd
:指定漫反射颜色 -
Ks
:指定镜面反射颜色 -
Ns
:定义镜面高光的聚焦程度 -
Ni
:定义光学密度(即折射率) -
d
:指定溶解因子 -
illum
:指定光照模型 -
map_Kd
:指定应用于材质漫反射反射率的颜色纹理文件
cube.obj
文件可以通过 Open3D 和 PyTorch3D 打开。以下代码片段 obj_example1.py
可以从我们的 GitHub 仓库中下载:
import open3d
from pytorch3d.io import load_obj
mesh_file = "cube.obj"
print('visualizing the mesh using open3D')
mesh = open3d.io.read_triangle_mesh(mesh_file)
open3d.visualization.draw_geometries([mesh],
mesh_show_wireframe = True,
mesh_show_back_face = True)
print("Loading the same file with PyTorch3D")
vertices, faces, aux = load_obj(mesh_file)
print('Type of vertices = ', type(vertices))
print("Type of faces = ", type(faces))
print("Type of aux = ", type(aux))
print('vertices = ', vertices)
print('faces = ', faces)
print('aux = ', aux)
在上面的代码片段中,定义的立方体网格可以通过使用 Open3D 的draw_geometries
函数进行交互式可视化。网格将在一个窗口中显示,您可以使用鼠标旋转、缩放以及放大或缩小网格。网格也可以通过 PyTorch3D 的load_obj
函数加载。load_obj
函数将返回vertices
、faces
和aux
变量,格式可以是 PyTorch 张量或 PyTorch 张量元组。
obj_example1.py
代码片段的一个示例输出如下所示:
visualizing the mesh using open3D
Loading the same file with PyTorch3D
Type of vertices = <class 'torch.Tensor'>
Type of faces = <class 'pytorch3d.io.obj_io.Faces'>
Type of aux = <class 'pytorch3d.io.obj_io.Properties'>
vertices = tensor([[-0.5000, -0.5000, 0.5000],
[-0.5000, -0.5000, -0.5000],
[-0.5000, 0.5000, -0.5000],
[-0.5000, 0.5000, 0.5000],
[ 0.5000, -0.5000, 0.5000],
[ 0.5000, -0.5000, -0.5000],
[ 0.5000, 0.5000, -0.5000],
[ 0.5000, 0.5000, 0.5000]])
faces = Faces(verts_idx=tensor([[0, 1, 2],
[5, 4, 7],
[6, 2, 1],
...
[3, 4, 0],
[7, 2, 6],
[5, 0, 4]]), normals_idx=tensor([[-1, -1, -1],
[-1, -1, -1],
[-1, -1, -1],
[-1, -1, -1],
...
[-1, -1, -1],
[-1, -1, -1]]), textures_idx=tensor([[-1, -1, -1],
[-1, -1, -1],
[-1, -1, -1],
...
[-1, -1, -1],
[-1, -1, -1]]), materials_idx=tensor([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]))
aux = Properties(normals=None, verts_uvs=None, material_colors={'Door': {'ambient_color': tensor([0.8000, 0.6000, 0.4000]), 'diffuse_color': tensor([0.8000, 0.6000, 0.4000]), 'specular_color': tensor([0.9000, 0.9000, 0.9000]), 'shininess': tensor([0.])}}, texture_images={}, texture_atlas=None)
从这里输出的代码片段中,我们知道返回的顶点变量是一个形状为 8 x 3 的 PyTorch 张量,其中每一行是一个顶点,包含x、y和z坐标。返回的变量faces
是一个包含三个 PyTorch 张量的命名元组,分别是verts_idx
、normals_idx
和textures_idx
。在之前的示例中,所有normals_idx
和textures_idx
张量都是无效的,因为cube.obj
没有包括法线和纹理的定义。在下一个示例中,我们将看到如何在 OBJ 文件格式中定义法线和纹理。verts_idx
是每个面的顶点索引。请注意,这里的顶点索引是从 0 开始的,在 PyTorch3D 中,索引从 0 开始。然而,OBJ 文件中的顶点索引是从 1 开始的,索引从 1 开始。PyTorch3D 已经为我们完成了两者之间的转换。
返回变量aux
包含一些额外的网格信息。请注意,aux
变量的texture_image
字段为空。纹理图像在 MTL 文件中用于定义顶点和面上的颜色。我们将在下一个示例中展示如何使用这个功能。
在第二个示例中,我们将使用一个示例cube_texture.obj
文件来突出更多 OBJ 文件的特性。该文件如下所示。
cube_texture.obj
文件与cube.obj
文件类似,不同之处如下:
-
有一些以
vt
开头的附加行。每一行都声明一个具有x和y坐标的纹理顶点。每个纹理顶点定义一个颜色。该颜色是所谓纹理图像中的像素颜色,其中像素位置是纹理顶点的x坐标与纹理的宽度以及y坐标与纹理的高度。纹理图像将在cube_texture.mtl
伴随文件中定义。 -
有一些以
vn
开头的附加行。每一行都声明一个法线向量——例如,vn 0.000000 -1.000000 0.000000
这一行声明了一个指向负z轴的法线向量。 -
每个面定义行现在包含关于每个顶点的更多信息。例如,
f 2/1/1 3/2/1 4/3/1
行包含三个顶点的定义。第一个三元组2/1/1
定义了第一个顶点,第二个三元组3/2/1
定义了第二个顶点,第三个三元组4/3/1
定义了第三个顶点。每个这样的三元组表示顶点索引、纹理顶点索引和法向量索引。例如,2/1/1
定义了一个顶点,其中顶点的几何位置在第二行以v
开头的定义中,颜色在第一行以vt
开头的定义中,而法向量在第一行以vn
开头的定义中:
mtllib cube_texture.mtl
v 1.000000 -1.000000 -1.000000
v 1.000000 -1.000000 1.000000
v -1.000000 -1.000000 1.000000
v -1.000000 -1.000000 -1.000000
v 1.000000 1.000000 -0.999999
v 0.999999 1.000000 1.000001
v -1.000000 1.000000 1.000000
v -1.000000 1.000000 -1.000000
vt 1.000000 0.333333
vt 1.000000 0.666667
vt 0.666667 0.666667
vt 0.666667 0.333333
vt 0.666667 0.000000
vt 0.000000 0.333333
vt 0.000000 0.000000
vt 0.333333 0.000000
vt 0.333333 1.000000
vt 0.000000 1.000000
vt 0.000000 0.666667
vt 0.333333 0.333333
vt 0.333333 0.666667
vt 1.000000 0.000000
vn 0.000000 -1.000000 0.000000
vn 0.000000 1.000000 0.000000
vn 1.000000 0.000000 0.000000
vn -0.000000 0.000000 1.000000
vn -1.000000 -0.000000 -0.000000
vn 0.000000 0.000000 -1.000000
g main
usemtl Skin
s 1
f 2/1/1 3/2/1 4/3/1
f 8/1/2 7/4/2 6/5/2
f 5/6/3 6/7/3 2/8/3
f 6/8/4 7/5/4 3/4/4
f 3/9/5 7/10/5 8/11/5
f 1/12/6 4/13/6 8/11/6
f 1/4/1 2/1/1 4/3/1
f 5/14/2 8/1/2 6/5/2
f 1/12/3 5/6/3 2/8/3
f 2/12/4 6/8/4 3/4/4
f 4/13/5 3/9/5 8/11/5
f 5/6/6 1/12/6 8/11/6
cube_texture.mtl
的伴随文件如下,其中以 map_Kd
开头的行声明了纹理图像。在这里,wal67ar_small.jpg
是一个 250 x 250 的 RGB 图像文件,和 MTL 文件在同一文件夹中:
newmtl Skin
Ka 0.200000 0.200000 0.200000
Kd 0.827451 0.792157 0.772549
Ks 0.000000 0.000000 0.000000
Ns 0.000000
map_Kd ./wal67ar_small.jpg
我们可以使用 Open3D 和 PyTorch3D 来加载 cube_texture.obj
文件中的网格,例如,通过使用以下 obj_example2.py
文件:
import open3d
from pytorch3d.io import load_obj
import torch
mesh_file = "cube_texture.obj"
print('visualizing the mesh using open3D')
mesh = open3d.io.read_triangle_mesh(mesh_file)
open3d.visualization.draw_geometries([mesh],
mesh_show_wireframe = True,
mesh_show_back_face = True)
print("Loading the same file with PyTorch3D")
vertices, faces, aux = load_obj(mesh_file)
print('Type of vertices = ', type(vertices))
print("Type of faces = ", type(faces))
print("Type of aux = ", type(aux))
print('vertices = ', vertices)
print('faces = ', faces)
print('aux = ', aux)
texture_images = getattr(aux, 'texture_images')
print('texture_images type = ', type(texture_images))
print(texture_images['Skin'].shape)
obj_example2.py
代码片段的输出应如下所示:
visualizing the mesh using open3D
Loading the same file with PyTorch3D
Type of vertices = <class 'torch.Tensor'>
Type of faces = <class 'pytorch3d.io.obj_io.Faces'>
Type of aux = <class 'pytorch3d.io.obj_io.Properties'>
vertices = tensor([[ 1.0000, -1.0000, -1.0000],
[ 1.0000, -1.0000, 1.0000],
[-1.0000, -1.0000, 1.0000],
[-1.0000, -1.0000, -1.0000],
[ 1.0000, 1.0000, -1.0000],
[ 1.0000, 1.0000, 1.0000],
[-1.0000, 1.0000, 1.0000],
[-1.0000, 1.0000, -1.0000]])
faces = Faces(verts_idx=tensor([[1, 2, 3],
[7, 6, 5],
[4, 5, 1],
[5, 6, 2],
[2, 6, 7],
[0, 3, 7],
[0, 1, 3],
...
[3, 3, 3],
[4, 4, 4],
[5, 5, 5]]), textures_idx=tensor([[ 0, 1, 2],
[ 0, 3, 4],
[ 5, 6, 7],
[ 7, 4, 3],
[ 8, 9, 10],
[11, 12, 10],
...
[12, 8, 10],
[ 5, 11, 10]]), materials_idx=tensor([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]))
aux = Properties(normals=tensor([[ 0., -1., 0.],
[ 0., 1., 0.],
[ 1., 0., 0.],
[-0., 0., 1.],
[-1., -0., -0.],
[ 0., 0., -1.]]), verts_uvs=tensor([[1.0000, 0.3333],
...
[0.3333, 0.6667],
[1.0000, 0.0000]]), material_colors={'Skin': {'ambient_color': tensor([0.2000, 0.2000, 0.2000]), 'diffuse_color': tensor([0.8275, 0.7922, 0.7725]), 'specular_color': tensor([0., 0., 0.]), 'shininess': tensor([0.])}}, texture_images={'Skin': tensor([[[0.2078, 0.1765, 0.1020],
[0.2039, 0.1725, 0.0980],
[0.1961, 0.1647, 0.0902],
...,
[0.2235, 0.1882, 0.1294]]])}, texture_atlas=None)
texture_images type = <class 'dict'>
Skin
torch.Size([250, 250, 3])
注意
这不是完整的输出;请在运行代码时检查这个。
与 obj_example1.py
代码片段的输出相比,前面的输出有以下不同:
-
faces
变量的normals_idx
和textures_idx
字段现在都包含有效的索引,而不是取-1
的值。 -
aux
变量的normals
字段现在是一个 PyTorch 张量,而不是None
。 -
aux
变量的verts_uvs
字段现在是一个 PyTorch 张量,而不是None
。 -
aux
变量的texture_images
字段不再是一个空字典。texture_images
字典包含一个条目,其中键为Skin
,值为一个形状为 (250, 250, 3) 的 PyTorch 张量。这个张量与mtl_texture.mtl
文件中定义的wal67ar_small.jpg
文件中的图像完全相同。
我们已经学会了如何使用基本的 3D 数据文件格式,以及 PLY 和 OBJ 文件。在下一节中,我们将学习 3D 坐标系的基本概念。
理解 3D 坐标系
在本节中,我们将学习 PyTorch3D 中常用的坐标系。本节内容改编自 PyTorch 的摄像机坐标系文档:pytorch3d.org/docs/cameras
。为了理解和使用 PyTorch3D 渲染系统,我们通常需要了解这些坐标系及其使用方法。如前所述,3D 数据可以通过点、面和体素来表示。每个点的位置可以通过一组 x、y 和 z 坐标来表示,相对于某个特定的坐标系。我们通常需要定义并使用多个坐标系,具体取决于哪一个最方便。
图 1.2 – 世界坐标系统,其中原点和轴是独立于相机位置定义的
我们经常使用的第一个坐标系统被称为世界坐标系统。这个坐标系统是相对于所有 3D 对象选择的 3D 坐标系统,以便可以轻松确定 3D 对象的位置。通常,世界坐标系统的轴与物体的朝向或相机的朝向不一致。因此,世界坐标系统的原点与物体和相机的朝向之间存在一些非零的旋转和平移。以下是一个展示世界坐标系统的图示:
图 1.3 – 相机视图坐标系统,其中原点位于相机投影中心,三个轴根据成像平面定义
由于世界坐标系统的轴通常与相机的朝向不一致,因此在许多情况下,定义并使用相机视图坐标系统会更为方便。在 PyTorch3D 中,相机视图坐标系统被定义为原点位于相机的投影点,x 轴指向左侧,y 轴指向上方,z 轴指向前方。
图 1.4 – NDC 坐标系统,其中体积被限制在相机可以渲染的范围内
标准化设备坐标 (NDC) 限制了相机可以渲染的体积。NDC 空间中的 x 坐标值范围从 -1 到 +1,y 坐标值也是如此。z 坐标值的范围从 znear 到 zfar,其中 znear 是最近的深度,zfar 是最远的深度。任何超出这个 znear 到 zfar 范围的物体都不会被相机渲染。
最后,屏幕坐标系统是根据渲染图像在我们屏幕上显示的方式定义的。该坐标系统包含 x 坐标作为像素的列,y 坐标作为像素的行,z 坐标对应物体的深度。
为了在我们的 2D 屏幕上正确渲染 3D 物体,我们需要在这些坐标系统之间进行切换。幸运的是,这些转换可以通过使用 PyTorch3D 相机模型轻松完成。在我们讨论相机模型后,将更详细地讨论坐标转换。
理解相机模型
在本节中,我们将学习相机模型。在 3D 深度学习中,通常我们需要使用 2D 图像进行 3D 检测。要么是从 2D 图像中单独检测 3D 信息,要么是将 2D 图像与深度信息融合以获得更高的准确性。尽管如此,相机模型在建立 2D 空间与 3D 世界之间的对应关系时至关重要。
在 PyTorch3D 中,有两种主要的相机模型,分别是由OrthographicCameras
类定义的正交相机和由PerspectiveCameras
类定义的透视相机模型。下图展示了这两种相机模型之间的区别。
图 1.5 – PyTorch3D 中实现的两种主要相机模型,透视和正交
正交相机使用正交投影将 3D 世界中的物体映射到 2D 图像,而透视相机使用透视投影将 3D 世界中的物体映射到 2D 图像。正交投影将物体映射到 2D 图像时不考虑物体的深度。例如,如图所示,两个在不同深度但几何大小相同的物体将被映射到相同大小的 2D 图像上。而在透视投影中,如果物体远离相机,它将在 2D 图像上被映射为更小的大小。
现在我们已经了解了相机模型的基本概念,接下来让我们通过一些编码示例来看一下如何创建和使用这些相机模型。
相机模型和坐标系的编码
在本节中,我们将利用所学的所有知识,构建一个具体的相机模型,并在不同坐标系之间进行转换,使用一个用 Python 和 PyTorch3D 编写的具体代码示例:
-
首先,我们将使用由
cube.obj
文件定义的以下网格。基本上,这个网格是一个立方体:mtllib ./cube.mtl o cube # Vertex list v -50 -50 20 v -50 -50 10 v -50 50 10 v -50 50 20 v 50 -50 20 v 50 -50 10 v 50 50 10 v 50 50 20 # Point/Line/Face list usemtl Door f 1 2 3 f 6 5 8 f 7 3 2 f 4 8 5 f 8 4 3 f 6 2 1 f 1 3 4 f 6 8 7 f 7 2 6 f 4 5 1 f 8 3 7 f 6 1 5 # End of file
示例代码片段是camera.py
,可以从本书的 GitHub 仓库中下载。
-
让我们导入所有需要的模块:
import open3d import torch import pytorch3d from pytorch3d.io import load_obj from scipy.spatial.transform import Rotation as Rotation from pytorch3d.renderer.cameras import PerspectiveCameras
-
我们可以通过使用 Open3D 的
draw_geometrics
函数加载并可视化网格:#Load meshes and visualize it with Open3D mesh_file = "cube.obj" print('visualizing the mesh using open3D') mesh = open3d.io.read_triangle_mesh(mesh_file) open3d.visualization.draw_geometries([mesh], mesh_show_wireframe = True, mesh_show_back_face = True)
-
我们定义了一个
camera
变量,作为一个 PyTorch3D 的PerspectiveCamera
对象。这里的相机实际上是 mini-batched 的。例如,旋转矩阵 R 是一个形状为[8, 3, 3]的 PyTorch 张量,它实际上定义了八个相机,每个相机都有一个旋转矩阵。其他所有相机参数,如图像大小、焦距和主点,也是同样的情况:#Define a mini-batch of 8 cameras image_size = torch.ones(8, 2) image_size[:,0] = image_size[:,0] * 1024 image_size[:,1] = image_size[:,1] * 512 image_size = image_size.cuda() focal_length = torch.ones(8, 2) focal_length[:,0] = focal_length[:,0] * 1200 focal_length[:,1] = focal_length[:,1] * 300 focal_length = focal_length.cuda() principal_point = torch.ones(8, 2) principal_point[:,0] = principal_point[:,0] * 512 principal_point[:,1] = principal_point[:,1] * 256 principal_point = principal_point.cuda() R = Rotation.from_euler('zyx', [ [n*5, n, n] for n in range(-4, 4, 1)], degrees=True).as_matrix() R = torch.from_numpy(R).cuda() T = [ [n, 0, 0] for n in range(-4, 4, 1)] T = torch.FloatTensor(T).cuda() camera = PerspectiveCameras(focal_length = focal_length, principal_point = principal_point, in_ndc = False, image_size = image_size, R = R, T = T, device = 'cuda')
-
一旦我们定义了相机变量,就可以调用
get_world_to_view_transform
类成员方法来获取一个Transform3d
对象,即world_to_view_transform
。然后,我们可以使用transform_points
成员方法将世界坐标转换为相机视图坐标。同样地,我们还可以使用get_full_projection_transform
成员方法来获取一个Transform3d
对象,该对象用于将世界坐标转换为屏幕坐标:world_to_view_transform = camera.get_world_to_view_transform() world_to_screen_transform = camera.get_full_projection_transform() #Load meshes using PyTorch3D vertices, faces, aux = load_obj(mesh_file) vertices = vertices.cuda() world_to_view_vertices = world_to_view_transform.transform_points(vertices) world_to_screen_vertices = world_to_screen_transform.transform_points(vertices) print('world_to_view_vertices = ', world_to_view_vertices) print('world_to_screen_vertices = ', world_to_screen_vertices
代码示例展示了如何使用 PyTorch3D 相机的基本方法,以及如何使用 PyTorch3D 在不同坐标系之间切换的简便性。
总结
在本章中,我们首先学习了如何设置开发环境。接着,我们讨论了最广泛使用的 3D 数据表示方法。然后,我们通过学习 3D 数据文件格式,如 PLY 格式和 OBJ 格式,来探讨了 3D 数据表示的具体实例。之后,我们了解了 3D 坐标系统和相机模型的基本概念。在本章的最后部分,我们通过实际编程示例学习了如何构建相机模型并在不同坐标系统之间进行转换。
在下一章,我们将讨论更多重要的 3D 深度学习概念,如渲染将 3D 模型转换为 2D 图像、异构小批量处理以及几种表示旋转的方法。
第三章:介绍 3D 计算机视觉与几何学
在本章中,我们将学习一些 3D 计算机视觉和几何的基本概念,这些概念将特别有助于本书后续章节的内容。我们将从讨论渲染、光栅化和着色开始。我们将讲解不同的光照模型和着色模型,如点光源、方向光源、环境光、漫反射、高光和光泽度。我们将通过一个示例代码,展示如何使用不同的光照模型和参数来渲染网格模型。
然后,我们将学习如何使用 PyTorch 来解决优化问题。特别是,我们将学习如何使用 PyTorch3D 进行异构小批量的随机梯度下降。我们还将学习 PyTorch3D 中小批量的不同格式,包括列表格式、填充格式和打包格式,并学习如何在这些格式之间转换。
在本章的最后部分,我们将讨论一些常用的旋转表示方法,以及如何在这些表示之间进行转换。
在本章中,我们将涵盖以下主题:
-
探索渲染、光栅化和着色的基本概念
-
了解 Lambertian 着色模型和 Phong 着色模型
-
如何定义 PyTorch 张量,并使用优化器优化张量
-
如何定义小批量、异构小批量以及打包和填充张量
-
旋转及描述旋转的不同方法
-
在 SE(3)空间中的指数映射和对数映射
技术要求
要运行本书中的示例代码片段,读者需要拥有一台计算机,最好配备 GPU。然而,仅使用 CPU 运行代码片段也是可能的。
推荐的计算机配置包括以下内容:
-
一台现代 GPU——例如,具有至少 8GB 内存的 Nvidia GTX 系列或 RTX 系列
-
Python 3
-
PyTorch 库和 PyTorch3D 库
本章中的代码片段可以在github.com/PacktPublishing/3D-Deep-Learning-with-Python.
找到
探索渲染、光栅化和着色的基本概念
渲染是一个过程,它将周围世界的 3D 数据模型作为输入,并输出图像。这是对现实世界中相机形成图像的物理过程的近似。通常,3D 数据模型是网格。在这种情况下,渲染通常通过光线追踪完成:
图 2.1:光线追踪渲染(光线从相机原点发射,并通过图像像素查找相关网格面)
光线追踪处理的示例如 图 2.1 所示。在该示例中,世界模型包含一个 3D 球体,该球体由一个网格模型表示。为了形成 3D 球体的图像,对于每个图像像素,我们生成一条射线,从相机原点出发并穿过图像像素。如果一条射线与一个网格面相交,那么我们知道该网格面可以将其颜色投射到图像像素上。我们还需要追踪每次相交的深度,因为具有较小深度的面会遮挡具有较大深度的面。
因此,渲染过程通常可以分为两个阶段——光栅化和着色。光线追踪过程是典型的光栅化过程——即为每个图像像素找到相关几何对象的过程。着色是根据光栅化的输出计算每个图像像素的像素值的过程。
PyTorch3D 中的 pytorch3d.renderer.mesh.rasterize_meshes.rasterize_meshes
函数通常为每个图像像素计算以下四个内容:
-
pix_to_face
是一个包含射线可能与之相交的面索引的列表。 -
zbuf
是一个包含这些面深度值的列表。 -
bary_coords
是一个包含每个面与射线交点的重心坐标的列表。 -
pix_dists
是一个包含像素(x 和 y)与所有相交面的最近点之间符号距离的列表。由于它包含符号距离,因此该列表的值可以为负值。
请注意,通常具有最小深度的一个面会遮挡所有深度更大的网格面。因此,如果我们只需要渲染图像,那么这个列表中只需要包含最小深度的面。然而,在更高级的可微渲染设置下(我们将在本书后面的章节中讨论),像素颜色通常是从多个网格面融合而来的。
理解重心坐标
对于与网格面共面的每个点,该点的坐标总可以表示为网格面三个顶点坐标的线性组合。例如,如下图所示,点 p 可以表示为 ,其中 A、B 和 C 是网格面三个顶点的坐标。因此,我们可以用系数 u、v 和 w 来表示每个这样的点。这个表示方法称为该点的重心坐标。如果点位于网格面三角形内,
,且所有 u、v、w 均为正数。由于重心坐标将面内的任何点定义为面顶点的函数,我们可以使用相同的系数根据在面顶点处定义的属性在整个面上插值其他属性。例如,我们可以将其用于着色,如 图 2.2 所示:
图 2.2:重心坐标系的定义
一旦我们有了pix_to_face
、zbuf
、bary_coords
和dists
值的列表,着色过程就会模拟现实世界中图像形成的物理过程。因此,我们将讨论几种颜色形成的物理模型。
光源模型
现实世界中的光传播可能是一个复杂的过程。为了减少计算成本,着色中通常会使用几种光源的近似模型:
-
第一个假设是环境光照,我们假设在足够反射后会有一些背景光辐射,这些光通常从各个方向发出,且所有图像像素的振幅几乎相同。
-
我们通常使用的另一个假设是,某些光源可以被视为点光源。点光源从一个单一的点发出光线,所有方向的辐射具有相同的颜色和振幅。
-
我们通常使用的第三个假设是,某些光源可以被建模为定向光源。在这种情况下,光源发出的光线在所有三维空间位置上的方向是相同的。定向光照是当光源远离渲染物体时的良好近似模型——例如,阳光。
了解 Lambertian 着色模型
我们将讨论的第一个物理模型是 Lambert 的余弦定律。Lambertian 表面是指那些完全不光亮的物体类型,如纸张、未加工的木材和未抛光的石头:
图 2.3:光在 Lambertian 表面上的扩散
图 2.3 显示了光如何在 Lambertian 表面上扩散的示例。Lambertian 余弦定律的一个基本思想是,对于 Lambertian 表面,反射光的振幅不依赖于观察者的角度,而仅仅依赖于表面法线与入射光方向之间的角度 。更准确地说,反射光的强度
如下:
在这里, 是材料的反射系数,
是入射光的振幅。如果我们进一步考虑环境光,反射光的振幅如下:
在这里, 是环境光的振幅。
了解 Phong 光照模型
对于光滑的表面,如抛光瓷砖地板和光泽涂料,反射光也包含一个高光成分。Phong 光照模型是处理这些光泽成分的常用模型:
图 2.4:Phong 光照模型
Phong 光照模型的示例如图 2**.4所示。Phong 光照模型的一个基本原理是,光泽光成分应该在入射光反射方向上最强。随着反射方向和观察角度之间的角度 变大,光泽成分会变弱。
更准确地说,光泽光组件的振幅 等于以下公式:
在这里,指数 是模型的一个参数,用于控制光泽成分在观察角度偏离反射方向时衰减的速度。
最后,如果我们考虑所有三大主要成分——环境光、漫反射和高光——那么光的最终振幅方程如下:
请注意,前述方程适用于每个颜色成分。换句话说,我们会为每个颜色通道(红色、绿色和蓝色)分别有一个这样的方程,并且有一组独特的 值:
现在,我们已经了解了渲染、光栅化和渲染的基本概念。我们还学习了不同的光源模型和着色模型。我们已经准备好进行一些编码练习,利用这些光源和着色模型。
3D 渲染的编码练习
在本节中,我们将通过使用 PyTorch3D 渲染网格模型来进行一个具体的编码练习。我们将学习如何定义摄像机模型以及如何在 PyTorch3D 中定义光源。我们还将学习如何更改输入的光成分和材质属性,以便通过控制三种光成分(环境光、漫反射和高光)渲染出更逼真的图像:
-
首先,我们需要导入所有需要的 Python 模块:
import open3d import os import sys import torch import matplotlib.pyplot as plt from pytorch3d.io import load_objs_as_meshes from pytorch3d.renderer import ( look_at_view_transform, PerspectiveCameras, PerspectiveCameras, PointLights, Materials, RasterizationSettings, MeshRenderer, MeshRasterizer ) from pytorch3d.renderer.mesh.shader import HardPhongShader sys.path.append(os.path.abspath(''))
-
然后,我们需要加载将要使用的网格。
cow.obj
文件包含一个玩具牛对象的网格模型:DATA_DIR = "./data" obj_filename = os.path.join(DATA_DIR, "cow_mesh/cow.obj") device = torch.device('cuda') mesh = load_objs_as_meshes([obj_filename], device=device)
-
接下来,我们将定义摄像机和光源。我们使用
look_at_view_transform
函数来映射易于理解的参数,如摄像机与物体的距离、俯仰角度和方位角度,从而得到旋转(R)和位移(T)矩阵。R
和T
变量定义了我们将如何放置摄像机。lights
变量是一个放置在[0.0, 0.0, -3.0]
位置的点光源:R, T = look_at_view_transform(2.7, 0, 180) cameras = PerspectiveCameras(device=device, R=R, T=T) lights = PointLights(device=device, location=[[0.0, 0.0, -3.0]])
-
我们将定义一个
renderer
变量,它是MeshRenderer
类型的。renderer
变量是一个可调用对象,可以接受网格作为输入并输出渲染图像。注意,渲染器的初始化需要两个输入——一个光栅化器和一个着色器。PyTorch3D 已经定义了几种不同类型的光栅化器和着色器。在这里,我们将使用MeshRasterizer
和HardPhongShader
。值得注意的是,我们还可以指定光栅化器的设置。image_size
设置为512
,这意味着渲染的图像将是 512 x 512 像素。blur_radius
设置为0
,faces_per_pixel
设置为1
。blur_radius
和faces_per_pixel
的设置对于可微分渲染非常有用,其中blur_radius
应该大于0
,而faces_per_pixel
应该大于1
:raster_settings = RasterizationSettings( image_size=512, blur_radius=0.0, faces_per_pixel=1, ) renderer = MeshRenderer( rasterizer=MeshRasterizer( cameras=cameras, raster_settings=raster_settings ), shader = HardPhongShader( device = device, cameras = cameras, lights = lights ) )
-
因此,我们已经准备好通过调用渲染器并传递网格模型来运行第一次渲染结果。渲染图像如图 2.5所示:
images = renderer(mesh) plt.figure(figsize=(10, 10)) plt.imshow(images[0, ..., :3].cpu().numpy()) plt.axis("off") plt.savefig('light_at_front.png') plt.show()
图 2.5:光源放置在前方时的渲染图像
-
接下来,我们将把光源的位置改变到网格的后面,看看会发生什么。渲染图像如图 2.6所示。在这种情况下,点光源发出的光无法与任何朝向我们的网格面相交。因此,我们能观察到的所有颜色都是由于环境光造成的:
lights.location = torch.tensor([0.0, 0.0, +1.0], device=device)[None] images = renderer(mesh, lights=lights, ) plt.figure(figsize=(10, 10)) plt.imshow(images[0, ..., :3].cpu().numpy()) plt.axis("off") plt.savefig('light_at_back.png') plt.show()
图 2.6:光源放置在玩具牛后方时的渲染图像
-
在接下来的实验中,我们将定义一个
materials
数据结构。在这里,我们更改配置,使得环境光成分接近 0(实际上为0.01
)。由于点光源位于物体后面,且环境光也被关闭,渲染的物体现在不再反射任何光。渲染图像如图 2.7所示:materials = Materials( device=device, specular_color=[[0.0, 1.0, 0.0]], shininess=10.0, ambient_color=((0.01, 0.01, 0.01),), ) images = renderer(mesh, lights=lights, materials = materials) plt.figure(figsize=(10, 10)) plt.imshow(images[0, ..., :3].cpu().numpy()) plt.axis("off") plt.savefig('dark.png') plt.show()
图 2.7:没有环境光且点光源位于玩具牛后方的渲染图像
-
在接下来的实验中,我们将再次旋转相机,并重新定义光源位置,使光线能够照射到牛的面部。请注意,在定义材质时,我们将
shininess
设置为10.0
。这个shininess
参数正是 Phong 光照模型中的p
参数。specular_color
是[0.0, 1.0, 0.0]
,这意味着表面在绿色成分上光泽最强。渲染结果如图 2.8所示:R, T = look_at_view_transform(dist=2.7, elev=10, azim=-150) cameras = PerspectiveCameras(device=device, R=R, T=T) lights.location = torch.tensor([[2.0, 2.0, -2.0]], device=device) materials = Materials( device=device, specular_color=[[0.0, 1.0, 0.0]], shininess=10.0 ) images = renderer(mesh, lights=lights, materials=materials, cameras=cameras) plt.figure(figsize=(10, 10)) plt.imshow(images[0, ..., :3].cpu().numpy()) plt.axis("off") plt.savefig('green.png') plt.show()
图 2.8:带有镜面光照成分的渲染图像
-
在下一个实验中,我们将把
specular_color
改为red
并增加shininess
值。结果见图 2.9:materials = Materials( device=device, specular_color=[[1.0, 0.0, 0.0]], shininess=20.0 ) images = renderer(mesh, lights=lights, materials=materials, cameras=cameras) plt.figure(figsize=(10, 10)) plt.imshow(images[0, ..., :3].cpu().numpy()) plt.savefig('red.png') plt.axis("off") plt.show()
图 2.9:带有红色高光颜色的渲染图像
-
最后,我们关闭了高光效果,结果见图 2.10:
materials = Materials( device=device, specular_color=[[0.0, 0.0, 0.0]], shininess=0.0 ) images = renderer(mesh, lights=lights, materials=materials, cameras=cameras) plt.figure(figsize=(10, 10)) plt.imshow(images[0, ..., :3].cpu().numpy()) plt.savefig('blue.png') plt.axis("off") plt.show()
图 2.10:没有高光成分的渲染图像
在本章的第一部分,我们主要讨论了渲染和阴影,它们对 3D 计算机视觉至关重要。接下来,我们将讨论另一个对 3D 深度学习非常重要的话题——优化中的异构批次问题。
使用 PyTorch3D 异构批次和 PyTorch 优化器
本节中,我们将学习如何在 PyTorch3D 异构小批次上使用 PyTorch 优化器。在深度学习中,我们通常会获得一个数据示例列表,如下所示– .. 这里,
是观察值,
是预测值。例如,
可能是一些图像,而
是实际的分类结果——例如,“猫”或“狗”。然后,训练一个深度神经网络,使得神经网络的输出尽可能接近
。通常,定义一个神经网络输出与
之间的损失函数,使得损失函数值随着神经网络输出接近
而减小。
因此,训练深度学习网络通常是通过最小化在所有训练数据示例上评估的损失函数来完成的, 和
。许多优化算法中常用的一种直接方法是首先计算梯度,如下方程所示,然后沿负梯度方向修改神经网络的参数。在方程中,f代表神经网络,它以
为输入,参数为Ɵ;loss 是神经网络输出与真实预测
之间的损失函数:
然而,计算这个梯度是昂贵的,因为计算成本与训练数据集的大小成正比。实际上,随机梯度下降(SGD)算法取代了原始的梯度下降算法。在 SGD 算法中,下降方向按照以下方程计算:
在这个方程中,所谓的小批量 D 是所有训练数据示例的一个小子集。小批量 D 在每次迭代中从整个训练数据示例中随机抽样。SGD 算法的计算成本远低于梯度下降算法。根据大数法则,SGD 中计算出的下降方向大致接近梯度下降的方向。普遍认为,SGD 引入了某种隐式正则化,这可能有助于深度学习模型的良好泛化性能。选择小批量大小的方法是一个需要认真考虑的重要超参数。尽管如此,SGD 算法及其变种仍然是训练深度学习模型的首选方法。
对于许多数据类型,例如图像,数据可以很容易地变得同质化。我们可以形成一个小批量的图像,所有图像具有相同的宽度、高度和通道。例如,一个包含三通道(红色、绿色和蓝色)、高度为 256、宽度为 256 的八张图像的小批量,可以构成一个 PyTorch 张量,维度为 8 x 3 x 256 x 256。通常,张量的第一维表示小批量中的数据样本索引。通常,对这种同质数据的计算可以高效地使用 GPU 完成。
另一方面,3D 数据通常是异构的。例如,一个小批量中的网格可能包含不同数量的顶点和面。高效地在 GPU 上处理这种异构数据并不是一件简单的事。异构小批量处理的编程也可能非常繁琐。幸运的是,PyTorch3D 具备非常高效地处理异构小批量的能力。我们将在下一节中讨论涉及这些 PyTorch3D 能力的编程练习。
一个关于异构小批量的编程练习
在本节中,我们将通过一个玩具示例来学习如何使用 PyTorch 优化器和 PyTorch3D 的异构小批量功能。在这个示例中,我们将考虑一个问题:一个深度摄像头被放置在一个未知位置,我们希望利用摄像头的感知结果估计这个未知位置。为了简化问题,我们假设摄像头的方向已知,唯一未知的是 3D 位移。
更具体地说,我们假设摄像头观察到场景中的三个物体,并且我们知道这三个物体的真实网格模型。让我们看一下使用 PyTorch 和 PyTorch3D 解决该问题的代码,如下所示:
-
在第一步中,我们将导入所有我们将要使用的包:
import open3d import os import torch from pytorch3d.io import load_objs_as_meshes from pytorch3d.structures.meshes import join_meshes_as_batch from pytorch3d.ops import sample_points_from_meshes from pytorch3d.loss import chamfer_distance import numpy as np
-
在下一步中,我们将使用 CPU 或 CUDA 定义一个
torch
设备:if torch.cuda.is_available(): device = torch.device("cuda:0") else: device = torch.device("cpu") print("WARNING: CPU only, this will be slow!")
-
你将在这个示例中使用的网格模型已包含在代码库中,并位于
data
子文件夹下。我们将使用包含在cube.obj
、diamond.obj
和dodecahedron.obj
文件中的三个网格模型。在以下代码片段中,我们使用Open3D
库加载这些网格模型并进行可视化:mesh_names = ['cube.obj', 'diamond.obj', 'dodecahedron.obj'] data_path = './data' for mesh_name in mesh_names: mesh = open3d.io.read_triangle_mesh(os.path.join(data_path, mesh_name)) open3d.visualization.draw_geometries([mesh], mesh_show_wireframe = True, mesh_show_back_face = True, )
-
接下来,我们将使用 PyTorch3D 加载相同的网格并构建一个网格列表,即
mesh_list
变量:mesh_list = list() device = torch.device('cuda') for mesh_name in mesh_names: mesh = load_objs_as_meshes([os.path.join(data_path, mesh_name)], device=device) mesh_list.append(mesh)
-
最后,我们可以通过使用
join_meshes_as_batch
PyTorch3D 函数创建一个 PyTorch3D 迷你批次的网格。该函数接受一个网格列表并返回一个迷你批次的网格:mesh_batch = join_meshes_as_batch(mesh_list, include_textures = False)
在每个 PyTorch3D 迷你批次中,有三种方式来表示顶点和面:
-
列表格式:顶点由一个张量列表表示,每个张量表示一个迷你批次中一个网格的顶点或面。
-
填充格式:所有顶点由一个张量表示,小的网格数据通过零填充,使得所有网格现在具有相同数量的顶点和面。
-
打包格式:所有顶点或面都打包到一个张量中。对于每个顶点或面,内部会跟踪它属于哪个网格。
这三种表示方法各有优缺点。然而,可以通过使用 PyTorch3D API 高效地在它们之间进行转换。
-
下一个代码片段展示了如何从迷你批次中返回列表格式的顶点和面:
vertex_list = mesh_batch.verts_list() print('vertex_list = ', vertex_list) face_list = mesh_batch.faces_list() print('face_list = ', face_list)
-
要返回填充格式的顶点和面,我们可以使用以下 PyTorch3D API:
vertex_padded = mesh_batch.verts_padded() print('vertex_padded = ', vertex_padded) face_padded = mesh_batch.faces_padded() print('face_padded = ', face_padded)
-
要以打包格式获取顶点和面,我们可以使用以下代码片段:
vertex_packed = mesh_batch.verts_packed() print('vertex_packed = ', vertex_packed) face_packed = mesh_batch.faces_packed() print('face_packed = ', face_packed) num_vertices = vertex_packed.shape[0] print('num_vertices = ', num_vertices)
-
在这个编码示例中,我们将
mesh_batch
变量视为三个物体的真实网格模型。接下来,我们将模拟这三个网格的一个有噪声并发生位移的版本。在第一步,我们要克隆真实网格模型:mesh_batch_noisy = mesh_batch.clone()
-
然后我们定义一个
motion_gt
变量来表示相机位置与原点之间的位移:motion_gt = np.array([3, 4, 5]) motion_gt = torch.as_tensor(motion_gt) print('motion ground truth = ', motion_gt) motion_gt = motion_gt[None, :] motion_gt = motion_gt.to(device)
-
为了模拟有噪声的深度相机观测,我们生成一些均值为
motion_gt
的随机高斯噪声。这些噪声通过offset_verts
PyTorch3D 函数被加到mesh_batch_noisy
中:noise = (0.1**0.5)*torch.randn(mesh_batch_noisy.verts_packed().shape).to(device) motion_gt = np.array([3, 4, 5]) motion_gt = torch.as_tensor(motion_gt) noise = noise + motion_gt mesh_batch_noisy = mesh_batch_noisy.offset_verts(noise).detach()
-
为了估计相机与原点之间的未知位移,我们将构建一个优化问题。首先,我们将定义
motion_estimate
优化变量。torch.zeros
函数将创建一个全零的 PyTorch 张量。请注意,我们将requires_grad
设置为true
。这意味着当我们从loss
函数运行梯度反向传播时,我们希望 PyTorch 自动为此变量计算梯度:motion_estimate = torch.zeros(motion_gt.shape, device = device, requires_grad=True)
-
接下来,我们将定义一个学习率为
0.1
的 PyTorch 优化器。通过将变量列表传递给优化器,我们指定了该优化问题的优化变量。在这里,优化变量是motion_estimate
变量:optimizer = torch.optim.SGD([motion_estimate], lr=0.1, momentum=0.9)
-
主要的优化过程如下所示。基本上,我们运行随机梯度下降 200 次迭代。经过 200 次迭代后,得到的
motion_estimate
应该非常接近地面真值。
每次优化迭代可以分为以下四个步骤:
-
在第一步,
optimizer.zero_grad()
将所有梯度值从上一次迭代计算的值重置为零。 -
在第二步,我们计算
loss
函数。请注意,PyTorch 保留了动态计算图。换句话说,所有计算loss
函数的步骤都会被记录下来,并在反向传播中使用。 -
在第三步,
loss.backward()
计算从loss
函数到 PyTorch 优化器中的优化变量的所有梯度。 -
在第四个也是最后一步,
optimizer.step
将所有优化变量朝着减小loss
函数的方向移动一步。
在计算loss
函数的过程中,我们从两个网格中随机抽取 5,000 个点并计算它们的 Chamfer 距离。Chamfer 距离是两组点之间的距离。我们将在后续章节中更详细地讨论这个距离函数:
for i in range(0, 200):
optimizer.zero_grad()
current_mesh_batch = mesh_batch.offset_verts(motion_estimate.repeat(num_vertices,1))
sample_trg = sample_points_from_meshes(current_mesh_batch, 5000)
sample_src = sample_points_from_meshes(mesh_batch_noisy, 5000)
loss, _ = chamfer_distance(sample_trg, sample_src)
loss.backward()
optimizer.step()
print('i = ', i, ', motion_estimation = ', motion_estimate)
我们可以检查优化过程是否会非常快速地收敛到[3,4,5]
的地面真值位置。
在这个编程练习中,我们学习了如何在 PyTorch3D 中使用异构小批量。接下来,我们将讨论 3D 计算机视觉中的另一个重要概念。
理解变换和旋转
在 3D 深度学习和计算机视觉中,我们通常需要处理 3D 变换,例如旋转和 3D 刚性运动。PyTorch3D 在其pytorch3d.transforms.Transform3d
类中对这些变换提供了高层封装。Transform3d
类的一个优点是它是基于小批量处理的。因此,像 3D 深度学习中常见的那样,可以仅用几行代码就对一批网格应用小批量变换。Transform3d
的另一个优点是梯度反向传播可以直接通过Transform3d
进行。
PyTorch3D 还提供了许多用于 Lie 群 SO(3)和 SE(3)计算的低级 API。这里,SO(3)表示 3D 中的特殊正交群,而 SE(3)表示 3D 中的特殊欧几里得群。通俗地说,SO(3)表示所有旋转变换的集合,SE(3)表示 3D 中所有刚性变换的集合。PyTorch3D 提供了许多关于 SE(3)和 SO(3)的低级 API。
在 3D 计算机视觉中,旋转有多种表示方法。一种表示方法是旋转矩阵!。
在这个方程中,x是一个 3D 向量,R是一个 3x3 矩阵。要成为旋转矩阵,R需要是一个正交矩阵,且行列式为+1。因此,并非所有的 3x3 矩阵都可以是旋转矩阵。旋转矩阵的自由度是 3。
3D 旋转也可以由一个 3D 向量 v 来表示,其中 v 的方向是旋转轴。也就是说,旋转会保持 v 固定,并围绕 v 旋转其他所有物体。通常,v 的幅度用来表示旋转角度。
旋转的两种表示之间有各种数学联系。如果我们考虑围绕轴 v 以恒定速度旋转,那么旋转矩阵将成为时间 t 的矩阵值函数 R(t)。在这种情况下,R(t) 的梯度始终是一个斜对称矩阵,形式如下所示:
这里适用以下内容:
从这两个方程中我们可以看到,梯度的斜对称矩阵是由向量 v 唯一确定的,反之亦然。这个从向量 v 到其斜对称矩阵形式的映射通常称为帽子运算符。
从斜对称矩阵梯度到旋转矩阵的闭式公式如下。该映射被称为指数映射,用于 :
当然,指数映射的逆映射也是存在的:
该映射被称为对数映射,用于 。
所有的帽子运算符、逆帽子运算符、指数运算和对数运算都已在 PyTorch3D 中实现。PyTorch3D 还实现了许多其他常用的 3D 操作,例如四元数操作和欧拉角。
转换和旋转的编码练习
在本节中,我们将通过一个编码练习来介绍如何使用 PyTorch3D 的一些低级 API:
-
我们首先导入必要的包:
import torch from pytorch3d.transforms.so3 import so3_exp_map, so3_log_map, hat_inv, hat
-
然后,我们使用 CPU 或 CUDA 来定义一个 PyTorch 设备:
if torch.cuda.is_available(): device = torch.device("cuda:0") else: device = torch.device("cpu") print("WARNING: CPU only, this will be slow!")
-
接下来,我们将定义一个包含四个旋转的小批次。在这里,每个旋转由一个 3D 向量表示。向量的方向表示旋转轴,向量的幅度表示旋转角度:
log_rot = torch.zeros([4, 3], device = device) log_rot[0, 0] = 0.001 log_rot[0, 1] = 0.0001 log_rot[0, 2] = 0.0002 log_rot[1, 0] = 0.0001 log_rot[1, 1] = 0.001 log_rot[1, 2] = 0.0002 log_rot[2, 0] = 0.0001 log_rot[2, 1] = 0.0002 log_rot[2, 2] = 0.001 log_rot[3, 0] = 0.001 log_rot[3, 1] = 0.002 log_rot[3, 2] = 0.003
-
log_rot
的形状是[4, 3]
,其中4
是批大小,每个旋转由一个 3D 向量表示。我们可以使用 PyTorch3D 中的帽子运算符将它们转换为 3 x 3 斜对称矩阵表示,代码如下:log_rot_hat = hat(log_rot) print('log_rot_hat shape = ', log_rot_hat.shape) print('log_rot_hat = ', log_rot_hat)
-
从斜对称矩阵形式到 3D 向量形式的反向转换也可以使用
hat_inv
运算符完成:log_rot_copy = hat_inv(log_rot_hat) print('log_rot_copy shape = ', log_rot_copy.shape) print('log_rot_copy = ', log_rot_copy)
-
从梯度矩阵,我们可以通过使用 PyTorch3D 的
so3_exp_map
函数计算旋转矩阵:rotation_matrices = so3_exp_map(log_rot) print('rotation_matrices = ', rotation_matrices)
-
反向转换是
so3_log_map
,它将旋转矩阵映射回梯度矩阵:log_rot_again = so3_log_map(rotation_matrices) print('log_rot_again = ', log_rot_again)
这些编码练习展示了 PyTorch3D 在变换和旋转方面最常用的 API。这些 API 对于现实世界中的 3D 计算机视觉项目非常有用。
总结
在本章中,我们学习了渲染、光栅化和着色的基本概念,包括光源模型、兰伯特着色模型和冯氏光照模型。我们学习了如何使用 PyTorch3D 实现渲染、光栅化和着色。我们还学习了如何更改渲染过程中诸如环境光、光泽度和镜面反射颜色等参数,并探讨了这些参数如何影响渲染结果。
接下来,我们学习了如何使用 PyTorch 优化器。我们通过一个代码示例,展示了如何在 PyTorch3D 的小批量数据上使用 PyTorch 优化器。在本章的最后,我们学习了如何使用 PyTorch3D 的 API 进行不同表示、旋转和变换之间的转换。
在下一章中,我们将学习一些更高级的技巧,使用可变形网格模型来拟合真实世界的 3D 数据。
第二部分:使用 PyTorch3D 进行 3D 深度学习
本部分将介绍一些使用 PyTorch3D 进行基本 3D 计算机视觉处理的方法。通过使用 PyTorch3D,实施这些 3D 计算机视觉算法可能会变得更加容易。读者将获得大量与网格、点云和图像拟合相关的实践经验。
本部分包括以下章节:
-
第三章,将可变形网格模型拟合到原始点云
-
第四章,通过可微分渲染进行物体姿态检测与跟踪
-
第五章,理解可微分体积渲染
-
第六章,探索神经辐射场(NeRF)
第四章:将可变形网格模型拟合到原始点云
本章将讨论一个项目,使用可变形网格模型拟合来自原始深度相机传感器结果的原始点云观测数据。深度相机的原始点云观测数据通常是没有任何关于这些点如何连接的信息的点云格式;即,点云不包含关于如何从这些点形成表面的信息。这与网格相反,网格通过定义面的列表显示了表面是如何形成的。如何将点聚集成表面的信息对于后续的后处理(如去噪和物体检测)非常重要。例如,如果一个点孤立无援,没有与任何其他点连接,那么该点很可能是传感器的误检。
因此,从点云中重建表面信息通常是 3D 数据处理流程中的标准步骤。关于从点云进行 3D 表面重建的前沿文献有很多,例如泊松重建。使用可变形网格模型进行表面重建也是一种常用方法。本章讨论的将可变形网格模型拟合到点云的方法是一种实用且简单的基准方法。
本章提出的方法基于 PyTorch 优化。该方法是如何使用 PyTorch 进行优化的另一个完美示范。我们将详细解释优化过程,以便你能进一步加深对 PyTorch 优化的理解。
损失函数在大多数深度学习算法中非常重要。在这里,我们还将讨论应该使用哪些损失函数,以及传统上在 PyTorch3D 中包含的损失函数。幸运的是,许多著名的损失函数已经在许多现代 3D 深度学习框架和库中实现,如 PyTorch3D。在本章中,我们将学习许多这样的损失函数。
本章将涵盖以下主要主题:
-
将网格拟合到点云——问题
-
将网格模型拟合问题表述为优化问题
-
用于正则化的损失函数
-
使用 PyTorch3D 实现网格拟合
技术要求
若要运行本书中的示例代码片段,理想情况下,你的计算机应配备 GPU。然而,仅使用 CPU 运行代码片段也是可行的。
推荐的计算机配置包括以下内容:
-
一块 GPU,例如 GTX 系列或 RTX 系列,至少有 8 GB 的内存
-
Python 3
-
PyTorch 和 PyTorch3D 库
本章中的代码片段可以在github.com/PacktPublishing/3D-Deep-Learning-with-Python
找到。
将网格拟合到点云——问题
现实世界中的深度相机,如激光雷达(LiDAR)、飞行时间(ToF)相机和立体视觉相机,通常输出深度图像或点云。例如,在飞行时间相机的情况下,调制的光线从相机投射到世界中,并通过接收到的反射光线的相位测量每个像素的深度。因此,在每个像素处,我们通常可以获得一个深度测量和一个反射光强度测量。然而,除了采样的深度信息外,我们通常没有表面的直接测量。例如,我们无法直接测量表面的光滑度或法线。
类似地,在立体视觉相机的情况下,每个时段,相机可以从一对相机中几乎在同一时间拍摄两张 RGB 图像。然后,相机通过寻找两张图像之间的像素对应关系来估算深度。输出的结果就是每个像素的深度估计。同样,相机无法给我们表面的任何直接测量。
然而,在许多实际应用中,通常需要表面信息。例如,在机器人抓取任务中,通常需要找到物体上的某些区域,使得机器人手臂能够牢固地抓住。在这种情况下,通常希望这些区域具有较大的面积并且相对平坦。
还有许多其他场景,我们需要将(可变形的)网格模型拟合到点云。例如,有一些机器视觉应用中,我们有工业部件的网格模型,深度相机的点云测量具有未知的方向和姿态。在这种情况下,找到网格模型与点云的拟合将恢复未知的物体姿态。
举个例子,在人脸追踪中,有时我们希望将一个可变形的面部网格模型拟合到点云测量数据上,以便我们可以恢复人的身份和/或面部表情。
损失函数是几乎所有优化问题中的核心概念。实质上,为了拟合点云,我们需要设计一个损失函数,使得当损失函数被最小化时,作为优化变量的网格能够拟合点云。
实际上,选择合适的损失函数通常是许多实际项目中的一个关键设计决策。不同的损失函数选择通常会导致显著不同的系统性能。损失函数的要求通常至少包括以下几个属性:
-
损失函数需要具备良好的数值特性,如光滑、凸性、没有梯度消失问题等。
-
损失函数(及其梯度)可以轻松计算;例如,它们可以高效地在 GPU 上计算。
-
损失函数是衡量模型拟合度的良好标准;也就是说,最小化损失函数会导致输入点云的网格模型拟合效果令人满意。
除了模型拟合优化问题中的一个主要损失函数外,我们通常还需要其他损失函数来正则化模型拟合。例如,如果我们有一些先验知识表明表面应该是光滑的,那么通常需要引入一个额外的正则化损失函数,使得不光滑的网格会受到更多惩罚。
使用行人进行点云测量的示例见于图 3.1。在本章的后续部分,我们将讨论一种基于变形网格的方法,用于将网格模型拟合到点云上。该点云存储在 pedestrian.ply
文件中,可以从本书的 GitHub 页面下载。通过使用 vis_input.py
中提供的代码片段,可以将该点云可视化。
图 3.1:一个深度相机输出的 3D 点云示例;注意点云密度相对较低
我们已经讨论了将网格拟合到点云的问题。现在,让我们来谈谈如何将其表述为一个优化问题。
将变形网格拟合问题表述为一个优化问题
在本节中,我们将讨论如何将网格拟合问题表述为优化问题。这里的一个关键观察是,物体表面(如行人)总是可以连续地变形为一个球体。因此,我们将采取的方法是从球体表面开始,通过变形表面来最小化成本函数。
成本函数应该选择得当,以便很好地衡量点云与网格的相似性。在这里,我们选择主要的成本函数为 Chamfer 集合距离。Chamfer 距离定义如下:
Chamfer 距离是对称的,且是两个项的和。在第一项中,对于第一个点云中的每个点 x,找到另一个点云中最近的点 y。对于每一对 x 和 y,计算它们之间的距离,并将所有对的距离相加。类似地,在第二项中,对于第二个点云中的每个 y,找到一个 x,并将这些 x 和 y 对之间的距离相加。
一般而言,Chamfer 距离是两个点云之间的距离。如果两个点云完全相同或非常相似,那么 Chamfer 距离可以为零或非常小。如果两个点云相距较远,则它们的 Chamfer 距离可能很大。
在 PyTorch3D 中,Chamfer 距离的实现位于 pytorch3d.loss.chamfer_distance
。不仅提供了前向损失函数的计算,我们还可以通过该实现轻松计算反向传播的梯度。
对于将网格拟合到点云的任务,我们首先从网格模型中随机采样一些点,然后优化网格模型中采样点与输入点云之间的 Chamfer 距离。随机采样是通过pytorch3d.ops.sample_points_from_meshes
实现的。同样,我们可以从pytorch3d.ops.sample_points_from_meshes
中计算用于反向传播的梯度。
现在,我们有了一个基本的优化问题版本。然而,我们仍然可能需要一些损失函数来对这个问题进行正则化。我们将在下一节中深入探讨这些问题。
正则化的损失函数
在上一节中,我们成功地将可变形网格拟合问题转化为一个优化问题。然而,直接优化这个主要损失函数的方法可能会有问题。问题在于,可能存在多个网格模型,它们都可以很好地拟合相同的点云。这些良好拟合的网格模型可能包括一些与平滑网格相距较远的网格模型。
另一方面,我们通常有关于行人的先验知识。例如,行人的表面通常是平滑的,表面范数也是平滑的。因此,即使一个非平滑网格在 Chamfer 距离上接近输入点云,我们也能以一定的信心知道它与真实值相差甚远。
机器学习文献在过去几十年中提供了解决方案,以排除这种不希望出现的非平滑解。这个解决方案称为正则化。本质上,我们想要优化的损失是选择多个损失函数的和。当然,这些和中的第一个项是主要的 Chamfer 距离。其他项则是用来惩罚表面非平滑性和范数非平滑性的。
在接下来的几个子章节中,我们将讨论几种这样的损失函数,包括以下内容:
-
网格拉普拉斯平滑损失
-
网格法线一致性损失
-
网格边缘损失
网格拉普拉斯平滑损失
网格拉普拉斯算子是著名的拉普拉斯-贝尔特拉米算子的离散版本。其一种版本(通常称为统一拉普拉斯)如下:
在前面的定义中,i点的拉普拉斯算子只是差值的总和,每个差值是当前顶点与相邻顶点坐标之间的差。
拉普拉斯算子是用来度量平滑度的。如果i点及其邻居都位于同一平面内,则拉普拉斯算子应该为零。在这里,我们使用的是拉普拉斯算子的统一版本,其中每个邻居对总和的贡献是等权重的。拉普拉斯算子还有更复杂的版本,其中前述贡献会根据不同的方案进行加权。
本质上,将这个损失函数包含到优化过程中,将会产生更平滑的解。网格拉普拉斯平滑损失的一个实现(包括多个不同版本,而不仅仅是均匀版本)可以在 pytorch3d.loss.mesh_laplacian_smoothing
中找到。同样,反向传播的梯度计算是启用的。
网格法线一致性损失
网格法线一致性损失是一个损失函数,用于惩罚网格上相邻法向量之间的距离。一个实现可以在 pytorch3d.loss.mesh_normal_consistency
中找到。
网格边缘损失
网格边缘损失用于惩罚网格中的长边。例如,在本章我们考虑的网格模型拟合问题中,我们希望最终获得一个解,使得所获得的网格模型能够均匀地拟合输入的点云。换句话说,点云的每个局部区域都被网格的小三角形覆盖。否则,网格模型将无法捕捉到缓慢变化表面的细节,意味着模型可能不够准确或可靠。
上述问题可以通过在目标函数中包含网格边缘损失来轻松避免。网格边缘损失本质上是网格中所有边长的总和。网格边缘损失的一个实现可以在 pytorch3d.loss.mesh_edge_loss
中找到。
现在,我们已经涵盖了这个网格拟合问题的所有概念和数学内容。接下来,让我们深入探讨如何使用 Python 和 PyTorch3D 来编写代码解决这个问题。
使用 PyTorch3D 实现网格拟合
输入的点云包含在 pedestrian.ply
中。可以使用 vis_input.py
代码片段来可视化网格。拟合网格模型到点云的主要代码片段包含在 deform1.py
中:
-
我们将从导入所需的包开始:
import os import sys import torch from pytorch3d.io import load_ply, save_ply from pytorch3d.io import load_obj, save_obj from pytorch3d.structures import Meshes from pytorch3d.utils import ico_sphere from pytorch3d.ops import sample_points_from_meshes from pytorch3d.loss import ( chamfer_distance, mesh_edge_loss, mesh_laplacian_smoothing, mesh_normal_consistency, ) import numpy as np
-
然后我们声明一个 PyTorch 设备。如果你有 GPU,那么设备将会被创建来使用 GPU。否则,设备将只能使用 CPU:
if torch.cuda.is_available(): device = torch.device("cuda:0") else: device = torch.device("cpu") print("WARNING: CPU only, this will be slow!")
-
我们将从
pedestrian.ply
加载点云。现在,load_ply
是一个 PyTorch3D 函数,它加载.ply
文件并输出verts
和faces
。在这个例子中,verts
是一个 PyTorch 张量,faces
是一个空的 PyTorch 张量,因为pedestrian.ply
实际上不包含任何面。to
成员函数将张量移动到设备上;如果设备使用 GPU,那么verts
和faces
会被传输到 GPU 内存中:verts, faces = load_ply("pedestrian.ply") verts = verts.to(device) faces = faces.to(device)
-
然后我们运行一些归一化处理并改变张量的形状以便后续处理:
center = verts.mean(0) verts = verts - center scale = max(verts.abs().max(0)[0]) verts = verts / scale verts = verts[None, :, :]
-
在下一步中,我们使用
ico_sphere
PyTorch3D 函数创建一个名为src_mesh
的网格变量。ico_sphere
函数本质上创建了一个大致表示球形的网格。这个src_mesh
将作为我们的优化变量;它将从一个球体开始,然后优化以适应点云:src_mesh = ico_sphere(4, device)
-
在下一步中,我们想定义一个
deform_verts
变量。deform_verts
是一个顶点位移的张量,对于src_mesh
中的每个顶点,都有一个三维向量的顶点位移。我们将优化deform_verts
,以寻找最优的可变形网格:src_vert = src_mesh.verts_list() deform_verts = torch.full(src_vert[0].shape, 0.0, device=device, requires_grad=True)
-
我们定义一个 SGD 优化器,以
deform_verts
作为优化变量:optimizer = torch.optim.SGD([deform_verts], lr=1.0, momentum=0.9)
-
我们定义了一组不同损失函数的权重。如前所述,我们需要多个损失函数,包括主要的损失函数和正则化损失函数。最终的损失将是不同损失函数的加权和。这里是我们定义权重的地方:
w_chamfer = 1.0 w_edge = 1.0 w_normal = 0.01 w_laplacian = 0.1
-
然后,我们就可以进入主要的优化迭代过程。我们将进行 2,000 次迭代来计算损失函数、计算梯度并沿着梯度下降方向前进。每次迭代从
optimizer.zero_grad()
开始,这将重置所有的梯度,然后计算loss
变量,接着在loss.backward()
中计算梯度反向传播;沿着梯度下降方向的更新在optimizer.step()
中完成。
为了能够计算 Chamfer 距离,在每次迭代中,我们通过使用 PyTorch3D 中的sample_points_from_meshes
函数从变形后的网格模型中随机采样一些点。请注意,sample_points_from_meshes
函数支持梯度反向传播计算。
我们还使用三个其他的损失函数进行正则化,mesh_edge_loss
、mesh_normal_consistency
和mesh_laplacian_smooth
。最终的loss
变量实际上是这四个损失函数的加权和:
for i in range(0, 2000):
print("i = ", i)
optimizer.zero_grad()
new_src_mesh = src_mesh.offset_verts(deform_verts)
sample_trg = verts
sample_src = sample_points_from_meshes(new_src_mesh, verts.shape[1])
loss_chamfer, _ = chamfer_distance(sample_trg, sample_src)
loss_edge = mesh_edge_loss(new_src_mesh)
loss_normal = mesh_normal_consistency(new_src_mesh)
loss_laplacian = mesh_laplacian_smoothing(new_src_mesh, method="uniform")
loss = (
loss_chamfer * w_chamfer
+ loss_edge * w_edge
+ loss_normal * w_normal
+ loss_laplacian * w_laplacian
)
loss.backward()
optimizer.step()
-
然后我们从
new_src_mesh
变量中提取得到的顶点和面,然后恢复它的原始中心位置和比例:final_verts, final_faces = new_src_mesh.get_mesh_verts_faces(0) final_verts = final_verts * scale + center
-
最终,得到的网格模型保存在
deform1.ply
文件中:final_obj = os.path.join("./", "deform1.ply") save_ply(final_obj, final_verts, final_faces, ascii=True)
图 3.2:优化后的变形网格模型。请注意,我们的点数比原始输入点云要多得多
可以通过使用vis1.py
在屏幕上可视化得到的网格。优化后的网格的截图如图 3.2所示。请注意,与原始输入点云相比,优化后的网格模型实际上包含更多的点(2,500 个点对比 239 个点)。而且得到的表面看起来比原始输入点更加平滑。
不使用任何正则化损失函数的实验
如果我们不使用任何正则化损失函数会怎样?我们使用deform2.py
中的代码运行实验。deform2.py
中的代码与deform1.py
中的代码唯一不同的是以下几行:
w_chamfer = 1.0
w_edge = 0.0
w_normal = 0.00
w_laplacian = 0.0
注意,所有权重已设置为零,除了 Chamfer 损失函数的权重。实际上,我们没有使用任何正则化损失函数。通过运行vis2.py
,可以在屏幕上可视化结果网格。截图显示在图 3.3中:
图 3.3: 未使用任何正则化损失函数获得的网格
注意,图 3.3中获得的网格并不平滑,并且不太可能接近实际的真实表面。
仅使用网格边缘损失的实验
这次,我们将使用以下一组权重。代码片段在deform3.py
中:
w_chamfer = 1.0
w_edge = 1.0
w_normal = 0.00
w_laplacian = 0.0
获得的网格模型位于deform3.ply
。可以通过使用vis3.py
在屏幕上可视化网格。网格的截图显示在图 3.4中:
图 3.4: 仅使用网格边缘损失正则化获得的网格
从图 3.4中,我们可以观察到获得的网格比图 3.3中的网格平滑得多。然而,似乎在表面法线方面存在一些急剧变化。实际上,你可以自己尝试其他权重,看看这些损失函数如何影响最终结果。
总结
本章讨论了一种将可变形网格模型拟合到点云的方法。如我们所讨论的,从点云获取网格通常是许多 3D 计算机视觉管道中的标准步骤。本章中的拟合方法可以作为实践中的简单基准方法。
从这个可变形网格拟合方法中,我们学会了如何使用 PyTorch 优化。我们还了解了许多损失函数及其在 PyTorch3D 中的实现,包括 Chamfer 距离、网格边缘损失、网格拉普拉斯平滑损失和网格法线一致性损失。
我们学会了这些损失函数应该在何时使用以及用于何种目的。我们看到了几个实验,展示了损失函数如何影响最终结果。也鼓励你运行自己的实验,尝试不同的损失函数和权重组合。
在下一章,我们将讨论一个非常激动人心的 3D 深度学习技术——可微分渲染。实际上,我们将在本书中有几章与可微分渲染相关。下一章将是这些章节中的第一章。
第五章:通过可微分渲染学习物体姿势检测与跟踪
在本章中,我们将探讨通过使用可微分渲染来进行物体姿势检测与跟踪的项目。在物体姿势检测中,我们关注的是检测某个物体的方向和位置。例如,我们可能会得到相机模型和物体网格模型,并需要根据物体的图像估计物体的方向和位置。在本章的方法中,我们将把这种姿势估计问题表述为一个优化问题,其中物体的姿势与图像观测值进行拟合。
与上述相同的方法也可以用于物体姿势跟踪,其中我们已经在第 1、2,……直到 t-1 时刻估计了物体的姿势,并希望基于物体在t时刻的图像观测值估计物体的姿势。
本章中我们将使用的一个重要技术叫做可微分渲染,这是当前深度学习中一个非常激动人心的主题。例如,CVPR 2021 最佳论文奖得主GIRAFFE: 通过生成神经特征场表示场景就将可微分渲染作为其管道中的一个重要组成部分。
渲染是将三维物理模型(例如物体的网格模型或相机模型)投影到二维图像中的过程。它是对图像形成物理过程的模拟。许多三维计算机视觉任务可以视为渲染过程的逆过程——也就是说,在许多计算机视觉问题中,我们希望从二维图像开始,估计三维物理模型(网格、点云分割、物体姿势或相机位置)。
因此,计算机视觉领域已经讨论了数十年的一个非常自然的思路是,我们可以将许多三维计算机视觉问题表述为优化问题,其中优化变量是三维模型(网格或点云体素),而目标函数则是渲染图像与观测图像之间的某种相似度度量。
为了高效地解决上述优化问题,渲染过程需要是可微分的。例如,如果渲染是可微分的,我们可以使用端到端的方法训练深度学习模型来解决该问题。然而,正如后续章节将详细讨论的那样,传统的渲染过程是不可微分的。因此,我们需要修改传统的方法使其变得可微分。我们将在接下来的章节中详细讨论如何做到这一点。
因此,在本章中,我们将首先探讨 为什么需要可微分渲染 问题,然后讨论 如何解决可微分渲染问题。接着,我们将讨论哪些 3D 计算机视觉问题通常可以通过使用可微分渲染来解决。我们将为此章节安排大量篇幅,具体介绍如何使用可微分渲染来解决物体姿态估计问题。在过程中,我们会展示代码示例。
在本章中,我们将涵盖以下主要主题:
-
为什么需要可微分渲染
-
如何使渲染可微分
-
可微分渲染能解决哪些问题
-
物体姿态估计问题
技术要求
为了运行本书中的示例代码,你需要一台理想情况下配备 GPU 的计算机。然而,仅使用 CPU 运行代码片段也是可行的。
推荐的计算机配置包括以下内容:
-
至少配备 8 GB 内存的 GPU,例如 GTX 系列或 RTX 系列
-
Python 3
-
PyTorch 和 PyTorch3D 库
本章的代码片段可以在 github.com/PacktPublishing/3D-Deep-Learning-with-Python
找到。
为什么我们希望使用可微分渲染
图像形成的物理过程是从 3D 模型到 2D 图像的映射。如 图 4**.1 所示,取决于红色和蓝色球体在 3D 空间中的位置(左侧展示了两种可能的配置),我们可能会得到不同的 2D 图像(右侧展示了对应两种配置的图像)。
图 4.1:图像形成过程是从 3D 模型到 2D 图像的映射
许多 3D 计算机视觉问题是图像形成的反向过程。在这些问题中,我们通常会获得 2D 图像,并需要从 2D 图像中估计出 3D 模型。例如,在 图 4**.2 中,我们给出了右侧的 2D 图像,问题是,哪一个 3D 模型对应于 观察到的图像?
图 4.2:许多 3D 计算机视觉问题是基于给定的 2D 图像来估计 3D 模型
根据几十年前计算机视觉领域首次讨论的一些思想,我们可以将问题表述为优化问题。在这种情况下,这里的优化变量是两个三维球体的位置。我们希望优化这两个中心,使得渲染出的图像与前面的二维观察图像相似。为了精确衡量相似性,我们需要使用成本函数——例如,我们可以使用逐像素的均方误差。然后,我们需要计算从成本函数到两个球体中心的梯度,以便通过朝着梯度下降方向迭代地最小化成本函数。
然而,我们只能在优化变量到成本函数的映射是可微分的条件下,从成本函数计算梯度到优化变量,这意味着渲染过程也是可微分的。
如何使渲染过程可微分
在本节中,我们将讨论为什么传统的渲染算法不可微分。我们将讨论 PyTorch3D 中的做法,它使得渲染过程变得可微分。
渲染是图像形成物理过程的模拟。图像形成的物理过程在许多情况下本身是可微分的。假设表面法线和物体的材料属性都是平滑的。那么,在这个例子中,像素颜色是球体位置的可微分函数。
然而,在某些情况下,像素颜色不是位置的平滑函数。例如,在遮挡边界处就可能发生这种情况。这在图 4.3中有所展示,其中蓝色球体位于一个位置,如果蓝色球体稍微向上移动一点,就会遮挡红色球体。此时该视图中的像素位置因此不是球体中心位置的可微分函数。
图 4.3:在遮挡边界处,图像生成不是平滑的函数
当我们使用传统的渲染算法时,由于离散化,局部梯度信息会丢失。正如我们在前几章中讨论的那样,光栅化是渲染的一个步骤,其中对于成像平面上的每个像素,我们找到最相关的网格面(或者决定找不到相关的网格面)。
在传统的光栅化中,对于每个像素,我们从相机中心生成一条射线穿过成像平面上的像素。我们将找到所有与这条射线相交的网格面。在传统方法中,光栅化器只会返回距离相机最近的网格面。然后将返回的网格面传递给着色器,这是渲染管线的下一步。着色器将应用于其中一种着色算法(如兰伯特模型或冯氏模型)来确定像素颜色。选择要渲染的网格的这一步骤是一个非可微过程,因为它在数学上被建模为一个阶跃函数。
在计算机视觉社区中已经有大量文献讨论如何使渲染可微化。PyTorch3D 库中实现的可微渲染主要使用了 Liu, Li, Chen 和 Li 的《Soft Rasterizer》中的方法(arXiv:1904.01786)。
不同可微渲染的主要思想在图 4**.4中进行了说明。在光栅化步骤中,我们不再仅返回一个相关的网格面,而是找到所有与射线距离在一定阈值内的网格面。在 PyTorch3D 中,可以通过设置RasterizationSettings.blur_radius
来设定此阈值。我们还可以通过设置RasterizationSettings.faces_per_pixel
来控制返回的网格面的最大数量。
图 4.4: 加权平均所有相关网格面的可微渲染
接下来,渲染器需要计算每个网格面的概率图,如下所示,其中dist
表示网格面与射线之间的距离,sigma 是一个超参数。在 PyTorch3D 中,可以通过设置BlendParams.sigma
来设定sigma
参数。简单来说,这个概率图表示了这个网格面覆盖该图像像素的概率。如果射线与网格面相交,距离可能为负。
接下来,像素颜色由光栅器返回的所有网格面的着色结果的加权平均确定。每个网格面的权重取决于其反深度值z和概率图D,如下方程所示。因为这个z值是反深度,任何离相机近的网格面比远离相机的网格面有更大的z值。wb 是背景颜色的小权重。这里的参数 gamma 是一个超参数。在 PyTorch3D 中,可以将此参数设置为BlendParams.gamma
:
因此,最终像素颜色可以通过以下方程确定:
PyTorch3D 实现的微分渲染也会为每个图像像素计算一个 alpha 值。这个 alpha 值表示该图像像素位于前景的可能性,射线至少与一个网格面相交,如图 4**.4所示。我们希望计算这个 alpha 值并使其可微分。在软光栅化器中,alpha 值是通过概率图计算的,具体如下。
现在我们已经学会了如何使渲染可微分,接下来我们将展示如何将其用于各种目的。
使用微分渲染可以解决哪些问题
如前所述,微分渲染在计算机视觉领域已被讨论数十年。在过去,微分渲染被用于单视角网格重建、基于图像的形状拟合等。在本章的以下部分,我们将展示一个使用微分渲染进行刚性物体姿态估计和跟踪的具体示例。
微分渲染是一种技术,我们可以将 3D 计算机视觉中的估计问题转化为优化问题。它可以应用于广泛的问题。更有趣的是,最近的一个令人兴奋的趋势是将微分渲染与深度学习结合。通常,微分渲染作为深度学习模型的生成部分。整个流程可以端到端地进行训练。
物体姿态估计问题
本节我们将展示一个具体示例,说明如何使用微分渲染解决 3D 计算机视觉问题。问题是从一张单一观察图像中估计物体的姿态。此外,我们假设我们已经拥有该物体的 3D 网格模型。
例如,假设我们有一只玩具牛和茶壶的 3D 网格模型,如图 4**.5和图 4**.7所示。现在,假设我们拍摄了玩具牛和茶壶的一张图像。因此,我们有一张玩具牛的 RGB 图像,如图 4**.6所示,以及一张茶壶的轮廓图像,如图 4**.8所示。问题则是估计玩具牛和茶壶在拍摄这些图像时的方向和位置。
由于旋转和移动网格较为繁琐,我们选择固定网格的方向和位置,优化相机的方向和位置。假设相机的方向始终指向网格,我们可以进一步简化问题,从而只需优化相机的位置。
因此,我们提出了优化问题,优化变量将是相机的位置。通过使用可微分渲染,我们可以为这两个网格渲染 RGB 图像和轮廓图像。渲染的图像与观察图像进行比较,从而可以计算渲染图像和观察图像之间的损失函数。在这里,我们使用均方误差作为损失函数。因为一切都是可微的,所以我们可以计算损失函数到优化变量的梯度。然后可以使用梯度下降算法找到最佳的相机位置,使得渲染的图像与观察图像匹配。
图 4.5:玩具牛的网格模型
下图显示了牛的 RGB 输出:
图 4.6:玩具牛的观察 RGB 图像
下图显示了茶壶的网格:
图 4.7:茶壶的网格模型
下图显示了茶壶的轮廓:
图 4.8:茶壶的观察轮廓
现在我们知道了问题以及如何解决它,让我们在下一部分开始编码吧。
它是如何编码的
代码存储在chap4
文件夹中的diff_render.py
文件中。茶壶的网格模型存储在data
子文件夹中的teapot.obj
文件中。我们将按以下步骤运行代码:
-
diff_render.py
中的代码首先导入所需的包:import os import torch import numpy as np import torch.nn as nn import matplotlib.pyplot as plt from skimage import img_as_ubyte from pytorch3d.io import load_obj from pytorch3d.structures import Meshes from pytorch3d.renderer import ( FoVPerspectiveCameras, look_at_view_transform, look_at_rotation, RasterizationSettings, MeshRenderer, MeshRasterizer, BlendParams, SoftSilhouetteShader, HardPhongShader, PointLights, TexturesVertex, )
-
在下一步中,我们声明一个 PyTorch 设备。如果你有 GPU,那么设备将被创建以使用 GPU。如果没有 GPU,设备则会使用 CPU:
if torch.cuda.is_available(): device = torch.device("cuda:0") else: device = torch.device("cpu") print("WARNING: CPU only, this will be slow!")
-
然后,我们在下一行定义
output_dir
。当我们运行diff_render.py
中的代码时,代码将为每次优化迭代生成一些渲染图像,这样我们可以逐步查看优化是如何进行的。所有由代码生成的渲染图像将放在这个output_dir
文件夹中。output_dir = './result_teapot'
-
然后,我们从
./data/teapot.obj
文件加载网格模型。由于这个网格模型没有附带纹理(材质颜色),我们创建一个全为 1 的张量,并将其作为网格模型的纹理。最终,我们获得了一个带有纹理的网格模型,并将其存储为teapot_mesh
变量:verts, faces_idx, _ = load_obj("./data/teapot.obj") faces = faces_idx.verts_idx verts_rgb = torch.ones_like(verts)[None] # (1, V, 3) textures = TexturesVertex(verts_features=verts_rgb.to(device)) teapot_mesh = Meshes( verts=[verts.to(device)], faces=[faces.to(device)], textures=textures )
-
接下来,我们在下一行定义相机模型。
cameras = FoVPerspectiveCameras(device=device)
-
在下一步中,我们将定义一个可微分渲染器,称为
silhouette_renderer
。每个渲染器主要有两个组件,例如一个光栅化器用于查找每个图像像素的相关网格面,一个着色器用于确定图像像素的颜色等。在这个例子中,我们实际上使用的是一个软轮廓着色器,它输出每个图像像素的 alpha 值。alpha 值是一个实数,范围从 0 到 1,表示该图像像素是前景还是背景的一部分。请注意,着色器的超参数在blend_params
变量中定义,sigma
参数用于计算概率图,gamma 用于计算网格面的权重。
在这里,我们使用MeshRasterizer
进行光栅化。请注意,参数blur_radius
是用于查找相关网格面的阈值,faces_per_pixel
是每个图像像素返回的最大网格面数:
blend_params = BlendParams(sigma=1e-4, gamma=1e-4)
raster_settings = RasterizationSettings(
image_size=256,
blur_radius=np.log(1\. / 1e-4 - 1.) * blend_params.sigma,
faces_per_pixel=100,
)
silhouette_renderer = MeshRenderer(
rasterizer=MeshRasterizer(
cameras=cameras,
raster_settings=raster_settings
),
shader=SoftSilhouetteShader(blend_params=blend_params)
)
-
然后,我们按如下方式定义
phong_renderer
。这个phong_renderer
主要用于可视化优化过程。基本上,在每次优化迭代中,我们都会根据该迭代中的相机位置渲染一张 RGB 图像。请注意,这个渲染器仅用于可视化目的,因此它不是一个可微分的渲染器。你可以通过注意以下几点来判断phong_renderer
不是一个可微分渲染器:-
它使用
HardPhoneShader
,每个图像像素仅接受一个网格面作为输入。 -
它使用
MeshRenderer
,blur_radius
值为 0.0,faces_per_pixel
设置为 1。
-
-
然后,我们定义一个光源
lights
,其位置为 2.0,2.0,-2.0:raster_settings = RasterizationSettings( image_size=256, blur_radius=0.0, faces_per_pixel=1, ) lights = PointLights(device=device, location=((2.0, 2.0, -2.0),)) phong_renderer = MeshRenderer( rasterizer=MeshRasterizer( cameras=cameras, raster_settings=raster_settings ), shader=HardPhongShader(device=device, cameras=cameras, lights=lights) )
-
接下来,我们定义一个相机位置,并计算相应的旋转
R
和位移T
。这个旋转和位移就是目标相机位置——也就是说,我们将从这个相机位置生成一张图像,并将其作为我们问题中的观察图像:distance = 3 elevation = 50.0 azimuth = 0.0 R, T = look_at_view_transform(distance, elevation, azimuth, device=device)
-
现在,我们从这个相机位置生成一张图像
image_ref
。image_ref
函数有四个通道,image_ref
函数还会保存为target_rgb.png
,以便我们后续检查:silhouette = silhouette_renderer(meshes_world=teapot_mesh, R=R, T=T) image_ref = phong_renderer(meshes_world=teapot_mesh, R=R, T=T) silhouette = silhouette.cpu().numpy() image_ref = image_ref.cpu().numpy() plt.figure(figsize=(10, 10)) plt.imshow(silhouette.squeeze()[..., 3]) # only plot the alpha channel of the RGBA image plt.grid(False) plt.savefig(os.path.join(output_dir, 'target_silhouette.png')) plt.close() plt.figure(figsize=(10, 10)) plt.imshow(image_ref.squeeze()) plt.grid(False) plt.savefig(os.path.join(output_dir, 'target_rgb.png')) plt.close()
-
在下一步中,我们将定义一个
Model
类。这个Model
类继承自torch.nn.Module
;因此,与许多其他 PyTorch 模型一样,可以为Model
启用自动梯度计算。
Model
类有一个初始化函数__init__
,该函数接受meshes
作为网格模型输入,renderer
作为渲染器,image_ref
作为Model
实例要拟合的目标图像。__init__
函数通过使用torch.nn.Module.register_buffer
函数创建一个image_ref
缓冲区。对于那些不太熟悉这部分 PyTorch 的读者提醒一下——缓冲区是一种可以作为state_dict
的一部分保存并在cuda()
和cpu()
设备间移动的东西,与模型的其他参数一起。然而,缓冲区不会被优化器更新。
__init__
函数还创建了一个模型参数camera_position
。作为一个模型参数,camera_position
变量可以被优化器更新。请注意,优化变量现在成为了模型参数。
Model
类还有一个forward
成员函数,它可以进行前向计算和反向梯度传播。前向函数根据当前相机位置渲染一个轮廓图像,并计算渲染图像与image_ref
(观察图像)之间的损失函数:
class Model(nn.Module):
def __init__(self, meshes, renderer, image_ref):
super().__init__()
self.meshes = meshes
self.device = meshes.device
self.renderer = renderer
image_ref = torch.from_numpy((image_ref[..., :3].max(-1) != 1).astype(np.float32))
self.register_buffer('image_ref', image_ref)
self.camera_position = nn.Parameter(
torch.from_numpy(np.array([3.0, 6.9, +2.5], dtype=np.float32)).to(meshes.device))
def forward(self):
R = look_at_rotation(self.camera_position[None, :], device=self.device) # (1, 3, 3)
T = -torch.bmm(R.transpose(1, 2), self.camera_position[None, :, None])[:, :, 0] # (1, 3)
image = self.renderer(meshes_world=self.meshes.clone(), R=R, T=T)
loss = torch.sum((image[..., 3] - self.image_ref) ** 2)
return loss, image
-
现在,我们已经定义了
Model
类。接下来,我们可以创建类的实例并定义优化器。在运行任何优化之前,我们希望渲染一张图像,展示起始的相机位置。这张起始相机位置的轮廓图像将被保存为starting_silhouette.png
:model = Model(meshes=teapot_mesh, renderer=silhouette_renderer, image_ref=image_ref).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=0.05) _, image_init = model() plt.figure(figsize=(10, 10)) plt.imshow(image_init.detach().squeeze().cpu().numpy()[..., 3]) plt.grid(False) plt.title("Starting Silhouette") plt.savefig(os.path.join(output_dir, 'starting_silhouette.png')) plt.close()
-
最后,我们可以进行优化迭代。在每次优化迭代中,我们将从相机位置渲染图像并保存到
output_dir
文件夹中的一个文件:for i in range(0, 200): if i%10 == 0: print('i = ', i) optimizer.zero_grad() loss, _ = model() loss.backward() optimizer.step() if loss.item() < 500: break R = look_at_rotation(model.camera_position[None, :], device=model.device) T = -torch.bmm(R.transpose(1, 2), model.camera_position[None, :, None])[:, :, 0] # (1, 3) image = phong_renderer(meshes_world=model.meshes.clone(), R=R, T=T) image = image[0, ..., :3].detach().squeeze().cpu().numpy() image = img_as_ubyte(image) plt.figure() plt.imshow(image[..., :3]) plt.title("iter: %d, loss: %0.2f" % (i, loss.data)) plt.axis("off") plt.savefig(os.path.join(output_dir, 'fitting_' + str(i) + '.png')) plt.close()
图 4.9展示了物体的观察轮廓(在这个例子中是茶壶):
图 4.9:茶壶的轮廓
我们将拟合问题公式化为一个优化问题。初始茶壶位置如图 4.10所示。
图 4.10:茶壶的初始位置
最终优化后的茶壶位置如图 4.11所示。
图 4.11:茶壶的最终位置
一个使用轮廓拟合和纹理拟合的物体姿态估计示例
在之前的示例中,我们通过轮廓拟合估计了物体姿态。在本节中,我们将展示另一个使用轮廓拟合和纹理拟合相结合的物体姿态估计示例。在 3D 计算机视觉中,我们通常使用纹理来表示颜色。因此,在这个示例中,我们将使用可微分渲染根据相机位置渲染 RGB 图像并优化相机位置。代码在diff_render_texture.py
中:
-
在第一步中,我们将导入所有必需的包:
import os import torch import numpy as np import torch.nn as nn import matplotlib.pyplot as plt from skimage import img_as_ubyte from pytorch3d.io import load_objs_as_meshes from pytorch3d.renderer import ( FoVPerspectiveCameras, look_at_view_transform, look_at_rotation, RasterizationSettings, MeshRenderer, MeshRasterizer, BlendParams, SoftSilhouetteShader, HardPhongShader, PointLights, SoftPhongShader )
-
接下来,我们使用 GPU 或 CPU 创建 PyTorch 设备:
if torch.cuda.is_available(): device = torch.device("cuda:0") torch.cuda.set_device(device) else: device = torch.device("cpu")
-
我们将
output_dir
设置为result_cow
。这是保存拟合结果的文件夹:output_dir = './result_cow'
-
我们从
cow.obj
文件中加载一个玩具牛的网格模型:obj_filename = "./data/cow_mesh/cow.obj" cow_mesh = load_objs_as_meshes([obj_filename], device=device)
-
我们按如下方式定义相机和光源:
cameras = FoVPerspectiveCameras(device=device) lights = PointLights(device=device, location=((2.0, 2.0, -2.0),))
-
接下来,我们创建一个
renderer_silhouette
渲染器。这是用于渲染轮廓图像的可微渲染器。注意blur_radius
和faces_per_pixel
的数值。这个渲染器主要用于轮廓拟合:blend_params = BlendParams(sigma=1e-4, gamma=1e-4) raster_settings = RasterizationSettings( image_size=256, blur_radius=np.log(1\. / 1e-4 - 1.) * blend_params.sigma, faces_per_pixel=100, ) renderer_silhouette = MeshRenderer( rasterizer=MeshRasterizer( cameras=cameras, raster_settings=raster_settings ), shader=SoftSilhouetteShader(blend_params=blend_params) )
-
接下来,我们创建一个
renderer_textured
渲染器。该渲染器是另一个可微渲染器,主要用于渲染 RGB 图像:sigma = 1e-4 raster_settings_soft = RasterizationSettings( image_size=256, blur_radius=np.log(1\. / 1e-4 - 1.)*sigma, faces_per_pixel=50, ) renderer_textured = MeshRenderer( rasterizer=MeshRasterizer( cameras=cameras, raster_settings=raster_settings_soft ), shader=SoftPhongShader(device=device, cameras=cameras, lights=lights) )
-
接下来,我们创建一个
phong_renderer
渲染器。该渲染器主要用于可视化。前面提到的可微渲染器倾向于生成模糊的图像。因此,拥有一个能够生成清晰图像的渲染器对我们来说非常重要:raster_settings = RasterizationSettings( image_size=256, blur_radius=0.0, faces_per_pixel=1, ) phong_renderer = MeshRenderer( rasterizer=MeshRasterizer( cameras=cameras, raster_settings=raster_settings ), shader=HardPhongShader(device=device, cameras=cameras, lights=lights) )
-
接下来,我们将定义一个相机位置及其对应的相机旋转和位置。这将是拍摄观察图像的相机位置。与之前的示例一样,我们优化的是相机的朝向和位置,而不是物体的朝向和位置。此外,我们假设相机始终指向物体。因此,我们只需要优化相机的位置:
distance = 3 elevation = 50.0 azimuth = 0.0 R, T = look_at_view_transform(distance, elevation, azimuth, device=device)
-
接下来,我们创建观察图像并将其保存到
target_silhouette.png
和target_rgb.png
。这些图像也会存储在silhouette
和image_ref
变量中:silhouette = renderer_silhouette(meshes_world=cow_mesh, R=R, T=T) image_ref = phong_renderer(meshes_world=cow_mesh, R=R, T=T) silhouette = silhouette.cpu().numpy() image_ref = image_ref.cpu().numpy() plt.figure(figsize=(10, 10)) plt.imshow(silhouette.squeeze()[..., 3]) plt.grid(False) plt.savefig(os.path.join(output_dir, 'target_silhouette.png')) plt.close() plt.figure(figsize=(10, 10)) plt.imshow(image_ref.squeeze()) plt.grid(False) plt.savefig(os.path.join(output_dir, 'target_rgb.png')) plt.close()
-
我们按照以下方式修改
Model
类的定义。与之前示例的主要区别在于,现在我们将同时渲染 alpha 通道图像和 RGB 图像,并将它们与观察到的图像进行比较。alpha 通道和 RGB 通道的均方误差损失值会加权以得到最终的损失值:class Model(nn.Module): def __init__(self, meshes, renderer_silhouette, renderer_textured, image_ref, weight_silhouette, weight_texture): super().__init__() self.meshes = meshes self.device = meshes.device self.renderer_silhouette = renderer_silhouette self.renderer_textured = renderer_textured self.weight_silhouette = weight_silhouette self.weight_texture = weight_texture image_ref_silhouette = torch.from_numpy((image_ref[..., :3].max(-1) != 1).astype(np.float32)) self.register_buffer('image_ref_silhouette', image_ref_silhouette) image_ref_textured = torch.from_numpy((image_ref[..., :3]).astype(np.float32)) self.register_buffer('image_ref_textured', image_ref_textured) self.camera_position = nn.Parameter( torch.from_numpy(np.array([3.0, 6.9, +2.5], dtype=np.float32)).to(meshes.device)) def forward(self): # Render the image using the updated camera position. Based on the new position of the # camera we calculate the rotation and translation matrices R = look_at_rotation(self.camera_position[None, :], device=self.device) # (1, 3, 3) T = -torch.bmm(R.transpose(1, 2), self.camera_position[None, :, None])[:, :, 0] # (1, 3) image_silhouette = self.renderer_silhouette(meshes_world=self.meshes.clone(), R=R, T=T) image_textured = self.renderer_textured(meshes_world=self.meshes.clone(), R=R, T=T) loss_silhouette = torch.sum((image_silhouette[..., 3] - self.image_ref_silhouette) ** 2) loss_texture = torch.sum((image_textured[..., :3] - self.image_ref_textured) ** 2) loss = self.weight_silhouette * loss_silhouette + self.weight_texture * loss_texture return loss, image_silhouette, image_textured
-
接下来,我们创建
Model
类的实例并创建一个优化器:model = Model(meshes=cow_mesh, renderer_silhouette=renderer_silhouette, renderer_textured = renderer_textured, image_ref=image_ref, weight_silhouette=1.0, weight_texture=0.1).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=0.05)
-
最后,我们进行 200 次优化迭代。每次迭代的渲染图像都会被保存:
for i in range(0, 200): if i%10 == 0: print('i = ', i) optimizer.zero_grad() loss, image_silhouette, image_textured = model() loss.backward() optimizer.step() plt.figure() plt.imshow(image_silhouette[..., 3].detach().squeeze().cpu().numpy()) plt.title("iter: %d, loss: %0.2f" % (i, loss.data)) plt.axis("off") plt.savefig(os.path.join(output_dir, 'soft_silhouette_' + str(i) + '.png')) plt.close() plt.figure() plt.imshow(image_textured.detach().squeeze().cpu().numpy()) plt.title("iter: %d, loss: %0.2f" % (i, loss.data)) plt.axis("off") plt.savefig(os.path.join(output_dir, 'soft_texture_' + str(i) + '.png')) plt.close() R = look_at_rotation(model.camera_position[None, :], device=model.device) T = -torch.bmm(R.transpose(1, 2), model.camera_position[None, :, None])[:, :, 0] # (1, 3) image = phong_renderer(meshes_world=model.meshes.clone(), R=R, T=T) plt.figure() plt.imshow(image[..., 3].detach().squeeze().cpu().numpy()) plt.title("iter: %d, loss: %0.2f" % (i, loss.data)) plt.axis("off") plt.savefig(os.path.join(output_dir, 'hard_silhouette_' + str(i) + '.png')) plt.close() image = image[0, ..., :3].detach().squeeze().cpu().numpy() image = img_as_ubyte(image) plt.figure() plt.imshow(image[..., :3]) plt.title("iter: %d, loss: %0.2f" % (i, loss.data)) plt.axis("off") plt.savefig(os.path.join(output_dir, 'hard_texture_' + str(i) + '.png')) plt.close() if loss.item() < 800: break
观察到的轮廓图像显示在图 4.12中:
图 4.12:观察到的轮廓图像
RGB 图像显示在图 4.13中:
图 4.13:观察到的 RGB 图像
与初始相机位置和最终拟合相机位置对应的渲染 RGB 图像分别显示在图 4.14和图 4.15中。
图 4.14:与初始相机位置对应的图像
与最终位置对应的图像如下:
图 4.15:与拟合相机位置对应的图像
总结
在本章中,我们从为什么需要可微渲染的问题开始。这个问题的答案在于渲染可以被看作是从 3D 场景(网格或点云)到 2D 图像的映射。如果渲染是可微的,那么我们就可以直接优化 3D 模型,通过在渲染图像和观察图像之间选择适当的代价函数。
然后我们讨论了一种使渲染可微的方法,这种方法在 PyTorch3D 库中得到了实现。接着,我们讨论了两个具体的示例,物体姿态估计被形式化为优化问题,在该问题中,物体姿态被直接优化,以最小化渲染图像和观察图像之间的均方误差。
我们还浏览了代码示例,其中 PyTorch3D 用于解决优化问题。在下一章,我们将探讨更多可微渲染的变体,以及我们可以在哪里使用它。
第六章:理解可微体积渲染
本章我们将讨论一种新的可微渲染方式。我们将使用体素 3D 数据表示方式,不同于上一章使用的网格 3D 数据表示。与网格模型相比,体素 3D 数据表示具有某些优势。例如,它更灵活,结构更清晰。
要理解体积渲染,我们需要了解一些重要的概念,如光线采样、体积、体积采样和光线行进。所有这些概念都有对应的 PyTorch3D 实现。我们将通过解释和编码练习讨论这些概念。
在我们理解了前面提到的体积渲染基本概念后,我们可以轻松地看到,所有提到的操作已经是可微的。体积渲染天生就是可微的。因此,到那时,我们将准备好使用可微的体积渲染进行一些实际应用。我们将通过一个编码示例,展示如何利用可微的体积渲染从多张图像中重建 3D 体素模型。
我们将首先高层次地理解体积渲染。然后我们将深入探讨一些基本概念,如光线采样、体积、体积采样和光线行进。接下来,我们将展示一个通过从不同视角拍摄的图像集合重建 3D 物体形状的编码示例。
本章将涵盖以下主要主题:
-
体积渲染的高层次描述
-
理解光线采样
-
使用体积采样
-
理解光线行进
-
从多视角图像重建 3D 物体和颜色
技术要求
为了运行本书中的示例代码片段,您需要一台计算机,最好配备 GPU。然而,仅使用 CPU 运行代码片段也是可以的。
推荐的计算机配置包括以下内容:
-
一张 GPU,例如,NVIDIA GTX 系列或 RTX 系列,至少具有 8GB 内存
-
Python 3
-
PyTorch 库和 PyTorch3D 库
本章的代码片段可以在github.com/PacktPublishing/3D-Deep-Learning-with-Python.
找到。
体积渲染概述
体积渲染是一种用于生成离散三维数据二维视图的技术集合。这些三维离散数据可以是图像集合、体素表示,或任何其他离散的数据表示。体积渲染的主要目标是呈现三维数据的二维投影,因为这正是我们的眼睛在平面屏幕上能够感知到的。这种方法生成这样的投影,而无需显式地转换为几何表示(如网格)。体积渲染通常在生成表面困难或可能导致错误时使用。当体积的内容(而不仅仅是几何形状和表面)很重要时,也可以使用这种方法。它通常用于数据可视化。例如,在脑部扫描中,通常非常重要的是对大脑内部内容的可视化。
在本节中,我们将探讨体积的体积渲染。我们将对体积渲染进行高层次的概述,如图 5.1所示:
-
首先,我们通过使用体积来表示三维空间和其中的物体,体积是一个规则间隔的三维网格。每个节点有两个属性:密度和颜色特征。密度通常在 0 到 1 之间。密度也可以理解为占用的概率。也就是说,我们对某个节点是否被某个物体占据的确信程度。在某些情况下,概率也可以是透明度。
-
我们需要定义一个或多个相机。渲染是确定相机从其视角所能观察到的内容的过程。
-
为了确定前述相机中每个像素的 RGB 值,首先从投影中心生成一条射线,通过相机的每个图像像素。我们需要检查沿射线的占用概率或不透明度和颜色,以确定该像素的 RGB 值。请注意,每条射线上有无数个点。因此,我们需要有一种采样方案来选择沿射线的若干个点。这一采样操作称为射线采样。
-
请注意,我们在体积的节点上定义了密度和颜色,但在射线上的点上没有定义。因此,我们需要一种方法将体积的密度和颜色转换为射线上的点。这一操作称为体积采样。
-
最后,根据射线的密度和颜色,我们需要确定每个像素的 RGB 值。在这个过程中,我们需要计算沿射线每个点上能够到达的入射光线数量,以及反射到图像像素的光线数量。我们将这个过程称为射线行进。
图 5.1:体积渲染
在理解了体积渲染的基本过程后,让我们更深入地探讨第一个概念:射线采样。
理解射线采样
光线采样是从相机发射光线,穿过图像像素并沿着这些光线采样点的过程。光线采样方案取决于具体应用。例如,有时我们可能希望随机采样穿过一些图像像素的光线。通常在训练时,我们只需要从完整数据中提取一个代表性样本,这时可以使用 Pytorch3D 中的 MonteCarloRaysampler
。在其他情况下,我们希望获取图像中每个像素的像素值并保持空间顺序,这对于渲染和可视化非常有用。对于这种应用,PyTorch3D 提供了 NDCMultiNomialRaysampler
。
在接下来的示例中,我们将演示如何使用 PyTorch3D 中的一个光线采样器 NDCGridRaysampler
。它类似于 NDCMultiNomialRaysampler
,即沿着网格采样像素。相关代码可以在 GitHub 仓库 understand_ray_sampling.py
中找到:
-
首先,我们需要导入所有的 Python 模块,包括
NDCGridRaysampler
的定义:import torch import math import numpy as np from pytorch3d.renderer import ( FoVPerspectiveCameras, PointLights, look_at_view_transform, NDCGridRaysampler, )
-
设置设备以供后续步骤使用。如果我们有 GPU,那么我们将使用第一块 GPU;否则,我们将使用 CPU:
if torch.cuda.is_available(): device = torch.device("cuda:0") torch.cuda.set_device(device) else: device = torch.device("cpu")
-
我们定义了一批 10 个相机。这里,
num_views
表示视图的数量,在本例中为 10。elev
变量表示仰角,azim
表示方位角。旋转矩阵R
和平移矩阵T
可以通过 PyTorch3D 的look_at_view_transform
函数来确定。然后,通过旋转和平移定义 10 个相机,这 10 个相机都指向位于世界坐标系中心的物体:num_views: int = 10 azimuth_range: float = 180 elev = torch.linspace(0, 0, num_views) azim = torch.linspace(-azimuth_range, azimuth_range, num_views) + 180.0 lights = PointLights(device=device, location=[[0.0, 0.0, -3.0]]) R, T = look_at_view_transform(dist=2.7, elev=elev, azim=azim) cameras = FoVPerspectiveCameras(device=device, R=R, T=T)
-
最后,我们可以定义光线采样器,也就是
raysampler
变量。我们需要指定相机的图像大小,还需要指定光线采样的最小深度和最大深度。n_pts_per_ray
输入参数表示每条光线上的点数:image_size = 64 volume_extent_world = 3.0 raysampler = NDCGridRaysampler( image_width=image_size, image_height=image_size, n_pts_per_ray=50, min_depth=0.1, max_depth=volume_extent_world, )
-
在前面的步骤中,我们已经定义了一个光线采样器。为了让光线采样器能够采样光线和点供后续使用,我们需要告知光线采样器我们的相机位置以及它们的指向方向。这可以通过将第 3 步中定义的相机传递给
raysampler
来轻松实现。然后我们得到一个ray_bundle
变量:ray_bundle = raysampler(cameras)
-
ray_bundle
变量包含一组不同的 PyTorch 张量,指定了采样的光线和点。我们可以打印这些成员变量来检查它们的形状并验证它们的内容:print('ray_bundle origins tensor shape = ', ray_bundle.origins.shape) print('ray_bundle directions shape = ', ray_bundle.directions.shape) print('ray_bundle lengths = ', ray_bundle.lengths.shape) print('ray_bundle xys shape = ', ray_bundle.xys.shape)
-
这些代码应该运行并打印以下信息:
-
我们可以看到
ray_bundle.origins
是一个张量,包含了光线的起点,批量大小为 10。由于图像大小为 64×64,第二维和第三维的大小都是 64。对于每个起点,我们需要三个数字来指定其三维位置。 -
ray_bundle.directions
是一个关于射线方向的张量。这里批次大小为 10,图像大小为 64x64。这解释了张量前面三个维度的大小。我们需要三个数字来指定 3D 空间中的一个方向。 -
ray_bundle.lengths
是一个关于射线每个点深度的张量。共有 10x64x64 条射线,每条射线有 50 个点。 -
ray_bundle.xys
是一个关于图像平面上每条射线的 x 和 y 位置的张量。共有 10x64x64 条射线。我们需要一个数字表示 x 位置,一个数字表示 y 位置:ray_bundle origins tensor shape = torch.Size([10, 64, 64, 3]) ray_bundle directions shape = torch.Size([10, 64, 64, 3]) ray_bundle lengths = torch.Size([10, 64, 64, 50]) ray_bundle xys shape = torch.Size([10, 64, 64, 2])
-
-
最后,我们将
ray_bundle
保存到ray_sampling.pt
文件中。这些射线对我们接下来的编码练习很有用:torch.save({ 'ray_bundle': ray_bundle }, 'ray_sampling.pt')
到目前为止,我们已经了解了射线采样器的作用。射线采样器给我们提供了一批射线和射线上的离散点。然而,我们仍然没有在这些点和射线上定义密度和颜色。接下来,我们将学习如何从体积中获取这些密度和颜色。
使用体积采样
体积采样是通过射线样本提供的点获取颜色和占用信息的过程。我们处理的体积表示是离散的。因此,在射线采样步骤中定义的点可能并不完全落在某个节点上。体积网格的节点和射线上的点通常具有不同的空间位置。我们需要使用插值方案来从体积的密度和颜色推算射线上的密度和颜色。我们可以使用 PyTorch3D 中实现的 VolumeSampler
来做到这一点。以下代码可以在 GitHub 仓库中的 understand_volume_sampler.py
文件中找到:
-
导入我们需要的 Python 模块:
import torch from pytorch3d.structures import Volumes from pytorch3d.renderer.implicit.renderer import VolumeSampler
-
设置设备:
if torch.cuda.is_available(): device = torch.device("cuda:0") torch.cuda.set_device(device) else: device = torch.device("cpu")
-
加载在上一节中计算的
ray_bundle
:checkpoint = torch.load('ray_sampling.pt') ray_bundle = checkpoint.get('ray_bundle')
-
接下来,我们定义一个体积。密度张量的形状是 [10, 1, 64, 64, 50],这里有 10 个体积批次,每个体积是一个 64x64x50 的节点网格。每个节点有一个数字表示该节点的密度。另一方面,颜色张量的形状是 [10, 3, 64, 64, 50],因为每种颜色需要三个数字来表示 RGB 值:
batch_size = 10 densities = torch.zeros([batch_size, 1, 64, 64, 64]).to(device) colors = torch.zeros(batch_size, 3, 64, 64, 64).to(device) voxel_size = 0.1 volumes = Volumes( densities=densities, features=colors, voxel_size=voxel_size )
-
我们需要根据体积定义
volume_sampler
。在这里,我们使用双线性插值进行体积采样。然后,可以通过将ray_bundle
传递给volume_sampler
来轻松获取射线上的点的密度和颜色:volume_sampler = VolumeSampler(volumes = volumes, sample_mode = "bilinear") rays_densities, rays_features = volume_sampler(ray_bundle)
-
我们可以打印出密度和颜色的形状:
print('rays_densities shape = ', rays_densities.shape) print('rays_features shape = ', rays_features.shape)
-
输出如下。注意,我们有 10 个相机的批次大小,这解释了张量第一维的大小。每个图像像素对应一条射线,我们的相机图像分辨率是 64x64。每条射线上的点数为 50,这解释了张量第四维的大小。每个密度可以用一个数字表示,每种颜色需要三个数字来表示 RGB 值:
rays_densities shape = torch.Size([10, 64, 64, 50, 1]) rays_features shape = torch.Size([10, 64, 64, 50, 3])
-
最后,让我们保存密度和颜色,因为我们需要在下一部分使用它们:
torch.save({ 'rays_densities': rays_densities, 'rays_features': rays_features }, 'volume_sampling.pt')
现在我们已经概览了体积采样。我们知道它是什么以及为什么它有用。在接下来的部分,我们将学习如何使用这些密度和颜色来生成批量相机的 RGB 图像值。
探索射线行进器
现在,我们已经获得了所有通过射线采样器采样的点的颜色和密度值,我们需要弄清楚如何利用它来最终在投影图像上渲染像素值。在这一部分,我们将讨论如何将射线点上的密度和颜色转换为图像上的 RGB 值的过程。这个过程模拟了图像形成的物理过程。
在这一部分,我们讨论一个非常简单的模型,其中每个图像像素的 RGB 值是相应射线上的点的颜色的加权和。如果我们将密度视为占据或不透明的概率,那么射线上每个点的入射光强度 a = (1 - p_i)的乘积,其中 p_i 是密度。假设该点被某个物体占据的概率为 p_i,那么从该点反射的期望光强度为 w_i = a p_i。我们将 w_i 作为加权和的颜色的权重。通常,我们通过应用 softmax 操作来对权重进行归一化,使得所有权重的总和为 1。
PyTorch3D 包含多种射线行进器的实现。以下代码可以在 GitHub 仓库中的understand_ray_marcher.py
找到:
-
在第一步中,我们将导入所有所需的包:
import torch from pytorch3d.renderer.implicit.raymarching import EmissionAbsorptionRaymarcher
-
接下来,我们加载上一部分中射线上的密度和颜色:
checkpoint = torch.load('volume_sampling.pt') rays_densities = checkpoint.get('rays_densities') rays_features = checkpoint.get('rays_features')
-
我们定义了
ray_marcher
并将射线上的密度和颜色传递给ray_marcher
。这将给我们image_features
,即渲染后的 RGB 值:ray_marcher = EmissionAbsorptionRaymarcher() image_features = ray_marcher(rays_densities = rays_densities, rays_features = rays_features)
-
我们可以打印图像特征的形状:
print('image_features shape = ', image_features.shape)
-
正如我们所预期的,形状是[10, 64, 64, 4],其中 10 是批量大小,64 是图像的宽度和高度。输出有四个通道,前三个是 RGB,最后一个通道是 alpha 通道,表示像素是前景还是背景:
image_features shape = torch.Size([10, 64, 64, 4])
我们现在已经了解了一些体积渲染的主要组成部分。注意,从体积密度和颜色到图像像素 RGB 值的计算过程已经是可微分的。因此,体积渲染自然是可微分的。考虑到前面的所有变量都是 PyTorch 张量,我们可以对这些变量计算梯度。
在下一部分,我们将学习可微分的体积渲染,并查看一个使用体积渲染从多视角图像重建 3D 模型的示例。
可微分体积渲染
虽然标准的体积渲染用于渲染 3D 数据的 2D 投影,但可微分体积渲染则用于做相反的事情:从 2D 图像构建 3D 数据。它的工作原理是:我们将物体的形状和纹理表示为一个参数化的函数。这个函数可以用来生成 2D 投影。然而,给定 2D 投影(通常是多视角的 3D 场景图像),我们可以优化这些隐式形状和纹理函数的参数,使其投影是多视角的 2D 图像。由于渲染过程是完全可微分的,且所使用的隐式函数也可微分,因此这种优化是可行的。
从多视角图像重建 3D 模型
在这一部分,我们将展示一个使用可微分体积渲染从多视角图像中重建 3D 模型的示例。重建 3D 模型是一个常见的难题。通常,直接测量 3D 世界的方法既困难又昂贵,例如,LiDAR 和雷达通常很昂贵。另一方面,2D 相机的成本要低得多,这使得从 2D 图像重建 3D 世界变得极具吸引力。当然,为了重建 3D 世界,我们需要来自多个视角的多张图像。
以下的 volume_renderer.py
代码可以在 GitHub 仓库中找到,并且它是从 PyTorch3D 的教程中修改而来。我们将使用这个代码示例展示体积渲染在实际应用中的运作方式:
-
首先,我们需要导入所有的 Python 模块:
import os import sys import time import json import glob import torch import math import matplotlib.pyplot as plt import numpy as np from PIL import Image from pytorch3d.structures import Volumes from pytorch3d.renderer import ( FoVPerspectiveCameras, VolumeRenderer, NDCGridRaysampler, EmissionAbsorptionRaymarcher ) from pytorch3d.transforms import so3_exp_map from plot_image_grid import image_grid from generate_cow_renders import generate_cow_renders
-
接下来,我们需要设置设备:
if torch.cuda.is_available(): device = torch.device("cuda:0") torch.cuda.set_device(device) else: device = torch.device("cpu")
-
使用 PyTorch3D 教程提供的函数,我们生成 40 个不同角度的相机、图像和轮廓图像。我们将这些图像视为给定的真实图像,并拟合一个 3D 体积模型到这些观察到的真实图像:
target_cameras, target_images, target_silhouettes = generate_cow_renders(num_views=40)
-
接下来,我们定义一个光线采样器。正如我们在前面几节中讨论的,光线采样器用于为我们采样光线及每条光线的采样点:
render_size = 128 volume_extent_world = 3.0 raysampler = NDCGridRaysampler( image_width=render_size, image_height=render_size, n_pts_per_ray=150, min_depth=0.1, max_depth=volume_extent_world, )
-
接下来,我们像之前一样创建光线行进器。注意,这次我们定义了一个
VolumeRenderer
类型的变量渲染器。VolumeRenderer
只是一个很好的接口,光线采样器和光线行进器在背后做所有繁重的工作:raymarcher = EmissionAbsorptionRaymarcher() renderer = VolumeRenderer( raysampler=raysampler, raymarcher=raymarcher, )
-
接下来,我们定义一个
VolumeModel
类。这个类的作用仅仅是封装一个体积,以便在前向函数中计算梯度,并且通过优化器更新体积密度和颜色:class VolumeModel(torch.nn.Module): def __init__(self, renderer, volume_size=[64] * 3, voxel_size=0.1): super().__init__() self.log_densities = torch.nn.Parameter(-4.0 * torch.ones(1, *volume_size)) self.log_colors = torch.nn.Parameter(torch.zeros(3, *volume_size)) self._voxel_size = voxel_size self._renderer = renderer def forward(self, cameras): batch_size = cameras.R.shape[0] densities = torch.sigmoid(self.log_densities) colors = torch.sigmoid(self.log_colors) volumes = Volumes( densities=densities[None].expand( batch_size, *self.log_densities.shape), features=colors[None].expand( batch_size, *self.log_colors.shape), voxel_size=self._voxel_size, ) return self._renderer(cameras=cameras, volumes=volumes)[0]
-
定义一个 Huber 损失函数。Huber 损失函数是一种鲁棒的损失函数,用于防止少数异常值使优化偏离真实的最优解。最小化这个损失函数将使 x 更接近 y:
def huber(x, y, scaling=0.1): diff_sq = (x - y) ** 2 loss = ((1 + diff_sq / (scaling ** 2)).clamp(1e-4).sqrt() - 1) * float(scaling) return loss
-
将所有内容移动到正确的设备上:
target_cameras = target_cameras.to(device) target_images = target_images.to(device) target_silhouettes = target_silhouettes.to(device)
-
定义一个
VolumeModel
的实例:volume_size = 128 volume_model = VolumeModel( renderer, volume_size=[volume_size] * 3, voxel_size=volume_extent_world / volume_size, ).to(device)
-
现在我们来设置优化器。学习率
lr
设置为 0.1。我们使用 Adam 优化器,优化迭代次数为 300 次:lr = 0.1 optimizer = torch.optim.Adam(volume_model.parameters(), lr=lr) batch_size = 10 n_iter = 300
-
接下来,我们进行主要的优化循环。体积的密度和颜色被渲染出来,得到的颜色和轮廓与观察到的多视图图像进行比较。通过最小化渲染图像和观察到的真实图像之间的 Huber 损失:
for iteration in range(n_iter): if iteration == round(n_iter * 0.75): print('Decreasing LR 10-fold ...') optimizer = torch.optim.Adam( volume_model.parameters(), lr=lr * 0.1 ) optimizer.zero_grad() batch_idx = torch.randperm(len(target_cameras))[:batch_size] # Sample the minibatch of cameras. batch_cameras = FoVPerspectiveCameras( R=target_cameras.R[batch_idx], T=target_cameras.T[batch_idx], znear=target_cameras.znear[batch_idx], zfar=target_cameras.zfar[batch_idx], aspect_ratio=target_cameras.aspect_ratio[batch_idx], fov=target_cameras.fov[batch_idx], device=device, ) rendered_images, rendered_silhouettes = volume_model( batch_cameras ).split([3, 1], dim=-1) sil_err = huber( rendered_silhouettes[..., 0], target_silhouettes[batch_idx], ).abs().mean() color_err = huber( rendered_images, target_images[batch_idx], ).abs().mean() loss = color_err + sil_err loss.backward() optimizer.step()
-
在优化完成后,我们将最终得到的体积模型从新的角度渲染图像:
with torch.no_grad(): rotating_volume_frames = ge erate_rotating_volume(volume_model, n_frames=7 * 4) image_grid(rotating_volume_frames.clamp(0., 1\. .cpu().numpy(), rows=4, cols=7, rgb=True, fill=True) plt.savefig('rotating_volume.png') plt.show()
-
最后,渲染出来的新图像显示在图 5.2 中:
图 5.2:来自拟合 3D 模型的渲染图像
到目前为止,我们已经概述了可微分体积渲染中的一些主要概念。我们还学习了一个具体的例子,展示了如何利用可微分体积渲染从多视图图像中重建 3D 模型。你应该已经掌握了这些技巧,并能够将其应用于自己的问题。
总结
在这一章中,我们首先对可微分体积渲染进行了高层次的描述。接着,我们深入探讨了可微分体积渲染中的几个重要概念,包括射线采样、体积采样和射线步进器,但仅通过解释和编码示例来进行讲解。我们还走过了一个使用可微分体积渲染从多视图图像中重建 3D 模型的编码示例。
使用体积数据进行 3D 深度学习近年来已经成为一个有趣的方向。随着这一方向上涌现出许多创新思想,许多突破也在不断出现。其中一个突破被称为神经辐射场(NeRF),它将成为我们下一章的主题。
第七章:探索神经辐射场(NeRF)
在上一章中,你了解了可微分体积渲染技术,其中你通过多个视图图像重建了 3D 体积。使用这种技术,你建模了由 N x N x N 体素组成的体积。存储该体积所需的空间规模将是 O(N3)。这是不理想的,特别是当我们希望通过网络传输这些信息时。其他方法可以克服如此大的磁盘空间需求,但它们容易平滑几何形状和纹理。因此,我们不能依赖它们来可靠地建模非常复杂或有纹理的场景。
在本章中,我们将讨论一种突破性的 3D 场景表示新方法,称为 神经辐射场(NeRF)。这是首批能够建模 3D 场景的技术之一,它比传统方法需要更少的常驻磁盘空间,同时捕捉复杂场景的精细几何和纹理。
在本章中,你将学习以下内容:
-
理解 NeRF
-
训练 NeRF 模型
-
理解 NeRF 模型架构
-
理解使用辐射场的体积渲染
技术要求
为了运行本书中的示例代码片段,你需要一台电脑,理想情况下配备约 8 GB GPU 内存的显卡。仅使用 CPU 运行代码片段并非不可能,但将非常慢。推荐的计算机配置如下:
-
一台 GPU 设备——例如,至少具有 8 GB 内存的 Nvidia GTX 系列或 RTX 系列
-
Python 3.7+
-
PyTorch 和 PyTorch3D 库
本章的代码片段可以在 github.com/PacktPublishing/3D-Deep-Learning-with-Python
找到。
理解 NeRF
视图合成是 3D 计算机视觉中的一个长期存在的问题。挑战在于使用少量可用的 2D 场景快照来合成 3D 场景的新视图。这尤其具有挑战性,因为复杂场景的视角可能依赖于许多因素,如物体伪影、光源、反射、不透明度、物体表面纹理和遮挡。任何好的表示方法都应该隐式或显式地捕捉这些信息。此外,许多物体具有复杂的结构,从某个视点并不能完全看到。这项挑战在于如何在给定不完全和噪声信息的情况下构建关于世界的完整信息。
顾名思义,NeRF 使用神经网络来建模世界。正如我们在本章稍后将学习到的,NeRF 以非常不同寻常的方式使用神经网络。这个概念最初是由来自 UC Berkeley、Google Research 和 UC San Diego 的研究团队开发的。由于它不寻常的神经网络使用方式以及所学模型的质量,它在视图合成、深度感知和 3D 重建等领域催生了多项新发明。因此,理解这个概念对你继续阅读本章及本书至关重要。
本节中,我们首先将探讨辐射场的含义,以及如何使用神经网络来表示这些辐射场。
什么是辐射场?
在我们讨论 NeRF 之前,让我们首先了解辐射场是什么。当光从某个物体反射并被你的感官系统处理时,你就能看到该物体。物体的光可以由物体本身产生,也可以是反射自物体的光。
辐射是衡量通过或从特定固体角度内的区域发出的光量的标准度量。就我们而言,辐射可以视为在特定方向上观察时,空间中某一点的光强度。当以 RGB 方式捕捉这一信息时,辐射将具有三个分量,分别对应红色、绿色和蓝色。空间中某一点的辐射可能取决于许多因素,包括以下内容:
-
照亮该点的光源
-
该点处是否存在可以反射光线的表面(或体积密度)
-
表面的纹理属性
以下图示展示了在特定角度观察时,3D 场景中某一点的辐射值。辐射场就是这些辐射值在 3D 场景中的所有点和观察角度上的集合:
图 6.1:在特定观察角度(θ,∅)下,点(x,y,z)处的辐射(r,g,b)
如果我们知道场景中所有点在所有方向上的辐射,我们就拥有了关于该场景的所有视觉信息。这个辐射值的场景就是辐射场。我们可以将辐射场信息存储为 3D 体素网格数据结构中的一个体积。我们在前一章讨论体积渲染时见过这一点。
使用神经网络表示辐射场
本节中,我们将探讨一种使用神经网络的新方式。在典型的计算机视觉任务中,我们使用神经网络将输入的像素空间映射到输出。对于判别模型,输出是一个类别标签;对于生成模型,输出也在像素空间中。而 NeRF 模型既不是前者,也不是后者。
NeRF 使用神经网络来表示一个体积场景函数。这个神经网络接受一个 5 维输入,分别是三个空间位置(x, y, z)和两个视角(θ, ∅)。它的输出是在(x, y, z)处的体积密度 σ 和从视角(θ, ∅)观察到的点(x, y, z)的发射颜色(r, g, b)。发射的颜色是用来估算该点辐射强度的代理。在实际应用中,NeRF 不直接使用(θ, ∅)来表示视角,而是使用 3D 笛卡尔坐标系中的单位方向向量 d。这两种表示方式是等效的视角表示。
因此,该模型将 3D 场景中的任何点和一个视角映射到该点的体积密度和辐射强度。然后,你可以使用这个模型通过查询相机射线上的 5D 坐标,利用你在前一章中学习的体积渲染技术将输出颜色和体积密度投影到图像中,从而合成视图。
在下图中,我们将展示如何使用神经网络预测某一点(x, y, z)在沿某个方向(θ, ∅)视角下的密度和辐射强度:
图 6.2:输入(x, y, z, θ 和 ∅)首先用于为空间位置和视角创建单独的谐波嵌入,然后形成神经网络的输入,神经网络输出预测的密度和辐射强度
注意,这是一种全连接网络——通常我们称之为多层感知器(MLP)。更重要的是,这不是卷积神经网络。我们将这种模型称为 NeRF 模型。单个 NeRF 模型是在单一场景的一组图像上进行优化的。因此,每个模型只了解它所优化的场景。这与我们通常需要模型对未见过的图像进行泛化的标准神经网络使用方式不同。在 NeRF 的情况下,我们需要网络能够很好地对未见过的视角进行泛化。
现在你已经了解了什么是 NeRF,接下来我们来看看如何使用它渲染新的视图。
训练一个 NeRF 模型
在本节中,我们将训练一个简单的 NeRF 模型,使用从合成牛模型生成的图像。我们只会实例化 NeRF 模型,而不关心它是如何实现的。实现细节将在下一节中介绍。一个单一的神经网络(NeRF 模型)被训练来表示单一的 3D 场景。以下代码可以在train_nerf.py
中找到,该文件位于本章的 GitHub 仓库中。它是从 PyTorch3D 教程修改而来的。让我们通过这段代码,在合成牛场景上训练一个 NeRF 模型:
-
首先,让我们导入标准模块:
import torch import matplotlib.pyplot as plt
-
接下来,让我们导入用于渲染的函数和类。这些是
pytorch3d
数据结构:from pytorch3d.renderer import ( FoVPerspectiveCameras, NDCMultinomialRaysampler, MonteCarloRaysampler, EmissionAbsorptionRaymarcher, ImplicitRenderer, ) from utils.helper_functions import (generate_rotating_nerf, huber, sample_images_at_mc_locs) from nerf_model import NeuralRadianceField
-
接下来,我们需要设置设备:
if torch.cuda.is_available(): device = torch.device("cuda:0") torch.cuda.set_device(device) else: device = torch.device("cpu")
-
接下来,让我们导入一些实用函数,用于生成合成训练数据并可视化图像:
from utils.plot_image_grid import image_grid from utils.generate_cow_renders import generate_cow_renders
-
我们现在可以使用这些实用函数从多个不同角度生成合成牛的相机角度、图像和轮廓。这将打印生成的图像、轮廓和相机角度的数量:
target_cameras, target_images, target_silhouettes = generate_cow_renders(num_views=40, azimuth_range=180) print(f'Generated {len(target_images)} images/silhouettes/cameras.')
-
就像我们在上一章做的那样,让我们定义一个光线采样器。我们将使用
MonteCarloRaysampler
。它从图像平面上的随机子集像素生成光线。我们在这里需要一个随机采样器,因为我们想使用小批量梯度下降算法来优化模型。这是一种标准的神经网络优化技术。系统地采样光线可能会在每一步优化时导致优化偏差,从而导致更差的模型,并增加模型训练时间。光线采样器沿着光线均匀采样点:render_size = target_images.shape[1] * 2 volume_extent_world = 3.0 raysampler_mc = MonteCarloRaysampler( min_x = -1.0, max_x = 1.0, min_y = -1.0, max_y = 1.0, n_rays_per_image=750, n_pts_per_ray=128, min_depth=0.1, max_depth=volume_extent_world, )
-
接下来,我们将定义光线行进器。它使用沿光线采样的点的体积密度和颜色,并渲染该光线的像素值。对于光线行进器,我们使用
EmissionAbsorptionRaymarcher
。它实现了经典的发射-吸收光线行进算法:raymarcher = EmissionAbsorptionRaymarcher()
-
我们现在将实例化
ImplicitRenderer
。它将光线采样器和光线行进器组合成一个单一的数据结构:renderer_mc = ImplicitRenderer(raysampler=raysampler_mc, raymarcher=raymarcher)
-
让我们来看一下 Huber 损失函数。它在
utils.helper_functions.huber
中定义,是均方误差函数的稳健替代,且对异常值不那么敏感:def huber(x, y, scaling=0.1): diff_sq = (x - y) ** 2 loss = ((1 + diff_sq / (scaling**2)).clamp(1e-4).sqrt() - 1) * float(scaling) return loss
-
我们现在来看一个定义在
utils.helper_functions.sample_images_at_mc_loss
中的辅助函数,它用于从目标图像中提取地面真实像素值。MonteCarloRaysampler
会采样经过图像中某些x
和y
位置的光线。这些位置在torch.nn.functional.grid_sample
函数中。该函数使用插值技术在后台提供准确的像素值。这比仅仅将 NDC 坐标映射到像素坐标并采样与 NDC 坐标值对应的单个像素要好。在 NDC 坐标系中,x
和y
的范围都是[-1, 1]
。例如,(x, y) = (-1, -1)对应图像的左上角:def sample_images_at_mc_locs(target_images, sampled_rays_xy): ba = target_images.shape[0] dim = target_images.shape[-1] spatial_size = sampled_rays_xy.shape[1:-1] images_sampled = torch.nn.functional.grid_sample( target_images.permute(0, 3, 1, 2), -sampled_rays_xy.view(ba, -1, 1, 2), # note the sign inversion align_corners=True ) return images_sampled.permute(0, 2, 3, 1).view( ba, *spatial_size, dim )
-
在训练模型时,查看模型输出总是很有用的。除了其他许多用途外,这将帮助我们进行调整,如果我们发现模型输出随着时间的推移没有变化。到目前为止,我们使用了
MonteCarloRaysampler
,它在训练模型时非常有用,但当我们想要渲染完整图像时,它就不再有用了,因为它是随机采样光线。为了查看完整的图像,我们需要系统地采样对应输出帧中所有像素的光线。为此,我们将使用NDCMultinomialRaysampler
:render_size = target_images.shape[1] * 2 volume_extent_world = 3.0 raysampler_grid = NDCMultinomialRaysampler( image_height=render_size, image_width=render_size, n_pts_per_ray=128, min_depth=0.1, max_depth=volume_extent_world, )
-
我们现在将实例化隐式渲染器:
renderer_grid = ImplicitRenderer( raysampler=raysampler_grid, raymarcher=raymarcher, )
-
为了可视化中间训练结果,我们定义了一个辅助函数,该函数以模型和相机参数为输入,并将其与目标图像及相应的轮廓进行比较。如果渲染图像非常大,可能无法一次性将所有光线装入 GPU 内存。因此,我们需要将它们分成批次,并在模型上运行多次前向传播以获取输出。我们需要将渲染的输出合并成一个连贯的图像。为了简化,我们将在这里导入该函数,但完整代码已提供在本书的 GitHub 仓库中:
from utils.helper_function import show_full_render
-
现在我们将实例化 NeRF 模型。为了简化起见,我们在这里不展示模型的类定义。你可以在本章的 GitHub 仓库中找到它。由于模型结构非常重要,我们将在单独的章节中详细讨论它:
from nerf_model import NeuralRadianceField neural_radiance_field = NeuralRadianceField()
-
现在我们准备开始训练模型。为了重现训练过程,我们应将
torch
中使用的随机种子设置为固定值。然后,我们需要将所有变量发送到用于处理的设备上。由于这是一个资源密集型的计算问题,理想情况下我们应在支持 GPU 的机器上运行它。在 CPU 上运行会非常耗时,不推荐使用:torch.manual_seed(1) renderer_grid = renderer_grid.to(device) renderer_mc = renderer_mc.to(device) target_cameras = target_cameras.to(device) target_images = target_images.to(device) target_silhouettes = target_silhouettes.to(device) neural_radiance_field = neural_radiance_field.to(device)
-
现在我们将定义用于训练模型的超参数。
lr
表示学习率,n_iter
表示训练迭代次数(或步骤),batch_size
表示在每个小批量中使用的随机相机数。这里的批量大小根据你的 GPU 内存来选择。如果你发现 GPU 内存不足,请选择一个较小的批量大小值:lr = 1e-3 optimizer = torch.optim.Adam(neural_radiance_field.parameters(), lr=lr) batch_size = 6 n_iter = 3000
-
现在我们准备训练模型了。在每次迭代中,我们应该随机采样一个小批量的相机:
loss_history_color, loss_history_sil = [], [] for iteration in range(n_iter): if iteration == round(n_iter * 0.75): print('Decreasing LR 10-fold ...') optimizer = torch.optim.Adam( neural_radiance_field.parameters(), lr=lr * 0.1 ) optimizer.zero_grad() batch_idx = torch.randperm(len(target_cameras))[:batch_size] batch_cameras = FoVPerspectiveCameras( R = target_cameras.R[batch_idx], T = target_cameras.T[batch_idx], znear = target_cameras.znear[batch_idx], zfar = target_cameras.zfar[batch_idx], aspect_ratio = target_cameras.aspect_ratio[batch_idx], fov = target_cameras.fov[batch_idx], device = device, )
-
在每次迭代中,首先,我们需要使用 NeRF 模型在随机采样的相机上获取渲染的像素值和渲染的轮廓。这些是预测值。这是前向传播步骤。我们想要将这些预测与真实值进行比较,以找出训练损失。我们的损失是两个损失函数的混合:A,基于预测轮廓和真实轮廓的 Huber 损失,以及 B,基于预测颜色和真实颜色的 Huber 损失。一旦我们得到损失值,我们可以通过 NeRF 模型进行反向传播,并使用优化器进行步进:
rendered_images_silhouettes, sampled_rays = renderer_mc( cameras=batch_cameras, volumetric_function=neural_radiance_field ) rendered_images, rendered_silhouettes = ( rendered_images_silhouettes.split([3, 1], dim=-1) ) silhouettes_at_rays = sample_images_at_mc_locs( target_silhouettes[batch_idx, ..., None], sampled_rays.xys ) sil_err = huber( rendered_silhouettes, silhouettes_at_rays, ).abs().mean() colors_at_rays = sample_images_at_mc_locs( target_images[batch_idx], sampled_rays.xys ) color_err = huber( rendered_images, colors_at_rays, ).abs().mean() loss = color_err + sil_err loss_history_color.append(float(color_err)) loss_history_sil.append(float(sil_err)) loss.backward() optimizer.step()
-
让我们在每 100 次迭代后可视化模型的表现。这将帮助我们跟踪模型进展,并在发生意外情况时终止训练。这会在运行代码的相同文件夹中生成图像:
if iteration % 100 == 0: show_idx = torch.randperm(len(target_cameras))[:1] fig = show_full_render( neural_radiance_field, FoVPerspectiveCameras( R = target_cameras.R[show_idx], T = target_cameras.T[show_idx], znear = target_cameras.znear[show_idx], zfar = target_cameras.zfar[show_idx], aspect_ratio = target_cameras.aspect_ratio[show_idx], fov = target_cameras.fov[show_idx], device = device), target_images[show_idx][0], target_silhouettes[show_idx][0], renderer_grid, loss_history_color, loss_history_sil) fig.savefig(f'intermediate_{iteration}')
图 6.3:用于跟踪模型训练的中间可视化
-
优化完成后,我们将使用最终的体积模型并从新角度渲染图像:
from utils import generate_rotating_nerf with torch.no_grad(): rotating_nerf_frames = generate_rotating_nerf(neural_radiance_field, n_frames=3*5) image_grid(rotating_nerf_frames.clamp(0., 1.).cpu().numpy(), rows=3, cols=5, rgb=True, fill=True) plt.show()
最终,新的渲染图像显示在这里的图中:
图 6.4:我们 NeRF 模型学习到的合成牛场景的渲染图像
我们在这一部分训练了一个 NeRF 模型,使用的是合成牛场景。接下来的部分,我们将通过更详细地分析代码来深入了解 NeRF 模型的实现。
理解 NeRF 模型架构
到目前为止,我们在没有完全了解 NeRF 模型的情况下使用了该模型类。在本节中,我们将首先可视化神经网络的外观,然后详细分析代码并理解它是如何实现的。
神经网络以空间位置 (x, y, z) 的谐波嵌入和 (θ, ∅) 的谐波嵌入作为输入,并输出预测的密度 σ 和预测的颜色 (r, g, b)。下图展示了我们将在本节中实现的网络架构:
图 6.5:NeRF 模型的简化模型架构
注意
我们要实现的模型架构与原始的 NeRF 模型架构不同。在这个实现中,我们正在实现其简化版本。这个简化架构使得训练更快、更容易。
让我们开始定义 NeuralRadianceField
类。我们将详细介绍该类定义的不同部分。有关该类的完整定义,请参考 GitHub 仓库中的代码:
- 每个输入点是一个 5 维向量。研究发现,直接在这个输入上训练模型在表示颜色和几何形状的高频变化时表现不佳。这是因为神经网络已知偏向于学习低频函数。解决此问题的一个好方法是将输入空间映射到更高维空间并利用该空间进行训练。这个映射函数是一组具有固定但唯一频率的正弦函数:
-
该函数应用于输入向量的每个组件:
class NeuralRadianceField(torch.nn.Module): def __init__(self, n_harmonic_functions=60, n_hidden_neurons=256): super().__init__() self.harmonic_embedding = HarmonicEmbedding(n_harmonic_functions)
-
神经网络由 MLP 主干组成。它接受位置 (x, y, z) 的嵌入作为输入。这是一个全连接网络,使用的激活函数是
softplus
。softplus
函数是 ReLU 激活函数的平滑版本。主干的输出是一个大小为n_hidden_neurons
的向量:embedding_dim = n_harmonic_functions * 2 * 3 self.mlp = torch.nn.Sequential( torch.nn.Linear(embedding_dim, n_hidden_neurons), torch.nn.Softplus(beta=10.0), torch.nn.Linear(n_hidden_neurons, n_hidden_neurons), torch.nn.Softplus(beta=10.0), )
-
我们定义了一个颜色层,它接受 MLP 主干的输出嵌入与射线方向输入嵌入,并输出输入的 RGB 颜色。我们将这些输入结合起来,因为颜色输出强烈依赖于点的位置和观察方向,因此提供更短的路径以充分利用这个神经网络非常重要:
self.color_layer = torch.nn.Sequential( torch.nn.Linear(n_hidden_neurons + embedding_dim, n_hidden_neurons), torch.nn.Softplus(beta=10.0), torch.nn.Linear(n_hidden_neurons, 3), torch.nn.Sigmoid(), )
-
接下来,我们定义
density
层。一个点的密度仅是其位置的函数:self.density_layer = torch.nn.Sequential( torch.nn.Linear(n_hidden_neurons, 1), torch.nn.Softplus(beta=10.0), self.density_layer[0].bias.data[0] = -1.5
-
现在,我们需要一个函数来获取
density_layer
的输出,并预测原始密度:def _get_densities(self, features): raw_densities = self.density_layer(features) return 1 - (-raw_densities).exp()
-
我们对给定光线方向的某一点颜色的获取也做了相同的处理。我们需要先对光线方向输入应用位置编码函数。然后,我们应将其与 MLP 主干的输出进行拼接:
def _get_colors(self, features, rays_directions): spatial_size = features.shape[:-1] rays_directions_normed = torch.nn.functional.normalize( rays_directions, dim=-1 ) rays_embedding = self.harmonic_embedding( rays_directions_normed ) rays_embedding_expand = rays_embedding[..., None, :].expand( *spatial_size, rays_embedding.shape[-1] ) color_layer_input = torch.cat( (features, rays_embedding_expand), dim=-1 ) return self.color_layer(color_layer_input)
-
我们定义了前向传播的函数。首先,我们获得嵌入(embeddings)。然后,我们通过 MLP 主干传递它们,以获得一组特征。接着,我们利用这些特征来获得密度。我们使用特征和光线方向来获得颜色。最终,我们返回密度和颜色:
def forward( self, ray_bundle: RayBundle, **kwargs, ): rays_points_world = ray_bundle_to_ray_points(ray_bundle) embeds = self.harmonic_embedding( rays_points_world ) features = self.mlp(embeds) rays_densities = self._get_densities(features) # rays_densities.shape = [minibatch x ... x 1] rays_colors = self._get_colors(features, ray_bundle.directions) return rays_densities, rays_colors
-
该函数用于实现输入光线的内存高效处理。首先,输入的光线被分成
n_batches
块,并通过self.forward
函数逐一传递,在for
循环中执行。结合禁用 PyTorch 梯度缓存(torch.no_grad()
),这使我们能够渲染大批量的光线,即使这些光线无法完全适应 GPU 内存,也能通过单次前向传播来处理。在我们的案例中,batched_forward
用于导出辐射场的完整渲染图,以便进行可视化:def batched_forward( self, ray_bundle: RayBundle, n_batches: int = 16, **kwargs, ): n_pts_per_ray = ray_bundle.lengths.shape[-1] spatial_size = [*ray_bundle.origins.shape[:-1], n_pts_per_ray] # Split the rays to `n_batches` batches. tot_samples = ray_bundle.origins.shape[:-1].numel() batches = torch.chunk(torch.arange(tot_samples), n_batches)
-
对于每一批次,我们需要先进行一次前向传播,然后分别提取
ray_densities
和ray_colors
,作为输出返回:batch_outputs = [ self.forward( RayBundle( origins=ray_bundle.origins.view(-1, 3)[batch_idx], directions=ray_bundle.directions.view(-1, 3)[batch_idx], lengths=ray_bundle.lengths.view(-1, n_pts_per_ray)[batch_idx], xys=None, ) ) for batch_idx in batches ] rays_densities, rays_colors = [ torch.cat( [batch_output[output_i] for batch_output in batch_outputs], dim=0 ).view(*spatial_size, -1) for output_i in (0, 1)] return rays_densities, rays_colors
在本节中,我们介绍了 NeRF 模型的实现。为了全面理解 NeRF,我们还需要探索其在体积渲染中的理论概念。在接下来的部分中,我们将更详细地探讨这一点。
理解使用辐射场的体积渲染
卷积渲染允许你创建 3D 图像或场景的 2D 投影。在本节中,我们将学习如何从不同的视角渲染 3D 场景。为了本节的目的,假设 NeRF 模型已经完全训练,并且能够准确地将输入坐标(x, y, z, dx, dy, dz)映射到输出(r, g, b, σ)。以下是这些输入和输出坐标的定义:
-
(x, y, z):世界坐标系中 3D 场景中的一点
-
(dx, dy, dz):这是一个单位向量,表示我们观察点(x, y, z)时的视线方向
-
(r, g, b):这是点(x, y, z)处的辐射值(或发射颜色)
-
σ:点(x, y, z)处的体积密度
在上一章中,你了解了体积渲染的基本概念。你使用射线采样技术从体积中获取体积密度和颜色。我们称这种操作为体积采样。在本章中,我们将对辐射场使用射线采样技术,获取体积密度和颜色。然后,我们可以执行射线行进(ray marching)来获得该点的颜色强度。上一章中使用的射线行进技术与本章中使用的在概念上是类似的。区别在于,3D 体素是 3D 空间的离散表示,而辐射场是其连续表示(因为我们使用神经网络来编码该表示)。这稍微改变了我们沿射线积累颜色强度的方式。
将射线投射到场景中
想象一下将一个相机放置在一个视点,并将其指向感兴趣的 3D 场景。这就是 NeRF 模型训练的场景。为了合成该场景的 2D 投影,我们首先从视点发射一条射线进入 3D 场景。
射线可以按如下方式参数化:
这里,r 是从原点 o 开始并沿方向 d 传播的射线。它由 t 参数化,可以通过改变 t 的值来移动到射线上的不同点。注意,r 是一个 3D 向量,表示空间中的一个点。
积累射线的颜色
我们可以使用一些知名的经典颜色渲染技术来渲染射线的颜色。在我们这样做之前,让我们先对一些标准定义有所了解:
-
假设我们想要积累位于 tn(近边界)和 tf(远边界)之间的射线颜色。我们不关心射线在这些边界之外的部分。
-
我们可以将体积密度σ(r(t))看作是射线在 r(t)附近的一个无限小点处终止的概率。
-
我们可以将
看作是沿 d 方向观察射线在点 r(t)处的颜色。
-
将测量 tn 与某一点 t 之间的累计体积密度。
-
将为我们提供从 tn 到某一点 t 沿射线的累计透射率的概念。体积密度越高,累计透射率到达点 t 时就越低。
-
射线的期望颜色现在可以定义如下:
重要说明
体积密度σ(r(t))是点 r(t)的一个函数。最重要的是,这不依赖于方向向量 d。这是因为体积密度是一个依赖于物理位置的函数,而不是方向。颜色 是点 r(t)和射线方向 d 的函数。这是因为同一点在不同方向观察时可能呈现不同的颜色。
我们的 NeRF 模型是一个连续函数,表示场景的辐射场。我们可以使用它来获取不同射线上的 c(r(t), d) 和 σ(r(t))。有许多技术可以用来数值估算积分 C(r)。在训练和可视化 NeRF 模型的输出时,我们使用了标准的 EmissionAbsorptionRaymarcher
方法来沿射线积累辐射。
总结
在本章中,我们了解了如何使用神经网络来建模和表示 3D 场景。这个神经网络被称为 NeRF 模型。我们随后在一个合成的 3D 场景上训练了一个简单的 NeRF 模型。接着,我们深入探讨了 NeRF 模型的架构及其在代码中的实现。我们还了解了模型的主要组成部分。然后,我们理解了使用 NeRF 模型渲染体积的原理。NeRF 模型用于捕捉单一场景。一旦我们建立了这个模型,就可以用它从不同的角度渲染这个 3D 场景。自然地,我们会想知道,是否有一种方法可以用一个模型捕捉多个场景,并且是否可以在场景中可预测地操控某些物体和属性。这是我们在下一章探索的话题,我们将在其中探讨 GIRAFFE 模型。
第三部分:使用 PyTorch3D 的最先进 3D 深度学习
本书的这一部分将全程介绍如何使用 PyTorch3D 来实现最先进的 3D 深度学习模型和算法。近年来,3D 计算机视觉技术正在快速进步,我们将学习如何以最佳方式实现和使用这些最先进的 3D 深度学习模型。
本部分包括以下章节:
-
第七章,探索可控神经特征场
-
第八章**,3D 人体建模
-
第九章**,使用 SynSin 进行端到端视图合成
-
第十章**,Mesh R-CNN
第八章:探索可控神经特征字段
在上一章中,你学习了如何使用神经辐射场(NeRF)表示 3D 场景。我们在一个 3D 场景的多视角图像上训练了一个单一的神经网络,来学习其隐式表示。然后,我们使用 NeRF 模型从不同的视点和视角渲染 3D 场景。在这个模型中,我们假设物体和背景是固定不变的。
但值得怀疑的是,是否可以生成 3D 场景的变化。我们能控制物体的数量、姿态和场景背景吗?我们能在没有摆姿势图像和不了解相机参数的情况下,学习事物的 3D 特性吗?
到本章结束时,你将了解到,确实可以做所有这些事情。具体来说,你应该能更好地理解 GIRAFFE,这是一种非常新颖的可控 3D 图像合成方法。它结合了图像合成和使用类似 NeRF 模型的隐式 3D 表示学习领域的思想。随着我们讨论以下主题,这一点将变得清晰:
-
理解基于 GAN 的图像合成
-
引入组合式 3D 感知图像合成
-
生成特征字段
-
将特征字段映射到图像
-
探索可控场景生成
-
训练 GIRAFFE 模型
技术要求
为了运行本书中的示例代码片段,理想情况下,你需要一台配备大约 8GB GPU 内存的计算机。仅使用 CPU 运行代码片段虽然不是不可能,但会非常慢。推荐的计算机配置如下:
-
一台 GPU 设备——例如,Nvidia GTX 系列或 RTX 系列,至少 8GB 内存
-
Python 3.7+
-
Anaconda3
本章的代码片段可以在github.com/PacktPublishing/3D-Deep-Learning-with-Python
找到。
理解基于 GAN 的图像合成
深度生成模型已被证明在训练特定领域的数据分布时,可以生成照片级逼真的 2D 图像。生成对抗网络(GANs)是最广泛使用的框架之一。它们可以以 1,024 x 1,024 及更高的分辨率合成高质量的逼真图像。例如,它们已经被用来生成逼真的人脸:
图 7.1:使用 StyleGAN2 随机生成的高质量 2D 图像的面孔
GANs 可以通过训练生成任何数据分布的相似图像。同样的 StyleGAN2 模型,当在汽车数据集上训练时,可以生成高分辨率的汽车图像:
图 7.2:使用 StyleGAN2 随机生成的汽车作为 2D 图像
GAN(生成对抗网络)基于博弈论场景,其中生成器神经网络生成图像。然而,为了成功,生成器必须欺骗判别器,使其将图像分类为真实的图像。生成器和判别器之间的这种博弈可以促使生成器生成逼真的图像。生成器通过在多维潜在空间上创建概率分布来实现这一点,使得该分布上的点是来自训练图像领域的真实图像。为了生成新的图像,我们只需要从潜在空间中采样一个点,让生成器从中创建图像:
图 7.3:经典 GAN
合成高分辨率的逼真图像固然重要,但这并不是生成模型唯一期望的特性。如果生成过程是可解耦且能够简单、可预测地进行控制,更多的现实应用就会打开。更重要的是,我们需要像物体形状、大小和姿势等属性尽可能解耦,这样我们就可以在不改变图像中其他属性的情况下改变这些属性。
现有基于 GAN 的图像生成方法生成的是 2D 图像,并没有真正理解图像背后的 3D 特性。因此,缺少对物体位置、形状、大小和姿势等属性的明确控制。这导致了 GAN 模型生成的图像属性之间的纠缠。例如,考虑一个生成逼真面部图像的 GAN 模型,在这个模型中,改变头部姿势也会改变生成面部的性别。这种情况发生的原因是性别和头部姿势属性变得纠缠在一起。这对于大多数实际应用场景来说是不可取的。我们需要能够在不影响其他属性的情况下改变某个属性。
在接下来的章节中,我们将概览一个可以生成 2D 图像,并隐含理解底层场景 3D 特性的模型。
引入组合式的 3D 感知图像合成
我们的目标是可控的图像合成。我们需要对图像中的物体数量、位置、形状、大小和姿势进行控制。GIRAFFE 模型是首批实现这些理想特性之一,同时还能生成高分辨率的逼真图像。为了对这些属性进行控制,模型必须对场景的 3D 特性有一定的认知。
现在,让我们来看一下 GIRAFFE 模型是如何在其他已知理念的基础上构建的。它利用了以下几个高级概念:
-
学习 3D 表示:一种类似 NeRF 的模型,用于学习隐式 3D 表示和特征场。与标准的 NeRF 模型不同,该模型输出的是特征场而不是颜色强度。这个类似 NeRF 的模型用于在生成的图像中强制执行 3D 一致性。
-
组合运算符:一个无参数的组合运算符,用于将多个物体的特征场组合成一个单一的特征场。这有助于生成包含所需物体数量的图像。
-
神经渲染模型:该模型使用组合的特征场来创建图像。这是一个 2D 卷积神经网络(CNN),它将特征场上采样以生成更高维度的输出图像。
-
生成对抗网络(GAN):GIRAFFE 模型使用 GAN 架构来生成新场景。前面提到的三个组件组成了生成器。该模型还包括一个判别神经网络,用于区分假图像和真实图像。由于包含了 NeRF 模型以及组合运算符,这个模型使得图像生成过程既具组合性又具有 3D 感知能力。
生成图像是一个两步过程:
-
基于相机视角和你想渲染的物体的一些信息来进行体积渲染。这个物体信息是一些抽象向量,你将在后续章节中了解。
-
使用神经渲染模型将特征场映射到高分辨率图像。
这种两步法被发现比直接从 NeRF 模型输出生成 RGB 值更适合生成高分辨率图像。从前一章我们知道,NeRF 模型是通过同一场景的图像进行训练的。训练好的模型只能生成来自同一场景的图像。这是 NeRF 模型的一个大限制。
相比之下,GIRAFFE 模型是在来自不同场景的无姿态图像上进行训练的。一个训练好的模型可以生成与其训练数据分布相同的图像。通常,该模型是在相同类型的数据上进行训练的。也就是说,训练数据分布来自单一领域。例如,如果我们在汽车数据集上训练模型,我们可以预期该模型生成的图像将是某种形式的汽车。它不能生成完全未见过的分布的图像,例如人脸。虽然这是该模型的一个局限性,但与标准的 NeRF 模型相比,它的限制要小得多。
到目前为止,我们讨论过的 GIRAFFE 模型中实现的基本概念总结如下图所示:
图 7.4:GIRAFFE 模型
生成器模型使用选择的相机姿势和N(物体的数量,包括背景),以及相应数量的形状和外观编码与仿射变换,首先合成特征场。然后,将对应单个物体的各个特征场合成在一起,形成一个汇总特征场。接着,它使用体积渲染的标准原理沿光线对特征场进行体积渲染。随后,一个神经渲染网络将该特征场转换为图像空间中的像素值。
在这一部分中,我们对 GIRAFFE 模型有了一个非常广泛的了解。现在让我们深入探讨它的各个组成部分,进一步理解它。
生成特征场
场景生成过程的第一步是生成特征场。这类似于在 NeRF 模型中生成 RGB 图像。在 NeRF 模型中,模型的输出是一个特征场,恰好是由 RGB 值构成的图像。然而,特征场可以是图像的任何抽象概念。它是图像矩阵的一个推广。这里的区别在于,GIRAFFE 模型不是生成一个三通道的 RGB 图像,而是生成一个更抽象的图像,我们称之为特征场,其维度为 HV、WV 和 Mf,其中 HV 是特征场的高度,WV 是其宽度,Mf 是特征场中的通道数。
对于这一部分,假设我们已经有一个训练好的 GIRAFFE 模型。它已经在某个预定义的数据集上进行了训练,当前我们不需要考虑这个数据集。要生成新图像,我们需要完成以下三件事:
-
指定相机姿势:这定义了相机的视角。作为预处理步骤,我们使用该相机姿势向场景投射一条光线,并生成一个方向向量(dj)以及采样点(xij)。我们将向场景投射许多这样的光线。
-
采样 2N 个潜在编码:我们为每个希望在渲染输出图像中看到的物体采样两个潜在编码。一个潜在编码对应物体的形状,另一个潜在编码对应其外观。这些编码是从标准正态分布中采样的。
-
指定N仿射变换:这对应于物体在场景中的姿势。
模型的生成部分执行以下操作:
-
对于场景中每个预期的物体,使用形状编码、外观编码、物体的姿势信息(即仿射变换)、视角方向向量和场景中的一个点(xij)来生成该点的特征场(一个向量)和体积密度。这就是 NeRF 模型的工作原理。
-
使用组合运算符将这些特征场和密度合成一个单一的特征场和密度值。这时,组合运算符执行以下操作:
点处的体积密度可以简单地求和。特征场通过将重要性分配给该点上物体的体积密度来进行平均。这样简单的算子有一个重要的优点,那就是它是可微的。因此,它可以被引入到神经网络中,因为在模型训练阶段,梯度可以通过这个算子进行传播。
- 我们使用体积渲染来渲染每条射线的特征场,这些射线是通过聚合沿射线方向的特征场值生成的,输入相机视角也会影响射线的生成。我们为多条射线执行此操作,生成一个维度为 HV x WV 的完整特征场。在这里,V 通常是一个较小的值。所以,我们实际上是在创建一个低分辨率的特征场。
特征场
特征场是图像的抽象概念。它们不是 RGB 值,通常具有较低的空间维度(例如 16 x 16 或 64 x 64),但通道维度较高。我们需要一张空间维度较高的图像(例如 512 x 512),但通道数为三(RGB)。让我们看看如何使用神经网络实现这一点。
将特征场映射到图像
当我们生成维度为 HV x WV x Mf 的特征场后,我们需要将其映射到维度为 H x W x 3 的图像。通常情况下,HV < H,WV < W,且 Mf > 3。GIRAFFE 模型使用两阶段方法,因为消融分析表明,这比直接使用单阶段方法生成图像更好。
映射操作是一个可以通过数据学习的参数化函数,使用 2D 卷积神经网络(CNN)最适合完成此任务,因为它是图像域中的一个函数。你可以将这个函数视为一个上采样神经网络,类似于自编码器中的解码器。这个神经网络的输出是我们可以看到、理解和评估的渲染图像。从数学上讲,可以定义如下:
该神经网络由一系列上采样层组成,通过n个最近邻上采样块完成,之后是 3 x 3 卷积和泄漏 ReLU。这样就创建了一系列n个不同空间分辨率的特征场。然而,在每个空间分辨率中,特征场会通过 3 x 3 卷积映射到一个三通道图像,该图像具有相同的空间分辨率。同时,来自前一空间分辨率的图像会使用一个非参数的双线性上采样算子进行上采样,并添加到新空间分辨率的图像中。这一过程会一直重复,直到达到所需的空间分辨率 H x W。
从特征场到相似维度图像的跳跃连接有助于在每个空间分辨率中为特征场提供强的梯度流。直观地说,这确保了神经渲染模型在每个空间分辨率上都对图像有很强的理解。此外,跳跃连接确保生成的最终图像是各个分辨率下图像理解的组合。
这个概念通过以下神经渲染模型的图示变得非常清晰:
图 7.5:神经渲染模型;这是一个 2D CNN,具有一系列最近邻上采样操作符,并与 RGB 图像域进行并行映射
神经渲染模型接收来自前一阶段的特征场输出,并生成高分辨率的 RGB 图像。由于特征场是使用基于 NeRF 的生成器生成的,它应该能够理解场景的三维特性、场景中的物体及其位置、姿势、形状和外观。并且由于我们使用了组合操作符,特征场还编码了场景中物体的数量。
在接下来的章节中,您将了解我们如何控制场景生成过程,以及我们为实现这一目标所拥有的控制机制。
探索可控场景生成
要真正理解和学习计算机视觉模型生成的内容,我们需要可视化训练模型的输出。由于我们处理的是生成式方法,可以通过简单地可视化模型生成的图像来做到这一点。在本节中,我们将探索预训练的 GIRAFFE 模型,并查看它们生成可控场景的能力。我们将使用 GIRAFFE 模型创建者提供的预训练检查点。本节提供的指令基于开源的 GitHub 代码库,github.com/autonomousvision/giraffe
。
使用以下命令创建名为giraffe
的 Anaconda 环境:
$ cd chap7/giraffe
$ conda env create -f environment.yml
$ conda activate giraffe
一旦conda
环境被激活,您就可以使用相应的预训练检查点开始为各种数据集渲染图像。GIRAFFE 模型的创建者已共享来自五个不同数据集的预训练模型:
-
汽车数据集:该数据集包含 136,726 张 196 种车型的图片。
-
CelebA-HQ 数据集:该数据集包含从原始CelebA数据集中选择的 30,000 张高分辨率面部图像。
-
LSUN 教堂数据集:该数据集包含约 126,227 张教堂的图片。
-
CLEVR 数据集:这是一个主要用于视觉问答研究的数据集,包含 54,336 张不同大小、形状和位置的物体图片。
-
Flickr-Faces-HQ 数据集:该数据集包含从 Flickr 获取的 70,000 张高质量的面部图片。
我们将探索在两个不同数据集上的模型输出,以便更好地理解它们。
探索可控的汽车生成
在本小节中,我们将探索一个在Cars数据集上训练的模型。提供给模型的外观和形状代码将生成汽车,因为该模型是基于汽车数据集进行训练的。您可以运行以下命令来生成图像样本:
$ python render.py configs/256res/cars_256_pretrained.yaml
这里,config
文件指定了存储生成图像的输出文件夹路径。render.py
脚本将自动下载 GIRAFFE 模型的检查点并用它们渲染图像。输出图像存储在 out/cars256_pretrained/rendering
中。此文件夹将包含以下子文件夹:
- out
- cars256_pretrained
- rendering
- interpolate_app
- interpolate_shape
- translation_object_depth
- interpolate_bg_app
- rotation_object
- translation_object_horizontal
这些文件夹中的每个文件夹包含了当我们改变 GIRAFFE 模型的特定输入时所获得的图像。例如,看看以下内容:
-
interpolate_app
:这是一组图像,用于展示当我们慢慢改变物体外观代码时会发生什么。 -
interpolate_bg_app
:此示例展示了当我们改变背景外观代码时会发生什么。 -
interpolate_shape
:此示例展示了当我们改变物体的形状代码时会发生什么。 -
translation_object_depth
:此示例展示了当我们改变物体的深度时会发生什么。这是仿射变换矩阵代码的一部分,作为输入的一部分。 -
translation_object_horizontal
:此示例展示了当我们希望将物体在图像中横向移动时会发生什么。这是仿射变换矩阵代码的一部分,作为输入的一部分。 -
rotation_object
:此示例展示了当我们希望改变物体的姿态时会发生什么。这是仿射变换矩阵代码的一部分,作为输入的一部分。
让我们看一下 rotation_object
文件夹中的图像并进行分析:
图 7.6:物体旋转模型图像
每一行的图像都是通过首先选择一个外观和形状代码,并改变仿射变换矩阵仅旋转物体来获得的。仿射变换代码中的横向和平移部分保持固定。物体的背景代码、外观和形状代码也保持不变。不同的行是通过使用不同的外观和形状代码获得的。以下是一些观察结果:
-
所有图像的背景在同一物体的不同图像中保持不变。这表明我们已成功将背景从图像的其他部分中分离出来。
-
颜色、反射与阴影:随着物体的旋转,图像的颜色和反射保持一致,符合物理物体旋转的预期。这是典型的,因为使用了类似 NeRF 的模型架构。
-
左右一致性:汽车的左右视角一致。
-
由于高频变化在图像中未能很好地被 GIRAFFE 模型捕捉到,因此出现了一些不自然的伪影,如模糊的物体边缘和涂抹的背景。
你现在可以探索其他文件夹,以了解当对象平移或背景变化时,模型生成的图像的一致性和质量。
探索可控面部生成
在这一小节中,我们将探索一个基于CelebA-HQ数据集训练的模型。提供给模型的外观和形状代码将生成面部,因为该模型是根据这些数据训练的。你可以运行以下命令生成图像样本:
$ python render.py configs/256res/celebahq_256_pretrained.yaml
config
文件指定了生成图像存储的输出文件夹路径。生成的图像存储在out/celebahq_256_pretrained/rendering
中。该文件夹将包含以下子文件夹:
- out
- celebahq_256_pretrained
- rendering
- interpolate_app
- interpolate_shape
- rotation_object
让我们看看interpolate_app
文件夹中的图像并分析它们:
图 7.7:插值应用图像
每一行的图像是通过首先选择一个形状代码,并改变外观代码仅改变面部外观来获得的。仿射变换矩阵代码也保持固定。不同的行是通过使用不同的形状代码获得的。以下是一些观察结果:
-
生成面部的形状在同一行的面部中基本保持不变。这表明形状代码对外观代码的变化具有鲁棒性。
-
面部的外观(如肤色、肤光、发色、眉毛颜色、眼睛颜色、嘴唇表情和鼻子形状)会随着外观代码的变化而变化。这表明外观代码编码了面部外观特征。
-
形状代码编码了面部的感知性别。这是有道理的,因为在训练数据集中,男性和女性面部图像之间的面部形状差异较大。
让我们看看interpolate_shape
文件夹中的图像并分析它们:
图 7.8:插值形状图像
每一行的图像是通过首先选择一个外观代码,并改变形状代码仅改变面部形状来获得的。仿射变换矩阵代码也保持固定。不同的行是通过使用不同的外观代码获得的。以下是一些观察结果:
-
面部的外观(如肤色、肤光、发色、眉毛颜色、眼睛颜色、嘴唇表情和鼻子形状)在形状代码改变时基本保持不变。这表明外观代码对面部形状特征的变化具有鲁棒性。
-
生成的面部形状会随着形状编码的变化而变化。这表明形状编码正确地编码了面部的形状特征。
-
形状编码表示面部的感知性别。这在很大程度上是合理的,因为在训练数据集中,男性和女性面部图像的形状差异较大。
在这一部分,我们探讨了如何使用 GIRAFFE 模型进行可控的 3D 场景生成。我们使用在Cars数据集上训练的模型生成了汽车。此外,我们还使用在CelebA-HQ数据集上训练的模型生成了面部图像。在这些案例中,我们看到模型的输入参数非常清晰地被解耦。我们使用了 GIRAFFE 模型创建者提供的预训练模型。在下一部分,我们将学习如何在新数据集上训练这样的模型。
训练 GIRAFFE 模型
到目前为止,在本章中,我们已经理解了训练好的 GIRAFFE 模型是如何工作的。我们已经理解了构成模型生成器部分的不同组件。
但是,为了训练模型,还有另一个部分是我们到目前为止没有涉及的,即判别器。和其他任何 GAN 模型一样,模型的判别器部分在图像合成时不会使用,但它是训练模型的重要组成部分。在本章中,我们将更详细地研究它,并了解使用的损失函数。我们将使用 GIRAFFE 的作者提供的训练模块从零开始训练一个新模型。
生成器输入的是与物体旋转、背景旋转、相机高度、水平和平移、物体大小相对应的各种潜在编码。这些信息首先用于生成特征场,然后通过神经渲染模块将其映射到 RGB 像素上。这就是生成器。判别器输入两张图像:一张是来自训练数据集的真实图像,另一张是生成器生成的图像。判别器的目标是将真实图像分类为真实,将生成图像分类为假。这就是 GAN 目标。
重要提示
训练数据集没有标签。图像中没有关于物体姿态参数、深度或位置的注释。然而,对于每个数据集,我们大致知道一些参数,如物体旋转率、背景旋转范围、相机高度范围、水平平移、深度平移范围以及物体尺度范围。在训练过程中,输入数据会从这些范围内随机抽取,假设在该范围内均匀分布。
判别器是一个二维卷积神经网络(CNN),它输入一张图像并输出关于真实图像和假图像的置信度分数。
Frechet Inception Distance
为了评估生成图像的质量,我们使用Frechet Inception Distance(FID)。这是一种衡量从真实图像和生成图像中提取的特征之间距离的方法。这不是对单个图像的度量,而是对整个图像集群的统计量。这是我们计算 FID 分数的方法:
-
首先,我们利用 InceptionV3 模型(一种在许多实际应用中使用的流行的深度学习骨干)从图像中提取特征向量。通常,这是分类层之前模型的最后一层。这个特征向量将图像汇总在一个低维空间中。
-
我们提取了整个实际图像和生成图像集的特征向量。
-
我们分别计算实际图像和生成图像集的特征向量的均值和协方差。
-
均值和协方差统计数据在距离公式中用于推导距离度量。
训练模型
让我们看看如何在Cars数据集上启动模型训练:
python train.py .yaml configs/256res/celebahq_256.yaml
可以通过查看配置文件configs/256res/celebahq_256.yaml
来理解训练参数:
-
数据:配置文件的此部分指定要使用的训练数据集的路径:
data: path: data/comprehensive_cars/images/*.jpg fid_file: data/comprehensive_cars/fid_files/comprehensiveCars_256.npz random_crop: True img_size: 256
-
模型:这指定了建模参数:
model: background_generator_kwargs: rgb_out_dim: 256 bounding_box_generator_kwargs: scale_range_min: [0.2, 0.16, 0.16] scale_range_max: [0.25, 0.2, 0.2] translation_range_min: [-0.22, -0.12, 0.] translation_range_max: [0.22, 0.12, 0.] generator_kwargs: range_v: [0.41667, 0.5] fov: 10 neural_renderer_kwargs: input_dim: 256 n_feat: 256 decoder_kwargs: rgb_out_dim: 256
-
d
-
目录路径和学习率等:
training: out_dir: out/cars256 learning_rate: 0.00025
重要说明
训练该模型是一项计算密集型任务。在单个 GPU 上完全训练该模型可能需要 1 到 4 天不等,具体取决于使用的 GPU 设备。
摘要
在这一章中,您将使用 GIRAFFE 模型探索可控的 3D 感知图像合成。该模型借鉴了 NeRF、GAN 和 2D CNN 的概念,以创建可控的 3D 场景。首先,我们回顾了 GANs。然后,我们深入探讨了 GIRAFFE 模型,生成特征场的生成方式,以及这些特征场如何转换为 RGB 图像。然后,我们探索了该模型的输出,并了解了其属性和局限性。最后,我们简要介绍了如何训练这个模型。
在下一章中,我们将探讨一种用于生成三维逼真人体的相对新技术,称为 SMPL 模型。值得注意的是,SMPL 模型是少数几个不使用深度神经网络的模型之一。相反,它使用更经典的统计技术,如主成分分析,来实现其目标。您将了解到在构建使用经典技术的模型时,良好的数学问题表述的重要性。
第九章:3D 建模人体
在前几章中,我们探索了 3D 场景及其物体的建模方法。我们建模的大多数物体都是静态且不变的,但现实生活中,计算机视觉的许多应用都集中在人类及其自然环境中。我们希望建模人类与其他人和物体的互动。
这一技术有多个应用。例如,Snapchat 滤镜、FaceRig、虚拟试衣和好莱坞的动作捕捉技术都受益于精确的 3D 人体建模。例如,考虑一下自动结账技术。在这里,零售店配备了多个深度感应摄像头。它们可能希望检测到顾客拿起物品时,并相应地修改他们的结账篮。这类应用和更多的应用都需要我们精确建模人体。
人体姿势估计是人体建模的一个核心问题。这样的模型可以预测关节的位置,例如肩膀、臀部和肘部,从而创建图像中人的骨架。然后,这些数据可用于多个下游应用,如动作识别和人机交互。然而,将人体建模为一组关节也有其局限性:
-
人体关节是不可见的,且永远不会与物理世界互动。因此,我们不能仅依靠它们来准确建模人机交互。
-
关节并不能建模人体的拓扑结构、体积和表面。对于某些应用,如模拟衣物如何贴合,单纯的关节建模是无用的。
我们可以达成共识:人体姿势模型对某些应用是有效的,但绝对不够真实。我们如何才能更真实地建模人体?这是否能解决这些局限性?这将开启哪些新的应用?我们将在本章回答这些问题。具体来说,我们将涵盖以下主题:
-
制定 3D 建模问题
-
理解线性混合蒙皮(Linear Blend Skinning)技术
-
理解 SMPL 模型
-
使用 SMPL 模型
-
使用 SMPLify 估计 3D 人体姿势与形状
-
探索 SMPLify
技术要求
本章代码的计算需求相对较低。然而,建议在 Linux 环境中运行,因为它对某些库有更好的支持。但在其他环境中运行也是可行的。在代码部分,我们将详细描述如何设置环境,以便成功运行代码。本章需要以下技术要求:
-
Python 2.7
-
库:如 opendr、matplotlib、opencv 和 numpy。
本章的代码片段可以在github.com/PacktPublishing/3D-Deep-Learning-with-Python
找到。
制定 3D 建模问题
“所有模型都是错误的,但有些模型是有用的” 这句名言在统计学中非常流行。它表明,通常很难精确地数学化问题中的所有细节。一个模型始终是对现实的近似,但有些模型比其他模型更准确,因此也更有用。
在机器学习领域,建模一个问题通常包括以下两个组件:
-
数学化问题表述
-
在该表述的约束和边界下,构建解决问题的算法
在处理不够明确的问题时,即使使用优秀的算法,通常也会得到次优的模型。然而,将较弱的算法应用于一个合理表述的模型,有时却能产生很好的解决方案。这一见解对于构建 3D 人体模型尤其适用。
这个建模问题的目标是创建逼真的动画人体。更重要的是,这些模型应该能够真实地表现人体形状,并且必须根据人体姿势变化自然变形,同时捕捉软组织的运动。3D 人体建模是一个艰巨的挑战。人体由大量的骨骼、器官、皮肤、肌肉和水分组成,它们之间以复杂的方式相互作用。为了精确地建模人体,我们需要建模所有这些个体组件的行为及它们之间的相互作用。这是一个艰难的挑战,对于一些实际应用来说,这种精确度并非必须。在本章中,我们将以建模人体表面和形状的方式,作为建模整个人体的代理。我们不需要模型完全精确;我们只需要它具有逼真的外观。这使得问题变得更加可处理。
定义一个好的表示
目标是用低维表示准确地描述人体。关节模型是低维表示(通常在 3D 空间中包含 17 到 25 个点),但它们并不包含很多关于人体形状和纹理的信息。另一方面,我们可以考虑体素网格表示。它可以建模 3D 人体的形状和纹理,但其维度极高,并且由于姿势变化,它并不自然适用于建模人体动态。
因此,我们需要一种能够联合表示身体关节和表面的表示方法,包含有关身体体积的信息。有几种候选表示方法可以用于表示表面;其中一种表示方法是顶点网格。Skinned Multi-Person Linear(SMPL)模型就是使用这种表示方法。一旦指定,这个顶点网格就能描述人体的 3D 形状。
由于这个问题已有很多历史背景,我们会发现许多角色动画领域的艺术家都曾致力于构建良好的人体网格。SMPL 模型运用了这些专家的见解来构建人体网格的良好初始模板。这是一个重要的第一步,因为人体的某些部分存在高频变化(例如面部和手部)。这些高频变化需要更多密集的点来描述,而频率较低的部位(如大腿)则只需要较少的点来准确描述。这样的手工制作初始网格有助于降低问题的维度,同时保持准确建模所需的信息。SMPL 模型中的这个网格是性别中立的,但你可以为男性和女性分别构建不同的变体。
图 8.1 – SMPL 模型静止姿势下的模板网格
更具体来说,初始模板网格由 6,890 个三维空间中的点组成,用于表示人体表面。当该网格进行矢量化时,模板网格的矢量长度为 6,890 x 3 = 20,670。任何人体都可以通过扭曲这个模板网格矢量来适应人体表面。
这个概念看起来在纸面上非常简单,但一个 20,670 维矢量的配置数量是极其庞大的。表示一个真实人体的配置集是所有可能性中的一个极其微小的子集。问题在于如何定义一种方法,以获得一个合理的配置来表示一个真实的人体。
在我们了解 SMPL 模型的设计之前,我们需要了解蒙皮模型。在接下来的部分,我们将探讨最简单的蒙皮技术之一:线性混合蒙皮技术。这一点很重要,因为 SMPL 模型就是在这种技术基础上构建的。
理解线性混合蒙皮技术
一旦我们拥有了良好的三维人体表示,我们就希望模拟它在不同姿势下的表现。这对于角色动画尤其重要。蒙皮的概念是将一个基础骨架用一层皮肤(表面)包裹起来,这个皮肤展现了被动画化物体的外观。这是动画行业中的一个术语。通常,这种表示形式采用顶点的方式,然后利用这些顶点定义连接的多边形来形成表面。
线性混合蒙皮模型(Linear Blend Skinning)用于将处于静止姿势的皮肤转换成任意姿势下的皮肤,使用的是一个简单的线性模型。由于其渲染效率高,许多游戏引擎支持这种技术,它也被用于实时渲染游戏角色。
图 8.2 – 初始混合形状(左)和使用混合蒙皮生成的变形网格(右)
现在我们来理解一下这个技术涉及的内容。该技术是一个使用以下参数的模型:
-
一个模板网格,T,具有N个顶点。在这个例子中,N = 6,890。
-
我们有K个关节位置,由向量J表示。这些关节对应人体中的关节(如肩膀、手腕和脚踝)。共有 23 个这样的关节(K = 23)。
-
混合权重,W。这通常是一个N x K大小的矩阵,捕捉N个表面顶点与K个身体关节之间的关系。
-
姿势参数,Ɵ。这些是每个 K 个关节的旋转参数。总共有 3K 个这样的参数。在这个例子中,我们有 72 个参数。69 个参数对应 23 个关节,3 个参数对应整个身体的旋转。
蒙皮函数将静止姿势网格、关节位置、混合权重和姿势参数作为输入,并生成输出顶点:
在线性混合蒙皮中,函数以简单的线性形式表示变换后的模板顶点,如下式所示:
这些术语的含义如下:
-
t_i表示处于静止姿势下原始网格中的顶点。
-
G(Ɵ, J)是将关节 k 从静止姿势变换到目标姿势的矩阵。
-
w_k, i是混合权重W的元素。它们表示关节 k 对顶点 i 的影响程度。
虽然这是一个易于使用的线性模型,并且在动画行业中得到了广泛应用,但它并没有明确保留体积。因此,变换可能看起来不自然。
为了解决这个问题,艺术家调整模板网格,以便在应用蒙皮模型时,最终效果看起来自然且逼真。通过对模板网格应用线性变形以获得逼真外观的变形网格,这些被称为混合形状。这些混合形状是艺术家根据动画角色可以有的不同姿势设计的。这是一个非常耗时的过程。
正如我们稍后将看到的,SMPL 模型在应用蒙皮模型之前,会自动计算混合形状。在下一部分中,你将了解 SMPL 模型是如何创建这些依赖姿势的混合形状,以及数据是如何用来引导它的。
了解 SMPL 模型
正如 SMPL 的缩写所暗示的那样,这是一个基于成千上万人的数据训练得到的线性学习模型。该模型建立在线性混合蒙皮模型的概念之上。它是一个无监督生成模型,通过提供的输入参数生成一个 20,670 维的向量,而这些参数是我们可以控制的。该模型计算所需的混合形状,以产生正确的变形,适应不同的输入参数。我们需要这些输入参数具备以下重要特性:
-
它应该对应人体的真实可感知属性。
-
特征必须本质上是低维的。这将使我们能够轻松地控制生成过程。
-
特征必须是可解耦的,并以可预测的方式进行控制。也就是说,改变一个参数不应改变与其他参数相关的输出特征。
考虑到这些要求,SMPL 模型的创建者提出了两个最重要的输入属性:身体身份的某种概念和身体姿势。SMPL 模型将最终的 3D 身体网格分解为基于身份的形状和基于姿势的形状(基于身份的形状也称为基于形状的形状,因为身体形状与一个人的身份紧密相关)。这使得该模型具有所需的特征解耦性质。还有一些其他重要因素,如呼吸和软组织动态(当身体处于运动状态时),我们在本章中不做详细解释,但它们是 SMPL 模型的一部分。
最重要的是,SMPL 模型是一个加性变形模型。也就是说,所需的输出体形向量是通过将变形添加到原始模板体形向量中得到的。这种加性特性使得该模型非常直观,易于理解和优化。
定义 SMPL 模型
SMPL 模型是在标准蒙皮模型的基础上构建的。它对其做出了以下改动:
-
它不使用标准的静止姿势模板,而是使用一个依赖于身体形状和姿势的模板网格
-
关节位置是身体形状的函数
SMPL 模型指定的函数具有以下形式:
以下是前述定义中各术语的含义:
-
β 是表示身份(也叫形状)向量。稍后我们将进一步了解它所表示的含义。
-
Ɵ 是对应于目标姿势的姿势参数。
-
W 是来自线性混合蒙皮模型的混合权重。
这个函数看起来与线性混合蒙皮(Linear Blend Skinning)模型非常相似。在这个函数中,模板网格是形状和姿势参数的函数,关节位置是形状参数的函数。而在线性混合蒙皮模型中并非如此。
依赖形状和姿势的模板网格
重新定义的模板网格是标准模板网格的加性线性变形:
在这里,我们看到以下内容:
-
是来自身体形状参数 β 的加性形变。它是一个学习到的形变,建模为形状位移的前几个主成分。这些主成分是从训练数据中获得的,涉及到不同的人在相同静态姿势下的数据。
-
是一个加性姿势形变项。它由 Ɵ 参数化。这也是从一个包含不同姿势下人物的多姿势数据集中学习得到的线性函数。
形状依赖的关节
由于关节位置依赖于身体形状,因此它们被重新定义为身体形状的函数。SMPL 模型将其定义如下:
这里, 是来自身体形状参数 β 的加性形变,J 是一个矩阵,用于将静态模板顶点转换为静态模板姿势。J 的参数也是从数据中学习得出的。
使用 SMPL 模型
现在你对 SMPL 模型有了大致了解,我们将看看如何使用它来创建人类的 3D 模型。在这一部分中,我们将从几个基本函数开始,这将帮助你探索 SMPL 模型。我们将加载 SMPL 模型,并编辑形状和姿势参数以创建一个新的 3D 身体。然后,我们将其保存为对象文件并进行可视化。
这段代码最初是由 SMPL 的作者为 python2
创建的。因此,我们需要创建一个独立的 python2
环境。以下是创建环境的说明:
cd chap8
conda create -n python2 python=2.7 anaconda
source activate python2
pip install –r requirements.txt
这会创建并激活 Python 2.7 的 conda 环境,并安装所需的模块。由于 Python 2.7 已经被废弃,因此你在使用时可能会看到警告信息。为了创建一个具有随机形状和姿势参数的 3D 人体,运行以下命令。
$ python render_smpl.py
这将弹出一个窗口,显示 3D 渲染的人体。我们的输出可能会有所不同,因为 render_smpl.py
会创建一个具有随机姿势和形状参数的人体。
图 8.3 – 由 explore_smpl.py 创建的 hello_smpl.obj 渲染示例
现在我们已经运行了代码并获得了可视化输出,让我们来看看 render_smpl.py
文件中的具体内容:
-
导入所有必需的模块:
import cv2 import numpy as np from opendr.renderer import ColoredRenderer from opendr.lighting import LambertianPointLight from opendr.camera import ProjectPoints from smpl.serialization import load_model
-
加载基础 SMPL 模型的模型权重。这里,我们加载了神经网络人体模型:
m = load_model('../smplify/code/models/basicModel_neutral_lbs_10_207_0_v1.0.0.pkl')
-
接下来,我们分配随机的姿势和形状参数。以下的姿势和形状参数决定了最终 3D 身体网格的外观:
m.pose[:] = np.random.rand(m.pose.size) * .2 m.betas[:] = np.random.rand(m.betas.size) * .03 m.pose[0] = np.pi
-
我们现在创建一个渲染器并为其分配属性,同时构建光源。默认情况下,我们使用 OpenDR 渲染器,但你可以切换为 PyTorch3D 渲染器和光源。在这样做之前,请确保解决任何 Python 不兼容性问题。
rn = ColoredRenderer() w, h = (640, 480) rn.camera = ProjectPoints(v=m, rt=np.zeros(3), t=np.array([0, 0, 2.]), f=np.array([w,w])/2., c=np.array([w,h])/2., k=np.zeros(5)) rn.frustum = {'near': 1., 'far': 10., 'width': w, 'height': h} rn.set(v=m, f=m.f, bgcolor=np.zeros(3)) rn.vc = LambertianPointLight(f=m.f, v=rn.v, num_verts=len(m), light_pos=np.array([-1000,-1000,-2000]), vc=np.ones_like(m)*.9, light_color=np.array([1., 1., 1.]))
-
现在我们可以渲染网格并在 OpenCV 窗口中显示它:
cv2.imshow('render_SMPL', rn.r) cv2.waitKey(0) cv2.destroyAllWindows()
我们现在已经使用 SMPL 模型生成了一个随机的 3D 人体。实际上,我们可能更感兴趣的是生成更加可控的 3D 形状。我们将在下一节中讨论如何做到这一点。
使用 SMPLify 估计 3D 人体姿态和形状
在上一节中,你探索了 SMPL 模型,并用它生成了一个形状和姿势随机的 3D 人体。自然地,我们会想知道是否可以利用 SMPL 模型将 3D 人体拟合到二维图像中的一个人身上。这有多个实际应用,比如理解人体动作或从二维视频创建动画。事实上,这是可行的,在本章中,我们将更详细地探讨这一思路。
假设你得到了一张单独的 RGB 图像,图中有一个人,但没有任何关于身体姿态、相机参数或形状参数的信息。我们的目标是仅从这张图像推测出 3D 形状和姿态。从二维图像估计 3D 形状并非总是没有误差的。这是一个具有挑战性的问题,因为人体的复杂性、关节运动、遮挡、服装、光照,以及从二维推测三维本身存在固有的模糊性(因为多个 3D 姿态在投影时可能具有相同的 2D 姿态)。我们还需要一种自动估计方法,尽量减少人工干预。它还需要在自然图像中,面对各种背景、光照条件和相机参数时,也能有效工作。
这种方法是由来自马普智能系统研究所(SMPL 模型的发明地)、微软、马里兰大学和图宾根大学的研究人员发明的。这个方法被称为 SMPLify。让我们更详细地探讨这个方法。
SMPLify 方法包括以下两个阶段:
-
使用现有的姿态检测模型(如 OpenPose 或 DeepCut)自动检测二维关节。只要它们预测的是相同的关节,任何二维关节检测器都可以替代使用。
-
使用 SMPL 模型生成 3D 形状。直接优化 SMPL 模型的参数,使得 SMPL 模型的关节在二维上与前一阶段预测的关节相匹配。
我们知道 SMPL 仅通过关节捕捉形状。因此,通过 SMPL 模型,我们可以仅通过关节获取关于人体形状的信息。在 SMPL 模型中,身体形状参数由β表示。它们是 PCA 形状模型中主成分的系数。姿态通过运动学树中 23 个关节的相对旋转和θ来参数化。我们需要拟合这些参数β和θ,以使目标函数最小化。
定义优化目标函数
任何目标函数必须捕捉我们最小化某种误差的意图。误差计算越准确,优化步骤的输出结果就会越准确。我们将首先查看整个目标函数,然后逐一分析该函数的各个组成部分,并解释它们为何必要:
-
我们希望通过优化参数β和Ɵ来最小化这个目标函数。它包含四个项和相应的系数,λƟ、λa、λsp 和λβ,它们是优化过程中的超参数。以下是每个单独项的含义:
-
是一个基于关节的项,用于惩罚 SMPL 模型的 2D 投影关节与 2D 关节检测器(如 DeepCut 或 OpenPose)预测的关节位置之间的距离。w_i是由 2D 关节检测模型提供的每个关节的置信度分数。当一个关节被遮挡时,该关节的置信度分数会很低。自然地,我们不应过于重视这些被遮挡的关节。
-
是一个惩罚关节之间大角度的姿势项。例如,它确保肘部和膝盖不会不自然地弯曲。
-
是一个拟合自然姿势的高斯混合模型,这些姿势是从一个非常大的数据集中获取的。这个数据集被称为 CMU 图形实验室动作捕捉数据库,包含近一百万个数据点。这个数据驱动项确保优化函数中的姿势参数接近我们在现实中观察到的姿势。
-
-
是自相交误差。当作者在优化目标函数时没有这个误差项时,他们看到了不自然的自相交现象,比如肘部和手部扭曲并穿透腹部。这在物理上是不可能的。然而,添加了这个误差项后,他们发现得到了自然的定性结果。这个误差项由身体部位组成,这些部位被近似为一组球体。他们定义了不兼容的球体,并惩罚这些不兼容球体的交集。
-
是从 SMPL 模型中获得的形状。这里需要注意的是,主成分矩阵是 SMPL 模型的一部分,它是通过在 SMPL 训练数据集上进行训练获得的。
总结来说,目标函数由五个组成部分构成,它们共同确保该目标函数的解是一组姿势和形状参数(theta 和 beta),既保证了 2D 关节投影距离的最小化,同时又确保没有大角度的关节、没有不自然的自相交,并且姿势和形状参数遵循我们在由自然体态和形状组成的大型数据集中观察到的先验分布。
探索 SMPLify
现在我们已经对如何估计一个人 2D RGB 图像中的 3D 身体形状有了大致的了解,接下来让我们通过代码来实际操作。具体来说,我们将拟合 3D 身体形状到 Leeds Sports Pose (LSP) 数据集中的两张 2D 图像。这些图像来自 Flickr,包含了 2,000 张带有姿势注释的运动员图像。我们将首先运行代码并生成这些拟合的身体形状,然后再深入探讨代码的细节。本节中使用的所有代码都来自论文 Keep it SMPL: Automatic Estimation of 3D Human Pose and Shape from a Single Image 的实现。我们仅对其进行了调整,以便帮助你这个学习者快速运行代码并自己可视化输出结果。
这段代码最初是由 SMPLify 的作者为 python2
创建的。因此,我们需要使用与探索 SMPL 模型时相同的 python2
环境。在运行任何代码之前,让我们快速了解代码的结构:
chap8
-- smplify
-- code
-- fit3d_utils.py
-- run_fit3d.py
-- render_model.py
-- lib
-- models
-- images
-- results
运行代码
你将直接与之交互的主要文件是 run_fir3d.py
。名为 images
的文件夹中包含一些来自 LSP 数据集的示例图像。然而,在我们运行代码之前,请确保正确设置了 PYTHONPATH
。它应该指向 chap8
文件夹的位置。你可以运行以下代码来设置:
export PYTHONPATH=$PYTHONPATH:<user-specific-path>/3D-Deep-Learning-with-Python/chap8/
现在,进入正确的文件夹:
cd smplify/code
你现在可以运行以下命令,将 3D 身体拟合到 images
文件夹中的图像上:
python run_fit3d.py --base_dir ../ --out_dir .
这次运行不会使用任何穿透误差,因为它会更快地进行优化迭代。最后,我们将拟合一个身体中立形状。你将能够可视化拟合到图像中的 3D 身体姿态。一旦优化完成,你将看到以下两张图像:
图 8.4 – LSP 数据集中一个正在跑步的人物图像(左)和与该图像相匹配的 3D 身体形状(右)
另一个输出如下:
图 8.5 – LSP 数据集中一个正在踢足球的运动员图像(左)和与该图像相匹配的 3D 身体形状(右)
探索代码
现在你已经运行了代码来拟合 2D 图像中的人物,让我们更详细地查看代码,以理解实现这一目标所需的一些主要组件。你会在 run_fit3d.py
文件中找到所有这些组件。你需要执行以下步骤:
-
让我们首先导入我们需要的所有模块:
from os.path import join, exists, abspath, dirname from os import makedirs import cPickle as pickle from glob import glob import cv2 import numpy as np import matplotlib.pyplot as plt import argparse from smpl.serialization import load_model from smplify.code.fit3d_utils import run_single_fit
-
现在让我们定义 SMPL 模型的位置。通过以下方法来完成:
MODEL_DIR = join(abspath(dirname(__file__)), 'models') MODEL_NEUTRAL_PATH = join( MODEL_DIR, 'basicModel_neutral_lbs_10_207_0_v1.0.0.pkl')
-
让我们设置一些优化方法所需的参数,并定义图像和结果所在的目录。结果文件夹将包含数据集中所有图像的关节估计。
viz
被设置为True
,以启用可视化。我们使用具有 10 个参数的 SMPL 模型(即,它使用 10 个主成分来建模身体形状)。flength
是相机的焦距;在优化过程中保持固定。pix_thsh
是阈值(以像素为单位)。如果 2D 肩部关节之间的距离低于pix_thsh
,则身体朝向是模糊的。这种情况可能发生在一个人站在与相机垂直的位置时。因此,很难判断他们是面朝左侧还是右侧。于是,系统会对估计的关节和它的翻转版本都进行拟合:viz = True n_betas = 10 flength = 5000.0 pix_thsh = 25.0 img_dir = join(abspath(base_dir), 'images/lsp') data_dir = join(abspath(base_dir), 'results/lsp') if not exists(out_dir): makedirs(out_dir)
-
然后,我们应该将这个性别中立的 SMPL 模型加载到内存中:
model = load_model(MODEL_NEUTRAL_PATH)
-
接下来,我们需要加载 LSP 数据集中图像的关节估计。LSP 数据集本身包含所有图像的关节估计和相应的关节置信度分数。我们将直接使用它。你也可以提供自己的关节估计,或者使用好的关节估计器,如 OpenPose 或 DeepCut,来获取关节估计:
est = np.load(join(data_dir, 'est_joints.npz'))['est_joints']
-
接下来,我们需要加载数据集中的图像,并获取相应的关节估计和置信度分数:
img_paths = sorted(glob(join(img_dir, '*[0-9].jpg'))) for ind, img_path in enumerate(img_paths): img = cv2.imread(img_path) joints = est[:2, :, ind].T conf = est[2, :, ind]
-
对于数据集中的每一张图像,使用
run_single_fit
函数来拟合参数 beta 和 theta。以下函数会在类似于我们前一节讨论的 SMPLify 目标函数上运行优化,并返回这些参数:params, vis = run_single_fit(img, joints, conf, model, regs=sph_regs, n_betas=n_betas, flength=flength, pix_thsh=pix_thsh, scale_factor=2, viz=viz, do_degrees=do_degrees)
在优化目标函数的过程中,函数会创建一个matplotlib
窗口,其中绿色圆圈是来自 2D 关节检测模型的 2D 关节估计(这些是由你提供的)。红色圆圈是正在拟合到 2D 图像上的 SMPL 3D 模型的投影关节:
图 8.6 – 可视化提供的 2D 关节(绿色)和正在拟合到 2D 图像上的 SMPL 模型的投影关节(红色)
-
接下来,我们想要将拟合好的 3D 人体与 2D RGB 图像一起进行可视化。我们使用 matplotlib 来完成此任务。以下代码将打开一个交互窗口,你可以在其中将图像保存到磁盘:
if viz: import matplotlib.pyplot as plt plt.ion() plt.show() plt.subplot(121) plt.imshow(img[:, :, ::-1]) for di, deg in enumerate(do_degrees): plt.subplot(122) plt.cla() plt.imshow(vis[di]) plt.draw() plt.title('%d deg' % deg) plt.pause(1) raw_input('Press any key to continue...')
-
然后,我们想要使用以下代码将这些参数和可视化结果保存到磁盘:
with open(out_path, 'w') as outf: pickle.dump(params, outf) if do_degrees is not None: cv2.imwrite(out_path.replace('.pkl', '.png'), vis[0])
在上述代码中,最重要的函数是run_single_fit
。你可以在smplify.code.fit3d_utils.py
中更详细地查看这个函数。
需要注意的是,拟合的 3D 身体的精度取决于 2D 关节的精度。由于 2D 关节是通过关节检测模型(例如 OpenPose 或 DeepCut)预测的,因此这些关节预测模型的准确性对这个问题非常重要。估计 2D 关节在以下场景中尤其容易出错:
-
不完全可见的关节很难预测。这可能由于多种原因导致,包括自遮挡、被其他物体遮挡、不寻常的衣物等。
-
很容易混淆左右关节(例如,左手腕和右手腕)。当人面对镜头侧面时,这一点尤其明显。
-
如果模型没有针对这些姿势进行训练,检测不寻常姿势中的关节是很困难的。这取决于用于训练关节检测器的数据集的多样性。
更广泛地说,一个由多个机器学习模型依次相互作用的系统(即,一个模型的输出成为另一个模型的输入)将会遭遇级联错误。在一个组件中出现的小错误将导致下游组件输出的大错误。通常通过端到端训练系统来解决这个问题。然而,由于目前研究界没有直接将 2D 输入图像映射到 3D 模型的真实数据,因此此策略暂时无法使用。
总结
在本章中,我们概览了 3D 人体建模的数学公式。我们理解了良好表示的强大功能,并通过简单的方法,如在强大表示上使用线性混合蒙皮(Linear Blend Skinning),获得了真实的输出。接着,我们对 SMPL 模型进行了高层次概述,并利用它创建了一个随机的 3D 人体。之后,我们回顾了生成它所用的代码。接下来,我们探讨了如何使用 SMPLify 将 3D 人体形状拟合到 2D RGB 图像中的人物身上。我们了解了它如何在后台使用 SMPL 模型。此外,我们还将人体形状拟合到 LSP 数据集中的两张图像,并理解了我们用于此目的的代码。通过这些,我们对 3D 人体建模有了高层次的概述。
在下一章,我们将探讨 SynSin 模型,通常用于 3D 重建。下一章的目标是理解如何仅凭一张图像重建来自不同视角的图像。
第十章:使用 SynSin 进行端到端视图合成
本章专注于名为 SynSin 的最新视图合成模型。视图合成是 3D 深度学习的主要方向之一,可以在多个不同领域(如 AR、VR、游戏等)中使用。其目标是为给定的图像作为输入,从另一个视角重建一个新的图像。
本章首先探讨视图合成及其现有的解决方法。我们将讨论这些技术的所有优缺点。
其次,我们将深入探讨 SynSin 模型的架构。这是一个端到端模型,由三个主要模块组成。我们将讨论每个模块,并了解这些模块如何帮助解决视图合成而无需任何 3D 数据。
在理解模型的整体结构之后,我们将进入实际操作阶段,设置和操作模型以更好地理解整个视图合成过程。我们将训练和测试模型,并使用预训练模型进行推断。
本章将涵盖以下主要主题:
-
视图合成概述
-
SynSin 网络架构
-
模型训练和测试实践
技术要求
要运行本书中的示例代码片段,理想情况下读者需要一台配备 GPU 的计算机。然而,仅使用 CPU 运行代码片段并非不可能。
推荐的计算机配置包括以下内容:
-
例如,具有至少 8GB 内存的 Nvidia GTX 系列或 RTX 系列 GPU
-
Python 3
-
PyTorch 和 PyTorch3D 库
本章的代码片段可以在https:github.com/PacktPublishing/3D-Deep-Learning-with-Python找到。
视图合成概述
在 3D 计算机视觉中最流行的研究方向之一是视图合成。在这个研究方向中,给定数据和视点,其核心思想是生成一个新的图像,从另一个视角渲染对象。
视图合成面临两个挑战。模型应该理解图像的 3D 结构和语义信息。所谓 3D 结构,是指当视角发生变化时,我们会靠近一些物体而远离其他物体。一个好的模型应该通过渲染图像来处理这种变化,其中一些物体变得更大,另一些则变得更小。所谓语义信息,是指模型应区分图像中的物体,并理解图像中展示的物体。这一点非常重要,因为某些物体可能只部分出现在图像中;因此,在重建过程中,模型应理解物体的语义,以便知道如何重建该物体的延续部分。例如,给定一张车子的一侧图像,我们只能看到两个车轮,我们知道车子的另一侧还有两个轮子。模型在重建时必须包含这些语义信息:
图 9.1:红框的照片展示了原始图像,蓝框的照片展示了新生成的图像;这是使用 SynSin 方法进行视图合成的一个示例。
许多挑战需要解决。对于模型来说,从一张图像中理解 3D 场景是很困难的。视图合成有几种方法:
-
多幅图像的视图合成:深度神经网络可以用来学习多幅图像的深度,然后从另一个视角重建新图像。然而,如前所述,这意味着我们有来自稍微不同视角的多幅图像,有时很难获得这种数据。
-
使用真实深度进行视图合成:这涉及一组技术,其中使用真实深度掩码与图像一同表示图像的深度和语义。尽管在某些情况下,这些类型的模型可以取得不错的结果,但收集大规模数据是困难且昂贵的,尤其是在户外场景中。此外,对这类数据进行大规模标注也既昂贵又耗时。
-
单幅图像的视图合成:当我们只有一张图像并且旨在从新视角重建图像时,这是一种更为现实的设置。仅使用一张图像很难获得更准确的结果。SynSin 属于一类能够实现最先进视图合成方法的技术。
所以,我们已经简要概述了视图合成的内容。接下来,我们将深入探讨 SynSin,研究其网络架构,并检查模型的训练和测试过程。
SynSin 网络架构
SynSin 的核心思想是通过一个端到端模型来解决视图合成问题,并且在测试时只需使用一张图像。这个模型不需要 3D 数据注释,并且与基准模型相比,能实现非常好的准确度:
图 9.2:端到端 SynSin 方法的结构
该模型是端到端训练的,包含三个不同的模块:
-
空间特征和深度网络
-
神经点云渲染器
-
精炼模块和判别器
让我们深入了解每个网络,以便更好地理解架构。
空间特征和深度网络
如果我们放大看一下图 9.2的第一部分,我们可以看到两种不同的网络,它们都输入相同的图像。这些分别是空间特征网络(f)和深度网络(d)(图 9.3):
图 9.3:空间特征和深度网络的输入与输出
给定一张参考图像和期望的姿态变化(T),我们希望生成一张图像,好像该姿态变化已经应用到参考图像中。对于第一部分,我们只使用参考图像,并将其输入到两个网络中。空间特征网络的目标是学习特征图,这些特征图是图像的高分辨率表示。模型的这一部分负责学习图像的语义信息。该模型由八个 ResNet 块组成,并为图像的每个像素输出 64 维特征图。输出的分辨率与原始图像相同。
接下来,深度网络的目标是学习图像的 3D 结构。由于我们没有使用精确的 3D 注释,它不会是一个准确的 3D 结构。但该模型会进一步改善这一点。此网络使用了带有八个下采样和上采样层的 UNet,并跟随一个 sigmoid 层。同样,输出具有与原始图像相同的分辨率。
正如你可能注意到的,两个模型都保持了输出通道的高分辨率。这将进一步帮助重建更准确、更高质量的图像。
神经点云渲染器
下一步是创建一个 3D 点云,然后可以使用视图变换点从新的视角渲染出新图像。为此,我们使用空间特征和深度网络的组合输出。
下一步应该是从另一个视角渲染图像。在大多数情况下,会使用一个简单的渲染器。该渲染器将 3D 点投影到新视图中的一个像素或小区域。简单渲染器使用 z-buffer 来保存从点到相机的所有距离。简单渲染器的问题在于它不可微分,这意味着我们无法使用梯度来更新我们的深度和空间特征网络。此外,我们希望渲染的是特征,而不是 RGB 图像。这意味着简单渲染器不适用于这一技术:
图 9.4:神经点云渲染器中的姿态变换
为什么不直接对朴素渲染器进行微分?在这里,我们面临两个问题:
-
小邻域:如前所述,每个点仅出现在渲染图像的一个或几个像素上。因此,每个点只有少量的梯度。这是局部梯度的一个缺点,降低了依赖梯度更新的网络性能。
-
硬 z 缓冲区:z 缓冲区只保留最近的点用于渲染图像。如果新的点出现得更近,输出将会突然发生剧烈变化。
为了解决这里提出的问题,该模型试图软化硬决策。这项技术被称为 神经点云渲染器。为此,渲染器不是为每个点分配一个像素,而是使用不同的影响力进行撒布。这样可以解决小邻域问题。对于硬 z 缓冲区问题,我们则通过积累最近点的影响,而不仅仅是最近的点:
图 9.5:使用撒布技术投影点
一个 3D 点通过半径 r(图 9.5)进行投影并进行点撒布。然后,测量该 3D 点对该像素的影响,方法是计算撒布点的中心到该像素的欧几里得距离:
图 9.6:神经点云渲染器在朴素渲染(b)和 SynSin 渲染(c)示例上的前向和反向传播效果
如前面的图所示,每个点都会进行撒布,这有助于我们不会丢失太多信息,并有助于解决棘手的问题。
这种方法的优势在于,它允许为一个 3D 点收集更多梯度,从而改善网络对空间特征和深度网络的学习过程:
图 9.7:朴素渲染器和神经点云渲染器的反向传播
最后,我们需要在 z 缓冲区中收集并积累点。首先,我们根据与新相机的距离对点进行排序,然后使用 K 近邻算法和 alpha 合成技术来积累点:
图 9.8:3D 点云输出
如图 9.8所示,3D 点云输出的是一个未经精细化的新视图。该输出应该作为精细化模块的输入。
精细化模块和判别器
最后但同样重要的是,该模型包括一个精炼模块。该模块有两个任务:首先是提高投影特征的准确性,其次是从新视角填充图像中不可见的部分。它应该输出语义上有意义且几何上准确的图像。例如,如果图像中只显示了桌子的某一部分,在新视角下,图像应该包含桌子的一部分,该模块应该从语义上理解这是桌子,并在重建过程中保持新部分的几何线条正确(例如,直线应该保持直线)。该模型从真实世界图像的数据集中学习这些特性:
图 9.9:精炼模块
精炼模块(g)从神经点云渲染器获取输入,然后输出最终重建的图像。之后,它被用于损失目标中,以改善训练过程。
该任务通过生成模型解决。使用了包含八个块的 ResNet,为了保持图像的良好分辨率,还使用了下采样和上采样模块。我们使用了具有两个多层判别器和特征匹配损失的 GAN。
模型的最终损失由 L1 损失、内容损失和生成图像与目标图像之间的判别器损失组成:
然后使用损失函数进行模型优化,如往常一样。
这就是 SynSin 如何结合各种模块,创建一个从单一图像合成视图的端到端过程。接下来,我们将探索模型的实际实现。
实践中的模型训练与测试
Facebook Research 发布了 SynSin 模型的 GitHub 仓库,使我们能够训练模型并使用已经预训练的模型进行推理。在这一部分,我们将讨论训练过程和使用预训练模型进行推理:
-
但首先,我们需要设置模型。我们需要克隆 GitHub 仓库,创建环境,并安装所有要求:
git clone https://github.com/facebookresearch/synsin.git cd synsin/ conda create –-name synsin_env –-file requirements.txt conda activate synsin_env
如果无法通过前面的命令安装要求,始终可以手动安装。手动安装时,请按照synsin/INSTALL.md
文件中的说明操作。
-
该模型在三个不同的数据集上进行训练:
-
RealEstate10K
-
MP3D
-
KITTI
-
对于训练,数据可以从数据集网站下载。对于本书,我们将使用KITTI
数据集;但也可以尝试其他数据集。
如何下载 KITTI 数据集的说明可以在 SynSin 仓库的github.com/facebookresearch/synsin/blob/main/KITTI.md
找到。
首先,我们需要从网站下载数据集,并将文件存储在${KITTI_HOME}/dataset_kitti
,其中KITTI_HOME
是数据集所在的路径。
-
接下来,我们需要更新
./options/options.py
文件,在其中添加本地计算机上 KITTI 数据集的路径:elif opt.dataset == 'kitti': opt.min_z = 1.0 opt.max_z = 50.0 opt.train_data_path = ( './DATA/dataset_kitti/' ) from data.kitti import KITTIDataLoader return KITTIDataLoader
如果你打算使用其他数据集,应该找到其他数据集的 DataLoader
并添加该数据集的路径。
-
在训练之前,我们必须通过运行以下命令下载预训练模型:
bash ./download_models.sh
如果我们打开文件并查看内部内容,会看到它包含了所有三个数据集的预训练模型。因此,在运行命令时,它会为每个数据集创建三个不同的文件夹,并下载该数据集的所有预训练模型。我们可以将它们用于训练和推理。如果你不想下载所有模型,可以通过运行以下命令手动下载:
wget https://dl.fbaipublicfiles.com/synsin/checkpoints/realestate/synsin.pth
该命令将运行 SynSin 预训练模型,用于房地产数据集。如需了解更多关于预训练模型的信息,可以下载 readme.txt
文件:
wget https://dl.fbaipublicfiles.com/synsin/checkpoints/readme.txt
-
对于训练,你需要运行
train.py
文件。你可以通过./train.sh
从命令行运行它。如果我们打开train.sh
文件,可以找到针对三个不同数据集的命令。KITTI 的默认示例如下:python train.py --batch-size 32 \ --folder 'temp' --num_workers 4 \ --resume --dataset 'kitti' --use_inv_z \ --use_inverse_depth \ --accumulation 'alphacomposite' \ --model_type 'zbuffer_pts' \ --refine_model_type 'resnet_256W8UpDown64' \ --norm_G 'sync:spectral_batch' \ --gpu_ids 0,1 --render_ids 1 \ --suffix '' --normalize_image --lr 0.0001
你可以调整参数和数据集,尝试模拟原始模型的结果。当训练过程完成后,你可以使用新的模型进行评估。
-
对于评估,首先,我们需要生成的真实图像。为了得到这个,我们需要运行以下代码:
export KITTI=${KITTI_HOME}/dataset_kitti/images python evaluation/eval_kitti.py --old_model ${OLD_MODEL} --result_folder ${TEST_FOLDER}
我们需要设置保存结果的路径,而不是 TEST_FOLDER
。
第一行导出一个名为 KITTI
的新变量,包含数据集图像的路径。接下来的脚本为每张图像创建生成的图像和真实图像配对:
图 9.10:eval_kitti.py 输出示例
第一张图是输入图像,第二张是地面真相。第三张是网络输出。如你所见,相机略微向前移动,对于这种特定情况,模型输出似乎生成得非常好。
-
然而,我们需要一些数值表示来理解网络的表现。因此,我们需要运行
evaluation/evaluate_perceptualsim.py
文件,它将计算准确度:python evaluation/evaluate_perceptualsim.py \ --folder ${TEST_FOLDER} \ --pred_image im_B.png \ --target_image im_res.png \ --output_file kitti_results
上述命令将帮助评估模型,给定测试图像的路径,其中一张是预测图像,另一张是目标图像。
我的测试输出如下:
Perceptual similarity for ./DATA/dataset_kitti/test_folder/: 2.0548
PSNR for /DATA/dataset_kitti/test_folder/: 16.7344
SSIM for /DATA/dataset_kitti/test_folder/: 0.5232
用于评估的指标之一是感知相似度,它衡量 VGG 特征空间中的距离。越接近零,图像之间的相似度越高。PSNR 是另一个衡量图像重建的指标。它计算最大信号功率与失真噪声功率之间的比率,在我们这种情况下,失真噪声就是重建图像。最后,结构相似性指数(SSIM)是一个量化图像质量退化的指标。
- 接下来,我们可以使用预训练模型进行推理。我们需要一张输入图像用于推理:
图 9.11:推理的输入图像
- 接下来,我们将使用
realestate
模型生成一张新图像。首先,我们需要设置模型。
设置模型的代码可以在 GitHub 仓库中的set_up_model_for_inference.py
文件中找到。
为了设置模型,首先,我们需要导入所有必要的包:
import torch
import torch.nn as nn
import sys
sys.path.insert(0, './synsin')
import os
os.environ['DEBUG'] = '0'
from synsin.models.networks.sync_batchnorm import convert_model
from synsin.models.base_model import BaseModel
from synsin.options.options import get_model
-
接下来,我们将创建一个函数,它以模型路径为输入并输出准备好的推理模型。为了更好地理解代码,我们将把整个函数分解成更小的部分。然而,完整的函数可以在 GitHub 上找到:
torch.backends.cudnn.enabled = True opts = torch.load(model_path)['opts'] opts.render_ids = [1] torch_devices = [int(gpu_id.strip()) for gpu_id in opts.gpu_ids.split(",")] device = 'cuda:' + str(torch_devices[0])
在这里,我们启用cudnn
包并定义模型将要运行的设备。此外,第二行导入模型,使其可以访问为训练设置的所有选项,如果需要可以进行修改。请注意,render_ids
指的是 GPU ID,在某些情况下,可能由于用户硬件配置不同而有所不同。
-
接下来,我们定义模型:
model = get_model(opts) if 'sync' in opts.norm_G: model = convert_model(model) model = nn.DataParallel(model, torch_devices[0:1]).cuda() else: model = nn.DataParallel(model, torch_devices[0:1]).cuda()
get_model
函数从options.py
文件导入,它加载权重并返回最终模型。然后,从options
中,我们检查是否有同步模型,这意味着我们在不同的机器上运行该模型。如果有,我们运行convert_model
函数,它会将模型中的所有BatchNorm
模块替换为SynchronizedBatchNorm
模块。
-
最后,我们加载模型:
# Load the original model to be tested model_to_test = BaseModel(model, opts) model_to_test.load_state_dict(torch.load(MODEL_PATH)['state_dict']) model_to_test.eval() print("Loaded model")
BaseModel
函数设置了最终的模式。根据训练模式或测试模式,它可以设置优化器并初始化权重。在我们的例子中,它将为测试模式设置模型。
所有这些代码被总结在一个名为synsin_model
的函数中,我们将导入该函数用于推理。
以下代码来自inference_unseen_image.py
文件。我们将编写一个函数,它接收模型路径、测试图像和新的视图转换参数,并输出来自新视图的图像。如果我们指定了save_path
参数,它将自动保存输出图像。
-
同样,我们将首先导入所有需要用于推理的模块:
import matplotlib.pyplot as plt import quaternion import torch import torch.nn as nn import torchvision.transforms as transforms from PIL import Image from set_up_model_for_inference import synsin_model
-
接下来,我们设置模型并创建用于预处理的数据转换:
model_to_test = synsin_model(path_to_model) # Load the image transform = transforms.Compose([ transforms.Resize((256,256)), transforms.ToTensor(), transforms.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5))]) if isinstance(test_image, str): im = Image.open(test_image) else: im = test_image im = transform(im)
-
现在我们需要指定视图转换参数:
# Parameters for the transformation theta = -0.15 phi = -0.1 tx = 0 ty = 0 tz = 0.1 RT = torch.eye(4).unsqueeze(0) # Set up rotation RT[0,0:3,0:3] = torch.Tensor(quaternion.as_rotation_matrix(quaternion.from_rotation_vector([phi, theta, 0]))) # Set up translation RT[0,0:3,3] = torch.Tensor([tx, ty, tz])
在这里,我们需要指定旋转和平移的参数。注意,theta
和phi
负责旋转,而tx
、ty
和tz
用于平移。
-
接下来,我们将使用上传的图像和新的变换来获取网络的输出:
batch = { 'images' : [im.unsqueeze(0)], 'cameras' : [{ 'K' : torch.eye(4).unsqueeze(0), 'Kinv' : torch.eye(4).unsqueeze(0) }] } # Generate a new view of the new transformation with torch.no_grad(): pred_imgs = model_to_test.model.module.forward_angle(batch, [RT]) depth = nn.Sigmoid()(model_to_test.model.module.pts_regressor(batch['images'][0].cuda()))
在这里,pred_imgs
是模型输出的新图像,depth 是模型预测的 3D 深度。
-
最后,我们将使用网络的输出可视化原始图像、新预测图像和输出图像:
fig, axis = plt.subplots(1,3, figsize=(10,20)) axis[0].axis('off') axis[1].axis('off') axis[2].axis('off') axis[0].imshow(im.permute(1,2,0) * 0.5 + 0.5) axis[0].set_title('Input Image') axis[1].imshow(pred_imgs[0].squeeze().cpu().permute(1,2,0).numpy() * 0.5 + 0.5) axis[1].set_title('Generated Image') axis[2].imshow(depth.squeeze().cpu().clamp(max=0.04)) axis[2].set_title('Predicted Depth')
我们使用matplotlib
来可视化输出。以下是以下代码的结果:
图 9.12: 推理结果
正如我们所见,我们获得了一个新的视角,模型很好地重建了新角度。现在我们可以调整变换参数,从另一个视角生成图像。
-
如果我们稍微改变
theta
和phi
,就会得到另一个视角的变换。现在我们将重建图像的右侧部分:# Parameters for the transformation theta = 0.15 phi = 0.1 tx = 0 ty = 0 tz = 0.1
输出结果如下所示:
图 9.13: 推理结果
一次性大幅度改变变换参数或步长较大可能会导致较差的精度。
-
现在我们知道如何从新视角创建图像。接下来,我们将编写一些简短的代码,顺序地创建图像并制作一个小视频:
from inference_unseen_image import inference from PIL import Image import numpy as np import imageio def create_gif(model_path, image_path, save_path, theta = -0.15, phi = -0.1, tx = 0, ty = 0, tz = 0.1, num_of_frames = 5): im = inference(model_path, test_image=image_path, theta=theta, phi=phi, tx=tx, ty=ty, tz=tz) frames = [] for i in range(num_of_frames): im = Image.fromarray((im * 255).astype(np.uint8)) frames.append(im) im = inference(model_path, im, theta=theta, phi=phi, tx=tx, ty=ty, tz=tz) imageio.mimsave(save_path, frames, duration=1)
这段代码将图像作为输入,并为给定数量的帧生成顺序图像。所谓顺序,是指模型的每个输出成为下一次图像生成的输入:
图 9.14: 顺序视图合成
在前面的图中,有四个连续的帧。正如你所看到的,当我们尝试较大步长时,模型生成的图像越来越差。此时正是开始调整模型超参数、不同相机设置和步长大小的好时机,以查看它如何改善或减少模型输出的精度。
摘要
在本章开始时,我们看到了 SynSin 模型的结构,并深入了解了该模型的端到端过程。如前所述,模型创建过程中的一个有趣方法是使用可微渲染器作为训练的一部分。此外,我们还看到该模型有助于解决没有大量标注数据集,或者在测试时没有多张图像的问题。这就是为什么这是一个最先进的模型,它更容易在实际场景中使用。我们查看了该模型的优缺点,也看了如何初始化模型、训练、测试并使用新图像进行推理。
在下一章,我们将探讨 Mesh R-CNN 模型,它将两种不同的任务(目标检测和 3D 模型构建)结合成一个模型。我们将深入了解该模型的架构,并在一张随机图像上测试模型性能。
第十一章:Mesh R-CNN
本章致力于介绍一个最先进的模型,称为 Mesh R-CNN,旨在将两项不同但重要的任务结合为一个端到端的模型。它结合了著名的图像分割模型 Mask R-CNN 和一个新的 3D 结构预测模型。这两项任务以前是单独研究的。
Mask R-CNN 是一种目标检测和实例分割算法,在基准数据集上获得了最高的精度分数。它属于 R-CNN 家族,是一个两阶段的端到端目标检测模型。
Mesh R-CNN 超越了 2D 目标检测问题,同时输出被检测物体的 3D 网格。如果我们认为世界是三维的,人类看到的物体也是三维的。那么,为什么不让一个检测模型同时输出三维物体呢?
在本章中,我们将理解 Mesh R-CNN 的工作原理。此外,我们将深入理解模型中使用的不同元素和技术,如体素、网格、图卷积网络以及 Cubify 运算符。
接下来,我们将探索 Mesh R-CNN 论文作者提供的 GitHub 仓库。我们将在自己的图像上尝试演示,并可视化预测结果。
最后,我们将讨论如何重现 Mesh R-CNN 的训练和测试过程,并理解模型精度的基准。
本章我们将涵盖以下主要内容:
-
理解网格和体素结构
-
理解模型的结构
-
理解什么是图卷积
-
尝试 Mesh R-CNN 的演示
-
理解模型的训练和测试过程
技术要求
为了运行本书中的示例代码,理想情况下,你需要一台配备 GPU 的计算机。然而,仅使用 CPU 运行代码片段也是可能的。
以下是推荐的计算机配置:
-
一个来自 NVIDIA GTX 系列或 RTX 系列的 GPU,至少需要 8 GB 的内存
-
Python 3
-
PyTorch 库和 PyTorch3D 库
-
Detectron2
-
Mesh R-CNN 仓库,可以在
github.com/facebookresearch/meshrcnn
找到
本章的代码片段可以在 github.com/PacktPublishing/3D-Deep-Learning-with-Python
找到。
网格和体素概述
正如本书前面提到的,网格和体素是两种不同的 3D 数据表示方式。Mesh R-CNN 使用这两种表示方式来获得更高质量的 3D 结构预测。
网格是 3D 模型的表面,以多边形形式表示,每个多边形可以表示为三角形。网格由通过边连接的顶点组成。边和顶点的连接创建了通常呈三角形的面。这种表示方式有助于更快速的变换和渲染:
图 10.1:多边形网格示例
体素是 3D 版本的 2D 像素。每个图像由 2D 像素组成,因此采用相同的思想来表示 3D 数据是合乎逻辑的。每个体素都是一个立方体,而每个物体则由一组体素组成,其中一些是外部可见部分,另一些则位于物体内部。使用体素更容易可视化 3D 物体,但这并不是唯一的使用场景。在深度学习问题中,体素可以作为 3D 卷积神经网络的输入。
图 10.2:体素示例
Mesh R-CNN 使用两种类型的 3D 数据表示。实验表明,先预测体素,然后将其转换为网格,接着再精细化网格,帮助网络更好地学习。
接下来,我们将查看 Mesh R-CNN 架构,了解上述 3D 数据表示是如何从图像输入中创建的。
Mesh R-CNN 架构
3D 形状检测吸引了许多研究人员的兴趣。虽然已经开发出了许多模型,取得了不错的精度,但它们大多集中于合成基准和孤立物体的检测:
图 10.3:ShapeNet 数据集中的 3D 物体示例
与此同时,2D 目标检测和图像分割问题也取得了快速进展。许多模型和架构以高精度和高速解决了这一问题。已经有方法可以定位物体并检测边界框和掩码。其中一种被称为 Mask R-CNN,是一种用于目标检测和实例分割的模型。该模型处于最前沿,并具有许多现实生活中的应用。
然而,我们看到的是一个 3D 的世界。Mesh R-CNN 论文的作者决定将这两种方法结合成一个单一的解决方案:一个能够检测真实图像中的物体,并输出 3D 网格而非掩码的模型。这个新模型采用最先进的目标检测模型,输入为 RGB 图像,输出物体的类别标签、分割掩码和 3D 网格。作者在 Mask R-CNN 中添加了一个新分支,用于预测高分辨率的三角形网格:
图 10.5:Mesh R-CNN 一般结构
作者的目标是创建一个可以端到端训练的模型。因此,他们采用了最先进的 Mask R-CNN 模型,并为网格预测添加了一个新分支。在深入研究网格预测部分之前,让我们快速回顾一下 Mask R-CNN:
图 10.6:Mask R-CNN 结构(参考文献:arxiv.org/abs/1703.06870
)
Mask R-CNN 以 RGB 图像作为输入,并输出边界框、类别标签和实例分割掩码。首先,图像通过主干网络,该网络通常基于 ResNet —— 例如 ResNet-50-FPN。主干网络输出特征图,作为下一个网络的输入:区域建议网络(RPN)。该网络输出建议。随后,目标分类和掩码预测分支处理这些建议,并分别输出类别和掩码。
Mask R-CNN 的这个结构在 Mesh R-CNN 中也是相同的。然而,最终增加了一个网格预测器。网格预测器是一个由两个分支组成的新模块:体素分支和网格精细化分支。
体素分支将提议和对齐后的特征作为输入,并输出粗略的体素预测。然后,这些预测作为输入提供给网格精细化分支,后者输出最终的网格。体素分支和网格精细化分支的损失与框和掩码的损失相加,模型进行端到端的训练:
图 10.7:Mesh R-CNN 架构
图卷积
在我们研究网格预测器的结构之前,先了解什么是图卷积以及它是如何工作的。
神经网络的早期变体被应用于结构化的欧几里得数据。然而,在现实世界中,大多数数据是非欧几里得的,并具有图结构。近年来,许多神经网络变体已开始适应图数据,其中之一就是卷积网络,称为图卷积网络(GCN)。
网格具有这种图结构,这也是 GCN 可应用于 3D 结构预测问题的原因。CNN 的基本操作是卷积,卷积是通过滤波器进行的。我们使用滑动窗口技术进行卷积,滤波器包括模型应学习的权重。GCN 使用类似的卷积技术,主要区别在于节点数量可以变化,而且节点是无序的:
图 10.8:欧几里得数据和图数据中的卷积操作示例(来源:https://arxiv.org/pdf/1901.00596.pdf)
图 10.9 显示了一个卷积层的示例。网络的输入是图和邻接矩阵,表示正向传播中节点之间的边。卷积层通过聚合其邻域的信息来封装每个节点的信息。之后,应用非线性变换。随后,该网络的输出可用于不同任务,如分类:
图 10.9:卷积神经网络示例(来源:https://arxiv.org/pdf/1901.00596.pdf)
网格预测器
网格预测器模块旨在检测物体的 3D 结构。它是RoIAlign
模块的逻辑延续,负责预测并输出最终的网格。
由于我们从现实生活中的图像中获得 3D 网格,因此无法使用固定网格模板和固定网格拓扑。这就是为什么网格预测器由两个分支组成。体素分支和网格细化分支的结合有助于减少固定拓扑问题。
体素分支类似于 Mask R-CNN 中的掩膜分支。它从 ROIAlign
获取对齐特征,并输出一个 G x G x G 的体素占据概率网格。接下来,使用 Cubify 操作。它使用一个阈值来二值化体素占据。每个被占据的体素都被替换为一个有 8 个顶点、18 条边和 12 个面的立方体三角形网格。
体素损失是二元交叉熵,最小化体素占据预测概率与真实占据之间的差异。
网格细化分支是三个不同操作的序列:顶点对齐、图卷积和顶点细化。顶点对齐类似于 ROI 对齐;对于每个网格顶点,它生成一个图像对齐的特征。
图卷积采用图像对齐的特征,并沿网格边缘传播信息。顶点细化更新顶点位置。它的目的是在保持拓扑固定的情况下更新顶点几何:
图 10.10:网格细化分支
如图 10.10所示,我们可以进行多阶段的细化。每个阶段包括顶点对齐、图卷积和顶点细化操作。最终,我们得到一个更精确的 3D 网格。
模型的最终重要部分是网格损失函数。对于这个分支,使用了 Chamfer 和法向损失。然而,这些技术需要从预测和真实网格中采样点。
使用以下网格采样方法:给定顶点和面,从网格表面的概率分布中均匀采样点。每个面的概率与其面积成正比。
使用这些采样技术,从真实的点云 Q 和预测的点云 P 中进行采样。接下来,我们计算 ΛPQ,这是成对(p,q)的集合,其中 q 是 P 中 p 的最近邻。
计算 P 和 Q 之间的 Chamfer 距离:
接下来,计算绝对法向距离:
这里,up 和 uq 分别是指向 p 和 q 点的单位法向量。
然而,只有这两种损失退化了网格。这就是为什么在高质量网格生成中,加入了一个形状正则化器,这被称为边缘损失:
最终的网格损失是三种损失的加权平均:Chamfer 损失、法线损失和边缘损失。
在训练方面,进行了两种类型的实验。第一个实验是检查网格预测分支。在这里,使用了 ShapeNet 数据集,该数据集包含 55 种常见的类别。这在 3D 形状预测的基准测试中被广泛使用;然而,它包含了 CAD 模型,这些模型有独立的背景。由于这一点,网格预测器模型达到了最先进的状态。此外,它还解决了以前的模型无法很好检测到的有孔物体的问题:
图 10.11:ShapeNet 数据集上的 Mesh 预测器
第三行表示网格预测器的输出。我们可以看到它预测了 3D 形状,并且很好地处理了物体的拓扑和几何结构:
图 10.12:端到端 Mesh R-CNN 模型的输出
下一步是对实际图像进行实验。为此,我们使用了 Pix3D 数据集,该数据集包含 395 个独特的 3D 模型,分布在 10,069 张真实图像中。在这种情况下,基准结果不可用,因为作者是首批尝试此技术的人。然而,我们可以查看图 10.11中的训练输出结果。
到此为止,我们已经讨论了 Mesh R-CNN 的架构。接下来,我们可以动手实践,使用 Mesh R-CNN 在测试图像中找到物体。
PyTorch 实现的 Mesh R-CNN 演示
在本节中,我们将使用 Mesh R-CNN 的代码库来运行演示。我们将尝试将模型应用到我们的图像上,并渲染输出的.obj
文件,看看模型是如何预测 3D 形状的。此外,我们还将讨论模型的训练过程。
安装 Mesh R-CNN 非常简单。你需要先安装 Detectron2 和 PyTorch3D,然后构建 Mesh R-CNN。Detectron2
是 Facebook Research 推出的一个库,提供最先进的检测和分割模型。它还包括 Mask R-CNN 模型,Mesh R-CNN 正是基于这个模型构建的。你可以通过运行以下命令来安装detectron2
:
python -m pip install 'git+https://github.com/facebookresearch/detectron2.git'
如果这个方法对你不起作用,可以查看官网了解其他安装方式。接下来,你需要按照本书前面的内容安装 PyTorch3D。当这两个要求都准备好后,你只需构建 Mesh R-CNN:
git clone https://github.com/facebookresearch/meshrcnn.git
cd meshrcnn && pip install -e .
演示
该代码库包括一个demo.py
文件,用于演示 Mesh R-CNN 的端到端过程。该文件位于meshrcnn/demo/demo.py
。我们来看一下代码,了解演示是如何进行的。该文件包括VisualizationDemo
类,包含两个主要方法:run_on_image
和visualize_prediction
。方法名本身就说明了它们的功能:第一个方法以图像作为输入,并输出模型的预测结果,而第二个方法则可视化遮罩的检测,然后保存最终的网格以及带有预测和置信度的图像:
python demo/demo.py \
--config-file configs/pix3d/meshrcnn_R50_FPN.yaml \
--input /path/to/image \
--output output_demo \
--onlyhighest MODEL.WEIGHTS meshrcnn://meshrcnn_R50.pth
对于演示,您只需要从终端运行前面的命令。该命令有以下参数:
-
--config-file
指定配置文件的路径,可以在configs
目录中找到该文件。 -
--input
指定输入图像的路径 -
--output
指定保存预测结果的目录路径 -
--onlyhighest
,如果为True
,则仅输出具有最高置信度的一个网格和遮罩。
现在,让我们运行并检查输出。
对于演示,我们将使用上一章中使用的公寓图像:
图 10.13:网络的输入图像
我们将此图像的路径传递给demo.py
。预测完成后,我们得到该图像的遮罩可视化和网格。由于我们使用了--onlyhighest
参数,我们只得到了一个遮罩,这是沙发物体的预测,置信度为 88.7%。遮罩预测是正确的——它几乎覆盖了整个沙发:
图 10.14:demo.py 文件的输出
除了遮罩外,我们还在同一目录中得到了网格,它是一个.obj
文件。现在,我们需要从 3D 对象渲染图像。
以下代码来自chapt10/viz_demo_results.py
文件:
-
首先,让我们导入代码中使用的所有库:
import torch import numpy as np import matplotlib.pyplot as plt from pytorch3d.io import load_obj from pytorch3d.structures import Meshes from pytorch3d.renderer import ( FoVPerspectiveCameras, look_at_view_transform, look_at_rotation, RasterizationSettings, MeshRenderer, MeshRasterizer, BlendParams, SoftSilhouetteShader, HardPhongShader, PointLights, TexturesVertex, ) import argparse
-
接下来,我们将定义运行代码所需的参数:
parser = argparse.ArgumentParser() parser.add_argument('--path_to_mesh', default="./demo_results/0_mesh_sofa_0.887.obj") parser.add_argument('--save_path', default='./demo_results/sofa_render.png') parser.add_argument('--distance', default=1, help = 'distance from camera to the object') parser.add_argument('--elevation', default=150.0, help = 'angle of elevation in degrees') parser.add_argument('--azimuth', default=-10.0, help = 'rotation of the camera') args = parser.parse_args()
我们需要为path_to_mesh
提供输入——即demo.py
的输出.obj
文件。我们还需要指定渲染输出应保存的路径,然后指定相机的距离、俯仰角度和旋转。
-
接下来,我们需要加载并初始化网格对象。首先,我们需要使用
pytorch3d
中的load_obj
函数加载.obj
文件。然后,我们需要将顶点设置为白色。我们将使用pytorch3d
中的Meshes
结构来创建网格对象:# Load the obj and ignore the textures and materials. verts, faces_idx, _ = load_obj(args.path_to_mesh) faces = faces_idx.verts_idx # Initialize each vertex to be white in color. verts_rgb = torch.ones_like(verts)[None] # (1, V, 3) textures = TexturesVertex(verts_features=verts_rgb.to(device)) # Create a Meshes object for the sofa. Here we have only one mesh in the batch. sofa_mesh = Meshes( verts=[verts.to(device)], faces=[faces.to(device)], textures=textures )
-
下一步是初始化透视相机。然后,我们需要设置混合参数,用于混合面。
sigma
控制不透明度,而gamma
控制边缘的锐利度:cameras = FoVPerspectiveCameras(device=device) blend_params = BlendParams(sigma=1e-4, gamma=1e-4)
-
接下来,我们需要定义光栅化和着色的设置。我们将把输出图像的大小设置为 256*256,并将
faces_per_pixel
设置为 100,这将把 100 个面混合成一个像素。然后,我们将使用光栅化设置来创建轮廓网格渲染器,方法是组合光栅化器和着色器:raster_settings = RasterizationSettings( image_size=256, blur_radius=np.log(1\. / 1e-4 - 1.) * blend_params.sigma, faces_per_pixel=100, ) silhouette_renderer = MeshRenderer( rasterizer=MeshRasterizer( cameras=cameras, raster_settings=raster_settings ), shader=SoftSilhouetteShader(blend_params=blend_params) )
-
我们需要创建另一个
RasterizationSettings
对象,因为我们还将使用 Phong 渲染器。它只需要每个像素混合一个面。再次强调,图像输出将是 256。然后,我们需要在物体前面添加一个点光源。最后,我们需要初始化 Phong 渲染器:raster_settings = RasterizationSettings( image_size=256, blur_radius=0.0, faces_per_pixel=1, ) lights = PointLights(device=device, location=((2.0, 2.0, -2.0),)) phong_renderer = MeshRenderer( rasterizer=MeshRasterizer( cameras=cameras, raster_settings=raster_settings ), shader=HardPhongShader(device=device, cameras=cameras, lights=lights) )
-
现在,我们必须根据球面角度创建摄像机的位置。我们将使用
look_at_view_transform
函数,并添加之前提到的distance
、elevation
和azimuth
参数。最后,我们必须通过给轮廓和 Phong 渲染器提供网格和摄像机位置作为输入,来获取渲染输出:R, T = look_at_view_transform(distance, elevation, azimuth, device=device) # Render the sofa providing the values of R and T. silhouette = silhouette_renderer(meshes_world=sofa_mesh, R=R, T=T) image_ref = phong_renderer(meshes_world=sofa_mesh, R=R, T=T)
-
最后一步是可视化结果。我们将使用
matplotlib
来绘制两个渲染图像:plt.figure(figsize=(10, 10)) plt.subplot(1, 2, 1) plt.imshow(silhouette.squeeze()[..., 3]) plt.grid(False) plt.subplot(1, 2, 2) plt.imshow(image_ref.squeeze()) plt.grid(False) plt.savefig(args.save_path)
前面的代码输出将是一个.png
图像,保存在给定的save_path
文件夹中。对于此参数和此处呈现的图像,渲染的网格将是这样的:
图 10.16:模型的渲染 3D 输出
从这个角度来看,网格与沙发非常相似,忽略不可见部分的某些缺陷。您可以调整摄像机位置和光照,以从另一个角度渲染该物体的图像。
该代码库还提供了运行并重现 Mesh R-CNN 论文中描述的实验的机会。它允许您运行 Pix3D 实验和 ShapeNet 实验。
如前所述,Pix3D 数据集包含不同 IKEA 家具的真实生活图像。该数据用于对整个 Mesh R-NN 进行端到端的评估。
要下载这些数据,您需要运行以下命令:
datasets/pix3d/download_pix3d.sh
数据包含两个分割,分别是 S1 和 S2,且代码库为这两个分割提供了权重。下载数据后,您可以通过运行以下命令来重现训练过程:
python tools/train_net.py \
--config-file configs/pix3d/meshrcnn_R50_FPN.yaml \
--eval-only MODEL.WEIGHTS /path/to/checkpoint_file
您只需要小心配置文件。原始模型是在 8 GB GPU 上分发并训练的。如果您的 GPU 容量不足,可能无法达到相同的准确度,因此您需要调整超参数以获得更好的准确度。
您可以使用自己训练的权重,或者可以简单地对作者提供的预训练模型进行评估:
python tools/train_net.py \
--config-file configs/pix3d/meshrcnn_R50_FPN.yaml \
--eval-only MODEL.WEIGHTS /path/to/checkpoint_file
前面的命令将评估指定检查点文件的模型。您可以通过访问模型的 GitHub 仓库找到检查点。
接下来,如果您想在 ShapeNet 上运行实验,您需要下载数据,可以通过运行以下命令来完成:
datasets/shapenet/download_shapenet.sh
这将下载训练集、验证集和测试集。作者还提供了 ShapeNet 数据集的预处理代码。预处理将减少加载时间。以下命令将输出压缩数据,便于在集群中进行训练:
python tools/preprocess_shapenet.py \
--shapenet_dir /path/to/ShapeNetCore.v1 \
--shapenet_binvox_dir /path/to/ShapeNetCore.v1.binvox \
--output_dir ./datasets/shapenet/ShapeNetV1processed \
--zip_output
接下来,要重现实验,你只需要运行带有相应配置的 train_net_shapenet.py
文件。再次提醒,在调整训练过程时,要小心硬件的能力:
python tools/train_net_shapenet.py --num-gpus 8 \
--config-file configs/shapenet/voxmesh_R50.yaml
最后,你可以通过运行以下命令来评估你的模型,或者作者提供的检查点:
python tools/train_net_shapenet.py --num-gpus 8 \
--config-file configs/shapenet/voxmesh_R50.yaml
你可以将你的结果与论文中提供的结果进行比较。下图展示了作者得到的尺度归一化协议训练结果:
图 10.17:在 ShapeNet 数据集上评估的结果
该图包括了类别名称、每个类别的实例数量、Chamfer 距离、法线损失和 F1 分数。
总结
在本章中,我们提出了一种全新的物体检测任务视角。3D 世界需要相应的解决方案,而这是朝着这一目标迈出的首个尝试之一。通过理解架构和模型结构,我们了解了 Mesh R-CNN 是如何工作的。我们深入探讨了该模型中使用的一些有趣操作和技术,如图卷积网络、Cubify 操作、网格预测器结构等。最后,我们了解了该模型如何在实践中用于检测网络从未见过的图像中的物体。我们通过渲染 3D 物体来评估结果。
本书贯穿了从基础到更高级的 3D 深度学习概念。首先,我们学习了各种 3D 数据类型和结构。然后,我们深入探讨了不同类型的模型,这些模型解决了不同类型的问题,如网格检测、视图合成等。此外,我们还将 PyTorch 3D 添加到了计算机视觉工具箱中。通过完成本书的学习,你应该能够应对与 3D 计算机视觉相关的现实世界问题,甚至更多。