使用OpenCV进行相机标定

前言

前面我总结过两篇关于双目相机的原理以及张氏标定法理论的介绍,而在本文中将通过使用openCV编写标定代码来标定ZED双目相机,不过在此之前先简单了解一下ZED相机(以下介绍参考取官方网站的内容:stereolabs.com/zed/)。

  • 视频流的帧率和分辨率:
模式 FPS 分辨率
2.2K 15 4416x1242
1080p 30 3840x1080
720p 60 2560x720
WVGA 100 1344x376
  • Stereo Baseline:120 mm
  • 光圈:ƒ/2.0
  • 视角:90° (H) x 60° (V) x 110° (D) max
  • 深度范围:0.5 - 20 m (2.3 to 65 ft)
  • 深度值位数:32-bits

获取ZED的图像

三种方法可以采集ZED相机拍摄的图像:

  1. 使用SDK中厂商提供的工具
  2. 使用SDK提供的接口函数
  3. 直接使用OpenCV获取

由于ZED的SDK完全依赖Nvidia的cudn,而我没有Nvidia的显卡,所以没法使用。不过ZED相机遵循UVC接口标准,而且仅仅需要采集其双目的原始图像,所以只用OpenCV即可,代码与其他摄像头一样:

1
2
3
4
5
6
7
8
9
10
11
12
import sys
import os
print(sys.version)
import cv2
import numpy as np
import matplotlib.pyplot as plt

cap = cv2.VideoCapture(0)
if not cap.isOpened():
print('Open Camera fail!')
else:
ret, frame = cap.read()

检查采集到的图像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
def pairImageShow(img1, img2, mode11=None, model2=None):
plt.figure(figsize=(15,15))
plt.subplot(1,2,1)
if mode11 == 'gray':
plt.imshow(img1, cmap='gray')
else:
plt.imshow(img1)
plt.subplot(1,2,2)
if model2 == 'gray':
plt.imshow(img2, cmap='gray')
else:
plt.imshow(img2)
plt.show()

imgs_l = []
imgs_r = []
image_path = '../stereo_calibration/ZED_stereo_calibration/calib_image'
image_list = os.listdir(image_path)
for image_name in image_list:
# print(image_name)
full_path = os.path.join(image_path, image_name)
img = cv2.imread(full_path)
half_col = img.shape[1]//2
imgs_l.append(img[:,:half_col,:])
imgs_r.append(img[:,half_col:,:])

img_rgb_l = cv2.cvtColor(imgs_l[0], cv2.COLOR_BGR2RGB)
img_rgb_r = cv2.cvtColor(imgs_r[0], cv2.COLOR_BGR2RGB)
pairImageShow(img_rgb_l, img_rgb_r)

角点检测

在前面的张氏标定的原理中,我已经介绍过当前广泛使用的张氏标定法通常用棋盘格作为标定物,通过寻找棋盘格的角点作为空间参考坐标点求未知量。利用opencv中的findChessboardCorners函数可以找到棋盘图像中的角点。

在下面的代码中,boardSize定义了角点的总个数和排列,如(7,5)表示为水平方向为7、竖直方向为5的角点阵列。只有当所有的角点都找到后,findChessboardCorners的返回值isFind才为真。findChessboardCorners函数找到的角点的坐标是不精确的,需要再使用cornerSubPix函数,进行亚像素的调整。对角点检查的结果,使用drawChessboardCorners函数画出来。

角点检测的坐标是角点当前姿态投影到照片的坐标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
def getChessboardCorners(colorimg, boardSize):
grayimg = cv2.cvtColor(colorimg, cv2.COLOR_RGB2GRAY)
isFind, corners = cv2.findChessboardCorners(colorimg, boardSize, 0)
if isFind:
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 20, 0.1) # 阈值
cv2.cornerSubPix(grayimg, corners, (5,5), (-1,-1), criteria)
return corners
else:
return None

w, h = 7, 5
boardSize = (w, h)
corners_l = []
corners_r = []
for i in range(len(image_list)):
# print('find corner in image:', i)
corner_l = getChessboardCorners(imgs_l[i], boardSize)
corner_r = getChessboardCorners(imgs_r[i], boardSize)
if corners is not None and corners is not None:
corners_l.append(corner_l)
corners_r.append(corner_r)

cornerimg = cv2.drawChessboardCorners(imgs_l[0], boardSize, corners_l[0], True)
cornerimg_rgb = cv2.cvtColor(cornerimg, cv2.COLOR_BGR2RGB)
plt.figure(figsize=(15,15))
plt.imshow(cornerimg_rgb)
plt.show()

下图为检查结果的截图:

corner detected

计算角点的空间坐标系

我们都以左上角的角点的为坐标原点,以棋盘格的横向和竖向方向为空间的两个坐标轴,建立空间坐标系,所以所有角点均有一个坐标值为0,而且不管棋盘怎么移动,角点的空间坐标都不变。使用numpy的mgrid函数生成网格坐标是十分好用的,虽然理解上需要点时间。

1
2
3
4
5
6
7
8
9
10
11
block_w = 51
block_h = 50

corner_real_coord = np.zeros((w*h,3), np.float32)
grid_data = np.mgrid[0:w,0:h]
grid_data[0,:,:] *= block_w
grid_data[1,:,:] *= block_h
corner_real_coord[:,:2] = grid_data.T.reshape(-1,2)
img_pair_num = len(corners_l)
print(img_pair_num)
corner_real_coords = [corner_real_coord for i in range(img_pair_num)]

计算内参和外参与畸变系数

前面的张氏标定原理有介绍,一个单应性矩阵有可以表示一个姿态下图像坐标到空间坐标的映射,而一个单应性矩阵有8个未知量,所以在一张棋盘格纸上需要至少超出8个以上的角点才能求得单应性矩阵。而当单应性矩阵求得后,能够提供两个方程来求相机得内参矩阵。在加入了倾斜因子后,内参矩阵中有5个未知量,所以至少需要3个姿态的棋盘照片。

在opencv中提供了calibrateCamera函数对相机进行标定,这个函数将标定结果、相机的内参数矩阵、畸变系数、旋转矩阵和平移向量一步完成并返回。

1
2
3
4
5
6
7
ret_l, mtx_l, dist_l, rvecs_l, tvecs_l = cv2.calibrateCamera(corner_real_coord, corners_l, (img_rgb_l.shape[1],img_rgb_l.shape[0]), None, None)
ret_r, mtx_r, dist_r, rvecs_r, tvecs_r = cv2.calibrateCamera(corner_real_coords, corners_r,(img_rgb_l.shape[1],img_rgb_l.shape[0]), None, None)
print('error: ',ret_l)
print('内参:',mtx_l)
print('畸变系数:',dist_l)
# print('旋转向量:',rvecs_l)
# print('平移向量:',tvecs_l)

去畸变

使用计算得到畸变系数,使用opencv中提供的undistort函数对图像进行去畸变(要注意如果照片少,畸变系数的误差会很大,校正的效果也不好)。

1
2
3
4
5
dst_img = cv2.undistort(imgs_r[0], mtx_r, dist_r)
dst_img_rgb = cv2.cvtColor(dst_img, cv2.COLOR_BGR2RGB)
plt.figure(figsize=(15,15))
plt.imshow(dst_img_rgb)
plt.show()

立体标定与矫正

使用opencv的stereoCalibrate函数能够同时对两个镜头进行标定,标定结果可以得到两个摄像头的内外参数矩阵和两个摄像头的相对位置的R和T,有一点要注意的就是:由于我们上面已经分别对每个摄像头求出了他们各自的内参,所以在这里就不用重复做了,需要将stereoCalibrate函数的flags参数设置为CALIB_FIX_INTRINSIC。

1
2
3
4
5
6
7
8
crit = (cv2.TERM_CRITERIA_COUNT + cv2.TERM_CRITERIA_EPS, 100, 1e-5)
ret = cv2.stereoCalibrate(corner_real_coords, corners_l, corners_r,
mtx_l, dist_l,
mtx_r, dist_r,
(img_rgb_l.shape[1],img_rgb_l.shape[0]),
flags = cv2.CALIB_FIX_INTRINSIC,
criteria = crit)
R, T = ret[-4:-2]

在实际矫正中,我们是根据两个相机的相对关系,最终得到其在同以平面对其的图像,因此我们需要先求得两个摄像头得矫正变换矩阵。通过opencv得stereoRectify函数可以得到,左右相机需要得变换矩阵Rl、Rr和投影矩阵Pl、Pr:

1
2
3
ret = cv2.stereoRectify(mtx_l, dist_l, mtx_r, dist_r, (img_rgb_l.shape[1],img_rgb_l.shape[0]), R, T)
Rl, Rr, Pl, Pr = ret[:4]
Rl, Rr, Pl, Pr

经过立体标定后,双目相机的相对位置就知道了,然后就可以进行立体矫正,在opencv中需要分两个步骤完成,通过initUndistortRectifyMap得到映
射关系,再使用remap函数完成矫正。

1
2
3
4
5
6
7
8
9
map_l1, map_l2 = cv2.initUndistortRectifyMap(mtx_l, dist_l, Rl, Pl, (img_rgb_l.shape[1],img_rgb_l.shape[0]), cv2.CV_32FC1)
map_r1, map_r2 = cv2.initUndistortRectifyMap(mtx_r, dist_r, Rr, Pr, (img_rgb_l.shape[1],img_rgb_l.shape[0]), cv2.CV_32FC1)

reimg_l = cv2.remap(imgs_l[10], map_l1, map_l2, cv2.INTER_LINEAR)
reimg_r = cv2.remap(imgs_r[10], map_r1, map_r2, cv2.INTER_LINEAR)
dst_img_rgb = cv2.cvtColor(reimg_r, cv2.COLOR_BGR2RGB)
plt.figure(figsize=(15,15))
plt.imshow(dst_img_rgb)
plt.show()

画水平线检查,立体矫正得效果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cav_w, cav_h = imgs_l[0].shape[1]//2, imgs_l[0].shape[0]//2
cavimg = np.zeros((cav_h, cav_w*2, 3), np.uint8)

reimg_l = cv2.remap(imgs_l[1], map_l1, map_l2, cv2.INTER_LINEAR)
reimg_r = cv2.remap(imgs_r[1], map_r1, map_r2, cv2.INTER_LINEAR)

cavimg[:,:cav_w,:] = cv2.resize(reimg_l, (cav_w, cav_h))
cavimg[:,cav_w:,:] = cv2.resize(reimg_r, (cav_w, cav_h))
step = cav_h//10
for i in range(10):
rowval = i*step
cv2.line(cavimg, (0, rowval), (cav_w*2, rowval), (0,0,255), 1, 8)

dst_img_rgb = cv2.cvtColor(cavimg, cv2.COLOR_BGR2RGB)
plt.figure(figsize=(15,15))
plt.imshow(dst_img_rgb)
plt.show()

保存和读取校准参数,opencv中有模块FileStorage来保存和读取数据,常用保存格式为yml或xml,FileStorage让矩阵的的保存和读取非常方便。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 def saveCaliResult(file_name, names, params):
WRITE = 1
f = cv2.FileStorage(file_name, WRITE)
for name, val in zip(names, params):
f.write(name, val)
f.release()

def loadCaliResult(califile):
READ = 0
fs = cv2.FileStorage(califile, READ)
mtx_l = fs.getNode('mtx_l').mat()
mtx_r = fs.getNode('mtx_r').mat()
dist_l = fs.getNode('dist_l').mat()
dist_r = fs.getNode('dist_r').mat()
Rl = fs.getNode('Rl').mat()
Rr = fs.getNode('Rr').mat()
Pl = fs.getNode('Pl').mat()
Pr = fs.getNode('Pr').mat()
fs.release()
return mtx_l, mtx_r, dist_l, dist_r, Rl, Rr, Pl, Pr

参考

  1. 使用OpenCV进行标定(Python)
  2. 双摄像头立体成像(三)-畸变矫正与立体校正
Compartir

Jupyter Notebook使用技巧

前言

本文的目的是总结和记录我自己在使用jupyter notebook过程中,学到的一些非常好用的功能或者容易遗忘小技巧。

模式切换与快捷键

用了好久的jupyter notebook,才知道原来它还有两种不同的模式:命令模式编辑模式,以下列出在模式切换和不同模式下功能的快捷键:

式切换

  • ESC:进入命令模式
  • Enter:进入编辑模式

模式下的常用的快捷键

  • Ctrl+Enter:运行本单元
  • Shift+Enter:运行本单元,选中下个单元
  • Y:转入代码状态
  • M:转入makedown状态
  • R:转入raw状态
  • A:在上方插入新的单元
  • B:在下方插入新的单元
  • DD:删除单元
  • Z:恢复操作
  • Shift+M:合并选中的单元
  • L:显示或隐藏行号
  • II:中断内核
  • 00:重启内核

辑模式下的常用的快捷键

由于编辑模式下的快捷方式与其他编辑器类似,所以就不特地列出,这里只记录一个拆分单元的功能。

  • Ctrl+Shift+-:分割单元

激活和关闭交互式的matplotlib图标

在jupyter notebook中使用matplotlib画图,在普通情况下,只能输出图标格式化后静态的的图像,而没有交互的功能。如果我们需要交互功能需要在cell中执行如下命令:

1
%matplotlib notebook

如果不再需要交互式的输出,则可以需要关闭它:

1
%matplotlib inline

这种以%或者%%开头的命令叫做magic命令,查询magic命令可以使用%lsmagic来将其打印出来。

除了%符号,jupyter提供“!”作为unix command的前导符合,来运行shell命令,当然只针对部分命令,如:

1
2
!python --version
!ls

在jupyter 中执行可执行文件

尽管jupyter提供magic命令,但是毕竟只是少量的,那么执行第可执行文件或者cmd/bash命令可能需要通过其他方法,如下以一条复合bash命令为例:

1
2
3
4
5
6
7
8
9
def printfile(name):
lines_str = ''
with open(name, 'r') as f:
lines = f.readlines()
lines_str = ''.join(lines)
print(lines_str)

os.system('ls | grep model* > test.txt')
printfile('test.txt')

为Jupyter Notebook添加目录功能

在jupyter中添加侧边栏目录

原始的Jupyter是不支持markdown添加目录功能的,不过我们可以可以利用Jupyter notebook
extensions去使得这种功能实现,首先安装jupyter_contrib_nbextensions:

1
conda install -c conda-forge jupyter_contrib_nbextensions

当安装好以后就会发现jupyter上方的菜单中多出了一个Nbextensions的菜单如下图:

Nbextensions

将Tabel of Contents勾选即可,而在Nbextension还有需要有趣的插件可以去探索。

在文本头增目录

上面是使用插件生成侧边栏目录,如果只需要在文章的头部生成目录则可以用另外的方法。在纯Markdown文本中是能够使用“[TOC]”来自动生成文章头部目录的,但是在jupyter notebook的cell中无法使用,我们只能通过创建文本内部的链接,手动创建目录,如下所示:

目录链接

修改jupyter的工作空间

修改工作空间有两种方法,一是在进入需要修改的目录中,然后在这个目录下打开jupyter,那么打开的jupyter notebook就会将当前目录做工作空间;二是修改配置文件。

第一种放简单灵活,是我们的首选,但是在Window下命令行启用jupyter,有时候修改工作空间是无效的,所以只能修改配置文件了。配置文件为:jupyter_notebook_config.py,如果不存在则需要手动创建,执行如下命令:

1
jupyter notebook --generate-config

在jupyter_notebook_config.py文件中找到如下内容,并将c.NotebookApp.notebook_dir的值修改为需要指定的目录:

1
2
# The directory to use for notebooks and kernels.
c.NotebookApp.notebook_dir = u'D:\Jupyter'

为jupyter添加内核

添加新python内核

以python为例,一般可能有两种情况需要增加内核,一是系统中同时安装了不同版本的python,如python2和python3,二是使用conda等工具创建的不同的python运行环境,在这里我假定是第二种情况。

首先需要安装ipykernel,注意是在要添加的环境中,如下我们先创建了一个虚拟环境,然后在其中安装ipykernel:

1
2
3
conda create -n py36-test python=3.6
source activate py36-test
conda install ipykernel

然后执行如下命令,注意同样是需要在要添加的环境下,下面命令中的python指的就是你要添加的内核,–name是你指定jupyter中添加内核的名字:

1
python -m ipykernel install --name py36-test

安装好后就可以在jupyter notebook中Kernel菜单的Change Kernel中看到

查看和删除内核

查看内核:

1
jupyter kernelspec list

删除内核:

1
jupyter kernelspec remove kernelname
Compartir

一个简单数据管理python项目教程

前言

一年多以前,我在公司有段时间帮忙做过python的新手培训课程(当然是在下班后),虽然我根本就算不上python高手,但是面对对于编程一无所知的初学者我还是绰绰有余的。后面领导希望能做一个样品管理工具作为最终学习的验收成果,而这篇这文章就是我为当时项目提供一个新手教程。

实际上整个项目的基本设计方案也是我提供的,其功能很简单,主要是对样品自身的信息整理分类、以及样品的借入借出做一个管控和信息记录,不过项目有一些强制的要求:

  1. 需要用python编写
  2. 需要有窗口图形界面
  3. 需要用数据库管理数据信息

于是我也针对性的从以下三点出发做简单的介绍:

  1. python的实用基础知识
  2. PyQt的使用
  3. MySQL connector的使用

以下就是我当时文章内容了(提到的书是指:《Python语言及其应用(美Lubanovic 2016)》)。

1. Python基础知识

1.1 模块的导入

在python的程序中,要使用第三方模块的功能,都需要先导入该模块。关于import的部分详细说明,查看书96页。这里只提示一些注意点:

  1. 使用自己写的其他python文件中的函数,也要先导入
  2. 导入的模块只有放在本目录与python环境中的默认目录,才能导入
  3. 如果需要导入其他目录的模块,可以用sys.path.append(),将这个模块的路径添加到查找路径中,就可以导入了。

1.2 Python的类

在书的第6章,有整整一章的内容讲解类的知识点,所以我这里只是提示一些使用时的注意点:

  • 类用class定义,它其中的每一个成员函数和每一个成员变量都需要在其参数中加self,当使用这些函数时也需要使用self.的形式来调用:
  • 类的成员变量的作用域存在于整个类,使用self.的形式可以在各个成员函数中使用
  • 类中的__init__函数是类的默认初始化函数(构造函数),在类的对象被创建时自动调用,因此一些类初始操作都放在这个函数中:

1.3 用函数返回多个值

在python中,函数是可以返回多个值的,当函数返回多个值时,实际上时返回的一个元组,当接收这样的返回值时除了用一个参数接收外,还可以用多个参数接收:

1
2
3
4
5
6
7
8
def testFuntion():
ret1 = "return data"
ret2 = 23
ret3 = [23,65,90]
return ret1, ret2, ret3

res = testFuntion()
data1,data2,data3 = testFuntion()

1.4 Python中的线程操作

在项目中,连接数据库可能需要很长的时间,单线程的操作很可能导致程序卡住很长时间无法响应。在这样情况下,我们一般会新创建一个线程,在线程中执行数据库连接的操作。

在Python中创建线程的最基本操作可以固定如下:

1
2
3
4
5
6
7
8
9
10
11
# 导入线程模块
from threading import Thread

# 线程开始时执行的函数
def connectThread():
print('conneting')

# 创建线程
th = Thread(target=connectThread)
th.setDaemon(True)
th.start()

2. PyQt的使用

2.1 PyQt简介

PyQt是Qt的Python版本,一般在Python发布版本(WinPython和Anaconda)中都会有带有,官方原生的Python则需要自己安装。原生的Qt是C++版的,当前主流Qt使用的是两个版本:Qt4和Qt5,对应Python的版本为PyQt4和PyQt5,在我们的项目中使用PyQt5。

使用Qt设计界面有一个很好用的工具,那就是Qt Designer。它提供可视化的界面设计环境,让能够让开发者更快速、简单的设计和布局界面。

2.2 PyQt使用基本步骤

下面,我将使用PyQt生成一个最简单的窗口,通过以下代码我们可以看到,仅仅需要五个简单的步骤就可以完成。这五个步骤可以看成使用PyQt的一个固定的模式,要创建其他更复杂的窗口时,只是在此基础上稍作修改然后增加新窗口的部分代码而已。

注:在jupyter环境下,直接运行以下代码,就可以得到一个新的窗口

1
2
3
4
5
6
7
8
9
10
11
12
import sys

# 第一步,导入PyQt5或者它的子库
from PyQt5.QtWidgets import QApplication, QMainWindow
# 第二步,创建一个Qt应用程序
app = QApplication(sys.argv)
# 第三步,创建一个QMainWindow窗口
mwind = QMainWindow()
# 第四步,显示创建的窗口
mwind.show()
# 第五步,开启消息循环
app.exec()

2.3 使用Qt Designer设计界面

前面说过,Qt Designer是Qt提供的一个可视化界面设计工具,使用Qt Designer不管是布局窗口或者调整控件的大小和位置,还是增加或者删除控件,都只需要简单拖动鼠标即可。

Qt Designer设计的界面保存的文件,是一个XML格式后缀名以.ui结尾的文件,设计时使用到的资源(如图片)将保存为以.qrc为后缀的资源文件。当我们的代码要使用这些窗口时,需要使用Qt给定的工具将ui文件和资源转化Python代码能够使用的.py文件:

  • pyuic5.bat:将.ui文件转化为.py文件
  • pyrcc5.exe:将.qrc文件转化为.py文件

在Windows系统中,可以在命令行中,执行以下转化命令:

ui文件的转化(假定ui文件名为MainWindow.ui):

1
pyuic5.bat -o Ui_MainWindow.py MainWindow.ui

资源文件的转化(假定pyrcc5.exe的路径如下,资源文件名为res.qrc):

1
D:\WinPython\python-3.4.4\Lib\site-packages\PyQt5\pyrcc5.exe -o res.py res.qrc

当然,你可以将这些转化命令写到python代码或者bat脚本中,然后一下执行一系列的转化。另外对于PyCharm的使用者,可以使用PyCharm封装了这两个命令的菜单来执行。

2.4 使用自己设计的窗口

在2.2中生成的窗口只是Qt中默认的窗口,在实际项目中我们肯定是需要使用自己设计的窗口的,我们只需要做两步修改即可:

  1. 创建一个新的窗口类(它同时继承于Qt的默认窗口和我们自己的设计的窗口),
  2. 将2.2中的代码,创建的窗口改为自己新建的窗口类

假定我们从2.3节转化得到的窗口文件为Ui_MainWindow.py,则我们需要增加的代码如下:

1
2
3
4
5
# 新建自己的窗口类
class MyUi(QMainWindow, Ui_MainWindow):
def __init__(self):
super(MyUi, self).__init__()
self.setupUi(self)

将mwind = QMainWindow()改为如下代码:

1
mwind = MyUi()

注:由于以上两段代码不全,所以在jupyter中不能执行

2.5 在代码中使用控件

在窗口设计中,窗口本身和窗口上分布的控件是两大不同的组成部分,窗口自身可能会有菜单栏、工具栏、状态栏,但是更丰富的功能还是需要使用控件来完成。这里例举一些常见的控件以及它对应作用:

  • QLabel:显示文字,提示作用
  • QLineEdit:单行文字输入,输入文字作用
  • QTextEdit:多行文件输入或显示,输入文字或显示信息的作用
  • QPushButton:按钮,提供用户操作的方法
  • QRadioButton:单选输入,提供选择输入
  • QComboBox: 下拉列表的形式,提供单选输入
  • QTabelView:显示表格信息,提供表格数据显示

使用控件时要注意以下几点:

  1. 控件的名字:在代码使用控件都是用控件的名字,所以控件命名时在同一窗口中不能重复了
  2. 控件的方法:每种控件都有自己不同的作用,他们的拥有的方法也可能不一样,这个需要在Qt的官方文档中查询(如QLineEdit的页面为: http://doc.qt.io/qt-5/qlineedit.html
  3. 控件的消息:控件在发生改变时可能会产生消息,控件拥有消息的类型需要在官方文档对应的页面的“Singals部分”查询

那么具体怎么在代码中使用这些控件呢?仍然以2.4中的例子为例,为获取QLineEdit的文字并改变其中的值,你需要在MyUi类中合适的地方加入:

1
2
3
4
# 获取名为lineEdit的QLineEdit控件的输入的文字
textStr = self.text()
# 设置lineEdit的文字,为在原有输入信息的前面加上“你输入了:”
self.setText("你输入了:"+textStr)

注意:尽管每个控件的很多都不一样,但是也有一些方法是一样的,如上面所示的text()和setText()函数,很多控件或缺它的文字和设置它的文字的方法都是这两个函数

那么又怎么让控件的信号关联到自己的消息处理函数上呢?以名为button的QPushButton为例,要处理它的用户点击消息,要在MyUi类中加入代码如下:

1
2
# OnLoginBtnClicked2是MyUi类中自己写的消息处理函数,clicked就是QPushButton控件中的一种信号,用connect可以将新号和处理函数关联起来
self.button.clicked.connect(self.OnLoginBtnClicked2)

3. Python中MySQL connector的使用

3.1 MySQL connector简介

在Python访问MySQL数据库需要用它的第三方库,在我们的项目中用的是mysql connector,利用它能够比较简单的实现MySQL数据库操作。一般在Python的发布版本中都自带mysql connector,官方原生的Python需要自己安装。

3.2 MySQL connector使用的基本流程

以下是mysql connector使用的基本流程(测试我的本地数据库服务器,地址:127.0.0.1,用户:testuser,密码:123456,数据库:test,端口:3306):

注:在jupyton环境下,可以直接执行,不过需要将数据库服务器的参数设置为自己实际情况的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 第一步,导入mysql.connector
import mysql.connector

# 第二步,连接数据库
db_server = '127.0.0.1'
db_user = 'testuser'
db_pw = '123456'
db_port = '3306'
db_database = 'test'
cnx = mysql.connector.connect(user=db_user, password=db_pw, host=db_server, database=db_database,port=db_port)

# 第三步, 获取cursor
cur = cnx.cursor()

# 第四步, 执行sql语句
cur.execute("show databases")

# 第五步,获取执行的结果
ret = cur.fetchall()

# 第六步, 关闭连接
cur.close()
cnx.close()

print("数据库服务器有库:",ret)

从上面,我们可以看到利用mysql connector操作数据库服务器的基本流程为6步,我们可以认为上面操作是所有操作的基础。其中第二步、第三步在可以认为是数据库的连接部分,在使用数据库前一定要先连接成功。第六步是关闭数据库连接的操作,在所有数据库操作完成后执行。第四步和第五步是实际数据库操作部分,在数据库连接成功后并且在没有关闭前,可以一直执行。

我们对数据的操作实际上都是用sql语句去做的,而上面代码的第四步就时执行sql语句,我们只要改变execute函数中的sql语句字符串就可以将它变成执行其他功能的操作。

3.3 MySQL connector执行sql语句的两种类型

使用mysql connnector执行数库语句有两种类型:

  1. 对数据库进行查询,需要返回数据
  2. 对数据库进行修改,不需要返回数据的

这两种类型,在使用mysql connector时会有稍许的差异,对应3.2中的代码中,不同的部分在第五个步骤,其他部分都一样。

对于第一种类型,使用fetch系列函数来获取数据:

1
ret = cur.fetchall()

对于第二种类型,使用commit来提交修改:

1
cnx.commit()

3.4 额外的sql语句知识

3.4.1 条件查询

使用where,可以根据条件查询得到对应的数据,如:查询用户名为“李四”的人的密码:

1
select password from usertable where username = "李四"

可以用and连接两个条件,如:查询用户名为“李四”同时年龄大于20的人的密码:

1
select password from usertable where username = "李四" and age > 20

3.4.2 使用数据库内置函数

数据库中有一些内置的函数,可以在sql语句中使用

  • count(): 统计查询结果的个数,如:select count(username) from usertable
  • max()、min(): 计算查找结果的最大、最小的一个返回,如:select max(age) from usertable
  • length(): 计算查找的字符长度,如:select username from usertable where length(username) < 6
Compartir

cython介绍

使用cython加速慢如蜗牛的纯python代码。

What’s Cython

官方解释:Cython is Python with C data types,翻译过来就是Cython是可以使用C数据类型的python。在实际使用中,Cython可以让你在纯python基础上,使用C语言的数据类型定义python的数据,然后将python
代码转成C代码,再将其编译为动态链接库,在其他python程序中可以像其他库一样直接import编译的Cython模块。

使用Cython的的优点是:

  1. 能极大的加快程序的运行速度
  2. 能使用绝大多数python的第三方库
  3. 方便使用C函数和C库

但是为了使用它,需要修改原始代码,虽然修改量不算很大。

基本使用方法

安装Cython

Cython和python的其他第三发库一样,既可以使用pip等工具自动安装即可,也可以在官方下载安装文件手动安装。如果是使用的Anconda,那其中已经包含Cython了。

必要文件准备

Cython的使用和普通的第三方库可以直接在代码中import后就可以使用不一样,它是于pyinstaller、swig这种工具类似,除了需要在代码需要修改,还需要一些额外的操作。首先就是需要有两个python文件:

  1. 从原始的python代码修改的加入了C数据类型申明的“.pyx”文件,这就是我们需要加速的python代码文件
  2. setup.py文件,用于确定转换的一些配置选项,并启动Cython转换

.pyx文件的写法

.pyx文件由原始的.py文件而来,不过原始python代码也可以不做任何修改只把后缀名从“.py”变为“.pyx”,但是这样无法提高效率了。将.py文件改写成.pyx文件的主要操作就是把原有的python动态类型在使用前,先用C语言的数据类型加以声明,声明时使用“cdef”作为前缀关键字

如使用整型变量和整型数组时先声明如下:

1
2
cdef int n, i, len_p
cdef int p[1000]

在使用numpy的代码中,需要注意numpy的类型声明,下面为一个比较完整的函数改写,原始代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import numpy as np

def naive_dot(a, b):
if a.shape[1] != b.shape[0]:
raise ValueError('shape not matched')
n, p, m = a.shape[0], a.shape[1], b.shape[1]
c = np.zeros((n, m), dtype=np.float32)
for i in xrange(n):
for j in xrange(m):
s = 0
for k in xrange(p):
s += a[i, k] * b[k, j]
c[i, j] = s
return c

改写后为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import numpy as np
cimport numpy as np
cimport cython

@cython.boundscheck(False)
@cython.wraparound(False)
cdef np.ndarray[np.float32_t, ndim=2] _naive_dot(np.ndarray[np.float32_t, ndim=2] a, np.ndarray[np.float32_t, ndim=2] b):
cdef np.ndarray[np.float32_t, ndim=2] c
cdef int n, p, m
cdef np.float32_t s
if a.shape[1] != b.shape[0]:
raise ValueError('shape not matched')
n, p, m = a.shape[0], a.shape[1], b.shape[1]
c = np.zeros((n, m), dtype=np.float32)
for i in xrange(n):
for j in xrange(m):
s = 0
for k in xrange(p):
s += a[i, k] * b[k, j]
c[i, j] = s
return c

def naive_dot(a, b):
return _naive_dot(a, b)

上面要注意如下几点:

  1. 使用cimport导入numpy和cython
  2. 导入cython时为了调用其中的boundscheck和wraparound方法关闭边界检查,这个可选。
  3. 有返回值的函数定义没有了def,为了保证外部能使用,需要写个接口函数
  4. 函数的参数不需要“cdef”的前缀
  5. numpy数组的声明需要确定形状和元素类型

setup.py文件写法

最简单的setup.py文件内容如下(仅仅指定了需要转换的.pyx文件):

1
2
3
4
5
6
from distutils.core import setup
from Cython.Build import cythonize

setup(
ext_modules = cythonize("helloworld.pyx")
)

稍微复杂的(加了numpy使用的):

1
2
3
4
5
6
7
8
9
10
11
12
13
from distutils.core import setup, Extension
from Cython.Build import cythonize
import numpy
setup(ext_modules = cythonize(Extension(
'dot_cython',
sources=['dot_cython.pyx'],
language='c',
include_dirs=[numpy.get_include()],
library_dirs=[],
libraries=[],
extra_compile_args=[],
extra_link_args=[]
)))

具体写法需要参考官方文档。

转换流程

在命令行中执行如下命令,就可完成转化和编译:

1
python setup.py build_ext --inplace

完成后,会生成两个文件:.c文件和库文件(Windows后缀为.pyd,Linux后缀为.so)。在Windows中生成的库文件不要修改文件名,不然会无法使用。在使用时直接在python代码中欧个import库名就可以了。

在jupyter notebook中使用

在jupyter notebook中使用Cython会简单很多,因为jupyter notebook自动帮你执行的转化的操作,但是原始代码还是需要参照Cython的要求来修改。

使能Cython

在jupyter notebook中,使用单独的一个cell,写入:

1
%load_ext Cython

使用cython编译

在一个cell的最前面,写入:

1
%%cython

那个当前的cell中的代码就会使用cython来编译

使用不同cell中Cython数据对象

需要注意的是,在jupyter notebook中每个cell是独立的,在使用Cython的不同cell中,要用到其他Cython编译的cell中的数据对象,需要import:

1
from __main__ import xxx

注意点

  1. numpy数据引用,用[,]比[][]效率要高,普通的使用中对比不是特别明显,但是在cython编译循环语句中使用时差距就会变得非常大,甚至相差百倍: 对比二维数组赋值一个0.1s,一个0.002s
  2. 在cython中,有定义类型的数据和没有定义类型的数据做运算的时候,会严重拖慢速度完全起不到提速的效果,一定要对每个有参与计算的类型对象声明类型
  3. 在符合规范的情况下,cython编译的结果比纯python(不包含优化的第三方库)要快400多倍
  4. 一般n,m,k = img.shape 需要写成 n,m,k = img.shape[0], img.shape[1], img.shape[2] 才能编译成功
  5. numpy赋值时new_img = np.zeros((rgb_img.shape[0],rgb_img.shape[1],rgb_img.shape[2]), dtype=np.uint16)+256,拆成new_img = np.zeros((rgb_img.shape[0],rgb_img.shape[1],rgb_img.shape[2]),dtype=np.uint16)和new_img += 256两部分效率要高
  6. cython中使用numpy的数组的并行操作效率极差,要用for循环代替
Compartir

B站新番信息爬虫设计

B站番剧播放数据

爬取B站所有正版番剧的视屏信息,当前目标需要获取如下基本数据:

  • 总的播放量、追番数、弹幕量、总集数
  • 单集的播放量、追番数、硬币数、分数
  • 放送年月和时间
  • 番剧Tag
  • 制作地区
  • 声优

以下为设计方案的主要流程。

1. 番剧查找

通过如下API接口,查找不同年份、季度的新番列表:

1
https://bangumi.bilibili.com/web_api/season/index_global

通过制定如下参数来设置引索:

1
2
3
4
5
6
7
8
9
10
11
12
data  = {
'page':0, # 第几页
'page_size':20, # 每页的数量
'version':0, # 类型: 全部 正片 剧场版 其他 [0 - 4]
'is_finish':0, # 状态: 全部 完结 连载 [0 2 1]
'start_year':2018, # 时间: 全部 某一年 [0 xxxx]
'tag_id':'', # 风格
'index_type':1, # 排序方式: 更新时间 追番人数 开播时间 [0 - 2]
'index_sort':0, # 排序类型: 递减 递增 [0 - 1]
'area':0, # 地区: 全部 日本 美国 其他 [0 2 3 4]
'quarter':0 # 季度: 全部 1月 4月 7月 10月 [0 - 4]
}

直接使用url可以得到json数据格式如下:

bangumi index

解析json数据可以得到番剧的一些基本信息,如番剧名、放松日期、和season_id、完结信息、总集数。

2. 获取总集数据和单集的aid

为了得到更多信息和数据,我们需要利用番剧的season_id。分析网站的JS源码,我们能够找到网页前端使用了如下接口,来向服务器请求获取番剧的详细信息:

season infor api

API格式为(中间season_id用实际值代替):

1
http://bangumi.bilibili.com/jsonp/seasoninfo/season_id.ver?callback=seasonListCallback

得到数据如下:

season infor

解析得到详细的番剧信息,另外一个重要的就是单集番剧的aid。

3. 获取单集番剧的信息

通过aid获取单个视屏的信息,这个接口在网上使用的比较广泛(xxxxxx换成实际的aid值即可):

1
https://api.bilibili.com/x/web-interface/archive/stat?aid=xxxxxx

得到json数据如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"code":0,
"message":"0",
"ttl":1,
"data":
{
"aid":19801429,
"view":985,
"danmaku":8,
"reply":8,
"favorite":522,
"coin":10,
"share":9,
"now_rank":0,
"his_rank":0,
"no_reprint":0,
"copyright":2
}
}

3. 整合获取数据存入本地

当前使用csv文件保存

github仓库:https://github.com/HarryMei/Spider中的bilibili_spider文件夹

Compartir

搜索算法整理

前言

最近研究路径规划问题,看了一大堆的算法:爬山算法、模拟退火算法、深度优先搜索、广度优先搜索、Dijkstra算法、Floyd算法、A星算法、prim和kruskal算法等等等等。看得太多感觉头都有点大了,所以希望乘着这个机会把这些零散的算法整理一下。

不同类型的搜索

一直以来,我就没有理清楚过这些算法之间的关系,今天仔细分析,才发现原来这些算法都有一个共同的性质,那就是搜索。不过很快我又发现,我说了等于白说,因为好像绝大多数问题都是能看做是搜索问题,只不过他们搜索的对象不一样,搜索的目标不一样。所以我暂且我就以搜索为线索,从搜索对象和搜索目标这两个角度来分析。

1. 不同对象的搜索

从搜索对象来看,我们发现针对不同的搜索对象其难易度是天差地别。我们将搜索对象抽象成我们常用的数据结构来分析:

  • 线性表:由于结构简单、所以搜索算法也简单,特别是有序的线性表,搜索效率会非常高;不过我们也要看到在存储结构上的差异,使得不同的线性表搜索的方法有所差异。如:有序的数组可以用二分查找,但是链表就只能顺序搜索了。
  • 树:树的结构比线性表要复杂,所以搜索的方法也开始多而复杂起来,笼统来讲树的搜索可分两类:深度优先和广度优先。树的结构虽然复杂,但是由于其结构的灵活性和规整性,它能够综合顺序存储线性表和链式存储线性表各自的优点。因此前人发明了很多特殊结构的树,特别是平衡查找二叉树。这些特殊结构的二叉树在搜索时也可以采用通用的深度优先和广度优先,但是结合它们自身结构的特殊性质能达到更优秀的性能。
  • 图:在基本的数据结构中图是最复杂的,图又有有向图、无向图、带权图等等之分。由于图也可以看做树的推广所以深度优先和广度优先也是图的通用搜索方法,但是在结构复杂的图中这种通用的方法往往效率太低,所以才出现个各种各样的图搜索算法,当然他们的搜索目标也可能不一样。

另外还需要额外提及的一类搜索算法,这类算法通常不关心被测对象的特殊结构,而是以最通用目标去设计,这类算法通常被归到人工智能领域。其中上面提到的基本的深度优先和广度优先搜索就属于这一类,所以所有类型的树所有的种类图都能使用它来搜索。除了这两种最基本搜索外,还有以下两种比较常用的算法(其实属于这一类的还有很多,这里就直选两种简单的)。

  • Hill Climbing(爬山算法):爬山算法是一种非常简单的贪心算法,该算法每次从当前解的临近解空间中选择一个最优解作为当前解,直到达到一个局部最优解。

  • Simulated Annealing(模拟退火算法):模拟退火算法对爬山算法进行了一定的优化,它除了向领域空间的更优解移动,也能提供一定几率向更差的领域空间去移动,这样能够让算法有机会跳出局部最小值。这个向更差的地方移动的概率就是模拟退火后物体温度降低的变化情况(e的指数下降),当温度越低,向更差地点移动的概率越小,温度低于下限结束搜索。模拟退火算法是一种随机算法,并不一定能找到全局的最优解,可以比较快的找到问题的近似最优解。

2. 不同搜索的搜索

从实际问题来看搜索的目标太多了,不同的问题有不同的搜索目标,所以我就将其分类为几种常见的类型(也许不严谨但是很典型):

  • 搜索特殊个体:如最大值、最小值等等
  • 搜索特殊结构:如特定结构的字符串搜索
  • 搜索特殊关系:如图的最短路径、图的最小生成树

从以上可以看出图的搜索是最多样、最复杂的,但是在实际的问题中的对象往往都是以图为模型很复杂的结构,所以下面对图的各种搜索算法做详细的讨论。

图的各种搜索算法

图的复杂性在与它其中的每一个元素(端点)与其他各个元素之间的链接关系(边),因此在图的搜索中单独搜索图中的端点是比较单一,但是只要和边以及其他端点联系起来就变得丰富多彩起来。

1. 最短路径

最短路径问题是图在应用最常见的问题之一,最短路径问题是在图两个端点的路径搜索的基础上,加上了一个最短长度的约束。最短路径的求解有很多经典的算法,不过它们也有各自的使用条件。

  • Dijkstra算法(迪杰斯特拉算法):能够求解单源最短路径,要求边的权重不能为负数,一般具有O(N2)的时间复杂度。Dijkstra算法思路是:利用贪心策略,从起点开始,向外搜索,一次搜索选择整体路径最短的端点作为新的搜索点,同时更新以搜索过的节点到起点的最短路径(在搜索过程中会依次比较,各个以搜端点一直记录当前到起点的最短路径),直到找到终点或搜索完所有端点。
  • Floyd算法(弗洛伊德算法):能够求解多源最短路径,多源最短路程是求任意两个端点之间的最短路径,可以正确处理负权边的问题,时间复杂度为O(N3),空间复杂度为O(N2)。Floyd算法是典型的动态规划,它的思路是:从任意节点i到任意节点j的最短路径不外乎2种可能,1是直接从i到j,2是从i经过若干个节点k到j。所以,我们假设Dis(i,j)为节点u到节点v的最短路径的距离,对于每一个节点k,我们检查Dis(i,k) + Dis(k,j) < Dis(i,j)是否成立,如果成立,证明从i到k再到j的路径比i直接到j的路径短,我们便设置Dis(i,j) = Dis(i,k) + Dis(k,j),这样一来,当我们遍历完所有节点k,Dis(i,j)中记录的便是i到j的最短路径的距离
  • Bellman-Ford算法:和Dijkstra算法一样处理单源最短路径问题,但是它能够解决负权边的情况,采用动态规划(Dynamic Programming)进行设计,实现的时间复杂度为 O(VxE),其中 V 为顶点数量,E 为边的数量。其算法思路为:对每一个顶点v,设置变量dis(v),意义是源点到v的最短路径。对每一条边做v-1次松弛操作,所谓松弛操作:对于边(u,v)如果dis(v)>dis(u)+a(u,v),那么dis(v)=dis(u)+dis(u,v)。

另外还有SPFA算法、拓扑序列算法就不都列举了。在最短路径搜索中还有一类算法式启发式的,最典型的就是A*了。

  • A*算法 : 所谓启发式,不属于问题直接的逻辑推理,而是利用经验和其他信息来探索和尝试来解决问题。A*也可以被归到人工智能搜索的领域,不过它一般用于地图搜索居多(满足欧式几何),对于一般的图则需要更强的条件来约束,启发式的方法才能有效,搜索到的路径才能保证是最短路径。A*的启发方法是利用地图信息找到一个估计当前点到到目标点的路程函数,同时记录当前点到起始点的距离,用两者之和作为当前最优估计,从而依次找到目标点。所以总体看来,A*算法的难点在于如何选择启发式函数,通常最简单的地图搜索的启发式估计函数可以直接定义为当前点坐标到目标点坐标的直线距离。

2. 最小生成树

图的最小生成树为题是指:在原图中去除多余的边但能保持所有结点连通的最少的边的构造,新的图是原图的子图。最小生成树可以用kruskal(克鲁斯卡尔)算法或prim(普里姆)算法求出。

  • kruskal算法(克鲁斯卡尔算法):此算法可以称为“加边法”,初始最小生成树边数为0,每次选择最小的边,将所选边和其顶点加入到最小生成树的边集合里,然后选择次小的边加入,依次按边的权重大小选择,直到覆盖到所有的顶点。

  • prim算法(普里姆算法):此算法可以称为“加点法”,算法从某一个顶点s开始,选择与其相连接的最小的边,将边的另一个顶点加入最小树,然后以这个顶点为基点找与其相连的最短边,这样逐渐覆盖整个连通网的所有顶点。

Compartir

双目视觉标定2——张氏标定法

前言

上一篇文章说明了其各种标定参数的作用和原理,现在看看这些参数的实际标定方法。对于双标定,我们要获取的参数是四类:内参、外参、畸变参数、立体校正参数,其中畸变参数的计算需要结合内参和外参共同优化得到,而立体校正参数可以直接由内参和外参直接计算得到,所以虽然是双目相机,但是每一个镜头各自的内参和外参的获取仍然是标定的重点。

内参和外参的数学表示

前面我们分析过相机的内参和外参的物理意义和计算原理,不过当我们计算时,为了保证其数学形式的简洁,通常使用矩阵来表示。从三维世界的坐标点到数字图像中的坐标点,其映射关系用矩阵方法可以表示为:

X是世界坐标系中的点坐标,m是数字图像中点的坐标,使用的是齐次坐标形式,K是相机的内参矩阵,R是相机相对世界坐标系的旋转矩阵、T是平移矩阵,$s_0$为缩放因子。

1. 内参矩阵

相机的内参包括:两个方向的像素密度、光轴在成像平面的中心位置、相机的焦距,从上面也可以看到我们在计算中使用的是内参矩阵(Camera Intrinsics):

其中$f_x$为在x方向焦距长度上拥有的像素个数,$f_y$为在y方向焦距长度上拥有的像素个数,称为摄像机在x轴和y轴方向上的尺度因子,使用它可以让内参矩阵形式更加简洁。它们可以由针孔模型和图像数字化采样直接推出:

上式中$a_x$和$a_y$分别表示x方向和y方向上的像素密度,f表示相机的焦距;另外$c_x$为中心在x轴上的像素偏置,$c_y$为中心在y轴上的像素偏置。至此内参矩阵就讲所有相机内部参数都包含进去了。

但是在实际中,由于制造工艺的限制,传感器上横向的像素和竖向的像素排列不是绝对的垂直,所以在内参中我们需要加入一个倾斜因子s:

不过需要注意的是,由于加入的倾斜因子,内参矩阵中个元素与相机各个参数的对应关系都要将倾斜因子考虑进去。

2. 旋转和平移的齐次表示

R和T是相机的外参,对应相机相对世界坐标系的旋转和平移,这里可以直接用刚体的坐标表换来计算,旋转用旋转矩阵R表示,平移用平移向量T来表示。不过有时候使用齐次矩阵统一旋转矩阵和平移向量能够让计算更简洁方便。

张正友标定法

1. 简介

相机标定主要有传统标定方法和自标定方法两类:

  • 传统标定方法需要标定参照物,参照物的参数已知,然后分析拍摄到的参照物图像,求得相机参数,传统方法操作相对复杂,但精度较高
  • 自标定方法不依赖于标定参照物,只领用摄像机的运动约束或者环境的约束来进行标定,自标定方法灵活方便,但由于是非线性标定,精度和鲁棒性都不高

张正有平面标定法也称张氏标定法,属于传统的标定方法。张氏标定只要求从不同的角度对同一平面拍摄2幅以上的的图像,就可以求出摄像机的内外参数。由于其平面模板(棋盘格)制作简单且不需要知道平面移动的具体的位置信息,即有穿透方法的高精度的优点、又相比其他传统的标定法要简单灵活,因此在业界得到了广泛使用。

标定所用的棋盘格如下,一般会将其打印出来贴在平面木板上当做标定物:

棋盘

2. 单应性映射

由于我们的标定物是棋盘平面,所以棋盘平面的成像实际上是单应性映射(Homography)。那什么是单应性映射?单应性变换是同一物体在不同平面的投影,那么一个投影平面到另一个投影平面的映射关系就是单应性映射。由于单应性映射是在普通的线性变换的基础上增加了额外的约束条件,所以单应性映射会更简单:由于两个被物体都是平面,所以我们可以把变换前后的两个坐标系都建立在平面之上,那么前后两个平面的坐标都可以只用两个坐标值表示。两个平面的单应性映射关系用单应性矩阵来描叙,在张正友标定法中我们直接求得的是单应性矩阵。

单应性映射

单应性映射矩阵方程为:

可以看到,前后连个平面的点都只用了两个坐标值的齐次坐标表示,其中H为3x3单应性矩阵,9个元素值全部未知;不过由于是齐次方程,通常将H提取一个比例系数,让其最后一个元素值为1,只需求得8个未知数即可。每一个点对对应一个矩阵方程,一个矩阵方程对应三个方程组,但是由于是齐次方程,需要去除一个比例系数,实际上一个单应性矩阵方程只能提供两个有效的方程组,因此为了求解H,所以至少需要四个对应点。

2. 从单应性矩阵计算相机内参与外参

结合本文最开始的单个相机的成像坐标转换公式,使用单应性变换,则单应性矩阵为:

其中K仍然是内参,λ为比例系数,r1、r2、t为外参矩阵的一部分,计算将H拆解为(h1, h2, h3)的向量,取与r1和r2相关的方程,然后结合r1和r2的两个约束条件:

  1. r1和r2位正交
  2. r1和r2都为单位向量(模为1)

可以得到如下两个消除了r1和r2,用来计算内参K的方程组:

内参矩阵K有5个未知量(加入了倾斜因子),每一个单应性矩阵只能得到两个上面的方程组,所以至少需要3个不同的单应性矩阵,即相机需要拍摄棋盘格三种不同的姿态。

得到了内参矩阵后,外参可以直接通过以下公式推出(由之前的推导得到):

3. 径向畸变估计

张氏标定只关注了影响大的径向畸变,根据径向畸变模型,我们有联系畸变后的联系畸变后的坐标和畸变前坐标的方程。一般为避免解非线性方程组,我们使用多个图像多个角点,使用最小二乘法,以畸变前后的坐标差异最小化为目标,迭代优化。

4. 用最大似然估计优化参数

在上面推导的是理想的结果,在实际成像中会受到噪声的干扰,我们认为噪声服从高斯分布,所以在张氏标定中我们用高斯分布来拟合,然后用最大似然法估计内外参数的最优结果。

具体做法是:

  1. 求出理想的内参值
  2. 拍摄多幅棋盘图像,用理想的内参求出不同姿态图像的外参
  3. 用理想的内参和外参预测每个图像中的每个角点的成像坐标,用实际拍摄得到的坐标作为真值,可以得到每一个预测值在高斯分布下的可能的概率。
  4. 使用极大似然法,将所有的预测概率相乘得到似然函数
  5. 以理想内参为初值,迭代优化,让似然函数取得最大值

中间的数学推导我就不写了,简化后最终优化的函数如下:

上式需要让其取最小值,优化算法一般用Levenberg-Marquardt算法(一种介于牛顿法与梯度下降法之间的一种非线性优化方法)迭代求解。

总结

总结整个张氏标定过程包括如下步骤:

  1. 打印一张棋盘格,把它贴在一个平面上,作为标定物
  2. 通过调整标定物或摄像机的方向,为标定物拍摄一些不同方向的照片
  3. 从照片中提取棋盘格角点
  4. 估算理想无畸变的情况下,五个内参和六个外参
  5. 应用最小二乘法估算实际存在径向畸变下的畸变系数
  6. 极大似然法,优化估计,提升估计精度

参考

  1. A Flexible New Technique for Camera Calibration
  2. 相机的那些事儿 (三)参数标定
  3. 双目视觉的立体标定方法
  4. 摄像机模型与标定(4)——单应矩阵
  5. 张正友标定算法原理详解
Compartir

双目视觉标定1——原理

前言

双目视觉定位是目前机器(机器人)等领域中使用得非常广泛的视觉定位技术,双目视觉是模拟人的视觉系统利用两个不同位置的摄像头的视差来确定物体的位置。由于有需要采集两个摄像头的图像共同参与计算,所以双目相机装配要求高、标定也比单目复杂。相机的标定是定位的基础标定,本文是我学习双目视觉标定方法的总结文档。

为什么要标定

作为初学者,我们会有疑问,什么相机需要进行标定,标定到底有什么用呢?这是因为摄像机是将三维世界投射到二维平面的设备,从三维世界的场景到二维平面的成像图像,这两者的转化存在着一种映射关系,不同的相机映射关系不同,所以他们对相同场景所成像的图像也就不同,而标定的目的就是确定相机的这种映射关系。

总体来说,通过标定能够确定相机的内参、外参、畸变系数这三类参数,而这些参数就是确定相机将三维世界转为二维图像的最主要因素;而双目相机还要确认两个摄像头之间的位置关系,这是双目用于计算物体位置的前提。

相机模型

内参、外参、畸变系数这些东西是怎么来的?那我们首先要了解一下相机的成像原理和成像过程。相机的成像是一个物理光学现象,从光学基础来讲还是比较复杂的,但是在大多数时候我们只需要关注它最核心的部分,通过构建一些简单的模型来描叙问题的核心,从而近似的还原真实的物理现象。在相机模型中,我们两类最重要的模型就是:成像模型和畸变模型。

1. 成像模型

在成像模型里针孔模型(就是我们通常所说的小孔成像)是最常用的,它简单但有很好的描叙了相机成像的本质。下图为针孔成像示意图:

针孔成像示意图

在相机中透镜充当了小孔的作用:

凸镜成像

在相机的成像平面上放置的是光敏传感器,这些传感器会将成像平面上连续的图像进行采样,这样最终得到的是离散化的图像。在这里就会涉及到相机的内参:成像平面两个方向上传感器单位尺寸的像素密度、成像的中心位置和焦距,这5个参数(像素密度和中心坐标都有两个方向的值)就是我们标定中要确定的主要的内参。

内参一般在相机生产后就比较固定,相对而言外参则是不固定的,因为外参表示的是相机自身在三维空间中相对于给定的世界坐标的位姿,这个位姿信息包括6个参数:三个位置参数、三个姿态参数,实际上两者就是一种线性映射关系。

2. 畸变模型

实际相机用透镜的聚光特性使得投影在成像平面上的光强度更大,成像质量会更好,但是也是由于凸镜的存在也会对图像产生不好的影响,如图像的畸变。

畸变类型和原因,主要有以下两点:

  • 透镜自身的形状对光线传播的影响——导致径向畸变
  • 在机械组装的过程中,透镜和成像平面不平行——导致切向畸变

在径向畸变可分为两类,如下图所示,左边为枕形畸变、右边为桶形畸变:

径向畸变

可以看到径向畸变只于图像到中心的距离有关,距中心越远畸变越大。对于径向畸变我们使用与中心距离r,相关的高次多项式模型来拟合,其中$r^2 = x^2 + y^2$:

切向畸变如下示意图:

切向畸变

我们新增参数p1、p2建立如下方程来拟合:

统一两种畸变:

上面统一的畸变公式,并不是将第一种畸变的公式整体代入第二个畸变公式,而是在无畸变图像上再增加两种畸变分量,而每种畸变分量只与无畸变图像相关,所以相互之间不影响,只不过径向畸变分量是乘性的而切向畸变是加性的。

根据畸变模型的公式,我们可以看到畸变系数主要是5个:k1、k2、k3、p1、p2,当然在实际中我们可以灵活选择,如畸变较小的镜头可以去掉高次项。

3. 相机成像总结

总体上,为了用数学描叙相机的成像,即将三维世界的物体转化为数字的图像,我们一般将其分为以下四个步骤(下图源于知乎):

相机成像

其中刚体变换需要用到相机的外参,透视投影需要用到针孔模型和相机内参中的焦距参数,畸变校正很显然需要使用两种畸变模型和对应的畸变系数,最后数字化图像则需要使用相机内参中剩余参数。

双目定位几何模型

成像模型和畸变模型是对单一镜头成像分析,而对于双目摄像头来说,它在成像的基础上还需要定位,因此需要建立一个能够计算物体位置的几何模型。

双目视觉的是模拟人眼,通过视差来计算物体的距离,从而得到物体的空间坐标信息,其几何模型如下图所示:

双目视觉几何模型
双目视觉几何模型

从上图可以看到,双目立体视觉的原理很简单,通过同一物体在两个图片中的视差和两个镜头光轴间的距离(基线),利用相似三角形就可以简单求出物体的距离。但是我们也要看到,此计算的基础是:两个摄像机的成像平面在同一平面上,当然为了计算简单,我们还希望两个相机的中心能水平对齐,这样我们计算视差只需要用到两个图像单个坐标方向上值

但是在实中是不存在完全共面的两个镜头的,所以在双目相机的标定中,我们除了确定单个相机的内外参数、对图像的畸变进行校正外,还需要对进行立体校正,立体校正的目的就是将实际中非共面对准的两个图像,校正到共面对准,注意这是对图像做的,因为相机镜头已经固定了。

立体校正

可以看到,为了将两个图像调整到同一平面且中心水平对齐(外极线校准),我们需要知道两个摄像头的相对位置关系,具体来讲就是:旋转矩阵R和平移矩阵T,然后对应将图像做旋转和平移即可。获取立体校正的参数就是立体标定要做的工作,实际上只是对每个镜头进行前面单目标定,用单目标定的参数计算得到立体校正的参数。

以标定物体上的点为参考,可以分别得到左右相机的参考坐标系的坐标表达式,综合两者可以得到从左相机坐标系到右相机坐标系的转关关系,(推导下式其实是很简单的,但是网上的一些资料很少写清楚下面这个公式,有些写出来了也是不对的):

其中,Rr、Tr、Rl、Tl都是通过单目标定得到的参数,这样我们就可以直接求出立体校正的参数。

到此,整个双目视觉需要标定原因、各种标定的原理就讲解完成。

参考

  1. 《视觉SLAM十四讲》——5.1 相机模型
  2. 相机的那些事儿 (二)成像模型
  3. 双目视觉几何框架详解
  4. 双目视觉的立体标定方法
Compartir

深度学习实践

前言

从开始接触mxnet的gluon教程开始,我就一直跟着官方教程自己动手做,也参加了kaggle的两个竞赛项目,到目前为止对训练调试过的神经网络是有更直观的了解和认识,对这些网络的优化和调试也多多少少有了一些的经验,所以乘着还没有忘记,赶紧总结记录出来(到目前为止做到的都是监督学习内容,所以本文也是以监督学习为对象说明)。

使用神经网络训练的步骤

这些天看过不少的深度神经网络的设计,也使用mxnet的gluon或者是直接手动编写过一些相对简单的网络来训练一些标准的数据集,体会最深的一点就是不管多么复杂的网络,变化的部分只是网络模型的本身,其他的部分做下来基本上都是属于同一个套路。

实现一个使用神经网络训练数据的项目,一般会有如下步骤:

  1. 整理、导入数据,这是准备工作
  2. 定义网络结构和代价函数,这是算法的核心
  3. 训练,将数据输入网络中,循环迭代
  4. 测试,使用训练好的模型测试实际的数据

以下会对上面四个步骤分别说明。

数据整理和导入

虽然当我们在编写代码时,这只是准备性质的工作,但是在处理实际的机器学习、深度学习的问题时,数据才是核心:不同类型的数据决定了你采用哪一种算法会更好,而在实际训练时,数据的质量直接关系到最终结果的好坏。

深度学习需要大量的数据,然而收集大量的数据是一件很困难的事情,而这也是以前制约深度学习发展的一个很重要的因素。所幸的是,在大数据的今天,数据的收集和存储已经方便很多,对我们普通学习者而言更有利的是,前人已经整理出了不少标准的数据库提供我们使用和研究,如我所用过的:MNIST(手写数字图像)、FishionMNIST(服装图像)、CIFAR-10图像数据集。

在数据整理和导入时,有以下几点要注意。

1. 图像数据的整理

对于图像数据在整理时,训练的数据为了方便导入,一般都是根据类别新建文件夹,各自存放各自的的类型。像mxnet就很提供的图像文件夹导入函数接口,可以很方便的导入图片数据和标签。

2. 一些预处理方法

对于非图像类的原始数据,可能有:无效数据Nan,数据属性取值范围相差过大等等问题。这是我们往往需要做预处理,这就属于数据处理方面的内容了,很多数据处理的方法可以使用。

  • 对于无效数据:我们可以自己差值补全(如插平均值),如果属性Nan值过多,直接删掉也可以这一列
  • 对于属性值范围差异大的问题:对数据做归一化(或者说是标准化)处理

在归一化处理时:对于连续数值型的数据归一化,通常选择减平均值除以标准差的方法——(x-x.mean())/x.std());在处理非数值非连续型的数据时,直接使用one-hot coding进行编码将一个属性变为多个0、1的属性,这样做的原因是可以统一用欧氏距离来评价数据点位置的远近。

定义网络模型和代价函数

目前,我所接触到的神经网络层类型有两种:全连接层或卷积模块,我们定义网络就是将这些层或者模块叠加组合起来。在最简单的单层全连接网络的时候,我有用python手动编写,这在有提供矩阵运算的编程语言中实现是比较简单:

  • 直接用矩阵运算的线性方程组表示网络
  • 根据问题定义代价函数
  • 编写使用微分法求近似偏导函数
  • 编写梯度下降(或随机梯度下降)函数

以上,虽然后面两点都是优化的内容,但是我还是觉得把这些核心算法放在一起比较好。

1. 使用深度学习框架的原因

单层全连接网络可以简单的手动实现,主要还是网络结构简单,求偏导容易。复杂的网络结构就不方便手动编写了,最好是借助各种深度学习框架,这些框架都有自动求导功能,这些基于计算图的求导更准确,且借助BP算法能够计算更深层次网络节点的偏导。另外一点就是复杂的模型,定义其本身就很麻烦,如深层的卷积神经网络,每一层的权重参数、卷积核的大小都需要自己指定这些参数且相互之间还有影响,输入的图片经过这一层一层的网络,形状不断的在改变,而这些网络的参数本身就很让人混淆,再经过一系列变换,就更难确定了。

2. 代价函数

代价函数(Cost Fuction)也可能被称为损失函数(Loss Function),是我们衡量模型预测准确性的衡量的方法,虽然对于不同的问题类型和算法模型可能需要考虑设计不同的代价函数,不过实际代价函数除了需要能够表示预测和真实之间的差距外,还需要考虑要方便做最小化优化,因此常用的代价函数也就并不多。对于回归问题,最常用的代价函数就是均方差了,而对与分类问题最常用的是softmax交叉熵。

3. 梯度下降和随机梯度下降(SGD)

说起优化算法,梯度下降应该算是最简单最经典的吧,虽然有更高级的优化算法存在,但是梯度下降仍然是得非常广泛。不过在实际中梯度下降一般都是以随机梯度下降的形式来使用的。随机梯度下降是梯度下降应用的一种形式,相比普通的梯度下降算法以整个数据集作为输入来作为优化对象(批梯度下降),他每次迭代只在数据集中随机选取一部分输入来优化,它的收敛会更快。

4. 正规方程

当然优化的方法,除了像梯度下降这样逐步迭代,一步一步逼近到最有解,还有可以用解析法直接求解线性方程,不过在基本上都是非线性的深度学习中就不适用了,即使在线性回归这样的简单机器学习问题中,我们也得考虑:输入矩阵是否可逆、求逆计算量大小的问题。

训练与测试

1. 泛化能力的测试

基本上所有的机器学习和深度学习都需要考虑一个问题,那就是模型的泛化能力,它是以判断一个模型好坏一个最重要的标准。为了测试模型的泛化能力,我们通常会将整个过程分为两个步骤:训练、测试。训练是指在训练集上不断循环迭代的使用优化算法将代价函数结果的值不断降低,测试是用训练好的模型测试一批已知结果的数据,检查模型的精度,我们希望模型在测试中的结果能够和在训练中的结果尽量的接近。

训练的数据和测试的数据集通常是不重合的,所以我们需要将原始的输入数据集划分为:训练集和测试集。这其中有不少方法,不过最基本最简单的就是,从原始数据集中按比例随机采样出一部分作为测试集用(通常训练集占绝大多数,测试集占小部分)。在花费时间可以接受的情况下,也可以采取K折验证法,即把原始的数据集随机分成相等的K份,每次取一份作为测试集,其他作为训练集,重复K次让测试集能够选完所有的份数。当然这样时间的开销就比之前多了K倍。

2. 过拟合

导致模型泛化能力弱的很大一部分原因来自过拟合,过拟合的现象就是,模型在训练集上表现非常好,但是在测试集上表现很差。导致过拟合的原理比较多,如:原始数据集量太小,模型过于复杂等。过拟合的情况非常常见,基本上调过机器学习/深度学习算法的可定会遇到它,不过对于过拟合,我们也有很多方法来降低它的影响。

  • Data Augmentation:数据增强,人工增加训练集的大小. 通过平移, 翻转, 加噪声等方法从已有数据中创造出一批”新”的数据
  • Regularization:正则化,通过在Loss Function 后面加上正则项可以抑制过拟合的产生,常见的如L0, L1, L2,他们能够减小大多数特征的影响来,而将影响集中到少数特征上 缺点是引入了一个需要手动调整的hyper-parameter
  • Dropout:这也是一种正则化手段. 不过跟以上不同的是它通过随机将部分神经元的输出置零来实现

3. GPU并行训练

当模型复杂、数据量大的时候,你会深深感受到GPU的可贵,在做cifar-10的实验的时候,我用CPU训练一次迭代就要半个多小时,换上GPU时间立刻缩短到1分半钟。能够很方便的使用GPU、甚至多GPU并行训练,这也是使用深度学习框架很大的优点。

Compartir

初识Deep Learning

前言

“学而不思则罔,思而不学则殆”,最近一直是一股老儿的接触新知识,我觉得有必要做一下梳理了,再不消化一下,也许就得撑死了。于是借助《Deep Learning》一书的引言部分,对最近看到的人工智能相关概念做一个小结。

从人工智能到深度学习

赶上这两年的AI热,我最近也一直在看这方面的书籍和资料,面对当前五花八门的新概念新名词,是既然让人既兴奋又困惑,兴奋的是自己能够了解到当前计算机人工智能领域最新的进展和进步,困惑的是这些看似相关性不大的概念名字是怎么和人工智能联系在一起的呢?要了解这些,有必要粗略回顾一下人工智能的发展历史。

实际上,创造能自主思考的机器是人类很早的梦想了,不够那时候还是只是一个美好的幻想,但是计算机的出现让这样的幻想有了实现的可能。

作为软件人员,我们知道,一直以来计算机软件的设计思路都是,开发人员需要深入理解要解决的问题,将该问题设计出一套规则和逻辑来解决,而早期的人工智能也正是沿着这种思想。这种基于知识和规则达到智能的方法,希望将世界的知识用形式化的语言进行硬编码,让计算机可以使用逻辑推理规则来自动地理解这些形式化语言中的声明,从而做出推断和决定。在《Deep Learning》一书中有提到基于人工智能的知识库(knowledge base)的Cyc项目尝试理解现实世界,但是将“Fred正拿着一个电动剃须刀”这样很简单情形推断出“Fred在刮胡子的时候是否仍然是一个人”结论。实际上对人来说,要设计出一套复杂的形式化规则能够像数学公式一样精确地描述世界来说太难了。

而其后,研究者们开始转向,他们希望AI系统能够自己从原始数据中提取模式、发现规则。从我个人的观点来看,这种思维方式转变是非常关键的,它让我们从一个传统的软件开发人员转向一个数据挖掘者。而这就是机器学习,机器学习在特定的问题领域很有用,因此在当前的被广泛的使用。然而,简单的机器学习仍然脱离不了人对原始数据的规整,机器学习所需要的数据输入时,需要人选取一系列能描叙实际情况的特征的集合来表示现实世界的情况,对原始数据表示的方法不同直接决定了后面机器学习的好坏。而面对现实世界的各种情况,机器学习是无法直接处理的。

然而,很多时候我们并不知道要选用哪些特征来表示原始数据会更好,为了解决这个问题,研究者们再了进一步,他们希望机器能自己挖掘表示本身,自己选择事物的描叙特征,这被称为表示学习。世界上表示学习也是通过机器学习的方法来学习表示,不过学习到的表示往往比手动设计的表示表现得更好,并且它们只需最少的人工干预,就能让AI系统迅速适应新的任务。

在一些环境复杂的系统中,要提取特征,需要高层次的抽象,在这种情况下简单的表示学习就没法做到的。在这里深度学习,多层抽象,先学习较简单的表示,然后用简单的表示来构建更复杂的表示。下图为《Deep Learning》中一个深层网络的示意图:

从以上可以看到,深度学习相比浅层的表示学习的“深”至少有两层含义:一是实际计算图层次变深,二是在学习的过程中,不同的层学习的对象有了明显功能的差异,高层的的网络能够对低层次的网络学到的结果基础上再进行学习。

至此,关于机器学习、表示学习、深度学习之间的关系,我们大致已经清楚,借助《Deep Learning》书中的维恩图能够更清晰的展示:

从上面的人工智能技术的发展,能够看到,深度学习是当前人工智能领域最前沿的技术。对于深度学习,在《Deep Learning》书中总结的很好:

深度学习是通向人工智能的途径之一。具体来说,它是机器学习的一种,一种能够使计算机系统从经验和数据中得到提高的技术。我们坚信机器学习可以构建出在复杂实际环境下运行的 AI 系统,并且是唯一切实可行的方法。深度学习是一种特定类型的机器学习,具有强大的能力和灵活性,它将大千世界表示为嵌套的层次概念体系。

学习心得

实际上,我接触机器学习已经有很长的时间了,但是只到最近才感觉自己刚刚入门,这些天里能够对这些概念有进一步的理解的原因,我想主要是一下两方面:

  • 第一是,对线性代数、概率论与数理统计等基本数学学科的补习
  • 第二是,真正自己动手对简单机器学习算法的编码实现和验证

这让我再次深刻理解到了,数学是各们学科、技术的基础,也知道了那些看似很高端很先进的技术其实不一定用了很深入的数学理论。对我这种数学基础稍差的人来说,针对性的补习一下数学的基础对机器学习/深度学习理解是有非常大的帮助,同时也要相信那些高大上的名词其背后的数学原因实际上是相对简单,所以不要一开始就被吓唬住了。

虽然,数学基础的补习让那些曾经困扰我许久的问题得到解决,但是直到我真正动手一步一步的实现一个最简单的算法的时候,我才感觉真正步入了机器学习/深度学习的大门。从最简单的线性回归,到softmax的多分类器,再到浅层前馈神经网络——多层感知机,基本上也是沿着前人曾经探索的道路前进,虽然大多都只是百来行python代码(使用框架模型后更少),但是却让我能够深入真正体会它的方法原理和实际性能以及不足和缺陷。

后面的学习方向

由于我主要的兴趣点在图像上,所以未来的学习方向应该会集中在各种卷积神经网路上。对我来说道路还很漫长,前面实现过的算法,基本上还是传统的机器学习的范畴,从卷积神经网路开始才慢慢踏入深度学习的领域。

Compartir