Processing-OpenCV-计算机视觉高级教程-全-
Processing OpenCV 计算机视觉高级教程(全)
原文:Pro Processing for Images and Computer Vision with OpenCV
一、Processing 和 OpenCV 入门
本章向您介绍 Processing 和 OpenCV 以及如何安装它们。在本章结束时,你将对通过遵循本书中的示例可以构建的应用类型有一个大致的了解。您还将能够编写一个“Hello World”程序,在处理编程环境中显示 OpenCV 的版本信息。
处理
来自麻省理工学院媒体实验室前美学+计算小组的本·弗莱和凯西·雷阿斯在 2001 年发起了处理项目( http://processing.org
),为艺术家和设计师创造一个编程环境,以在电子艺术的背景下学习计算机编程的基础。基于 Java 编程语言,Processing 被建模为电子速写本,供艺术家和设计师产生他们的创造性想法。处理是通过集成开发环境(IDE)实现的。用户可以直接在环境中编码并执行代码,以实时查看可视化结果。Processing 配备了一套全面的框架和库,以提供对用于创建 2D 和 3D 图形以及构建动画的功能的简单访问。选择 Java 作为编程语言是为了满足跨平台的兼容性。目前,它支持 macOS、Windows 和主要的 Linux 操作系统。最近,处理已经发展到包括其他编程语言,如 JavaScript 和 Python。
除了处理语言的核心功能和大量的原生 Java 库之外,处理还支持来自社区的用户贡献的库( https://processing.org/reference/libraries/
)。许多库的建立是为了隐藏实现复杂软件的技术细节,如物理引擎和机器学习算法,或者支持附加的硬件设备,如 Kinect 摄像头。例如,我开发了一个名为Kinect4WinSDK
的包装库,通过官方的 Kinect for Windows 软件开发工具包(SDK)来支持 Kinect 版本 1 相机。
在计算机图形领域,处理能够产生矢量图形和光栅图形。在创造性应用中,算法艺术和生成艺术(图 1-1 )经常会用到矢量图形。在本书中,重点是图像处理和计算机视觉。在这种情况下,光栅图形将是生成图像的主要方法。
图 1-1。
Algorithmic art example
开放计算机视觉
开源计算机视觉库(OpenCV, http://opencv.org/
)始于 1999 年左右,是英特尔的一项研究计划。现在,它是最受欢迎的计算机视觉和机器学习开源软件库。一开始是一套图像处理和计算机视觉的 C 库函数。现在,它有 C++、Python、Java 和 MATLAB 绑定,可以在 macOS、Windows、Linux、Android 和 iOS 上工作,并有 CUDA 和 OpenCL 的加速支持。OpenCV 库附带了一组模块。每个模块在图像处理、计算机视觉和机器学习的保护伞下处理一组特定的应用。以下是常用模块:
core
:核心 OpenCV 数据结构和功能imgproc
:图像处理imgcodecs
:图像文件读写videoio
:媒体输入/输出程序highgui
:高级图形用户界面video
:视频分析calib3d
:摄像机标定和三维重建features2d
:处理 2D 特征描述和匹配objdetect
:人脸等目标检测ml
:机器学习flann
:高维空间中的聚类和搜索photo
:计算摄影stitching
:将图像拼接在一起shape
:形状匹配superres
:超分辨率增强videostab
:视频稳定viz
: 3D 可视化
OpenCV 包括几个额外的模块,提供额外的功能,如文本识别、表面匹配和 3D 深度处理。本书还涵盖了执行光流分析的模块optflow
。
加工设备
本节说明下载和安装加工编程环境的步骤。在撰写本文时,最新的处理版本是 3.2.3。出于兼容性原因,建议您使用版本 3 而不是以前版本的处理。每一个分发的处理还包括与 Java 运行时代码。这三个平台的安装过程简单而相似。
安装处理
从 https://processing.org/download/
下载加工代码。在本书中,我将使用 64 位版本。如果想看看处理源代码,可以从 GitHub 发行版( https://github.com/processing/processing
)下载。以下是适用于 macOS、Windows 和 Linux 平台的三个文件:
processing-3.2.3-macosx.zip
processing-3.2.3-windows64.zip
processing-3.2.3-linux64.tgz
处理不假设任何特定的位置来安装软件。对于 macOS,您可以下载该文件并将其展开到名为 Processing 的 macOS 程序中。将程序复制到Applications
文件夹,类似于为 macOS 安装的其他应用。对于 Windows 和 Linux,压缩文件将被展开到一个名为processing-3.2.3
的文件夹中。您可以下载压缩文件并将其展开到您想要维护处理软件的任何文件夹中。在本书中,我们将文件夹processing-3.2.3
展开成用户的Documents
文件夹。图 1-2 显示了文件夹的内容。要运行处理,只需双击处理图标。
图 1-2。
Processing folder for Windows
图 1-3 显示了处理 IDE 的默认屏幕布局。窗口中的代码将是您要测试的第一个处理程序。
图 1-3。
Processing IDE screen
void setup() {
size(800, 600);
}
void draw() {
background(100, 100, 100);
}
开始加工后,它会自动在您的个人Documents
文件夹中创建一个文件夹,保存所有的加工程序。对于 macOS,它的名字是/Users/bryan/Documents/Processing
。在 Windows 中,文件夹名为C:\Users\chung_000
\Documents\Processing
。在 Linux 中,它是/home/bryan/sketchbook
。(在示例中,用户名是bryan
或chung_000
。)图 1-4 显示了文件夹内容的示例视图。
图 1-4。
Processing sketchbook folder contents
每个加工程序都保存在Processing
文件夹中自己的文件夹中。除了每个程序之外,该文件夹还包含其他子文件夹,例如用于从处理发行版下载外部库的libraries
文件夹和用于在处理中实现其他语言(如 Python 和 JavaScript)的modes
文件夹。
运行处理
在处理 IDE 的左上角,有两个按钮,播放和停止。单击播放按钮将开始程序的编译和执行。图 1-5 显示了你的第一个程序创建的空白屏幕。此时点击停止按钮将停止执行并关闭窗口。
图 1-5。
First Processing program
您需要为本书中的练习安装一个额外的库。它就是构建在开源多媒体框架 GStreamer ( https://gstreamer.freedesktop.org/
)之上的video
库。处理将使用它来播放数字视频,如 MP4 文件,并用网络摄像头捕捉直播视频。要安装库(图 1-6 ),从主菜单中选择草图➤导入库➤添加库。从贡献管理器窗口中,选择视频库并单击安装按钮。该库将被下载到您的处理文件夹的libraries
子文件夹中。
图 1-6。
Installing the video library
在第二章中,您将使用这个库来加载外部数字视频和从网络摄像头捕捉实时视频流。安装处理编程环境后,您可以继续在您的系统上安装 OpenCV。
OpenCV 安装
OpenCV 的安装有点复杂,因为您将从源代码构建 OpenCV 库。您将要构建的库不同于现有的由 Greg Borenstein ( https://github.com/atduskgreg/opencv-processing
)编写的 OpenCV for Processing 库。在继续这个安装过程之前,您最好删除现有的 OpenCV for Processing 库。OpenCV 发行版包含了所有的核心函数。要使用其他函数进行运动分析,您还需要构建在贡献的库中维护的额外模块。您将从 GitHub 库下载这两个版本。最初的 OpenCV 源代码在 https://github.com/opencv/opencv
,额外模块的源代码在 https://github.com/opencv/opencv_contrib
。请注意,OpenCV 存储库中的主分支只包含 2.4 版。要使用 3.1 版,您需要选择 3.1.0 标签,如图 1-7 所示。选择正确的版本标签后,点击“克隆或下载”按钮,然后点击下载 ZIP 按钮,即可下载 OpenCV 源代码,如图 1-8 所示。
图 1-8。
Downloading the OpenCV source
图 1-7。
Selecting the tag 3.1.0
下载并解压缩 OpenCV 源代码后,该过程将创建opencv-3.1.0
文件夹。对于opencv_contrib
源,按照相同的步骤选择 3.1.0 标签,下载 zip 文件,并解压到您的opencv-3.1.0
文件夹中。图 1-9 显示了opencv-3.1.0
文件夹的内容。
图 1-9。
Contents of the opencv-3.1.0 folder
成功下载 OpenCV 3.1.0 源代码和额外模块库后,可以在opencv-3.1.0
文件夹中创建一个名为build
的子文件夹。所有 OpenCV 库都将被构建到这个文件夹中。在开始构建过程之前,还有一个步骤需要处理。要构建包含额外模块optflow
的 Java 库,您将使用它进行运动分析,您必须编辑它的CMakeLists.txt
文件。从opencv_contrib-3.1.0
文件夹,进入modules
文件夹,然后进入optflow
文件夹。使用任何文本编辑器修改optflow
文件夹中的CMakeLists.txt
文件。在第二行,原始代码如下:
ocv_define_module(optflow opencv_core opencv_imgproc opencv_video opencv_highgui opencv_ximgproc WRAP python)
在两个关键字WRAP
和python
之间插入记号java
。新生产线将如下所示:
ocv_define_module(optflow opencv_core opencv_imgproc opencv_video opencv_highgui opencv_ximgproc WRAP java python)
新文件将使构建过程能够将optflow
模块包含到已构建的 Java 库中。以下部分根据您使用的平台描述不同的构建过程。既然您要构建 OpenCV Java 库,那么您也应该从 Oracle 网站 www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html
下载并安装 Java 开发工具包(JDK)。要检查您是否已经安装了 JDK,您可以进入终端或命令行会话并键入以下内容:
javac -version
苹果
你将使用自制软件安装必要的相关软件。安装过程将从命令行终端会话中执行。终端工具在/Applications/Utilities
文件夹中。家酿安装说明在官方网站 http://brew.sh/
上。安装了家酿软件包管理器后,您可以开始安装 OpenCV 构建过程所需的软件。在终端会话中,输入以下内容:
brew install cmake
brew install ant
这两个命令安装软件cmake
和ant
。cmake
工具( http://cmake.org
)是一个构建、测试和打包软件的开源工具。Apache ant
工具( http://ant.apache.org
)是构建 Java 应用的实用工具。下一步是使用ccmake
交互工具开始配置过程。首先导航到原 OpenCV 文件夹opencv-3.1.0
的build
文件夹,发出ccmake
命令,如图 1-10 所示。
图 1-10。
ccmake command to configure the build process
ccmake ..
在ccmake
面板中,输入c
来配置安装过程。选择合适的选项,如图 1-11 所示。请注意,您应该首先关闭第一页上的大多数选项,包括BUILD_SHARED_LIBS
选项。接下来打开BUILD_opencv_java
选项,如图 1-12 和图 1-13 所示。
图 1-13。
Third page of the build options
图 1-12。
Second page of the build options
图 1-11。
BUILD_SHARED_LIBS and other options
下一个重要的选项是OPENCV_EXTRA_MODULES_PATH
,它应该被设置为 OpenCV 额外模块的路径名。具体来说,应该是你原来的opencv-3.1.0
文件夹里面的文件夹opencv_contrib-3.1.0/modules
,如图 1-14 所示。
图 1-14。
OPENCV_EXTRA_MODULES_PATH option
其余的构建选项如下图所示:图 1-15 ,图 1-16 ,图 1-17 ,图 1-18 。
图 1-18。
Last page of OpenCV build options
图 1-17。
OpenCV build options, continued
图 1-16。
OpenCV build options, continued
图 1-15。
OpenCV build options
填写完第一轮构建选项后,再次键入c
来配置额外的模块。首先关闭BUILD_FAT_JAVA_LIB
选项,如图 1-19 所示。
图 1-19。
OpenCV extra modules build options
为了继续本书后面的光流示例,你还应该打开BUILD_opencv_optflow
、BUILD_opencv_ximgproc
和BUILD_opencv_java
的选项,如图 1-20 和图 1-21 所示。
图 1-21。
Turning on the option for ximgproc
图 1-20。
Turning on options for Java and optflow
完成剩余的额外模块选项,如图 1-22 所示。
图 1-22。
Extra module options
设置好所有选项后,再次输入c
完成最后一个配置任务。键入选项g
生成配置文件(图 1-23 )。ccmake
程序将退出并带你回到终端窗口。
图 1-23。
Generating the configuration file
输入以下命令开始构建过程:
make –j4
当构建过程成功完成时,导航到build
文件夹中的bin
文件夹。找到opencv-310.jar
文件。然后导航到build
文件夹中的lib
文件夹。找到libopencv_java310.so
文件。改名为libopencv_java310.dylib
。将这两个文件复制到单独的文件夹中。您将准备 Windows 和 Linux 版本,并将它们复制到同一个文件夹中,以创建多平台库。作者已经测试了在 macOS 10.11,El Capitan 中构建 OpenCV 3.1。对于使用新 macOS 10.12 Sierra 的读者来说,OpenCV 3.1 的构建将由于 QTKit 的移除而失败。这种情况下,最好是 OpenCV 3.2 配合 macOS 10.12 一起使用。请参考 www。神奇的爱情。com/blog/2017/03/02/OpenCV-3-2-Java-build/
用 OpenCV 3.2 正确生成optflow
模块。
Windows 操作系统
在 Windows 系统上,您使用图形版本的cmake
来配置安装过程。我已经在 Windows 8.1 和 Windows 10 中测试了安装。为 OpenCV 构建过程下载并安装以下软件包:
- 微软 Visual Studio 社区 2015 在
https://www.visualstudio.com/downloads/
- CMake at
https://cmake.org/download/
- 阿帕奇蚂蚁
http://ant.apache.org/bindownload.cgi
www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html
甲骨文 JDK 8- 蟒于
https://www.python.org/downloads/
成功安装软件包依赖项后,运行 CMake ( cmake-gui
)程序开始配置过程。填写 OpenCV 发行版的源文件夹名和构建文件夹名,如图 1-24 所示。请记住,您需要在 OpenCV 分发文件夹中创建构建文件夹。
图 1-24。
Folder names of OpenCV distribution in the CMake window
单击“配置”按钮开始配置。对于第一个生成器面板,从下拉菜单中选择 Visual Studio 14 2015 Win64,选择“使用默认本机编译器”单选按钮,如图 1-25 所示。单击“完成”按钮继续。
图 1-25。
Choosing the default compiler
按照图 1-26 到图 1-33 进入构建选项。确保首先关闭BUILD_SHARED_LIBS
选项,并为ANT_EXECUTABLE
选项输入ant.bat
的路径名。
图 1-33。
Eighth page of OpenCV build options
图 1-32。
Seventh page of OpenCV build options
图 1-31。
Sixth page of OpenCV build options
图 1-30。
Fifth page of OpenCV build options
图 1-29。
Fourth page of OpenCV build options
图 1-28。
Third page of OpenCV build options
图 1-27。
Second page of OpenCV build options
图 1-26。
Turning off the BUILD_SHARED_LIBS option
在下一个屏幕中(图 1-34 ,输入OPENCV_EXTRA_MODULES_PATH
选项的opencv_contrib
额外模块的路径名。图 1-35 至 1-37 显示了其余的设置。
图 1-37。
The last page of OpenCV build options
图 1-36。
Eleventh page of OpenCV build options
图 1-35。
Tenth page of OpenCV build options
图 1-34。
OPENCV_EXTRA_MODULES_PATH option
单击“配置”按钮创建配置详细信息。在红色区域,确保启用选项BUILD_opencv_java
、BUILD_opencv_optflow
和BUILD_opencv_ximgproc
(图 1-38 和 1-39 )。将剩余的额外模块保留为空选项。
图 1-39。
BUILD_opencv_ximgproc option
图 1-38。
BUILD_opencv_java and BUILD_opencv_optflow options
再次单击“配置”按钮以完成配置过程。设置完所有配置选项后,单击“生成”按钮创建 Visual Studio 解决方案文件。完成后,退出 CMake 程序。在build
文件夹中,启动 Visual Studio 解决方案OpenCV.sln
(图 1-40 )。
图 1-40。
OpenCV Visual Studio solution file
在 Visual Studio 程序中,从解决方案配置菜单中选择发布(图 1-41);从解决方案平台菜单中选择 x64。
图 1-41。
Visual Studio project options
从解决方案资源管理器中,展开 CMakeTargets 然后右键单击 ALL_BUILD 目标并选择 BUILD(图 1-42 )。
图 1-42。
Choosing the OpenCV build target
成功构建解决方案后,退出 Visual Studio 并导航到build
文件夹。在bin
文件夹中,你会看到opencv-310.jar
文件,如图 1-43 所示。
图 1-43。
OpenCV Windows build file
双击打开bin
文件夹内的Release
文件夹;OpenCV Windows 原生库opencv_java310.dll
将驻留在那里,如图 1-44 所示。
图 1-44。
OpenCV Windows native library file
Linux 操作系统
书中测试的 Linux 发行版是 Ubuntu 16.04。对于 Linux 系统,可以使用apt-get
命令安装相关软件包。OpenCV 3.1.0 文档中有一页描述了详细的安装过程。你可以在 http://docs.opencv.org/3.1.0/d7/d9f/tutorial_linux_install.html
找到参考。在安装 OpenCV 之前,您需要设置适当的 Java 环境。在本书中,您将在 Linux 安装中使用 Oracle JDK 8。要获得正确的版本,请在终端会话中输入以下内容:
sudo add-apt-repository ppa:webupd8team/java
sudo apt-get update
sudo apt-get install oracle-java8-installer
在 Java 安装之后,您需要为 OpenCV 构建过程设置适当的环境变量JAVA_HOME
。使用文本编辑器编辑环境文件。
sudo gedit /etc/environment
在文件末尾,插入以下一行:
JAVA_HOME="/usr/lib/jvm/java-8-oracle"
保存并退出环境文件,然后用以下内容重新加载它:
source /etc/environment
使用echo
命令验证环境变量是否设置正确。它应该返回 Java 安装的正确位置。
echo $JAVA_HOME
成功安装 JDK 后,您可以使用apt-get
继续安装 OpenCV 的相关软件包。
sudo apt-get install ant build-essential cmake git libgtk-2.0-dev pkg-config libavcodec-dev, libavformat-dev libswscale-dev python-dev execstack
为了简化构建过程,可以为cmake
安装图形用户界面,这样就可以使用ccmake
来构建 OpenCV。
sudo apt-get install cmake-curses-gui
首先,导航到 OpenCV 发行版文件夹opencv-3.1.0
中的build
文件夹。使用ccmake
命令启动配置过程。
ccmake ..
在菜单屏幕上,键入c
开始自动配置过程。从图 1-45 开始,按照以下截图所示填写选项。
图 1-45。
BUILD_SHARED_LIBS option
请务必打开BUILD_opencv_java
选项,如图 1-46 所示。
图 1-46。
BUILD_opencv_java option
然后在OPENCV_EXTRA_MODULES_PATH
选项中输入 OpenCV 额外模块的路径信息(图 1-47 )。
图 1-47。
OPENCV_EXTRA_MODULES_PATH option
通过打开WITH_GTK
选项(图 1-48 )包括 GTK 对配置的支持。
图 1-48。
WITH_GTK option
继续其余的配置选项,如图 1-49 和图 1-50 所示。
图 1-50。
WITH_V4L option
图 1-49。
WITH_LIBV4L option
在输入最后一个构建选项后,键入c
运行带有额外模块的配置。为BUILD_FAT_JAVA_LIB
选项选择OFF
(图 1-51 )。进入BUILD_opencv_optflow
和BUILD_opencv_ximgproc
的ON
选项。
图 1-51。
BUILD_FAT_JAVA_LIB and BUILD_opencv_optflow options
将其余的额外模块设置为OFF
(图 1-52 )。
图 1-52。
BUILD_opencv_ximgproc option
再次键入c
运行最终配置。键入g
生成所有构建文件并退出(图 1-53 )。
图 1-53。
Final configuration
通过输入以下命令开始构建过程:
make -j4
OpenCV 构建成功后,导航到当前build
文件夹下的bin
文件夹(图 1-54 )。现货opencv-3.1.0.jar
文件。
图 1-54。
Location of opencv-310.jar
然后将目录更改为build
文件夹中的lib
文件夹。找到libopencv_java310.so
文件。在终端会话中,对 OpenCV 库文件运行execstack
,清除可执行堆栈标志(图 1-55 )。
图 1-55。
Location of libopencv_java310.so
execstack -c ./libopencv_java310.so
试运转
安装 Processing 并构建 OpenCV 的 Java 版本后,您可以开始编写两个程序来验证安装并尝试 OpenCV 库的功能。第一个程序是一个“Hello World”练习,用来显示 OpenCV 库的版本信息。第二个程序将定义一个 OpenCV 矩阵数据结构。
你好世界
创建一个名为Chapter01_02
的新加工草图。在 IDE 的菜单栏中,选择草图➤显示草图文件夹,如图 1-56 所示。
图 1-56。
Show Sketch Folder menu item in Processing IDE
在弹出窗口中,创建一个名为code
的新文件夹。将所有 OpenCV Java 库文件复制到其中。它应该包含以下文件。您可以只保留生成的三个平台中的一个opencv-310.jar
文件。或者,你可以只复制与你正在使用的操作系统相关的本地库,比如 macOS 的libopencv_java310.dylib
,Linux 的libopencv_java310.so
,或者 Windows 的opencv_java310.dll
。
opencv-310.jar
libopencv_java310.dylib
libopencv_java310.so
opencv_java310.dll
在 IDE 主窗口中,键入以下代码,然后单击播放按钮执行:
import org.opencv.core.Core;
void setup() {
size(640, 480);
println(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
}
void draw() {
background(100, 100, 100);
}
这将在 IDE 窗口的底部返回 OpenCV 本地库名称opencv_java310
和版本号 3.1.0。这个位置就是控制台窗口,也是显示消息的地方,如图 1-57 所示。第一个import
语句导入 OpenCV core
模块供后续引用。在setup
函数中,size
函数为程序定义了 Java 窗口的大小。两个println
语句显示两个常量Core.NATIVE_LIBRARY_NAME
和Core.VERSION
的内容。下一条语句System.loadLibrary
从code
文件夹加载本地库。draw
例程只有一个功能,将窗口背景画成灰色。
图 1-57。
Displaying OpenCV information in Processing
矩阵示例
从之前的“Hello world”练习中,选择文件菜单;选择另存为以新名称Chapter01_03
保存程序。在这种情况下,code
文件夹中的内容将被复制到新程序中。在本练习中使用以下代码:
import org.opencv.core.Core;
import org.opencv.core.Mat;
import org.opencv.core.CvType;
void setup() {
size(640, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
Mat m = Mat.eye(3, 3, CvType.CV_8UC1);
println("Content of the matrix m is:");
println(m.dump());
}
void draw() {
background(100, 100, 100);
}
三个import
语句包括 OpenCV 核心组件的定义、矩阵数据类和矩阵元素的数据类型。新语句定义了一个矩阵m
,是一个三行三列的单位矩阵。
Mat m = Mat.eye(3, 3, CvType.CV_8UC1);
矩阵中的每个数据元素都是 8 位无符号字符(一个字节)。第二个println
语句将转储矩阵m
的内容。(下一章将有关于矩阵数据结构及其表示和使用的更详细的解释)。
println(m.dump());
图 1-58 显示了println
语句在加工窗口中显示的内容。
图 1-58。
Displaying matrix information
结论
本章指导您在三个最常见的平台上安装 Processing 和 OpenCV,这三个平台是 macOS、Microsoft Windows 和 Ubuntu Linux。此时,您应该能够准备好环境,继续处理图像处理和计算机视觉任务。下一章将描述数字图像在 Processing (Java)和 OpenCV 中的表示。
二、图像源和表示
本章解释了在 Processing 中创建数字图像的过程,以及在 Processing 和 OpenCV 中光栅图像的内部表示。它演示了如何将外部图像导入到程序中,以及如何使用不同类型的图像处理函数。作为数字图像的一种扩展形式,数字视频和实时视频流的使用将在本章后面的章节中介绍。以下是本章涵盖的基本概念:
- 数字图像基础
- 正在处理的图像
- 处理中的移动图像
- OpenCV 中的矩阵和图像
- Processing 和 OpenCV 之间的图像转换
数字图像基础
我通常用网格的比喻来表示一幅数字图像。图像的尺寸等于网格的大小,宽度代表列数,高度代表行数。因此,网格内的单元数等于宽度×高度。网格中的每个单元格都是一个彩色像素。对于灰度图像,像素是表示灰色调强度的数字。如果使用一个字节的数据来表示每个像素,灰度将在 0 到 255 的范围内。图 2-1 表示八列六行的灰度图像。
图 2-1。
Grayscale image representation
对于彩色图像,像素是一组代表各个颜色通道强度的数字。常见的颜色表示是 RGB(红、绿、蓝)和 HSB(色调、饱和度、亮度)。为了在 Processing 和 OpenCV 之间架起颜色格式的桥梁,本书主要采用了 ARGB 表示法。每个颜色像素有四个独立的颜色通道,即 alpha(透明度)、红色、绿色和蓝色。例如,完全不透明的红色将是(255,255,0,0),或者用十六进制表示法#FFFF0000。
总而言之,您使用宽度和高度来描述数字图像的尺寸,使用通道来描述每个像素中颜色元素的数量,使用深度来描述表示每种颜色的数据位数。
正在处理的图像
您用来处理数字图像的主要处理类是PImage
( https://processing.org/reference/PImage.html
)。它是仿照 Java 中的BufferedImage
类( https://docs.oracle.com/javase/8/docs/api/java/awt/img/BufferedImage.html
)设计的。我不会要求您学习用于PImage
类的 Javadoc,而是带您完成常见的图像处理任务,以完成以下任务:
- 导入外部图像
- 在处理中创建图像
- 显示图像
- 导出图像
导入外部图像
对于这一系列练习,首先创建一个名为Chapter02_01
的加工草图(程序)。添加外部图像的最简单方法是将图像直接拖动到处理 IDE 窗口上。该过程将在您的加工草图文件夹中创建一个data
文件夹。或者,您可以在加工草图文件夹中手动创建一个data
文件夹,并将图像复制到data
文件夹中。处理过程将自动在该文件夹中搜索外部图像、电影和其他数据文件,如可扩展标记语言(XML)文件。检查您的data
文件夹中的外部图像。本次练习使用的图像为HongKong.png
,如图 2-2 所示。下面的代码将加载图像并在处理窗口中显示它。在本例中,处理窗口的大小为 640×480,与图像的大小相同。当裁剪和填充的大小不同时,它们可能会出现。
图 2-2。
Loading an external image
PImage img;
void setup() {
size(640, 480);
img = loadImage("HongKong.png");
noLoop();
}
void draw() {
image(img, 0, 0);
}
第一条语句定义了PImage
类的一个实例img
,并且是外部图像的容器。如图所示,setup()
函数中的语句执行图像文件HongKong.png
到img
变量的实际加载:
img = loadImage("HongKong.png");
draw()
函数中的唯一语句,如这里所示,在加工图形窗口中偏移量(0,0)处显示图像:
image(img, 0, 0);
注意在setup()
函数内部有一个noLoop()
语句。它将执行一次draw()
功能,而不是在动画模式下循环执行。
在下一个练习中,您将从互联网上加载一个外部图像(图 2-3 )。创建另一个名为Chapter02_02
的加工草图。输入以下代码。修改String
变量fName
,使其指向您想要导入的任何外部图像的 URL。
图 2-3。
Loading an image from the Internet
PImage img;
String fName;
void setup() {
size(640, 480);
background(255, 200, 200);
fName = "http://www.magicandlove.com/blog/wp-content/uploads/2011/10/BryanChung-225x300.png";
img = requestImage(fName);
}
void draw() {
if (img.width > 0 && img.height > 0) {
image(img, 360, 100);
}
}
在本练习中,您将使用函数requestImage()
来加载一个外部映像,该映像通常驻留在互联网上的某个服务器上。该函数与另一个线程执行异步加载。但是,它不会在成功加载后进行回调。您可以利用PImage
类的两个属性width
和height
来检查加载是否完成。在加载过程中,图像的width
和height
属性的值为 0。成功完成加载后,这些值将成为所加载图像的尺寸。以下代码显示了如何修改前面的代码以打印draw()
函数中width
和height
的值,以便您可以检查它们的值:
void draw() {
println(img.width + ", " + img.height);
if (img.width > 0 && img.height > 0) {
image(img, 360, 100);
}
}
如果您故意将 URL 更改为错误的地址,您可能会发现img.width
和img.height
的值都变成了-1。
在处理中创建图像
除了加载外部图像(图 2-4 ,您还可以在处理过程中从头开始创建数字图像。这样做的函数是createImage()
,它将返回一个PImage
类的实例。下一个程序,Chapter02_03
,将创建一个空的图像,并改变其所有像素为黄色:
图 2-4。
Creating a yellow image within Processing
PImage img;
void setup() {
size(640, 480);
background(100, 100, 100);
img = createImage(width, height, ARGB);
color yellow = color(255, 255, 0);
for (int y=0; y<img.height; y++) {
for (int x=0; x<img.width; x++) {
img.set(x, y, yellow);
}
}
}
void draw() {
image(img, 0, 0);
}
以下语句创建大小为width
× height
的数字图像:
img = createImage(width, height, ARGB);
变量width
是指在size()
函数中指定的加工窗口宽度。它的值是 640。类似地,变量height
是 480,在size()
函数中定义。参数ARGB
定义了四通道图像(即阿尔法、红色、绿色和蓝色)。您还可以使用RGB
定义三通道图像,使用ALPHA
定义单个 alpha 通道图像。然而,PImage
类的内部表示仍然是ARGB
。下一条语句定义了一个名为yellow
的颜色变量:
color yellow = color(255, 255, 0);
其值为yellow
,红色和绿色通道的强度最大(255)。带有索引y
和x
的嵌套for
循环简单地遍历图像的所有像素img
,并将像素颜色更改为yellow
。注意使用set()
功能修改单个像素的颜色。这是一种通过使用水平和垂直索引来改变图像中特定点的像素颜色的简便方法。然而,set()
函数并不是最有效的像素操作方式。我将在本章的后面介绍其他方法来达到这个效果。
img.set(x, y, yellow);
图形和图像
在下一个练习Chapter02_04
中,您将研究处理画布的内部结构。类PGraphics
是主要的图形和渲染上下文。它也是PImage
的子类。在这种情况下,您可以使用相同的image()
函数来显示这个图形上下文。下面的代码将首先在画布的左上角绘制一个矩形,然后按偏移量显示画布。执行后,您将看到两个矩形。
PGraphics pg;
void setup() {
size(640, 480);
background(100, 100, 100);
pg = getGraphics();
noLoop();
}
void draw() {
rect(0, 0, 200, 120);
image(pg, 200, 120);
}
图 2-5 显示了执行的结果。左上角的第一个矩形是draw()
函数中rect()
语句的结果。image()
语句将整个画布水平偏移 200 像素,垂直偏移 120 像素,并显示整个画布。当您需要将当前绘图画布捕获为图像时,该技术非常有用。
图 2-5。
Use of PGraphics as PImage
在本练习Chapter02_05
中,您将学习PGraphics
类的一般用法。您可以将PGraphics
实例视为一个独立的画布,这样您就可以在屏幕外的画布上进行绘制。当它准备好显示时,您可以使用image()
功能将其显示在加工窗口中。
PGraphics pg;
boolean toDraw;
void setup() {
size(640, 480);
background(0);
pg = createGraphics(width, height);
toDraw = false;
}
void draw() {
if (toDraw)
image(pg, 0, 0);
}
void mouseDragged() {
pg.beginDraw();
pg.noStroke();
pg.fill(255, 100, 0);
pg.ellipse(mouseX, mouseY, 20, 20);
pg.endDraw();
}
void mousePressed() {
pg.beginDraw();
pg.background(0);
pg.endDraw();
toDraw = false;
}
void mouseReleased() {
toDraw = true;
}
图 2-6 显示了草图样本运行的结果。
图 2-6。
Use of createGraphics() and the PGraphics
注意使用下面的createGraphics()
函数来创建一个与处理窗口大小相同的PGraphics
类的实例。它将被用作一个离屏缓冲区来存储您通过拖动鼠标绘制的图形。当您按下、拖动并释放鼠标按钮时,mousePressed()
、mouseDragged()
和mouseReleased()
这三个回调函数将被触发。如果你想在PGraphics
实例pg
中创建任何图形,你必须把命令放在pg.beginDraw()
和pg.endDraw()
块中。还要注意,您可以通过只输入一个数字来指定灰度颜色,例如在background(0)
函数中,它用黑色清除背景。在以下代码行中,变量对mouseX
和mouseY
将返回处理图形窗口中的当前鼠标位置,以像素为单位:
pg.ellipse(mouseX, mouseY, 20, 20);
在该语句中,在当前鼠标位置的屏幕外缓冲区pg
上绘制了一个椭圆/圆。处理还提供了另一对变量,pmouseX
和pmouseY
,它们存储动画最后一帧中的鼠标位置。当您需要绘制一条从前一个鼠标位置到当前位置的线段时,这两对鼠标位置变量将非常有用。
加工中的缓冲损伤
在前面的小节中,您学习了如何在处理中使用主图像处理类,PImage
。对于熟悉 Java 图像处理的人来说,类BufferedImage
( https://docs.oracle.com/javase/8/docs/api/java/awt/img/BufferedImage.html
)对于程序员能够在 Java 中操作图像是很重要的。在处理过程中,您还可以在PImage
类和BufferedImage
类之间执行转换。有时候,在处理返回一个BufferedImage
类的过程中加入其他 Java 图像处理库会很有用。下面的代码演示了处理过程中PImage
类和BufferedImage
类之间的转换:
import java.awt.image.BufferedImage;
PImage img;
BufferedImage bim;
void setup() {
size(640, 480);
noLoop();
}
void draw() {
background(0);
// create the PImage instance img
img = createImage(width, height, ARGB);
// create the BufferedImage instance bim from img
bim = (BufferedImage) img.getNative();
println(bim.getWidth() + ", " + bim.getHeight());
// create a new PImage instance nim from BufferedImage bim
PImage nim = new PImage(bim);
println(nim.width + ", " + nim.height);
}
首先,您使用import java.awt.image.BufferedImage
将BufferedImage
的引用包含到您的加工草图中。在draw()
函数中,使用createImage()
函数创建一个空的PImage
实例img
。通过使用getNative()
方法,您可以创建一个BufferedImage
格式的原始图像的副本。给定一个BufferedImage
、bim
,您可以通过使用new PImage(bim)
命令再次创建一个PImage
。在下面的示例Chapter02_06
中,您可以看到这种转换在创造性结果中的实际应用:
import java.awt.Robot;
import java.awt.image.BufferedImage;
import java.awt.Rectangle;
Robot robot;
void setup() {
size(640, 480);
try {
robot = new Robot();
}
catch (Exception e) {
println(e.getMessage());
}
}
void draw() {
background(0);
Rectangle rec = new Rectangle(mouseX, mouseY, width, height);
BufferedImage img1 = robot.createScreenCapture(rec);
PImage img2 = new PImage(img1);
image(img2, 0, 0);
}
这个处理草图主要使用 Java Robot
类( https://docs.oracle.com/javase/8/docs/api/java/awt/Robot.html
)做一个截屏。屏幕截图的输出(图 2-7 )是一个BufferedImage
,您可以将其转换为PImage
以在draw()
功能中显示。在setup()
函数中,初始化try
块中的robot
实例来捕获AWTException
。在draw()
函数中,首先使用一个Rectangle
对象来定义要捕捉的屏幕区域的偏移量和大小。robot.createScreenCapture(rec)
将执行实际的屏幕截图,生成的图像存储在img1
中,它是BufferedImage
的一个实例。下一条语句将img1
实例转换成另一个PImage
实例img2
,以便用image()
函数显示。当您在处理窗口内移动鼠标时,您会发现一个有趣的结果,类似于视频艺术中的反馈效果。正是image()
功能修改了每一帧中的屏幕内容,促成了这个反馈循环。
图 2-7。
Screen capture with PImage
处理中的移动图像
您在上一章中安装的用于处理的外部视频库( https://processing.org/reference/libraries/video/index.html
)提供了视频播放和捕获的必要功能。它基于 GStreamer 多媒体框架中的 Java 绑定。该库包含两个独立的类:Movie
用于视频回放,而Capture
用于实时视频捕捉。两者都是PImage
的子类。您可以使用类似的方法来处理像素数据。
数字电影
下一个练习Chapter02_07
将循环播放视频库中分发的示例视频transit.mov
。就像将图像添加到处理草图中一样,您只需将数字视频文件拖到处理 IDE 窗口中。或者你可以在 sketch 文件夹里面创建一个data
文件夹,把视频文件复制到那里。以下代码执行数字视频的异步回放。每当一个新的帧准备好了,回调movieEvent()
就会被触发来读取该帧。
import processing.video.*;
Movie mov;
void setup() {
size(640, 360);
background(0);
mov = new Movie(this, "transit.mov");
mov.loop();
}
void draw() {
image(mov, 0, 0);
}
void movieEvent(Movie m) {
m.read();
}
当您从主菜单中选择草图➤导入库➤视频时,处理将自动生成第一个import
语句。下一步是定义Movie
类实例mov
。下面的语句将使用视频的名称创建新的实例:
mov = new Movie(this, "transit.mov");
关键字this
指的是当前的加工草图(即Chapter02_07
),回调函数movieEvent()
需要引用它。图 2-8 为运行示意图。
图 2-8。
Digital video playback example
下一个例子Chapter02_08
,提供了另一种读取数字视频的方法。在这个版本中,每个动画帧中的草图检查新帧的可用性,并同步读取它。
import processing.video.*;
Movie mov;
void setup() {
size(640, 360);
background(0);
mov = new Movie(this, "transit.mov");
mov.loop();
frameRate(30);
}
void draw() {
if (mov.available()) {
mov.read();
}
image(mov, 0, 0);
}
注意在setup()
函数中有一个新的frameRate()
语句,指定了draw()
函数的每秒帧速率。对于较慢的计算机,实际帧速率可能比这里指定的要慢。
由于Movie
是PImage
的子类,你可以使用PImage
的get()
方法从视频的任何一帧中检索像素颜色数据。下一张加工草图Chapter02_09
将展示这一点:
import processing.video.*;
Movie mov;
void setup() {
size(640, 360);
background(0);
mov = new Movie(this, "transit.mov");
mov.loop();
frameRate(30);
}
void draw() {
if (mov.available()) {
mov.read();
}
image(mov, 0, 0);
}
void mouseClicked() {
color c = mov.get(mouseX, mouseY);
println(red(c) + ", " + green(c) + ", " + blue(c));
}
在本练习中,您将显示所单击像素的颜色信息。这在mouseClicked()
回调函数中完成。您提供了像素在mov
帧中的水平和垂直位置。它返回变量c
中的颜色数据。通过使用red()
、green()
和blue()
函数,您可以从中检索三原色分量。数字的范围将在 0 到 255 之间。
实时视频捕捉
除了数字视频回放之外,Processing 还提供了类Capture
来支持从常规网络摄像头或捕获设备实时捕获视频流。就像使用Movie
类时,需要导入video
库,如下面的练习Chapter02_10
所示:
import processing.video.*;
Capture cap;
void setup() {
size(640, 480);
background(0);
cap = new Capture(this, width, height);
cap.start();
}
void draw() {
image(cap, 0, 0);
}
void captureEvent(Capture c) {
c.read();
}
您将Capture
类与实例cap
一起使用。new
语句创建该类的一个新实例,并将其分配给cap
。它还需要一个start()
方法来启动捕获设备。类似于Movie
类,Capture
类带有名为captureEvent()
的回调函数,其中捕获设备可以异步通知主处理草图读入任何可用的新视频帧。由于Capture
是PImage
的子类,您可以使用相同的image()
函数在处理窗口中显示捕捉帧。
在下一个练习Chapter02_11
中,您将在draw()
函数中使用捕捉帧的同步读取。同时,您引入了mask()
功能,通过在一幅蒙版图像上交互绘制来遮盖图像的一部分。
import processing.video.*;
Capture cap;
PGraphics pg;
void setup() {
size(640, 480);
background(0);
cap = new Capture(this, width, height);
cap.start();
pg = createGraphics(width, height);
pg.beginDraw();
pg.noStroke();
pg.fill(255);
pg.background(0);
pg.endDraw();
}
void draw() {
if (cap.available()) {
cap.read();
}
tint(255, 0, 0, 40);
cap.mask(pg);
image(cap, 0, 0);
}
void mouseDragged() {
pg.beginDraw();
pg.ellipse(mouseX, mouseY, 20, 20);
pg.endDraw();
}
在本练习中,您将使用一个名为pg
的PGraphics
实例作为离屏缓冲区。在mouseDragged()
回调函数中,用户可以在黑色背景上创建一个白色圆形标记。在draw()
函数中,您引入了两个新函数。第一个是tint()
,用红色(255,0,0)和一点透明度给结果图像着色,如第四个参数 40 所示。第二个是mask()
功能,在这里你把蒙版pg
应用到原始图像上(图 2-9)cap
。结果是一种交互式体验,你可以拖动鼠标来显示底层的实时视频流。
图 2-9。
Live video capture with a mask
OpenCV 中的矩阵和图像
既然我已经介绍完了如何使用外部图像、视频和直播流进行处理,我将切换回 OpenCV 来帮助您理解它是如何表示数字图像的。开始之前,记得在下一个加工草图Chapter02_12
中创建一个code
文件夹。在code
文件夹中,放入你在前一章创建的 OpenCV 库文件。以下是文件夹中的 OpenCV 文件:
opencv-310.jar
libopencv_java310.dylib
(适用于苹果电脑)libopencv_java310.so
(对于 Linux,如 Ubuntu)opencv_java310.dll
(用于 Windows)
在本练习中,您将使用不同的选项定义多个空矩阵Mat
,以便理解Mat
类的内部结构。我将讲述的不同的班级是Mat
、Size
、CvType
和Scalar
。
import org.opencv.core.*;
void setup() {
size(640, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
noLoop();
}
void draw() {
background(0);
Mat m1 = new Mat();
println(m1.dump());
Mat m2 = new Mat(3, 4, CvType.CV_8UC1, Scalar.all(0));
println(m2.dump());
Mat m3 = new Mat(3, 4, CvType.CV_8UC3, Scalar.all(255));
println(m3.dump());
Mat m4 = new Mat(new Size(4, 3), CvType.CV_8UC3, new Scalar(0, 255, 0));
println(m4.dump());
}
在本练习中,您定义了四个矩阵。第一个矩阵m1
是一个没有维度信息的空矩阵。方法m1.dump()
将返回矩阵内容的可打印形式。第二个矩阵m2
有三行四列,所以元素总数是 12。每个元素都是 8 位无符号数(CvType.CV_8UC1
)。首次创建矩阵时,元素的值为 0 ( Scalar.all(0)
)。方法m2.dump()
将在三行四列中显示 12 个 0 元素。第三个矩阵m3
与m2
具有相同的尺寸。然而,m3
中的每个元素由三个独立的数字或通道组成(CvType.CV_8UC3
)。所有元素的值都是 255 ( Scalar.all(255)
)。您使用不同的方法定义第四个矩阵的维度,m4
。new Size(4, 3)
定义了一个新的Size
对象实例,宽度为 4,高度为 3,相当于一个三行四列的矩阵。每个矩阵元素对于三个通道是相同的。在m4
中,您用值为(0, 255, 0)
的Scalar
实例初始化矩阵元素。
在继续下一个练习之前,让我们看看CvType
类是如何工作的。CvType
之后的数据类型规范如下:
CV_[bits][type]C[channels]
这里有一个解释:
[bits]
表示代表每个数据元素的位数。它可以是 8、16 或 32。[type]
表示数据表示的类型。可以不签名,U
;署名,S
;还是浮,F
。[channels]
表示矩阵中每个数据元素的通道数。它可以是 1、2、3 或 4。
在下一个练习Chapter02_13
中,您将探索Mat
类中的许多方法,以理解矩阵元素的数据类型和表示:
import org.opencv.core.*;
void setup() {
size(640, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
noLoop();
}
void draw() {
background(0);
Mat m1 = new Mat(new Size(4, 3), CvType.CV_8UC3, new Scalar(0, 100, 0));
println(m1.dump());
println(m1.rows() + ", " + m1.cols());
println(m1.width() + ", " + m1.height());
println("Size: " + m1.size());
println("Dimension: " + m1.dims());
println("Number of elements: " + m1.total());
println("Element size: " + m1.elemSize());
println("Depth: " + m1.depth());
println("Number of channels: " + m1.channels());
}
您应该从处理控制台窗口获得以下输出:
[ 0, 100, 0, 0, 100, 0, 0, 100, 0, 0, 100, 0;
0, 100, 0, 0, 100, 0, 0, 100, 0, 0, 100, 0;
0, 100, 0, 0, 100, 0, 0, 100, 0, 0, 100, 0]
3, 4
4, 3
Size: 4x3
Dimension: 2
Number of elements: 12
Element size: 3
Depth: 0
Number of channels: 3
大多数信息都很简单。元素大小是每个矩阵元素包含的字节数。深度是每个通道的数据类型指示器。值 0 表示数据类型是 8 位无符号整数,主要用于处理和 OpenCV 之间。除了从现有矩阵中获取信息,下一个练习Chapter02_14
将展示如何使用get()
方法从矩阵的单个元素中检索信息:
import org.opencv.core.*;
void setup() {
size(640, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
noLoop();
}
void draw() {
background(0);
Mat m1 = new Mat(new Size(4, 3), CvType.CV_8UC4, new Scalar(100, 200, 80, 255));
double [] result = m1.get(0, 0);
printArray(result);
byte [] data = new byte[m1.channels()];
m1.get(2, 2, data);
for (byte b : data) {
int i = (b < 0) ? b + 256 : b;
println(i);
}
}
请注意,在前面的代码中,下面的语句使用get()
方法来检索位于m1
中第 0 行第 0 列的数据元素。返回的数据将存储在一个名为result
的double
数组中。
double [] result = m1.get(0, 0);
您可能会发现,即使矩阵中的每个数据元素都被定义为一个字节(8 位),使用这种语法的get()
方法的结果总是返回一个双数组。双数组result
的长度为 4,这是CV_8UC4
中定义的通道数。如果将数据元素定义为CV_8UC1
(即只有一个通道),那么返回的result
也将是一个长度等于 1 的双数组。练习的第二部分演示了您还可以显式定义一个长度为 4 的名为data
的字节数组,使用不同的语法和get()
方法从位置行 2 列 2 中检索数据元素,并直接将其存储到字节数组data
中。在for
循环中,您还需要考虑 Java 没有无符号字节数据类型的事实。对于负数,必须加上 256 才能转换成大于 127 的原始数。
在get()
方法之后,下一个练习Chapter02_15
探索了put()
方法来改变矩阵中数据元素的内容。它演示了使用put()
方法的两种方式。第一个用字节数组更新数据元素。第二个用一个双数字列表更新数据元素。
import org.opencv.core.*;
void setup() {
size(640, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
noLoop();
}
void draw() {
background(0);
Mat m1 = new Mat(new Size(4, 3), CvType.CV_8UC4, new Scalar(100, 200, 80, 255));
byte [] data1 = new byte[m1.channels()];
byte [] data2 = new byte[m1.channels()];
m1.get(1, 1, data1);
data2[0] = data1[3];
data2[1] = data1[2];
data2[2] = data1[1];
data2[3] = data1[0];
m1.put(1, 1, data2);
printArray(m1.get(1, 1));
m1.put(2, 2, 123, 234, 200, 100);
printArray(m1.get(2, 2));
}
本练习的第一部分是将第 1 行第 1 列的数据元素检索到data1
数组中。然后将data1
数组重新排序为data2
数组,长度相同。第一个put()
方法将data2
数组存储到同一个数据元素中。然后使用printArray()
功能显示该数据元素的单个通道信息。在练习的第二部分,您只需在put()
方法中列出四通道值的四个数字。它们将被存储在第 2 行第 2 列,如第二个printArray()
语句所示。草图控制台窗口的结果如下:
[0] 255.0
[1] 80.0
[2] 200.0
[3] 100.0
[0] 123.0
[1] 234.0
[2] 200.0
[3] 100.0
在结束本次会议之前,您将学习put()
和get()
函数的另一个特性,即进行批量信息更新和检索。当编写代码在 Processing 和 OpenCV 之间转换图像数据时,该功能是必不可少的。在接下来的练习Chapter02_16
中,您将使用一个字节数组和一个矩阵大小的数字序列进行批量更新和检索。
import org.opencv.core.*;
void setup() {
size(640, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
noLoop();
}
void draw() {
background(0);
Mat m1 = new Mat(new Size(3, 2), CvType.CV_8UC1);
for (int r=0; r<m1.rows(); r++) {
for (int c=0; c<m1.cols(); c++) {
m1.put(r, c, floor(random(100)));
}
}
println(m1.dump());
byte [] data = new byte[m1.rows()*m1.cols()*m1.channels()];
m1.get(0, 0, data);
printArray(data);
Mat m2 = new Mat(new Size(3, 2), CvType.CV_8UC2, Scalar.all(0));
m2.put(0, 0, 1, 2, 3, 4, 5, 6, 7, 8);
println(m2.dump());
}
练习的第一部分定义了一个两行三列的小矩阵。每个数据元素都是存储在一个字节中的单通道数字。for
循环用小于 100 的随机整数值初始化矩阵m1
。然后定义一个名为data
的空字节数组,其大小由矩阵m1
的大小决定(即 2 × 3 × 1 = 6)。在get()
方法之后,所有的矩阵内容都被转储到data
数组中。在本练习的第二部分,您将定义另一个矩阵m2
,它有两行三列。每个数据元素都是一个双通道数字对,如CV_8UC2
所示。put()
方法将把数字序列存储到数组的前四个数据元素中。该序列将按行顺序排列。受影响的单元格是(0,0)、(0,1)、(0,2)、(1,0)。括号内的第一个数字是行号,第二个数字是列号。以下语句将(1,2)、(3,4)、(5,6)、(7,8)存储到(0,0)、(0,1)、(0,2)、(1,0)处的位置:
m2.put(0, 0, 1, 2, 3, 4, 5, 6, 7, 8);
其余的数据元素不会受到影响。图 2-10 显示了操作后的原始矩阵和新矩阵。
图 2-10。
Operation of the matrix put() function
您可能会注意到,即使您在get()
和put()
函数中指定了一个矩阵元素,如果您使用的字节数组超过了一个元素的大小,这些函数也会影响矩阵的其余内容。在下一节中,您将使用这种技术在 Processing 的PImage
和 OpenCV 的Mat
之间转换数据。
Processing 和 OpenCV 之间的图像转换
这一节对于任何想要使用 OpenCV 进行处理的应用都很重要。要使用 OpenCV,您必须将您在处理环境中创建的原始图像(如静态照片、数字视频或网络摄像头直播)转换为 OpenCV 可以操作的Mat
格式。在对图像执行 OpenCV 操作之后,最后一步是将它们转换成处理可以在其窗口中显示的PImage
格式。
在本章开始时,您已经了解到处理中的图像是彩色像素的二维数组。水平尺寸是宽度,垂直尺寸是高度。每个像素都是数据类型color
。颜色像素的内部表示是 32 位的整数。color 实例的十六进制表示法是 0xAARRGGBB,对应于 alpha、红色、绿色和蓝色通道。每个颜色通道的值范围是从 0 到 255。例如,要定义黄色,您可以编写以下代码:
color yellow = color(255, 255, 0);
如果你只指定三个颜色通道,默认的 alpha 值会自动设置为 255。您也可以用十六进制符号来表示颜色,如下所示:
color yellow = 0xFFFFFF00;
以下代码段将演示color
变量的使用以及从中检索颜色通道值的不同方法:
color col = color(200, 100, 40);
println("Color value as integer");
println(col);
println("RGB from bitwise operations");
println(col & 0x000000FF);
println((col & 0x0000FF00) >> 8);
println((col & 0x00FF0000) >> 16);
println("RGB from functions");
println(red(col));
println(green(col));
println(blue(col));
内部处理不会将图像存储为二维数组。而是存储为名为pixels[]
的一维整数数组。数组的长度是由它的width
× height
定义的图像的像素总数。对于图像中第y
行和第x
列的像素,pixels[]
数组的索引如下:
index = y * width + x;
例如,当您有一个只有两行三列的图像时,二维数组如下所示:
| 0 (0, 0) | 1 (0, 1) | 2 (0, 2) | | 3 (1, 0) | 4 (1, 1) | 5 (1, 2) |括号内的两个数字是列和行的索引。括号外的单个数字是一维数组中的索引,pixels[]
,存储处理中的图像。
在pixels[]
数组中,每个单元格都是像素的颜色信息,以 0xAARRGGBB 格式存储为整数。每个整数由 4 个字节组成。整数中的每个字节按照 ARGB 顺序为像素存储一个单独的颜色通道。
在 OpenCV 中,颜色像素格式更加灵活,如前一节所示。为了使 OpenCV 与 Processing 兼容,您将坚持使用CV_8UC4
格式,这样您就可以在 Processing 和 OpenCV 之间交换相同数量的存储。然而,许多 OpenCV 函数依赖于灰度图像(即CV_8UC1
)和三通道彩色图像的使用,例如 BGR 顺序中的CV_8UC3)
。在这种情况下,让我们灵活地将图像转换为三个通道和一个单通道彩色图像。
以您用于处理的两行三列为例,OpenCV 表示如下:
| 倍黑 | 游戏结束 | 乡邮投递路线 | 嗜酒者互诫协会 | 倍黑 | 游戏结束 | 乡邮投递路线 | 嗜酒者互诫协会 | 倍黑 | 游戏结束 | 乡邮投递路线 | 嗜酒者互诫协会 | 倍黑 | 游戏结束 | 乡邮投递路线 | 嗜酒者互诫协会 | 倍黑 | 游戏结束 | 乡邮投递路线 | 嗜酒者互诫协会 | 倍黑 | 游戏结束 | 乡邮投递路线 | 嗜酒者互诫协会 |细胞总数为width × height × channels
,在本例中为 24。二维图像矩阵将存储为 24 字节或 48 个十六进制字符的线性数组。四个连续的字节组成一个通道顺序为 BGRA 的像素。字节数组将是 OpenCV 图像矩阵的内部表示。
现在你有两个数组。第一个是来自处理的大小为(width × height
)的整数数组;第二个是来自 OpenCV 的大小为(width × height × channels
)的字节数组。问题是如何在它们之间进行转换。Java ByteBuffer
和IntBuffer
类是这个问题的解决方案。
从处理到 OpenCV
您需要在处理环境中有一个源图像。您将使用Capture
类来检索本练习的视频帧Chapter02_17
。在每次运行draw()
函数时,你都试图将帧转换成 OpenCV Mat
。为了验证转换是否有效,处理草图将允许用户单击视频帧内的任何位置,以在窗口的右上角显示其像素颜色。
import processing.video.*;
import org.opencv.core.*;
import java.nio.ByteBuffer;
Capture cap;
String colStr;
Mat fm;
void setup() {
size(640, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width, height);
cap.start();
frameRate(30);
colStr = "";
fm = new Mat();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
fm = imgToMat(cap);
image(cap, 0, 0);
text(nf(round(frameRate), 2), 10, 20);
text(colStr, 550, 20);
}
Mat imgToMat(PImage m) {
Mat f = new Mat(new Size(m.width, m.height), CvType.CV_8UC4,
Scalar.all(0));
ByteBuffer b = ByteBuffer.allocate(f.rows()*f.cols()*f.channels());
b.asIntBuffer().put(m.pixels);
b.rewind();
f.put(0, 0, b.array());
return f;
}
void mouseClicked() {
int x = constrain(mouseX, 0, width-1);
int y = constrain(mouseY, 0, height-1);
double [] px = fm.get(y, x);
colStr = nf(round((float)px[1]), 3) + ", " +
nf(round((float)px[2]), 3) + ", " +
nf(round((float)px[3]), 3);
}
有三个全局变量。第一个,cap
,是视频捕捉对象。第二个,fm
,是临时的 OpenCV Mat
,存储网络摄像头图像的当前帧。第三个是String
变量colStr
,保存用户点击的像素的 RGB 颜色值。在draw()
函数中,程序将当前网络摄像头图像cap
传递给函数imgToMat()
。该函数返回一个 OpenCV Mat
并存储在变量fm
中。每当用户点击屏幕,回调函数mouseClicked()
将通过使用get(y, x)
函数从fm
对象获取像素颜色数据。然后,它将返回一个名为px[]
的双数组,该数组按照 ARGB 顺序保存颜色像素信息。请注意,您尚未执行通道重新排序过程,以从处理中的 ARGB 顺序更改为 OpenCV 中的 BGRA 顺序。
程序的核心是imgToMat()
函数。它接受类型为PImage
的输入参数。第一条语句定义了一个临时 OpenCV Mat f
,其大小与输入m
相同。第二条语句创建了一个大小为 640 × 480 × 4 = 1228800 的ByteBuffer
变量b
。它是处理PImage
和 OpenCV Mat
之间交换数据的关键缓冲区。下一条语句将ByteBuffer
视为IntBuffer
,并将整数数组m.pixels
作为内容放入自身。在put
动作之后,你倒带缓冲区b
,这样指针将回到它的起点,以便后续访问。最后一步是用下面的语句将byte
数组缓冲区b
的内容放到Mat f
中:
f.put(0, 0, b.array());
图 2-11 显示了该处理草图从处理视频捕获图像转换为 OpenCV 的测试运行的示例截图。在下一节中,您将从相反的方向将 OpenCV 矩阵转换为处理图像。
图 2-11。
Conversion from Processing to OpenCV
从 OpenCV 到处理
在下一个练习Chapter02_18
中,您将简单地定义一个纯色的四通道 OpenCV 矩阵,并将其直接转换为处理PImage
进行显示。
import org.opencv.core.*;
import java.nio.ByteBuffer;
void setup() {
size(640, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
noLoop();
}
void draw() {
background(0);
Mat fm = new Mat(new Size(width, height), CvType.CV_8UC4, new Scalar(255, 255, 200, 0));
PImage img = matToImg(fm);
image(img, 0, 0);
}
PImage matToImg(Mat m) {
PImage im = createImage(m.cols(), m.rows(), ARGB);
ByteBuffer b = ByteBuffer.allocate(m.rows()*m.cols()*m.channels());
m.get(0, 0, b.array());
b.rewind();
b.asIntBuffer().get(im.pixels);
im.updatePixels();
return im;
}
这个程序的核心函数是matToImg()
。它将 OpenCV Mat
作为唯一的参数,并输出一个处理PImage
作为返回值。逻辑与上一节正好相反。它再次使用一个ByteBuffer
类作为临时存储位置。该函数的第一条语句创建一个临时的PImage
变量im
,其大小与输入Mat
参数m
相同。第二条语句定义了 1,228,800 字节的临时存储。第三条语句使用Mat
的get()
方法将内容加载到ByteBuffer b
中。第四个语句在加载后倒回ByteBuffer
。下一条语句将ByteBuffer
视为IntBuffer
,并将其内容作为整数数组传输到临时PImage
变量im
的pixels
。然后对PImage
执行一个updatePixels()
来刷新它的内容并返回给调用者。该草图的结果将是一个填充橙色的窗口,如 ARGB 顺序中Scalar(255, 255, 200, 0)
所定义。
在下一个练习Chapter02_19
中,您将在 OpenCV 中进行视频捕捉,并将Mat
图片帧转换为处理后的PImage
进行显示。我将在本练习中介绍一些新功能。第一个是 OpenCV 中执行视频捕捉任务的videoio
(视频输入输出)模块。第二个是imgproc
(图像处理)模块,帮助你进行色彩转换。您使用与上一个练习相同的matToImg()
功能。
import org.opencv.core.*;
import org.opencv.videoio.*;
import org.opencv.imgproc.*;
import java.nio.ByteBuffer;
VideoCapture cap;
Mat fm;
void setup() {
size(640, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new VideoCapture();
cap.set(Videoio.CAP_PROP_FRAME_WIDTH, width);
cap.set(Videoio.CAP_PROP_FRAME_HEIGHT, height);
cap.open(Videoio.CAP_ANY);
fm = new Mat();
frameRate(30);
}
void draw() {
background(0);
Mat tmp = new Mat();
cap.read(tmp);
Imgproc.cvtColor(tmp, fm, Imgproc.COLOR_BGR2RGBA);
PImage img = matToImg(fm);
image(img, 0, 0);
text(nf(round(frameRate), 2), 10, 20);
tmp.release();
}
PImage matToImg(Mat m) {
PImage im = createImage(m.cols(), m.rows(), ARGB);
ByteBuffer b = ByteBuffer.allocate(m.rows()*m.cols()*m.channels());
m.get(0, 0, b.array());
b.rewind();
b.asIntBuffer().get(im.pixels);
im.updatePixels();
return im;
}
第一步是从 OpenCV 导入您将在本练习中使用的所有新模块(即org.opencv.videoio.*
和org.opencv.imgproc.*
)。setup()
函数中的new
语句是针对VideoCapture
对象实例cap
的。它需要定义其捕捉帧大小和计算机中可用的默认相机Videoio.CAP_ANY
。在draw()
函数中,cap.read(tmp)
语句抓取并获取新的视频帧到临时的Mat tmp
。不幸的是,tmp
中的颜色通道数量只有三个,并且按 BGR 顺序排列。如果您感兴趣,您可以尝试通过使用它的channels()
方法来显示通道的数量。下一条语句使用imgproc
模块将色彩空间从 BGR 转换到 RGBA,并将新图像保存在矩阵变量fm
中:
Imgproc.cvtColor(tmp, fm, Imgproc.COLOR_BGR2RGBA);
如果你查看 OpenCV 3.1.0 的 Javadoc(http://docs.opencv.org/java/3.1.0/
),你实际上不会发现一个直接从 BGR 到 ARGB 的色彩空间转换。在本练习中,您可以坐下来看看网络摄像头图像会是什么样子。您将在下一个练习中学习如何处理这个问题。图 2-12 为该加工示意图。
图 2-12。
Conversion from OpenCV to Processing
正如所料,由于颜色通道的顺序错误,颜色不自然。您将在下一个练习Chapter02_20
中通过重新排列颜色通道的顺序来解决这个问题:
import org.opencv.core.*;
import org.opencv.videoio.*;
import org.opencv.imgproc.*;
import java.nio.ByteBuffer;
import java.util.ArrayList;
VideoCapture cap;
Mat fm;
void setup() {
size(640, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new VideoCapture();
cap.set(Videoio.CAP_PROP_FRAME_WIDTH, width);
cap.set(Videoio.CAP_PROP_FRAME_HEIGHT, height);
cap.open(Videoio.CAP_ANY);
fm = new Mat();
frameRate(30);
}
void draw() {
background(0);
Mat tmp = new Mat();
Mat src = new Mat();
cap.read(tmp);
Imgproc.cvtColor(tmp, src, Imgproc.COLOR_BGR2RGBA);
fm = src.clone();
ArrayList<Mat> srcList = new ArrayList<Mat>();
ArrayList<Mat> dstList = new ArrayList<Mat>();
Core.split(src, srcList);
Core.split(fm, dstList);
Core.mixChannels(srcList, dstList, new MatOfInt(0, 1, 1, 2, 2, 3, 3, 0));
Core.merge(dstList, fm);
PImage img = matToImg(fm);
image(img, 0, 0);
text(nf(round(frameRate), 2), 10, 20);
src.release();
tmp.release();
}
PImage matToImg(Mat m) {
PImage im = createImage(m.cols(), m.rows(), ARGB);
ByteBuffer b = ByteBuffer.allocate(m.rows()*m.cols()*m.channels());
m.get(0, 0, b.array());
b.rewind();
b.asIntBuffer().get(im.pixels);
im.updatePixels();
return im;
}
您在本练习中使用的重新排列颜色通道的新功能是split()
、mixChannels()
和merge()
。您还可以使用 Java 中的ArrayList
类来处理图像的各个颜色通道。在draw()
函数中,在函数Imgproc.cvtColor()
将 BGR 颜色矩阵转换为 RGBA 颜色矩阵src
之后,您计划将Mat src
复制到目标Mat fm
,颜色通道按照 ARGB 顺序重新排列。首先,将src
矩阵复制到fm
。第二,您将源Mat src
分割成一个由四个Mat
、srcList
组成的ArrayList
。列表中的每个成员都是一个数据类型为CV_8UC1
的Mat
,对应一个单色通道。第三,你把目的地Mat fm
拆分成Mat
的另一个ArrayList
,命名为dstList
。第四,函数Core.mixChannels()
使用MatOfInt
参数中指定的信息重新排列颜色通道的顺序。MatOfInt
是Mat
的子类。它类似于 C++中的向量。本练习中的MatOfInt
实例是一个一行八列的矩阵。这个矩阵的内容是四对数字,将源通道位置映射到目的通道位置。src
和srcList
中的原始颜色通道顺序为 RGBA。fm
和dstList
中的目的色彩通道顺序是 ARGB。
- 资料来源:R(0)、G(1)、B(2)、A(3)
- 目的地:A(0),R(1),G(2),B(3)
红色的源通道 0 映射到目的通道 1。源通道 1 映射到目的通道 2 以获得绿色。蓝色的源通道 2 映射到目的通道 3。对于 alpha,源通道 3 映射到目的地 0。这正是new MatOfInt(0, 1, 1, 2, 2, 3, 3, 0)
命令所指定的。在Core.mixChannels()
函数之后,名为dstList
的ArrayList
包含四个具有正确颜色顺序的单通道矩阵。下一个函数,Core.merge()
,将把四个矩阵组合成一个单一的矩阵,四个颜色通道按 ARGB 顺序排列。然后程序将matToImg()
函数应用于fm
并将PImage
实例返回给img
,通过image()
函数显示在窗口中。图 2-13 显示了如何在加工中运行草图。
图 2-13。
Conversion from OpenCV to Processing with correct color channel order
对于本章的最后一个练习Chapter02_21
,您将把处理和 OpenCV 之间的转换封装在一个 Java 类中,这样您就不需要在本书的后续练习中显式地调用它们。由于转换函数在处理过程中依赖于类PImage
,所以扩展PImage
类来定义它的子类是很方便的。在本练习中,您将新类命名为CVImage
。在处理 IDE 中,可以添加新的页签来创建新的类,如图 2-14 所示。将新选项卡命名为 CVImage。
图 2-14。
Adding a new tab to create a class in Processing
CVImage
类的内容如下:
import org.opencv.core.*;
import org.opencv.imgproc.*;
import java.nio.ByteBuffer;
import java.util.ArrayList;
public class CVImage extends PImage {
final private MatOfInt BGRA2ARGB = new MatOfInt(0, 3, 1, 2, 2, 1, 3, 0);
final private MatOfInt ARGB2BGRA = new MatOfInt(0, 3, 1, 2, 2, 1, 3, 0);
// cvImg - OpenCV Mat in BGRA format
// pixCnt - number of bytes in the image
private Mat cvImg;
private int pixCnt;
public CVImage(int w, int h) {
super(w, h, ARGB);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
pixCnt = w*h*4;
cvImg = new Mat(new Size(w, h), CvType.CV_8UC4, Scalar.all(0));
}
public void copyTo() {
// Copy from the PImage pixels array to the Mat cvImg
Mat tmp = new Mat(new Size(this.width, this.height), CvType.CV_8UC4, Scalar.all(0));
ByteBuffer b = ByteBuffer.allocate(pixCnt);
b.asIntBuffer().put(this.pixels);
b.rewind();
tmp.put(0, 0, b.array());
cvImg = ARGBToBGRA(tmp);
tmp.release();
}
public void copyTo(PImage i) {
// Copy from an external PImage to here
if (i.width != this.width || i.height != this.height) {
println("Size not identical");
return;
}
PApplet.arrayCopy(i.pixels, this.pixels);
this.updatePixels();
copyTo();
}
public void copyTo(Mat m) {
// Copy from an external Mat to both the Mat cvImg and PImage pixels array
if (m.rows() != this.height || m.cols() != this.width) {
println("Size not identical");
return;
}
Mat out = new Mat(cvImg.size(), cvImg.type(), Scalar.all(0));
switch (m.channels()) {
case 1:
// Greyscale image
Imgproc.cvtColor(m, cvImg, Imgproc.COLOR_GRAY2BGRA);
break;
case 3:
// 3 channels colour image BGR
Imgproc.cvtColor(m, cvImg, Imgproc.COLOR_BGR2BGRA);
break;
case 4:
// 4 channels colour image BGRA
m.copyTo(cvImg);
break;
default:
println("Invalid number of channels " + m.channels());
return;
}
out = BGRAToARGB(cvImg);
ByteBuffer b = ByteBuffer.allocate(pixCnt);
out.get(0, 0, b.array());
b.rewind();
b.asIntBuffer().get(this.pixels);
this.updatePixels();
out.release();
}
private Mat BGRAToARGB(Mat m) {
Mat tmp = new Mat(m.size(), CvType.CV_8UC4, Scalar.all(0));
ArrayList<Mat> in = new ArrayList<Mat>();
ArrayList<Mat> out = new ArrayList<Mat>();
Core.split(m, in);
Core.split(tmp, out);
Core.mixChannels(in, out, BGRA2ARGB);
Core.merge(out, tmp);
return tmp;
}
private Mat ARGBToBGRA(Mat m) {
Mat tmp = new Mat(m.size(), CvType.CV_8UC4, Scalar.all(0));
ArrayList<Mat> in = new ArrayList<Mat>();
ArrayList<Mat> out = new ArrayList<Mat>();
Core.split(m, in);
Core.split(tmp, out);
Core.mixChannels(in, out, ARGB2BGRA);
Core.merge(out, tmp);
return tmp;
}
public Mat getBGRA() {
// Get a copy of the Mat cvImg
Mat mat = cvImg.clone();
return mat;
}
public Mat getBGR() {
// Get a 3 channels Mat in BGR
Mat mat = new Mat(cvImg.size(), CvType.CV_8UC3, Scalar.all(0));
Imgproc.cvtColor(cvImg, mat, Imgproc.COLOR_BGRA2BGR);
return mat;
}
public Mat getGrey() {
// Get a greyscale copy of the image
Mat out = new Mat(cvImg.size(), CvType.CV_8UC1, Scalar.all(0));
Imgproc.cvtColor(cvImg, out, Imgproc.COLOR_BGRA2GRAY);
return out;
}
}
类定义最重要的部分是Mat
变量cvImg
。它维护了一个 OpenCV 矩阵的副本,类型为CV_8UC4
,颜色通道顺序在 BGRA。copyTo()
方法有三个版本。第一个没有参数的函数将当前的本地pixels
数组复制到 OpenCV 矩阵cvImg
。带有PImage
参数的第二个函数将输入参数pixels
数组复制到本地PImage
pixels
数组,并更新cvImg
。带有Mat
参数的第三个是最复杂的一个。根据输入参数的通道数,该方法首先使用Imgproc.cvtColor()
函数将输入Mat
转换为 BGRA 格式的标准四色通道并存储在cvImg
中。同时,通过使用ByteBuffer b
,它将图像内容复制到内部的pixels
数组中,该数组具有 ARGB 颜色通道顺序,用于处理。剩下的三个方法将不同类型的 OpenCV Mat
返回给调用者。getGrey()
方法返回类型为CV_8UC1
的灰度图像。getBGR()
方法以 BGR 顺序返回带有 OpenCV 标准三色通道的彩色图像。getBGRA()
方法返回存储为cvImg
的彩色图像Mat
,四个通道按 BGRA 顺序排列。
为了演示它的用法,主程序将使用视频捕获类来启动网络摄像头图像流,并从CVImage
对象实例img
中获取灰度图像。灰度图像被复制回实例以供显示。处理窗口中的最终显示将是原始网络摄像头图像的灰度版本。
import processing.video.*;
Capture cap;
CVImage img;
void setup() {
size(640, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width, height);
cap.start();
img = new CVImage(cap.width, cap.height);
frameRate(30);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat grey = img.getGrey();
img.copyTo(grey);
image(img, 0, 0);
text(nf(round(frameRate), 2), 10, 20);
grey.release();
}
在结束本章之前,让我们在前面的代码中再添加一个函数。您可能经常想要保存图像的内容以备后用。为此,您可以在处理中使用PImage
类中的save()
方法。save()
方法的参数是您想要保存的图像文件的完整路径名。它可以接受 TARGA、TIFF、JPEG 和 PNG 格式。每当用户按下鼠标左键时,下面的代码将把名为screenshot.jpg
的图像保存到草图的data
文件夹中:
void mousePressed() {
img.save(dataPath("screenshot.jpg"));
}
结论
本章解释了 Processing 和 OpenCV 中不同的图像表示。通过以下练习,您已经掌握了在处理环境中创建和操作图像的基本技能。您学习了如何在 Processing 和 OpenCV 之间转换图像。上一个练习中定义的类将构成本书的基础,让您学习 OpenCV 和处理,而不必回到与格式转换相关的繁琐细节。在下一章,你将开始操作图像的单个像素来生成有创意的图片。
三、基于像素的操作
本章介绍了处理单个像素颜色值的不同方法,从而为图像创建有趣的效果。您将学习如何以算法和交互的方式处理单个像素。在这一章中,你将只关注改变像素的颜色值,而不是它们在图像中的位置和总数。在学习图像处理的技术细节之前,本章还将介绍艺术和设计中常用的基本图形属性。本章将涵盖以下主题:
- 视觉属性
- 像素颜色处理
- 随机性
- 用现有图像绘图
- 混合多个图像
视觉属性
在视觉艺术和设计专业,你学习如何创造视觉材料,并把它们组合起来。对于任何视觉材料,您通常可以用以下内容来描述其属性:
- 位置
- 大小
- 形状
- 方向
- 颜色
- 价值
在经典著作《图形符号学》中,Jacque Bertin 使用术语视网膜变量来描述视觉元素的相似属性。让我们浏览一下这些属性,看看它们中是否有任何一个与像素颜色处理的讨论有关。
位置
图像中的每个像素都有一个位置。如图 3-1 所示,测量的原点在左上角,而不是你可能在学校学过的左下角。水平位置是 x 轴,其值随着向右侧移动而增加。垂直位置是 y 轴,其值随着向底部移动而增加。
图 3-1。
Pixel position in an image
您可以根据像素在图像中的位置来更改像素颜色信息,以实现渐变效果。图 3-2 显示了一个典型的例子。
图 3-2。
Gradation effect
大小
像素没有任何大小信息。准确地说,每个像素的大小为 1 乘 1。您可以想象通过将像素周围的相邻像素更改为相同的颜色来增加像素的大小。这就是你大概熟悉的马赛克效果,如图 3-3 。
图 3-3。
Mosaic effect
形状
很难描述像素的形状。事实上,由于它的大小只有一个像素,所以描述像素的形状是没有意义的。从概念上讲,在矩形网格表示中,您可以将像素视为一个微小的正方形或圆形。
方向
如果一个像素没有确定的形状,你就不能描述它的方向(即,在二维平面上的旋转量)。但是,您可以从整体上描述数码图像的方向/旋转。在这种情况下,您正在转换图像中像素的位置,这将是下一章的主题。
颜色
像素的颜色信息是本章的主要内容。你将会看到如何用不同的方式改变颜色。您可以使用颜色来传达图像中的信息。如果两幅图像有两种不同的纯色,你很容易断定它们是不同的。如图 3-4 所示,如果你随意选择颜色,你无法立即分辨哪个“高”哪个“低”。
图 3-4。
Color difference
价值
该值有时被称为颜色的强度或亮度。如果你只是使用灰度图像,这更有意义,如图 3-5 所示。与任意使用颜色不同,它创建了一个比较来提示订单信息。例如,使用深灰色和浅灰色可能暗示重量比较,以表明某人较重或较轻。在处理中,除了使用 RGB 作为默认的颜色表示外,还可以使用 HSB(色调、饱和度、亮度)。在这种情况下,如果保持色调和饱和度不变,只改变亮度,就可以创建一个表示顺序的比较。
图 3-5。
Grayscale image with value comparison
像素颜色处理
在前一章中,您在处理中使用了PImage
的get()
和set()
方法来获取和更新像素颜色信息。PImage
对象有一个内部数组来存储每个像素的颜色信息。在这一章中,我将介绍一种更新PImage
对象的内部数组pixels[]
的直接方法。在第一个练习Chapter03_01
中,草图通过使用pixels[]
数组将所有像素改变为一种颜色来创建一个纯色图像。
PImage img;
void setup() {
size(750, 750);
background(0);
img = createImage(width, height, ARGB);
noLoop();
}
void draw() {
img.loadPixels();
color orange = color(255, 160, 0);
for (int i=0; i<img.pixels.length; i++) {
img.pixels[i] = orange;
}
img.updatePixels();
image(img, 0, 0);
}
注意,代码使用了一个名为img
的PImage
对象实例。它是使用该设置中的createImage()
功能创建的。您还可以使用noLoop()
功能运行一次draw()
功能,而不会循环。在draw()
函数中,使用loadPixels()
方法将图像数据加载到img
的pixels
数组中,在for
循环中更新pixels
数组元素后,使用updatePixels()
方法改变颜色。在for
循环中,使用索引i
从 0 开始执行重复,直到到达pixels
数组的长度。每个像素的颜色都与变量 orange 中定义的颜色相同。pixels[]
数组是一个整数数组,大小等于PImage
的width × height
(即图像的像素数)。每个像素是一个 32 位整数,以 ARGB 格式存储四个颜色通道。不用直接为一种颜色写一个整数,你可以使用color()
函数用四个数字指定一种颜色为color(red, green, blue, alpha)
。默认情况下,red
、green
、blue
和alpha
值都是 0 到 255 范围内的数字。
颜色随像素位置变化
在下一个练习Chapter03_02
中,您将考虑像素位置来改变其颜色。它创建的结果图像将是您在“视觉属性”一节中学到的渐变效果。
PImage img;
void setup() {
size(750, 750);
img = createImage(width, height, ARGB);
noLoop();
}
void draw() {
background(0);
img.loadPixels();
float xStep = 256.0/img.width;
float yStep = 256.0/img.height;
for (int y=0; y<img.height; y++) {
int rows = y*img.width;
for (int x=0; x<img.width; x++) {
img.pixels[rows+x] = color(x*xStep, 0, y*yStep);
}
}
img.updatePixels();
image(img, 0, 0);
}
图 3-6 显示运行加工草图的结果。
图 3-6。
Gradation image in two colors, red and blue
在上一个练习中,您根据像素位置的线性变化更改了颜色 RGB 分量。然而,你可以尝试另一种非线性的方法来观察差异。在下面的练习Chapter03_03
中,您可以看到这种方法的演示:
PImage img;
void setup() {
size(750, 750);
img = createImage(width, height, ARGB);
noLoop();
}
void draw() {
background(0);
img.loadPixels();
float colStep = 256.0/colFunc(img.height);
for (int y=0; y<img.height; y++) {
int rows = y*img.width;
color col = color(colFunc(y)*colStep);
for (int x=0; x<img.width; x++) {
img.pixels[rows+x] = col;
}
}
img.updatePixels();
image(img, 0, 0);
}
float colFunc(float v) {
return v;
}
在第一个版本中,您使用线性颜色渐变,它实际上是 y 轴上的灰度渐变。为了灵活起见,您使用一个名为colFunc()
的独立函数来计算像素的颜色变化和y
位置之间的关系。在第一次运行时,您只需返回y
位置值作为函数的输出。在draw()
函数中,通过除以 256 来定义变量colStep
,256 是灰度的最大值colFunc(img.height)
,最大值来自colFunc()
。在 y 轴的for
循环的每一步中,颜色变量col
通过将colFunc(y)
乘以colStep
值来计算。在这种情况下,当y
位置为 0 时col
的最小值为 0,当y
位置为img.height - 1
时col
的最大值为 255。图 3-7 显示运行加工草图的结果。
图 3-7。
Grayscale gradation with linear function
在第二个版本中,您可以通过返回v
的平方来修改colFunc()
函数,如下所示:
float colFunc(float v) {
return v*v;
}
图 3-8 显示了该版本根据y
位置灰度非线性变化的结果。
图 3-8。
Grayscale gradation with nonlinear change, y-square
在本练习的最后一个版本中,您用一个更一般的数学函数代替了colFunc()
函数,这个函数叫做非整数值的pow()
。你可以尝试,例如,把参数v
的 1.5 次方。新的colFunc()
定义如下:
float colFunc(float v) {
return (float) Math.pow(v, 1.5);
}
图 3-9 包含在这里,以便您可以与最后两个进行比较。
图 3-9。
Grayscale gradation with nonlinear change, y to the power of 1.5
颜色随像素距离变化
除了根据像素的位置更改颜色值,您还可以根据像素与屏幕上另一个位置的距离更改颜色值。在以下练习中,您将尝试不同的距离函数和位置,并查看结果。首先,试着用这个练习来比较一个像素到图像中心的距离,Chapter03_04
:
PImage img;
void setup() {
size(750, 750);
img = createImage(width, height, ARGB);
noLoop();
}
void draw() {
background(0);
img.loadPixels();
float colStep = 256.0/max(img.width/2, img.height/2);
PVector ctr = new PVector(img.width/2, img.height/2);
for (int y=0; y<img.height; y++) {
int rows = y*img.width;
for (int x=0; x<img.width; x++) {
float d = distance(ctr, new PVector(x, y));
color col = color(d*colStep, 0, 255-d*colStep);
img.pixels[rows+x] = col;
}
}
img.updatePixels();
image(img, 0, 0);
}
float distance(PVector p1, PVector p2) {
float d = abs(p1.x-p2.x) + abs(p1.y-p2.y);
return d;
}
请注意在名为distance()
的程序中使用了自定义距离函数。它有两个类型为PVector
( https://processing.org/reference/PVector.html
)的参数,这是一个在处理中很有用的类,可以简化矢量计算的使用。一个PVector
有三个属性:x
、y
和z
。这些对应于三维空间中的位置。在本练习中,您仅使用 2D 图形中的x
和y
。该版本的距离函数使用两点的x
和y
位置之间差值的绝对值之和。在draw()
函数中,您计算每个像素到图像中心的距离,并使用它来计算红色和蓝色分量。图 3-10 显示运行加工草图的结果。
图 3-10。
Color change with distance from center
您可以修改distance()
函数,使用更常见的欧几里德距离来测试结果。以下是distance()
函数的新定义:
float distance(PVector p1, PVector p2) {
float d = p1.dist(p2);
return d;
}
它采用PVector
内置的dist()
方法来计算二维空间中两点之间的距离。最终的图像(如图 3-11 所示)将看起来像一个圆形而不是菱形。
图 3-11。
Color change with distance from center
在下一个练习Chapter03_05
中,您将增强draw()
函数中的计算,这样您就可以在处理中利用一些好的特性来简化代码:
PImage img;
void setup() {
size(750, 750);
img = createImage(width, height, ARGB);
noLoop();
}
void draw() {
background(0);
img.loadPixels();
float distMax = max(img.width/2, img.height/2);
PVector ctr = new PVector(img.width/2, img.height/2);
for (int y=0; y<img.height; y++) {
int rows = y*img.width;
for (int x=0; x<img.width; x++) {
float d = distance(ctr, new PVector(x, y));
float c = map(d, 0, distMax, 0, 255);
color col = color(c, 0, 255-c);
img.pixels[rows+x] = col;
}
}
img.updatePixels();
image(img, 0, 0);
}
float distance(PVector p1, PVector p2) {
float d = p1.dist(p2);
return d;
}
使用的新功能是map()
功能。它接受示例中的变量d
,并将其从源范围 0 到distMax
映射到目标范围 0 到 255。它简化了许多应用的线性映射计算。
该程序的一个快速变化是向变量ctr
引入交互性。想象一下,如果它能跟随鼠标的移动;你可以通过使用mouseX
和mouseY
变量来生成它的交互版本。
PImage img;
float distMax;
void setup() {
size(750, 750);
img = createImage(width, height, ARGB);
img.loadPixels();
distMax = max(img.width, img.height);
}
void draw() {
background(0);
PVector ctr = new PVector(mouseX, mouseY);
for (int y=0; y<img.height; y++) {
int rows = y*img.width;
for (int x=0; x<img.width; x++) {
float d = distance(ctr, new PVector(x, y));
float c = map(d, 0, distMax, 0, 255);
color col = color(c, 0, 255-c);
img.pixels[rows+x] = col;
}
}
img.updatePixels();
image(img, 0, 0);
}
float distance(PVector p1, PVector p2) {
float d = p1.dist(p2);
return d;
}
用三角函数改变颜色
三角函数指的是你在学校学过的正弦、余弦、正切函数。正弦和余弦函数的输出值具有周期性。Processing 内置了从 Java 中采用的sin()
和cos()
函数。它们有一个输入值,以弧度为单位。输入值的正常范围是在-PI 到 PI 的范围内,以完成一个周期。两个函数的输出范围都在-1 到 1 的范围内。在下一个练习Chapter03_07
中,将输入范围(即像素和鼠标位置之间的距离)映射到-PI 和 PI 之间,同时将-1 到 1 之间的输出范围映射到 0 到 255 之间的颜色范围。
PImage img;
float num;
void setup() {
size(750, 750);
img = createImage(width, height, ARGB);
img.loadPixels();
num = 8;
}
void draw() {
background(0);
PVector mouse = new PVector(mouseX, mouseY);
for (int y=0; y<img.height; y++) {
int rows = y*img.width;
for (int x=0; x<img.width; x++) {
PVector dist = distance(mouse, new PVector(x, y));
float xRange = map(dist.x, -img.width, img.width, -PI*num, PI*num);
float yRange = map(dist.y, -img.height, img.height, -PI*num, PI*num);
float xCol = map(cos(xRange), -1, 1, 0, 255);
float yCol = map(sin(yRange), -1, 1, 0, 255);
color col = color(xCol, 0, yCol);
img.pixels[rows+x] = col;
}
}
img.updatePixels();
image(img, 0, 0);
}
PVector distance(PVector p1, PVector p2) {
return PVector.sub(p1, p2);
}
您修改distance()
函数来返回一个PVector
,存储两个输入向量的相减结果。在输入范围中,还引入了一个新变量num
,来扩展原来的范围(-PI,PI)。图像将由更多的重复组成。图 3-12 显示了试运行的结果。
图 3-12。
Color change with trigonometric functions
为了增强图像的复杂性,您可以简单地将x
和y for
循环变量添加到输入范围计算中,如下一个练习Chapter03_08
所示。同时,您需要减少变量num
的值,这样xRange
和yRange
的值就不会变得太大。
PImage img;
float num;
void setup() {
size(750, 750);
img = createImage(width, height, ARGB);
img.loadPixels();
num = 0.1;
}
void draw() {
background(0);
PVector mouse = new PVector(mouseX, mouseY);
for (int y=0; y<img.height; y++) {
int rows = y*img.width;
for (int x=0; x<img.width; x++) {
PVector dist = distance(mouse, new PVector(x, y));
float xRange = map(dist.x, -img.width, img.width, -PI*num*y, PI*num*x);
float yRange = map(dist.y, -img.height, img.height, -PI*num*x, PI*num*y);
float xCol = map(cos(xRange), -1, 1, 0, 255);
float yCol = map(sin(yRange), -1, 1, 0, 255);
color col = color(xCol, 0, yCol);
img.pixels[rows+x] = col;
}
}
img.updatePixels();
image(img, 0, 0);
}
PVector distance(PVector p1, PVector p2) {
return PVector.sub(p1, p2);
}
这将产生一种更迷幻的效果,类似于 20 世纪 60 年代和 70 年代常见的光学艺术图形,如图 3-13 所示。因为在计算xRange
和yRange
值时包含了x
和y
值,所以结果不太容易预测。
图 3-13。
Another example with trigonometric function
在下一个练习Chapter03_09
中,您将简化distance()
函数,并在draw()
函数中仅使用一个值dist
来生成正弦和余弦函数的输入范围。修改不是实质性的,但视觉效果与之前的有很大不同。
PImage img;
float num;
void setup() {
size(750, 750);
img = createImage(width, height, ARGB);
img.loadPixels();
num = 2;
}
void draw() {
background(0);
PVector mouse = new PVector(mouseX, mouseY);
for (int y=0; y<img.height; y++) {
int rows = y*img.width;
for (int x=0; x<img.width; x++) {
float dist = distance(mouse, new PVector(x, y));
float range = map(dist, -img.width, img.width, -PI*num*y, PI*num*x);
float xCol = map(sin(range), -1, 1, 0, 255);
float yCol = map(cos(range), -1, 1, 0, 255);
color col = color(0, 255-xCol, yCol);
img.pixels[rows+x] = col;
}
}
img.updatePixels();
image(img, 0, 0);
}
float distance(PVector p1, PVector p2) {
return p1.dist(p2);
}
xCol
和yCol
颜色变量共享相同的输入范围值,但使用不同的三角函数。视觉结果可能类似于一个圆,因为您知道圆可以表示如下:
x = radius * cos(angle)
y = radius * sin(angle)
在本练习中,图像要复杂得多,因为这里表示为angle
的输入范围不仅仅是从-PI 到 PI。图 3-14 显示运行程序后的图像。
图 3-14。
Color change with more trigonometric function
在图像处理中使用三角函数会给你带来很多乐趣。请随意探索更多的变化。在下一节,我将开始解释随机性的想法如何帮助你生成有趣的图像。
随机性
处理提供了一个基于java.util.Random
类的随机数生成器。您可以使用random()
功能创建各种类型的随机彩色图像。在下一个练习Chapter03_10
中,你将使用随机数来填充灰度图像:
PImage img;
void setup() {
size(750, 750);
img = createImage(width, height, ARGB);
img.loadPixels();
noLoop();
}
void draw() {
background(0);
for (int i=0; i<img.pixels.length; i++) {
img.pixels[i] = color(floor(random(0, 256)));
}
img.updatePixels();
image(img, 0, 0);
}
draw()
函数中的for
循环遍历PImage
中的所有像素,并使用函数random(0, 256)
将颜色设置为 0 到 255 之间的随机值。结果图像完全混乱,没有任何可识别的图案,如图 3-15 所示。
图 3-15。
Random grayscale image
如果您希望创建一个更具随机性的视觉愉悦的图像,可以通过在颜色信息中施加规则来降低随机性的程度。下一个练习Chapter03_11
,将使用随机灰色调初始化图像中的第一个像素。下一个像素将增加或减少随机部分的灰度值。比较两个结果,看看第二个版本中是否有任何模式。
PImage img;
float value1;
float range;
void setup() {
size(750, 750);
img = createImage(width, height, ARGB);
img.loadPixels();
value1 = floor(random(0, 256));
range = 50;
noLoop();
}
void draw() {
background(0);
for (int i=0; i<img.pixels.length; i++) {
float v = random(-range, range);
value1 += v;
value1 = constrain(value1, 0, 255);
img.pixels[i] = color(value1);
}
img.updatePixels();
image(img, 0, 0);
}
代码基本上使用了random(-range, range)
语句在draw()
函数中引入了一个受控版本的随机性。图像将由随机的灰色调像素组成,但随机性被控制在一个较小的范围内,同时依赖于前一个像素,如图 3-16 所示。
图 3-16。
Random grayscale image with patterns
由于像素颜色信息依赖于最后一个,具有一定程度的随机性,您可以很容易地识别图像的水平纹理,因为阵列中像素的排列首先按行顺序排序。
下一个练习Chapter03_12
,在处理中使用noise()
函数来探索随机性。这是肯·柏林开发的柏林噪声函数。该函数的输出显示了一个更自然、更平滑的数字序列。处理提供了多达三维的柏林噪声函数。在本练习中,您将使用二维版本的噪波值用灰色调填充图像。
PImage img;
float xScale, yScale;
void setup() {
size(750, 750);
background(0);
img = createImage(width, height, ARGB);
img.loadPixels();
xScale = 0.01;
yScale = 0.01;
noLoop();
}
void draw() {
for (int y=0; y<img.height; y++) {
int rows = y*img.width;
for (int x=0; x<img.width; x++) {
img.pixels[rows+x] = color(floor(noise(x*xScale, y*yScale)*256));
}
}
img.updatePixels();
image(img, 0, 0);
}
请注意,对于像素的x
和y
位置,您使用xScale
和yScale
变量来缩小范围,以实现图像中更平滑的噪声效果,如图 3-17 所示。
图 3-17。
Grayscale color with Perlin noise
到目前为止,您已经使用算法的方式为每个像素填充颜色,创建了一个图像。您还学习了如何创建像素中带有随机颜色的图像。在下一节中,您将导入一个现有的图像,并使用前面几节中的步骤来处理像素颜色。
用现有图像绘图
下一个练习Chapter03_13
,使用现有图像将彩色图像转换为灰色调。当然,您可以使用 Processing 和 OpenCV 的内置函数来进行转换。您可以将此练习作为起点,学习如何编写简单的图像处理代码。
PImage img1, img2;
void setup() {
size(1500, 750);
background(0);
img1 = loadImage("landscape.png");
img1.loadPixels();
img2 = createImage(img1.width, img1.height, ARGB);
img2.loadPixels();
noLoop();
}
void draw() {
for (int i=0; i<img1.pixels.length; i++) {
color col = img1.pixels[i];
img2.pixels[i] = color((red(col) + green(col) + blue(col))/3);
}
img2.updatePixels();
image(img1, 0, 0);
image(img2, img1.width, 0);
}
在程序中,您将处理窗口定义为照片宽度的两倍,以便并排显示原始图像和修改后的图像。您使用两个PImage
变量。第一个是img1
,加载外部图像。第二个,img2
,使用红色、绿色和蓝色的简单平均,将第一个的颜色像素转换成单一的灰色调。图 3-18 显示了转换过程。
图 3-18。
Color to grayscale conversion with simple averaging
还有另一种方法可以从原始 RGB 图像计算灰度图像的亮度。视觉不会检测到强度相等的 RGB。在本练习版本Chapter03_14
中,您将使用以下公式得出亮度值:
img2.pixels[i] = color(0.2*red(col) + 0.7*green(col) + 0.1*blue(col));
图 3-19 显示了用于比较的结果图像。
图 3-19。
Color to grayscale conversion with relative luminance
在下一个练习Chapter03_15
中,您将编写一个反转滤镜来反转原始彩色图像的所有红色、绿色和蓝色通道。为了达到这种效果,您使用 255 并减去所有三个颜色通道值。公式如下:
img2.pixels[i] = color(255-red(col), 255-green(col), 255-blue(col));
图 3-20 显示了结果图像。
图 3-20。
Color change with inverse effect
您也可以交换三个颜色通道,以不同的顺序混合它们,以获得 Photoshop 中可以找到的其他效果。下面是一个例子,Chapter03_16
,它交换了三个通道的顺序,并反转了原来的红色通道:
img2.pixels[i] = color(blue(col), 255-red(col), green(col));
图 3-21 显示了使用相同图像的输出。
图 3-21。
Color change by swapping different color channels
处理有一个filter()
功能(
https://processing.org/reference/filter_.html
,它提供了许多图像处理预设,如下所示:
THRESHOLD
GRAY
OPAQUE
INVERT
POSTERIZE
BLUR
ERODE
DILATE
除了这些预设,您还可以实现自己的预设。以下练习将说明如何基于现有图像在画布上进行绘制。你要测试的第一个预设是 Photoshop 中的马赛克效果。马赛克效果实质上是在保持图像尺寸的同时降低图像分辨率。让我们来看看这个练习的代码,Chapter03_17
:
PImage img;
int step;
void setup() {
size(1500, 750);
background(0);
img = loadImage("landscape.png");
img.loadPixels();
step = 10;
noStroke();
noLoop();
}
void draw() {
for (int y=0; y<img.height; y+=step) {
int rows = y*img.width;
for (int x=0; x<img.width; x+=step) {
color col = img.pixels[rows+x];
fill(col);
rect(x+img.width, y, step, step);
}
}
image(img, 0, 0);
}
注意,在嵌套的for
循环中,你不需要遍历每一个像素。相反,您用变量step
中的一个值来增加索引。然后对这些像素的颜色进行采样,并将其用作正方形的fill()
颜色。图 3-22 显示了原始照片和拼接图像。
图 3-22。
Mosaic effect example
如果将rect()
命令替换为ellipse()
命令,可以实现圆形镶嵌效果,如图 3-23 所示。
图 3-23。
Mosaic effect with circles
前两个练习对矩形和圆形使用fill()
颜色。如果你使用stroke()
颜色来绘制线条,你可以对同一张照片进行不同的渲染,类似于图 3-24 。
图 3-24。
Mosaic effect with short line segments
在draw()
函数中,您使用随机机制floor(random(2))
来选择线段的绘制方向。其结果将是 0 或 1。您可以用它来确定对角线线段的方向。
PImage img;
int step;
void setup() {
size(1500, 750);
background(0);
img = loadImage("landscape.png");
img.loadPixels();
step = 10;
smooth();
noFill();
noLoop();
}
void draw() {
for (int y=0; y<img.height; y+=step) {
int rows = y*img.width;
for (int x=0; x<img.width; x+=step) {
color col = img.pixels[rows+x];
stroke(col);
int num = floor(random(2));
if (num == 0) {
line(x+img.width, y, x+img.width+step, y+step);
} else {
line(x+img.width+step, y, x+img.width, y+step);
}
}
}
image(img, 0, 0);
}
下一个练习Chapter03_20
,探索图像处理中常见的条形码效应。Irma Boom 等著名设计师也对旧的经典画作进行了采样,并用垂直色条来表示,类似于您在本练习中计划做的事情。首先,你拍摄一张彩色照片,并在照片中间添加一条水平线(图 3-25 )。
图 3-25。
Sample photograph with a horizontal line
沿着水平线,对线上每个像素进行采样,并检索其颜色值。通过使用颜色值,您可以沿着水平线为每个像素绘制一条垂直线。代码如下:
PImage img;
void setup() {
size(1200, 900);
background(0);
img = loadImage("christmas.png");
img.loadPixels();
noFill();
noLoop();
}
void draw() {
int y = img.height/2;
for (int x=0; x<img.width; x++) {
color c = img.pixels[y*img.width+x];
stroke(c);
line(x, 0, x, img.height-1);
}
}
这个程序很简单。视觉结果是原始照片的条形码表示,如图 3-26 所示。
图 3-26。
Barcode effect example
您可以通过将变量y
改为mouseY
并删除noLoop()
函数来试验这个程序的交互版本。在这种情况下,结果是仅由一张照片生成一个美丽的动画。
到目前为止,您已经探索了基于现有图像创建新图像的各种方法,或者通过替换像素颜色,或者通过在画布上参照像素颜色进行绘制。在下一节中,您将学习如何组合两幅图像。
混合多个图像
处理有一个blend()
功能( https://processing.org/reference/blend_.html
),它提供了许多选项来组合两幅图像。工作机制类似于 Photoshop 中的图层选项。本节将不详细解释每个选项。本节中的练习将说明组合两个图像的基本逻辑。下面的练习Chapter03_21
演示了blend()
功能在选项ADD
处理中的使用:
PImage img1, img2;
void setup() {
size(1200, 900);
background(0);
img1 = loadImage("hongkong.png");
img2 = loadImage("sydney.png");
noLoop();
}
void draw() {
img1.blend(img2, 0, 0, img2.width, img2.height,
0, 0, img1.width, img1.height, ADD);
image(img1, 0, 0);
}
您有两个PImage
实例,img1
和img2
,每个实例都从data
文件夹加载了一个外部图像。在draw()
函数中,img2
实例将通过img1.blend()
方法融合到img1
实例中。其余的参数是源偏移(x, y
)和尺寸(width, height
)、目标偏移(x, y
)和尺寸(width, height
)以及混合选项ADD
。注意,在blend()
功能之后,img1
的内容将会改变。练习中使用的两幅图像大小相同(1200×900 像素)。然而,这里的blend()
功能将改变img2
的分辨率,如果两幅图像的尺寸不同。图 3-27 显示了结果图像。
图 3-27。
Blending two images with the ADD option
您也可以使用自己的代码在处理过程中执行这种混合效果。对于ADD
选项,您可以将两幅图像中的两个像素颜色分量相加。因为 RGB 的有效范围是 0 到 255,所以您可以将值限制在此范围内。这里是练习的来源,Chapter03_22
。在这个版本中,假设两个图像具有相同的大小(1200×900 像素)。
PImage img1, img2, img3;
void setup() {
size(1200, 900);
background(0);
img1 = loadImage("hongkong.png");
img2 = loadImage("sydney.png");
img3 = createImage(img1.width, img1.height, ARGB);
noLoop();
}
void draw() {
for (int i=0; i<img1.pixels.length; i++) {
color c1 = img1.pixels[i];
color c2 = img2.pixels[i];
float r = constrain(red(c1) + red(c2), 0, 255);
float g = constrain(green(c1) + green(c2), 0, 255);
float b = constrain(blue(c1) + blue(c2), 0, 255);
img3.pixels[i] = color(r, g, b);
}
img3.updatePixels();
image(img3, 0, 0);
}
逻辑很简单。draw()
函数有一个for
循环来遍历img1
和img2
中的所有像素。三种颜色分量相加在一起,并限制在 0 到 255 的范围内。第三个PImage
实例img3
,存储所有新的像素颜色值,并在屏幕上显示图像。
作为演示,本节的最后一个练习Chapter03_23
,也将展示一个在 OpenCV 中完成的版本。要在处理环境中使用 OpenCV,记得将code
文件夹复制到你的 sketch 文件夹中,并在一个新的选项卡中重新创建CVImage
类,如前一章所示。您将创建三个CVImage
类的实例来维护hongkong.png
、sydney.png
和生成的图像。这里显示了主程序的示例代码。同样,假设两个源图像具有相同的大小(1200×900 像素)。
CVImage img1, img2, img3;
void setup() {
size(1200, 900);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
background(0);
PImage tmp = loadImage("hongkong.png");
img1 = new CVImage(tmp.width, tmp.height);
img2 = new CVImage(tmp.width, tmp.height);
img3 = new CVImage(tmp.width, tmp.height);
img1.copyTo(tmp);
tmp = loadImage("sydney.png");
img2.copyTo(tmp);
noLoop();
}
void draw() {
Mat m1 = img1.getBGR();
Mat m2 = img2.getBGR();
Mat m3 = new Mat(m1.size(), m1.type());
Core.add(m1, m2, m3);
img3.copyTo(m3);
image(img3, 0, 0);
m1.release();
m2.release();
m3.release();
}
程序的主要命令是Core.add()
功能。它将前两个源矩阵与第三个源矩阵相加作为目标矩阵。它也依赖于你在前一章开发的CVImage
类。图像对象使用copyTo()
和getBGR()
方法在处理格式和 OpenCV 格式之间转换。
结论
本章通过改变单个像素的颜色来介绍图像处理的基本任务。您现在了解了如何实现简单的图像滤镜,如灰度和反转滤镜。您还了解了如何从头开始创建图形图像,以及如何修改现有图像以进行创造性输出。在下一章中,你将改变像素的位置,这样你可以获得更动态的图像处理效果。
四、几何与变换
在本章中,您将继续处理数码图像的转换。在前一章中,您主要修改了图像的像素颜色信息,在处理中使用了内置函数和自定义函数。在这一章中,你将关注于在不改变图像内容的情况下使图像的像素网格变形。本质上,这改变了图像中每个像素的位置,从而修改了原始图像的几何形状。由于处理语言缺乏这样的功能,您将使用 OpenCV 来处理这些练习。同时,你将探索在三维特征处理中实现数字图像的几何变换。以下是本章涵盖的主题:
- 图像变换
- 图象取向
- 图像大小调整
- 仿射变换
- 透视变换
- 线性坐标与极坐标
- 三维空间
- 普通像素映射
图像变换
第一种类型的图像转换是翻译。在这种类型中,如图 4-1 所示,由矩形网格定义的整个数字图像在水平或垂直方向上移动。图像的大小和方向在变换前后保持不变。
图 4-1。
Image translation
本章介绍的第二种和第三种变换会改变图像的方向。它们是旋转和翻转。旋转时,图像在 2D 平面上沿假想的 z 轴旋转,没有任何尺寸变化或变形,如图 4-2 所示。在处理过程中,旋转的锚点是左上角(0,0)。
图 4-2。
Image rotation
翻转是沿着 x 轴和/或 y 轴的反射。在您将要进行的练习中,您可以在单个轴或两个轴上翻转图像。图 4-3 显示了正方形图像的垂直翻转。
图 4-3。
Image flipping
这三种类型的变换保留了原始图像的大小和形状。然而,图 4-4 所示的下一种变换将改变图像的大小。这是一个调整大小的变换。
图 4-4。
Image resize
前面介绍的四种变换保持了图像的形状。下一种类型,仿射变换,将扭曲原始形状,但它仍然保留平行线。矩形像素网格会变换成平行四边形,如图 4-5 所示。
图 4-5。
Affine transform
我要介绍的最后一种几何变换是透视变换。它将矩形图像网格转换为任意四点凸多边形。该变换还对应于透视投影,其中用附近的照相机将 3D 对象投影到 2D 平面上。图 4-6 显示了一个透视变换的例子。
图 4-6。
Perspective transform
图象取向
通过图像定向,我指的是诸如在二维平面中翻转和旋转图像的任务。通过使用flip()
函数,在 OpenCV 中很容易实现翻转或反射图像。在二维图形中,您可以让沿水平轴、垂直轴或两个轴翻转。flip()
函数的语法和参数如下:
public static void flip(Mat src, Mat dst, int flipCode);
该命令将根据flipCode
值中指定的内容,将src
矩阵翻转为dst
矩阵。flipCode
的零值将沿 x 轴翻转,正值将沿 y 轴翻转,负值将沿两个轴翻转。下面的练习Chapter04_01
演示了翻转在 OpenCV 处理中的使用。记住,如第一章所述,在加工草图和CVImage
类定义中包含code
文件夹。code
文件夹包含所有必需的 OpenCV Java 和本地文件。处理窗口大小适合并排显示两个图像。原始图像(600×600 像素)将位于左侧,翻转后的图像将位于右侧。
import org.opencv.core.*;
PImage img;
CVImage cv;
void setup() {
size(1200, 600);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
img = loadImage("hongkong.png");
cv = new CVImage(img.width, img.height);
noLoop();
}
void draw() {
background(0);
cv.copyTo(img);
Mat mat = cv.getBGR();
Core.flip(mat, mat, -1);
cv.copyTo(mat);
image(img, 0, 0);
image(cv, img.width, 0);
mat.release();
}
程序生成的图像包含两部分,如图 4-7 所示。左边是原始图像,右边是沿两个轴翻转的图像。
图 4-7。
Transform with flip in both axes
下一个练习Chapter04_02
,将帮助您学习图像旋转的命令。旋转图像需要遵循两个步骤。第一步是计算旋转变换矩阵。第二步是对源图像应用旋转变换矩阵。获取旋转矩阵的第一个命令的语法如下:
public static Mat Imgproc.getRotationMatrix2D(Point center, double angle, double scale)
第一个参数center
,是源图像中旋转中心点的坐标。第二个参数angle
是以度为单位测量的旋转角度。请注意,处理旋转以弧度为单位,而 OpenCV 旋转以度为单位。第三个参数scale
,是转换中应用的比例因子。该函数将输出一个 2×3 的矩阵,如下所示:
a b (1-a)*center.x-b*center.y
-b a b*center.x+(1-a)*center.y
这里,a = scale*cos(angle)
和b = scale*sin(angle)
。
一旦你有了旋转变换矩阵,你可以用warpAffine()
函数将矩阵应用到源图像。语法如下:
public static void Imgproc.warpAffine(Mat src, Mat dst, Mat m, Size dsize)
第一个参数src
是源图像。第二个参数dst
是目标图像,其类型与src
相同,大小与第四个参数dsize
中指定的相同。第三个参数,m
,是从上一步获得的旋转变换矩阵。第四个参数dsize
是目标图像的大小。同样,确保带有 OpenCV 库和CVImage
类的code
文件夹在加工草图文件夹中。原始图像为 600×600 像素。旋转后的图像将显示在原始图像的右侧。本练习的完整源代码Chapter04_02
如下所示:
import org.opencv.core.*;
import org.opencv.imgproc.*;
CVImage cvout;
Mat in;
PImage img;
Point ctr;
float angle;
void setup() {
size(1200, 600);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
img = loadImage("hongkong.png");
CVImage cvin = new CVImage(img.width, img.height);
cvout = new CVImage(cvin.width, cvin.height);
cvin.copyTo(img);
in = cvin.getBGR();
ctr = new Point(img.width/2, img.height/2);
angle = 0;
frameRate(30);
}
void draw() {
background(0);
Mat rot = Imgproc.getRotationMatrix2D(ctr, angle, 1.0);
Mat out = new Mat(in.size(), in.type());
Imgproc.warpAffine(in, out, rot, out.size());
cvout.copyTo(out);
image(img, 0, 0);
image(cvout, img.width, 0);
angle += 0.5;
angle %= 360;
out.release();
rot.release();
}
在代码中,你使用CVImage cvout
来保持旋转后的图像。Mat in
以 OpenCV 矩阵格式保存输入图像。OpenCV 类Point ctr
以图像的中心作为旋转的支点。float angle
是当前旋转角度。在draw()
功能中,每转一圈,它将增加半度。图 4-8 显示了运行草图的加工窗口示例。
图 4-8。
Rotation transform with digital image
通过使用处理tint()
功能,您可以在旋转显示中获得更多乐趣。在使用image()
函数之前,你可以通过指定一个小于 255 的 alpha 值来改变填充颜色的透明度,比如tint(255, 20)
。在draw()
功能中,如果去掉background(0)
,增加两个tint()
功能,就可以实现旋转图像中的运动模糊效果。新的draw()
功能如下:
void draw() {
// background(0);
Mat rot = Imgproc.getRotationMatrix2D(ctr, angle, 1.0);
Mat out = new Mat(in.size(), in.type());
Imgproc.warpAffine(in, out, rot, out.size());
cvout.copyTo(out);
tint(255, 255);
image(img, 0, 0);
tint(255, 20);
image(cvout, img.width, 0);
angle += 0.5;
angle %= 360;
out.release();
rot.release();
}
在image(cvout, img.width, 0)
之前的tint(255, 20)
函数将设置透明的填充颜色。在这种情况下,只有旋转图像会有运动模糊效果,而不是左侧的原始图像。图 4-9 显示了结果。
图 4-9。
Rotation transform with motion blur
图像大小调整
在前面的部分中,变换翻转和旋转不会改变图像的大小/面积。如果您想要更改图像大小,同时保持其形状,可以使用调整大小变换。该函数是来自 OpenCV Imgproc
模块的resize()
,如下图所示:
public static void Imgproc.resize(Mat src, Mat dst, Size dsize)
第一个参数src
是源图像。第二个参数dst
是目标图像。第三个参数,dsize
,是目标图像的大小。它属于 OpenCV Size
类。下图Chapter04_03
展示了resize()
功能在图形合成中的使用。程序中原始图像的大小为 800×600 像素。
import org.opencv.core.*;
import org.opencv.imgproc.*;
PImage img;
CVImage cv;
void setup() {
size(1200, 600);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
img = loadImage("hongkong.png");
cv = new CVImage(img.width, img.height);
cv.copyTo(img);
noLoop();
}
void draw() {
background(0);
Mat in = cv.getBGR();
Mat out = new Mat(new Size(img.width*0.5, img.height*0.5), in.type());
Imgproc.resize(in, out, out.size());
CVImage small = new CVImage(out.cols(), out.rows());
small.copyTo(out);
image(img, 0, 0);
tint(255, 100, 100);
image(small, img.width, 0);
tint(100, 100, 255);
image(small, img.width, small.height);
}
该程序创建了原始图像img
的副本,宽度和高度都是原来的一半。较小的图像small
在加工窗口的右侧以不同的色调显示两次,如图 4-10 所示。
图 4-10。
Resize transform with color tint
不使用 OpenCV,您也可以使用PImage
类的copy()
方法获得相同的结果。下一个练习,Chapter04_04
,将展示你如何用copy()
方法创作同样的作品。测试图像的尺寸为 800×600 像素。
PImage img;
void setup() {
size(1200, 600);
img = loadImage("hongkong.png");
noLoop();
}
void draw() {
background(0);
PImage small = createImage(round(img.width*0.5),
round(img.height*0.5), ARGB);
small.copy(img, 0, 0, img.width, img.height,
0, 0, small.width, small.height);
small.updatePixels();
image(img, 0, 0);
tint(255, 100, 100);
image(small, img.width, 0);
tint(100, 100, 255);
image(small, img.width, small.height);
}
copy()
方法将像素从原始图像img
复制到目标图像small
。除了源图像之外,这些参数还包括源图像和目标图像的偏移量(x, y
)和大小(width, height
)。
仿射变换
下一个几何变换是仿射变换,它可以在变换中保留平行线。要定义转换矩阵,您需要在源图像中有三个点,以及它们在目标图像中的相应位置。在下一个练习Chapter04_05
中,您将使用图像的左上角、右上角和右下角来定义变换。假设你有原始图像,img
。来自源图像的三个点如下:
0, 0
img.width-1, 0
img.width-1, img.height-1
仿射变换后,假设这三个点将分别移动到以下位置:
50, 50
img.width-100, 100
img.width-50, img.height-100
在这个程序中,你需要根据六个角点的映射来计算变换矩阵。使用矩阵,您可以将其应用于整个图像以创建输出图像。用于测试的图像尺寸为 600×600 像素。代码如下:
import org.opencv.core.*;
import org.opencv.imgproc.*;
PImage img;
CVImage cv;
void setup() {
size(1200, 600);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
img = loadImage("hongkong.png");
cv = new CVImage(img.width, img.height);
cv.copyTo(img);
noLoop();
}
void draw() {
background(0);
MatOfPoint2f srcMat = new MatOfPoint2f(new Point(0, 0),
new Point(img.width-1, 0),
new Point(img.width-1, img.height-1));
MatOfPoint2f dstMat = new MatOfPoint2f(new Point(50, 50),
new Point(img.width-100, 100),
new Point(img.width-50, img.height-100));
Mat affine = Imgproc.getAffineTransform(srcMat, dstMat);
Mat in = cv.getBGR();
Mat out = new Mat(in.size(), in.type());
Imgproc.warpAffine(in, out, affine, out.size());
cv.copyTo(out);
image(img, 0, 0);
image(cv, img.width, 0);
in.release();
out.release();
affine.release();
}
draw()
功能有两个步骤。第一个是基于六个角点计算变换矩阵。这是通过Imgproc.getAffineTransform()
功能完成的。
public static Mat Imgproc.getAffineTransform(MatOfPoint2f src, MatOfPoint2f dst)
第一个参数由源图像中的三个点组成。第二个参数由目标图像中的三个对应点组成。两个参数都属于 OpenCV 类MatOfPoint2f
。它类似于 C++中的vector
和 Java 中的ArrayList
。你可以把它看作是基类Point
的有序集合。第二步是对源图像in
应用仿射矩阵,并使用您在上一节中学习的warpAffine()
函数生成目标矩阵out
。图 4-11 显示了加工窗口中显示的结果图像。
图 4-11。
Affine transform
下一个练习Chapter04_06
,是仿射变换在图像处理中更实际的应用。该计划将允许用户改变锚点,以操纵变形的程度。在源代码中,您将引入另外一个类Corner
,来表示您可以拖动来改变转换的每个锚点。Corner
类的定义如下:
public class Corner {
float radius;
PVector pos;
boolean picked;
public Corner(float x, float y) {
pos = new PVector(x, y);
radius = 10.0;
picked = false;
}
PVector getPos() {
return pos;
}
void drag(float x, float y) {
if (picked) {
PVector p = new PVector(x, y);
pos.set(p.x, p.y);
}
}
void pick(float x, float y) {
PVector p = new PVector(x, y);
float d = p.dist(pos);
if (d < radius) {
picked = true;
pos.set(p.x, p.y);
}
}
void unpick() {
picked = false;
}
void draw() {
pushStyle();
fill(255, 255, 0, 160);
noStroke();
ellipse(pos.x, pos.y, radius*2, radius*2);
popStyle();
}
}
该类将显示一个圆圈来指示数字图像的角。在仿射变换中,只使用三个角。在本练习中,您将使用左上角、右上角和右下角。用户可以单击并拖动来移动角点。在下一节中使用透视转换时,您将重用该类。这里显示了Chapter04_06
的主程序:
import org.opencv.core.*;
import org.opencv.imgproc.*;
PImage img;
CVImage cvout;
PVector offset;
MatOfPoint2f srcMat, dstMat;
Mat in;
Corner [] corners;
void setup() {
size(720, 720);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
img = loadImage("hongkong.png");
CVImage cvin = new CVImage(img.width, img.height);
cvin.copyTo(img);
in = cvin.getBGR();
cvout = new CVImage(img.width, img.height);
offset = new PVector((width-img.width)/2, (height-img.height)/2);
srcMat = new MatOfPoint2f(new Point(0, 0),
new Point(img.width-1, 0),
new Point(img.width-1, img.height-1));
dstMat = new MatOfPoint2f();
corners = new Corner[srcMat.rows()];
corners[0] = new Corner(0+offset.x, 0+offset.y);
corners[1] = new Corner(img.width-1+offset.x, 0+offset.y);
corners[2] = new Corner(img.width-1+offset.x, img.height-1+offset.y);
}
void draw() {
background(0);
drawFrame();
Point [] points = new Point[corners.length];
for (int i=0; i<corners.length; i++) {
PVector p = corners[i].getPos();
points[i] = new Point(p.x-offset.x, p.y-offset.y);
}
dstMat.fromArray(points);
Mat affine = Imgproc.getAffineTransform(srcMat, dstMat);
Mat out = new Mat(in.size(), in.type());
Imgproc.warpAffine(in, out, affine, out.size());
cvout.copyTo(out);
image(cvout, offset.x, offset.y);
for (Corner c : corners) {
c.draw();
}
out.release();
affine.release();
}
void
drawFrame() {
pushStyle();
noFill();
stroke(100);
line(offset.x-1, offset.y-1,
img.width+offset.x, offset.y-1);
line(img.width+offset.x, offset.y-1,
img.width+offset.x, img.height+offset.y);
line(offset.x-1, img.height+offset.y,
img.width+offset.x, img.height+offset.y);
line(offset.x-1, offset.y-1,
offset.x-1, img.height+offset.y);
popStyle();
}
void
mousePressed() {
for (Corner c : corners) {
c.pick(mouseX, mouseY);
}
}
void mouseDragged() {
for (Corner c : corners) {
if (mouseX<offset.x ||
mouseX>offset.x+img.width ||
mouseY<offset.y ||
mouseY>offset.y+img.height)
continue;
c.drag(mouseX, mouseY);
}
}
void mouseReleased() {
for (Corner c : corners) {
c.unpick();
}
}
该程序添加了鼠标事件处理程序来管理由Corner
类定义的带有锚点的鼠标点击动作。在draw()
函数中,使用另一种方法来初始化dstMat
矩阵。您已经将points
定义为一个Point
数组。在每一帧中,将锚点信息从corners
复制到points
,并使用fromArray()
方法初始化dstMat
以进行后续处理。程序的其余部分与上一个类似。图 4-12 显示了程序的可视化显示。
图 4-12。
Interactive affine transform
透视变换
透视变换的用法类似于上一节中的仿射变换,只是您需要使用四个点而不是三个点来定义变换。在变换之后,它不能像在仿射变换中那样保持平行线。生成透视变换矩阵的函数如下:
public static Mat Imgproc.getPerspectiveTransform(MatOfPoint2f src, MatOfPoint2f dst)
第一个参数src
,是来自源图像的四个锚点的集合(MatOfPont2f)
)。在练习中,Chapter04_07
,你使用输入图像的四个角,img
。它们如下:
0, 0
:左上角img.width-1, 0
:右上角img.width-1, img.height-1
:右下角0, img.height-1
:左下角
第二个参数dst
,是变换后输出图像的四个角点的集合(MatOfPoint2f)
)。您采用上一个练习中的类Corner
。用户可以单击/拖动角点,以交互方式更改变换矩阵。源代码和前面的差不多。您只需用透视变换替换仿射变换,并使用四个点而不是三个点。这里使用的原始图像的大小是 700×700 像素。
PImage img;
CVImage cvout;
PVector offset;
MatOfPoint2f srcMat, dstMat;
Mat in;
Corner [] corners;
void
setup() {
size(720, 720);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
img = loadImage("hongkong.png");
CVImage cvin = new CVImage(img.width, img.height);
cvin.copyTo(img);
in = cvin.getBGR();
cvout = new CVImage(img.width, img.height);
offset = new PVector((width-img.width)/2, (height-img.height)/2);
srcMat = new MatOfPoint2f(new Point(0, 0),
new Point(img.width-1, 0),
new Point(img.width-1, img.height-1),
new Point(0, img.height-1));
dstMat = new MatOfPoint2f();
corners = new Corner[srcMat.rows()];
corners[0] = new Corner(0+offset.x, 0+offset.y);
corners[1] = new Corner(img.width-1+offset.x, 0+offset.y);
corners[2] = new Corner(img.width-1+offset.x, img.height-1+offset.y);
corners[3] = new Corner(0+offset.x, img.height-1+offset.y);
}
void
draw() {
background(0);
drawFrame();
Point [] points = new Point[corners.length];
for (int i=0; i<corners.length; i++) {
PVector p = corners[i].getPos();
points[i] = new Point(p.x-offset.x, p.y-offset.y);
}
dstMat.fromArray(points);
Mat transform = Imgproc.getPerspectiveTransform(srcMat, dstMat);
Mat out = new Mat(in.size(), in.type());
Imgproc.warpPerspective(in, out, transform, out.size());
cvout.copyTo(out);
image(cvout, offset.x, offset.y);
for (Corner c : corners) {
c.draw();
}
out.release();
transform.release();
}
void
drawFrame() {
pushStyle();
noFill();
stroke(100);
line(offset.x-1, offset.y-1,
img.width+offset.x, offset.y-1);
line(img.width+offset.x, offset.y-1,
img.width+offset.x, img.height+offset.y);
line(offset.x-1, img.height+offset.y,
img.width+offset.x, img.height+offset.y);
line(offset.x-1, offset.y-1,
offset.x-1, img.height+offset.y);
popStyle();
}
void
mousePressed() {
for (Corner c : corners) {
c.pick(mouseX, mouseY);
}
}
void mouseDragged() {
for (Corner c : corners) {
if (mouseX<offset.x ||
mouseX>offset.x+img.width ||
mouseY<offset.y ||
mouseY>offset.y+img.height)
continue;
c.drag(mouseX, mouseY);
}
}
void mouseReleased() {
for (Corner c : corners) {
c.unpick();
}
}
为了执行透视变换,你使用新的warpPerspective()
函数和矩阵transform
,它是从上一个getPerspectiveTransform()
矩阵生成的,如图 4-13 所示。
图 4-13。
Perspective transform with interactivity
请注意,当用户单击/拖动角点时,您不会检查新形状是否是凸形的。当新形状不是凸形时,可能会导致图像失真。
线性坐标与极坐标
您使用的 x,y 坐标系统是线性的,或笛卡尔坐标。这两个轴是互相垂直的直线。除了线性坐标系,你还可以用半径和角度的测量来表示二维平面上的一个点(x,y),如图 4-14 所示。
图 4-14。
Linear and polar coordinates
OpenCV 通过图像处理模块Imgproc
提供了将图像从线性坐标空间转换到极坐标空间的转换函数。该功能如下所示:
public static void Imgproc.linearPolar(Mat src, Mat dst, Point center, double maxRadius, int flags)
第一个参数src
是源图像。第二个参数dst
是目标图像,其大小和类型与源图像相同。第三个参数,center
,是转换中心。你通常把它设置在图像的中心。第四个参数maxRadius
是要变换的边界圆的半径。第五个参数flags
,是插值方法的组合。使用双线性插值INTER_LINEAR
,填充所有目标像素WARP_FILL_OUTLIERS
。在演示练习Chapter04_08
中,您将使用实时网络摄像头作为输入图像,并并排显示源图像和转换后的图像。
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.*;
Capture cap;
CVImage img, out;
int capW, capH;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
capW = width/2;
capH = height;
cap = new Capture(this, capW, capH);
cap.start();
img = new CVImage(cap.width, cap.height);
out = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat linear = img.getBGR();
Mat polar = new Mat();
Point ctr = new Point(cap.width/2, cap.height/2);
double radius = min(cap.width, cap.height)/2.0;
Imgproc.linearPolar(linear, polar, ctr, radius,
Imgproc.INTER_LINEAR+Imgproc.WARP_FILL_OUTLIERS);
out.copyTo(polar);
image(cap, 0, 0);
image(out, cap.width, 0);
linear.release();
polar.release();
}
在本练习中,您将半径设置为视频图像高度的一半。图 4-15 显示了加工窗口图像。
图 4-15。
Linear to polar transform
OpenCV 还提供了另一种极坐标变换,logPolar()
。它类似于linearPolar()
函数,只是它使用距离的自然对数。以下练习Chapter04_09
展示了如何使用logPolar()
功能处理网络摄像头图像:
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.*;
Capture cap;
CVImage img, out;
int capW, capH;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
capW = width/2;
capH = height;
cap = new Capture(this, capW, capH);
cap.start();
img = new CVImage(cap.width, cap.height);
out = new CVImage(cap.width, cap.height);
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat linear = img.getBGR();
Mat polar = new Mat();
Point ctr = new Point(cap.width/2, cap.height/2);
double radius = (double)min(cap.width, cap.height)/2.0;
double m = (double)cap.width/log((float)radius);
Imgproc.logPolar(linear, polar, ctr, m,
Imgproc.INTER_LINEAR+Imgproc.WARP_FILL_OUTLIERS);
out.copyTo(polar);
image(cap, 0, 0);
image(out, cap.width, 0);
linear.release();
polar.release();
}
该功能声称模仿人类的“视网膜中央凹”视觉,看起来更“自然”图 4-16 显示了结果图像。
图 4-16。
Linear to log polar transform
三维空间
除了使用 OpenCV 中的图像处理模块,您还可以使用处理中的 3D 图形功能来转换图像。在本书的大部分练习中,您使用image()
函数直接在屏幕上显示PImage
对象实例。在处理过程中,还有其他方式来显示图像。下面的练习Chapter04_10
演示了如何使用PShape
类( https://processing.org/reference/PShape.html
)和PImage
类作为贴图纹理。这个练习由两幅图像组成。第一个是来自一个PImage
实例的背景图像。第二个是来自Capture
实例的前景图像。3D 空间中的旋转模拟透视变换。
import processing.video.*;
Capture cap;
PImage img;
PShape canvas;
int capW, capH;
float angle;
void setup() {
size(800, 600, P3D);
hint(DISABLE_DEPTH_TEST);
capW = 640;
capH = 480;
cap = new Capture(this, capW, capH);
cap.start();
img = loadImage("hongkong.png");
canvas = createShape(RECT, 0, 0, cap.width, cap.height);
canvas.setStroke(false);
canvas.setTexture(cap);
shapeMode(CENTER);
angle = 0;
}
void draw() {
if (!cap.available())
return;
cap.read();
background(0);
image(img, 0, 0);
translate(width/2, height/2, -100);
rotateX(radians(angle));
shape(canvas, 0, 0);
angle += 0.5;
angle %= 360;
}
程序的第一个变化是size()
功能。它有一个额外的参数P3D
,表示您现在处于 3D 显示模式。高级用户可以使用 OpenGL 提示功能来控制渲染参数( https://processing.org/tutorials/rendering/
)。您在这里使用的hint()
函数有一个参数来禁用渲染中的深度测试,这样前面的表面就不会遮挡后面的表面。在setup()
函数中,您用下面的函数定义了PShape
实例canvas
:
createShape(RECT, 0, 0, cap.width, cap.height);
这将创建一个矩形形状RECT
,左上角为(0,0),宽度和高度等于网络摄像头捕捉的宽度和高度。下一条语句禁用描边颜色。您还可以使用canvas.setTexture(cap)
将来自网络摄像头的PImage cap
关联为形状的纹理canvas
。在draw()
功能中,首先将背景清除为黑色,然后在加工窗口上直接显示背景图像img
。translate()
功能将图形移动到屏幕中心,在 z 方向也是负值。rotateX()
功能将图形沿 X 轴旋转angle
中指定的量。请注意,旋转以弧度为度量单位。如果使用的是度,则需要使用radians()
功能进行转换。最后一步是使用shape()
函数在中心(0,0)显示PShape
实例canvas
。还要注意,在setup()
函数中,您将shapeMode
设置为CENTER
,而不是左上角。示例显示将类似于图 4-17 所示。
图 4-17。
Perspective transform in Processing by rotation
在上一个练习中,您使用内置矩形形状来定义形状。事实上,你可以定义你自己的顶点。下面的练习Chapter04_11
,将使用一系列vertex()
命令定义相同的图形。之后,您可以检索单个顶点并改变其位置,以实现更动态的动画。
import processing.video.*;
Capture cap;
PImage img;
PShape canvas;
int capW, capH;
float angle;
int vCnt;
void setup() {
size(800, 600, P3D);
hint(DISABLE_DEPTH_TEST);
capW = 640;
capH = 480;
cap = new Capture(this, capW, capH);
cap.start();
img = loadImage("hongkong.png");
canvas = createShape();
canvas.beginShape();
canvas.textureMode(NORMAL);
canvas.texture(cap);
canvas.noStroke();
canvas.vertex(0, 0, 0, 0, 0);
canvas.vertex(cap.width, 0, 0, 1, 0);
canvas.vertex(cap.width, cap.height, 0, 1, 1);
canvas.vertex(0, cap.height, 0, 0, 1);
canvas.endShape(CLOSE);
shapeMode(CENTER);
angle = 0;
vCnt = canvas.getVertexCount();
}
void draw() {
if (!cap.available())
return;
cap.read();
background(0);
image(img, 0, 0);
for (int i=0; i<vCnt; i++) {
PVector pos = canvas.getVertex(i);
if (i < 2) {
pos.z = 100*cos(radians(angle*3));
} else {
pos.z = 100*sin(radians(angle*5));
}
canvas.setVertex(i, pos);
}
translate(width/2, height/2, -100);
rotateY(radians(angle));
shape(canvas, 0, 0);
angle += 0.5;
angle %= 360;
}
图 4-18 显示了结果。
图 4-18。
Perspective transform with custom shape
前面的练习仅使用矩形的四个角来指定矩形。图像没有失真太多。如果使用线框栅格作为纹理贴图的骨架,可以修改每个点以进一步扭曲图像。图 4-19 显示了网格和映射在其上的图像。
图 4-19。
Image text-mapped on a grid
要在处理中定义网格,您可以使用QUAD_STRIP
形状。首先,为整个网格定义一个GROUP
形状。第二,将网格的每一行创建为一个QUAD_STRIP
形状。要在QUAD_STRIP
中创建一个单元格,您必须按以下顺序定义点:左上、左下、右上和右下。第三,将每一行作为子对象添加到GROUP
形状中。图 4-20 说明了该形状的详细配置。
图 4-20。
A GROUP shape with QUAD_STRIP as children
下一个练习Chapter04_12
,展示了如何使用一个叫做canvas
的GROUP PShape
来定义详细纹理映射的网格:
import processing.video.*;
Capture cap;
PShape canvas;
int capW, capH;
float step;
void setup() {
size(800, 600, P3D);
hint(DISABLE_DEPTH_TEST);
capW = 640;
capH = 480;
step = 40;
cap = new Capture(this, capW, capH);
cap.start();
initShape();
shapeMode(CENTER);
}
void initShape() {
// initialize the GROUP PShape grid
canvas = createShape(GROUP);
int nRows = floor(cap.height/step) + 1;
int nCols = floor(cap.width/step) + 1;
for (int y=0; y<nRows-1; y++) {
// initialize each row of the grid
PShape tmp = createShape();
tmp.beginShape(QUAD_STRIP);
tmp.texture(cap);
for (int x=0; x<nCols; x++) {
// initialize the top-left, bottom-left points
int x1 = (int)constrain(x*step, 0, cap.width-1);
int y1 = (int)constrain(y*step, 0, cap.height-1);
int y2 = (int)constrain((y+1)*step, 0, cap.height-1);
tmp.vertex(x1, y1, 0, x1, y1);
tmp.vertex(x1, y2, 0, x1, y2);
}
tmp.endShape();
canvas.addChild(tmp);
}
}
void draw() {
if (!cap.available())
return;
cap.read();
background(100);
translate(width/2, height/2, -80);
rotateX(radians(20));
shape(canvas, 0, 0);
}
难的部分在initShape()
函数中完成。首先将整个网格的PShape
定义为
canvas = createShape(GROUP)
然后遍历网格中的每个单元格。请注意,您需要通过将总行数nRows
和总列数nCols
加 1 来处理右边距和下边距。对于每一行,您将临时变量tmp
定义为一个QUAD_STRIP
形状。创建完QUAD_STRIP
中的所有顶点后,使用canvas.addChild(tmp)
将其添加到canvas
形状中。现在,您可以将视频捕获图像作为纹理映射到网格上。然而,你不会就此止步。您的目的是改变顶点在屏幕上的 z 位置,这样您就可以获得视频捕获的失真图像。
下一个练习Chapter04_13
,将在网格中维护一个二维的顶点数组。此外,您将为每个顶点设置一个随机的初始 z 位置。
import processing.video.*;
Capture cap;
PShape canvas;
int capW, capH;
float step;
PVector [][] points;
float angle;
void setup() {
size(800, 600, P3D);
hint(DISABLE_DEPTH_TEST);
capW = 640;
capH = 480;
step = 20;
cap = new Capture(this, capW, capH);
cap.start();
initGrid();
initShape();
shapeMode(CENTER);
angle = 0;
}
void initGrid() {
// initialize the matrix of points for texture mapping
points = new PVector[floor(cap.height/step)+1][floor(cap.width/step)+1];
for (int y=0; y<points.length; y++) {
for (int x=0; x<points[y].length; x++) {
float xVal = constrain(x*step, 0, cap.width-1);
float yVal = constrain(y*step, 0, cap.height-1);
// random z value
points[y][x] = new PVector(xVal, yVal, noise(x*0.2, y*0.2)*60-30);
}
}
}
void initShape() {
// initialize the GROUP PShape grid
canvas = createShape(GROUP);
for (int y=0; y<points.length-1; y++) {
// initialize each row of the grid
PShape tmp = createShape();
tmp.beginShape(QUAD_STRIP);
tmp.noStroke();
tmp.texture(cap);
for (int x=0; x<points[y].length; x++) {
PVector p1 = points[y][x];
PVector p2 = points[y+1][x];
tmp.vertex(p1.x, p1.y, p1.z, p1.x, p1.y);
tmp.vertex(p2.x, p2.y, p2.z, p2.x, p2.y);
}
tmp.endShape();
canvas.addChild(tmp);
}
}
void draw() {
if (!cap.available())
return;
cap.read();
lights();
background(100);
translate(width/2, height/2, -100);
rotateX(radians(angle*1.3));
rotateY(radians(angle));
shape(canvas, 0, 0);
angle += 0.5;
angle %= 360;
}
被称为points
的PVector
的 2D 数组维护网格的所有顶点。函数initGrid()
初始化位置信息。对于 z 位置,您使用 Perlin 噪声函数来初始化它。initShape()
函数将复制points
数组中的信息,用适当的纹理映射创建GROUP PShape
、canvas
。请注意,您还可以使用lights()
功能来启用draw()
功能中的默认照明条件。生成的图像(如图 4-21 所示)将类似于 3D 地形,网络摄像头图像映射在其上。
图 4-21。
Texture map with irregular surface
在上一个练习中,请注意栅格中的顶点没有移动。它们在setup()
函数中创建一次,没有任何进一步的改变。在下一个练习Chapter04_14
中,您将尝试根据网络摄像头图像制作顶点动画,以便获得交互式观看体验。不是为每个顶点的 z 位置输入一个随机数,而是通过使用来自网络摄像头的颜色信息来改变它的值。在加工中,默认的颜色模式是RGB
。但是,如果您想要明确使用亮度信息,可以将其切换到 HSB(色调、饱和度、亮度)。在本练习中,您的目标是用该像素的亮度信息交换顶点的 z 位置。可以使用的功能是brightness()
。
import processing.video.*;
Capture cap;
int capW, capH;
float step;
PVector [][] points;
float angle;
PShape canvas;
void setup() {
size(800, 600, P3D);
hint(DISABLE_DEPTH_TEST);
capW = 640;
capH = 480;
step = 10;
cap = new Capture(this, capW, capH);
cap.start();
initGrid();
initShape();
shapeMode(CENTER);
angle = 0;
}
void initGrid() {
// initialize the matrix of points for texture mapping
points = new PVector[floor(cap.height/step)+1][floor(cap.width/step)+1];
for (int y=0; y<points.length; y++) {
for (int x=0; x<points[y].length; x++) {
float xVal = constrain(x*step, 0, cap.width-1);
float yVal = constrain(y*step, 0, cap.height-1);
points[y][x] = new PVector(xVal, yVal, 0);
}
}
}
void initShape() {
canvas = createShape(GROUP);
for (int y=0; y<points.length-1; y++) {
// initialize each row of the grid
PShape tmp = createShape();
tmp.beginShape(QUAD_STRIP);
tmp.noFill();
for (int x=0; x<points[y].length; x++) {
PVector p1 = points[y][x];
PVector p2 = points[y+1][x];
tmp.vertex(p1.x, p1.y, p1.z);
tmp.vertex(p2.x, p2.y, p2.z);
}
tmp.endShape();
canvas.addChild(tmp);
}
}
color getColor(int x, int y) {
// obtain color information from cap
int x1 = constrain(floor(x*step), 0, cap.width-1);
int y1 = constrain(floor(y*step), 0, cap.height-1);
return cap.get(x1, y1);
}
void updatePoints() {
// update the depth of vertices using color
// brightness from cap
float factor = 0.3;
for (int y=0; y<points.length; y++) {
for (int x=0; x<points[y].length; x++) {
color c = getColor(x, y);
points[y][x].z = brightness(c)*factor;
}
}
}
void updateShape() {
// update the color and depth of vertices
for (int i=0; i<canvas.getChildCount(); i++) {
for (int j=0; j<canvas.getChild(i).getVertexCount(); j++) {
PVector p = canvas.getChild(i).getVertex(j);
int x = constrain(floor(p.x/step), 0, points[0].length-1);
int y = constrain(floor(p.y/step), 0, points.length-1);
p.z = points[y][x].z;
color c = getColor(x, y);
canvas.getChild(i).setStroke(j, c);
canvas.getChild(i).setVertex(j, p);
}
}
}
void draw() {
if (!cap.available())
return;
cap.read();
updatePoints();
updateShape();
background(0);
translate(width/2, height/2, -100);
rotateX(radians(angle));
shape(canvas, 0, 0);
angle += 0.5;
angle %= 360;
}
initGrid()
函数初始化点数组。initShape()
函数使用来自点数组的信息来初始化PShape canvas
。在该函数中,您不需要直接在每个QUAD_STRIP
子节点中设置纹理。您可以启用描边颜色,但禁用子形状的填充颜色。在draw()
函数中,你编写了updatePoints()
函数来根据颜色亮度更新顶点的 z 位置。updateShape()
函数遍历PShape canvas
的所有子节点,并更新顶点的 z 位置和笔画颜色。图 4-22 显示了显示窗口的示例。
图 4-22。
3D effect using brightness as depth
请注意,在图像中,颜色较深的区域看起来较深,而较亮的区域在网格平面中较高。如果将initShape()
函数中的tmp.noFill()
语句改为tmp.noStroke()
,将updateShape()
函数中的canvas.getChild(i).setStroke(j, c)
语句改为canvas.getChild(i).setFill(j, c)
,就可以将线框显示切换为实心版本,如图 4-23 所示。
图 4-23。
3D effect with brightness as depth
普通像素映射
除了 Processing 和 OpenCV 中用于图像转换的内置函数之外,您还可以通过从源图像到目标图像的逐像素映射来编写通用图像转换算法。在本章的最后一个练习Chapter04_15
中,您将尝试将第一幅图像img1
中的单个像素复制到第二幅图像img2
中。该变换将基于正弦和余弦函数产生的谐波运动。这里使用的图像大小为 600×600 像素。
PImage img1, img2;
float angle;
void setup() {
size(1200, 600);
img1 = loadImage("hongkong.png");
img2 = createImage(img1.width, img1.height, ARGB);
angle = 0;
}
void draw() {
// Variables rx, ry are for the radii of the sine/cosine functions
// Variables ax, ay are for the angles of the sine/cosine functions
background(0);
for (int y=0; y<img2.height; y++) {
float ay = y*angle/img2.height;
float ry = y*angle/360.0;
for (int x=0; x<img2.width; x++) {
float ax = x*angle/img2.width;
float rx = x*angle/360.0;
int x1 = x + (int)(rx*cos(radians(ay)));
int y1 = y + (int)(ry*sin(radians(ax)));
x1 = constrain(x1, 0, img1.width-1);
y1 = constrain(y1, 0, img1.height-1);
img2.pixels[y*img2.width+x] = img1.pixels[y1*img1.width+x1];
}
}
angle += 1;
angle %= 360;
img2.updatePixels();
image(img1, 0, 0);
image(img2, img1.width, 0);
}
第一个图像img1
是源图像。第二个图像img2
,与img1
大小相同。在draw()
函数的嵌套for
循环中,你从相反的方向穿过目标图像中的每个像素img2
。对于每个像素,您可以从源图像中找到应该将哪个像素复制到目标图像中。对于源像素,采用正弦和余弦函数,变量影响半径和角度。总体结果是一个扭曲效果作用于源图像的动画,如图 4-24 所示。图 4-24 和图 4-25 显示了不同时间点的两个样本显示。
图 4-25。
General mapping of pixels sample 2
图 4-24。
General mapping of pixels sample 1
这是你从动画中捕捉到的第二个瞬间(图 4-25 )。
从目标图像返回到源图像的原因是不要让任何目标像素为空。这是开发像素映射转换时的常见做法。
结论
本章描述了通过改变像素位置从而改变几何图形来修改图像的步骤。Processing 和 OpenCV 都具有几何变换功能。为了简化编码任务,您可以根据应用需求选择使用哪一个。或者,您可以通过指定目标图像中的所有像素以及它们在源图像中的来源来编写自己的图像转换函数。到目前为止,您只为创造性结果修改了图像。你还没有尝试去理解这些图像。在下一章,你将开始理解图像中的内容。
五、结构的识别
在前两章中学习了图像处理之后,您将开始使用处理和 OpenCV 探索计算机视觉。在前几章中,网络摄像头图像是创意输出的来源材料。你没有尝试去理解图像的内容。在本章中,您可以使用计算机视觉的概念来识别图像中的结构。通过这些结构,你会对图像的内容有更多的理解。本章将涉及的主题如下:
- 图像准备
- 边缘检测
- 车道检测
- 圆形检测
- 轮廓处理
- 形状检测
图像准备
在发送源图像进行检测之前,通常需要对图像进行优化。所谓优化,我指的是减少原始图像中不必要信息的过程。例如,当您想要识别图像中的直线时,通常不需要彩色图像。灰度的就可以了。有时,黑白图像可能足以满足形状检测的目的。以下是准备图像进行检测时要遵循的步骤:
- 转换为灰度
- 转换成黑白图像
- 形态学操作(侵蚀、扩张)
- 模糊操作(平滑)
转换为灰度
在第二章中,你学习了如何通过改变每个像素将彩色 RGB 图像转换成灰度图像。在下面的练习中,您将探索在处理和 OpenCV 中的不同方法来达到相同的效果。第一个练习Chapter05_01
,将在处理中使用filter()
函数。本练习中使用的示例图像的大小为 600×600 像素。
PImage source, grey;
void setup() {
size(1200, 600);
source = loadImage("sample04.jpg");
grey = createImage(source.width, source.height, ARGB);
noLoop();
}
void draw() {
background(0);
arrayCopy(source.pixels, grey.pixels);
grey.updatePixels();
grey.filter(GRAY);
image(source, 0, 0);
image(grey, source.width, 0);
}
该程序还演示了如何使用arrayCopy()
函数有效地从一个数组复制到另一个相同大小的数组。转换图像的实际函数是grey.filter(GRAY)
。程序会将原始图像和灰度并排显示进行对比,如图 5-1 所示。
图 5-1。
Grayscale conversion in Processing
下一个版本Chapter05_02
将使用 OpenCV 函数来执行灰度转换。请注意,在章节 2 示例Chapter02_21
中定义的CVImage
类中,您已经编写了getGrey()
方法来返回灰度图像矩阵。在使用 OpenCV 进行加工之前,请记住将code
文件夹和CVImage
定义复制到草图文件夹。样本图像的大小为 600×600 像素。
PImage source;
CVImage srccv, greycv;
void setup() {
size(1200, 600);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
source = loadImage("sample04.jpg");
srccv = new CVImage(source.width, source.height);
srccv.copyTo(source);
greycv = new CVImage(source.width, source.height);
noLoop();
}
void draw() {
background(0);
Mat mat = srccv.getGrey();
greycv.copyTo(mat);
image(source, 0, 0);
image(greycv, source.width, 0);
mat.release();
}
在程序中,您使用CVImage
实例greycv
来保存通过getGrey()
方法转换后的灰度图像。
转换成黑白图像
您在上一节中获得的灰度图像通常包含 256 级灰色调。在某些应用中,您可能希望只有两个级别,简单的黑色和白色。在这种情况下,您可以使用以下方法将灰度图像进一步转换为黑白图像。练习Chapter05_03
将向您展示如何使用处理filter()
函数来实现这一点。本练习中示例图像的大小为 600×600 像素。
PImage source, grey, bw;
void setup() {
size(1800, 600);
source = loadImage("sample01.jpg");
grey = createImage(source.width, source.height, ARGB);
bw = createImage(source.width, source.height, ARGB);
noLoop();
}
void draw() {
background(0);
arrayCopy(source.pixels, grey.pixels);
grey.updatePixels();
grey.filter(GRAY);
arrayCopy(grey.pixels, bw.pixels);
bw.updatePixels();
bw.filter(THRESHOLD, 0.5);
image(source, 0, 0);
image(grey, source.width, 0);
image(bw, source.width+grey.width, 0);
}
我经常把黑白转换称为阈值转换。当灰度值低于阈值时,灰度值高于阈值的像素将被认为是黑白的。这里用的函数是bw.filter(THRESHOLD, 0.5)
,其中数字 0.5 是阈值。图 5-2 显示窗口。
图 5-2。
Black-and-white image conversion with thresholding
左边的图像是原始照片。中间的是第一个filter()
函数后的灰度版。右边的是第二个filter()
功能后的黑白图像,这次带有选项THRESHOLD
。下一个练习Chapter05_04
将展示一个在 OpenCV 中完成的版本:
PImage source;
CVImage srccv, bwcv;
void setup() {
size(1800, 600);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
source = loadImage("sample04.jpg");
srccv = new CVImage(source.width, source.height);
bwcv = new CVImage(source.width, source.height);
srccv.copyTo(source);
noLoop();
}
void draw() {
background(0);
Mat grey = srccv.getGrey();
Mat bw = new Mat();
Imgproc.threshold(grey, bw, 127, 255, Imgproc.THRESH_BINARY);
bwcv.copyTo(bw);
srccv.copyTo(grey);
image(source, 0, 0);
image(srccv, source.width, 0);
image(bwcv, source.width+srccv.width, 0);
grey.release();
bw.release();
}
以下是执行阈值操作的 OpenCV 函数:
Imgproc.threshold(grey, bw, 127, 255, Imgproc.THRESH_BINARY);
在函数中,第一个数字 127 是 0 到 255 范围内的中点。这是阈值。第二个数字 255 是灰度级别的最大数字。
形态学运算
图像处理中的形态学操作是修改图像中图案形状的变换。在这一节中,我只介绍侵蚀和扩张操作。下面的练习Chapter05_05
展示了如何在处理过程中做到这一点:
PImage source, grey, bw, dilate, erode;
void setup() {
size(1800, 600);
source = loadImage("sample02.jpg");
grey = createImage(source.width, source.height, ARGB);
bw = createImage(source.width, source.height, ARGB);
dilate = createImage(source.width, source.height, ARGB);
erode = createImage(source.width, source.height, ARGB);
noLoop();
}
void draw() {
background(0);
arrayCopy(source.pixels, grey.pixels);
grey.updatePixels();
grey.filter(GRAY);
arrayCopy(grey.pixels, bw.pixels);
bw.updatePixels();
bw.filter(THRESHOLD, 0.5);
arrayCopy(bw.pixels, erode.pixels);
arrayCopy(bw.pixels, dilate.pixels);
erode.updatePixels();
dilate.updatePixels();
dilate.filter(DILATE);
erode.filter(ERODE);
image(bw, 0, 0);
image(erode, bw.width, 0);
image(dilate, bw.width+erode.width, 0);
}
结果显示包含三幅图像,如图 5-3 所示。左边的是来自THRESHOLD
滤镜的黑白图像。中间的是ERODE
版本。右边的是DILATE
版本。
图 5-3。
Erode and dilate filters in Processing
ERODE
滤镜减少白色区域的数量,而DILATE
滤镜增加白色区域的数量。对于想要消除黑暗、微小噪声模式的应用来说,DILATE
滤镜将是一个不错的选择。对于 OpenCV 版本,请参考以下练习,Chapter05_06
:
PImage source;
CVImage srccv, bwcv, erodecv, dilatecv;
void setup() {
size(1800, 600);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
source = loadImage("sample02.jpg");
srccv = new CVImage(source.width, source.height);
bwcv = new CVImage(source.width, source.height);
erodecv = new CVImage(source.width, source.height);
dilatecv = new CVImage(source.width, source.height);
srccv.copyTo(source);
noLoop();
}
void draw() {
background(0);
Mat grey = srccv.getGrey();
Mat bw = new Mat();
Imgproc.threshold(grey, bw, 127, 255, Imgproc.THRESH_BINARY);
Mat erode = new Mat();
Mat dilate = new Mat();
Mat elem = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(3, 3));
Imgproc.erode(bw, erode, elem);
Imgproc.dilate(bw, dilate, elem);
bwcv.copyTo(bw);
erodecv.copyTo(erode);
dilatecv.copyTo(dilate);
image(bwcv, 0, 0);
image(erodecv, bwcv.width, 0);
image(dilatecv, bwcv.width+erodecv.width, 0);
grey.release();
bw.release();
erode.release();
dilate.release();
}
程序使用前一个Imgproc.threshold()
函数先将灰度图像转换成黑白图像。随后的Imgproc.erode()
和Imgproc.dilate()
功能将分别执行侵蚀和扩张形态操作。在进行侵蚀和扩张操作之前,您需要另一个矩阵,称为elem
,它是描述形态学操作的结构化元素或内核。它通常有三种形状。
Imgproc.MORPH_RECT
Imgproc.MORPH_CROSS
Imgproc.MORPH_ELLIPSE
不同形状参数的elem
的内容如下所示:
你会发现,尺寸为 3×3 时,MORPH_CROSS
和MORPH_ELLIPSE
效果是一样的。对于更大的尺寸,它们会有所不同。MORPH_CROSS
只会在中间的行和列中有一个 1,而MORPH_ELLIPSE
会有一个近似圆形的 1。过滤操作将使用矩阵elem
扫描源图像。只有那些在elem
中具有值 1 的像素将被收集用于计算。DILATE
过滤器将用elem
中定义的邻域像素中的最大值替换原始图像像素。ERODE
滤镜将用邻域中的最小值替换原始图像像素。你可以在 OpenCV 文档中的 http://docs.opencv.org/3.1.0/d4/d86/group__imgproc__filter.html#gac2db39b56866583a95a5680313c314ad
找到这三个图形的细节。对于Size()
参数,尺寸越大,变换效果越明显。一般来说,它是一个有一对奇数的正方形。
模糊操作
要进一步减少图像中的噪点或不必要的细节,您可以考虑使用模糊效果。Processing 和 OpenCV 都有一个模糊过滤器或函数。下一个练习Chapter05_07
,在处理中使用模糊滤镜来执行操作:
PImage source, blur;
void setup() {
size(1200, 600);
source = loadImage("sample03.jpg");
blur = createImage(source.width, source.height, ARGB);
noLoop();
}
void draw() {
background(0);
arrayCopy(source.pixels, blur.pixels);
blur.updatePixels();
blur.filter(BLUR, 3);
image(source, 0, 0);
image(blur, source.width, 0);
}
这个程序很简单。它使用带有BLUR
选项的filter()
函数。选项后的数字是模糊量。数字越大,图像越模糊。图 5-4 显示了程序产生的显示窗口。
图 5-4。
Blur filter in Processing
对于 OpenCV,有几个模糊函数。在下一个练习Chapter05_08
中,您将探究其中的一些并比较结果。它使用 OpenCV 的imgproc
模块中的blur()
、medianBlur()
和GaussianBlur()
函数。第一个blur()
功能是局部平均操作,其中新图像像素是其邻域像素的平均值。在计算平均值时,GaussianBlur()
函数对较近的像素赋予较高的权重,这对于去除可见噪声更有效。medianBlur()
函数采用中值而不是平均值来计算新的像素值,这在去除噪声的同时更有效地保留了边缘/边界。
PImage source;
CVImage srccv, blurcv, mediancv, gaussiancv;
void setup() {
size(1800, 600);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
source = loadImage("sample03.jpg");
srccv = new CVImage(source.width, source.height);
blurcv = new CVImage(source.width, source.height);
mediancv = new CVImage(source.width, source.height);
gaussiancv = new CVImage(source.width, source.height);
srccv.copyTo(source);
noLoop();
}
void draw() {
background(0);
Mat mat = srccv.getBGR();
Mat blur = new Mat();
Mat median = new Mat();
Mat gaussian = new Mat();
Imgproc.medianBlur(mat, median, 9);
Imgproc.blur(mat, blur, new Size(9, 9));
Imgproc.GaussianBlur(mat, gaussian, new Size(9, 9), 0);
blurcv.copyTo(blur);
mediancv.copyTo(median);
gaussiancv.copyTo(gaussian);
image(blurcv, 0, 0);
image(mediancv, blurcv.width, 0);
image(gaussiancv, blurcv.width+mediancv.width, 0);
mat.release();
blur.release();
median.release();
gaussian.release();
}
三个功能的模糊图像并排显示,如图 5-5 所示。
图 5-5。
Three blurring functions in OpenCV
作为本节的总结,您将结合这些操作来构建一个实际的应用,将实时网络摄像头图像转换为二进制黑白图像,以供以后处理。第一个版本是纯处理为练习而写的,Chapter05_09
,如下所示:
import processing.video.*;
Capture cap;
void setup() {
size(1280, 480);
cap = new Capture(this, width/2, height);
cap.start();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
PImage tmp = createImage(cap.width, cap.height, ARGB);
arrayCopy(cap.pixels, tmp.pixels);
tmp.filter(GRAY);
tmp.filter(BLUR, 2);
tmp.filter(THRESHOLD, 0.25);
tmp.filter(DILATE);
image(cap, 0, 0);
image(tmp, cap.width, 0);
text(nf(round(frameRate), 2), 10, 20);
}
在程序中,你结合了模糊,灰度,阈值和腐蚀操作。对于纯处理实现,性能并不好。您添加text()
功能,在屏幕上显示当前的帧速率,以便进行比较。图 5-6 显示加工显示窗口。
图 5-6。
Image preparation in Processing
对于 OpenCV 实现,在练习Chapter05_10
中,您还可以将图像操作合并到一个单独的程序中,并将实时网络摄像头图像作为输入。性能比纯处理版好很多。
import processing.video.*;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.GaussianBlur(tmp1, tmp2, new Size(5, 5), 0);
Imgproc.threshold(tmp2, tmp1, 80, 255, Imgproc.THRESH_BINARY);
Mat elem = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, new Size(3, 3));
Imgproc.dilate(tmp1, tmp2, elem);
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(tmp2);
image(cap, 0, 0);
image(out, cap.width, 0);
tmp1.release();
tmp2.release();
elem.release();
text(nf(round(frameRate), 2), 10, 20);
}
图 5-7 显示加工显示窗口图像。帧速率明显高于处理版本。
图 5-7。
Image preparation in Processing with OpenCV
边缘检测
了解了准备图像的步骤后,您将发现的第一个结构是图像中任何对象的边缘或轮廓。计算机实际上不理解任何图像内容。它只能系统地扫描每个像素及其邻居。对于那些与相邻像素有明显色差的像素,您可以断定这些像素属于可能将两个对象或一个对象与其背景分开的轮廓。
处理没有边缘检测滤波器,尽管实现起来并不困难。对于 OpenCV,可以使用约翰·f·坎尼在 1986 年开发的著名的坎尼边缘检测器。为了运行边缘检测,执行模糊操作以去除噪声并将彩色图像转换为灰度通常是有益的。下一个练习Chapter05_11
将说明这些步骤:
import processing.video.*;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.GaussianBlur(tmp1, tmp2, new Size(7, 7), 1.5, 1.5);
Imgproc.Canny(tmp2, tmp1, 10, 30);
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(tmp1);
image(cap, 0, 0);
image(out, cap.width, 0);
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
}
以下是边缘检测的主要功能:
Imgproc.Canny(tmp2, tmp1, 10, 30);
图像tmp2
是模糊的灰度图像。图像tmp1
是包含边缘图像的图像。该函数有两个阈值。第一个数字是下限。如果一个像素的梯度值低于较低的阈值,它将被拒绝。第二个数字是上限。如果像素的梯度值大于上限阈值,它将被接受为边缘像素。如果像素的梯度值介于两个阈值之间,则只有当它连接到高于上限阈值的另一个像素时,它才会被接受为边缘。Canny 也建议第二个是第一个的 2 到 3 倍之间的值。值越大,图像中检测到的边缘越少。图 5-8 显示了检测结果。
图 5-8。
Canny edge detection
作为比较,您也可以使用threshold()
功能将灰度图像转换为黑白图像。之后,您可以使用黑白图像执行边缘检测。下一个练习Chapter05_12
演示了这种方法:
import processing.video.*;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.GaussianBlur(tmp1, tmp2, new Size(7, 7), 1.5, 1.5);
Imgproc.threshold(tmp2, tmp1, 110, 255, Imgproc.THRESH_BINARY);
Imgproc.Canny(tmp1, tmp2, 10, 30);
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(tmp2);
img.copyTo(tmp1);
image(img, 0, 0);
image(out, img.width, 0);
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
}
生成的图像更加抽象,如图 5-9 所示。最终图像中的细节和噪点会更少。
图 5-9。
Canny edge detection with black-and-white image
车道检测
除了检测图像中形状的边缘或边界,您还可以使用 OpenCV 中的 Hough 直线变换来检测直线段。官方 OpenCV 文档中有霍夫线变换背后的数学细节;你可以在 http://docs.opencv.org/3.1.0/d9/db0/tutorial_hough_lines.html
找到文档。下面的练习Chapter05_13
是处理中的一个简单实现:
import processing.video.*;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
noStroke();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.Canny(tmp1, tmp2, 50, 150);
MatOfPoint2f lines = new MatOfPoint2f();
Imgproc.HoughLines(tmp2, lines, 1, PI/180, 100);
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(tmp2);
image(cap, 0, 0);
image(out, cap.width, 0);
Point [] points = lines.toArray();
pushStyle();
noFill();
stroke(255);
for (Point p : points) {
double rho = p.x;
double theta = p.y;
double a = cos((float)theta);
double b = sin((float)theta);
PVector pt1, pt2;
double x0 = rho*a;
double y0 = rho*b;
pt1 = new PVector((float)(x0 + cap.width*(-b)), (float)(y0 + cap.width*(a)));
pt2 = new PVector((float)(x0 - cap.width*(-b)), (float)(y0 - cap.width*(a)));
line(pt1.x, pt1.y, pt2.x, pt2.y);
}
popStyle();
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
lines.release();
}
线检测的主要命令是Imgproc.HoughLines()
功能。第一个参数是 Canny 边缘检测后的黑白图像。第二个参数是存储所有检测到的行信息的输出矩阵。由于它是一个 1×N 双通道矩阵,为了方便起见,您使用了子类MatOfPoint2f
。其余的参数将决定检测的准确性。从高中代数中,你大概明白一条线可以用下面的式子来表示:
y = m * x + c
在HoughLines()
函数中,同一行由另一个公式表示。
rho = x * cos (theta) + y * sin(theta)
这里,rho
是图像原点到直线的垂直距离,theta
是垂直线与水平 x 轴所成的角度。HoughLines()
函数保存一个 2D 数组;第一维是rho
的值,以像素为单位,第二维是theta
的值,以度为单位。
第三个参数是测量rho
的像素分辨率。本例中的值 1 表示rho
的分辨率为 1 个像素。较大的值通常会生成更多精度较低的线。第四个参数是测量theta
的角度分辨率。本例中的值PI/180
表示theta
的分辨率为 1 度。第五个参数决定了线条的检测效果。在本例中,将只报告那些通过的点超过 100 的线。在线检测之后,您将lines
矩阵转换为Point
的数组。数组中的每个成员都是一行。您使用for
循环中的计算来计算每条线的两个端点,最后line()
函数用白色绘制这条线。
图 5-10 显示了加工窗口。检测到的线条绘制在实时网络摄像头图像上。
图 5-10。
Hough line transform detection
OpenCV 还有另一个叫做HoughLinesP()
的直线检测函数,它更高效,使用起来也更友好。它将返回每条线段的两个端点。下面的练习Chapter05_14
说明了该函数的用法:
import processing.video.*;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.Canny(tmp1, tmp2, 50, 150);
Mat lines = new Mat();
Imgproc.HoughLinesP(tmp2, lines, 1, PI/180, 80, 30, 10);
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(tmp2);
image(out, cap.width, 0);
pushStyle();
fill(100);
rect(0, 0, cap.width, cap.height);
noFill();
stroke(0);
for (int i=0; i<lines.rows(); i++) {
double [] pts = lines.get(i, 0);
float x1 = (float)pts[0];
float y1 = (float)pts[1];
float x2 = (float)pts[2];
float y2 = (float)pts[3];
line(x1, y1, x2, y2);
}
popStyle();
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
lines.release();
}
对于HoughLinesP()
函数的参数,第一个是图像矩阵。第二个参数是存储所有线段信息的输出矩阵lines
。第三个参数1
是像素分辨率,而第四个参数PI/180
是角度分辨率,单位为度。第五个参数80
是阈值。第六个参数30
是最小线路长度。第七个参数10
是最大线间隙。输出lines
是一个一维矩阵,只有一列多行。在draw()
函数内的for
循环中,您遍历来自lines
的所有行。每个元素实际上是另一个大小为 4 的数组。前两个是第一个端点的x
和y
位置。数组的第三和第四个元素是第二个端点的 x 和 y 位置。对于这两个端点,使用line()
功能在它们之间画一条直线。图 5-11 显示了结果图像。
图 5-11。
Hough line transform detection
在下一个练习Chapter05_15
中,您将使用创意图像处理中常用的一种技术来修改之前的练习。对于每条线段,计算它的中点并对像素颜色信息进行采样。使用此颜色,您可以更改该线段的描边颜色。其结果将类似于绘画中的彩色素描技术。
import processing.video.*;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.Canny(tmp1, tmp2, 20, 60);
Mat lines = new Mat();
Imgproc.HoughLinesP(tmp2, lines, 1, PI/180, 70, 30, 10);
image(cap, 0, 0);
pushStyle();
noFill();
for (int i=0; i<lines.rows(); i++) {
double [] pts = lines.get(i, 0);
float x1 = (float)pts[0];
float y1 = (float)pts[1];
float x2 = (float)pts[2];
float y2 = (float)pts[3];
int mx = (int)constrain((x1+x2)/2, 0, cap.width-1);
int my = (int)constrain((y1+y2)/2, 0, cap.height-1);
color c = cap.pixels[my*cap.width+mx];
stroke(c);
strokeWeight(random(1, 5));
line(x1+cap.width, y1, x2+cap.width, y2);
}
popStyle();
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
lines.release();
}
请注意,您还引入了一个strokeWeight(random(1, 5))
命令来为线段使用不同的笔画粗细。图 5-12 显示输出显示。
图 5-12。
Line detection as drawing
OpenCV 有一个LineSegmentDetector
类实现 Rafael Grompone von Gioi 的线段检测器。这种方法将首先在一个非常小的区域中检测图像梯度方向,例如 2×2 像素。相似的方向串接在一起,判断是否可以是线段。下一个练习Chapter05_16
使用新方法重新创建上一个练习:
import processing.video.*;
Capture cap;
CVImage img;
LineSegmentDetector line;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
line = Imgproc.createLineSegmentDetector();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat lines = new Mat();
line.detect(tmp1, lines);
pushStyle();
for (int i=0; i<lines.rows(); i++) {
double [] pts = lines.get(i, 0);
float x1 = (float)pts[0];
float y1 = (float)pts[1];
float x2 = (float)pts[2];
float y2 = (float)pts[3];
int mx = (int)constrain((x1+x2)/2, 0, cap.width-1);
int my = (int)constrain((y1+y2)/2, 0, cap.height-1);
color col = cap.pixels[my*cap.width+mx];
stroke(col);
strokeWeight(random(1, 3));
line(x1+cap.width, y1, x2+cap.width, y2);
}
popStyle();
image(cap, 0, 0);
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
lines.release();
}
首先将全局变量line
定义为LineSegmentDetector
的一个实例。在setup()
函数中,您使用带有默认设置的静态函数Imgproc.createLineSegmentDetector()
初始化实例。在draw()
功能中,检测很简单。使用line.detect()
方法完成,输入矩阵tmp1
和输出结果lines
作为参数。lines
矩阵的结构与之前的练习相似。每个条目包含两个端点的x
和y
位置。结果显示看起来与之前的练习不同,如图 5-13 所示。
图 5-13。
Line detection with the OpenCV LineSegmentDetector
圆形检测
与直线检测类似,OpenCV 图像处理模块imgproc
也包括使用霍夫圆变换的圆检测方法HoughCircles()
。在下一个练习Chapter05_17
中,您将探索此功能,从实时网络摄像头拍摄的准备好的图像中检测圆形:
import processing.video.*;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.GaussianBlur(tmp1, tmp2, new Size(9, 9), 1);
Imgproc.Canny(tmp2, tmp1, 100, 200);
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(tmp1);
MatOfPoint3f circles = new MatOfPoint3f();
Imgproc.HoughCircles(tmp1, circles, Imgproc.HOUGH_GRADIENT, 1, tmp1.rows()/8, 200, 45, 0, 0);
Point3 [] points = circles.toArray();
image(cap, 0, 0);
image(out, cap.width, 0);
pushStyle();
noStroke();
fill(0, 0, 255, 100);
for (Point3 p : points) {
ellipse((float)p.x, (float)p.y, (float)(p.z*2), (float)(p.z*2));
}
popStyle();
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
circles.release();
}
该程序首先将图像转换为灰度,然后应用高斯模糊滤镜,最后检测边缘。然后将 Canny 边缘图像发送到HoughCircles()
功能进行圆检测。第一个参数tmp1
是输入图像。第二个参数circles
是输出结果。第三个参数Imgproc.HOUGH_GRADIENT
,是圆检测的唯一选项。第四个参数是分辨率的反比。通常是 1。第五个参数tmp1.rows()/8
,是被检测圆之间的最小距离。第六个参数200
是内部 Canny 边缘检测器的阈值上限。第七个参数45
,是中心检测的阈值。该值越小,它将检测到的圆越多。其余参数是半径的最小值和最大值。它们默认为 0。结果circles
是一个一维矩阵。您使用一个MatOfPoint3f
来存储它的值。每个条目将包含三个值的数组,对应于圆心(x
、y
位置)和半径。for
循环遍历所有圆圈,并以半透明的蓝色显示。图 5-14 显示了结果图像。
图 5-14。
Hough circle transform for circle detection
您可以通过过度检测来玩圆形检测程序。在下面的练习Chapter05_18
中,您特意在HoughCircles()
函数的第七个参数中放了一个小值,这样会产生很多错误检测。下面是程序的源代码:
import processing.video.*;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
MatOfPoint3f circles = new MatOfPoint3f();
Imgproc.HoughCircles(img.getGrey(), circles, Imgproc.HOUGH_GRADIENT, 1, img.height/10, 200, 20, 0, 0);
Point3 [] points = circles.toArray();
pushStyle();
noStroke();
for (Point3 p : points) {
int x1 = constrain((int)p.x, 0, cap.width-1);
int y1 = constrain((int)p.y, 0, cap.height-1);
color col = cap.pixels[y1*cap.width+x1];
fill(color(red(col), green(col), blue(col), 160));
ellipse(x1+cap.width, y1, (float)(p.z*2), (float)(p.z*2));
}
popStyle();
image(cap, 0, 0);
text(nf(round(frameRate), 2), 10, 20);
circles.release();
}
你也删除了准备步骤,希望产生更多的圆圈。在for
循环中,你使用前一种方法给圆圈上色。在这个版本中,你也为每个圆圈使用半透明的颜色。图 5-15 显示了结果显示。
图 5-15。
Drawing with Hough circle transform
该图像是原始网络摄像头图像的抽象渲染。你可以从颜色的使用和圆圈的位置上看出相似之处。就形状而言,你很难把它们与原作联系起来。
轮廓处理
在前面的小节中,您使用了 OpenCV 图像处理模块imgproc
,从数字图像中识别特定的形状。在轮廓处理中,您使用相同的模块来识别图形形状的更一般的轮廓。它包括寻找轮廓和解释轮廓信息的方法。因为这些函数只对二进制图像有效,所以您必须准备好图像,使它们只包含黑白信息。我将介绍轮廓处理的以下步骤:
- 寻找轮廓
- 包围盒
- 最小面积矩形
- 凸包
- 多边形近似
- 测试轮廓中的点
- 检查交叉路口
寻找轮廓
在下一个练习Chapter05_19
中,程序首先模糊灰度图像,然后用Canny()
函数提取边缘,然后发送给findContours()
函数:
import processing.video.*;
import java.util.ArrayList;
import java.util.Iterator;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.blur(tmp1, tmp2, new Size(3, 3));
Imgproc.Canny(tmp2, tmp1, 50, 100);
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();
Imgproc.findContours(tmp1, contours, hierarchy,
Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);
image(cap, 0, 0);
pushStyle();
noFill();
stroke(255, 255, 0);
Iterator<MatOfPoint> it = contours.iterator();
while (it.hasNext()) {
Point [] pts = it.next().toArray();
for (int i=0; i<pts.length-1; i++) {
Point p1 = pts[i];
Point p2 = pts[i+1];
line((float)p1.x+cap.width, (float)p1.y, (float)p2.x+cap.width, (float)p2.y);
}
}
popStyle();
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
}
在findContours()
函数中,第一个参数是黑白图像。第二个参数是输出等值线数据结构。第三个参数是跟踪外部边缘和内部孔的关系的层级信息。第四个参数Imgproc.RETR_LIST
检索轮廓信息,无需跟踪层次关系。第五个参数Imgproc.CHAIN_APPROX_SIMPLE
,只将等高线线段压缩成两个端点。您将在后面的练习中使用其他选项。主要输出contours
,是MatOfPoint
的一个 Java ArrayList
。每个MatOfPoint
被转换成一个Point
的数组。for
循环从一个Point
到下一个for
绘制一条线段。图 5-16 显示了结果图像。
图 5-16。
Contours processing with black-and-white Canny image
下一个练习Chapter05_20
没有使用 Canny 边缘检测图像,而是使用由threshold()
函数准备的黑白图像:
import processing.video.*;
import java.util.ArrayList;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.blur(tmp1, tmp2, new Size(5, 5));
Imgproc.threshold(tmp2, tmp1, 80, 255, Imgproc.THRESH_BINARY);
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();
tmp1 = tmp2.clone();
Imgproc.findContours(tmp1, contours, hierarchy,
Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(tmp1);
image(out, 0, 0);
pushStyle();
noFill();
stroke(255, 255, 0);
for (MatOfPoint ps : contours) {
Point [] pts = ps.toArray();
for (int i=0; i<pts.length-1; i++) {
Point p1 = pts[i];
Point p2 = pts[i+1];
line((float)p1.x+cap.width, (float)p1.y, (float)p2.x+cap.width, (float)p2.y);
}
}
popStyle();
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
}
该练习使用了threshold()
函数将灰色图像转换为纯黑白图像。findContours()
功能可以立即在黑白图像上执行轮廓跟踪。在这两个练习中,我还演示了使用for
循环和iterator
遍历MatOfPoint
的 Java List
的不同方式。图 5-17 显示了结果图像。
图 5-17。
Contours processing with threshold image
在接下来的练习Chapter05_21
中,您将使用findContours()
函数中的另一个选项来仅检索外部轮廓,而不返回那些内部孔。你用Imgproc.RETR_EXTERNAL
替换原来的选项Imgproc.RETR_LIST
。其余保持不变。新声明如下:
Imgproc.findContours(tmp2, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE);
如图 5-18 所示,汉字的内轮廓在新选项下不可见。
图 5-18。
Contours processing with the RETR_EXTERNAL option
现在,您将进一步探索轮廓检索模式中的其他选项。下一个练习Chapter05_22
,将使用一个更复杂的RETR_CCOMP
。它将所有轮廓组织成两个层次。所有外部边界都将位于顶层。这些洞在第二层。对于洞内的任何轮廓也将在顶层。在练习中,您可以利用这些信息用两种不同的颜色填充外部轮廓和孔。程序中使用的源图像大小为 600×600 像素。
import java.util.ArrayList;
CVImage cvimg;
PImage img;
void setup() {
size(1200, 600);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
img = loadImage("chinese.png");
cvimg = new CVImage(img.width, img.height);
noLoop();
}
void draw() {
background(0);
cvimg.copyTo(img);
Mat tmp1 = new Mat();
Imgproc.blur(cvimg.getGrey(), tmp1, new Size(3, 3));
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();
Imgproc.findContours(tmp1, contours, hierarchy,
Imgproc.RETR_CCOMP, Imgproc.CHAIN_APPROX_SIMPLE);
image(img, 0, 0);
pushStyle();
stroke(255);
for (int i=0; i<contours.size(); i++) {
Point [] pts = contours.get(i).toArray();
int parent = (int)hierarchy.get(0, i)[3];
// parent -1 implies it is the outer contour.
if (parent == -1) {
fill(200);
} else {
fill(100);
}
beginShape();
for (Point p : pts) {
vertex((float)p.x+img.width, (float)p.y);
}
endShape(CLOSE);
}
popStyle();
tmp1.release();
hierarchy.release();
}
除了将检索模式更改为RETR_CCOMP
,您还可以使用hierarchy
矩阵。它是一个一维矩阵。每一列对应于contours
矩阵中的一个条目,具有相同的索引排列。hierarchy
中的每个条目都是一个有四个值的数组。每个值都是轮廓矩阵中条目的索引。索引的映射如下:
hierarchy.get(0, i)[0]
:下一个兄弟轮廓hierarchy.get(0, i)[1]
:上一个兄弟轮廓hierarchy.get(0, i)[2]
:第一个子轮廓hierarchy.get(0, i)[3]
:父轮廓
索引中的值-1 表示相应的条目不可用。如果您看一下draw()
函数中的for
循环,该语句检查当前轮廓在位置i
的父索引。
int parent = (int)hierarchy.get(0, i)[3];
如果它没有任何父级(-1),就用浅灰色着色(如果有,就用深灰色着色)。图 5-19 显示了结果图像。左边的汉字来自原图。右边的图像是具有两种灰色调的轮廓的渲染。
图 5-19。
Contours processing with option RETR_CCOMP
还有另一个检索模式RETR_TREE
,它将在层次矩阵中存储每个轮廓的完整父子树关系。由于它的复杂性,我不会在本书中涉及它。
在您检测到图形形状的轮廓后,绘制轮廓将不是您唯一关心的事情。您可能希望确定移动的图形形状之间的相互作用,或者检查重叠区域。在接下来的部分中,您将研究如何理解从图像中检测到的轮廓信息。
包围盒
您可以从轮廓信息中获得的第一个信息是它的边界框。您可以使用 OpenCV 图像处理模块中的boundingRect()
函数。输入参数是一个轮廓,由一个MatOfPoint
类实例维护。输出是 OpenCV 矩形类,Rect
。
import processing.video.*;
import java.util.ArrayList;
import java.util.Iterator;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.blur(tmp1, tmp2, new Size(3, 3));
Imgproc.Canny(tmp2, tmp1, 80, 160);
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();
Imgproc.findContours(tmp1, contours, hierarchy,
Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);
image(cap, 0, 0);
pushStyle();
noStroke();
Iterator<MatOfPoint> it = contours.iterator();
while (it.hasNext()) {
Rect r = Imgproc.boundingRect(it.next());
int cx = (int)(r.x + r.width/2);
int cy = (int)(r.y + r.height/2);
cx = constrain(cx, 0, cap.width-1);
cy = constrain(cy, 0, cap.height-1);
color col = cap.pixels[cy*cap.width+cx];
fill(color(red(col), green(col), blue(col), 200));
rect((float)r.x+cap.width, (float)r.y, (float)r.width, (float)r.height);
}
popStyle();
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
}
在这个程序中,Chapter05_23
,一旦你获得每个包围盒的数据作为一个Rect
,你就使用处理函数rect()
来绘制矩形。Rect
类包含四个属性:x
、y
、width
和height
。您还可以从矩形的中心获得颜色信息,并使用它给矩形着色,使其具有透明度。结果是原始图像的抽象渲染,如图 5-20 所示。
图 5-20。
Bounding rectangle for contours
最小面积矩形
OpenCV 图像处理模块有另一个函数minAreaRect()
,用于计算轮廓的最小面积边界矩形。在下一个练习Chapter05_24
中,您将获得轮廓的最小面积旋转矩形。结果是一个旋转的矩形类RotatedRect
。
import processing.video.*;
import java.util.ArrayList;
import java.util.Iterator;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.blur(tmp1, tmp2, new Size(3, 3));
Imgproc.Canny(tmp2, tmp1, 100, 200);
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();
Imgproc.findContours(tmp1, contours, hierarchy,
Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);
image(cap, 0, 0);
pushStyle();
rectMode(CENTER);
noFill();
strokeWeight(2);
Iterator<MatOfPoint> it = contours.iterator();
while (it.hasNext()) {
RotatedRect r = Imgproc.minAreaRect(new MatOfPoint2f(it.next().toArray()));
int cx = constrain((int)r.center.x, 0, cap.width-1);
int cy = constrain((int)r.center.y, 0, cap.height-1);
color col = cap.pixels[cy*cap.width+cx];
stroke(col);
Point [] pts = new Point[4];
r.points(pts);
beginShape();
for (int i=0; i<pts.length; i++) {
vertex((float)pts[i].x+cap.width, (float)pts[i].y);
}
endShape(CLOSE);
}
popStyle();
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
}
minAreaRect()
函数接受一个MatOfPoint2f
格式的参数。轮廓输出的每个成员都是MatOfPoint
的一个实例。在这种情况下,您必须将其转换为适当的类MatOfPoint2f
,然后才能在minAreaRect()
函数中使用。以下语句可以执行转换:
new MatOfPoint2f(it.next().toArray())
RotatedRect
实例r
具有属性center
,该属性保持旋转矩形的中心位置。使用中心点找出绘制矩形的颜色信息。要绘制矩形,使用points()
方法计算旋转矩形的四个角点。结果是一个Point
数组,pts
。有了这四个角点,您可以使用beginShape()
和endShape(CLOSE)
方法,通过指定顶点来绘制矩形。图 5-21 显示了输出图像。
图 5-21。
Minimum-area rectangle of contour
凸包
除了包围盒,还可以使用 OpenCV 来寻找轮廓信息的凸包。您使用的功能是convexHull()
。它获取MatOfPoint
轮廓信息并输出一个MatOfInt
矩阵hull
。输出实际上是轮廓索引的一个Point
数组。原则上,hull
中的条目数小于Point
数组pts
,因为它只包含构成凸形的点。
import java.util.ArrayList;
import java.util.Iterator;
CVImage cv;
PImage img;
void setup() {
size(1200, 600);
background(50);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
img = loadImage("chinese.png");
cv = new CVImage(img.width, img.height);
noLoop();
}
void draw() {
cv.copyTo(img);
Mat tmp1 = cv.getGrey();
Mat tmp2 = new Mat();
Imgproc.blur(tmp1, tmp2, new Size(3, 3));
Imgproc.Canny(tmp2, tmp1, 100, 200);
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();
Imgproc.findContours(tmp1, contours, hierarchy,
Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);
image(img, 0, 0);
pushStyle();
noFill();
stroke(250);
Iterator<MatOfPoint> it = contours.iterator();
while (it.hasNext()) {
MatOfInt hull = new MatOfInt();
MatOfPoint mPt = it.next();
Point [] pts = mPt.toArray();
Imgproc.convexHull(mPt, hull);
int [] indices = hull.toArray();
beginShape();
for (int i=0; i<indices.length; i++) {
vertex((float)pts[indices[i]].x+img.width, (float)pts[indices[i]].y);
}
endShape(CLOSE);
hull.release();
mPt.release();
}
popStyle();
tmp1.release();
tmp2.release();
}
在这个程序中,Chapter05_25
,你使用汉字进行测试。结果会更明显。在while
循环中,你遍历每个轮廓并使用hull
数组中的顶点创建一个闭合的形状。图 5-22 显示了结果图像以供参考。左边的字符是原件,而右边的图形是从轮廓上看的凸包。
图 5-22。
Convex hull processing in OpenCV
多边形近似
除了使用凸包来简化轮廓,OpenCV 还提供了其他方法来简化轮廓。下一个练习Chapter05_26
介绍了一种给定轮廓的多边形近似方法。功能是approxPolyDP()
。
import processing.video.*;
import java.util.ArrayList;
import java.util.Iterator;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.blur(tmp1, tmp2, new Size(3, 3));
Imgproc.Canny(tmp2, tmp1, 100, 200);
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();
Imgproc.findContours(tmp1, contours, hierarchy,
Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);
image(cap, 0, 0);
pushStyle();
noFill();
Iterator<MatOfPoint> it = contours.iterator();
while (it.hasNext()) {
strokeWeight(random(5));
stroke(255, random(160, 256));
MatOfPoint2f poly = new MatOfPoint2f();
Imgproc.approxPolyDP(new MatOfPoint2f(it.next().toArray()), poly, 3, true);
Point [] pts = poly.toArray();
beginShape();
for (int i=0; i<pts.length; i++) {
vertex((float)pts[i].x+cap.width, (float)pts[i].y);
}
endShape(CLOSE);
}
popStyle();
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
}
在while
循环中,将每个轮廓传递给approxPolyDP()
函数。第一个参数是转换成MatOfPoint2f
的轮廓信息。第二个参数poly
是存储为另一个MatOfPoint2f
的输出多边形信息。第三个参数是近似精度。较小的值将具有更接近的近似值。第四个参数中的true
值表示近似曲线是闭合的。请注意,您还可以改变描边粗细和描边颜色来模拟手绘动画效果。图 5-23 显示了结果图像。
图 5-23。
Polygon approximation
测试轮廓中的点
下一个练习Chapter05_27
,是一个交互式的练习,因为你可以用鼠标改变轮廓的fill()
颜色。在draw()
功能中,在绘制每个轮廓之前,使用功能pointPolygonTest()
执行一个测试,查看当前鼠标位置mouseX
和mouseY
是否在其中。由于您使用的是窗口的右侧,您必须将mouseX
值减去窗口大小的一半,即cap.width
。要使用pointPolygonTest()
功能,首先将当前轮廓信息mp
从MatOfPoint
转换为MatOfPoint2f
,并将其作为第一个参数传递。第二个参数是存储在Point
对象实例中的鼠标位置。第三个布尔参数指示是否要返回距离数据。在本练习中,您使用false
返回一个指示器,显示该点是在轮廓内部还是外部。正值表示该点位于轮廓内部,负值表示该点位于轮廓外部,而零表示该点位于边缘上。
import processing.video.*;
import java.util.ArrayList;
import java.util.Iterator;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
}
void
draw() {
if (!cap.available())
return;
background(250);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.blur(tmp1, tmp2, new Size(3, 3));
Imgproc.Canny(tmp2, tmp1, 80, 160);
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();
Imgproc.findContours(tmp1, contours, hierarchy,
Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);
image(cap, 0, 0);
pushStyle();
stroke(50);
Iterator<MatOfPoint> it = contours.iterator();
while (it.hasNext()) {
MatOfPoint mp = it.next();
Point [] pts = mp.toArray();
boolean inside = true;
if (mouseX < cap.width) {
noFill();
} else {
int mx = constrain(mouseX-cap.width, 0, cap.width-1);
int my = constrain(mouseY, 0, cap.height-1);
double result = Imgproc.pointPolygonTest(new MatOfPoint2f(pts),
new Point(mx, my), false);
if (result > 0) {
fill(255, 0, 0);
} else {
noFill();
}
}
beginShape();
for (int i=0; i<pts.length; i++) {
vertex((float)pts[i].x+cap.width, (float)pts[i].y);
}
endShape(CLOSE);
}
popStyle();
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
hierarchy.release();
}
在该程序中,当鼠标位置在轮廓内时,将fill()
颜色设置为红色。否则就是noFill()
。图 5-24 显示鼠标位置在手指形成的孔内的瞬间。
图 5-24。
Testing whether a point is inside a contour with pointPolygonTest
检查交叉路口
在进入一般的形状匹配部分之前,我将用另外一个练习Chapter05_28
来总结轮廓处理的使用。在本练习中,您将参考上一个练习Chapter05_24
中RotatedRect
的使用,并在固定矩形区域和从屏幕上的实时网络摄像头图像生成的旋转矩形之间执行检测。
import processing.video.*;
import java.util.ArrayList;
import java.util.Iterator;
Capture cap;
CVImage img;
float minArea, maxArea;
RotatedRect rRect;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
minArea = 50;
maxArea = 6000;
// This is the fixed rectangular region of size 200x200.
rRect = new RotatedRect(new Point(cap.width/2, cap.height/2),
new Size(200, 200), 0);
rectMode(CENTER);
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat tmp1 = img.getGrey();
Mat tmp2 = new Mat();
Imgproc.blur(tmp1, tmp2, new Size(3, 3));
Imgproc.Canny(tmp2, tmp1, 100, 200);
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();
Imgproc.findContours(tmp1, contours, hierarchy,
Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);
// Draw the fixed rectangular region.
pushStyle();
fill(255, 20);
stroke(0, 0, 255);
rect((float)rRect.center.x+cap.width,
(float)rRect.center.y, (float)rRect.size.width,
(float)rRect.size.height);
popStyle();
pushStyle();
Iterator<MatOfPoint> it = contours.iterator();
while (it.hasNext()) {
MatOfPoint ctr = it.next();
float area = (float)Imgproc.contourArea(ctr);
// Exclude the large and small rectangles
if (area < minArea || area > maxArea)
continue
;
// Obtain the rotated rectangles from each contour.
RotatedRect r = Imgproc.minAreaRect(new MatOfPoint2f(ctr.toArray()));
Point [] pts = new Point[4];
r.points(pts);
stroke(255, 255, 0);
noFill();
// Draw the rotated rectangles.
beginShape();
for (int i=0; i<pts.length; i++) {
vertex((float)pts[i].x+cap.width, (float)pts[i].y);
}
endShape(CLOSE);
// Compute the intersection between the fixed region and
// each rotated rectangle.
MatOfPoint2f inter = new MatOfPoint2f();
int rc = Imgproc.rotatedRectangleIntersection(r, rRect, inter);
// Skip
the cases with no intersection.
if (rc == Imgproc.INTERSECT_NONE)
continue;
// Obtain the convex hull of the intersection polygon.
MatOfInt idx = new MatOfInt();
MatOfPoint mp = new MatOfPoint(inter.toArray());
Imgproc.convexHull(mp, idx);
int [] idArray = idx.toArray();
Point [] ptArray = mp.toArray();
// Fill the intersection area.
noStroke();
fill(255, 100);
beginShape();
for (int i=0; i<idArray.length; i++) {
Point p = ptArray[idArray[i]];
vertex((float)p.x+cap.width, (float)p.y);
}
endShape(CLOSE);
inter.release();
idx.release();
mp.release();
}
popStyle();
image(cap, 0, 0);
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
hierarchy.release();
}
程序首先使用一个RotatedRect
实例rRect
定义一个固定区域。它的位置在视频采集屏幕的中心,尺寸为 200×200 像素。在draw()
功能中,你首先从网络摄像头图像中检索所有轮廓。对于每个轮廓,你筛掉那些尺寸太小或太大的轮廓。对于其余的,您计算存储在变量r
中的最小面积旋转矩形。对于每个旋转的矩形r
,用下面的语句对照固定区域rRect
进行检查:
int rc = Imgproc.rotatedRectangleIntersection(r, rRect, inter);
如果它们之间有交集,顶点信息将在MatOfPoint2f
变量inter
中。返回代码rc
,实际上会告诉你发生的交互的类型。rc
的可能值如下:
Imgproc.INTERSECT_NONE
(无重叠区域)Imgproc.INTERSECT_PARTIAL
(有重叠区域)Imgproc.INTERSECT_FULL
(一个矩形在另一个内)
您可以在 http://docs.opencv.org/3.1.0/d3/dc0/group__imgproc__shape.html
找到检查的详细说明。对于有交集的情况,您可以尝试使用半透明填充颜色来绘制重叠区域。然而,您会发现从变量inter
返回的顶点顺序并不能保证一个凸形。在程序中,在你把它们画在屏幕上之前,你添加几行来从inter
中的顶点找到凸包。图 5-25 显示了程序的样本输出显示。
图 5-25。
Finding intersection between rotated rectangles
形状检测
在本章的最后一节,我将介绍 OpenCV 图像处理模块中的形状匹配函数matchShapes()
。Chapter05_29
练习的工作机制是构建一个形状模板,您希望将该模板与实时网络摄像头图像进行匹配。在这种情况下,您将使用如图 5-26 所示的汉字。您也可以创建自己的模式。黑色背景上的任何白色形状通常都很好。该图案图像的尺寸为 640×480 像素。
图 5-26。
Sample Chinese character to match with
该程序将从data
文件夹中加载图像,并使用您在前面章节中了解到的findContours()
函数构建轮廓。因为你事先知道这个字符只包含一个轮廓,你只需将第一个轮廓存储在一个MatOfPoint
变量中。以下源代码中的prepareChar()
函数执行此功能:
import processing.video.*;
import java.util.ArrayList;
import java.util.Iterator;
Capture cap;
PImage img;
CVImage cv;
MatOfPoint ch;
float maxVal;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, width/2, height);
cap.start();
img = loadImage("chinese.png");
ch = prepareChar(img);
cv = new CVImage(cap.width, cap.height);
maxVal = 5;
}
MatOfPoint
prepareChar(PImage i) {
CVImage chr = new CVImage(i.width, i.height);
chr.copyTo(i);
Mat tmp1 = chr.getGrey();
Mat tmp2 = new Mat();
Imgproc.blur(tmp1, tmp2, new Size(3, 3));
Imgproc.threshold(tmp2, tmp1, 127, 255, Imgproc.THRESH_BINARY);
Mat hierarchy = new Mat();
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Imgproc.findContours(tmp1, contours, hierarchy,
Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);
tmp1.release();
tmp2.release();
hierarchy.release();
return contours.get(0);
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
cv.copyTo(cap);
Mat tmp1 = cv.getGrey();
Mat tmp2 = new Mat();
Imgproc.blur(tmp1, tmp2, new Size(3, 3));
Imgproc.Canny(tmp2, tmp1, 100, 200);
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();
Imgproc.findContours(tmp1, contours, hierarchy,
Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);
Iterator<MatOfPoint> it = contours.iterator();
pushStyle();
while (it.hasNext()) {
MatOfPoint cont = it.next();
double val = Imgproc.matchShapes(ch, cont, Imgproc.CV_CONTOURS_MATCH_I1, 0);
if (val > maxVal)
continue;
RotatedRect r = Imgproc.minAreaRect(new MatOfPoint2f(cont.toArray()));
Point ctr = r.center
;
noStroke();
fill(255, 200, 0);
text((float)val, (float)ctr.x+cap.width, (float)ctr.y);
Point [] pts = cont.toArray();
noFill();
stroke(100);
beginShape();
for (int i=0; i<pts.length; i++) {
vertex((float)pts[i].x+cap.width, (float)pts[i].y);
}
endShape(CLOSE);
}
popStyle();
image(cap, 0, 0);
text(nf(round(frameRate), 2), 10, 20);
tmp1.release();
tmp2.release();
hierarchy.release();
}
在draw()
功能中,您可以浏览来自实时网络摄像头图像的每个轮廓。您使用matchShapes()
函数来执行匹配。前两个参数是汉字轮廓和每个实况网络摄像机图像轮廓。剩下的就是匹配方法和一个伪参数。返回值val
,表示匹配有多接近;数值越小越好。您还可以排除那些返回值大于阈值maxVal
的轮廓。使用minAreaRect()
功能找出轮廓的中心,以便在屏幕上显示匹配值。程序的其余部分类似于前几节中绘制每个等高线的部分。
在图 5-27 所示的测试中,样本字符与存储的字符不相同。匹配值的范围从 1.5 到 3.5。
图 5-27。
Shape-matching test with other characters
在接下来的测试中,如图 5-28 所示,三个字符中有一个是正确的。正确字符的匹配值约为 0.6。
图 5-28。
Shape-matching test with one correct character
在下一个测试中,如图 5-29 所示,您使用相同的三个字符,但方向颠倒。正确字符的匹配值约为 0.4。
图 5-29。
Shape-matching test with upside-down characters
在下一个测试中,如图 5-30 所示,您使用一个手绘字符。样本字符的匹配值大约为 1.0。您可以在matchShapes()
功能中探索匹配方法参数。不同的方法可能会产生不同范围的返回值。有必要进行测试和实验,以找到适合应用的方法。
图 5-30。
Shape-matching test with hand-drawn character
结论
在本章中,您开始了一些计算机视觉任务,以识别和分析数字图像中的结构元素。你从准备图像和提取边缘开始。从边缘信息中,您可以检测到直线和圆等几何元素。通过一般轮廓处理任务,您开发了一个简单的应用来检测实时网络摄像头视频流中更复杂的形状。在下一章,我将介绍从预先录制的或现场直播的视频中检测和分析运动的想法。
六、理解运动
在上一章中,你学习了如何理解一帧图像中的内容。在这一章中,您将开始了解多帧数字视频或实时网络摄像头流中的运动。作为一个简单的解释,只要两个连续帧之间有差异,就可以识别运动。在计算机视觉中,你试图使用各种方法来理解这些差异,以便理解运动方向和前景背景分离等现象。在这一章的开始,我将介绍数字艺术家在处理动态图像时使用的现有方法。我将涉及的主题如下:
- 运动图像的效果
- 帧差分
- 背景去除
- 光流
- 运动历史
运动图像的效果
在 20 世纪 90 年代,多媒体设计者主要使用软件导演来创建通过 CD-ROM 平台交付的交互式内容。当时的数字视频资料主要由预先录制的内容组成。然而,Director 能够通过附加组件或插件来扩展其功能。丹尼尔·罗津开发的 TrackThemColors 就是其中之一。extras 使导演能够捕捉和分析从网络摄像头捕捉的数字图像。大约在 1999 年,约翰·梅达的反应式图书系列《镜子镜子》也使用视频输入作为交互功能。此外,Josh Nimoy 的米隆图书馆(由 WebCamXtra 和 JMyron 组成)提供了从 Director、Java 和 Processing 访问网络摄像头的功能。该图书馆以米隆·克鲁格的名字命名,他是一位伟大的美国计算机研究人员和艺术家,在 20 世纪 70 年代用实时视频流创建了增强现实应用的早期形式。另一个参考是伟大的英国摄影师埃德沃德·迈布里奇的定格运动研究,他展示了一系列静态照片来说明连续的运动,如一匹马在奔跑。
通过在处理中使用video
库,您有了一组一致的函数来处理运动图像。媒体艺术家和设计师一直在探索在处理运动图像时产生创造性视觉效果的方法。以下部分将实现处理中的一些常见效果,以说明这些效果背后的创造性概念。我将介绍以下内容:
- 马赛克效应
- 狭缝扫描摄影
- 滚动效果
- 三维可视化
马赛克效应
第一个练习Chapter06_01
,是你在第三章中完成的马赛克效果的修改版本。您将为每个单元格创建原始图像的缩小版本,而不是为网格中的每个单元格使用单一的纯色,在本例中,为每个单元格创建实时网络摄像机视频流。这种效果已经在很多数字艺术和广告材料中使用。以下是节目来源:
// Mosaic effect
import processing.video.*;
final int CELLS = 40;
Capture cap;
PImage img;
int idx;
int rows, cols;
void setup() {
size(960, 720);
background(0);
cap = new Capture(this, 640, 480);
cap.start();
rows = CELLS;
cols = CELLS;
img = createImage(width/cols, height/rows, ARGB);
idx = 0;
}
void draw() {
if (!cap.available())
return;
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
int px = idx % cols;
int py = idx / cols;
int ix = px*cap.width/cols;
int iy = py*cap.height/rows;
color col = cap.pixels[iy*cap.width+ix];
tint(col);
image(img, px*img.width, py*img.height);
idx++;
idx %= (rows*cols);
}
在draw()
函数中,程序的每一帧都会将网络摄像头视频图像的快照复制到一个更小的叫做img
的PImage
中。它将从左到右、从上到下遍历整个屏幕,将最新的帧粘贴到网格的每个单元格中。在粘贴img
之前,它使用tint()
函数来改变颜色,从单元格的左上角反映颜色信息。结果,最终显示将类似于实时图像,而每个单元是时间上的独立帧。图 6-1 显示了显示屏的样本。
图 6-1。
Mosaic with live camera input
狭缝扫描效应
狭缝扫描是一种摄影技术,一次只曝光图像的一条狭缝。对于数字图像处理,您可以修改它,使其一次只包含一行像素。在下一个练习Chapter06_02
中,您将从网络摄像机直播流的每一帧中仅复制一行垂直像素。这是从运动图像生成静止图像的常用技术。Golan Levin 在 http://www.flong.com/texts/lists/slit_scan/
提供狭缝扫描艺术品的综合信息目录。以下清单是练习的来源:
// Slit-scan effect
import processing.video.*;
Capture cap;
PImage img;
int idx, mid;
void setup() {
size(1280, 480);
background(0);
cap = new Capture(this, width/2, height);
cap.start();
img = createImage(1, cap.height, ARGB);
idx = 0;
mid = cap.width/2;
}
void draw() {
if (!cap.available())
return;
cap.read();
img.copy(cap, mid, 0, 1, cap.height,
0, 0, img.width, img.height);
image(img, idx, 0);
idx++;
idx %= width;
}
程序很简单。在draw()
功能中,在捕获视频的中心取一条垂直的像素线,并将其复制到一个水平移动的位置,由idx
表示。在这种情况下,屏幕上的每条垂直线代表一个单独的时间点,从左向右移动。图 6-2 显示了结果图像。
图 6-2。
Slit-scan effect with Processing
滚动效果
同样,回到 20 世纪 90 年代,英国多媒体艺术团体 Antirom ( http://www.antirom.com/
)让电影的滚动效果变得流行起来。在 Flash 时代,日本设计师 Yugop Nakamura 也大量试验了滚动条作为界面元素。这背后的想法很简单。首先,您构建一个由多个图像组成的长条,类似于模拟电影胶片。这些图像通常是连续运动的快照。然后,您可以通过水平或垂直的滚动运动来制作影片动画。当滚动速度达到一定的阈值时,电影胶片中的每个单元似乎都在自己制作动画,产生类似于早期电影的效果。在下面的练习中,您将实现一个处理版本,Chapter06_03
:
// Scrolling effect
import processing.video.*;
// Processing modes for the draw() function
public enum Mode {
WAITING, RECORDING, PLAYING
}
final int FPS = 24;
Capture cap;
Mode mode;
PShape [] shp;
PImage [] img;
PShape strip;
int dispW, dispH;
int recFrame;
float px, vx;
void setup() {
size(800, 600, P3D);
background(0);
cap = new Capture(this, 640, 480);
cap.start();
// Frame size of the film strip
dispW = 160;
dispH = 120;
// Position and velocity of the film strip
px = 0;
vx = 0;
prepareShape();
mode = Mode.WAITING;
recFrame = 0;
frameRate(FPS);
noStroke();
fill(255);
}
void prepareShape() {
// Film strip shape
strip = createShape(GROUP);
// Keep 24 frames in the PImage array
img = new PImage[FPS];
int extra = ceil(width/dispW);
// Keep 5 more frames to compensate for the
// continuous scrolling effect
shp = new PShape[FPS+extra];
for (int i=0; i<FPS; i++) {
img[i] = createImage(dispW, dispH, ARGB);
shp[i] = createShape(RECT, 0, 0, dispW, dispH);
shp[i].setStroke(false);
shp[i].setFill(color(255));
shp[i].setTexture(img[i]);
shp[i].translate(i*img[i].width, 0);
strip.addChild(shp[i]);
}
// The 5 extra frames are the same as the
// first 5 ones.
for (int i=FPS; i<shp.length; i++) {
shp[i] = createShape(RECT, 0, 0, dispW, dispH);
shp[i].setStroke(false);
shp[i].setFill(color(255));
int j = i % img.length;
shp[i].setTexture(img[j]);
shp[i].translate(i*img[j].width, 0);
strip.addChild(shp[i]);
}
}
void
draw() {
switch (mode) {
case WAITING:
waitFrame();
break;
case RECORDING:
recordFrame();
break;
case PLAYING:
playFrame();
break;
}
}
void waitFrame() {
// Display to live webcam image while waiting
if (!cap.available())
return;
cap.read();
background(0);
image(cap, (width-cap.width)/2, (height-cap.height)/2);
}
void
recordFrame() {
// Record each frame into the PImage array
if (!cap.available())
return;
if (recFrame >= FPS) {
mode = Mode.PLAYING;
recFrame = 0;
println("Finish recording");
return;
}
cap.read();
img[recFrame].copy(cap, 0, 0, cap.width, cap.height,
0, 0, img[recFrame].width, img[recFrame].height);
int sw = 80;
int sh = 60;
int tx = recFrame % (width/sw);
int ty = recFrame / (width/sw);
image(img[recFrame], tx*sw, ty*sh, sw, sh);
recFrame++;
}
void playFrame() {
background(0);
// Compute the scrolling speed
vx = (width/2 - mouseX)*0.6;
px += vx;
// Check for 2 boundary conditions
if (px < (width-strip.getWidth())) {
px = width - strip.getWidth() - px;
} else if (px > 0) {
px = px - strip.getWidth() + width;
}
shape(strip, px, 250);
}
void mousePressed() {
// Press mouse button to record
if (mode != Mode.RECORDING) {
mode = Mode.RECORDING;
recFrame = 0;
background(0);
println("Start recording");
}
}
程序有三种状态,由enum
类型mode
表示。第一个是WAITING
状态,屏幕上显示实时网络摄像头。一旦用户按下鼠标按钮,程序进入RECORDING
状态。在这种状态下,它将 24 帧记录到名为img
的PImage
数组中。用户还可以在那一秒钟内获得屏幕上 24 个小框架布局的反馈。录制完成后,它会进入PLAYING
状态,显示一个长的水平连续画面。它将根据鼠标位置向左或向右滚动。用户也可以通过向左或向右移动鼠标来改变滚动速度。要创建连续循环滚动的幻像,需要在原来的 24 帧的末尾再添加 5 帧。这五帧构成了显示屏的宽度(800 像素)。当电影胶片滚动超出其边界时,您只需将胶片的另一端放在屏幕窗口内,如playFrame()
功能所示。整个电影胶片保存在由shp
阵列中的 29 帧组成的strip PShape
中。图 6-3 显示了一个示例截图供参考。
图 6-3。
Scrolling effect of filmstrip
三维可视化
你可以进一步将你的实验扩展到三维空间。在下一个练习Chapter06_04
中,您将在处理显示窗口中显示 24 个帧的集合。该程序将在一个由 24 个图片帧组成的半透明块中同时可视化 24 个连续帧,在三维空间中缓慢旋转。
// 3D effect
import processing.video.*;
final int FPS = 24;
final int CAPW = 640;
final int CAPH = 480;
Capture cap;
PImage [] img;
PShape [] shp;
int idx;
float angle;
int dispW, dispH;
void setup() {
size(800, 600, P3D);
cap = new Capture(this, CAPW, CAPH, FPS);
cap.start();
idx = 0;
angle = 0;
frameRate(FPS);
// Keep the 24 frames in each img array member
img = new PImage[FPS];
// Keep the 24 images in a separate PShape
shp = new PShape[FPS];
dispW = cap.width;
dispH = cap.height;
for (int i=0; i<FPS; i++) {
img[i] = createImage(dispW, dispH, ARGB);
shp[i] = createShape(RECT, 0, 0, dispW, dispH);
shp[i].setStroke(false);
shp[i].setFill(color(255, 255, 255, 80));
shp[i].setTint(color(255, 255, 255, 80));
shp[i].setTexture(img[i]);
}
}
void draw() {
if (!cap.available())
return;
background(0);
lights();
cap.read();
// Copy the latest capture image into the
// array member with index - idx
img[idx].copy(cap, 0, 0, cap.width, cap.height,
0, 0, img[idx].width, img[idx].height);
pushMatrix();
translate(width/2, height/2, -480);
rotateY(radians(angle));
translate(-dispW/2, -dispH/2, -480);
displayAll();
popMatrix();
// Loop through the array with the idx
idx++;
idx %= FPS;
angle += 0.5;
angle %= 360;
text(nf(round(frameRate), 2), 10, 20);
}
void
displayAll() {
// Always display the first frame of
// index - idx
pushMatrix();
int i = idx - FPS + 1;
if (i < 0)
i += FPS;
for (int j=0; j<FPS; j++) {
shape(shp[i], 0, 0);
i++;
i %= FPS;
translate(0, 0, 40);
}
popMatrix();
}
每个矩形图像帧对应于一秒钟内 24 帧中的一帧。最上面的总是最新的帧。实际上,你可以看到运动一个接一个地向下传播到其他帧。由于框架是半透明的,当运动向下下沉时,您可以透过它们看到。我在我的作品《时间运动》第一部分中使用了这种效果。有了这个效果,电影中的跳跃剪辑将变成一个平滑的过渡。诀窍在于displayAll()
函数。变量idx
代表最新的帧。然后,将从以下语句中计算最早的帧,并由于负值而进行额外的调整:
int i = idx – FPS + 1;
之后的for
循环将以正确的顺序显示每一帧。为了在一秒钟内保存所有的 24 帧,你使用两个数组,img
和shp
。数组img
将每个视频帧存储为一个PImage
,它将作为纹理映射到数组shp
的每个成员之上,如PShape
。draw()
功能管理整个图框块的旋转,如图 6-4 所示。
图 6-4。
Video frames in 3D
帧差分
现在,您已经看到了许多处理运动图像中的帧的例子,您可以继续了解计算机视觉中如何检测运动。基本原理是,只有当两个图像帧发生变化时,您才能实现运动。通过比较两个帧,您可以简单地知道这两个帧之间发生了什么类型的运动。比较两帧的方法是在处理中使用第三章中的blend()
功能。在下一个练习Chapter06_05
中,您将实现实时网络摄像头流和静态图像之间的帧差:
// Difference between video and background
import processing.video.*;
final int CAPW = 640;
final int CAPH = 480;
Capture cap;
PImage back, img, diff;
int dispW, dispH;
void setup() {
size(800, 600);
cap = new Capture(this, CAPW, CAPH);
cap.start();
dispW = width/2;
dispH = height/2;
back = createImage(dispW, dispH, ARGB);
img = createImage(dispW, dispH, ARGB);
diff = createImage(dispW, dispH, ARGB);
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
// Get the difference image.
diff.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
diff.filter(GRAY);
diff.blend(back, 0, 0, back.width, back.height,
0, 0, diff.width, diff.height, DIFFERENCE);
// Obtain the threshold binary image.
img.copy(diff, 0, 0, diff.width, diff.height,
0, 0, img.width, img.height);
img.filter(THRESHOLD, 0.4);
image(cap, 0, 0, dispW, dispH);
image(back, dispW, 0, dispW, dispH);
image(diff, 0, dispH, dispW, dispH);
image(img, dispW, dispH, dispW, dispH);
text(nf(round(frameRate), 2), 10, 20);
}
void mousePressed() {
// Update the background image.
back.copy(cap, 0, 0, cap.width, cap.height,
0, 0, back.width, back.height);
back.filter(GRAY);
}
在这个程序中,你可以按下鼠标键从网络摄像头直播流中录制一个静态图像,并将其作为背景帧存储在名为back
的PImage
变量中。在每一帧中,在draw()
函数中,它使用blend()
函数将当前帧与背景进行比较,并将差异存储在PImage
变量diff
中。进一步应用阈值滤波器来生成称为img
的二进制PImage
。在处理显示窗口中,左上角显示当前视频帧,右上角显示背景图像,左下角显示差异图像,右下角显示阈值二进制图像。在阈值图像中,白色区域表示运动发生的位置。图 6-5 显示了一个示例截图供参考。
图 6-5。
Frame difference between live video and background
对于无法获得静态背景图像的应用,您可以考虑比较两个连续的帧来获得差异。下面的练习Chapter06_06
演示了获取两帧之间的差异的纯处理实现:
// Difference between consecutive frames
import processing.video.*;
final int CNT = 2;
// Capture size
final int CAPW = 640;
final int CAPH = 480;
Capture cap;
// Keep two frames to use alternately with
// array indices (prev, curr).
PImage [] img;
int prev, curr;
// Display image size
int dispW, dispH;
void setup() {
size(800, 600);
dispW = width/2;
dispH = height/2;
cap = new Capture(this, CAPW, CAPH);
cap.start();
img = new PImage[CNT];
for (int i=0; i<img.length; i++) {
img[i] = createImage(dispW, dispH, ARGB);
}
prev = 0;
curr = 1;
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
// Copy video image to current frame.
img[curr].copy(cap, 0, 0, cap.width, cap.height,
0, 0, img[curr].width, img[curr].height);
// Display current and previous frames.
image(img[curr], 0, 0, dispW, dispH);
image(img[prev], dispW, 0, dispW, dispH);
PImage tmp = createImage(dispW, dispH, ARGB);
arrayCopy(img[curr].pixels, tmp.pixels);
tmp.updatePixels();
// Create the difference image.
tmp.blend(img[prev], 0, 0, img[prev].width, img[prev].height,
0, 0, tmp.width, tmp.height, DIFFERENCE);
tmp.filter(GRAY);
image(tmp, 0, dispH, dispW, dispH);
// Convert the difference image to binary.
tmp.filter(THRESHOLD, 0.3);
image(tmp, dispW, dispH, dispW, dispH);
text(nf(round(frameRate), 2), 10, 20);
// Swap the two array indices.
int temp = prev;
prev = curr;
curr = temp;
}
程序保持一个PImage
缓冲数组img
,通过交换两个指针索引prev
和curr
来维护视频流中的前一帧和当前帧。其余的代码与前一个程序类似。它使用blend()
函数检索DIFFERENCE
图像,使用THRESHOLD
过滤器提取黑白二值图像。图 6-6 显示了该程序的示例截图。
图 6-6。
Difference between two frames in Processing
有了黑白差异图像,下一步就是从中获取有意义的信息。在第五章中,你学习了如何从黑色背景下的白色区域获取轮廓信息。在下一个练习Chapter06_07
中,您将使用相同的技术找出从黑白图像中识别出的轮廓的边界框。这个程序将使用 OpenCV。记得将带有 OpenCV 库的code
文件夹和CVImage
类定义添加到加工草图文件夹。
// Difference between 2 consecutive frames
import processing.video.*;
import java.util.ArrayList;
import java.util.Iterator;
final int CNT = 2;
// Capture size
final int CAPW = 640;
final int CAPH = 480;
// Minimum bounding box area
final float MINAREA = 200.0;
Capture cap;
// Previous and current frames in Mat format
Mat [] frames;
int prev, curr;
CVImage img;
// Display size
int dispW, dispH;
void setup() {
size(800, 600);
dispW = width/2;
dispH = height/2;
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, CAPW, CAPH);
cap.start();
img = new CVImage(dispW, dispH);
frames = new Mat[CNT];
for (int i=0; i<CNT; i++) {
frames[i] = new Mat(img.height, img.width,
CvType.CV_8UC1, Scalar.all(0));
}
prev = 0;
curr = 1;
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
PImage tmp0 = createImage(dispW, dispH, ARGB);
tmp0.copy(cap, 0, 0, cap.width, cap.height,
0, 0, tmp0.width, tmp0.height);
// Display current frame.
image(tmp0, 0, 0);
img.copyTo(tmp0);
frames[curr] = img.getGrey();
CVImage out = new CVImage(dispW, dispH);
out.copyTo(frames[prev]);
// Display previous frame.
image(out, dispW, 0, dispW, dispH);
Mat tmp1 = new Mat();
Mat tmp2 = new Mat();
// Difference between previous and current frames
Core.absdiff(frames[prev], frames[curr], tmp1);
Imgproc.threshold(tmp1, tmp2, 90, 255, Imgproc.THRESH_BINARY);
out.copyTo(tmp2);
// Display threshold difference image.
image(out, 0, dispH, dispW, dispH);
// Obtain contours of the difference binary image
ArrayList<MatOfPoint> contours = new ArrayList<MatOfPoint>();
Mat hierarchy = new Mat();
Imgproc.findContours(tmp2, contours, hierarchy,
Imgproc.RETR_LIST, Imgproc.CHAIN_APPROX_SIMPLE);
Iterator<MatOfPoint> it = contours.iterator();
pushStyle();
fill(255, 180);
noStroke();
while
(it.hasNext()) {
MatOfPoint cont = it.next();
// Draw each bounding box
Rect rct = Imgproc.boundingRect(cont);
float area = (float)(rct.width * rct.height);
if (area < MINAREA)
continue;
rect((float)rct.x+dispW, (float)rct.y+dispH,
(float)rct.width, (float)rct.height);
}
popStyle();
text(nf(round(frameRate), 2), 10, 20);
int temp = prev;
prev = curr;
curr = temp;
hierarchy.release();
tmp1.release();
tmp2.release();
}
该程序与前一个类似,只是您使用名为frames
的 OpenCV Mat
实例来存储前一帧和当前帧。您还可以使用Core.absdiff()
函数来计算差异图像,并使用Imgproc.threshold()
来生成黑白二值图像。当你在contours
数据结构中循环时,你首先计算边界框面积来过滤那些面积较小的轮廓。剩下的,你在显示窗口的右下角显示矩形,如图 6-7 所示。
图 6-7。
Simple tracking with frame differencing
背景去除
在前面的帧差分练习中,如果你观察足够长的时间,静态背景将保持黑色。只有前景中的运动物体是白色的。OpenCV 中的背景去除或背景减除是指将前景运动物体从静态背景图像中分离出来。您不需要像练习Chapter06_05
中那样提供静态背景图像。在 OpenCV 的video
模块中,BackgroundSubtractor
类将从一系列输入图像中学习,通过在当前帧和背景模型(包含场景的静态背景)之间执行减法来生成前景遮罩。下一个练习Chapter06_08
说明了背景减除的基本操作:
// Background subtraction
import processing.video.*;
import org.opencv.video.*;
import org.opencv.video.Video;
// Capture size
final int CAPW = 640;
final int CAPH = 480;
Capture cap;
CVImage img;
PImage back;
// OpenCV background subtractor
BackgroundSubtractorMOG2 bkg;
// Foreground mask
Mat fgMask;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, CAPW, CAPH);
cap.start();
img = new CVImage(cap.width, cap.height);
bkg = Video.createBackgroundSubtractorMOG2();
fgMask = new Mat();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copyTo(cap);
Mat capFrame = img.getBGRA();
bkg.apply(capFrame, fgMask);
CVImage out = new CVImage(fgMask.cols(), fgMask.rows());
out.copyTo(fgMask);
image(cap, 0, 0);
// Display the foreground mask
image(out, cap.width, 0);
text(nf(round(frameRate), 2), 10, 20);
capFrame.release();
}
该程序使用 Zoran Zivkovic 的基于高斯混合的背景/前景分割算法。类定义在 OpenCV 的video
模块中。注意使用额外的import
语句来包含类定义。这个类实例是由Video.createBackgroundSubtractorMOG2()
函数创建的。要使用该对象,您需要将视频帧和前景蒙版Mat
、fgMask
传递给draw()
函数中每一帧的apply()
函数。BackgroundSubtractor
对象bkg
将从每一帧中学习静态背景应该是什么,并生成前景蒙版。前景蒙版fgMask
是黑白图像,其中黑色区域是背景,白色区域是前景对象。程序会在左侧显示原始视频帧,在右侧显示前景遮罩,如图 6-8 所示。
图 6-8。
Background subtraction in OpenCV
使用前景蒙版,您可以将其与视频帧结合,以从背景中检索前景图像。下面的练习Chapter06_09
,将使用这种方法实现效果,类似于视频制作中的色度键:
// Background subtraction
import processing.video.*;
import org.opencv.video.*;
import org.opencv.video.Video;
// Capture size
final int CAPW = 640;
final int CAPH = 480;
Capture cap;
CVImage img;
PImage back;
BackgroundSubtractorKNN bkg;
Mat fgMask;
int dispW, dispH;
void setup() {
size(800, 600);
dispW = width/2;
dispH = height/2;
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, CAPW, CAPH);
cap.start();
img = new CVImage(dispW, dispH);
bkg = Video.createBackgroundSubtractorKNN();
fgMask = new Mat();
// Background image
back = loadImage("background.png");
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
PImage tmp = createImage(dispW, dispH, ARGB);
// Resize the capture image
tmp.copy(cap, 0, 0, cap.width, cap.height,
0, 0, tmp.width, tmp.height);
img.copyTo(tmp);
Mat capFrame = img.getBGRA();
bkg.apply(capFrame, fgMask);
// Combine the video frame and foreground
// mask to obtain the foreground image.
Mat fgImage = new Mat();
capFrame.copyTo(fgImage, fgMask);
CVImage out = new CVImage(fgMask.cols(), fgMask.rows());
// Display the original video capture image.
image(tmp, 0, 0);
// Display the static background image.
image(back, dispW, 0);
out.copyTo(fgMask);
// Display the foreground mask.
image(out, 0, dispH);
out.copyTo(fgImage);
// Display the foreground image on top of
// the static background.
image(back, dispW, dispH);
image(out, dispW, dispH);
text(nf(round(frameRate), 2), 10, 20);
capFrame.release();
fgImage.release();
}
在这个程序中,您将显示四幅图像。左上方的是实时视频流。右上角的是静态背景图像,存储在名为back
的PImage
实例中。左下角的是前景蒙版,如前一个练习所示。右下角的是显示在背景图像上面的前景图像。您还可以尝试另一种背景减除方法,即 K 近邻背景减除法BackgroundSubtractorKNN
。当图像中的前景像素较少时,这种方法更有效。在draw()
函数中,程序定义了一个名为fgImage
的新变量来存储前景图像。你用前景蒙版fgMask
将当前视频图像capFrame
复制到fgImage
。
capFrame.copyTo(fgImage, fgMask);
在这种情况下,只有蒙版中的白色区域会被复制。图 6-9 显示了整体结果图像。
图 6-9。
Background subtraction and foreground extraction
除了前景图像,OpenCV BackgroundSubtractor
还可以用getBackgroundImage()
函数检索背景图像。下一个练习Chapter06_10
将演示它的用法。
// Background subtraction
import processing.video.*;
import org.opencv.video.*;
import org.opencv.video.Video;
// Capture size
final int CAPW = 640;
final int CAPH = 480;
Capture cap;
CVImage img;
PImage back;
BackgroundSubtractorKNN bkg;
// Foreground mask object
Mat fgMask;
int dispW, dispH;
void setup() {
size(800, 600);
dispW = width/2;
dispH = height/2;
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, CAPW, CAPH);
cap.start();
img = new CVImage(dispW, dispH);
bkg = Video.createBackgroundSubtractorKNN();
fgMask = new Mat();
// Background image
back = loadImage("background.png");
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
PImage tmp = createImage(dispW, dispH, ARGB);
// Resize the capture image
tmp.copy(cap, 0, 0, cap.width, cap.height,
0, 0, tmp.width, tmp.height);
img.copyTo(tmp);
Mat capFrame = img.getBGR();
bkg.apply(capFrame, fgMask);
// Background image object
Mat bkImage = new Mat();
bkg.getBackgroundImage(bkImage);
CVImage out = new CVImage(fgMask.cols(), fgMask.rows());
// Display the original video capture image.
image(tmp, 0, 0);
out.copyTo(bkImage);
// Display the background image.
image(out, dispW, 0);
out.copyTo(fgMask);
// Display the foreground mask.
image(out, 0, dispH);
// Obtain the foreground image with the PImage
// mask method.
tmp.mask(out);
// Display the forground image on top of
// the static background.
image(back, dispW, dispH);
image(tmp, dispW, dispH);
text(nf(round(frameRate), 2), 10, 20);
capFrame.release();
}
在draw()
函数中,您定义了一个名为bkImage
的新的Mat
变量,并使用getBackgroundImage(bkImage)
方法将背景图像矩阵传递给bkImage
变量。该程序还解释了使用处理PImage
类的mask()
方法执行屏蔽操作的另一种方式。图 6-10 显示了一个示例截图。
图 6-10。
Background image retrieval
光流
OpenCV 有另一种方法来找出运动图像中的运动细节:video
模块中的光流特征。简单来说,光流就是对像素如何在两个连续帧间移动的分析,如图 6-11 所示。
图 6-11。
Optical flow
从第 2 帧开始,您可以逐个扫描每个像素,并尝试将其与第 1 帧中的像素匹配,围绕原始邻域。如果找到匹配,就可以声明第 1 帧中的像素流到第 2 帧中的新位置。您为该像素确定的箭头将是光流信息。要使用光流,您可以假设以下情况:运动对象的像素强度在连续帧之间变化不大,相邻像素具有相似的运动,并且该对象不会移动得太快。
在 OpenCV 实现中,有两种类型的光流分析:稀疏和密集。在这一章中,你将首先研究稠密光流。稀疏光流涉及特征点识别,这是下一章的主题。通常,密集光流是对图像中每个单个像素的光流信息的计算。它是资源密集型的。通常,您会减小视频帧的大小来增强性能。第一个光流练习(第 06_11 章)将基于 Gunnar Farneback 在 2003 年发表的论文“基于多项式展开的两帧运动估计”实现密集光流算法。
// Dense optical flow
import processing.video.*;
import org.opencv.video.*;
import org.opencv.video.Video;
// Capture size
final int CAPW = 640;
final int CAPH = 480;
Capture cap;
CVImage img;
float scaling;
int w, h;
Mat last;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, CAPW, CAPH);
cap.start();
scaling = 10;
w = floor(CAPW/scaling);
h = floor(CAPH/scaling);
img = new CVImage(w, h);
last = new Mat(h, w, CvType.CV_8UC1);
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
Mat flow = new Mat(last.size(), CvType.CV_32FC2);
Video.calcOpticalFlowFarneback(last, grey, flow,
0.5, 3, 10, 2, 7, 1.5, Video.OPTFLOW_FARNEBACK_GAUSSIAN);
grey.copyTo(last);
drawFlow(flow);
image(cap, 0, 0);
grey.release();
flow.release();
text(nf(round(frameRate), 2), 10, 20);
}
void drawFlow(Mat f) {
// Draw the flow data.
pushStyle();
noFill();
stroke(255);
for (int y=0; y<f.rows(); y++) {
float py = y*scaling;
for (int x=0; x<f.cols(); x++) {
double [] pt = f.get(y, x);
float dx = (float)pt[0];
float dy = (float)pt[1];
// Skip areas with no flow.
if (dx == 0 && dy == 0)
continue;
float px = x*scaling;
dx *= scaling;
dy *= scaling;
line(px+cap.width, py, px+cap.width+dx, py+dy);
}
}
popStyle();
}
图 6-12 显示了结果截图。
图 6-12。
Farneback dense optical flow
您可以从视频捕获帧中检索颜色信息,并用原始颜色给线条着色,而不是用白色绘制流线。在这种情况下,您可以轻松地生成网络摄像头实时图像的交互式渲染,如图 6-13 所示。
图 6-13。
Dense optical flow in color
在这个版本Chapter06_12
中,您需要做的唯一更改是在drawFlow()
函数中。不是在for
循环外使用stroke(255)
函数,而是计算像素颜色并将其分配给stroke()
函数。您已经在前面的章节中使用了这种技术。
void drawFlow(Mat f) {
// Draw the flow data.
pushStyle();
noFill();
for (int y=0; y<f.rows(); y++) {
int py = (int)constrain(y*scaling, 0, cap.height-1);
for (int x=0; x<f.cols(); x++) {
double [] pt = f.get(y, x);
float dx = (float)pt[0];
float dy = (float)pt[1];
// Skip areas with no flow.
if (dx == 0 && dy == 0)
continue;
int px = (int)constrain(x*scaling, 0, cap.width-1);
color col = cap.pixels[py*cap.width+px];
stroke(col);
dx *= scaling;
dy *= scaling;
line(px+cap.width, py, px+cap.width+dx, py+dy);
}
}
popStyle();
}
除了使用光流信息渲染网络摄像头图像之外,您还可以将其用于交互设计。例如,您可以在显示屏上定义一个虚拟热点,以及来自网络摄像头的实时图像。当你在虚拟热点上挥手时,你可以为程序触发一个事件,比如回放一个简短的声音剪辑。在交互设计中设计这样的空鼓套件或钢琴是相当常见的。下面的练习Chapter06_13
将使用光流信息实现这样一个虚拟热点。为了简化程序,您将定义一个额外的类Region
,来封装代码以实现热点。以下是Region
的定义:
import java.awt.Rectangle;
import java.lang.reflect.Method;
// The class to define the hotspot.
class Region {
// Threshold value to trigger the callback function.
final float FLOW_THRESH = 20;
Rectangle rct; // area of the hotspot
Rectangle screen; // area of the live capture
float scaling; // scaling factor for optical flow size
PVector flowInfo; // flow information within the hotspot
PApplet parent;
Method func; // callback function
boolean touched;
public Region(PApplet p, Rectangle r, Rectangle s, float f) {
parent = p;
// Register the callback function named regionTriggered.
try {
func = p.getClass().getMethod("regionTriggered",
new Class[]{this.getClass()});
}
catch
(Exception e) {
println(e.getMessage());
}
screen = s;
rct = (Rectangle)screen.createIntersection(r);
scaling = f;
flowInfo = new PVector(0, 0);
touched = false;
}
void update(Mat f) {
Rect sr = new Rect(floor(rct.x/scaling), floor(rct.y/scaling),
floor(rct.width/scaling), floor(rct.height/scaling));
// Obtain the submatrix - region of interest.
Mat flow = f.submat(sr);
flowInfo.set(0, 0);
// Accumulate the optical flow vectors.
for (int y=0; y<flow.rows(); y++) {
for (int x=0; x<flow.cols(); x++) {
double [] vec = flow.get(y, x);
PVector item = new PVector((float)vec[0], (float)vec[1]);
flowInfo.add(item);
}
}
flow.release();
// When the magnitude of total flow is larger than a
// threshold, trigger the callback.
if (flowInfo.mag()>FLOW_THRESH) {
touched = true;
try {
func.invoke(parent, this);
}
catch (Exception e) {
println(e.getMessage());
}
} else {
touched = false;
}
}
void
drawBox() {
// Draw the hotspot rectangle.
pushStyle();
if (touched) {
stroke(255, 200, 0);
fill(0, 100, 255, 160);
} else {
stroke(160);
noFill();
}
rect((float)(rct.x+screen.x), (float)(rct.y+screen.y),
(float)rct.width, (float)rct.height);
popStyle();
}
void drawFlow(Mat f, PVector o) {
// Visualize flow inside the region on
// the right hand side screen.
Rect sr = new Rect(floor(rct.x/scaling), floor(rct.y/scaling),
floor(rct.width/scaling), floor(rct.height/scaling));
Mat flow = f.submat(sr);
pushStyle();
noFill();
stroke(255);
for (int y=0; y<flow.rows(); y++) {
float y1 = y*scaling+rct.y + o.y;
for (int x=0; x<flow.cols(); x++) {
double [] vec = flow.get(y, x);
float x1 = x*scaling+rct.x + o.x;
float dx = (float)(vec[0]*scaling);
float dy = (float)(vec[1]*scaling);
line(x1, y1, x1+dx, y1+dy);
}
}
popStyle();
flow.release();
}
float getFlowMag() {
// Get the flow vector magnitude.
return flowInfo.mag();
}
void writeMsg(PVector o, String m) {
// Display message on screen.
int px = round(o.x + rct.x);
int py = round(o.y + rct.y);
text(m, px, py-10);
}
}
在Region
的类定义中,你使用一个叫做rct
的 Java Rectangle
来定义热点区域。另一个Rectangle
是视频捕捉窗口,叫做screen
。您使用 Java Rectangle
而不是 OpenCV Rect
,因为它为您提供了一种额外的方法来计算两个矩形之间的交集,以免rct
的定义在screen
之外,如以下语句所示:
rct = (Rectangle)screen.createIntersection(r);
在Region
的构造函数中,你也使用 Java Method
类从主程序注册方法regionTriggered
。在update()
方法中,你从参数f
中得到光流矩阵。由于您按照scaling
中给出的数量对视频采集图像进行了缩减采样,因此为了计算光流,您还需要按照相同的数量对Region
矩形进行缩减采样。之后,使用Region
矩形和以下语句计算原始光流矩阵中的子矩阵:
Mat flow = f.submat(sr);
在两个嵌套的for
循环中,您将所有的流向量累积到变量flowInfo
中。如果它的大小大于一个阈值,你可以断定有什么东西在摄像机前面移动,从而调用主程序中的回调函数regionTriggered
。其他方法很简单。他们只是画出矩形和流线。
对于主程序,您已经定义了两个测试热点。在draw()
函数中,在计算光流信息之后,循环通过regions
数组来更新和绘制必要的信息。作为回调函数,您定义了一个名为regionTriggered
的函数。引起触发的热点将作为一个Region
对象实例传递给回调。它首先检索区域内所有流向量的大小,然后调用方法writeMsg()
在区域顶部显示数字。
// Interaction design with optical flow
import processing.video.*;
import org.opencv.video.*;
import org.opencv.video.Video;
import java.awt.Rectangle;
// Capture size
final int CAPW = 640;
final int CAPH = 480;
Capture cap;
CVImage img;
float scaling;
int w, h;
Mat last;
Region [] regions;
// Flag to indicate if it is the first frame.
boolean first;
// Offset to the right hand side display.
PVector offset;
void setup() {
size(1280, 480);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
cap = new Capture(this, CAPW, CAPH);
cap.start();
scaling = 20;
w = floor(CAPW/scaling);
h = floor(CAPH/scaling);
img = new CVImage(w, h);
last = new Mat(h, w, CvType.CV_8UC1);
Rectangle screen = new Rectangle(0, 0, cap.width, cap.height);
// Define 2 hotspots
.
regions = new Region[2];
regions[0] = new Region(this, new Rectangle(100, 100, 100, 100),
screen, scaling);
regions[1] = new Region(this, new Rectangle(500, 200, 100, 100),
screen, scaling);
first = true;
offset = new PVector(cap.width, 0);
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
if (first) {
grey.copyTo(last);
first = false;
return
;
}
Mat flow = new Mat(last.size(), CvType.CV_32FC2);
Video.calcOpticalFlowFarneback(last, grey, flow,
0.5, 3, 10, 2, 7, 1.5, Video.OPTFLOW_FARNEBACK_GAUSSIAN);
grey.copyTo(last);
image(cap, 0, 0);
drawFlow(flow);
// Update the hotspots with the flow matrix.
// Draw the hotspot rectangle.
// Draw also the flow on the right hand side display.
for (Region rg : regions) {
rg.update(flow);
rg.drawBox();
rg.drawFlow(flow, offset);
}
grey.release();
flow.release();
text(nf(round(frameRate), 2), 10, 20);
}
void drawFlow(Mat f) {
// Draw the flow data.
pushStyle();
noFill();
stroke(255);
for (int y=0; y<f.rows(); y++) {
int py = (int)constrain(y*scaling, 0, cap.height-1);
for (int x=0; x<f.cols(); x++) {
double [] pt = f.get(y, x);
float dx = (float)pt[0];
float dy = (float)pt[1];
// Skip areas with no flow.
if (dx == 0 && dy == 0)
continue;
int px = (int)constrain(x*scaling, 0, cap.width-1);
dx *= scaling;
dy *= scaling;
line(px, py, px+dx, py+dy);
}
}
popStyle();
}
void
regionTriggered(Region r) {
// Callback function from the Region class.
// It displays the flow magnitude number on
// top of the hotspot rectangle.
int mag = round(r.getFlowMag());
r.writeMsg(offset, nf(mag, 3));
}
图 6-14 显示了一个示例截图供参考。请注意,其中一个热点是通过在网络摄像头前挥手激活的。它用半透明颜色填充,并且在显示器的右侧显示光流幅度值。
图 6-14。
Virtual hotspots with optical flow interaction
运动历史
在光流分析中,注意该函数仅使用两帧来计算流信息。OpenCV 提供了其他函数,可以累积更多的帧来详细分析运动历史。然而,从 3.0 版本开始,这些函数不再是 OpenCV 的标准发行版。它现在分布在 https://github.com/opencv/opencv_contrib
的opencv_contrib
库的额外模块中。这就是为什么在第一章中你用额外的模块optflow
构建了 OpenCV 库。以下是与运动历史相关的功能:
calcGlobalOrientation
calcMotionGradient
segmentMotion
updateMotionHistory
下一个练习Chapter06_14
,基于opencv_contrib
分布中的motempl.cpp
样本。因为它有点复杂,所以您将一步一步地构建它。我将回顾本章前一节中介绍的比较两个连续帧以创建阈值差图像的技术。
// Display threshold difference image.
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
final int CNT = 2;
Capture cap;
CVImage img;
Mat [] buf;
Mat silh;
int last;
void setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
last = 0;
// Two frames buffer for comparison
buf = new Mat[CNT];
for (int i=0; i<CNT; i++) {
buf[i] = Mat.zeros(cap.height, cap.width,
CvType.CV_8UC1);
}
// Threshold difference image
silh = new Mat(cap.height, cap.width, CvType.CV_8UC1,
Scalar.all(0));
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
grey.copyTo(buf[last]);
int idx1, idx2;
idx1 = last;
idx2 = (last + 1) % buf.length;
last = idx2;
silh = buf[idx2];
// Create the threshold difference image between two frames.
Core.absdiff(buf[idx1], buf[idx2], silh);
Imgproc.threshold(silh, silh, 30, 255, Imgproc.THRESH_BINARY);
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(silh);
image(img, 0, 0);
image(out, cap.width, 0);
text(nf(round(frameRate), 2), 10, 20);
grey.release();
}
该程序使用一个名为buf
的Mat
数组来维护来自网络摄像头的两个连续帧。基本上,它利用Core.absdiff()
和Imgproc.threshold()
函数来计算draw()
函数中每一帧的阈值差图像。图 6-15 显示了一个示例截图。
图 6-15。
Threshold difference image
结果就像你在图 6-6 中所做的处理。由于阈值差图像仅包含两帧的信息,下一步Chapter06_15
是累积这些图像中的一些以构建运动历史图像。
// Display motion history image.
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
import org.opencv.optflow.Optflow;
import java.lang.System;
final int CNT = 2;
// Motion history duration is 5 seconds.
final double MHI_DURATION = 5;
Capture cap;
CVImage img;
Mat [] buf;
Mat mhi, silh, mask;
int last;
double time0;
void setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
last = 0;
// Maintain two buffer frames.
buf = new Mat[CNT];
for (int i=0; i<CNT; i++) {
buf[i] = Mat.zeros(cap.height, cap.width,
CvType.CV_8UC1);
}
// Initialize the threshold difference image.
silh = new Mat(cap.height, cap.width, CvType.CV_8UC1,
Scalar.all(0));
// Initialize motion history image.
mhi = Mat.zeros(cap.height, cap.width, CvType.CV_32FC1);
mask = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
// Store timestamp when program starts to run.
time0 = System.nanoTime();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
grey.copyTo(buf[last]);
int idx1, idx2;
idx1 = last;
idx2 = (last + 1) % buf.length;
last = idx2;
silh = buf[idx2];
// Get current timestamp in seconds.
double timestamp = (System.nanoTime() - time0)/1e9;
// Create binary threshold image from two frames.
Core.absdiff(buf[idx1], buf[idx2], silh);
Imgproc.threshold(silh, silh, 30, 255, Imgproc.THRESH_BINARY);
// Update motion history image from the threshold.
Optflow.updateMotionHistory(silh, mhi, timestamp, MHI_DURATION);
mhi.convertTo(mask, CvType.CV_8UC1,
255.0/MHI_DURATION,
(MHI_DURATION - timestamp)*255.0/MHI_DURATION);
// Display the greyscale motion history image.
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(mask);
image(img, 0, 0);
image(out, cap.width, 0);
text(nf(round(frameRate), 2), 10, 20);
grey.release();
}
获得剪影的阈值差异图像silh
后,使用 OpenCV 额外模块optflow
,通过功能Optflow.updateMotionHistory()
创建运动历史图像。第一个参数是输入轮廓图像。第二个参数是输出运动历史图像。第三个参数是以秒为单位的当前时间戳。最后一个参数是您想要保持的运动细节的最大持续时间(以秒为单位),在本例中是 5 秒。运动历史图像mhi
然后被转换回 8 位,称为mask
,用于显示。亮的区域是最近的运动,没有更多的运动时会褪成黑色。图 6-16 显示了一个示例截图。
图 6-16。
Motion history image
下一步Chapter06_16
,将进一步分析运动历史图像,找出运动梯度。即像素在帧之间移动的方向。光流模块提供另一个功能calcMotionGradient()
,计算运动历史图像中每个像素的运动方向。
// Display global motion direction
.
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
import org.opencv.optflow.Optflow;
import java.lang.System;
final int CNT = 2;
// Define motion history duration.
final double MHI_DURATION = 5;
final double MAX_TIME_DELTA = 0.5;
final double MIN_TIME_DELTA = 0.05;
Capture cap;
CVImage img;
Mat [] buf;
Mat mhi, mask, orient, silh;
int last;
double time0;
void setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
last = 0;
// Image buffer with two frames.
buf = new Mat[CNT];
for (int i=0; i<CNT; i++) {
buf[i] = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
}
// Motion history image
mhi = Mat.zeros(cap.height, cap.width, CvType.CV_32FC1);
// Threshold difference image
silh = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
mask = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
orient = Mat.zeros(cap.height, cap.width, CvType.CV_32FC1);
// Program start time
time0 = System.nanoTime();
smooth();
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
grey.copyTo(buf[last]);
int idx1, idx2;
idx1 = last;
idx2 = (last + 1) % buf.length;
last = idx2;
silh = buf[idx2];
// Get current time in seconds.
double timestamp = (System.nanoTime() - time0)/1e9;
// Compute difference with threshold.
Core.absdiff(buf[idx1], buf[idx2], silh);
Imgproc.threshold(silh, silh, 30, 255, Imgproc.THRESH_BINARY);
// Update motion history image.
Optflow.updateMotionHistory(silh, mhi, timestamp, MHI_DURATION);
mhi.convertTo(mask, CvType.CV_8UC1,
255.0/MHI_DURATION,
(MHI_DURATION - timestamp)*255.0/MHI_DURATION);
// Display motion history image in 8bit greyscale.
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(mask);
image(img, 0, 0);
image(out, cap.width, 0);
// Compute overall motion gradient.
Optflow.calcMotionGradient(mhi, mask, orient,
MAX_TIME_DELTA, MIN_TIME_DELTA, 3);
// Calculate motion direction of whole frame.
double angle = Optflow.calcGlobalOrientation(orient, mask,
mhi, timestamp, MHI_DURATION);
// Skip cases with too little motion.
double count = Core.norm(silh, Core.NORM_L1);
if (count > (cap.width*cap.height*0.1)) {
pushStyle();
noFill();
stroke(255, 0, 0);
float radius = min(cap.width, cap.height)/2.0;
ellipse(cap.width/2+cap.width, cap.height/2, radius*2, radius*2);
stroke(0, 0, 255);
// Draw the main direction of motion.
line(cap.width/2+cap.width, cap.height/2,
cap.width/2+cap.width+radius*cos(radians((float)angle)),
cap.height/2+radius*sin(radians((float)angle)));
popStyle();
}
fill(0);
text(nf(round(frameRate), 2), 10, 20);
grey.release();
}
在draw()
函数中,该语句获取运动历史图像mhi
,并产生两个输出图像。
Optflow.calcMotionGradient(mhi, mask, orient, MAX_TIME_DELTA, MIN_TIME_DELTA, 3);
第一个,mask
,指示哪些像素具有有效的运动梯度信息。第二个是orient
,显示每个像素的运动方向角,单位为度。注意,名为mask
的输出Mat
将覆盖前面步骤中的原始内容。下一条语句根据上一条语句的结果计算平均运动方向:
double angle = Optflow.calcGlobalOrientation(orient, mask, mhi, timestamp, MHI_DURATION);
它将返回以度为单位的运动角度,值从 0 到 360。当屏幕上运动太少时,程序也会跳过这些情况。最后,程序会画一个大圆,并从圆心向检测到的运动方向画一条直线。图 6-17 显示了一个带有指向运动方向的蓝线的示例截图。
图 6-17。
Global motion direction
一旦你有了全局运动方向,你就可以用它来进行手势交互。下一个练习Chapter06_17
演示了从变量angle
获得的运动方向的简单用法:
// Gestural interaction demo
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
import org.opencv.optflow.Optflow;
import java.lang.System;
final int CNT = 2;
// Define motion history duration.
final double MHI_DURATION = 3;
final double MAX_TIME_DELTA = 0.5;
final double MIN_TIME_DELTA = 0.05;
Capture cap;
CVImage img;
Mat [] buf;
Mat mhi, mask, orient, silh;
int last;
double time0;
float rot, vel, drag;
void setup() {
// Three dimensional scene
size(640, 480, P3D);
background(0);
// Disable depth test.
hint(DISABLE_DEPTH_TEST);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width, height);
cap.start();
img = new CVImage(cap.width, cap.height);
last = 0;
// Image buffer with two frames.
buf = new Mat[CNT];
for (int i=0; i<CNT; i++) {
buf[i] = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
}
// Motion
history image
mhi = Mat.zeros(cap.height, cap.width, CvType.CV_32FC1);
// Threshold difference image
silh = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
mask = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
orient = Mat.zeros(cap.height, cap.width, CvType.CV_32FC1);
// Program start time
time0 = System.nanoTime();
smooth();
// Rotation of the cube in Y direction
rot = 0;
// Rotation velocity
vel = 0;
// Damping force
drag = 0.9;
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
grey.copyTo(buf[last]);
int idx1, idx2;
idx1 = last;
idx2 = (last + 1) % buf.length;
last = idx2;
silh = buf[idx2];
// Get current time in seconds.
double timestamp = (System.nanoTime() - time0)/1e9;
// Compute difference with threshold.
Core.absdiff(buf[idx1], buf[idx2], silh);
Imgproc.threshold(silh, silh, 30, 255, Imgproc.THRESH_BINARY);
// Update motion history image.
Optflow.updateMotionHistory(silh, mhi, timestamp, MHI_DURATION);
mhi.convertTo(mask, CvType.CV_8UC1,
255.0/MHI_DURATION,
(MHI_DURATION - timestamp)*255.0/MHI_DURATION);
// Display motion history image in 8bit greyscale.
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(mask);
image(img, 0, 0);
// Compute overall motion gradient.
Optflow.calcMotionGradient(mhi, mask, orient,
MAX_TIME_DELTA, MIN_TIME_DELTA, 3);
// Calculate motion direction of whole frame.
double angle = Optflow.calcGlobalOrientation(orient, mask,
mhi, timestamp, MHI_DURATION);
// Skip cases with too little motion.
double count = Core.norm(silh, Core.NORM_L1);
if (count > (cap.width*cap.height*0.1)) {
// Moving to the right
if (angle < 10 || (360 - angle) < 10) {
vel -= 0.02;
// Moving to the left
} else if (abs((float)angle-180) < 20) {
vel += 0.02;
}
}
// Slow down the velocity
vel *= drag;
// Update the rotation angle
rot += vel;
fill(0);
text(nf(round(frameRate), 2), 10, 20);
// Draw the cube.
pushMatrix();
pushStyle();
fill(255, 80);
stroke(255);
translate(cap.width/2, cap.height/2, 0);
rotateY(rot);
box(200);
popStyle();
popMatrix();
grey.release();
}
程序的结构保持不变。您可以在屏幕中央添加一个带有半透明立方体的 3D 场景。当你在摄像头前水平移动时,你沿着 y 轴旋转立方体。你把这个运动当作一个加速力来改变旋转的速度。图 6-18 为程序截图。
图 6-18。
Gestural interaction with motion direction
除了检索全局运动方向之外,您还可以分割运动梯度图像以识别各个运动区域。下一个练习Chapter06_18
将展示如何使用函数segmentMotion()
将整体运动信息分割成单独的区域:
// Motion history with motion segment
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
import org.opencv.optflow.Optflow;
import java.lang.System;
import java.util.ArrayList;
final int CNT = 2;
// Minimum region area to display
final float MIN_AREA = 300;
// Motion history duration
final double MHI_DURATION = 3;
final double MAX_TIME_DELTA = 0.5;
final double MIN_TIME_DELTA = 0.05;
Capture cap;
CVImage img;
Mat [] buf;
Mat mhi, mask, orient, segMask, silh;
int last;
double time0, timestamp;
void
setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
last = 0;
buf = new Mat[CNT];
for (int i=0; i<CNT; i++) {
buf[i] = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
}
// Motion history image
mhi = Mat.zeros(cap.height, cap.width, CvType.CV_32FC1);
mask = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
orient = Mat.zeros(cap.height, cap.width, CvType.CV_32FC1);
segMask = Mat.zeros(cap.height, cap.width, CvType.CV_32FC1);
// Threshold difference image
silh = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
// Program start time
time0 = System.nanoTime();
timestamp = 0;
smooth();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
grey.copyTo(buf[last]);
int idx1, idx2;
idx1 = last;
idx2 = (last + 1) % buf.length;
last = idx2;
silh = buf[idx2];
double timestamp = (System.nanoTime() - time0)/1e9;
// Create threshold difference image.
Core.absdiff(buf[idx1], buf[idx2], silh);
Imgproc.threshold(silh, silh, 30, 255, Imgproc.THRESH_BINARY);
// Update motion history image.
Optflow.updateMotionHistory(silh, mhi, timestamp, MHI_DURATION);
// Convert motion
history to 8bit image.
mhi.convertTo(mask, CvType.CV_8UC1,
255.0/MHI_DURATION,
(MHI_DURATION - timestamp)*255.0/MHI_DURATION);
// Display motion history image in greyscale.
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(mask);
// Calculate overall motion gradient.
Optflow.calcMotionGradient(mhi, mask, orient,
MAX_TIME_DELTA, MIN_TIME_DELTA, 3);
// Segment general motion into different regions.
MatOfRect regions = new MatOfRect();
Optflow.segmentMotion(mhi, segMask, regions,
timestamp, MAX_TIME_DELTA);
image(img, 0, 0);
image(out, cap.width, 0);
// Plot individual motion areas.
plotMotion(regions.toArray());
pushStyle();
fill(0);
text(nf(round(frameRate), 2), 10, 20);
popStyle();
grey.release();
regions.release();
}
void plotMotion(Rect [] rs) {
pushStyle();
fill(0, 0, 255, 80);
stroke(255, 255, 0);
for (Rect r : rs) {
// Skip regions of small area.
float area = r.width*r.height;
if (area < MIN_AREA)
continue;
// Obtain submatrices from motion images.
Mat silh_roi = silh.submat(r);
Mat mhi_roi = mhi.submat(r);
Mat orient_roi = orient.submat(r);
Mat mask_roi = mask.submat(r);
// Calculate motion direction of that region.
double angle = Optflow.calcGlobalOrientation(orient_roi,
mask_roi, mhi_roi, timestamp, MHI_DURATION);
// Skip regions with little motion.
double count = Core.norm(silh_roi, Core.NORM_L1);
if (count < (r.width*r.height*0.05))
continue;
PVector center = new PVector(r.x + r.width/2,
r.y + r.height/2);
float radius = min(r.width, r.height)/2.0;
ellipse(center.x, center.y, radius*2, radius*2);
line(center.x, center.y,
center.x+radius*cos(radians((float)angle)),
center.y+radius*sin(radians((float)angle)));
silh_roi.release();
mhi_roi.release();
orient_roi.release();
mask_roi.release();
}
popStyle();
}
完成计算运动梯度图像的语句后,使用以下语句分割运动信息:
Optflow.segmentMotion(mhi, segMask, regions, timestamp, MAX_TIME_DELTA);
主要输入是运动历史图像mhi
。在这种情况下,您没有段掩码。第二个参数segMask
只是一个空图像。操作的结果将存储在MatOfRect
变量regions
中。你写了函数plotMotion()
来遍历从regions
开始的每一个Rect
。在函数中,它会跳过面积太小而无法使用的区域。您使用相同的calcGlobalOrientation()
功能找出运动方向。唯一的区别是您使用子矩阵作为每个图像mhi
、orient
和mask
的感兴趣区域。其余部分与您在练习Chapter06_16
中所做的相同。图 6-19 显示了一个示例截图以供参考。
图 6-19。
Segment motion demonstration
图像左侧的每个圆圈是运动片段区域。圆的大小由区域的宽度和高度的较短边来定义。圆内的直线从圆心指向运动方向。
结论
在本章中,您研究了如何创造性地使用运动来生成视觉效果。此外,您还学习了如何从一系列帧中识别运动,以及如何将这些信息用于手势交互的界面设计。在下一章中,您将继续学习运动,首先识别感兴趣的点,然后跨图像帧跟踪它们以了解更多关于运动的信息。
七、特征检测和匹配
本章用更复杂的跟踪方法继续上一章的运动探索。在上一章中,您比较和分析了帧之间的整个图像,以识别运动信息。结果,从这些方法跟踪的运动细节是通用的,没有利用图像中的特定结构元素。在本章中,您将首先研究如何定位感兴趣的检测点。它们的通用术语是特征点。然后,您将尝试跟踪这些特征点如何在帧之间移动。这些功能主要在 OpenCV 的features2d
模块中提供。除了特征点,您还将探索如何使用objdetect
模块检测面部特征和人物。以下是本章涵盖的主题:
- 角点检测
- 稀疏光流
- 特征检测
- 特征匹配
- 人脸检测
- 人物检测
角点检测
在前面的章节中,您已经了解到,在imgproc
模块中,Canny()
功能可以有效地检测数字图像中的边缘。在本章中,您将进一步检测数字图像中的角点。这个概念就像边缘检测。如图 7-1 所示,角点是那些在不同方向上颜色发生显著变化的像素。
图 7-1。
Corner detection
第一个练习Chapter07_01
,演示了由 Chris Harris 和 Mike Stephens 在 1988 年创建的 Harris 角点检测方法。为了加快执行速度,在本练习中,您将按比例因子 10 缩小原始网络摄像头图像。检测到角点后,将结果矩阵归一化为 8 位分辨率,并遍历它以识别值高于阈值的角点像素。
// Harris corner detection
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
// Threshold value for a corner
final int THRESH = 140;
Capture cap;
CVImage img;
// Scale down the image for detection.
float scaling;
int w, h;
void setup() {
size(640, 480);
background(0);
scaling = 10;
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width, height);
cap.start();
w = floor(cap.width/scaling);
h = floor(cap.height/scaling);
img = new CVImage(w, h);
smooth();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
// Output matrix of corner information
Mat corners = Mat.zeros(grey.size(), CvType.CV_32FC1);
Imgproc.cornerHarris(grey, corners, 2, 3, 0.04,
Core.BORDER_DEFAULT);
// Normalize the corner information matrix.
Mat cor_norm = Mat.zeros(grey.size(), CvType.CV_8UC1);
Core.normalize(corners, cor_norm, 0, 255,
Core.NORM_MINMAX, CvType.CV_8UC1);
image(cap, 0, 0);
pushStyle();
noFill();
stroke(255, 0, 0);
strokeWeight(2);
// Draw each corner with value greater than threshold.
for (int y=0; y<cor_norm.rows(); y++) {
for (int x=0; x<cor_norm.cols(); x++) {
if (cor_norm.get(y, x)[0] < THRESH)
continue;
ellipse(x*scaling, y*scaling, 10, 10);
}
}
fill(0);
text(nf(round(frameRate), 2), 10, 20);
popStyle();
grey.release();
corners.release();
cor_norm.release();
}
主要功能是来自imgproc
模块的cornerHarris()
功能。第一个参数是输入灰度图像,grey
。第二个参数是输出矩阵corners
,它表示每个像素成为角点的可能性。其余参数的技术解释超出了本书的范围。有兴趣可以在 http://docs.opencv.org/3.1.0/d4/d7d/tutorial_harris_detector.html
找到 OpenCV 官方教程。第三个参数是用于计算梯度(像素强度的变化)的 2×2 邻域大小。第四个参数是 Sobel 导数的 3×3 孔径大小,如 OpenCV 文档中的 http://docs.opencv.org/3.1.0/d2/d2c/tutorial_sobel_derivatives.html
所示。第五个参数是 Harris 检测器参数,如前面提到的 Harris 检测器教程所示,最后一个参数是边框类型指示器。图 7-2 显示了程序的运行示例。
图 7-2。
Harris corner detection
稀疏光流
您在第六章中学习了如何使用密集光流功能。在本节中,我将解释如何使用稀疏光流进行运动检测。在密集光流中,您检查并跟踪缩减像素采样图像中的所有像素,而在稀疏光流中,您只检查选定数量的像素。这些是您感兴趣跟踪的点,称为特征点。一般来说,它们是角点。以下是您需要遵循的步骤:
- 识别特征点。
- 提高分的准确性。
- 计算这些点的光流。
- 可视化流程信息。
识别特征点
下一个练习Chapter07_02
,将使用时剑波和卡洛·托马西在 1994 年开发的函数goodFeaturesToTrack()
。该函数返回数字图像中最突出的角点。
// Feature points detection
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
smooth();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
MatOfPoint corners = new MatOfPoint();
// Identify the good feature points.
Imgproc.goodFeaturesToTrack(grey, corners,
100, 0.01, 10);
Point [] points = corners.toArray();
pushStyle();
noStroke();
// Draw each feature point according to its
// original color of the pixel.
for (Point p : points) {
int x = (int)constrain((float)p.x, 0, cap.width-1);
int y = (int)constrain((float)p.y, 0, cap.height-1);
color c = cap.pixels[y*cap.width+x];
fill(c);
ellipse(x+cap.width, y, 10, 10);
}
image(img, 0, 0);
fill(0);
text(nf(round(frameRate), 2), 10, 20);
popStyle();
grey.release();
corners.release();
}
在draw()
函数中,获得灰度图像后,将它传递给goodFeaturesToTrack()
函数。它将返回名为corners
的MatOfPoint
变量中的特征点信息。剩余的三个参数是检测的点的最大数量、检测的质量水平和每个特征点之间的最小距离。将corners
变量转换成名为points
的Point
数组后,循环遍历它,用从原始视频捕获图像中提取的颜色将每个角绘制成一个圆。图 7-3 显示了该程序的示例截图。
图 7-3。
Good features to track
提高准确性
获得特征点列表后,可以使用 OpenCV 函数来提高点的位置精度。即使您正在处理像素位于整数位置的数字图像,拐角也可能出现在两个相邻像素之间的位置。也就是在子像素位置。以下练习Chapter07_03
探究了此函数cornerSubPix()
,以提高角点位置的准确性:
// Feature points detection with subpixel accuracy
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
Capture cap;
CVImage img;
TermCriteria term;
int w, h;
float xRatio, yRatio;
void setup() {
size(800, 600);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
w = 640;
h = 480;
xRatio = (float)width/w;
yRatio = (float)height/h;
cap = new Capture(this, w, h);
cap.start();
img = new CVImage(cap.width, cap.height);
term = new TermCriteria(TermCriteria.COUNT | TermCriteria.EPS,
20, 0.03);
smooth();
}
void
draw() {
if (!cap.available())
return;
background(200);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
MatOfPoint corners = new MatOfPoint();
// Detect the initial feature points.
Imgproc.goodFeaturesToTrack(grey, corners,
100, 0.01, 10);
MatOfPoint2f c2 = new MatOfPoint2f(corners.toArray());
Imgproc.cornerSubPix(grey, c2,
new Size(5, 5),
new Size(-1, -1), term);
Point [] points = corners.toArray();
pushStyle();
noFill();
stroke(100);
// Display the original points.
for (Point p : points) {
ellipse((float)p.x*xRatio, (float)p.y*yRatio, 20, 20);
}
points = c2.toArray();
stroke(0);
// Display the more accurate points.
for (Point p : points) {
ellipse((float)p.x*xRatio, (float)p.y*yRatio, 20, 20);
}
fill(0);
text(nf(round(frameRate), 2), 10, 20);
popStyle();
grey.release();
corners.release();
c2.release();
}
在程序中,您使用较大的草图画布尺寸和较小的视频捕获尺寸来显示旧(像素级)和新(子像素级)角位置之间的差异。在draw()
函数中,在goodFeaturesToTrack()
函数之后,你得到一个名为corners
的MatOfPoint
变量中的特征点列表。新函数cornerSubPix()
将使用相同的输入,即grey
图像和corners
矩阵。角点将用作输入和输出,以亚像素精度存储新的特征点。为了提高精度,输入角必须采用新的浮点格式MatOfPoint2f
。对于cornerSubPix()
函数,第三个参数Size(5, 5)
是搜索窗口大小的一半。第四个是Size(-1, -1)
,是搜索窗口中没有搜索的区域的一半大小。负值表示没有这样的区域。最后一个term
,是迭代过程的终止标准。它确定迭代过程,例如cornerSubPix()
何时结束,或者达到最大计数 20,或者达到 0.03 像素的期望精度。在本例中,您在setup()
函数中将其指定为最大计数 20,所需精度为 0.03 像素。图 7-4 是运行程序的截图。灰色圆圈表示像素级拐角,而黑色圆圈表示子像素级拐角。
图 7-4。
Subpixel accuracy feature points
计算光流
当你有了特征点的准确位置后,下一个程序Chapter07_04
将会跟踪这些特征点的流向。主要函数是 OpenCV 的video
模块中的calcOpticalFlowPyrLK()
。它是基于 Jean-Yves Bouguet 在 2000 年发表的论文“Lucas Kanade 特征跟踪器的金字塔式实现”的实现。
// Sparse optical flow
import processing.video.*;
import org.opencv.core.*;
import org.opencv.video.Video;
import org.opencv.imgproc.Imgproc;
final int CNT = 2;
// Threshold to recalculate the feature points
final int MIN_PTS = 20;
// Number of points to track
final int TRACK_PTS = 150;
Capture
cap;
CVImage img;
TermCriteria term;
// Keep the old and new frames in greyscale.
Mat [] grey;
// Keep the old and new feature points.
MatOfPoint2f [] points;
// Keep the last index of the buffer.
int last;
// First run of the program
boolean first;
void setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
term = new TermCriteria(TermCriteria.COUNT | TermCriteria.EPS,
20, 0.03);
// Initialize the image and keypoint buffers.
grey = new Mat[CNT];
points = new MatOfPoint2f[CNT];
for (int i=0; i<CNT; i++) {
grey[i] = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
points[i] = new MatOfPoint2f();
}
last = 0;
first = true;
smooth();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
if (first) {
// Initialize feature points in first run.
findFeatures(img.getGrey());
first = false;
return
;
}
int idx1, idx2;
idx1 = last;
idx2 = (idx1 + 1) % grey.length;
last = idx2;
grey[idx2] = img.getGrey();
// Keep status and error of running the
// optical flow function.
MatOfByte status = new MatOfByte();
MatOfFloat err = new MatOfFloat();
Video.calcOpticalFlowPyrLK(grey[idx1], grey[idx2],
points[idx1], points[idx2], status, err);
Point [] pts = points[idx2].toArray();
byte [] statArr = status.toArray();
pushStyle();
noStroke();
int count = 0;
for (int i=0; i<pts.length; i++) {
// Skip error cases.
if (statArr[i] == 0)
continue;
int x = (int)constrain((float)pts[i].x, 0, cap.width-1);
int y = (int)constrain((float)pts[i].y, 0, cap.height-1);
color c = cap.pixels[y*cap.width+x];
fill(c);
ellipse(x+cap.width, y, 10, 10);
count++;
}
// Re-initialize feature points when valid points
// drop down to the threshold.
if (count < MIN_PTS)
findFeatures(img.getGrey());
image(img, 0, 0);
fill(0);
text(nf(round(frameRate), 2), 10, 20);
popStyle();
status.release();
err.release();
}
void
findFeatures(Mat g) {
// Find feature points given the greyscale image g.
int idx1, idx2;
idx1 = last;
idx2 = (idx1 + 1) % grey.length;
last = idx2;
grey[idx2] = g;
MatOfPoint pt = new MatOfPoint();
// Calculate feature points at pixel level.
Imgproc.goodFeaturesToTrack(grey[idx2], pt,
TRACK_PTS, 0.01, 10);
points[idx2] = new MatOfPoint2f(pt.toArray());
// Recalculate feature points at subpixel level.
Imgproc.cornerSubPix(grey[idx2], points[idx2],
new Size(10, 10),
new Size(-1, -1), term);
grey[idx2].copyTo(grey[idx1]);
points[idx2].copyTo(points[idx1]);
pt.release();
}
void keyPressed() {
if (keyCode == 32) {
// Press SPACE to initialize feature points.
findFeatures(img.getGrey());
}
}
关于数据结构,程序在名为grey
的数组变量中保存了两个连续的灰度帧。它还需要在称为points
的MatOfPoint2f
数组中保存两个连续的特征点列表。您使用整数变量last
来跟踪数组中哪个索引是最后一个图像帧数据。boolean
变量first
表示是否第一次运行draw()
循环。在第一次运行的情况下,它将通过调用findFeatures()
找到特征点,并更新前一帧和当前帧信息。功能findFeatures()
与您在之前的练习Chapter07_03
中所做的相同。
在draw()
函数中,将索引idx1
更新到最后一帧,将idx2
更新到当前帧。更新后,使用主函数Video.calcOpticalFlowPyrLK()
计算上一帧和当前帧之间的光流信息。函数的四个输入参数是前一帧、当前帧、前一帧特征点和当前帧特征点。该函数有两个输出。第一个是MatOfByte
变量status
,当找到相应的流时返回 1,否则返回 0。第二个输出是当前练习中未使用的误差度量。然后,for
循环将遍历所有有效的流程,并在当前帧特征点绘制小圆圈。该程序还对有效的流数据进行计数,如果该数目低于阈值MIN_PTS
,它将启动findFeatures()
功能来重新计算当前视频图像的特征点。图 7-5 是程序的样例截图。
图 7-5。
Optical flow visualization
可视化流程信息
代替在屏幕上绘制当前的特征点,您可以生成更有创造性的光流信息的可视化。下一个例子,Chapter07_05
,是一个流信息的交互动画。逻辑很简单。将每对特征点从前一个位置连接到其当前位置。
// Optical flow animation
import processing.video.*;
import org.opencv.core.*;
import org.opencv.video.Video;
import org.opencv.imgproc.Imgproc;
final int CNT = 2;
final int TRACK_PTS = 200;
final int MAX_DIST = 100;
Capture cap;
CVImage img;
TermCriteria term;
// Keep two consecutive frames and feature
// points list.
Mat [] grey;
MatOfPoint2f [] points;
int last;
boolean first;
void
setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
term = new TermCriteria(TermCriteria.COUNT | TermCriteria.EPS,
20, 0.03);
grey = new Mat[CNT];
points = new MatOfPoint2f[CNT];
for (int i=0; i<CNT; i++) {
grey[i] = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
points[i] = new MatOfPoint2f();
}
last = 0;
first = true;
smooth();
}
void draw() {
if (!cap.available())
return;
fillBack();
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
if (first) {
findFeatures(img.getGrey());
first = false;
return;
}
int idx1, idx2;
idx1 = last;
idx2 = (idx1 + 1) % grey.length;
last = idx2;
grey[idx2] = img.getGrey();
MatOfByte status = new MatOfByte();
MatOfFloat err = new MatOfFloat();
Video.calcOpticalFlowPyrLK(grey[idx1], grey[idx2],
points[idx1], points[idx2], status, err);
// pt1 - last feature points list
// pt2 - current feature points list
Point [] pt1 = points[idx1].toArray();
Point [] pt2 = points[idx2].toArray();
byte [] statArr = status.toArray();
PVector p1 = new PVector(0, 0);
PVector p2 = new PVector(0, 0);
pushStyle();
stroke(255, 200);
noFill();
for (int i=0; i<pt2.length; i++) {
if (statArr[i] == 0)
continue;
// Constrain the points inside the video frame.
p1.x = (int)constrain((float)pt1[i].x, 0, cap.width-1);
p1.y = (int)constrain((float)pt1[i].y, 0, cap.height-1);
p2.x = (int)constrain((float)pt2[i].x, 0, cap.width-1);
p2.y = (int)constrain((float)pt2[i].y, 0, cap.height-1);
// Discard the flow with great distance.
if (p1.dist(p2) > MAX_DIST)
continue;
line(p1.x+cap.width, p1.y, p2.x+cap.width, p2.y);
}
// Find
new feature points for each frame.
findFeatures(img.getGrey());
image(img, 0, 0);
fill(0);
text(nf(round(frameRate), 2), 10, 20);
popStyle();
status.release();
err.release();
}
void findFeatures(Mat g) {
grey[last] = g;
MatOfPoint pt = new MatOfPoint();
Imgproc.goodFeaturesToTrack(grey[last], pt,
TRACK_PTS, 0.01, 10);
points[last] = new MatOfPoint2f(pt.toArray());
Imgproc.cornerSubPix(grey[last], points[last],
new Size(5, 5),
new Size(-1, -1), term);
pt.release();
}
void fillBack() {
// Set background color with transparency.
pushStyle();
noStroke();
fill(0, 0, 0, 80);
rect(cap.width, 0, cap.width, cap.height);
popStyle();
}
要创建运动模糊效果,不要将背景颜色完全清除为黑色。在fillBack()
功能中,你用一个半透明的矩形填充背景来创建线条的运动轨迹。图 7-6 显示了动画的截图。
图 7-6。
Optical flow animation
在创造性编码中,你经常没有正确和明确的答案。在大多数情况下,你只是不停地问“如果呢?”问题。从前面的练习开始,你可以问,如果你不把屏幕清成黑色会怎么样?如果你从视频图像中提取线条的颜色会怎么样?如果使用不同的笔画粗细会怎样?下一个练习Chapter07_06
,通过将流动动画积累成一种手势绘画的形式来说明这些想法。你可以很容易地将这些效果与杰森·布拉克等画家的动作绘画联系起来。
// Optical flow drawing
import processing.video.*;
import org.opencv.core.*;
import org.opencv.video.Video;
import org.opencv.imgproc.Imgproc;
final int CNT = 2;
final int TRACK_PTS = 150;
final int MAX_DIST = 100;
Capture
cap;
CVImage img;
TermCriteria term;
Mat [] grey;
MatOfPoint2f [] points;
int last;
boolean first;
void setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
term = new TermCriteria(TermCriteria.COUNT | TermCriteria.EPS,
20, 0.03);
// Initialize the buffers for the 2 images and 2 keypoint lists.
grey = new Mat[CNT];
points = new MatOfPoint2f[CNT];
for (int i=0; i<CNT; i++) {
grey[i] = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
points[i] = new MatOfPoint2f();
}
last = 0;
first = true;
smooth();
}
void draw() {
// Note that we do not clear the background.
if (!cap.available())
return;
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
if (first) {
findFeatures(img.getGrey());
first = false;
return
;
}
int idx1, idx2;
idx1 = last;
idx2 = (idx1 + 1) % grey.length;
last = idx2;
grey[idx2] = img.getGrey();
MatOfByte status = new MatOfByte();
MatOfFloat err = new MatOfFloat();
Video.calcOpticalFlowPyrLK(grey[idx1], grey[idx2],
points[idx1], points[idx2], status, err);
Point [] pt2 = points[idx2].toArray();
Point [] pt1 = points[idx1].toArray();
byte [] statArr = status.toArray();
PVector p1 = new PVector(0, 0);
PVector p2 = new PVector(0, 0);
pushStyle();
noFill();
for (int i=0; i<pt2.length; i++) {
if (statArr[i] == 0)
continue;
p1.x = (int)constrain((float)pt1[i].x, 0, cap.width-1);
p1.y = (int)constrain((float)pt1[i].y, 0, cap.height-1);
p2.x = (int)constrain((float)pt2[i].x, 0, cap.width-1);
p2.y = (int)constrain((float)pt2[i].y, 0, cap.height-1);
if (p1.dist(p2) > MAX_DIST)
continue;
color c = cap.pixels[(int)p2.y*cap.width+(int)p2.x];
stroke(red(c), green(c), blue(c), (int)random(100, 160));
strokeWeight(random(3, 6));
line(p1.x+cap.width, p1.y, p2.x+cap.width, p2.y);
c = cap.pixels[(int)p1.y*cap.width+(int)p1.x];
stroke(red(c), green(c), blue(c), (int)random(120, 240));
strokeWeight(random(1, 4));
line(p1.x+cap.width, p1.y, p2.x+cap.width, p2.y);
}
findFeatures(img.getGrey());
image(img, 0, 0);
fill(0);
text(nf(round(frameRate), 2), 10, 20);
popStyle();
status.release();
err.release();
}
void
findFeatures(Mat g) {
// Re-initialize the feature points.
grey[last] = g;
MatOfPoint pt = new MatOfPoint();
Imgproc.goodFeaturesToTrack(grey[last], pt,
TRACK_PTS, 0.01, 10);
points[last] = new MatOfPoint2f(pt.toArray());
Imgproc.cornerSubPix(grey[last], points[last],
new Size(10, 10),
new Size(-1, -1), term);
pt.release();
}
这个程序类似于上一个,除了你没有清除背景。在绘制流数据的for
循环中,首先从实时视频图像中选取颜色,然后绘制两条线而不是一条线。第一条线比较粗,颜色比较透明。第二条线更细,更不透明。它创造了一种更有绘画感的效果。图 7-7 包含两张光流绘制的效果图截图。我的作品时间运动,第一部分( http://www.magicandlove.com/blog/artworks/movement-in-time-v-1/
)是一个使用稀疏光流从经典好莱坞电影序列中生成手势绘画的例子。
图 7-7。
Optical flow drawing
特征检测
在前面的章节中,您尝试通过使用 Harris 角点方法和带有 Shi 和 Tomasi 方法的goodFeaturesToTrack()
函数来定位关键特征点。OpenCV 为您提供了通用的关键点处理来检测、描述它们,并在连续的帧之间进行匹配。在本节中,您将首先学习如何使用features2d
模块中的FeatureDetector
类来识别关键点。下一个练习Chapter07_07
将演示该类的基本操作:
// Features detection
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
import org.opencv.features2d.FeatureDetector;
final float MIN_RESP = 0.003;
Capture cap;
CVImage img;
FeatureDetector fd;
void setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
// Create the instance of the class.
fd = FeatureDetector.create(FeatureDetector.ORB);
smooth();
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
MatOfKeyPoint pt = new MatOfKeyPoint();
// Detect keypoints from the image.
fd.detect(grey, pt);
image(cap, 0, 0);
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(grey);
tint(255, 100);
image(out, cap.width, 0);
noTint();
pushStyle();
noFill();
stroke(255, 200, 0);
KeyPoint [] kps = pt.toArray();
for (KeyPoint kp : kps) {
// Skip the keypoints that are less likely.
if (kp.response < MIN_RESP)
continue;
float x1 = (float)kp.pt.x;
float y1 = (float)kp.pt.y;
float x2 = x1 + kp.size*cos(radians(kp.angle))/2;
float y2 = y1 + kp.size*sin(radians(kp.angle))/2;
// size is the diameter of neighborhood.
ellipse(x1+cap.width, y1, kp.size, kp.size);
// Draw also the orientation direction.
line(x1+cap.width, y1, x2+cap.width, y2);
}
fill(0);
text(nf(round(frameRate), 2), 10, 20);
popStyle();
grey.release();
pt.release();
}
您使用FeatureDetector
类实例fd
来处理主要任务。在setup()
函数中,您用FeatureDetector.create()
函数创建了实例fd
。该参数指示您使用的检测器类型。在 OpenCV 3.1 的 Java 版本中,有以下类型:
AKAZE
,DYNAMIC_AKAZE
,GRID_AKAZE
,PYRAMID_AKAZE
,BRISK
,DYNAMIC_BRISK
,GRID_BRISK
,PYRAMID_BRISK
,FAST
,DYNAMIC_FAST
,GRID_FAST
,PYRAMID_FAST
,GFTT
,DYNAMIC_GFTT
,GRID_GFTT
,PYRAMID
,GFTT
,HARRIS
,DYNAMIC_HARRIS
,GRID_HARRIS
,PYRAMID_HARRIS
,MSER
,DYNAMIC_MSER
,GRID_MSER
,PYRAMID_MSER
,ORB
,DYNAMIC_ORB
,GRID_ORB
,PYRAMID_ORB
,SIMPLEBLOB
、DYNAMIC_SIMPLEBLOB
、GRID_SIMPLEBLOB
、PYRAMID_SIMPLEBLOB
在当前的练习中,您将使用类型FeatureDetector.ORB
。各种探测器类型的详细描述超出了本书的范围。然而,您可以参考本章后面的图 7-9 来比较各种探测器类型。
在draw()
函数中,您使用方法fd.detect(grey, pt)
来执行关键点检测,并将结果存储在名为pt
的MatOfKeyPoint
实例中。将pt
转换成KeyPoint
数组kps
后,使用for
循环遍历每个KeyPoint
对象。对于每个KeyPoint
,属性pt
是点的位置。属性response
描述了它成为关键点的可能性。您将它与阈值MIN_RESP
进行比较,以跳过值较小的那些。属性size
是关键点邻域的直径。属性angle
显示关键点方向。使用一个圆来表示关键点及其邻域大小,使用一条直线来表示方向。图 7-8 显示了一个示例截图。灰度图像以较暗的色调显示,与关键点圆圈形成较高的对比度。
图 7-8。
Feature detection in features2d
图 7-9 显示了使用不同FeatureDetector
类型检测到的关键点的集合。
图 7-9。
Comparison of different FeatureDetector types
您可以使用关键点信息进行创造性的可视化。然而,在下一节中,您将学习 OpenCV 中的通用特征匹配,以便进行后续跟踪。在使用特征匹配之前,还有一个步骤:关键点描述。您将使用来自features2d
模块的DescriptorExtractor
类来计算关键点的描述符。下一个练习Chapter07_08
,将说明描述符的用法:
// Keypoint descriptor
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
import org.opencv.features2d.FeatureDetector;
Capture
cap;
CVImage img;
FeatureDetector fd;
DescriptorExtractor de;
void setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
fd = FeatureDetector.create(FeatureDetector.AKAZE);
// Create the instance for the descriptor
de = DescriptorExtractor.create(DescriptorExtractor.AKAZE);
smooth();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
image(cap, 0, 0);
CVImage out = new CVImage(cap.width, cap.height);
out.copyTo(grey);
tint(255, 200);
image(out, cap.width, 0);
noTint();
MatOfKeyPoint pt = new MatOfKeyPoint();
fd.detect(grey, pt);
Mat desc = new Mat();
// Compute the descriptor from grey and pt.
de.compute(grey, pt, desc);
pushStyle();
noFill();
stroke(255, 200, 0);
KeyPoint [] kps = pt.toArray();
for (KeyPoint kp : kps) {
float x = (float)kp.pt.x;
float y = (float)kp.pt.y;
ellipse(x+cap.width, y, kp.size, kp.size);
}
popStyle();
pt.release();
grey.release();
desc.release();
fill(0);
text(nf(round(frameRate), 2), 10, 20);
}
节目就像上一个。它只添加了一个新类DescriptorExtractor
和它的实例de
。它使用DescriptorExtractor.create()
方法在setup()
函数中创建一个实例。在draw()
函数中,它使用compute()
方法在Mat
中创建名为desc
的描述符。加工窗口中的显示与图 7-8 相似,除了您在屏幕上生成更多的关键点,因为您没有跳过那些响应较低的关键点。对于pt
中的每个关键点,在desc
中将有一个条目用于描述该关键点。一旦在desc
中有了描述符信息,就可以开始下一部分的匹配了。
特征匹配
特征匹配通常涉及两组信息。第一组由已知图像的特征点和描述符组成。你可以把它称为训练集。第二个包括来自新图像的特征点和描述符,通常来自实时捕获图像。您可以将其称为查询集。特征匹配的工作是在训练集和查询集之间进行特征点匹配。进行特征匹配的目的是从训练集中识别已知模式,并跟踪该模式在查询集中的移动位置。在接下来的练习中,您将首先在实时视频流的两个快照之间执行常规特征匹配,在第二个练习中,您将在快照中交互选择一个模式,并尝试跟踪它在实时视频流中的移动位置。
接下来的练习Chapter07_09
,是匹配的准备。它将显示经过训练的快照图像和实时查询图像,以及关键点信息。您可以按下鼠标按钮来切换训练动作。
// Features matching
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
import org.opencv.features2d.FeatureDetector;
Capture cap;
CVImage img;
FeatureDetector fd;
DescriptorExtractor de;
// Two sets of keypoints: train, query
MatOfKeyPoint trainKp, queryKp;
// Two sets of descriptor: train, query
Mat trainDc, queryDc;
Mat grey
;
// Keep if training started.
boolean trained;
// Keep the trained image
.
PImage trainImg;
void setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
trainImg = createImage(cap.width, cap.height, ARGB);
fd = FeatureDetector.create(FeatureDetector.BRISK);
de = DescriptorExtractor.create(DescriptorExtractor.BRISK);
trainKp = new MatOfKeyPoint();
queryKp = new MatOfKeyPoint();
trainDc = new Mat();
queryDc = new Mat();
grey = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
trained = false;
smooth();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
if (trained) {
image(trainImg, 0, 0);
image(cap, trainImg.width, 0);
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
grey = img.getGrey();
fd.detect(grey, queryKp);
de.compute(grey, queryKp, queryDc);
drawTrain();
drawQuery();
} else {
image(cap, 0, 0);
image(cap, cap.width, 0);
}
pushStyle();
fill(0);
text(nf(round(frameRate), 2), 10, 20);
popStyle();
}
void
drawTrain() {
// Draw the keypoints for the trained snapshot.
pushStyle();
noFill();
stroke(255, 200, 0);
KeyPoint [] kps = trainKp.toArray();
for (KeyPoint kp : kps) {
float x = (float)kp.pt.x;
float y = (float)kp.pt.y;
ellipse(x, y, kp.size, kp.size);
}
popStyle();
}
void
drawQuery() {
// Draw the keypoints for live query image.
pushStyle();
noFill();
stroke(255, 200, 0);
KeyPoint [] kps = queryKp.toArray();
for (KeyPoint kp : kps) {
float x = (float)kp.pt.x;
float y = (float)kp.pt.y;
ellipse(x+trainImg.width, y, kp.size, kp.size);
}
popStyle();
}
void
mousePressed() {
// Press mouse button to toggle training.
if (!trained) {
arrayCopy(cap.pixels, trainImg.pixels);
trainImg.updatePixels();
img.copy(trainImg, 0, 0, trainImg.width, trainImg.height,
0, 0, img.width, img.height);
img.copyTo();
grey = img.getGrey();
fd.detect(grey, trainKp);
de.compute(grey, trainKp, trainDc);
trained = true;
} else {
trained = false;
}
}
这个程序相对简单。你保存了两对数据结构。第一对是用于训练图像的MatOfKeyPoint
、trainKp
和查询图像的queryKp
。第二对由描述符trainDc
和queryDC
组成。当用户按下鼠标按钮时,它将拍摄当前视频流的快照,并使用该图像来计算训练的关键点trainKp
和描述符trainDc
。在draw()
函数中,如果有经过训练的图像,程序将从实时视频图像中计算查询关键点queryKp
和描述符queryDc
。图像和关键点信息都将显示在处理窗口中。
图 7-10 显示了运行程序的示例截图。左图是静止图像及其训练好的关键点。右图是现场视频图像及其查询要点。
图 7-10。
Feature points from the trained and query images
下一个练习Chapter07_10
将引入匹配来识别训练图像和查询图像之间的对应关键点。
// Features matching
import processing.video.*;
import java.util.Arrays;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
import org.opencv.features2d.FeatureDetector;
import org.opencv.features2d.DescriptorExtractor;
import org.opencv.features2d.DescriptorMatcher;
final
int MAX_DIST = 200;
Capture cap;
CVImage img;
FeatureDetector fd;
DescriptorExtractor de;
MatOfKeyPoint trainKp, queryKp;
Mat trainDc, queryDc;
DescriptorMatcher match;
Mat grey;
boolean trained;
PImage trainImg;
void
setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
trainImg = createImage(cap.width, cap.height, ARGB);
fd = FeatureDetector.create(FeatureDetector.ORB);
de = DescriptorExtractor.create(DescriptorExtractor.ORB);
match = DescriptorMatcher.create(DescriptorMatcher.BRUTEFORCE_L1);
trainKp = new MatOfKeyPoint();
queryKp = new MatOfKeyPoint();
trainDc = new Mat();
queryDc = new Mat();
grey = Mat.zeros(cap.height, cap.width, CvType.CV_8UC1);
trained = false;
smooth();
}
void
draw() {
if (!cap.available())
return
;
background(0);
cap.read();
if (trained) {
image(trainImg, 0, 0);
image(cap, trainImg.width, 0);
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
grey = img.getGrey();
fd.detect(grey, queryKp);
de.compute(grey, queryKp, queryDc);
MatOfDMatch pairs = new MatOfDMatch();
// Perform key point matching.
match.match(queryDc, trainDc, pairs);
DMatch [] dm = pairs.toArray();
KeyPoint [] tKp = trainKp.toArray();
KeyPoint [] qKp = queryKp.toArray();
// Connect the matched key points.
for (DMatch d : dm) {
// Skip those with large distance.
if (d.distance>MAX_DIST)
continue;
KeyPoint t = tKp[d.trainIdx];
KeyPoint q = qKp[d.queryIdx];
line((float)t.pt.x, (float)t.pt.y,
(float)q.pt.x+cap.width, (float)q.pt.y);
}
drawTrain();
drawQuery();
pairs.release();
} else
{
image(cap, 0, 0);
image(cap, cap.width, 0);
}
pushStyle();
fill(0);
text(nf(round(frameRate), 2), 10, 20);
popStyle();
}
void
drawTrain() {
pushStyle();
noFill();
stroke(255, 200, 0);
KeyPoint [] kps = trainKp.toArray();
for (KeyPoint kp : kps) {
float x = (float)kp.pt.x;
float y = (float)kp.pt.y;
ellipse(x, y, kp.size, kp.size);
}
popStyle();
}
void
drawQuery() {
pushStyle();
noFill();
stroke(255, 200, 0);
KeyPoint [] kps = queryKp.toArray();
for (KeyPoint kp : kps) {
float x = (float)kp.pt.x;
float y = (float)kp.pt.y;
ellipse(x+trainImg.width, y, kp.size, kp.size);
}
popStyle();
}
void
mousePressed() {
if (!trained) {
arrayCopy(cap.pixels, trainImg.pixels);
trainImg.updatePixels();
img.copy(trainImg, 0, 0, trainImg.width, trainImg.height,
0, 0, img.width, img.height);
img.copyTo();
grey = img.getGrey();
fd.detect(grey, trainKp);
de.compute(grey, trainKp, trainDc);
trained = true;
} else {
trained = false;
}
}
大部分代码与前面的程序Chapter07_09
相同。然而,在这个代码中你有一些新的条目。在setup()
函数中,您必须用下面的语句初始化DescriptorMatcher
类实例match
:
match = DescriptorMatcher.create(DescriptorMatcher.BRUTEFORCE_L1);
静态create()
方法内部的参数是匹配方法。支持以下暴力破解方法的变体:BRUTEFORCE
、BRUTEFORCE_HAMMING
、BRUTEFORCE_HAMMINGLUT
、BRUTEFORCE_L1
和BRUTEFORCE_SL2
。如果点击加工图像内部,将在draw()
函数内部执行以下语句:
match.match(queryDc, trainDc, pairs);
match()
功能将执行实时图像的关键点描述符queryDc
和左侧存储图像的关键点描述符trainDc
之间的匹配。变量pairs
将所有匹配的关键点对存储为一个MatOfDMatch
实例。DMatch
是一种数据结构,用于维护存储在查询和训练关键点列表queryKp
和trainKp
中的关键点queryIdx
和trainIdx
的匹配索引。之后的for
循环将枚举所有的关键点匹配对,并为匹配距离d.distance
小于MAX_DIST
阈值的匹配对绘制匹配线。图 7-11 显示了执行的结果截图。
图 7-11。
Feature matching
在许多情况下,您不会通过网络摄像头使用整个图像作为训练图像模式。您可以只选择图像的一部分作为您想要跟踪的图案。在下一个练习Chapter07_11
中,您将使用鼠标绘制一个矩形,仅选择实时图像的一部分进行跟踪。这类似于大多数图形软件中的矩形选框工具。您单击并拖动以定义一个矩形区域作为训练图像,并仅使用该区域内的那些关键点来匹配来自实况视频的查询图像中的那些关键点。为了简化主程序,您定义了一个单独的类Dragging
,从这里处理鼠标交互。
import org.opencv.core.Rect;
// Define 3 states of mouse drag action.
public enum State {
IDLE,
DRAGGING,
SELECTED
}
// A class to handle the mouse drag action
public class Dragging {
PVector p1, p2;
Rect roi;
State state;
public Dragging() {
p1 = new PVector(Float.MAX_VALUE, Float.MAX_VALUE);
p2 = new PVector(Float.MIN_VALUE, Float.MIN_VALUE);
roi = new Rect(0, 0, 0, 0);
state = State.IDLE;
}
void init(PVector m) {
empty(m);
state = State.DRAGGING;
}
void update(PVector m) {
p2.set(m.x, m.y);
roi.x = (int)min(p1.x, p2.x);
roi.y = (int)min(p1.y, p2.y);
roi.width = (int)abs(p2.x-p1.x);
roi.height = (int)abs(p2.y-p1.y);
}
void move(PVector m) {
update(m);
}
void stop(PVector m) {
update(m);
state = State.SELECTED;
}
void empty(PVector m) {
p1.set(m.x, m.y);
p2.set(m.x, m.y);
roi.x = (int)m.x;
roi.y = (int)m.y;
roi.width = 0;
roi.height = 0;
}
void
reset(PVector m) {
empty(m);
state = State.IDLE;
}
boolean
isDragging() {
return (state == State.DRAGGING);
}
boolean isSelected() {
return (state == State.SELECTED);
}
boolean isIdle() {
return (state == State.IDLE);
}
Rect getRoi() {
return roi;
}
}
该类定义了鼠标交互的三种状态:IDLE
,当没有选择开始时;DRAGGING
,用户点击并开始拖动鼠标时;和SELECTED
,当用户释放鼠标按钮以确认选择矩形时,roi
。该类维护两个PVector
对象:p1
,选择矩形的左上角,和p2
,选择矩形的右下角。当用户开始点击-拖动动作时,程序将调用init()
方法。在拖动动作过程中,它会调用move()
方法。当用户停止并释放鼠标按钮时,它将调用stop()
方法。当用户点击而没有任何拖动时,它将通过调用reset()
方法清除选择。该类还为用户提供了三个布尔方法(isIdle()
、isDragging()
和isSelected()
)来查询交互的状态。
主程序类似于Chapter07_10
练习,除了您有额外的代码来处理选择交互和消除选择矩形外的关键点的方法。
// Features matching with selection
import processing.video.*;
import java.util.Arrays;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
import org.opencv.features2d.FeatureDetector;
import org.opencv.features2d.DescriptorExtractor;
import org.opencv.features2d.DescriptorMatcher;
import org.opencv.calib3d.Calib3d;
Capture cap;
CVImage img;
// Feature detector, extractor and matcher
FeatureDetector fd;
DescriptorExtractor de;
DescriptorMatcher match;
// Key points and descriptors for train and query
MatOfKeyPoint trainKp, queryKp;
Mat trainDc, queryDc;
// Buffer for the trained image
PImage trainImg;
// A class to work with mouse drag & selection
Dragging drag;
Mat hg;
MatOfPoint2f trainRect, queryRect;
void setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
trainImg = createImage(cap.width, cap.height, ARGB);
fd = FeatureDetector.create(FeatureDetector.ORB);
de = DescriptorExtractor.create(DescriptorExtractor.ORB);
match = DescriptorMatcher.create(DescriptorMatcher.BRUTEFORCE_HAMMING);
trainKp = new MatOfKeyPoint();
queryKp = new MatOfKeyPoint();
trainDc = new Mat();
queryDc = new Mat();
hg = Mat.eye(3, 3, CvType.CV_32FC1);
drag = new Dragging();
smooth();
trainRect = new MatOfPoint2f();
queryRect = new MatOfPoint2f();
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
image(trainImg, 0, 0);
image(cap, trainImg.width, 0);
if (drag.isDragging()) {
drawRect(cap.width);
} else if (drag.isSelected()) {
drawRect(0);
matchPoints(grey);
drawTrain();
drawQuery();
}
pushStyle();
fill(80);
text(nf(round(frameRate), 2), 10, 20);
popStyle();
grey.release();
}
void matchPoints(Mat im) {
// Match the trained and query key points.
fd.detect(im, queryKp);
de.compute(im, queryKp, queryDc);
// Skip if the trained or query descriptors are empty.
if (!queryDc.empty() &&
!trainDc.empty()) {
MatOfDMatch pairs = new MatOfDMatch();
match.match(queryDc, trainDc, pairs);
DMatch [] dm = pairs.toArray();
// Convert trained and query MatOfKeyPoint to array.
KeyPoint [] tKp = trainKp.toArray();
KeyPoint [] qKp = queryKp.toArray();
float minDist = Float.MAX_VALUE;
float maxDist = Float.MIN_VALUE;
// Obtain the min and max distances of matching.
for (DMatch d : dm) {
if (d.distance < minDist) {
minDist = d.distance;
}
if (d.distance > maxDist) {
maxDist = d.distance;
}
}
float
thresVal = 2*minDist;
ArrayList<Point> trainList = new ArrayList<Point>();
ArrayList<Point> queryList = new ArrayList<Point>();
pushStyle();
noFill();
stroke(255);
for (DMatch d : dm) {
if (d.queryIdx >= qKp.length ||
d.trainIdx >= tKp.length)
continue;
// Skip match data with distance larger than
// 2 times of min distance.
if (d.distance > thresVal)
continue;
KeyPoint t = tKp[d.trainIdx];
KeyPoint q = qKp[d.queryIdx];
trainList.add(t.pt);
queryList.add(q.pt);
// Draw a line for each pair of matching key points.
line((float)t.pt.x, (float)t.pt.y,
(float)q.pt.x+cap.width, (float)q.pt.y);
}
MatOfPoint2f trainM = new MatOfPoint2f();
MatOfPoint2f queryM = new MatOfPoint2f();
trainM.fromList(trainList);
queryM.fromList(queryList);
// Find the homography matrix between the trained
// key points and query key points.
// Proceed only with more than 5 key points.
if (trainList.size() > 5 &&
queryList.size() > 5) {
hg = Calib3d.findHomography(trainM, queryM, Calib3d.RANSAC, 3.0);
if (!hg.empty()) {
// Perform perspective transform to the
// selection rectangle with the homography matrix.
Core.perspectiveTransform(trainRect, queryRect, hg);
}
pairs.release();
trainM.release();
queryM.release();
hg.release();
}
if (!queryRect.empty()) {
// Draw the transformed selection matrix.
Point [] out = queryRect.toArray();
stroke(255, 255, 0);
for (int i=0; i<out.length; i++) {
int j = (i+1) % out.length;
Point p1 = out[i];
Point p2 = out[j];
line((float)p1.x+cap.width, (float)p1.y,
(float)p2.x+cap.width, (float)p2.y);
}
}
}
popStyle();
}
void
drawRect(float ox) {
// Draw the selection rectangle.
pushStyle();
noFill();
stroke(255, 255, 0);
rect(drag.getRoi().x+ox, drag.getRoi().y,
drag.getRoi().width, drag.getRoi().height);
popStyle();
}
void
drawTrain() {
// Draw the trained key points.
pushStyle();
noFill();
stroke(255, 200, 0);
KeyPoint [] kps = trainKp.toArray();
for (KeyPoint kp : kps) {
float x = (float)kp.pt.x;
float y = (float)kp.pt.y;
ellipse(x, y, 10, 10);
}
popStyle();
}
void drawQuery() {
// Draw live image key points.
pushStyle();
noFill();
stroke(255, 200, 0);
KeyPoint [] kps = queryKp.toArray();
for (KeyPoint kp : kps) {
float x = (float)kp.pt.x;
float y = (float)kp.pt.y;
ellipse(x+trainImg.width, y, 10, 10);
}
popStyle();
}
void mouseClicked() {
// Reset the drag rectangle.
drag.reset(new PVector(0, 0));
}
void mousePressed() {
// Click only on the right hand side of the window
// to start the drag action.
if (mouseX < cap.width || mouseX >= cap.width*2)
return;
if (mouseY < 0 || mouseY >= cap.height)
return;
drag.init(new PVector(mouseX-cap.width, mouseY));
}
void
mouseDragged() {
// Drag the selection rectangle.
int x = constrain(mouseX, cap.width, cap.width*2-1);
int y = constrain(mouseY, 0, cap.height-1);
drag.move(new PVector(x-cap.width, y));
}
void mouseReleased() {
// Finalize the selection rectangle.
int x = constrain(mouseX, cap.width, cap.width*2-1);
int y = constrain(mouseY, 0, cap.height-1);
drag.stop(new PVector(x-cap.width, y));
// Compute the trained key points and descriptor.
arrayCopy(cap.pixels, trainImg.pixels);
trainImg.updatePixels();
CVImage tBGR = new CVImage(trainImg.width, trainImg.height);
tBGR.copy(trainImg, 0, 0, trainImg.width, trainImg.height,
0, 0, tBGR.width, tBGR.height);
tBGR.copyTo();
Mat temp = tBGR.getGrey();
Mat tTrain = new Mat();
// Detect and compute key points and descriptors.
fd.detect(temp, trainKp);
de.compute(temp, trainKp, tTrain);
// Define the selection rectangle.
Rect r = drag.getRoi();
// Convert MatOfKeyPoint to array.
KeyPoint [] iKpt = trainKp.toArray();
ArrayList<KeyPoint> oKpt = new ArrayList<KeyPoint>();
trainDc.release();
// Select only the key points inside selection rectangle.
for (int i=0; i<iKpt.length; i++) {
if (r.contains(iKpt[i].pt)) {
// Add key point to the output list.
oKpt.add(iKpt[i]);
trainDc.push_back(tTrain.row(i));
}
}
trainKp.fromList(oKpt);
// Compute the selection rectangle as MatOfPoint2f.
ArrayList<Point> quad = new ArrayList<Point>();
quad.add(new Point(r.x, r.y));
quad.add(new Point(r.x+r.width, r.y));
quad.add(new Point(r.x+r.width, r.y+r.height));
quad.add(new Point(r.x, r.y+r.height));
trainRect.fromList(quad);
queryRect.release();
tTrain.release();
temp.release();
}
在处理窗口中,屏幕上将有两个图像。左边的是当用户通过鼠标拖动动作执行选择时的训练图像。右边是现场视频图像。当用户想要做出选择时,用户需要点击并拖动右边的实时图像。当选择矩形被确认时,它将与实时视频图像的快照一起被发送到左侧。处理事件处理器mouseClicked()
、mousePressed()
、mouseDragged()
和mouseReleased()
管理交互选择过程。在mouseReleased()
方法中,你有额外的代码来首先从实时视频图像的灰度版本中检测关键点;其次计算关键点的描述符;第三遍所有的关键点,只选择那些在选择矩形内的,drag.getRoi()
;第四,准备已训练的关键点列表trainKp
和描述符trainDc
;最后将选择矩形转换为名为trainRect
的MatOfPoint2f
变量。
在draw()
函数中,您只需在DRAGGING
状态下绘制临时选择矩形。在SELECTED
状态下,你将调用matchPoints()
函数,这是程序中最复杂的函数。在这个函数中,它首先从实时视频图像中检测关键点,并计算描述符。当训练描述符和查询描述符都不为空时,它执行关键点匹配。注意,经过训练的描述符trainDc
仅包含选择矩形内的关键点描述。匹配后,该函数将遍历所有匹配对,找出名为pairs
的MatOfDMatch
对象内的最小和最大距离。在随后的循环中,只处理距离小于最小距离值两倍的匹配对。在for
循环之后,你将绘制连接所有匹配关键点的线,并从关键点列表中创建另外两个MatOfPoint2f
变量trainM
和queryM
。当trainM
和queryM
都包含五个以上的关键点时,使用Calib3d.findHomography()
方法从两个关键点列表中计算转换矩阵(单应矩阵)hg
。通过单应矩阵hg
,执行透视变换Core.perspectiveTransform()
,将保存在trainRect
中的选择矩形转换为queryRect
。queryRect
形状由转换后的矩形的四个角的坐标组成,位于窗口的右侧。本质上,四个角将定义跟踪图案的矩形。matchPoints()
函数的最后一部分绘制了连接queryRect
中四个角的四条直线。
图 7-12 显示了结果截图。右侧的四边形是通过使用从左侧的静态训练图像中检测到的模式来跟踪的区域。为了获得最佳效果,您选择的图案应该包含高对比度的视觉纹理。你也应该避免在背景中出现类似的纹理。在matchPoints()
功能中,您建立一个阈值来跳过差异大于两倍最小距离的匹配关键点。您可以降低阈值来减少噪音条件。
图 7-12。
Key point matching with a selection rectangle
除了绘制四边形的轮廓之外,下一个练习Chapter07_12
将在实时网络摄像头图像上绘制的四边形上执行纹理映射。我没有在这里列出整个练习的源代码,我只是在Chapter07_11
中强调了原始版本的变化。您定义了一个全局的PImage
变量photo
,来保存您想要映射到被跟踪图案顶部的图像。在setup()
函数中,你使用P3D
渲染为size(1280, 480, P3D)
,同时设置纹理模式为textureMode(NORMAL)
正常。在matchPoints()
函数的末尾,您有以下代码来绘制上一个练习Chapter07_11
中的四边形:
if (!queryRect.empty()) {
// Draw the transformed selection matrix.
Point [] out = queryRect.toArray();
stroke(255, 255, 0);
for (int i=0; i<out.length; i++) {
int j = (i+1) % out.length;
Point p1 = out[i];
Point p2 = out[j];
line((float)p1.x+cap.width, (float)p1.y,
(float)p2.x+cap.width, (float)p2.y);
}
}
在这个新版本中,Chapter07_12
,你通过使用beginShape()
和endShape()
函数来绘制四边形。在形状定义中,使用四个vertex()
函数通过纹理映射选项绘制四边形。
if (!queryRect.empty()) {
// Draw the transformed selection matrix.
Point [] out = queryRect.toArray();
noStroke();
fill(255);
beginShape();
texture(photo);
vertex((float)out[0].x+cap.width, (float)out[0].y, 0, 0, 0);
vertex((float)out[1].x+cap.width, (float)out[1].y, 0, 1, 0);
vertex((float)out[2].x+cap.width, (float)out[2].y, 0, 1, 1);
vertex((float)out[3].x+cap.width, (float)out[3].y, 0, 0, 1);
endShape(CLOSE);
}
生成的图像将类似于图 7-13 所示。
图 7-13。
Key points matching with texture mapped onto the rectangle
您可能会发现,前面的练习是构建无标记增强现实应用的基础。在更高级的使用中,PImage
变量photo
会被一个三维物体代替。然而,这超出了本书讨论细节的范围。如果感兴趣,可以在 OpenCV 相关文档中寻找 3D 姿态估计。
人脸检测
在交互式媒体制作中,艺术家和设计师经常求助于 OpenCV 的人脸检测功能。该功能是 OpenCV objdetect
模块中的特性之一。该实现基于 Paul Viola 和 Michael Jones 在 2001 年发表的论文“使用简单特征的增强级联进行快速对象检测”。人脸检测是一个机器学习过程。这意味着,在执行人脸检测之前,您需要训练程序来学习有效和无效的人脸。然而,在 OpenCV 中,发行版包括保存在data/haarcascades
文件夹中的预训练信息。您可以使用任何一个 XML 文件来检测特征,如正面脸、侧面脸、眼睛,甚至表情,如微笑。
在下一个练习Chapter07_13
中,您将使用参数文件haarcascade_frontalface_default.xml
检测用户的正面人脸。该文件位于opencv-3.1.0/data/haarcascades
的 OpenCV 分发文件夹中。您需要将该文件从 OpenCV 发行版复制到加工草图的data
文件夹中。
// Face detection
import processing.video.*;
import org.opencv.core.*;
import org.opencv.objdetect.CascadeClassifier;
// Detection image size
final
int W = 320, H = 240;
Capture cap;
CVImage img;
CascadeClassifier face;
// Ratio between capture size and
// detection size
float ratio;
void setup() {
size(640, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width, height);
cap.start();
img = new CVImage(W, H);
// Load the trained face information.
face = new CascadeClassifier(dataPath("haarcascade_frontalface_default.xml"));
ratio = float(width)/W;
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
image(cap, 0, 0);
Mat grey = img.getGrey();
// Perform face detction. Detection
// result is in the faces.
MatOfRect faces = new MatOfRect();
face.detectMultiScale(grey, faces);
Rect [] facesArr = faces.toArray();
pushStyle();
fill(255, 255, 0, 100);
stroke(255);
// Draw each detected face.
for (Rect r : facesArr) {
rect(r.x*ratio, r.y*ratio, r.width*ratio, r.height*ratio);
}
grey.release();
faces.release();
noStroke();
fill(0);
text(nf(round(frameRate), 2, 0), 10, 20);
popStyle();
}
您在人脸检测中使用的参数属于CascadeClassifier
类。首先,您必须定义这个类的一个实例face
。在setup()
功能中,你用来自文件haarcascade_frontalface_default.xml
的训练过的正面面部细节创建新的实例,该文件被复制到data
文件夹中。您还可以使用处理函数dataPath()
来返回data
文件夹的绝对路径。为了优化性能,您在以下语句中使用一个较小尺寸(320×240)的灰度图像grey
进行检测:
face.detectMultiScale(grey, faces);
第一个参数是要检测人脸的灰度图像。结果会在第二个参数中,也就是MatOfRect
变量faces
。通过将它转换成一个Rect
数组facesArr
,您可以使用一个for
循环来显示所有的边界矩形。图 7-14 显示了程序的一个示例显示。
图 7-14。
Face detection
一旦检测到人脸,您可以进一步检测人脸的边框内的面部特征。在下面的练习Chapter07_14
中,您将在一张脸内执行微笑检测。这个节目就像上一个。检测到面部后,使用边框创建一个较小的图像,并在其中检测微笑面部特征。为了测试程序,您还需要将 OpenCV 发行版中的haarcascade_smile.xml
文件复制到加工草图的data
文件夹中。
// Smile detection
import processing.video.*;
import org.opencv.core.*;
import org.opencv.objdetect.CascadeClassifier;
// Face
detection size
final int W = 320, H = 240;
Capture cap;
CVImage img;
// Two classifiers, one for face, one for smile
CascadeClassifier face, smile;
float ratio;
void setup() {
size(640, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width, height);
cap.start();
img = new CVImage(W, H);
face = new CascadeClassifier(dataPath("haarcascade_frontalface_default.xml"));
smile = new CascadeClassifier(dataPath("haarcascade_smile.xml"));
ratio = float(width)/W;
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
noStroke();
image(cap, 0, 0);
Mat grey = img.getGrey();
MatOfRect faces = new MatOfRect();
// Detect the faces first.
face.detectMultiScale(grey, faces, 1.15, 3,
Objdetect.CASCADE_SCALE_IMAGE,
new Size(60, 60), new Size(200, 200));
Rect [] facesArr = faces.toArray();
pushStyle();
for (Rect r : facesArr) {
fill(255, 255, 0, 100);
stroke(255, 0, 0);
float cx = r.x + r.width/2.0;
float cy = r.y + r.height/2.0;
ellipse(cx*ratio, cy*ratio,
r.width*ratio, r.height*ratio);
// For each face, obtain the image within the bounding box.
Mat fa = grey.submat(r);
MatOfRect m = new MatOfRect();
// Detect smiling expression.
smile.detectMultiScale(fa, m, 1.2, 25,
Objdetect.CASCADE_SCALE_IMAGE,
new Size(30, 30), new Size(80, 80));
Rect [] mArr = m.toArray();
stroke(0, 0, 255);
noFill();
// Draw the line of the mouth.
for (Rect sm : mArr) {
float yy = sm.y+r.y+sm.height/2.0;
line((sm.x+r.x)*ratio, yy*ratio,
(sm.x+r.x+sm.width)*ratio, yy*ratio);
}
fa.release();
m.release();
}
noStroke();
fill(0);
text(nf(round(frameRate), 2, 0), 10, 20);
popStyle();
grey.release();
faces.release();
}
在setup()
函数中,您初始化两个分类器,一个用于您在前一个练习中使用的人脸。第二个分类器是一个新的分类器,其训练信息在haarcascade_smile.xml
中。在draw()
功能中,您还可以使用另一个版本的detectMultiScale()
功能。前两个参数是相同的。第三个参数是图像在每个比例下缩小的比例因子。数字越大,检测速度越快,但这是以不太准确为代价的。第四个参数是保留的最小邻居数量。较大的数量将消除更多的错误检测。第五个参数是一个伪参数。最后两个参数是您想要检测的对象(面部或微笑)的最小和最大尺寸。
在第一个for
循环中,显示所有椭圆形状的面。对于每个面,你使用包围矩形r
创建一个子矩阵(感兴趣的区域)fa
。然后你在这个小图像中检测出微笑,并在检测的中心画一条水平线。图 7-15 展示了一次成功的微笑检测。
图 7-15。
Successful smile detection
图 7-16 显示了另一个微笑检测不成功的试验。
图 7-16。
Unsuccessful smile detection
人物检测
除了常规的面部特征检测,OpenCV 中的objdetect
模块还通过HOGDescriptor
(梯度方向直方图)类提供了人物检测功能。你可以使用这个类从数字图像中检测整个人体。下面的练习Chapter07_15
将演示如何使用HOGDescriptor
功能从实时视频图像中检测人体。为了获得最佳效果,您需要在相对清晰的背景下检测整个身体。
// People detection
import processing.video.*;
import org.opencv.core.*;
import org.opencv.objdetect.HOGDescriptor;
// Detection size
final int W = 320, H = 240;
Capture
cap;
CVImage img;
// People detection
descriptor
HOGDescriptor hog;
float
ratio;
void setup() {
size(640, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width, height);
cap.start();
img = new CVImage(W, H);
// Initialize the descriptor.
hog = new HOGDescriptor();
// User the people detector.
hog.setSVMDetector(HOGDescriptor.getDefaultPeopleDetector());
ratio = float(width)/W;
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
image(cap, 0, 0);
Mat grey = img.getGrey();
MatOfRect found = new MatOfRect();
MatOfDouble weight = new MatOfDouble();
// Perform the people detection.
hog.detectMultiScale(grey, found, weight);
Rect [] people = found.toArray();
pushStyle();
fill(255, 255, 0, 100);
stroke(255);
// Draw the bounding boxes of people detected.
for (Rect r : people) {
rect(r.x*ratio, r.y*ratio, r.width*ratio, r.height*ratio);
}
grey.release();
found.release();
weight.release();
noStroke();
fill(0);
text(nf(round(frameRate), 2, 0), 10, 20);
popStyle();
}
相比人脸检测,程序更简单。您不需要加载任何经过训练的数据文件。您只需用下面的语句初始化HOGDescriptor class
实例hog
并设置默认的人员描述符信息:
hog.setSVMDetector(HOGDescriptor.getDefaultPeopleDetector());
在draw()
函数中,使用detectMultiScale()
方法从灰度图像grey
中识别人物,并将结果保存在MatOfRect
变量found
中。最后一个参数是一个伪参数。在for
循环中,用矩形绘制每个边界框r
。图 7-17 为程序截图。
图 7-17。
People detection
结论
在这一章中,你看到了从图像中识别关键点的不同方法。使用从两个连续帧中识别的关键点,可以执行稀疏光流分析或通用关键点描述符匹配来跟踪帧之间的视觉模式。这项技术对增强现实应用很有用。除了关键点跟踪,您还探索了 OpenCV 中面部特征和全身检测的简单使用。这些方法有利于艺术家和设计师通过计算机视觉进行具体化交互。在下一章中,您将了解在部署应用时使用处理的专业实践。
八、应用部署和总结
最后一章将总结目前为止在处理编程环境中使用 OpenCV 时所学到的内容。它还指出了本书中未涉及的其余模块,以及您可以从哪里获得其他资源。此外,本章还提供了生产知识,您可以使用这些知识来部署在 Processing 中开发的应用。本章将涵盖以下主题:
- 在处理中开发库
- 从处理中导出应用
- 在处理中使用系统命令
- 用卡尔曼滤波器优化跟踪
- 其他 OpenCV 模块
在处理中开发库
您可能会发现,无论何时在加工中使用 OpenCV,您都必须在每个加工程序或草图中包含code
文件夹以及CVImage
类定义。如果你能卸载这个过程,那就更好了。这就是处理库可以提供帮助的地方。在官方处理网站上,您可以在 https://processing.org/reference/libraries/
找到许多社区贡献的库。这些是由 Processing 批准的第三方贡献。有兴趣的可以参考 https://github.com/processing/processing/wiki/Library-Guidelines
关于如何在处理中开发和分发库的指南。在这一节中,我将带您通过一些步骤,用开源软件 Eclipse ( http://www.eclipse.org/
),一个 Java 软件开发工具,准备一个名为CVImage
的临时库。
要安装集中式分布式处理库,可以使用处理 IDE 窗口。安装的库位于 Processing 或 Sketchbook 文件夹内的libraries
文件夹中,具体取决于您使用的操作系统。对于每个库,它通常包含以下子文件夹:
examples
library
reference
src
最重要的是library
文件夹,它包含了所有的 Java JAR 文件和构建这个库的本地库(.dll
、.dylib
或.so
文件)。对于CVImage
示例,您将只创建library
文件夹,其中包含您放在处理code
文件夹中的现有组件。您只需要准备一次库。然后它可以在不同的操作系统上使用。以下过程将显示如何使用 macOS 环境准备库。
安装 Eclipse 软件
首先你安装开源 Java 开发环境 Eclipse,从 http://www.eclipse.org/
。在撰写本文时,下载按钮将把您带到 Eclipse Neon 安装程序的下载页面。解压缩文件后,您可以使用 Eclipse 安装程序为 Java 开发人员安装 Eclipse IDE。在开始安装之前,你必须用最新的内容更新安装程序,如图 8-1 所示。
图 8-1。
Updating the Eclipse installer
成功更新后,您可以选择为 Java 开发人员安装 Eclipse IDE,并将安装文件夹位置保留为默认位置(图 8-2 )。
图 8-2。
Installing the Eclipse IDE for Java developers
当您第一次启动 Eclipse IDE 时,它会为您创建默认的工作区。该位置通常在用户的默认个人文件夹或Documents
文件夹中。
准备 OpenCV 和处理库
在workspace
文件夹中,可以新建一个名为libs
的文件夹(图 8-3 )。
图 8-3。
Creating the libs folder inside the workspace folder
在libs
文件夹中,您将复制必要的处理和 OpenCV 库。第一个是处理核心库。在 macOS 上,有点复杂。您需要找到处理应用。点击右键,选择显示包内容,如图 8-4 所示。
图 8-4。
Searching for the macOS Processing core library
在Contents
文件夹中,进入Java
文件夹。找到core.jar
文件。将其复制到您在上一步中刚刚创建的libs
文件夹中。同时将之前code
文件夹的 OpenCV 库内容复制到libs
文件夹,如图 8-5 所示。core.jar
和opencv-310.jar
文件对于所有操作系统都是必不可少的。其他三个文件是特定于平台的:libopencv_java310.dylib
(macOS 64 位)、libopencv_java310.so
(Linux 64 位)和opencv_java310.dll
(Windows 64 位)。对于 Linux 和 Windows 操作系统,core.jar
文件位于processing-3.2.3/core/library
的处理应用文件夹中。
图 8-5。
Content of the libs folder
构建 cximage 库
在 Eclipse 中创建新的 Java 项目(图 8-6 )。
图 8-6。
Creating a Java project in Eclipse
将项目命名为 CVImage(图 8-7 )。然后单击下一步按钮。
图 8-7。
Naming the project CVImage
在 Java 设置中,添加相应的外部库的 JAR 文件(图 8-8 )。
图 8-8。
Adding external library JAR files
从工作区内的libs
文件夹中选择core.jar
和opencv-310.jar
(图 8-9 )。
图 8-9。
Choosing the Processing and OpenCV JAR files
在opencv-310.jar
的库定义中,点击三角形选择本机库位置(图 8-10 )。然后单击编辑并选择外部文件夹。
图 8-10。
Specifying the native library location for opencv-310.jar
再次选择libs
文件夹作为opencv-310.jar
的本地库位置,因为您已经将所有本地库,包括 macOS、Windows 和 Linux 的本地库放在那里(图 8-11 )。
图 8-11。
Choosing libs for the native library location
单击 Finish 确认外部库的所有信息后,您可以将新类添加到项目中。从包资源管理器中,右键单击 CVImage 项目以添加新的包。指定项目的包名cvimage
(图 8-12 )。
图 8-12。
Specifying the package name
下一条信息是项目的类名CVImage
(图 8-13 )。同样,在 Package Explorer 中,右键单击包cvimage
来添加一个新类。
图 8-13。
Specifying the class name of the project
在您填写了 CVImage 项目的所有必要信息之后,Eclipse 将向您显示空文件CVImage.java
。您可以从本书中使用的任何 Processing sketch 文件夹内的CVImage.pde
中复制原始的类定义到这个文件中。然而,您需要修改几行代码来适应 Eclipse 环境。完整代码如下:
package cvimage;
import processing.core.*;
import org.opencv.core.*;
import org.opencv.imgproc.*;
import java.nio.ByteBuffer;
import java.util.ArrayList;
public class CVImage extends PImage {
final private MatOfInt BGRA2ARGB = new MatOfInt(0, 3, 1, 2, 2, 1, 3, 0);
final private MatOfInt ARGB2BGRA = new MatOfInt(0, 3, 1, 2, 2, 1, 3, 0);
// cvImg - OpenCV Mat in BGRA format
// pixCnt - number of bytes in the image
private Mat cvImg;
private int pixCnt;
public
CVImage(int w, int h) {
super(w, h, ARGB);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
pixCnt = w*h*4;
cvImg = new Mat(new Size(w, h), CvType.CV_8UC4, Scalar.all(0));
}
public void copyTo() {
// Copy from the PImage pixels array to the Mat cvImg
Mat tmp = new Mat(new Size(this.width, this.height), CvType.CV_8UC4, Scalar.all(0));
ByteBuffer b = ByteBuffer.allocate(pixCnt);
b.asIntBuffer().put(this.pixels);
b.rewind();
tmp.put(0, 0, b.array());
cvImg = ARGBToBGRA(tmp);
tmp.release();
}
public void copyTo(PImage i) {
// Copy from an external PImage to here
if (i.width != this.width || i.height != this.height) {
System.out.println("Size not identical");
return;
}
PApplet.arrayCopy(i.pixels, this.pixels);
this.updatePixels();
copyTo();
}
public void copyTo(Mat m) {
// Copy from an external Mat to both the Mat cvImg and PImage
pixels array
if (m.rows() != this.height || m.cols() != this.width) {
System.out.println("Size not identical");
return;
}
Mat out = new Mat(cvImg.size(), cvImg.type(), Scalar.all(0));
switch (m.channels()) {
case 1:
// Greyscale image
Imgproc.cvtColor(m, cvImg, Imgproc.COLOR_GRAY2BGRA);
break;
case 3:
// 3 channels colour image BGR
Imgproc.cvtColor(m, cvImg, Imgproc.COLOR_BGR2BGRA);
break;
case 4:
// 4 channels colour image BGRA
m.copyTo(cvImg);
break;
default:
System.out.println("Invalid number of channels " + m.channels());
return;
}
out = BGRAToARGB(cvImg);
ByteBuffer b = ByteBuffer.allocate(pixCnt);
out.get(0, 0, b.array());
b.rewind();
b.asIntBuffer().get(this.pixels);
this.updatePixels();
out.release();
}
private Mat BGRAToARGB(Mat m) {
Mat tmp = new Mat(m.size(), CvType.CV_8UC4, Scalar.all(0));
ArrayList<Mat> in = new ArrayList<Mat>();
ArrayList<Mat> out = new ArrayList<Mat>();
Core.split(m, in);
Core.split(tmp, out);
Core.mixChannels(in, out, BGRA2ARGB);
Core.merge(out, tmp);
return tmp;
}
private
Mat ARGBToBGRA(Mat m) {
Mat tmp = new Mat(m.size(), CvType.CV_8UC4, Scalar.all(0));
ArrayList<Mat> in = new ArrayList<Mat>();
ArrayList<Mat> out = new ArrayList<Mat>();
Core.split(m, in);
Core.split(tmp, out);
Core.mixChannels(in, out, ARGB2BGRA);
Core.merge(out, tmp);
return tmp;
}
public
Mat getBGRA() {
// Get a copy of the Mat cvImg
Mat mat = cvImg.clone();
return mat;
}
public Mat getBGR() {
// Get a 3 channels Mat in BGR
Mat mat = new Mat(cvImg.size(), CvType.CV_8UC3, Scalar.all(0));
Imgproc.cvtColor(cvImg, mat, Imgproc.COLOR_BGRA2BGR);
return mat;
}
public Mat getGrey() {
// Get a greyscale copy of the image
Mat out = new Mat(cvImg.size(), CvType.CV_8UC1, Scalar.all(0));
Imgproc.cvtColor(cvImg, out, Imgproc.COLOR_BGRA2GRAY);
return out;
}
}
在第一行中,您添加了package cvimage
语句来指定该类在这个包中。其他的修改是println()
函数,它们被改为System.out.println()
,因为在这种情况下这个类不在处理环境中。在copyTo(PImage i)
和copyTo(Mat m)
方法中有三种这样的情况。通过从菜单栏中选择项目➤构建项目来构建项目。构建项目后,您可以将输出导出为 JAR 文件(图 8-14 )。从包资源管理器中,您可以首先右键单击项目名 CVImage 并选择 Export。
图 8-14。
Exporting the output JAR file
首先,选择导出 Java JAR 文件(图 8-15 ),然后指定导出到哪里(图 8-16 )。
图 8-16。
Selecting the export destination
图 8-15。
Choosing to export the Java JAR file
为了准备新的处理库,您需要创建一个名为CVImage
的文件夹。在这个文件夹中,创建一个名为library
的子文件夹。在前面段落中显示的导出操作之后,将导出的 JAR 文件CVImage.jar
复制到library
文件夹中。在library
文件夹中,将现有code
文件夹中的所有文件复制到那里,如图 8-17 所示。您也可以决定只为您自己的操作系统复制本地库。
图 8-17。
Packaging the library content
在 library 文件夹中,创建一个名为export.txt
的新文本文件,内容如下。它将指示处理将哪些文件复制到导出的应用中,这将在下一节中介绍。
name = CVImage
application.macosx=CVImage.jar,opencv-310.jar,libopencv_java310.dylib
application.windows64=CVImage.jar,opencv-310.jar,opencv_java310.dll
application.linux64=CVImage.jar,opencv-310.jar,libopencv_java310.so
您还需要将 library 文件夹放在另一个名为CVImage
的文件夹中。它将是新创建的处理库的主文件夹。您将把CVImage
文件夹放在本地加工或 Sketchbook 文件夹的libraries
文件夹中,在那里您保存了所有的加工草图。从现在开始,您不需要在使用 OpenCV 库的处理程序中包含code
文件夹和CVImage
类定义。在任何新的加工程序中,你可以使用菜单项草图➤导入库➤ CVImage 将CVImage
库包含到你的代码中(图 8-18 )。
图 8-18。
Inserting the newly created library
这将自动生成以下语句:
import cvimage.*;
语句之后,您可以继续使用前面章节中演示的所有代码。
从处理中导出应用
到目前为止,您已经在 ide 中执行了处理程序。在生产环境中,最好为您的程序创建一个独立的本机应用。处理 IDE 在菜单项“文件➤导出应用”中提供了此功能。通过浏览这些选项,您可以选择为不同的平台创建应用,比如 Windows、macOS 和 Linux。您可以创建一个全屏应用,也可以创建一个窗口大小在您的size()
函数中指定的应用。在 macOS 平台上,还可以选择在应用中嵌入 Java 8。在下面的示例Chapter08_01
中,您可以使用现有的处理程序测试运行导出应用流程,以显示来自网络摄像头的彩色和灰度图像:
// Greyscale image
import processing.video.*;
import org.opencv.core.*;
import org.opencv.imgproc.Imgproc;
import cvimage.*;
Capture cap;
CVImage img;
void setup() {
size(1280, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width/2, height);
cap.start();
img = new CVImage(cap.width, cap.height);
smooth();
}
void draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
Mat grey = img.getGrey();
img.copyTo(grey);
image(cap, 0, 0);
image(img, cap.width, 0);
grey.release();
}
注意程序中import cvimage.*;
语句的使用。您可以通过在处理 IDE 中选择文件➤导出应用来导出应用。导出应用后,根据您选择的操作系统,处理 IDE 将生成以下应用文件夹:
application.linux64
application.macosx64
application.windows64
因为您只有 64 位版本的库,所以在导出中将不会生成 32 位版本。由于我正在使用 macOS 操作系统进行演示,我可以进入application.macosx64
文件夹,双击图标Chapter08_01
来启动应用。图 8-19 显示了一个示例显示。
图 8-19。
Full-screen application
请注意,我选择了黑色作为背景色,并放置了一个“停止”按钮。
在处理中使用系统命令
在生产环境中,除了构建独立的应用之外,您可能还需要在处理应用中执行系统任务。一个常见的任务是在退出处理应用后关闭计算机。下一个示例Chapter08_02
将尝试调用系统命令来关闭处理应用中的计算机。这是 macOS 版本的代码。
// Shutdown computer
import java.lang.Process;
import java.lang.Runtime;
import java.io.*;
import java.util.Arrays;
String comm;
String pw;
void
setup() {
size(640, 480);
// Shutdown command
comm = "sudo -S shutdown -h now";
pw = "password";
}
void draw() {
background(0);
}
void mousePressed() {
shutdown();
}
void shutdown() {
try {
// Execute the shutdown command.
Process proc = Runtime.getRuntime().exec(comm);
BufferedReader buf = new BufferedReader(
new InputStreamReader(proc.getInputStream()));
BufferedReader err = new BufferedReader(
new InputStreamReader(proc.getErrorStream()));
BufferedWriter out = new BufferedWriter(
new OutputStreamWriter(proc.getOutputStream()));
char [] pwc = pw.toCharArray();
// Send out the sudo password.
out.write(pwc);
out.write('\n');
out.flush();
// Erase the password.
Arrays.fill(pwc, '\0');
pw = "";
// Print out messages.
String line;
println("Output message");
while ((line = buf.readLine()) != null) {
println(line);
}
println("Error message");
while ((line = err.readLine()) != null) {
println(line);
}
int rc = proc.exitValue();
println(rc);
System.exit(0);
}
catch
(IOException e) {
println(e.getMessage());
System.exit(-1);
}
}
程序使用 Java 的Runtime
类来执行 shell 命令。因为它需要sudo
密码来执行命令,所以您必须将密码放在String
变量pw
中。对于 Windows 操作系统,在下面的Chapter08_03
示例中,可以将代码简化如下:
// Shutdown computer in Windows
.
import java.lang.Process;
import java.lang.Runtime;
import java.io.*;
String comm;
void setup() {
size(640, 480);
// Command string
comm = "shutdown -s -t 0";
}
void draw() {
background(0);
}
void mousePressed() {
shutdown();
}
void shutdown() {
try {
// Execute the shutdown command.
Process proc = Runtime.getRuntime().exec(comm);
BufferedReader buf = new BufferedReader(
new InputStreamReader(proc.getInputStream()));
BufferedReader err = new BufferedReader(
new InputStreamReader(proc.getErrorStream()));
// Print out the messages.
String line;
println("Output message");
while ((line = buf.readLine()) != null) {
println(line);
}
println("Error message");
while ((line = err.readLine()) != null) {
println(line);
}
int rc = proc.exitValue();
println(rc);
System.exit(0);
}
catch
(IOException e) {
println(e.getMessage());
System.exit(-1);
}
}
命令字符串是不同的,但是您不需要提供密码来执行命令。对于 Linux,它类似于 macOS 版本,但是同样不需要有sudo
密码部分。
// Shutdown computer in Linux
.
import java.lang.Process;
import java.lang.Runtime;
import java.io.*;
import java.util.Arrays;
String comm;
void setup() {
size(640, 480);
// Command string
comm = "shutdown -h now";
}
void draw() {
background(0);
}
void mousePressed() {
shutdown();
}
void
shutdown() {
try {
// Execute the shutdown command.
Process proc = Runtime.getRuntime().exec(comm);
BufferedReader buf = new BufferedReader(
new InputStreamReader(proc.getInputStream()));
BufferedReader err = new BufferedReader(
new InputStreamReader(proc.getErrorStream()));
// Print any messages.
String line;
println("Output message");
while ((line = buf.readLine()) != null) {
println(line);
}
println("Error message");
while ((line = err.readLine()) != null) {
println(line);
}
int rc = proc.exitValue();
println(rc);
System.exit(0);
}
catch (IOException e) {
println(e.getMessage());
System.exit(-1);
}
}
现在,您可以从处理 IDE 中导出应用。根据操作系统,在适当的应用文件夹内导航,例如application.linux64
、application.macosx64
或application.windows64
。双击应用Chapter08_02
。请注意,通过单击应用窗口,它将关闭计算机。
用卡尔曼滤波器优化跟踪
在前一章中,你看到了一个人脸检测的例子。在示例中,您使用了一个矩形来指示检测到人脸的区域。如果你观察跟踪结果,很容易发现矩形的移动相当锯齿状。在 OpenCV 的video
模块中,KalmanFilter
类可以提供平滑跟踪结果的方法。以下练习将提供平滑人脸检测跟踪结果的代码。如果想深入了解卡尔曼滤波器,可以参考 https://www.cs.unc.edu/~welch/media/pdf/kalman_intro.pdf
的文档。从本质上来说,KalmanFilter
类可以帮助您基于以前的测量来预测数字结果。练习Chapter08_04
将包括一个单独的类KFilter
,它封装了 OpenCV 中KalmanFilter
类的处理。
// Kalman filter
import org.opencv.video.KalmanFilter;
public class KFilter {
KalmanFilter kf;
MatOfFloat measurement;
int numS;
int numM;
public KFilter(int s, int m) {
// Initialize the Kalman filter with
// number of states and measurements.
// Our measurements are the x, y location of
// the face rectangle and its width and height.
numS = s;
numM = m;
kf = new KalmanFilter(numS, numM, 0, CvType.CV_32F);
float [] tmp = new float[numM];
for (int i=0; i<tmp.length; i++) {
tmp[i] = 0;
}
measurement = new MatOfFloat(tmp);
}
void initFilter(int fps) {
// Initialize the state transition matrix.
double dt1 = 1.0/fps;
Mat tmp = Mat.eye(numS, numS, CvType.CV_32F);
tmp.put(0, 4, dt1);
tmp.put(1, 5, dt1);
tmp.put(2, 6, dt1);
tmp.put(3, 7, dt1);
kf.set_transitionMatrix(tmp);
// Initialize the measurement matrix.
tmp = kf.get_measurementMatrix();
for (int i=0; i<numM; i++) {
tmp.put(i, i, 1);
}
kf.set_measurementMatrix(tmp);
tmp = kf.get_processNoiseCov();
Core.setIdentity(tmp, Scalar.all(1e-5));
kf.set_processNoiseCov(tmp);
tmp = kf.get_measurementNoiseCov();
Core.setIdentity(tmp, Scalar.all(1e-2));
kf.set_measurementNoiseCov(tmp);
tmp = kf.get_errorCovPost();
Core.setIdentity(tmp, Scalar.all(1));
kf.set_errorCovPost(tmp);
tmp.release();
}
MatOfFloat updateFilter(float x, float y, float w, float h) {
// Update the Kalman filter with latest measurements on
// x, y locations and width, height.
Mat prediction = kf.predict();
measurement.fromArray(new float[]{x, y, w, h});
MatOfFloat estimated = new MatOfFloat(kf.correct(measurement));
prediction.release();
// Return the estimated version of the 4 measurements.
return estimated;
}
}
您将要预测的度量是指示被跟踪的面部的矩形的规格。它有四个数字:矩形的x
位置、y
位置、宽度和高度。对于每一帧,你使用updateFilter()
的方法用面部矩形的最近信息更新卡尔曼滤波器,并获得它的估计值。在下面的主程序中,请注意您是如何仅使用单个面部的信息并绘制面部矩形的估计位置的:
// Face detection
import processing.video.*;
import cvimage.*;
import org.opencv.core.*;
import org.opencv.objdetect.CascadeClassifier;
// Detection image size
final int W = 320, H = 240;
Capture cap;
CVImage img;
CascadeClassifier face;
// Ratio between capture size and
// detection size
float ratio;
KFilter kalman;
void setup() {
size(640, 480);
background(0);
System.loadLibrary(Core.NATIVE_LIBRARY_NAME);
println(Core.VERSION);
cap = new Capture(this, width, height);
cap.start();
img = new CVImage(W, H);
// Load the trained face information.
face = new CascadeClassifier(dataPath("haarcascade_frontalface_default.xml"));
ratio = float(width)/W;
kalman = new KFilter(8, 4);
frameRate(30);
kalman.initFilter(30);
}
void
draw() {
if (!cap.available())
return;
background(0);
cap.read();
img.copy(cap, 0, 0, cap.width, cap.height,
0, 0, img.width, img.height);
img.copyTo();
image(cap, 0, 0);
Mat grey = img.getGrey();
// Perform face detction. Detection
// result is in the faces.
MatOfRect faces = new MatOfRect();
face.detectMultiScale(grey, faces);
Rect [] facesArr = faces.toArray();
pushStyle();
fill(255, 255, 0, 100);
stroke(255);
// Draw only one single face.
if (facesArr.length == 1) {
Rect r = facesArr[0];
float [] tmp = kalman.updateFilter(r.x, r.y, r.width, r.height).toArray();
rect(tmp[0]*ratio, tmp[1]*ratio, tmp[2]*ratio, tmp[3]*ratio);
}
grey.release();
faces.release();
noStroke();
fill(0);
text(nf(round(frameRate), 2, 0), 10, 20);
popStyle();
}
主程序类似于你在前一章做的练习。在draw()
函数中,你只画一个面。在绘制矩形之前,该函数使用矩形信息更新卡尔曼滤波器,并获得实际显示的估计(平滑)版本。当你运行程序的时候,你会注意到面矩形的移动会平滑很多。
其他 OpenCV 模块
在本书中,你已经基本使用了 OpenCV 中的calib3d
、core
、features2d
、imgproc
、objdetect
、video
模块。除了这些模块,还有很多其他模块您还没有看到。例如,您使用了图像输入/输出和图形显示的处理功能,但没有使用 OpenCV imgcodecs
和highgui
模块。还有,我没有接触过任何与计算摄影(photo
)和机器学习(ml
)相关的话题。在 3D 重建中,我只涉及了一个练习。通过使用深度相机,如微软 Kinect,OpenCV 能够使用 OpenNI2 ( https://structure.io/openni
)以及英特尔实感技术(
www.intel.com/content/www/us/en/architecture-and-technology/realsense-overview.html
)从videoio
模块获取深度图像。在opencv_contrib
存储库中,您只需使用optflow
模块。该库还包含深度神经网络(dnn
)、通过运动结构进行 3D 重建(sfm
)、文本识别(text
)等模块。未来,预计深度学习、3D 视觉以及虚拟和增强现实将是有重大发展的领域。
结论
在这一章中,我总结了如何在使用 Processing 的创造性应用开发中使用 OpenCV。有了这种制作技术,艺术家和设计师可以用更专业的方式部署应用。在本书中,您学习了执行图像处理任务的各种方法以及对象/特征检测和跟踪的基础知识,目的是增强人机交互体验。除了提供 OpenCV 的技术演示,书中的练习还暗示了艺术家和设计师可能会发现有帮助的创造性思维过程。有了本章中构建的CVImage
库,您现在可以使用 OpenCV,而无需处理格式转换和数据迁移的繁琐任务。同时,库并不打算隐藏每一个 OpenCV 函数;请随意探索 OpenCV,以便更好地理解图像处理和计算机视觉的基本概念。
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】凌霞软件回馈社区,博客园 & 1Panel & Halo 联合会员上线
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】博客园社区专享云产品让利特惠,阿里云新客6.5折上折
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek “源神”启动!「GitHub 热点速览」
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
· DeepSeek R1 简明指南:架构、训练、本地部署及硬件要求
· NetPad:一个.NET开源、跨平台的C#编辑器