diff --git a/404.html b/404.html new file mode 100644 index 0000000..3c6f4a0 --- /dev/null +++ b/404.html @@ -0,0 +1,336 @@ + + +
+ +这段时间是我同时使用屏幕数量最多的时候,主屏幕写代码,副屏幕看网页;surface 和工控屏做直播推流;iPad 负责预览直播画面:
+几乎每天都在宿舍,买了本《明解 C++》一边学语言一边学算法。虽然算法已经忘得差不多了,但坚实的 C++
基础对我帮助很大,甚至我现在实习的主要内容就是 C++
开发。
学完了 C++
语言基础后,我开始意识到数据结构对编程的重要性。同时也有为考研做准备的打算,我开始每天往返图书馆,刷王道考研的 CS408 课程。
为了方便往返图书馆并尽可能满足我多屏幕的需求,买了一台可以 180 度开合的笔记本(ThinkBook 14)和一个立式支架,搭配 ThinkPad 的 GaN 100W 充电器和雷电线,只需 2 根线完成所有的连接。这样的好处是可以上面写代码下面看课程,同时使用外接键盘不会很突兀。
+春季学期结束后,我也放弃了考研的打算,开始往机器学习和 Pytorch 的方向学习。翻出以前买的西瓜书和南瓜书,对着 Github 上的开源笔记边看边学。由于宿舍桌子太窄了,放三块屏幕过于拥挤,于是将 15.6 寸的副屏收了起来。
+趁着 618 换了罗技的 master 3s 鼠标,手感确实比雷蛇的 click Pro 好很多。同时每天都坚持录视频,之前买的麦克风也派上了用场。
+随着 Pytorch 学习的深入,笔记本的核显已经无法满足需求。而此时矿潮已经开始逐渐褪去,随着 4090 的发布老显卡的价格开始走低,索性在咸鱼上淘了一张服务器版的 2080Ti:
+其 11G 的显存足以满足入门需求,同时后期还可以加焊 22G 显存,是一张性能和成长空间都不错的显卡。
+同时为了最大限度使用显卡,直接把机器刷成了 Ubuntu server,将之前的监控屏用来监控显卡状态。
+搞深度学习,真的是屏幕越多越好,有太多的资料和数据集看不过来:
+秋季学期重回宿舍后,开始迷上了 Minecraft 和泡茶,一杯茶一个种子就是一天。这段时间是最放松的时间,每天都在搭方块。
+开始实习后,第一次用上了 MacOS。但尴尬的是,工作用的编译和测试环境还是 Windows,所以基本上都是在 Mac 上面通过 SSH 远程 Windows 进行开发(VsCode 大法好):
+同时,我也负责服务器上的容器部署等相关工作,所以我有幸同时使用 3 大主流操作系统。
+适应实习的节奏后,晚上回宿舍的几个小时是每天最舒服的,因为可以不用管公司的事情专注自己喜欢的项目。
+趁着放假买了一块树莓派 4B 在宿舍折腾,初步搭了 Seafile 和 Gitlab。
+变量提供一个具名的、可供程序操作的存储空间。C++
中每个变量都有其数据类型,数据类型决定着变量所占内存空间的大小和布局方式、该空间能存储的值的范围,以及变量能参与的运算。
为了允许把程序拆分成多个逻辑部分来编写,C++
语言支持分离式编译(separate complication) 机制,该机制允许将程序分割为若干个文件,每个文件可被独立编译。
为了支持分离式编译,C++
语言将声明和定义区分开来。声明(declaration) 使得名字为程序所致,一个文件如果想使用别处定义的的名字则必须包含对那个名字的声明。而 定义(definition) 负责创建与名字关联的实体。
变量声明规定了变量的类型和名字,定义在此基础上,还申请存储空间,甚至可能会为变量赋一个初始值。
+
+
|
+
+
|
注意: 变量只能被定义一次,但可以被多次声明。因此在多个文件中使用同一个变量名时,需要多次声明,但只能有且仅在一个文件中定义。
+C++
的 标识符(identifier) 由字母、数字和下划线组成,其中必须以字母或下划线开头,没有长度限制,但对大小写敏感。
作用域(scope) 是程序的一部分,在其中名字有特定的含义,C++
语言中大多数作用域都以花括号分隔。
作用域能彼此包含,被包含(嵌套)的作用域称为 内层作用域(inner scope),包含着别的作用域的作用域称为 外层作用域(outer scope)。作用域中一旦声明了某个名字,它所嵌套着的所有作用域中都能访问该名字,同时允许在内层作用域中重新定义外层作用域已有的名字。
+
+
|
+
+
|
复合类型(compound type) 是指基于其他类型定义的类型,主要介绍引用和指针。
+引用(reference) 为对象起了另外一个名字,定义引用时,程序把引用和它的初始值 绑定(bind) 在一起,而不是将初始者拷贝给引用。
+++引用并非对象,相反的,它只是为一个已经存在的对象所起的另一个名字。
+
+
|
+
+
|
指针(pointer) 是“指向(point to)”另外一种类型的复合类型。与引用类似,指针也实现了对其他对象的间接访问,但指针还有很多不同点:
+
+
|
+
+
|
指针存放某个对象的地址,想获取该地址,需要使用 取地址符(&
):
+
|
+
+
|
++因为引用不是对象,没有实际地址,因此不能定义指向引用的指针。
+
指针的值(即地址)应属于下列 4 种状态之一:
+如果指针指向了一个对象,则允许使用 解引用符(*
) 来访问该对象:
+
|
+
+
|
++解引用操作仅适用于有效指针,无效指针无法解引用。
+
空指针(null pointer) 不指向任何对象,在试图使用一个指针之前代码可以先检查其是否为空。
+生成空指针:
+
+
|
+
+
|
指针和引用都能提供对其他对象的间接访问,然而在具体实现细节上二者有很大不同,其中引用本身并非是一个对象。定义引用之后,就无法令其再绑定到另外的对象,之后每次使用这个引用都是访问它最初绑定的那个对象。
+指针没有这种限制,给指针赋值就是令它存放一个新的地址,从而指向一个新的对象。
+
+
|
+
+
|
有时我们想定义这样一种变量,它的值不能被改变,这在程序运行过程中对于某些特定值非常有用。为了满足这一要求,可以使用关键字const
对变量的类型加以限定:
+
|
+
+
|
默认情况下,const
对象被设定为仅在单个文件内有效。当多个文件中出现了同名的const
变量时,其等同于在多个文件中分别定义了独立的变量。但当我们需要其在多个文件中保持一致时,需要在定义和声明前面都加上extern
:
+
|
+
+
|
与引用一样,也可以令指针指向常量与非常量。指向常量的指针(pointer to const) 不能用于改变其所指对象的值。要想存放常量对象的地址,必须使用指向常量的指针:
+
+
|
+
+
|
随着程序越来越复杂,其中使用的变量类型也越复杂,如何处理这些类型成为一个问题。
+类型别名(type alias) 是一个名字,它是某种类型的同义词。
+使用关键字typedef
定义类型别名:
+
|
+
+
|
使用 别名声明(alias declaration) 定义类型别名:
+
+
|
+
+
|
编程时常常需要将表达式的结果赋给变量,这就要求需要事先知道结果的类型。但是要做到这一点有时并不容易,因此C++11
引入了auto
类型说明符,它能自动分析表达式结果的类型。
+
|
+
+
|
使用auto
定义或声明多个变量时,所有变量的类型必须一致:
+
|
+
+
|
有时会遇到这种情况:希望从表达式中推断出要定义的变量的类型,但是不想用该表达式的值初始化变量——即只使用表达式的数据类型,不使用表达式的结果。
+因此C++11
引入了decltype
类型指示符,它的作用是返回操作数的数据类型:
+
|
+
+
|
内置的数据类型并不能满足所有的需求,因此c++
提供了自定义数据类型的方式:
+
|
+
+
|
和内置数据类型一样,自定义数据结构也能使用别名:
+
+
|
+
+
|
当我们在编写头文件时,会引入其他头文件,而在生产文件中,又会再次引入这些头文件。这样就导致一个问题,某些头文件被重复引入了。因此,在编写头文件时需要做一定的保护措施:
+
+
|
+
+
|
using
声明
+我们使用的库函数都有一个对应的命名空间,通常需要在声明或初始化变量时指定命名空间。为了简化这个操作,我们可以使用using
进行声明:
+
|
+
+
|
++头文件中不应该包含
+using
声明,这样使用了该头文件的源文件也会使用这个声明,会带来风险。
string
+标准库类型 string
表示可变长的字符序列,使用 string
类型必须首先包含 string
头文件:
+
|
+
+
|
string
对象
+初始化 string
对象的方式:
方式 | +解释 | +
---|---|
string s1 |
+默认初始化,s1 是个空字符串 |
+
string s2(s1) |
+s2 是s1 的副本 |
+
string s2 = s1 |
+等价于s2(s1) ,s2 是s1 的副本 |
+
string s3("value") |
+s3 是字面值“value”的副本,除了字面值最后的那个空字符外 |
+
string s3 = "value" |
+等价于s3("value") ,s3 是字面值"value"的副本 |
+
string s4(n, 'c') |
+把s4 初始化为由连续n 个字符c 组成的串 |
+
拷贝初始化(copy initialization):使用 =
将一个已有的对象拷贝到正在创建的对象。
直接初始化(direct initialization):通过括号给对象赋值。
+string
对象的操作
+string
的操作:
操作 | +解释 | +
---|---|
os << s |
+将s 写到输出流os 当中,返回os |
+
is >> s |
+从is 中读取字符串赋给s ,字符串以空白分割,返回is |
+
getline(is, s) |
+从is 中读取一行赋给s ,返回is |
+
s.empty() |
+s 为空返回true ,否则返回false |
+
s.size() |
+返回s 中字符的个数 |
+
s[n] |
+返回s 中第n 个字符的引用,位置n 从0计起 |
+
s1+s2 |
+返回s1 和s2 连接后的结果 |
+
s1=s2 |
+用s2 的副本代替s1 中原来的字符 |
+
s1==s2 |
+如果s1 和s2 中所含的字符完全一样,则它们相等;string 对象的相等性判断对字母的大小写敏感 |
+
s1!=s2 |
+同上 | +
< , <= , > , >= |
+利用字符在字典中的顺序进行比较,且对字母的大小写敏感(对第一个不相同的位置进行比较) | +
读取 string
对象:
IO
操作符 >>
读取:忽略开头的空白(空格符、换行符、制表符等),从第一个真正的字符开始读起,直到遇到下一个空白。getline()
函数读取:将一整行读取为 string
对象,包括空白。s.size()
返回 string::size_type
类型,是 无符号 类型的值,不能和 int
混用。
s1 + s2
使用时,必须保证至少其中一个为 string
类型。例如:string s = "hello" + "world"
错误,其 +
两边都为字符串字面值。
字符串字面值 和 string
是不同的类型。
string
对象中的字符
+C++
修改了 c
的标准库 ctype.h
为 cctype
,其中定义了一组标准函数:
函数 | +解释 | +
---|---|
isalnum(c) |
+当c 是字母或数字时为真 |
+
isalpha(c) |
+当c 是字母时为真 |
+
iscntrl(c) |
+当c 是控制字符时为真 |
+
isdigit(c) |
+当c 是数字时为真 |
+
isgraph(c) |
+当c 不是空格但可以打印时为真 |
+
islower(c) |
+当c 是小写字母时为真 |
+
isprint(c) |
+当c 是可打印字符时为真 |
+
ispunct(c) |
+当c 是标点符号时为真 |
+
isspace(c) |
+当c 是空白时为真(空格、横向制表符、纵向制表符、回车符、换行符、进纸符) |
+
isupper(c) |
+当c 是大写字母时为真 |
+
isxdigit(c) |
+当c 是十六进制数字时为真 |
+
tolower(c) |
+当c 是大写字母,输出对应的小写字母;否则原样输出c |
+
toupper(c) |
+当c 是小写字母,输出对应的大写字母;否则原样输出c |
+
遍历字符串:
+
+
|
+
+
|
str[idx]
中的 idx
为 string::size_type
类型,如果使用 int
会隐式转换为该类型。
vector
+标准库类型 vector
表示对象的集合,其中给所有对象的类型都相同。因为 vector
容纳着其他对象,所以称其为 容器(container),使用 vector
必须包含其头文件:
+
|
+
+
|
vector
同时也是 类模板(class template),模板本身不是类或函数,但可以使用模板创建类,这个过程称为 实例化(instantiation)。
当使用模板时,需要指出编译器应把类或函数实例化成何种类型:
+
+
|
+
+
|
+++
vector
是模板,vector<int>
是类型。
vector
对象
+初始化vector
对象的方法:
方法 | +解释 | +
---|---|
vector<T> v1 |
+v1 是一个空vector ,它潜在的元素是T 类型的,执行默认初始化 |
+
vector<T> v2(v1) |
+v2 中包含有v1 所有元素的副本 |
+
vector<T> v2 = v1 |
+等价于v2(v1) ,v2 中包含v1 所有元素的副本 |
+
vector<T> v3(n, val) |
+v3 包含了n个重复的元素,每个元素的值都是val |
+
vector<T> v4(n) |
+v4 包含了n个重复地执行了值初始化的对象 |
+
vector<T> v5{a, b, c...} |
+v5 包含了初始值个数的元素,每个元素被赋予相应的初始值 |
+
vector<T> v5={a, b, c...} |
+等价于v5{a, b, c...} |
+
vector
对象的操作:
+vector
支持的操作:
操作 | +解释 | +
---|---|
v.emtpy() |
+如果v 不含有任何元素,返回真;否则返回假 |
+
v.size() |
+返回v 中元素的个数 |
+
v.push_back(t) |
+向v 的尾端添加一个值为t 的元素 |
+
v[n] |
+返回v 中第n 个位置上元素的引用 |
+
v1 = v2 |
+用v2 中的元素拷贝替换v1 中的元素 |
+
v1 = {a,b,c...} |
+用列表中元素的拷贝替换v1 中的元素 |
+
v1 == v2 |
+v1 和v2 相等当且仅当它们的元素数量相同且对应位置的元素值都相同 |
+
v1 != v2 |
+同上 | +
< ,<= ,> , >= |
+以字典顺序进行比较 | +
除了下标运算符外,迭代器(iterator) 也可以访问对象中的元素,所有标准库的容器都支持迭代器。类似于指针类型,迭代器也提供了对对象的间接访问。
+拥有迭代器的类型都具有 begin
和 end
成员,其中 begin
成员返回指向第一个元素的迭代器:
+
|
+
+
|
end
成员返回指向容器“尾元素的下一个位置(one past the end)”的迭代器,即 end
指向容器的 尾后(off the end) 元素。这样的迭代器通常没有意义,只是作为标记,被称为 尾后迭代器(off-the-end iterator) 或 尾迭代器(end iterator)。
++若容器为空,则
+begin
和end
都返回尾后迭代器。
标准容器迭代器的运算符:
+运算符 | +解释 | +
---|---|
*iter |
+返回迭代器iter 所指向的元素的引用 |
+
iter->mem |
+等价于(*iter).mem |
+
++iter |
+令iter 指示容器中的下一个元素 |
+
--iter |
+令iter 指示容器中的上一个元素 |
+
iter1 == iter2 |
+判断两个迭代器是否相等 | +
++泛型编程:尽量使用
+!=
来对迭代器进行判断
迭代器也拥有自己的类型:
+
+
|
+
+
|
如果容器中的值为常量,则 begin
和 end
返回 const_iterator
,否则返回 iterator
。
解引用和成员访问:解引用迭代器可以获得迭代器所指的对象,如果该对象是一个类,则可以进一步访问其成员:
+
+
|
+
+
|
string
和 vector
的迭代器提供了额外的运算符,支持迭代器的关系运算和跨过多个元素,这些运算称为 迭代器运算(iterator arithmetic):
运算符 | +解释 | +
---|---|
iter + n |
+迭代器加上一个整数值仍得到一个迭代器,迭代器指示的新位置和原来相比向前移动了若干个元素。结果迭代器或者指示容器内的一个元素,或者指示容器尾元素的下一位置。 | +
iter - n |
+迭代器减去一个整数仍得到一个迭代器,迭代器指示的新位置比原来向后移动了若干个元素。结果迭代器或者指向容器内的一个元素,或者指示容器尾元素的下一位置。 | +
iter1 += n |
+迭代器加法的复合赋值语句,将iter1 加n的结果赋给iter1 |
+
iter1 -= n |
+迭代器减法的复合赋值语句,将iter2 减n的加过赋给iter1 |
+
iter1 - iter2 |
+两个迭代器相减的结果是它们之间的距离,也就是说,将运算符右侧的迭代器向前移动差值个元素后得到左侧的迭代器。参与运算的两个迭代器必须指向的是同一个容器中的元素或者尾元素的下一位置。 | +
> 、>= 、< 、<= |
+迭代器的关系运算符,如果某迭代器 | +
当两个迭代器指向同一个容器时,它们可以进行加减操作得到距离,这个距离的类型为 difference_type
类型,是带符号整数型。
数组可以看做 vector
的低配版,其 长度固定。
数组的声明和定义形如 a[d]
,其中 a
是数组的名字,d
是数组的维度(大于 0):
+
|
+
+
|
字符数组具有一定特殊性,使用字符串初始化字符数组时在结尾处必须增加一个空字符:
+
+
|
+
+
|
不能将数组的内容拷贝给其他数组作为其初始值,也不能用数组为其他数组赋值:
+
+
|
+
+
|
数组的下标为 size_t
类型,是一种机器相关的无符号类型,它被设计得足够大以便能够表示内存中任意对象的大小。
++下标存在越界导致缓冲区溢出等情况,这种情况需要程序员自行检查。
+
使用数组时,编译器会将其转换成指针。使用取地址符可以获取数组的元素的指针,如果是取数组的指针,则默认返回数组第一个元素的指针:
+
+
|
+
+
|
C
风格字符串
+字符串字面值是一种通用结构的实例,这种结构是 C++
由 C
继承而来的 C
风格字符串(C-style character string) 。
+按此习惯书写的字符串存放在字符数组中并以 空字符结束(null terminated)。
++在
+C++
程序中尽量不要使用C
风格字符串,容易引起安全漏洞且不方便。
C标准库String函数,定义在<cstring>
中:
函数 | +介绍 | +
---|---|
strlen(p) |
+返回p 的长度,空字符不计算在内 |
+
strcmp(p1, p2) |
+比较p1 和p2 的相等性。如果p1==p2 ,返回0;如果p1>p2 ,返回一个正值;如果p1<p2 ,返回一个负值。 |
+
strcat(p1, p2) |
+将p2 附加到p1 之后,返回p1 |
+
strcpy(p1, p2) |
+将p2 拷贝给p1 ,返回p1 |
+
严格来说,C++
语言中没有多维数组,所谓的多维数组实际是数组的数组。
+
|
+
+
|
for
语句处理多维数组
+
+
|
+
+
|
表达式由一个或多个 运算对象(operand) 组成对表达式求值将得到一个 结果(result)。
+C++
定义了一元运算符(unary operator)和二元运算符(binary operator),分别作用于一个运算对象和两个运算对象。此外,还有三元运算符,有些运算符既是一元也是二元运算符。
对于含有多个运算符的复杂表达式,首先需要理解运算符的:优先级(precedence)、结合律(associativity)以及运算对象的求值顺序(order of evaluation)。
+在表达式求值过程中,小整数类型(bool
、char
、short
)等通常会被 提升(promoted) 成较大的整数类型(int
)。
当运算符作用在类类型的运算对象时,用户可以自定定义其含义,称为 重载运算符(overloaded operator)。
+C++
的表达式要不然是 右值(rvalue),要不然就是 左值(lvalue),这两个名词是从 C
继承过来的。当一个对象被用作右值的时候,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)。
在大多数情况下,表达式求值的顺序是没有明确指定的:
+
+
|
+
+
|
我们知道 f1
和 f2
一定会在执行乘法之前被调用,但是无法知道是 f1
先被调用还是 f2
先被调用。对于没有指定调用顺序的程序来说,如果 f1
和 f2
同时修改了同一个对象,将会引发错误并产生未定义的行为。
溢出:当计算的结果超出该类型所能表示的范围时就会产生溢出。
+bool
类型不应该参与计算。
取余运算:m % n
的结果的符号与 m
相同。
短路求值:逻辑与运算符和逻辑或运算符都是先求左侧运算对象的值,再求右侧运算对象的值,当且仅当左侧运算对象无法确定表达式的结果时才会计算右侧运算对象的值。
+函数是一个命名了的代码块,我们通过调用函数执行响应的代码。函数可以有 0 个或多个参数,而且(通常)会返回一个结果。可以重载函数,即同一个名字可以对应几个不同的函数。
+一个典型的 函数(function) 定义包括以下部分:返回类型(return type)、函数名字、由 0 个或多个 形参(parameter) 组成的列表以及函数体。
+我们通过 调用运算符(call operator) 来执行函数:调用运算符的形式是一对圆括号,它作用于一个表达式,该表达式是函数或者指向函数的指针;圆括号之内是一个用逗号隔开的 实参(argument) 列表,我们用实参初始化函数的形参,调用表达式的类型就是函数返回的类型。
+编写函数:例如编写一个求 n
的阶乘的函数:
+
|
+
+
|
调用函数:要调用 fact
函数,首先需要提供一个整数,得到的返回结果也是一个整数值:
+
|
+
+
|
函数的调用完成两项工作:实参初始化函数对应的形参,将控制权转移给被调函数。此时,主调函数(calling funciton) 的执行暂停,被调函数(called funciton) 开始执行。
+在 C++
语言中,名字有作用域,对象有 生命周期(lifetime):
在函数体内,形参和内部定义的变量统称为 局部变量(local variable),它们对函数而言是“局部”饿,仅在函数的作用域内可见,同时局部变量还会 **隐藏(hide)**在外层作用域中同名的其他声明中。
+局部静态对象:某些时候,有必要令局部变量的生命周期贯穿函数调用及之后的时间。可以将局部变量定义成 static
类型从而获得这样的对象,局部静态对象(local static object) 在程序的执行路径第一次经过对象定义语句时初始化,并且直到程序终止才被销毁,在此期间即使对象所在的函数结束执行也不会对它有影响。
类的基本思想是 数据抽象(data abstraction) 和 封装(encapsulation)。数据抽象是一种依赖于 接口(interface) 和 实现(implementation) 分离的编程(及设计)技术。类的接口包括用户所能执行的操作,类的实现则包括类的数据成员、负责接口实现的函数体以及定义类所需的各种私有函数。
+封装实现了类的接口和实现的分离,封装后的类隐藏了它的实现细节,即类的用户只能使用接口而无法访问实现部分。
+类要想实现数据抽象和封装,首先需要定义一个 抽象数据类型(abstract data type)。
+我们为类定义了接口之后,没有任何机制强制用户使用这些接口,我们的类还没有进行封装。在 C++
中使用 访问说明符(access specifiers) 加强类的封装:
public
说明符之后的成员,在整个程序内都可被访问。private
说明符之后的成员,只能被类的成员访问,即隐藏了这些成员的实现。定义新的 Sales_data
类:
+
|
+
+
|
我们的程序已经使用了很多 IO 库设施:
+istream
(输入流)类型,提供输入操作。ostream
(输出流)类型,提供输出操作。cin
,一个 istream
对象,从标准输入读取数据。cout
,一个 ostream
对象,想标准输出写入数据。cerr
,一个 ostream
对象,通常用于输出程序错误信息,写入到标准错误。>>
运算符,用来从一个 istream
对象中读取输入数据。<<
运算符,用来向一个 ostream
对象中写入输出数据。getline
函数,从一个给定的 istream
对象中读取一行数据,存入到一个给定的 string
对象中。IO 类型和对象一般都是操纵 char
数据的,但有些使用需要对文件、string
进行操作,因此分别定义了三个头文件:
iostream
头文件:从标准流中读写数据,istream
、ostream
等。fstream
头文件:从文件中读写数据,ifstream
、ofstream
等。sstream
头文件:从字符串中读写数据,istringstream
、ostringstream
等。一个容器就是一些特定类型对象的集合,顺序容器(sequence container) 为程序员提供了控制元素存储和访问顺序的能力。
+下面列出了标准库中的顺序容器,不同容器有不同的性能折中:
+容器类型 | +介绍 | +
---|---|
vector |
+可变大小数组。支持快速随机访问。在尾部之外的位置插入或删除元素可能很慢。 | +
deque |
+双端队列。支持快速随机访问。在头尾位置插入/删除速度很快。 | +
list |
+双向链表。只支持双向顺序访问。在list 中任何位置进行插入/删除操作速度都很快。 |
+
forward_list |
+单向链表。只支持单向顺序访问。在链表任何位置进行插入/删除操作速度都很快。 | +
array |
+固定大小数组。支持快速随机访问。不能添加或者删除元素。 | +
string |
+与vector 相似的容器,但专门用于保存字符。随机访问块。在尾部插入/删除速度快。 |
+
array
外,其他容器都提供高效、灵活的内存管理。vector
是最好的选择,除非你有很好的理由选择其他容器。list
或 forward_list
。vector
或 deque
。list
或 forward_list
。deque
。容器类型操作上形成了一种层次:
+操作 | +解释 | +
---|---|
iterator |
+此容器类型的迭代器类型 | +
const_iterator |
+可以读取元素但不能修改元素的迭代器类型 | +
size_type |
+无符号整数类型,足够保存此种容器类型最大可能的大小 | +
difference_type |
+带符号整数类型,足够保存两个迭代器之间的距离 | +
value_type |
+元素类型 | +
reference |
+元素的左值类型;和value_type & 含义相同 |
+
const_reference |
+元素的const 左值类型,即const value_type & |
+
操作 | +解释 | +
---|---|
C c; |
+默认构造函数,构造空容器 | +
C c1(c2); 或C c1 = c2; |
+构造c2 的拷贝c1 |
+
C c(b, e) |
+构造c ,将迭代器b 和e 指定范围内的所有元素拷贝到c |
+
C c(a, b, c...) |
+列表初始化c |
+
C c(n) |
+只支持顺序容器,且不包括array ,包含n 个元素,这些元素进行了值初始化 |
+
C c(n, t) |
+包含n 个初始值为t 的元素 |
+
array
具有固定大小。array
是非空的。swap
+操作 | +解释 | +
---|---|
c1 = c2; |
+将c1 中的元素替换成c2 中的元素 |
+
c1 = {a, b, c...} |
+将c1 中的元素替换成列表中的元素(不适用于array ) |
+
c1.swap(c2) |
+交换c1 和c2 的元素 |
+
swap(c1, c2) |
+等价于c1.swap(c2) |
+
c.assign(b, e) |
+将c 中的元素替换成迭代器b 和e 表示范围中的元素,b 和e 不能指向c 中的元素 |
+
c.assign(il) |
+将c 中的元素替换成初始化列表il 中的元素 |
+
c.assign(n, r) |
+将c 中的元素替换为n 个值是t 的元素 |
+
swap
是一个好习惯。assign
操作不适用于关联容器和array
操作 | +解释 | +
---|---|
c.size() |
+c 中元素的数目(不支持forward_list ) |
+
c.max_size() |
+c 中可保存的最大元素数目 |
+
c.empty() |
+若c 中存储了元素,返回false ,否则返回true |
+
操作 | +解释 | +
---|---|
c.push_back(t) |
+在c 尾部创建一个值为t 的元素,返回void |
+
c.emplace_back(args) |
+同上 | +
c.push_front(t) |
+在c 头部创建一个值为t 的元素,返回void |
+
c.emplace_front(args) |
+同上 | +
c.insert(p, t) |
+在迭代器p 指向的元素之前创建一个值是t 的元素,返回指向新元素的迭代器 |
+
c.emplace(p, args) |
+同上 | +
c.insert(p, n, t) |
+在迭代器p 指向的元素之前插入n 个值为t 的元素,返回指向第一个新元素的迭代器;如果n 是0,则返回p |
+
c.insert(p, b, e) |
+将迭代器b 和e 范围内的元素,插入到p 指向的元素之前;如果范围为空,则返回p |
+
c.insert(p, il) |
+il 是一个花括号包围中的元素值列表,将其插入到p 指向的元素之前;如果il 是空,则返回p |
+
array
。forward_list
有自己专有版本的insert
和emplace
。forward_list
不支持push_back
和emplace_back
。emplace
开头的函数是新标准引入的,这些操作是构造而不是拷贝元素。emplace
的参数必须和元素类型的构造函数相匹配。操作 | +解释 | +
---|---|
c.back() |
+返回c 中尾元素的引用。若c 为空,函数行为未定义 |
+
c.front() |
+返回c 中头元素的引用。若c 为空,函数行为未定义 |
+
c[n] |
+返回c 中下标是n 的元素的引用,n 时候一个无符号证书。若n>=c.size() ,则函数行为未定义 |
+
c.at(n) |
+返回下标为n 的元素引用。如果下标越界,则抛出out_of_range 异常 |
+
at
和下标操作只适用于string
、vector
、deque
、array
。back
不适用于forward_list
。at
函数。操作 | +解释 | +
---|---|
c.pop_back() |
+删除c 中尾元素,若c 为空,则函数行为未定义。函数返回void |
+
c.pop_front() |
+删除c 中首元素,若c 为空,则函数行为未定义。函数返回void |
+
c.erase(p) |
+删除迭代器p 指向的元素,返回一个指向被删除元素之后的元素的迭代器,若p 本身是尾后迭代器,则函数行为未定义 |
+
c.erase(b, e) |
+删除迭代器b 和e 范围内的元素,返回指向最后一个被删元素之后元素的迭代器,若e 本身就是尾后迭代器,则返回尾后迭代器 |
+
c.clear() |
+删除c 中所有元素,返回void |
+
array
。forward_list
有特殊版本的erase
forward_list
不支持pop_back
vector
和string
不支持pop_front
forwad_list
操作
+forward_list
定义了before_begin
,即首前(off-the-begining)迭代器,允许我们再在首元素之前添加或删除元素。操作 | +解释 | +
---|---|
lst.before_begin() |
+返回指向链表首元素之前不存在的元素的迭代器,此迭代器不能解引用。 | +
lst.cbefore_begin() |
+同上,但是返回的是常量迭代器。 | +
lst.insert_after(p, t) |
+在迭代器p 之后插入元素。t 是一个对象 |
+
lst.insert_after(p, n, t) |
+在迭代器p 之后插入元素。t 是一个对象,n 是数量。若n 是0则函数行为未定义 |
+
lst.insert_after(p, b, e) |
+在迭代器p 之后插入元素。由迭代器b 和e 指定范围。 |
+
lst.insert_after(p, il) |
+在迭代器p 之后插入元素。由il 指定初始化列表。 |
+
emplace_after(p, args) |
+使用args 在p 之后的位置,创建一个元素,返回一个指向这个新元素的迭代器。若p 为尾后迭代器,则函数行为未定义。 |
+
lst.erase_after(p) |
+删除p 指向位置之后的元素,返回一个指向被删元素之后的元素的迭代器,若p 指向lst 的尾元素或者是一个尾后迭代器,则函数行为未定义。 |
+
lst.erase_after(b, e) |
+类似上面,删除对象换成从b 到e 指定的范围。 |
+
操作 | +解释 | +
---|---|
c.resize(n) |
+调整c 的大小为n 个元素,若n<c.size() ,则多出的元素被丢弃。若必须添加新元素,对新元素进行值初始化 |
+
c.resize(n, t) |
+调整c 的大小为n 个元素,任何新添加的元素都初始化为值t |
+
操作 | +解释 | +
---|---|
c.begin() , c.end() |
+返回指向c 的首元素和尾元素之后位置的迭代器 |
+
c.cbegin() , c.cend() |
+返回const_iterator |
+
c
开头的版本是C++11新标准引入的cbegin
和cend
。操作 | +解释 | +
---|---|
reverse_iterator |
+按逆序寻址元素的迭代器 | +
const_reverse_iterator |
+不能修改元素的逆序迭代器 | +
c.rbegin() , c.rend() |
+返回指向c 的尾元素和首元素之前位置的迭代器 |
+
c.crbegin() , c.crend() |
+返回const_reverse_iterator |
+
forward_list
在图像测量过程以及机器视觉应用中,为确定空间无哦表面某点的三维几何位置与其在图像中对应点之间的相互关系,必须建立相机成像的几何模型,这些几何模型参数就是相机参数。
+在大多数条件下这些参数必须通过实验与计算才能得到,这个求解参数的过程就称之为相机标定(或摄像头标定)。
+相机标定涉及的知识面很广:成像几何、镜头畸变、单应矩阵、非线性优化等。
+相机标定有自标定(找图像中特征点)、标定板标定(特征点易求,稳定性好),一般采用标定板标定。
+相机标定按照相机是否静止,可分为静态相机标定(标定板动,相机静止),动态相机标定(标定板静止,相机运动)。
+任何理论物理模型都是在特定假设上对真实事物的近似,然而在实际应用中存在误差,普通相机的成像模型也不例外(透视投影)。
+实际中,普通相机成像误差的主要来源有两部分:
+我们所处的世界是三维的,而照片是二维的,这样我们可以把相机认为是一个函数,输入量是一个场景,输出量是一幅灰度图。这个从三维到二维的过程的函数是不可逆的。
+相机标定的目标是我们找一个合适的数学模型,求出这个模型的参数,这样我们能够近似这个三维到二维的过程,使这个三维到二维的过程的函数找到反函数。
+这个逼近的过程就是「相机标定」,我们用简单的数学模型来表达复杂的成像过程,并且求出成像的反过程。标定之后的相机,可以进行三维场景的重建,即深度的感知。
+焦点:在几何光学中有时也称为像点,是源头的光线经过物镜后汇聚的点。
+焦距:也称为焦长,是光学系统中衡量光的聚集或发散的度量方式,指从透镜中心到光聚集之焦点的距离。亦是照相机中,从镜片光学中心到底片、CCD或CMOS等成像平面的距离。
+正透镜、负透镜、凹面镜和凸面镜的焦点F
和焦距f
:
镜头(Lenses):是将拍摄景物在传感器上成像的器件,它通常由几片透镜、光圈叶片、对焦马达等光学元件组成。
+传感器(Sensor):是摄像头组成的核心,其作用是作为相机的感光元件。摄像头传感器主要有两种,一种是CCD传感器,一种是CMOS传感器,两者区别在于:CCD的优势在于成像质量好,但是由于制造工艺复杂,成本居高不下,特别是大型CCD,价格非常高昂。在相同分辨率下,CMOS价格比CCD便宜,但是CMOS器件产生的图像质量相比CCD来说要低一些。
+光心:凸透镜近轴光线中,入射线和与其对应且相平行的出射线构成共轭光线,其入射点跟出射点的连线与主光轴的交点,称为凸透镜的焦点,位于透镜中央的点叫光心。
+从图中可知,O
为光心,F
为焦点。每个透镜主轴上都有一个特殊点,凡是通过该点的光,其传播方向不变,这个点叫光心。经过光心的光线的传播方向不会发生改变。
我们通常将相机看成如下所示的透镜模型:
+在实际分析时,通常将其简化为针孔模型(小孔成像):
+一般为了分析简单,将成像平面画在对称位置,这样图像不再颠倒:
+世界坐标系:用户定义的三维世界的坐标系,用于描述目标物体在真实世界里的位置。单位通常为米(m)。该坐标系作用于三维空间。
+相机坐标系:在相机上建立的坐标系,为了从相机的角度描述物体位置而定义,作为沟通世界坐标系和图像/像素坐标系的中间一环。单位通常为米(m)。相机坐标系的原点在光心,其 $X_c、Y_c$ 轴分别与像面的两边平行,其 $Z_c$ 轴与光轴重合,且垂直于图像坐标系平面并通过图像坐标系的原点(实际情况中可能存在主点偏移),相机坐标系与图像坐标系之间的距离为焦距 $f$。该坐标系作用于三维空间。
+图像坐标系:为了描述成像过程中物体从相机坐标系到图像坐标系的投影投射关系而引入,方便进一步得到像素坐标系下的坐标。其原点是相机光轴与像面的交点(称为主点),即图像的中心点。其 $x, y$ 轴和像素坐标系的 $u, v$ 轴平行,故图像坐标系和像素坐标系实际是平移关系。单位通常为毫米(mm)。该坐标系作用于二维空间。
+像素坐标系:为了描述物体成像后的像点在数字图像上(相片)的坐标而引入,是我们真正从相机内读取到的信息所在的坐标系。单位为像素。像素坐标平面和图像坐标系平面重合,但像素坐标系原点位于图像左上角。该坐标系作用于二维空间。
+将世界坐标系中的点映射到相机坐标系:相机坐标系是世界坐标系通过刚体变换得到的。
+++刚体变换能够保持物体中各点的距离和角度,常见的刚体变换有:平移、旋转和镜像。
+
我们先只考虑旋转,假设将坐标系以 $X$ 轴为中心进行旋转,即 $X$ 不变,旋转 $Y - Z$ 平面。
+假设旋转角度为 $\theta$,即 $\angle Y’ O Y = \angle Z’ O Z = \theta$。旋转前的坐标系为 $X - Y - Z$,旋转后的坐标系为 $X’ - Y’ - Z’$。假设点 $P$ 在 $X - Y - Z$ 中的坐标为($X_w, Y_w, Z_w$),旋转后,其在 $X’ - Y’ - Z’$ 中的坐标为($X_c, Y_c, Z_c$): +$$ +X_C = X_w +$$
+$$ +\begin{array}{l} +Y_c & = OC + CD = OA \cdot \sin \theta + BP \\ +& = Z_w \cdot \sin \theta + AP \cdot \cos \theta \\ +& = Z_w \sin \theta + Y_w \cos \theta +\end{array} +$$
+$$ +\begin{array}{l} +Z_c & = PD = AC - AB \\ +& = AO \cdot \cos \theta - AP \cdot \cos \theta \\ +& = Z_w \cos \theta + Y_w \cos \theta +\end{array} +$$
+写成矩阵形式: +$$\displaystyle \begin{bmatrix} X_c \\ Y_c \\ Z_c \end{bmatrix} = \mathbf{R_{cw}} \begin{bmatrix} X_w \\ Y_w \\ Z_w \end{bmatrix} or \begin{bmatrix} X_w \\ Y_w \\ Z_w \end{bmatrix} = \mathbf{R_{wc}} \begin{bmatrix} X_c \\ Y_c \\ Z_c \end{bmatrix} $$ +推广到每个方向,可得到 $\mathbf{R_{cw}}, \mathbf{R_{wc}}$ 为: +$$ +\mathbf{R_{cw}} (X_A, \theta) = +\begin{bmatrix} +1 & 0 & 0 \\ +0 & \cos \theta & \sin \theta \\ +0 & - \sin \theta & \cos \theta +\end{bmatrix} +, +\mathbf{R_{wc}} (X_A, \theta) = +\begin{bmatrix} +1 & 0 & 0 \\ +0 & \cos \theta & - \sin \theta \\ +0 & \sin \theta & \cos \theta +\end{bmatrix} +$$ +$$ +\mathbf{R_{cw}} (Y_A, \theta) = +\begin{bmatrix} +\cos \theta & 0 & \sin \theta \\ +0 & 1 & 0 \\ - \sin \theta & 0 & \cos \theta +\end{bmatrix} +, +\mathbf{R_{wc}} (Y_A, \theta) = +\begin{bmatrix} +\cos \theta & 0 & - \sin \theta \\ +0 & 1 & 0 \\ +\sin \theta & 0 & \cos \theta +\end{bmatrix} +$$ +$$ +\mathbf{R_{cw}} (Z_A, \theta) = +\begin{bmatrix} +\cos \theta & \sin \theta & 0 \\ - \sin \theta & \cos \theta & 0 \\ +0 & 0 & 1 +\end{bmatrix} +, +\mathbf{R_{wc}} (Z_A, \theta) = +\begin{bmatrix} +\cos \theta & - \sin \theta & 0 \\ +\sin \theta & \cos \theta & 0 \\ +0 & 0 & 1 +\end{bmatrix} +$$
+这里我们使用右手笛卡尔三维坐标系:
+旋转可分为主动旋转与被动旋转。主动旋转是指将向量逆时针围绕旋转轴所做出的旋转。被动旋转是对坐标轴本身进行的逆时针旋转,它相当于主动旋转的逆操作。关于右手笛卡尔坐标系的 $X, Y, Z$ 轴的旋转分别叫做roll
,pitch
和yaw
旋转:
因为逆时针和顺时针旋转会得到不一样的旋转矩阵,所以我们统一如下:
+绕 $X$ 轴的主动旋转定义为($\theta_x$ 是roll
角 ):
+$$
+R(X_A, \theta_x) =
+\begin{bmatrix}
+1 & 0 & 0 \\
+0 & \cos \theta_x & - \sin \theta_x \\
+0 & \sin \theta_x & \cos \theta_x
+\end{bmatrix} =
+\exp \left ( \theta_x
+\begin{bmatrix}
+0 & 0 & 0\\
+0 & 0 & -1\\
+0 & 1 & 0
+\end{bmatrix}
+\right )
+$$
+绕 $Y$ 轴的主动旋转定义为($\theta_y$ 是pitch
角):
+$$
+R(Y_A, \theta_y) =
+\begin{bmatrix}
+\cos \theta_y & 0 & \sin \theta_y \\
+0 & 1 & 0 \\ - \sin \theta_y & 0 & \cos \theta_y
+\end{bmatrix} =
+\exp \left ( \theta_y
+\begin{bmatrix}
+0 & 0 & 1\\
+0 & 0 & 0\\ -1 & 0 & 0
+\end{bmatrix}
+\right )
+$$
+绕 $Z$ 轴的主动旋转定义为($\theta_z$ 是yaw
角):
+$$
+R(Z_A, \theta_z) =
+\begin{bmatrix}
+\cos \theta_z & - \sin \theta_z & 0 \\
+\sin \theta_z & \cos \theta_z & 0 \\
+0 & 0 & 1
+\end{bmatrix} =
+\exp \left ( \theta_y
+\begin{bmatrix}
+0 & -1 & 0\\
+1 & 0 & 0\\
+0 & 0 & 0
+\end{bmatrix}
+\right )
+$$
+将上述三个旋转矩阵结合起来,最终的旋转矩阵(设绕 $X, Y, Z$ 轴旋转的角度分别为 $\alpha, \beta, \gamma$):
+$$
+\begin{array}{ll}
+M(\alpha, \beta, \gamma) & = R_x(\alpha) R_y(\beta) R_z(\gamma) \\
+& =
+\begin{bmatrix}
+1 & 0 & 0 \\
+0 & \cos \alpha & - \sin \alpha \\
+0 & \sin \alpha & \cos \alpha
+\end{bmatrix}
+\begin{bmatrix}
+\cos \beta & 0 & \sin \beta \\
+0 & 1 & 0 \\ - \sin \beta & 0 & \cos \beta
+\end{bmatrix}
+\begin{bmatrix}
+\cos \gamma & -\sin \gamma & 0 \\
+\sin \gamma & \cos \gamma & 0 \\
+0 & 0 & 1
+\end{bmatrix} \\
+& = \begin{bmatrix}
+\cos \gamma \cos \beta & - \sin \gamma \cos \alpha + \cos \gamma \sin \beta \sin \alpha & \sin \gamma \sin \alpha + \cos \gamma \sin \beta \cos \alpha \\
+\sin \gamma \cos \beta & \cos \gamma \cos \alpha + \sin \gamma \sin \beta \sin \alpha & - \cos \gamma \sin \alpha + \sin \gamma \sin \beta \cos \alpha \\ - \sin \beta & \cos \beta \sin \alpha & \cos \beta \cos \alpha
+\end{bmatrix}
+\end{array}
+$$
此时我们再加上平移向量 $T$ 便可完成从世界坐标系到相机坐标系的这个刚体变换了:
+$$ +\begin{bmatrix} +X_c \\ +Y_c \\ +Z_c +\end{bmatrix} = +\begin{bmatrix} +r_{11} & r_{12} & r_{13} \\ +r_{21} & r_{22} & r_{23} \\ +r_{31} & r_{32} & r_{33} +\end{bmatrix} +\begin{bmatrix} +X_w \\ +Y_w \\ +Z_w +\end{bmatrix} + +\begin{bmatrix} +t_x \\ +t_y \\ +t_z +\end{bmatrix} = +\mathbf{R} +\begin{bmatrix} +X_w \\ +Y_w \\ +Z_w +\end{bmatrix} + T +$$
+可进一步写成如下形式:
+$$ +\begin{bmatrix} +X_c \\ +Y_c \\ +Z_c \\ +1 +\end{bmatrix} = +\begin{bmatrix} +\mathbf{R} & \mathbf{T} \\ +0_3^T & 1 +\end{bmatrix} +\begin{bmatrix} +X_w \\ +Y_w \\ +Z_w \\ +1 +\end{bmatrix} +$$
+其中,$\mathbf{R}$ 和 $\mathbf{T}$ 便是相机外参。
+首先考虑图像坐标系($xy$)和像素坐标系($uv$)之间的转换:
+$$ +\begin{bmatrix} +u \\ +v \\ +1 +\end{bmatrix}= +\begin{bmatrix} +\displaystyle \frac{1}{dx} & 0 & u_0 \\ +0 & \displaystyle \frac{1}{dy} & v_0 \\ +0 & 0 & 1 +\end{bmatrix}= +\begin{bmatrix} +x \\ +y \\ +1 +\end{bmatrix} +$$
+$dx$ 表示一个像素点在 $x$ 方向的长度是多少毫米,$dy$ 表示一个像素点在 $y$ 方向的长度是多少毫米;$(u_0, v_0)$ 为图像的中心点。
+然后考虑相机坐标系和图像坐标系之间的转换:
+$$ +\Delta ABO_c \sim \Delta oCO_c, \Delta PBO_c \sim \Delta pCO_c +$$ +$$ +\displaystyle \frac{AB}{oC} = \frac{AO_c}{oO_c} = \frac{PB}{p C} = \frac{X_c}{x} = \frac{Z_c}{f} = \frac{Y_c}{y} +$$ +$$ +x = f \displaystyle \frac{X_c}{Z_c}, y = f \frac{Y_c}{Z_c} +$$
+$$ +Z_c \begin{bmatrix} +x \\ +y \\ +1 +\end{bmatrix}= +\lambda +\begin{bmatrix} +u \\ +v \\ +1 +\end{bmatrix}= +\begin{bmatrix} +f & 0 & 0 & 0 \\ +0 & f & 0 & 0 \\ +0 & 0 & 1 & 0 +\end{bmatrix} +\begin{bmatrix} +X_c \\ +Y_c \\ +Z_c \\ +1 +\end{bmatrix} +$$
+其中,$f$ 是焦距,结合外参我们最终可以得到世界坐标系和像素坐标系之间的映射关系:
+$$ +\begin{array}{l} +\lambda \begin{bmatrix} +u \\ +v \\ +1 +\end{bmatrix} & = +\begin{bmatrix} +\displaystyle \frac{1}{dx} & 0 & u_0 \\ +0 & \displaystyle \frac{1}{dy} & v_0 \\ +0 & 0 & 1 +\end{bmatrix} +\begin{bmatrix} +f & 0 & 0 & 0 \\ +0 & f & 0 & 0 \\ +0 & 0 & 1 & 0 +\end{bmatrix} +\begin{bmatrix} +\mathbf{R} & \mathbf{T} \\ +0 & 1 +\end{bmatrix} +\begin{bmatrix} +X_w \\ +Y_w \\ +Z_w \\ +1 +\end{bmatrix}\\ +& = +\begin{bmatrix} +fx & 0 & u_0 & 0 \\ +0 & fy & v_0 & 0 \\ +0 & 0 & 1 & 0 +\end{bmatrix} +\begin{bmatrix} +\mathbf{R} & \mathbf{T} \\ +0 & 1 +\end{bmatrix} +\begin{bmatrix} +X_w \\ +Y_w \\ +Z_w \\ +1 +\end{bmatrix} +\end{array} +$$ +其中,相机内参为(不考虑图像传感器的特性): +$$ +\begin{bmatrix} +fx & 0 & u_0 & 0 \\ +0 & fy & v_0 & 0 \\ +0 & 0 & 1 & 0 +\end{bmatrix} +$$
+其中,$f_x, f_y$ 即为焦距的物理距离在像素坐标系中的长度,相机内参标定主要是标定相机的焦距、主点、歪斜等内部参数。
+主点是光轴和相机成像平面的交点,在理想情况下,图像坐标系和相机坐标系原点重合,不存在坐标系偏移。但在实际情况中,图像坐标系往往在图片的左上角,光轴过图像中心,因此图像坐标系和相机坐标系不重合。两个坐标系之间存在一个平移运动:
+考虑主点偏移后,图像坐标和3D在相机坐标系的关系为:
+$$ +\begin{matrix} +u = f \frac{X}{Z} + O_x \\ +v = f \frac{X}{Z} + O_y +\end{matrix} +$$ +此时,透视投影模型(像素坐标系和相机坐标系)的关系为: +$$ +\lambda +\begin{bmatrix} +u \\ +v \\ +1 +\end{bmatrix} = +\begin{bmatrix} +f & 0 & O_x & 0 \\ +0 & f & O_x & 0 \\ +0 & 0 & 1 & 0 +\end{bmatrix} +\begin{bmatrix} +X_c \\ +Y_c \\ +Z_c \\ +1 +\end{bmatrix} +$$
+仔细观察就会发现,该关系与上面提到的关系是等价的,只不过上面使用坐标 $(u_0, v_0)$ 来代表偏移量 $(O_x, O_y)$。
+图像传感器像原尺寸在制造过程可能不是正方形,同时可能存在歪斜(skewed),因此需要考虑这些影响因素,传感器歪斜和不是正方形主要对相机 $x$ 和 $y$ 方向的焦距产生影响。
+此时,透视投影模型(像素坐标系和相机坐标系)的关系为: +$$ +\lambda +\begin{bmatrix} +u \\ +v \\ +1 +\end{bmatrix} = +\begin{bmatrix} +f & s & O_x & 0 \\ +0 & \eta f & O_x & 0 \\ +0 & 0 & 1 & 0 +\end{bmatrix} +\begin{bmatrix} +X_c \\ +Y_c \\ +Z_c \\ +1 +\end{bmatrix} = [K, 0_3] P +$$ +其中,$K$ 矩阵即为最终的内参矩阵。
+小孔成像模型虽然充分考虑了相机内部参数对成像的影响,但没有考虑成像系统另一个重要的部分,镜头。镜头常用的有普通镜头、广角镜头、鱼眼镜头等,在无人驾驶和视觉slam领域,鱼眼镜头和广角镜头用的很多,主要是视角很大,可以观测到更多的信息。任何镜头都存在不同程度的畸变,不同类型的镜头用到的畸变模型也不相同。
+在几何光学和阴极射线管(CRT)显示中,畸变(distortion)是对直线投影的一种偏移。简单来说直线投影是场景内的一条直线投影到图片上也保持为一条直线。那畸变简单来说就是一条直线投影到图片上不能保持为一条直线了,这是一种光学畸变。畸变一般可以分为两大类,包括径向畸变(radial distortion)和切向畸变(tangential distortion)。
+径向畸变来自于透镜形状,主要是由于透镜不同部位放大倍率不同造成的。切向畸变来自于整个相机的组装过程,主要是由于透镜安装与成像平面不平行造成的。
+透过镜头边缘的光线很容易产生径向畸变,这种现象来源于“筒形”或“鱼眼”的影响。光线离镜头中心越远,畸变越大。
+从图像可以看出,径向畸变以某一个中心往外延伸,且越往外,畸变越大;显然畸变与距离成一种非线性的变换关系,参考众多文献,可以用多项式来近似: +$$ +\begin{matrix} +x_{rcrt} = x(1 + k_1 r^2 + k_2 r^4 + k_3 r^6) \\\\ +y_{rcrt} = y(1 + k_1 r^2 + k_2 r^4 + k_3 r^6) +\end{matrix} +$$ +其中,$x, y$ 是归一化的图像坐标,即坐标原点已经移动到主点,并且像素坐标除以焦距。$k_1, k_2, k_3$ 是径向畸变系数,$r^2 = x^2 + y^2$。
+切向畸变主要发生在相机sensor和镜头不平行的情况下;因为有夹角,所以光透过镜头传到图像传感器上时,成像位置发生了变化。
+
+$$
+\begin{matrix}
+x_{tcrt} = x + [2p_1 xy + p_2 (r^2 + 2 x^2)] \\\\
+y_{tcrt} = y + [2p_2 xy + p_1 (r^2 + 2 y^2)]
+\end{matrix}
+$$
+其中,$x, y$ 是归一化的图像坐标,即坐标原点已经移动到主点,并且像素坐标除以焦距。$p_1, p_2$ 是切向畸变系数,$r^2 = x^2 + y^2$。
考虑镜头畸变前,我们可以将相机标定简单描述为以下过程:像素坐标 $(u_{ccd}, v_{ccd})$ $\to$ 图像坐标 $(x, y)$ $\to$ 相机坐标 $(X_c, Y_c, Z_c)$ $\to$ 世界坐标 $(X_w, Y_w, Z_w)$。
+此时我们考虑加入镜头畸变: +$$ +\begin{matrix} +x_{crt} = x_{rcrt} + x_{tcrt} \\\\ +y_{crt} = y_{rcrt} + y_{tcrt} +\end{matrix} +$$ +得到消除镜头畸变的相机标定流程:像素坐标 $(u_{ccd - crt}, v_{ccd - crt})$ $\to$ 图像坐标 $(x_{crt}, y_{crt})$ $\to$ 相机坐标 $(X_c, Y_c, Z_c)$ $\to$ 世界坐标 $(X_w, Y_w, Z_w)$。
+针孔相机模型中,只要确定这9个参数就可以唯一的确定针孔相机模型:
+$$ +f_x,f_y,O_x,O_y,k_1,k_2,k_3,p_1,p_2 +$$
+这个过程就称为「相机标定」,其中前4个我们称为内参数,后5个称为畸变参数,畸变参数是为了补充内参的。所以一旦相机结构固定,包括镜头结构固定,对焦距离固定,我们就可以用这9个的参数去近似这个相机。这里说的「镜头结构固定」,按我个人的理解,除了焦距固定之外,也应当包含光圈固定,因为改变光圈的大小,除了景深之外,是有可能改变针孔相机模型中的光心位置,但是影响并不是很大。这意味着标定好的相机如果改变光圈大小,会使得标定误差变大但应该不会大到难以接受的地步。
+对于针孔相机本身需要拟合的方程如下:
+$$ +\begin{bmatrix} +u_{ccd - crt} * Z\\ +v_{ccd - crt} * Z\\ +Z +\end{bmatrix} = +J(k_1, k_2, k_3, p_1, p_2) +\begin{bmatrix} +f_x & 0 & O_x \\ +0 & f_y & O_y \\ +0 & 0 & 1 +\end{bmatrix} +\begin{bmatrix} +X \\ +Y \\ +X +\end{bmatrix} +$$
+因此,我们现在的任务就是找出一大堆具有对应关系的像点 ${(u_{ccd - crt}, v_{ccd - crt}) ^T }$ 和物点 ${ (X, Y, Z)^T }$ 的点作为样本,来训练出模型的参数。这里就引发了两个问题:
+为了解决上述问题,标定板应运而生。标定板的一大作用,确定物点和像点的对应性。这里用到的原理主要是「透视不变性」,打个比方,你近看一个人和远看一个人,虽然他的鼻子大小变了,你看鼻子的视角也变了,但是拓扑结构肯定是不变的,你也不可能把鼻子看成是嘴巴。
+所以在标定板中,印刷了拓扑结构,广泛应用的是棋盘格和圆点格,这两种之所以成为主流,不仅是因为它们的拓扑结构明确且均匀,更重要的是检测其拓扑结构的算法简单且有效。棋盘格检测的是角点,只要对拍摄到的棋盘格图像横纵两个方向计算梯度就可获得;而圆点格的检测只需要对拍摄到的圆点格图样计算质心即可。假如你开发了一套非常完美的检测人脸全部特征的算法,你完全可以用你的照片当作标定板。
+按照我的经验,圆点格的效果应该是好于棋盘格,因为圆点质心的「透视不变性」要比棋盘格的角点稳定的多。下图是同样尺寸、同样比例棋盘格和圆点在最大重投影误差处的误差对比,红色十字是提取的角点/质心,绿色圆圈是针孔相机模型计算出来认为的角点/质心位置。
+但是圆点格的检测似乎是Halcon的专利(存疑),因此OpenCV和Matlab标定工具箱用的是棋盘格,要用圆点格得要自己写算法。下文中提到的标定板说的都是棋盘格。
+标定板的第二大作用是把标定板中的角点变换到相机坐标系下的坐标 $(X, Y, Z)$。对于标定的初学者来说,很容易忽略的一点是标定板是具有标定板坐标系的。换句话说,标定板中的每个角点,在标定板坐标系下的位置是确定并且是已知的。
+而标定板坐标系变换到相机坐标系的变换矩阵,我们称它的元素为外参数。
+如果用OpenCV或Matlab标定工具箱进行标定,需要给出棋盘格的物理尺寸,这其实就是在建立标定板坐标系,从测量的角度讲,标定板的精度是相机标定精度的基准,是误差传递链上的第一个环节。所以为了使针孔相机模型更逼近真实相机,对标定板的质量有以下要求(按重要性顺序):
+当我们在编写函数时,若两个函数如下所示,只存在变量类型的差异:
+
+
|
+
+
|
此时我们当然可以直接复制其中一个函数,然后修改其中的类型来实现我们的需求,但是这样会增加代码的冗余和出错的可能性。
+我们可以发现,如果不管变量是任何类型,函数都可以正常运行就好了。
+在 CPP
中可以使用 泛型 (genericity)函数来实现,基于泛型实现的函数称为 泛型函数 :
+
|
+
+
|
其中前缀 template <class T>
表明接下来声明的是 函数模板(function template),而不是普通函数,接收的 类型 要赋给函数的形参 T
(T
可以取任意名称)。
调用时直接使用函数模板即可:
+
+
|
+
+
|
在使用函数模板时,编译器会自动查找传入参数的类型,并将函数模板的 T
修改为对应类型,这样创建的函数实体称为 模板函数(template function)。
在创建模板函数时,如果传入的参数类型错误,则会引发报错:
+
+
|
+
+
|
在上文中我们提到,在创建模板函数时如果传入的参数类型错误则会引发报错,那么有没有方法可以解决这种报错呢?
+CPP
提供了 显式实例化 的方法来创建模板函数:
+
|
+
+
|
使用显式实例化的方式创建模板函数,函数会使用指定的类型进行实例化。
+虽然函数模板可以对任意类型的变量起作用,但是对于例如 string
类型的变量:
+
|
+
+
|
这样实例化的模板函数在功能上来说是没有意义的,因为实际比较的是两个字面量的地址而非 string
的实际大小。因此,对于这种情况,我们需要特例化一个模板函数:
+
|
+
+
|
显式特例化的定义一般为:
+
+
|
+
+
|
在大型项目开发中,我们常常会使用多个源文件来管理和开发不同模块,如下面所示:
+
+
|
+
+
|
在编译时分别对不同源文件进行编译,然后链接在一起:
+
+
|
+
+
|
在 CPP
程序中,需遵循 单一定义规则(One Definition Rule, ODR):对象和函数只能定义一次。
因此,当我们在 A.cpp
中定义了某个对象或函数,想在 B.cpp
中使用时,就必须使用 extern
关键字进行声明。
同时,如果我们在 A.cpp
中定义了某个对象或函数,不想让其他文件使用该对象或函数,可以使用 static
关键字进行声明和定义:
+
|
+
+
|
如上所示,使用 static
声明和定义的对象和函数只能在其源文件中使用的情况,称为 内部链接(internal linkage)。此外,对于该程序中没有使用的以下函数或对象,即时没有 static
,也会被自动赋予内部链接:
const
定义的常量对象当源文件数量增加时,为了方便管理和维护,我们使用头文件来集中管理具有外部链接的变量和函数等的声明。
+
+
|
+
+
|
注意:使用自定义的头文件时,要用 "..."
而不是 <...>
,其区别在于:
#include <...>
会优先查找存储编译器提供标准库的地方。#include "..."
会优先查找源文件所在目录。作用域依赖于源文件中的声明的物理位置,名称的通用范围依赖于源文件这样的物理单元,这些都是从 C
语言继承过来的性质。
CPP
对此进行了改良,使用 命名空间(namespace)来控制每个标识符的通用范围:
+
|
+
+
|
属于命名空间的变量和函数等叫作 命名空间成员(namespace member)。可以在命名空间定义中只 声明 命名空间成员,在其他地方 定义 成员。
+
+
|
+
+
|
+
|
+
+
|
在命名空间 Outer
中定义的 x
使用 Outer::x
访问,在命名空间 Inner
中定义的 x
使用 Outer::Inner::x
访问。
我们也可以定义没有名称的 无名命名空间(unnamed namespace),属于无名命名空间的成员的标识符 只在定义它的源文件中通用。
+因此,这本质上相当于赋予标识符内部链接(同 static
的作用)。
在 CPP
程序中,不推荐使用 static
来赋予比那辆和函数内部链接,建议使用功能更多且更具弹性的无名命名空间。
同时,不推荐 static
的原因还在于其含义会根据上下文的不同而不同:
static
用来指定 静态存储期static
用来指定 内部链接static
用来指定 静态成员命名空间不能重名,为了方便可以为较长的命名空间定义 别名:
+
+
|
+
+
|
using
声明和指令
+使用 using
声明可以子啊不使用作用域解析运算符的情况下,通过简单名称来访问标识符;使用 using
指令可以实现通过简单名称使用 属于某个命名空间的所有标识符。
+
|
+
+
|
CppJson
第一章节:自动测试,NULL
和 bool
值解析
+JSON(JavaScript Object Notation)是一个用于数据交换的文本格式,现时的标准为ECMA-404。
+虽然 JSON 源至于 JavaScript 语言,但它只是一种数据格式,可用于任何编程语言。现时具类似功能的格式有 XML、YAML,当中以 JSON 的语法最为简单。
+例如,一个动态网页想从服务器获得数据时,服务器从数据库查找数据,然后把数据转换成 JSON 文本格式:
+
+
|
+
+
|
网页的脚本代码就可以把此 JSON 文本解析为内部的数据结构去使用。
+从此例子可看出,JSON 是树状结构,而 JSON 只包含 6 种数据类型:
+我们要实现的 JSON 库,主要是完成 3 个需求:
+我们会逐步实现这些需求。在本章节中,我们只实现最简单的 null 和 boolean 解析。
+我们要做的库是跨平台、跨编译器的,同学可使用任意平台进行练习。
+我们的 JSON 库名为 CppJson,代码文件只有 3 个:
+include/cppjson.hpp
:CppJson 的头文件(header file),含有对外的类型和 API 函数声明。cppjson.cpp
:CppJson 的实现文件(implementation file),含有内部的类型声明和函数实现。此文件会编译成库。cppjsonTest.cpp
:我们使用测试驱动开发(test driven development, TDD)。此文件包含测试程序,需要链接 CppJson 库。为了方便跨平台开发,我们会使用一个现时最流行的软件配置工具 CMake。
+在 OS X 平台中,在命令行通过命令:
+
+
|
+
+
|
将 Debug 改成 Release 就会生成 Release 配置的 makefile。
+在 Vscode 中,可以通过配置 tasks.json
文件来进行自动 build:
+
|
+
+
|
然后执行 build 生成的文件:
+
+
|
+
+
|
若看到类似以上的结果,说明已成功搭建编译环境,我们可以去看看那几个代码文件的内容了。
+Cpp
语言有头文件的概念,需要使用 #include
去引入头文件中的类型声明和函数声明。但由于头文件也可以 #include
其他头文件,为避免重复声明,通常会利用宏加入 include 防范(include guard):
+
|
+
+
|
如前所述,JSON 中有 6 种数据类型,如果把 true 和 false 当作两个类型就是 7 种,我们为此声明一个枚举类(enumeration calss):
+
+
|
+
+
|
接下来,我们声明 JSON 的数据结构。JSON 是一个树形结构,我们最终需要实现一个树的数据结构,每个节点使用 cppjson_value
结构体表示,我们会称它为一个 JSON 值(JSON value)。
在此单元中,我们只需要实现 null
, true
和 false
的解析,因此该结构体只需要存储一个 cppjsonType
,之后的单元会逐步加入其他数据。
+
|
+
+
|
然后,我们现在只需要两个 API 函数,一个是解析 JSON:
+
+
|
+
+
|
传入的 JSON 文本是一个 string
字符串,由于我们不应该改动这个输入字符串,所以使用 const std::string
类型。
返回值是以下这些枚举类中的值,无错误会返回 cppjsonParseCode::OK
,其他值在下节解释。
+
|
+
+
|
现时我们只需要一个访问结果的函数,就是获取其类型:
+
+
|
+
+
|
下面是此单元的 JSON 语法子集,使用 RFC7159 中的 ABNF 表示:
+
+
|
+
+
|
当中 %xhh
表示以 16 进制表示的字符,/
是多选一,*
是零或多个,()
用于分组。
那么第一行的意思是,JSON 文本由 3 部分组成,首先是空白(whitespace),接着是一个值,最后是空白。
+第二行告诉我们,所谓空白,是由零或多个空格符(space U+0020)、制表符(tab U+0009)、换行符(LF U+000A)、回车符(CR U+000D)所组成。
+第三行是说,我们现时的值只可以是 null
、false
或 true
,它们分别有对应的字面值(literal)。
我们的解析器应能判断输入是否一个合法的 JSON。如果输入的 JSON 不合符这个语法,我们要产生对应的错误码,方便使用者追查问题。
+在这个 JSON 语法子集下,我们定义 3 种错误码:
+LEPT_PARSE_EXPECT_VALUE
。LEPT_PARSE_ROOT_NOT_SINGULAR
。LEPT_PARSE_INVALID_VALUE
。许多同学在做练习题时,都是以 printf
/cout
打印结果,再用肉眼对比结果是否乎合预期。但当软件项目越来越复杂,这个做法会越来越低效。一般我们会采用自动的测试方式,例如单元测试(unit testing)。单元测试也能确保其他人修改代码后,原来的功能维持正确(这称为回归测试/regression testing)。
常用的单元测试框架有 xUnit 系列,如 C++ 的 Google Test、C# 的 NUnit。我们为了简单起见,会编写一个极简单的单元测试方式。
+一般来说,软件开发是以周期进行的。例如,加入一个功能,再写关于该功能的单元测试。但也有另一种软件开发方法论,称为测试驱动开发(test-driven development, TDD),它的主要循环步骤是:
+TDD 是先写测试,再实现功能。好处是实现只会刚好满足测试,而不会写了一些不需要的代码,或是没有被测试的代码。
+但无论我们是采用 TDD,或是先实现后测试,都应尽量加入足够覆盖率的单元测试。
+回到 CppJson 项目,cppjsonTest.cpp
包含了一个极简的单元测试框架:
+
|
+
+
|
现时只提供了一个 EXPECT_TYPE(expect, actual)
的宏,每次使用这个宏时,如果 expect != actual(预期值不等于实际值),便会输出错误信息。
若按照 TDD 的步骤,我们先写一个测试,如上面的 test_parse_null()
,而 cppjson_parse()
只返回 cppjsonParseCode::OK
:
+
|
+
+
|
为通过的测试是因为 cppjson_parse()
没有把 v.type
改成 cppjsonType::CPPJSON_NULL
,造成失败。我们再实现 lept_parse()
令到它能通过测试。
然而,完全按照 TDD 的步骤来开发,是会减慢开发进程。所以我个人会在这两种极端的工作方式取平衡。通常会在设计 API 后,先写部分测试代码,再写满足那些测试的实现。
+有了 API 的设计、单元测试,终于要实现解析器了。
+首先为了减少解析函数之间传递多个参数,我们把这些数据都放进一个 cppjson_context
结构体:
+
|
+
+
|
CppJson 是一个手写的递归下降解析器(recursive descent parser)。由于 JSON 语法特别简单,我们不需要写分词器(tokenizer),只需检测下一个字符,便可以知道它是哪种类型的值,然后调用相关的分析函数。对于完整的 JSON 语法,跳过空白后,只需检测当前字符:
+所以,我们可以按照 JSON 语法一节的 EBNF 简单翻译成解析函数:
+
+
|
+
+
|
CppJson
第二章节:number
值解析
+将仓库克隆到你需要的位置:
+
+
|
+
+
|
如果你不会使用Git
,可以进入网页:https://github.com/3000ye/dhuBachelor 然后下载Zip
文件:
如果你没有科学代理,无法进入Github
,请扫码关注公众号:3000ye Blog
然后回复latex模板
获取百度网盘分享链接。
在使用模板之前,请先找到并打开fonts
文件夹,安装里面的所有字体。
如果你是 $\LaTeX$ 小白,请先行阅读:使用 LaTeX 优雅地完成创作,在这个教程里你可以学会如何安装并配置适合你的 $\TeX$ 环境。
+使用你喜欢的编辑器,打开dhuBachelor.tex
文件,选择xelatex
命令编译文件。如果没有出现报错,同目录下会生成一个dhuBachelor.pdf
文件,这就是我们的论文。
看到这里,相信你已经成功编译好了模板文件,现在你可以在其基础上创作你的论文。
+本模板的格式严格按照东华大学本科生毕业设计(论文)撰写规范设置,下面是一些会用到的组件的详细说明。
+根据要求,论文题目使用三号黑体,上下各空一行,居中显示。添加代码:
+
+
|
+
+
|
根据要求,摘要使用四号黑体,下面空一行,居中显示。添加代码:
+
+
|
+
+
|
摘要内容直接写在摘要下方,首行缩进两字符。
+中文关键词:小四号黑体(标题),小四号宋体(关键词),逗号分隔,末尾没有标点符号。
+英文关键词:Times New Roman(标题加黑)。
+
+
|
+
+
|
目录标题需居中显示,添加代码:
+
+
|
+
+
|
规范中明确给出,最多只能使用三级标题,其中一级标题需上下各空一行。
+
+
|
+
+
|
规范中并没有给出无序列表的样例,因此不建议使用,只用有序列表:
+
+
|
+
+
|
行内公式:$f(x) = x + 1$
跨行公式:跨行公式请使用equation
环境,默认按照章节自动编号。
+
|
+
+
|
图片插入默认在文字下方,请严格按照模板的格式进行插入,使用时只需要改width
大小和图片路径,并根据实际更改图例和索引。
注意:图片需要保存在assets/
目录中才能被正确插入,读者可以自己新建其他目录实现插入。
+
|
+
+
|
有时可能需要多图并排,模板使用minipage
来实现并排展示,使用时可以修改子图所占比例:
+
|
+
+
|
使用 excel2latex 工具生成表格代码后,需要手动添加分割线(\toprule, \midrule, \bottomrule
),以达到三线表的格式要求。
+
|
+
+
|
对于需要多表并排的情况,和图片的方式类似,使用minipage
来实现:
+
|
+
+
|
可以直接在.tex
文件中编写代码,并指定语言和标题:
+
|
+
+
|
另一种更为推荐的方式是加载文件中的代码,代码文件需要保存在assets/
目录下:
+
|
+
+
|
使用宏包algorithm, algorithmic
来实现伪代码的添加,具体实现可以查看文档,下面是一个简单示例:
+
|
+
+
|
参考文献使用\bibitem
来添加,添加时需要手动更改{RNi}
索引(i
是你文献的序号)。
+
|
+
+
|
+
|
+
+
|
本模板为东华大学专用的学术报告 Slides 的 LaTeX Beamer 模板使用说明,主要特点为:
+.tex
文件中只需聚焦内容,.sty
配置文件隔离存放。Beamer 根据元素在 Slides 中的不同作用,主要做了以下划分:
+$\LaTeX$ 是一个文档准备系统 (Document Preparing System),它非常适用于生成高印刷质量的科技类和数学类文档。它也能够生成所有其他种类的文档,小到简单的信件,大到完整的书籍。 $\LaTeX$ 使用 $\TeX$ 作为它的排版引擎,学习 $\LaTeX$ 是一个漫长而痛苦的过程,我们应该充分利用已知的资料,来尽量完成我们的需求。
+$\TeX$ 引擎类似于 gcc/g++
或 Python
,用于编译 $\LaTeX$ 文档。
不同平台中 $\TeX$ 的安装方法不尽相同,本文提供:Windows11
、Linux(Ubuntu 22.04)
、MacOs(12.7)
、Windows11-wsl2(Ubuntu22.04)
的安装方法。
如果你只是想简单体验 $\LaTeX$,可以使用 overleaf 在线编译平台。但出于环境稳定性和数据的安全性等因素,并不建议将其作为主力平台。
+进入网站tug.org for windows,点击install-tl-windows.exe
下载 $\TeX$ 安装器,然后运行安装即可。
不过,这种方法需要一直联网安装,网速不好的环境可以直接下载iso
镜像进行本地安装。本文给出清华源镜像地址:mirrors.tuna,下载后缀为.iso
的文件(只用下载一个)。
下载完成后双击文件挂载镜像,然后打开镜像文件夹,右键点击install-tl-windows.bat
文件,使用管理员打开,然后按照指引安装即可。
最新版本的安装器会自动添加环境变量,安装完成后打开cmd
然后输入:
+
|
+
+
|
若能输出 $\TeX$ 版本信息则安装成功:
+
+
|
+
+
|
打开终端,然后执行安装命令:
+
+
|
+
+
|
等待安装完成即可,安装完成后执行命令:
+
+
|
+
+
|
若能输出 $\TeX$ 版本信息则安装成功:
+
+
|
+
+
|
打开终端,然后执行安装命令(推荐安装无窗体版本):
+
+
|
+
+
|
等待安装完成即可,安装完成后执行命令:
+
+
|
+
+
|
若能输出 $\TeX$ 版本信息则安装成功:
+
+
|
+
+
|
在wsl2
中安装方式与在Linux
中一样。
市面上有很多 $\LaTeX$ 编辑器,且与使用的系统有关,下面是一些主观评价:
+Vs Code
:作为地表最强编辑器,Vs Code
拥有非常丰富的 $\LaTeX$ 插件和完备的配置方案,并且可以免费使用,但缺点是配置较为繁琐。Jetbrains
: 与Vs Code
相对应的是Jetbrains
系列, 其虽然也有 $\LaTeX$ 插件,但使用体验非常不好,且其文件管理方式并不适合每个人。Neovim
:如果说Vs Code
是编辑器中的王后,那么nvim
就是国王。nvim
可以实现最大程度的自定义编辑方案,拥有海量插件生态,但缺点是学习路线非常陡峭,常人难以驾驭。sublime text
:nvim
固然强大,但其难以上手的特点使得很多人对其望而却步。sublime
打破了这个束缚,其界面优雅程度不亚于nvim
,也具有丰富的插件来实现你的理想配置,但配置同样较为繁琐,且需要付费。TexStudio
:texlive
默认自带编辑器,简单好用容易上手,是很多教程的主推编辑器,但笔者认为界面过于丑陋,不建议用。Windows
独占:
+Winedt 11
:如果不考虑跨平台,那么Winedt 11
就是Windows
上的最佳编辑器。这是一款罕见的非所见即所得的编辑器,笔者认为这完美契合了 $\LaTeX$ 的风格,同时其优雅成熟的界面和高度可定制化的功能使其一骑绝尘。但需要付费(169元买断)。综上所述,笔者最推荐Vs Code
,但如果你只有Windows
平台的使用需求并不介意一点费用的话,请果断购买Winedt 11
。同样的,MacOs
也拥有独占编辑器,但笔者没用过,在此不做评价。
关于这些编辑器如何配置,网上的教程有很多,读者可自行查阅。
+对于所有初学者来说,Ishort-zh-cn都是最好的入门教程。在开始你的创作之前,请务必先行完整阅读一遍,并动手尝试书中的案例。
+如果你已经完成了所有的案例,相信你已经对 $\LaTeX$ 语法有了简单了解,下面笔者给出一些新手可能遇到的常见问题。但请不要灰心,$\LaTeX$ 的学习是一件持久且困难的事,我们并不需要完全精通,只需要能够达到创作目的即可。
+$\LaTeX$ 默认只打印英文,如果没有合理的设置,.tex
文件中的中文将无法正确打印。
从下图可以看出,目前支持全平台通用的方案只有XeLaTeX
和LuaTeX
。因此,主流方案是一般使用xelatex+ctex
编译方案,底层调用xeCJK
字符集来实现中文打印。
使用时只需在导言区加入,编译器会使用默认字体进行编译:
+
+
|
+
+
|
如果要指定字体,则需分别设置font-family
:
+
|
+
+
|
同样的,英文也可以自定义字体:
+
+
|
+
+
|
++详细配置可以参考:LaTex 中文字体配置指南
+
你是否好奇过数学教材或者论文中复杂的数学公式是如何编写的?答案就是 $\LaTeX$,这也是 $\LaTeX$ 为什么被奉为珍宝的原因之一。
+但是笔者并不建议读者专门花时间来学习如何编写 $\LaTeX$ 数学公式,而是利用现成的工具来快速完成你的公式。
+MathType
:如果你是Windows
用户,那么强烈建议使用MathType
来生成数学公式的 $\LaTeX$ 代码,好处是完全不需要代码基础并且功能十分强大,但是需要付费。mathpix
:这是一款专为 $\LaTeX$ 打造的数学公式 OCR 识别器,你可以截屏、拍照、甚至手写数学公式来得到你想要的代码。你一定使用过Excel
来绘制表格,但是在 $\LaTeX$ 中绘制表格并不是一件轻松的事情,其中有非常多的坑且几乎每个人都无法避免。
但不用担心,本文为你介绍开源项目 excel2latex。这是一款Excel
插件,可以将你在Excel
中绘制的表格自动转译为 $\LaTeX$ 代码。
但是这个插件并不是万能的,比如绘制三线表,即使是在Excel
中也较为繁琐。因此,最好的处理方式是使用excel2latex
插件生成表格主体,然后再自己添加分隔格式。
绘制表格和插入图片并称为 $\LaTeX$ 中两大天坑,对于图片插入笔者尚未发现有效替代工具,在下文中会给出一些示例代码供读者参考。
+阅读到这里,相信你已经能够使用 $\LaTeX$ 创作出你自己的内容了。那么你应该不难发现,在创作的时候有很多代码都是可以重复使用的,只需要更改一些参数即可。但是 $\LaTeX$ 并不能像编程语言那样编写函数来实现代码的复用,当然有其他方法来实现(比如编写.sty
和.cls
文件),但这对初学者来说太难了。
因此,有没有一种好的方法可以实现这个需求呢?答案是代码片段(code snippets)。
+代码片段可以给你的编辑器添加些许魔力。它如同咒语一般。你只要说出指令(输入前缀),挥动魔杖(按下 Enter 或者 Tab 键),然后神奇的事情就发生在你眼前了。
+点击左下角的设置按钮,然后点击设置用户代码片段:
+在弹出的窗口中输入latex
然后选中即可跳转到latex.json
文件,我们可以在这里设置我们的代码片段。
比如这段设置,保存文件后我们只需要在.tex
后缀的文件中输入insertImg
然后回车就会自动填充以下代码,并且使用tab
来依次输入参数。
+
|
+
+
|
依次点击Option -> Options Interface -> Menus and Toolbar -> Main Menu
,修改或添加配置:
+
|
+
+
|
然后就可以使用快捷键Ctrl+Alt+F
填充插入图片的代码片段。
使用 $\LaTeX$ 来完成创作时,不同的需求的格式要求通常也不同。一般而言,格式的设置复杂且繁琐,如果将大部分时间花在调整格式上面有违 $\LaTeX$ 的初衷。
+因此,常见期刊都会提供对应的 $\LaTeX$ 风格模板和示例,其中主要文件通常为:
+.sty
:$\LaTeX$ 样式文件,包含一组宏包和命令,用于定制文档的样式、格式和功能。通常包括:宏包的引入、自定义命令、颜色与字体预设等。.cls
:$\LaTeX$ 文档文件,定义文档的整体结构和布局。通常包括:导言区设置、章节标题样式、页眉页脚与文档尺寸预设等。.tex
:示例文件,通常会包括论文中会用到的所有样式的示例代码。阅读示例文件可以让我们快速创作出符合格式要求的作品,让我们不再为格式烦恼,只用专注于内容本身。
+ +在服务器部署定时任务(爬虫、拉取数据库、模型训练等),任务完成后自动填写 commit 信息,并 push
到 Gitlab。
使用飞书机器人自动监听 Gitlab 项目,并获取 commit 信息,最终发送到飞书指定用户或群聊。
+实现这个功能,你需要准备:
+push
到 Gitlab。使用准备好的邮箱,注册 Gitlab 账号:https://gitlab.com/
+注册完成后新建一个新项目,并添加一个 README.md
文件。
使用 Terminal 连接你的服务器,使用以下命令(Ubuntu 22.04):
+
+
|
+
+
|
其中 name
为 Gitlab 账号的名称,email
为 Gitlab 账号的邮箱,将输出的内容复制到剪切板。
然后在 Gitlab 中,进入用户设置,找到 SSH 秘钥然后选择添加新秘钥,粘贴剪切板的内容保存即可。
+在 Gitlab 中打开项目,点击右上角的代码,复制使用 SSH 克隆的链接:
+然后在 Terminal 中找到合适的位置,Clone 项目:
+
+
|
+
+
|
编写你的任务脚本(shell
或 Python
等),然后确保其能正常运行。
使用飞书机器人助手,新建机器人指令,触发器选择 [新的 commit 创建],按照教程绑定 Gitlab 账号和项目:
+然后选择操作 [通过官方机器人发消息],设置消息内容为 commit 说明,并选择发送目标:
+点击完成后,启用机器人即可。
+首先测试飞书机器人是否能够正常抓取 Gitlab 项目的 commit,测试成功后,在服务器上为任务创建定时任务。
+本文推荐使用 systemctl
和 systemd
的 timer
来创建定时任务。
创建一个 task.timer
文件:
+
|
+
+
|
创建一个 task.service
文件(默认使用 root 用户,建议指定 User
用户):
+
|
+
+
|
+
|
+
+
|
任务运行成功后,使用以下命令启用定时任务:
+
+
|
+
+
|
产品需求文档(Product Requirements Document,PRD)是软件工程和互联网产品设计中的术语,是将商业需求文档(Business Requirements Document,BRD)和市场需求文档(Market Requirements Document,MRD)用更加专业的语言进行描述。
+产品需求文档,是交互设计的基础。通常包含了产品的理念宗旨、功能需求、逻辑架构、页面设计等信息。产品需求文档的撰写,是软件工程的重要阶段,对于把握产品需求、保证产品经理、设计师和软件开发者等人员之间的沟通有据有着重要意义。
+在实际的公司生产中,由于产品经理和开发人员之间往往会发生以下问题:
+因此,如何写一份用户体验好、开发喜欢看、靠谱的需求文档成为每个产品经理的必修课。
+function | +user1 | +user2 | +
---|---|---|
func 01 | +get | +get | +
线性表是具有相同数据类型的 $n(n \ge 0)$ 个数据元素的有限序列,其中 $n$ 为表长,当 $n = 0$ 时线性表是一个空表。若用 $L$ 命名线性表,则其一般表示为:
+$$ +L = (a_1, a_2, \cdots, a_i, a_{i + 1}, \cdots, a_n) +$$
+几个概念:
+除第一个元素外,每个元素有且仅有一个直接前驱;除最后一个元素外,每个元素有且仅有一个直接后继。
+线性表有两种存储方式,一种是顺序存储结构,另一种是链式存储结构。我们常用的数组就是一种典型的顺序存储结构。
+链式存储结构就是两个相邻的元素在内存中可能不是相邻的,每一个元素都有一个指针域,指针域一般是存储着到下一个元素的指针。
+这种存储方式的优点是定点插入和定点删除的时间复杂度为 $O(1)$,缺点是访问的时间复杂度最坏为 $O(n)$。
+链表就是链式存储的线性表。根据指针域的不同,链表分为单向链表、双向链表、循环链表等等。
+单向链表中包含数据域和指针域,其中数据域用于存放数据,指针域用来连接当前结点和下一节点。
+在一台设备上使用 Git 托管代码时,我们可能会遇到以下需求:
+这时,如何生成和管理 SSH Key 成为一个问题。
+在不同文件夹下,都可以设置不同的局部 Git 信息:
+
+
|
+
+
|
开启 SSH Agent 对 SSH 执行代理,用于缓存私钥:
+
+
|
+
+
|
指定文件名,生成不同的 SSH Key,即使相同邮箱也可以进行区分:
+
+
|
+
+
|
将生成的私钥添加进代理:
+
+
|
+
+
|
首先复制 SSH 公钥:
+
+
|
+
+
|
然后到 Git 平台中,添加该公钥。
+ +在 Swift 中,常量使用 let
进行声明,变量使用 var
进行声明:
+
|
+
+
|
在 Swift 中,程序会根据声明的值来自动分析标识符的类型,同时也提供了显示指定类型的方法:
+
+
|
+
+
|
需要注意的是,若只不在声明时定义标识符的值,则必须指定类型:
+
+
|
+
+
|
Swift 提供了8、16、32和64位的有符号和无符号整数类型。
+可以访问不同整数类型的 min
和 max
属性来获取对应类型的最小值和最大值:
+
|
+
+
|
$$ +\varphi = 1+\frac{1} {1+\frac{1} {1+\frac{1} {1+\cdots} } } +$$
+
+
|
+
+
|
在程序开发中,很多时候都是程序都是串行处理,这没有什么问题。然而,在某些重复工作较多,且性能要求较高的场景,串行处理所需时间往往过于漫长。
+因此,合理地使用线程管理有助于我们程序的更好运行。但是请注意,不是一味地使用多线程或线程池就一定是好的,适合运行场景的处理方式才是最好的。
+在本文中,我们考虑这样一个场景:有一个非常耗时的计算函数,其计算一次需要 time
秒。
+
|
+
+
|
按照常规的做法,我们串行地对批量任务进行处理:
+
+
|
+
+
|
可以预见,这种处理方法会非常耗时。
+为了加速程序运行和处理的速度,我们可以使用多线程来并行处理。多线程的思想是:先将要进行的任务放入队列中,然后让这些任务同时运行,最终实现加速程序运行的效果。
+
+
|
+
+
|
多线程虽好,但是频繁地创建和删除线程,同样会造成时间和空间的浪费。因此,线程池出现了,在每次任务完成之后,保留现有线程并继续处理下一个任务。
+
+
|
+
+
|