无论是打数模竞赛，还是日常办公，总会遇到以$Excel$ 表格提供源数据的场景。然后我们就需要对其中的数据作处理、运算，最终再以$Excel$ 表格的形式把结果给呈现回去。如果是简单的一行两列的操作，直接在$office$ 上就能完成，而一旦有一些稍微复杂点的需求，那么使用程序来编写脚本则是非常合适的选择！其中，python 的 pandas 库能满足大部分需求。就不能不用 pandas 么起初我也有这个质疑，毕竟在程序员眼里，表格不就是个二维数组嘛，那不如只使用读/写接口，其他的运算就转为普通的数组之后再自己实现嘛。而如果仅仅是对$Excel$ 表格（后缀为.xlsx的文件）作读写，python 内置的 csv 也能完成这个任务，根本不需要用 pandas，还搞得那么复杂。对于数模比赛，因为表格中一般没有字符串，确实可以只用读/写接口。而一旦涉及日常办公，里面往往出现较多的字符串，此时表格的每一列的格式不同，不能直接无脑遍历，然后还要记住字符串列的下标，再用 if 作额外判断。列数多起来时，变量数蹭蹭往上涨，难度不亚于$CSP$ 第3题的大模拟，而且可读性极差，要一边打开表格，一边对照数组的下标看看是用的哪一个数，过个两天根本看不懂自己写了啥。所以，用 pandas 也许不能优化算法的时间复杂度，但一定可以让代码变得美观易懂，并提升开发效率。先学IO当然是先学输入输出啦！读入都不会，怎么处理数据呢，QwQ。话不多说，先放代码：import pandas as pd df = pd.read_excel("./输入.xlsx") # 普通读入，会自动忽略第一行，将其视为表头 df = pd.read_excel("./输入.xlsx", header = None) # 把第一行视为数据，额外添加0,1,2,...作为默认表头 df.to_excel("./输出") # 普通输出，加一列填入行下标 df.to_excel("./输出",index = None) # 不输出行下标安装库的话，一句pip install pandas即可。导入库时，往往会命名为 pd，这只是大伙的习惯，跟 numpy 命名为 np是一个道理，在网上搜或是问$ChatGPT$ 也会得到这种命名方式。使用pandas库时，存储表格的对象类型叫做 DataFrame，所以往往命名为 df。让我们看上述代码，输入用一句read_excel函数即可搞定，但需要注意自己的$Excel$ 表格的真正的数据是从第几行开始的，read_excel函数默认最顶上的 1 行为表头，读入后成为列下标，实践中不注意这个的话可能会漏掉一条数据。输出也是用一句to_excel函数就行，也一样要区分是否输出行下标。pandas 的类的性质使用 pandas 库时，它的对象类型跟数组有关系，但依然有较大的区别。首先是存储表格的对象类型为 DataFrame，它类似一个二维数组，但是使用单个下标访问 DataFrame时，访问的是一整列，而不是一整行，而我们平时使用的numpy数组，单下标会访问一整行，这两者有一个转置的关系。所以想遍历表格的每一行时，不能用i作为下标去遍历，要用专门的遍历方式（后面会说）。其次，我们常常会接触到的一个类型为 Series，它类似一个一维数组，其实就可以理解为 DataFrame的某一行或某一列，当使用单下标去访问 DataFrame时，返回的对象就是 Series类型的。在 DataFrame 中，既有行下标，也有列下标。这两种下标既可以是自然数$0,1,2,\cdots$，也可以是字符串或布尔值等，它综合了python 中的列表和字典的访问方式，甚至额外加了其他的方式。所以后面一定要分清哪个是下标，哪个是真正返回的数。DataFrame 的访问当我们手上读入一个表格时，无论要遍历还是查询还是修改，前提都是访问数据。我们可能需要访问其中的某一列、某一行、某一格、满足条件的某一列、满足条件的某一行等等，这些其实都属于语法范畴，按模板抄就行。下面将列出访问表格数据的种种情况。转为numpy数组返璞归真式解决方案，如果表格本身是纯数值（没有字符串等），那么直接转为numpy数组处理是非常简单易读的，而且 DataFrame和numpy数组的相互转换非常方便。numpy_array = df.to_numpy() df = pd.DataFrame(numpy_array) # 处理完之后可以将 numpy 转回 DataFrame用下标访问 DataFrame自然数下标。df[0]，使用自然数作为下标时，返回的是一整列列名下标。df["学号"]，使用列名作为下标时，返回的是一整列布尔数组下标。df[df["语文成绩"]>90] 使用布尔Seires作为下标时，返回的是布尔值为True的那些行。Seires类型可以进行整列的判断，返回相等长度的布尔数组。比如df["语文成绩"]>90语句会返回一个类似于[True,False,True]的Seires数组，然后再用它作为 DataFrame 的下标，那么只有True的那几行会被返回，这种访问方式可以起到筛选的作用。用下标访问 Seires遍历 DataFrame 每一行for index, row in df.iterrows(): # row的类型是 Seires print(row) for i in range(len(df)): # 用自然数作下标 row = df.iloc[i] # row的类型是 Seires print(row) for row in df.itertuples():# row的类型是命名元组 print(row) 遍历 DataFrame 每一列for col_name, col_data in df.iteritems(): # 用迭代器遍历 print("列名:", col_name) print("对应的数据:", col_data) # 类型是 Seires for col_name in df.columns: # 用列名作下标 print(df[col_name]) # 类型是 Seires遍历 Seiresfor index, value in series.items(): # 用迭代器遍历 print(value) for i in range(len(series)): # 用下标遍历 value = series[i] for index, value in series.iteritems(): print(value) for index in series.index: # 假如series是一行，那么它的index可能是列名，即字符串 print(series[index])返回某几列Dataframe支持用多个下标，只要把原本的多个下标组成一个列表，然后把该列表作为下标即可，其中也有类似于python切片的机制。print(df'A', 'C') # 用列名取出若干列 print(df.iloc[:, [0, 2]]) # 用下标取出若干列，这里取出的是第0列和第2列返回某几行print(df.iloc[[1, 3]]) # 用下标取出若干行，这里取出的是第0行和第3行 print(df.loc'row1', 'row3') # 用行标签取出若干列，但通常情况其实行标签默认为自然数下标返回子表格如果只想取表格中的某几列的某几行，那么其实按顺序运用上面两节的方法就能做到，当然，也可以像下面这样一步到位。其中，iloc方法的用法跟numpy数组很像；而loc方法虽然是用行标签作索引，但大多数情况中，行标签就是自然数下标，所以可以将行下标和列名混用，个人感觉loc更好用。print(df.iloc[2:6, [0, 2]]) # 用下标取，这里取了第0列和第2列的2到6行，用法很像numpy print(df.loc[['row1', 'row2'], ['A', 'C']]) # 用列名、行标签取 print(df.loc[[0, 1], ['A', 'C']]) # 行标签默认为自然数下标的情况。遍历每一格其实，把前面的方法结合起来，就能得到遍历每一格的方法，就像遍历数组一样，一维一维依次遍历即可。下面没有提到转换成numpy的方法，但其实先转成numpy再遍历也是一种可行的方法。# 第一种，iterrows方法，先遍历行，再遍历列 for index, row in df.iterrows(): for column in df.columns: print(row[column]) # 第二种，itertuples方法，也是遍历行，再遍历列，但使用了命名元组，比上一种更高效 for row in df.itertuples(): for column in df.columns: print(getattr(row, column)) # 第三种，同时访问行标签和列标签，然后用标签定位单元格 for i in df.index: for j in df.columns: print(df.at[i, j])结语本篇讲述了怎么用python去处理Excel文件，有了 pandas 库的各种访问方法，那么剩下的数值计算、字符串处理等无非就是简单的算法题了，当然也有可能上升到极其复杂的大模拟（笑），那些就看实际要实现的业务咯。这一篇博客所描述的技能相当简单，但应用面其实非常广泛，现今其实除了理工科的职业，其实很多岗位每天都处理着重复的文档工作，如果可以用python将自动化办公引入这些地方，可以大幅提高工作效率。相比于令人眼花缭乱的文生视频技术，也许用python处理文档表格的技能更受欢迎。
【五仁科技】如何在你的朋友中发现小团体？——社区检测
复杂网络学科大量的实证研究发现很多网络存在聚簇效应，比如常见的社交网络。正所谓人以群分，物以类聚。在数据挖掘中，有一个分支叫做社区检测（community detection），用于在复杂的网络中寻找无形的群聚关系。任务描述假如我们现在知道很多名微信用户，并且只知道这些微信用户之间的好友关系，即知道任意两个微信用户是否互为好友，就像下图一样。显然，在人与人的交往中难免产生一些小团体，就像现实生活中有同一个学校、同一个班级、同一个宿舍的学生会聚成不同层级的小团体，在微信中也存在群聊的功能。我们现在的任务是，试图只从多个微信用户之间的好友关系，去猜测这些用户的小团体，也就是把这些用户划分为一个一个不同的集合（假设一个用户只属于一个小团体）。数据挖掘就是在一堆看似杂乱无章的数据中发现隐含的有价值的信息。在一般的机器学习中，聚类算法是在一些各自独立的向量上进行操作的，目的是将独立的向量划分成若干个聚簇。而社区检测可以看作是图论上的聚类算法：在一个拥有若干个节点和边的图中，将这些节点划分为若干个社区（聚簇）。不同的地方在于，机器学习中的经典聚类是用样本自身的属性来计算，通过每个个体自身的值来得到个体之间的相似性，从而划分为不同类。而社区检测则不关注每个点，而是只知道每个个体之间的关系，通过点与点之间的边来把整个图的点划分成若干个集合。朴素思想我们不妨先看看下面这张图，看到下图中的网络，你想把它分成几个社区呢？怎么分，每个节点属于哪一个社区呢？我相信，绝大多数人的第一感觉都是把这个网络分成左右 2 个社区，其中[0,1,2,3,4]节点属于第 1 个社区，而[5,6,7,8,9]节点属于第 2 个社区（当然，社区的顺序可以随意）。为什么会有这样的第一感觉呢？虽然有很多种可能，但我们可以合理地怀疑跟边的密集程度有关。设想在学校中，同一个班级的学生，互为微信好友的可能性很高，甚至可能是完全图，即每个同学都与同班所有其他同学互为微信好友，而两个学生在分别属于两个不同班级的情况下，他们互为微信好友的可能性就比较低了。也就是说，假如我们划分出一个点集，这个点集的边的密度非常高，那么这个点集就应该能划分成一个社区。那么，如何来衡量一个点集的边的密度？直接用边的数量除以点的数量吗，可能是其中一种方法，但仔细想想却并不合理。这里就不再卖关子了，我们直接引出我们的 modularity（模块度） 的概念。在对网络进行某个社区划分之后，模块度就可以用于测量这样划分得好不好，也可以理解为用于测量社区内的边的密度高不高。Modularity 模块度Modularity(模块度), 这个概念是2003年一个叫Newman的人提出的。这个人先后发表了很多关于社区划分的论文，包括2002年发表的著名的Girvan-Newman(G-N)算法，和2004发表的Fast Newman(F-B)算法，Modularity就是F-B算法中提出的（03年提出的概念，04年确认发表）。在2006年的时候Newman重新定义了Modularity，实际上只是对原来的公式做了变换，使其适用于Spectral Optimization Algorithms。早期的算法不能够很好的确认什么样的社区划分是最优的。Modularity这个概念就是为了定义一个对于社区划分结果优劣的评判。接下来，我们以一种相对抽象的想法来理解模块度。假如一个网络，考虑它的某一个点集，点集里面的每一个点都与该点集里面的其他点有较多的边相连，而与其他点集的点只有较少的边相连，那么这些点的边是不均匀分布的。点集中某个点的邻边，有更大的概率与点集内的点相邻，这可以看作是社区的一种“特征”。那么这种不均匀的分布会导致什么呢？我们来考虑一种叫 “平均图” 的东西，它的点的邻边的分布是比较均匀，这种图的点与点之间一视同仁，并不存在明显小团体。那么，当我们用原来的不均匀分布的网络，减去这种“平均图”，那么边比较密集的那些区域就会体现出来。这样的说法有点抽象，可以慢慢理解。于是，我们现在就产生了一种方法，去求得这个“平均图”。如果使用正则图就有点太过离谱了，毕竟我们只在意每个点的邻边的分布，而并不需要改变每个点的度数（邻边的数量）。所以，我们可以通过点的度数来计算边权，从而在得到“平均图”的同时，保证邻边的期望值（边权的和）与原来的网络相等。所以，我们现在可以把“平均图”称作期望图。假设在原来的网络中，有 m 条边，则总度数为 2m，有任意两个节点 i 和 j (允许i=j)，它们的度数分别为 $k_i$ 和 $k_j$，那么在期望图中，节点 i 和 j 相邻边的边权就为 $\frac{k_ik_j}{2m}$。用数学表达就是：$$e(i,j)_{i\in N,j\in N}=\frac{k_ik_j}{2m}$$其中 N 为网络所有点的集合，m 为边数。请记住，期望图和原网络的点都是一样的，不同的只是边权而已。像上面的式子，这样的设计有非常巧妙的地方，让我们来算一算每个点的度数的期望（边权之和）：$$E(i)=\sum_{j\in N}e(i,j)=\sum_{j\in N}\frac{k_ik_j}{2m}=\frac{k_i}{2m}\sum_{j\in N}k_j=\frac{k_i}{2m}\cdot 2m=k_i$$可以看到，在期望图中，任意一个节点 i 的邻边的边权之和为 $k_i$，而 $k_i$ 恰好又等于原网络的节点 i 的度数。这样设计，相当于没有改变每个点的邻边的数量，而是把邻边相对均匀地分到其他每一个点。并且，整个图的总度数也没有改变（对上式进行求和即可）。求期望图的代码也非常简单，示例如下（python）：# 各顶点的度数 d = np.sum(A, axis = 0) # 边数，即总度数的一半 m = np.sum(d) / 2 # 用度数求出期望图 B = np.array([d]).T @ np.array([d]) / (m * 2)下一步，我们用原网络减去期望图。原网络的两点之间如果有边相连，则可以把边权看作是 1，如果两点之间没有边，则可以看作有边且边权为 0。而期望图中，任意两点之间的边权都可以用上式计算得到，于是将原网络的每一条边的边权减去期望图的对应边的边权。不难想到，这样得到的新网络中，边是有负权的，不妨思考一下哪些边是负权。因为网络中存在隐式的社区，社区内的边比较密集，而不同社区之间的边比较稀疏，所以在减去期望图之后，社区内的边的边权很有可能还是正数，而不同社区之间的边的边权可能就变成了正数。那么此时有一个很朴素的想法就是，尽量去找那些边权为正数的边。我们前面说过，模块度 modularity 用来评估某个社区检测的效果，它是从已有的社区检测算出某个值。那么假设我们现在手上有原网络减去期望图之后的边权，有正有负，而且已把所有点划分为若干个社区，那么我们只统计在社区内的边。也就是说，我们只对其中一部分的边进行求和，这些边所连接的两个节点都在同一个社区内。假如划分得非常好，使得社区内的边尽可能的密集，社区之间的边尽可能的稀疏，那么求和时就会保留那些社区内的正权边，同时丢弃社区之间的负权边，于是最后得到的值就会比较大。数学表达如下：$$Q(G,S)=\frac{1}{2m}\sum_{s\in S}\sum_{i\in s}\sum_{j\in s}(G_{ij}-\frac{k_ik_j}{2m})$$其中，$G$ 是图的邻接矩阵，保存了边的信息，$G_{ij}$ 即为点 i 和点 j 之间的边权（相连即为1，否则为0）；$S$ 是对图的一个划分，把所有点划分为若干个子集；$k_i$ 是点 i 的度数；$m$ 是图的总边数，$2m$ 就是总度数。式子右边的 $\sum_{s\in S}$ 表示依次取出划分的一个子集，然后统计那些两个端点都在子集内的边（包括自环）。最后乘上 $\frac{1}{2m}$ 用于归一化，由于图的总度数为 $2m$，期望图的总度数不变，也是 $2m$，那么减去期望图之后，取部分边进行求和，最终所得到的结果的取指范围就是 [-2m, 2m]，乘上 $\frac{1}{2m}$ 后就能把取指范围限制到 [-1, 1]。所以模块度的取值范围是 [-1, 1]，一般来说，当模块度在 [0.3, 0.7] 区间内时，我们认为实现了一个较好的社区划分，这是实践所总结的经验。Modularity 的 python 实现前面介绍了模块度的计算方法，其实按照公式的概念很简单就能用代码来实现。但我们可以通过矩阵相乘的形式去简化它，注意我说的是简化而不是优化，用矩阵相乘来计算模块度并不会降低时间复杂度。我们考虑下面这张图：只有 3 个节点 A、B、C 和 2 条边 AB、AC，那么它的邻接矩阵可以简单地像这样表示：$$ \left[ \begin{array}{ccc} 0&1&1 \\ 1&0&0 \\ 1&0&0 \end{array} \right] $$现在假设我们划分两个社区，把节点 A 和 C 划分到 1 个社区，节点 B 单独划分到另 1 个社区。我们前面说过，模块度只统计社区内的边，于是我们可以一个一个社区地进行统计，我们不妨来看 A 和 C 组成的社区。在邻接矩阵中，A 和 C 组成的社区里的边如下所示:矩阵中黄色位置的元素就是我们要统计的元素，换句话说，我们计算这个社区时，要把矩阵中那些位置的元素抽出来求和。我们不妨来看下面的运算：$$ \left[ \begin{array}{ccc} 1&0&1 \end{array} \right] \cdot \left[ \begin{array}{ccc} 0&1&1 \\ 1&0&0 \\ 1&0&0 \end{array} \right] \cdot \left[ \begin{array}{c} 1 \\ 0 \\ 1 \end{array} \right]=2 $$我们只需要在邻接矩阵左右分别乘上一个相同的向量，就可以提取出想要的元素，如果看不懂可以再看下面的一般形式：$$ \left[ \begin{array}{ccc} 1&0&1 \end{array} \right] \cdot \left[ \begin{array}{ccc} a&b&c \\ d&e&f \\ g&h&i \end{array} \right] \cdot \left[ \begin{array}{c} 1 \\ 0 \\ 1 \end{array} \right]= \left[ \begin{array}{ccc} a+g&b+h&c+i \end{array} \right] \cdot \left[ \begin{array}{c} 1 \\ 0 \\ 1 \end{array} \right]=a+c+g+i $$请以自己的线性代数知识感受一下，这两个向量就像掩码，分别从水平方向和竖直方向对邻接矩阵的元素进行过滤，只留下社区内的边进行求和。因为我们现在是以 A 和 C 为一个社区，进行求和，所以向量是 [1 0 1]，如果我们是以 B 为一个社区进行求和，那么向量就是 [0 1 0]，也就是说，社区内有哪些点，我们就把对应位置设为 1。那么假如我们想同时求这两个社区的元素，那么可以把这些向量并起来一起算，比如下面这样：$$ \left[ \begin{array}{ccc} 1&0&1 \\ 0&1&0 \end{array} \right] \cdot \left[ \begin{array}{ccc} 0&1&1 \\ 1&0&0 \\ 1&0&0 \end{array} \right] \cdot \left[ \begin{array}{c} 1&0 \\ 0&1 \\ 1&0 \end{array} \right]= \left[ \begin{array}{c} 2&1 \\ 1&0 \end{array} \right] $$其中，最终结果所得到的矩阵的对角线就是每一个社区的边权之和（每一条边要算两遍），所以，我们使用矩阵的迹来得到对角线上的和，就等于模块度。所以我们可以完全使用矩阵运算的方式来求得模块度，这在 python 中能极大地压缩代码量，所以最后我们求模块度的代码如下：# 计算图的模块度，输入为图的邻接矩阵A和社区数组community def modularity(A, community): # 各顶点的度数 d = np.sum(A, axis = 0) # 边数，即总度数的一半 m = np.sum(d) / 2 # 社区矩阵，若第i个节点归属于第j个社区，则矩阵第i行第j列为1，其他为0 S = np.zeros(A.shape, dtype=int) for i in range(0, A.shape[0]): S[i][community[i]] = 1 # 用度数求出随机期望的图，再用原图的邻接矩阵减去那个图 B = A - (np.array([d]).T @ np.array([d]) / (m * 2)) # 返回模块度 return np.trace(S.T @ B @ S) / (m * 2)Louvain 算法Louvain 算法，原文是 Fast unfolding of communities in large networks。虽然我们知道如何用模块度来评价社区划分的好坏，但我们目标还是想要获得一个效果较好的社区检测。那么思路就很简单，我们只需要想办法去改变社区的划分，使得模块度的值增大，那就能不断优化社区检测。这就像机器学习中，我们使用梯度下降的方法去改变模型使得 $Loss$ 函数的值降低。Louvain 算法的思路很简单，它是一个贪心算法。就是一直尝试改变点所属的社区，如果改变能使模块度增大，那就改变；如果不能使模块度增大，那就不变。Louvain 算法用于求出不同层次的社区，它总共分两部分，但我们可以先单看它的第一部分：在初始化时，每一个点自己都是一个独立的社区，有 N 个点就有 N 个社区然后不断循环遍历每一个点：（遍历到最后一个点就再从第一个点开始）对于第i个点，遍历与它相邻的所有点：当遍历到第i个点的第j个邻居时，尝试把i点脱离原来的社区，加入邻居所在的社区，然后计算模块度。如果i点换社区之后，模块度相比原来的有所提升，那么i点就留在新社区（贪心）；反之如果模块度没提升，i点就返回原来的社区。假如所有的点无论怎么改变它们所在的社区，都无法提升模块度时，那么停止迭代，算法完成。这样就知道了每个点所属的社区，也就得到了一个社区划分。所以第一部分就是一个非常简单的贪心思路：能增就改。它的输入就是图的邻接矩阵，输出就是对这个图的划分，它的 python 代码实现如下，相关注释已经表明，非常清晰易懂：# Louvain算法的第一步，将图G的节点聚到多个社区 def first_phase(G): community = np.arange(G.shape[0]) # 社区数组，记录每个节点所属社区的下标 # 记录上一次改变社区的节点，如果遍历了一轮都没有节点改变自己的社区，那么算法收敛，结束循环 last_i = G.shape[0] - 1 max_modularity = modularity(G, community) i = 0 cnt = 0 # 统计迭代次数 while True: s_i = community[i] # 记录节点i原来的社区 flag = False # 遍历节点i之后，节点i的社区是否发生改变 # 遍历第i个节点的所有邻居，尝试加入邻居的社区，选择能够使模块度增最多的那个社区 for j in range(0, G.shape[0]): if G[i][j] == 1: community[i] = community[j] # 节点i加入新的社区 next_modularity = modularity(G, community) # 计算新的模块度 community[i] = s_i # 节点i返回原来的社区 # 如果改变节点i的社区能使模块度变大，那就直接变过去 if next_modularity > max_modularity: max_modularity = next_modularity s_i = community[i] = community[j] flag = True if last_i == i and flag == False: #如果所有的点跑完一轮都没变过，那么就结束循环 break if flag == True: last_i = i i = (i + 1) % G.shape[0] # 循环遍历节点 cnt += 1 print("遍历次数: ", cnt) # 利用桶排序，将社区的标签映射到01234... b = np.zeros(len(community), dtype=int) for i in range(0, len(community)): b[community[i]] += 1 labels = np.zeros(len(community), dtype=int) num = 0 for i in range(0, len(community)): if b[i] > 0: labels[community[i]] = num num += 1 for i in range(0, len(community)): community[i] = labels[community[i]] return community显然，Louvain 算法的第一部分注重的是个体改变对模块度的影响，那么自然而然地可以想到，多个点同时改变社区也能对模块度产生影响。所以，Louvain 算法的第二部分就很朴素，把社区合并成一个点，也就是纯粹地压缩。Louvain 算法的第一部分把所有点划分成多个社区，社区内有边，社区之间也有边。Louvain 算法的第二部分就是把这些社区分别合成一个点，原来的社区内部的边权加起来作为点的自环的边权，原来的社区与社区之间的边权也加起来作为点与点之间的边。就像下图所示：在上图中，Modularity Optimization 就是 Louvain 算法的第一部分，Community Aggregation 就是第二部分。两个部分合起来叫做一个 pass。第二部分的代码实现如下：# Louvain算法的第二步，将社区合成一个节点 def second_phase(G, community): n = max(community) + 1 A = np.zeros((n, n)) ## 直接将原来的边权加到新图的边 for i in range(0, G.shape[0]): for j in range(0, G.shape[1]): A[community[i]][community[j]] += G[i][j] return A在经过了第二部分之后，整个图的规模就变小了，就要重新进行第一部分的计算。我们前面说过，第一部分关注的是单个点的改变对模块度产生的影响，并没有考虑一群点的改变。而第二部分把社区合成点之后，再丢进第一部分的运算中，我们就可以把整个社区看成一个整体，试图把一整个社区加入到另一个社区中，观察对模块度产生什么影响，假如模块度还能增大，那么就说明在原来的网络中，把两个社区合并是更明智的选择。所以，整个 Louvain 算法就是不断地重复第一部分和第二部分，直到模块度无法再增大为止（假如第一部分的输入是N个点，输出还有N个社区，那么说明任何点都不需改变，整个 Louvain 算法就停止）。 python 代码如下：# Louvain 算法，输入为图的邻接矩阵，输出为图的最优社区划分 def Louvain(G): # 社区数组，记录每个节点所属社区的下标 community = np.arange(G.shape[0]) print(modularity(G, community)) while True: # 不断进行两部分 next_community = first_phase(G) # 第一部分，划分 if np.max(next_community) + 1 == len(next_community): # 如果输入N个点，划分后还是N个社区，算法停止 break print(next_community) print(modularity(G, next_community)) # 经过第一部分之后，要更新原图的社区标签 for i in range(0, len(community)): community[i] = next_community[community[i]] G = second_phase(G, next_community) # 第二部分，压缩 return community实验对比下面我们来找若干个数据集进行测试。这一次所需的数据集跟一般的数据集有点不同，这次我们是在网络上进行操作的，需要知道点和边，用数据集构建图的信息。我从 http://www-personal.umich.edu/~mejn/netdata/ 下载数据集进行测试，里面的数据集都是 .gml 的文件格式，在 python 中我们可以用 networkx 库来处理这种文件，只需要先安装即可：pip install networkx本篇中，我们自己实现的代码都是用邻接矩阵来表示图，所以需要知道从 .gml 文件获得图的邻接矩阵，方法也很简单，像下面这样即可：import networkx as nx # 从数据集中读入图 gml = nx.read_gml("./football.gml", label='id') # 获得原始图的邻接矩阵 G = np.array(nx.adjacency_matrix(gml).todense())当然，我们还需要将自己实现社区划分的效果与一般的函数接口作对比，在 中有通用的 Louvain 算法的接口，那么我们以同样的方式安装该库即可：pip install python-louvainpython-louvain 库恰好跟 networkx 库一起使用，只需要像下面这样，就能把从 .gml 文件中读到的图使用官方接口进行社区检测，得到的就是每个点的社区标签，用于我们后面作对比。import networkx as nx from community import community_louvain gml = nx.read_gml("./football.gml", label='id') # 通过官方接口进行社区划分， partition = community_louvain.best_partition(gml)对于每一个数据集，我们手上有自己实现的 Louvain 算法所得到的社区标签、调用函数接口得到的社区标签，还可能有数据集上自带的真实的社区标签。我们既可以用一份标签来计算 modularity 模块度，也可以用两份标签来计算 NMI 标准化互信息，实验结果如下：（含有真实分类的数据集真的太难找了，几乎只有一个 football.gml 是用得舒服的）数据集A模块度B模块度C模块度NMI(A,B)NMI(A,C)NMI(B,C)football.gml0.5539733187140.5931529743000.4781514707170.8988419570850.7023605505970.710891868390polbooks.gml0.4149402769420.48455119009050.3938790935870.4216816748330.35400864251800.483870408489adjnoun.gml-0.24192664359860.20341038062280.243916955010.003087674412610.007049789599590.4346447199389karate.gmlnoun0.3613576594340.293885601577nounnoun0.479548636089lesmis.gmlnoun0.5326430652860.2591527683055nounnoun0.492993980138A代表数据集自带的真实社区划分，B代表本篇手动实现的社区划分，C代表函数接口的通用社区划分。可以看到，在正常的数据集中（比如前两个），我们实现的 Louvain 算法表现出了较好的效果，模块度是三者最高，与真实社区划分的 NMI 甚至超过了用接口计算的社区检测，这令我非常吃惊，可能是咱们写得太好了？另外要提一下第三个数据集 adjnoun.gml，它非常得特别，它是列出了文本中出现的形容词和名词，把这些词作为节点，如果形容词和名词连在一起出现，那么在这两个词的节点之间连上一条边，所以在这个数据集中，有边相连的两个点反而是不同类别的，而我们的社区检测更多是把有边相连的点看作同一类别，所以该数据集的性质跟我们的算法是反过来的，因此，可以看到那一行的各个数据都非常小。总的来说，我们实现的算法是很成功的。能较好地检测出网络中隐藏的社区，效果甚至比接口要好。心得体会这次的算法非常好玩，它的思想和实现都很简单，却能实现很神奇的效果。而且，像模块度这样东西，我觉得它很像那些数模比赛的产物。当初打数模比赛的时候，就是针对一个问题，自己构建个奇奇怪怪的模型去评估它。这个模块度也是，虽然整体思想看似很合理，但一切的构造都有点太巧合了，包括用度数求出期望边权、相减、逐社区求和，这些操作让我觉得它是那种先有公式后有理论的东西。就是那种，大家奇思妙想发明了很多公式，然后发现某一个公式的效果还不错，就共同决定使用那个公式，并给它编出合理的推论。无论怎么说，发明出这些算法的人还是很聪明的，善于从问题中抽象出数学模型，我在用代码实现时也感受到了快乐。
在计算机领域学习，多多少少都会遇到跟图像相关的领域，比如计算机视觉、图形学、图像处理。跟图像打交道的话，一般都会用到第三方库，而 opencv 就是一个非常常见的图像库。使用 opencv 需要先安装，对于 python 来说，安装只是一句命令行的事；而对于 c++ 来说，安装第三方库就没这么简单了，往往都会经历一些磕磕碰碰，那么这次就来介绍一下如何安装和使用 opencv。推荐阅读 【五仁C++】静态库？动态库？.a.so.lib.dll的生成与使用(g++版)为什么要源码编译opencv 是提供了 windows 二进制安装包的，也就是那种，用鼠标点几下就能成功安装，但是这种方式安装的 opencv 只能在 VS 里面使用，不能用通常的方式在其他的编译器中使用（微软不愧是你）。用过 VS-code 的应该知道，VS-code 本质上就是使用命令行来编译，通过调整 g++ 的参数来链接第三方库，想要以这种方式来使用 opencv，就必须进行源码编译。当然，源码编译出来的 opencv 在 VS 也能用。所以说，如果只想在 VS 里面使用 opencv 的话，就不需要看这篇文章后面的内容了，直接去官网下载个二进制安装包，鼠标点两下就安装完了。前置条件已安装 cmake 和 mingw先下载这是 opencv 的官网：https://opencv.org，从官网可以去到文档、论坛等地方，也可以去到 github。我们可以直接去 https://github.com/opencv/opencv/releases 找 opencv 的多个发行版本，在这些版本里面，以3开头的版本和以4开头的版本有较大的差别，它们的函数接口的规范可能都不一样，如果你的朋友使用 opencv 的 3.4.15 版本，而你下载了 4.5.5 版本，那么你朋友的代码在你的电脑上很可能无法运行，而如果你下载了 3.4.16 版本，虽然也可能有版本不兼容的问题，但这种可能性比较小。以我举例，我习惯了用 3 开头的版本，所以我就找到 3 开头的最晚的版本 3.4.16，然后下载它的源码。如下图，Source code 就是我们需要的源码，其他的不需要下载。提一下，下图那个 opencv-3.4.16-vc14_vc15.exe 就是前面提到过的用鼠标点几下就能安装的二进制安装包，不过只能在 VS 里面使用。下载完之后，解压到自己喜欢的位置（这个总不用教吧），然后随便新建一个文件夹，作为安装 opencv 的位置。如下图，我新建了一个名为 QwQ 的文件夹，这个文件夹的位置其实没有规定，你喜欢安装在哪里都行，但大伙通常都喜欢把它跟源码放在同一个位置，并且这个文件夹的命名一般为 build，不过这些都是约定俗成的东西。再重复一遍，我们需要新建一个文件夹来作为安装目录，文件夹的位置和命名都没用硬性要求，只不过一般情况下都跟源码放在一起，且命名为 build，不过我个人更喜欢用 QwQ 命名。跟着做下面将演示，如何利用 cmake 和 mingw 从源码编译出 opencv。运行 cmake 的 bin 目录下的 cmake-gui.exe，将会打开一个面板，我们首先要对 2 个地方进行设置。“where is the source code” 指的是源码目录，也就是我们从 github 上下载后解压出来的目录；“where to build the binaries” 指的是安装目录，也就是我们刚刚新建的目录。修改这两项，分别指向正确的目录（如下图所示）修改好两个目录之后，点击左下角的 Configure 按钮，此时正在进行的是配置编译器，它会弹出窗口让我们选择等下用于编译的编译器，如下图所示进行选择：点击 Next 按钮，如果是第一次选择某个编译器，它会弹出窗口让我们设置编译器在哪，选择 MinGW 中的 C 和 C++ 编译的 .exe 程序，他们在 bin 目录下（前提是已经安装好mingw）选择完编译器后就会开始第一步的配置，这一步主要是配置跟编译器相关的东西，请耐心等待右下角的进度条读完。上一步是配置编译器，完成之后，第二步将会进行安装选项的配置。opencv 的工程师们制作出了很多东西，但有些东西是我们不需要的，比如工具包里面有剪刀、锤子、钳等很多工具，我们往往只挑选自己需要的几个工具，这样可以节省空间。所以，第二步的配置主要是选择最后安装多少。每个选项都有默认值，我们唯一要修改的是 BUILD_opencv_world 这个选项，它的默认值是 false，也就是不打勾；我们需要手动将它的值设置为 true，也就是打勾。这个选项的主要作用是将编译后的多个库文件打包成一个，如果不勾选的话，使用 opencv 时常常要手动链接多个库文件，勾选之后就只用链接一个库文件了。勾选之后再次点击左下角的 Configure 按钮。完成了两步配置之后，就可以点击左下角的 “Generate”，等待显示 “Generate done”，就会生成一些跟编译相关的文件，类似于 Makefile 之类的。此时我们打开之前新建的安装目录，我的目录是 QwQ。可以看到里面出现了很多叫做 cmake什么什么的文件。此时还没有开始编译 opencv，如果类比于建一栋大楼，我们刚才的操作就是画出设计图，这些 cmake什么什么的文件就是等下用于指导编译的设计图，但还没开始施工，所以像 bin、lib 这些目录里面全是空的。下面要做的事就很简单了，只需要打开命令提示符 cmd，输入两句命令行即可。第一句命令行：按 win+R, 输入 cmd，打开 dos 窗口，切换到刚才新建的 QwQ 文件夹下，输入 minGW32-make 命令，这个时候大概花费 40 分钟左右的时间来编译。mingw32-make.exe 是 mingw 的 bin 目录下的可执行文件，有时根据需要会将其改名为 make.exe，这种情况下也要相应地将编译命令改为 make。minGW32-make如果你曾看过我最初的一篇博客【五仁C++】静态库？动态库？.a.so.lib.dll的生成与使用(g++版)，那么再看上图所显示出的绿色信息，就会知道这是在编译出中间文件 .obj，并生成库。第二句命令行：在等待编译完成后，输入命令 minGW32-make install，这是最后一步的安装。minGW32-make install在安装完成后，我们进入安装目录的 install 目录，对我来说就是 QwQ 里的 install 目录，里面包含了 opencv 的头文件以及库文件。如果配置过其他跟 c++ 有关环境的小伙伴可能知道，像这种包含 bin 目录的东西，我们在安装完之后都要配置环境变量 PATH，否则程序运行时是找不到动态库的。具体操作就不详细阐述了，去随便搜索“环境变量”就有教程，只需要像下图这样找到 bin 目录然后加入到环境变量 PATH 即可。使用 opencv写一个类似于 helloworld 的程序：#include <opencv2/opencv.hpp> using namespace cv; int main(){ Mat img; img = imread("test.jpg"); imshow("1", img); waitKey(); return 0; }VS-code 一般用 g++ 来编译 .cpp 文件，当我们的程序涉及外部库时，会用到它的 -I、-L、-l 参数。其中 -I 后面跟着头文件的目录，用于让编译器找到头文件，-L 后面跟着库文件的目录，用于告诉编译器哪里有库文件，这两个目录都可以在我们 opencv 的安装目录中找到，-l 后面跟着具体的某一个库，用于指定链接哪一个库，由于我们之前指定生成了 opencv_world 库，它整合了多个库，所以我们此时只需要一个库即可，具体参考下图的编译命令。需要关注的是，-I 和后面的一个目录，-L 和后面的一个目录，-l 和后面的一个库。这三个参数就足以让我们使用 opencv 了。结语c++ 是一个很神奇的东西，什么东西都能搞得很复杂。我有个老师曾说，最难的东西是 helloworld，我以前把它当成一句笑话，现在逐渐发现这是一句真理。即使是计算机专业的学生，也常常会在安装这一步卡住很久，更别说后面的任务了。看这篇文章的内容会发现，如果没有大家的分享，一个初学者连安装都很难搞明白，这也是我写这些文章的意义。如果当初我入门时可以有多几篇这样的教程，我的学习之路可能不会如此艰辛。这些跟编译有关的内容，很硬核，很枯燥，但也很值得记录。这些令人心烦的知识，只有反复多次观看，才能理解其精妙之处。这篇文章不仅是写给大家看，也是写给我自己看，温故而知新，希望你也能和我一样喜欢这篇文章。
【五仁图形学】Unity Shader 来个最简单的顶点片元着色器 Helloworld!
任何的程序，任何的语言，都有 Helloworld，它是初学者迈入新世界的大门，Unity Shader 也不例外。在实现光彩夺目的画面之前，我们首先来实现一个最简单的 Unity Shader。Helloworld以我以往的经验，此时应该直接写出所有的步骤，而不涉及任何的原理。也就是说，只动手，不动脑。（1）在 Unity 中新建一个场景，操作是点击菜单栏中的 File → New Scene，然后点击 Create，即可创建一个含有摄像头和平行光的场景。（2）新建一个 Unity Shader，操作是在项目面板中单机创建按钮，然后选择 shader，选任意一个模板都可以。（3）新建一个材质，类似于上一步，在项目面板中创建。然后把第 2 步创建的 Unity Shader 赋给这个材质，操作是在项目面板中把 Unity Shader 文件拖拽到材质文件上。 （4）在场景中新建一个球体，操作是在层级面板中创建。然后把第 3 步创建的材质赋给这个球体，操作是把项目面板中的材质文件拖拽到层级面板的球体上。（5）双击打开第 2 步创建的 Unity Shader。删除里面的所有代码，把下面的代码粘帖进去。Shader "Wuren/Helloworld" { SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag float4 vert(float4 v : POSITION) : SV_POSITION { return UnityObjectToClipPos(v); } fixed4 frag() : SV_Target { return fixed4(1.0, 1.0, 1.0, 1.0); } ENDCG } } }保存之后，返回 Unity 会自动编译，就能查看结果了。这段 shader 代码的效果如下图所示：首先，代码的第一行通过 Shader 语义定义了这个 Unity Shader 的名字。正如 C++ 的函数不一定要传入参数，Unity Shader 也不一定需要 Properties 语义块，我们可以选择不声明任何材质属性。然后，我们声明了 SubShader 和 Pass 语义块，没有进行任何的渲染设置和标签设置。接着，就是由 CGPROGRAM 和 ENDCG 所包围的 CG 代码片段，这是我们的重点，在 Unity Shader，被 CGPROGRAM 和 ENDCG 包围的部分才是我们真正用于计算的代码，在其中可以发挥我们无穷无尽的创意。在 CG 代码中，首先是两行非常重要的编译指令:#pragma vertex vert #pragma fragment frag它们将告诉 Unity，哪个函数包含了顶点着色器的代码，哪个函数包含了片元着色器的代码，上面这两句对应了后面的 vert 函数和 frag 函数。当然，想要个性化也可以写 #pragma vertex QwQ 并且后面定义一个 QwQ 函数作为顶点着色器。接下来，我们看一下 vert 函数的定义：float4 vert(float4 v : POSITION) : SV_POSITION { return UnityObjectToClipPos(v); }它跟 C 语言很像，函数的输入是 float4 类型，即 4 维向量，变量名是 v，返回值也是 float4 类型的变量。特别的地方在于函数中的 POSITION 和 SV_POSITION，它们是 CG/HLSL 语言中的语义（semantics），这是不可省略的，它们的存在，给变量赋予了一个身份。在图形学的渲染管线中我们了解到，着色器是有特定的输入输出的，比如顶点的模型空间的坐标会作为顶点着色器的输入，且顶点着色器的输出至少包含顶点的裁剪空间的坐标。我们可以定义顶点着色器的输入和输出包含很多变量，那么哪个变量作为那些特定的输入输出呢？于是，我们用 CG/HLSL 语言中的语义来给变量赋予身份，比如这里 POSITION 将告诉 Unity，变量 v 的身份就是模型的顶点坐标， 于是 Shader 运行时就会把模型的顶点坐标填充到输入参数 v 中，SV_POSITION 将告诉 Unity，顶点着色器的输出是裁剪空间中的顶点坐标。而 UnityObjectToClipPos(*) 函数是 Unity Shader 的内置函数，我们用英文来理解它，就是把顶点在模型空间中的坐标变换成裁剪空间中的坐标。在以前，这种操作是通过 mul(UNITY_MATRIX_MVP,*) 这句代码来实现的，也就是左乘变换矩阵，UNITY_MATRIX_MVP 在以前是内置变量。现在只不过是换了种形式，原理都是一样的。像 UnityObjectToClipPos(*) 这样的内置函数还有很多，我们以后也会用到，还会用到内置宏。没办法，学习一门语言总要背几个函数。幸运的是，这些内置函数的命名还算好理解，它们使用驼峰式命名法，拆开成几个单词来理解还是比较好记的。然后我们再来看一下 frag 函数：fixed4 frag() : SV_Target { return fixed4(1.0, 1.0, 1.0, 1.0); }非常的朴实无华，没有输入参数，只有 fixed4 类型的返回值。fixed 类型跟 float 类似，也是存储浮点数，只不过存储的位数不同，也就是精度不同，我们之后还会遇到 half 类型也是类似的，都是精度不同的浮点数。我们知道片元着色器的要求是，要输出片元的颜色，同样的，我们用语义 SV_Target 来给 frag 函数的返回值赋予一个身份，告诉 Unity 这个返回值就是颜色向量！ 这个 frag 函数直接返回一个全为 1 的向量，也就是纯白色，所以在 Unity 中看到的效果就是一个纯白色的球。在上面的例子中，在顶点着色器中我们使用 POSITION 语义得到了模型的顶点位置。那么，如果我们想要得到更多模型数据怎么办呢？比如模型的法线、纹理坐标，我们不仅想从模型中得到这些数据，还想把它们传递给片元着色器，那么顶点着色器和片元着色器之间要如何通信呢？Helloworld PlusC 语言有 C++，我们 Helloworld 也有 Helloworld++。如果说刚刚说的是真正意义上的 Unity Shader 的 Helloworld，那么接下来要介绍的，就是没那么 HelloWorld 的 HelloWorld。根据图形学的基础知识，我们知道一个顶点一般有多种属性，包括位置、法向量、纹理坐标。shader 还可能还需要接收一些额外的属性，比如材质本身的颜色、使用的纹理贴图等，这些属性会使用 Properties 语义传递给 Shader，那么我们前面的最简单的 Helloworld 是没办法延伸那些功能的。所以下面将会介绍一个，体系相对完整的 Helloworld，我们仍然先不使用任何的光照计算。重复之前的步骤，我们要改的只是 shader 代码：（1）在 Unity 中新建一个场景。（2）新建一个 Unity Shader。（3）新建一个材质，然后把第 2 步创建的 Unity Shader 赋给这个材质。 （4）在场景中新建一个球体，把第 3 步创建的材质赋给这个球体。（5）双击打开第 2 步创建的 Unity Shader。删除里面的所有代码，把下面的代码粘帖进去。Shader "Wuren/Helloworld Plus" { Properties { _Color ("Color Tint", Color) = (1.0, 1.0, 1.0, 1.0) } SubShader { Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag fixed4 _Color; struct a2v{ float4 vertex : POSITION; float3 normal : NORMAL; float4 texcoord : TEXCOORD0; }; struct v2f { float4 pos : SV_POSITION; fixed3 color : COLOR0; }; v2f vert(a2v v) { v2f o; o.pos = UnityObjectToClipPos(v.vertex); o.color = v.normal * 0.5 + fixed3(0.5, 0.5, 0.5); return o; } fixed4 frag(v2f i) : SV_Target { fixed3 c = i.color; c *= _Color.rgb; return fixed4(c, 1.0); } ENDCG } } }保存之后，返回 Unity 会自动编译，就能查看结果了。这段 shader 代码的效果如下图所示：在本篇最开始的 Helloworld 中，我们只渲染了一个纯白的球体，那个 shader 非常的简单，它的顶点着色器只接收了顶点的位置坐标，并且只把顶点的裁剪空间坐标传给片元着色器。显然，巧妇难为无米之炊，如果只使用这么少的变量，再精妙的算法也很难产生炫酷的效果。我们希望，一个具有高可操作性的 shader，应该可以让我们自由定义着色器的输入和输出，上面的 Helloworld Plus 代码就描述了这么一种自由的结构。让我们来看代码，首先 Properties 语义中包含了一个名叫 _Color 的属性，在 Properties 语义中定义的属性，是从 Unity 应用传入 shader 的值，选中小球时，可以在检视面板里看到这个属性，并可以修改（如下图）。我们可以把在 Properties 语义中定义的属性看作是跟材质有关的变量，即不同的材质可能有不同的色泽、亮度，但这些材质共用一个 shader，也就是会使用相同的算法来渲染。shader 代码的 _Color ("Color Tint", Color) = (1.0, 1.0, 1.0, 1.0) 中，"Color Tint" 是我们定义的在检视面板中显示的名称，"_Color" 是 shader 中的变量名，"Color" 是变量类型，"(1.0, 1.0, 1.0, 1.0)" 是显示在检视面板的默认值。然后我们来看 Pass 语义中的 CG 代码，首先依然是两个 #pragma 预定义，用以声明之后的两个函数分别作为顶点着色器和片元着色器。为了在 CG 代码中可以访问 Properties 语义 中的属性，我们需要在 CG 代码片段中提前定义一个新的变量，这个变量的名称和类型必须与 Properties 语义块中的属性定义相匹配，于是我们增加了一行 fixed4 _Color;。这里可能有疑问，为什么在 Properties 语义中定义过的东西还要重新定义一遍？其实有很多种合理的解释，其中一种就是，Unity Shader 中使用的是 shaderlab 语言，而我们写的渲染代码是 CG 语言，这是两种不同的语言（也可以用 GLSL 语言来替代 CG 语言），所以我们用 CGPROGRAM 和 ENDCG 将 CG 代码包裹起来，一种语言中定义的变量当然不能直接在另一种语言中使用。所以，在我们编写 Unity Shader 时，存在着隐式的数据传递，我们需要用重新定义同名变量的形式让 Properties 语义块中的数据传递给 CG 代码。显然，我们必须考虑两种语言的数据类型，就像浮点数不能存储在整型中一样，我们至少知道 Properties 中属性的类型能对应 CG 代码中的那些变量类型，如下表：ShaderLab 属性类型CG 变量类型Color, Vectorfloat4, half4, fixed4Range, Floatfloat, half, fixed2Dsampler2DCubesamplerCube3Dsampler3D继续看 Helloworld Plus 的代码，我们声明了两个结构体 a2v 和 v2f，它们有什么用呢？前面提过，我们想要自由定义着色器的输入和输出，既然我们的函数只有单个形参和单个返回值，那么用结构体来包含多个变量不就可以了嘛。于是，a2v 的意思是 "application to vertex shader"，即顶点着色器的输入，也就是把数据从应用阶段传递到顶点着色器中；v2f 的意思是 "vertex shader to fragment shader"，即顶点着色器的输入和片元着色器的输出。在 a2v 中我们定义了 3 个变量，它们都有各自的语义来代表变量的身份，在 CG 代码中，一些语义在特别的位置有特殊的作用，POSITION 语义我们前面提过，表示顶点的模型空间的坐标，NORMAL 语义表示法向量，TEXCOORD0 表示纹理坐标，这 3 者作为顶点着色器的输入时都是有特定含义的，因为 Unity 会根据这些语义来把数据填充进变量里。不过，3 个语义用在别的地方时，就没有特定含义。也就是说，在特定的地方使用特定的语义才会产生特定的作用，这些“特定情况”不多，一般需要我们记住。像 v2f 结构体中使用到的 COLOR0 语义，其实就没有特定的含义。补充说明一下，填充到 POSITION、NORMAL 这些语义的数据从哪里来，这个其实稍微了解一下就行。在 Unity 中，它们是由材质的 Mesh Render 组件提供的。在每帧调用 Draw Call 的时候，Mesh Render 组件会把它负责渲染的模型数据发送给 Unity Shader。我们知道，一个模型包含了多个顶点，每个顶点又包含了一些数据，例如法线、切线、纹理坐标等，通过语义，我们就可以在顶点着色器中访问顶点的这些模型数据。在定义了结构体后，着色器的函数就可以将结构体类型作为自己的形参和返回值了，就像代码中的 v2f vert(a2v v) 和 fixed4 frag(v2f i) : SV_Target，相比于本篇最开始的 Helloworld 代码的顶点着色器 float4 vert(float4 v : POSITION) : SV_POSITION，现在不再需要在函数头中声明语义，因为语义的声明已经放在结构体里了。需要额外提醒的是，一般情况下，变量和形参的命名请与这里的代码保持一致，也就是说，vert(a2v v) 的形参最好就叫 v，返回值最好就叫 o，frag 函数的形参也最好就叫 i，a2v 结构体中的变量最好就叫 vertex 、texcoord，等等。而不要擅自使用 QwQ 之类的变量名，这也许很令人困惑，变量名难道不是咱们程序员爱叫啥就叫啥吗？原来，Unity Shader 给我们提供了一些内置宏，宏可以用一小段字符串来表示一大段代码，如果某段计算在大部分算法中都被用到，那么它往往被设计成宏，而宏就相当于代码替换，在被替换的代码中会直接出现变量名，举个例子，宏 WUREN 会被替换成 a = a + 1，假如我们预先定义的变量名不是 a，那么使用这个宏就会报错。所以，并不是说变量名一定要统一，只是说，为了之后使用 Unity Shader 的内置宏来简化我们的代码，我们往往会规范自己的变量名。当然，如果你就是不用 Unity Shader 的内置宏，那么无论用什么变量名都无所谓。至于 Helloworld Plus 中使用到的渲染算法，则非常简单，只是把顶点的法向量归一化作为顶点的颜色，然后通过插值得到片元的颜色（插值是在顶点着色器和片元着色器之间自动进行的，代码中不体现）。有 c 语言基础的话应该马上就能理解代码。最后呈现的效果就是一个五彩斑斓的球体。结语本篇讲述了 Unity Shader 中最基础的语法理解，有如万丈高楼的地基一般。基本上，每一个有点用的 shader，都会用到本篇所提到的所有概念。在写这篇 Helloworld 的时候，其实我已经看了很多进阶知识，但越往后学，越觉得遗忘，逐渐看不懂代码，直到我回来总结了两篇博客（这篇和另一篇），有种“拨开云雾见月明”的通透感。另外总结一下本篇要背诵的知识，讲过 UnityObjectToClipPos(*) 函数，讲过一些特殊位置会发挥特殊作用的语义，包括 POSITION 、NORMAL 、TEXCOORD0 、SV_POSITION、SV_Target。网上能查到的知识很零碎，讲基础的更是少之又少，本篇博客既是我的一篇笔记，也是我希望帮助初学者的一点努力。最后希望你喜欢这篇博客~
首先简述问题和任务：正常情况下拍的一张照片无法同时将明亮的部分和阴暗的部分都显示清楚，我们需要将曝光时间长的照片（用于看清暗部）和曝光时间短的照片（用于看清亮部）进行融合，得到一张不论明亮都非常清晰的照片。组合在不同曝光设置下获取的同一场景的不同图像的过程称为高动态范围 (HDR)成像。前置知识（1） 我们平时所看到的图片文件，比如jpg、png那些，像素值是0~255的整数，我们称为 LDR（低动态范围）图片；而文件后缀名为 .hdr 的图片的像素值是小数，不是整数，在正常情况下是不能直接打开的，需要通过某个算法将小数的像素值转化为整数，才能在计算机中正常打开，这个算法称为色调映射算法。而某些能直接打开.hdr文件的软件，如PS（Photoshop），其实是有一个自己内置的色调映射算法。（2） 辐射强度的概念：单位时间内物体单位表面积辐射出某特定波长射线的能量，称为单色波长的辐射强度。常用单位为：W/m2，瓦特/平方米。以下将辐射强度称为辐照度。（3） 响应曲线的概念：当我们拍照的时候，现实场景中的光照会转化照片中的像素值，这可看作是一种映射关系，即函数。我们用字母表示物理量，辐照度为 E，曝光时间为 Δt，曝光为 X，那么照片的像素值（整数）对曝光X的响应是一个函数，将现实场景映射到照片中，这个函数就称为相机的响应函数（CRF），也可以叫做响应曲线。在曝光时间 Δt 不变的前提下，当某个光源变成两倍亮时（辐照度变为两倍），照片中的像素值往往不会变成原来的两倍，所以说响应曲线是一个非线性函数。任务分解我们的目的是将多张不同曝光时间的照片合成一张照片，合成之后的那张照片既能看清楚很亮的区域，也能看清楚很暗的区域。我们的步骤一共有三步，请记住：① 生成相机响应曲线② 将多张照片融合，得到一个后缀为 .hdr 的图片文件，像素值为小数③ 色调映射，将上一步得到的 hdr 图片转化成能查看的 jpg、png 图片文件在计算机 opencv 库中，上述的三个步骤可以对应三个函数。需要注意的是，由于计算机的函数可以重载，所以同名函数可以传入不同的参数。为什么要说这个呢，因为上述的第 ② 步可以不依赖于第 ① 步。正常情况下，确实应该先求出响应曲线，然后第 ② 步利用这个响应曲线来融合照片。但是，如果没有求出第 ① 步的响应曲线，也不妨碍第 ② 步的执行，前面提到，真实的响应曲线一般是非线性的，换而言之，我们可以假设一个最理想但不真实的响应曲线，他是线性的，然后用这个线性响应曲线来融合图片。简单的说，就是第 ② 步的融合函数有两个重载，一个需要传入响应曲线后进行融合，这个响应曲线是第 ① 步生成的；另一个重载不需要传入响应曲线，它直接使用一个固定的线性响应曲线，当然，这种固定的线性响应曲线一般不符合真实场景，所以融合出的图片也容易不符合预期。Debevec算法下面我们来介绍一下 Debevec 算法：这个算法包含两个部分，对应第 ① 步和第 ② 步，即生成响应曲线和生成 hdr 图片。如何求相机响应曲线。已有的材料是几张有亮有暗的照片，已知是这几张照片的各个像素的像素值，以及这几张照片的曝光时间 Δt。各符号的意义：曝光 X，辐照度 E，曝光时间 Δt，像素值 Z前提假设：X=E·Δt，请务必记住这个假设，它是Debevec算法成功的前提，以后会介绍当这个假设不成立时，Debevec 算法会如何失败。X=E·Δt 可以理解为，当辐照度 E 固定时，曝光度 X 随着曝光时间 Δt 线性增大，从人体直观的感受上来看，就是曝光时间越久的照片越亮。事实上，严格满足 X=E·Δt 几乎是不可能的，但是，越接近这个式子，Debevec 算法的效果就越好。前面说过，相机响应曲线是曝光 X 到像素值 Z 的函数，我们设为 f，即 $Z = f(X) = f(E·Δt)$，我们对应到具体的某一张照片上，即 $Z_{ij} = f(E_i·Δt_j)$，其中 $Z_{ij}$ 为第 j 张照片的第 i 个像素，$E_i$ 为照片中第 i 个像素所对应的位置在现实世界中的辐照度，$Δt_j$ 为第 j 张照片的曝光时间。这里可能会有疑问，一张照片不是有长和宽吗，这明明需要 2 个坐标才能表示一个像素的位置，为什么下标 i 可以表示照片某个像素的位置呢？虽然图片是二维像素矩阵，但实际上可以压成一维，比如一张分辨率 2×5 的图片，实际上也可以表示为一个 10 维的向量，这样我们就能用一个下标来表示任意位置的像素了。第 6 个像素即对应于分辨率 2×5 图片中第 2 行第 1 列的像素。公式推演下面我们对 $Z_{ij} = f(E_i·Δt_j)$ 这条式子作变换，首先是初始公式：$$Z_{ij} = f(E_i·Δt_j) \tag{1}$$因为我们假设 f 是单调的（环境越亮，照片就越亮），则它是可逆的，我们可以把 (1) 重写为：$$f^{-1}(Z_{ij}) = E_i·Δt_j$$两边同时取对数，得：$$ \ln{f^{-1}(Z_{ij})} = \ln{E_i} + \ln{Δt_j}$$我们定义复合函数 $g(x) = \ln{f^{-1}(x)}$，得：$$ g(Z_{ij}) = \ln{E_i} + \ln{Δt_j} \tag{2}$$我们由（1）式得到了（2）式，这就是我们的核心公式，我们将以（2）式为基础来建立模型。针对（2）式出现的字母，回想我们已知和未知的数据：各个点的像素值 $Z_{ij}$ 和各张图片的曝光时间 $Δt_j$已知，各个位置的辐照度 $E_i$ 和函数 g 未知。在这里再次明确我们的任务是求响应函数 f，注意 f 的值域是离散的，只有 0~255 的整数，相对应的，函数 g 的定义域也是只有 0~255 的整数，所以我们只需要求出函数 g 的 256 个点，即 $\{~g(0),g(1),\cdots,g(255)~\}$，就可以大致绘出响应曲线（小学生也懂的描点绘图法）。于是，我们把每个像素点对应的 $Z_{ij}$ 值和 $Δt_j$ 值代入（2）式，能得到多条方程，他们组成一个方程组，其中未知数为 $E_i$ 和 $\{~g(0),g(1),\cdots,g(255)~\}$，它们也是我们所要求出的方程组的解。显然，只要方程的数量等于未知数的数量，这个方程组就有唯一解。幸运的是，方程的数量往往是过多的，比如我们要合成 2 张 1920×1080 的照片，那么每张照片就有 2073600 像素，i 的范围是 1~2073600，j 的范围是 1~2，相当于用 4147200 个方程解 2073856（2073600+256）个未知数，这种方程个数大于未知数个数的方程组叫做超定方程组，一般是不存在精确解的，但是超定方程组一定存在最小二乘解，这也是机器学习中在拟合时常用的技巧。如果还记得线性代数中奇异值分解（SVD）的知识，就能知道可以用 SVD 求伪逆的方法来求解最小二乘法问题。由最小二乘的知识可以知道，用上述方法求出的解使得下列式子的 O 值最小：$$O = \sum_{i=1}^{N}\sum_{j=1}^{P}[g(Z_{ij}) - \ln{E_i} - \ln{Δt_j}]^2 \tag{3}$$其中 P 是图片数，N 是一张图片的像素数。在机器学习中我们可以把 O 值理解为损失函数。但是仅此而已还体现不出算法的精妙性，所以Debevec算法对上式做了以下变形：$$ O = \sum_{i=1}^{N}\sum_{j=1}^{P}[g(Z_{ij}) - \ln{E_i} - \ln{Δt_j}]^2+\lambda\sum_{z=Z_{min}+1}^{Z_{max}-1}g''(z)^2 \tag{4}\\ g''(z) = g(z-1) - 2g(z) + g(z+1) $$注意到，相比于（3）式，（4）式后面加多了一项求和的东西，这一项有什么作用呢。原来这一项是平滑项，之前的 O 值的表达式过于理想，没有考虑到现实环境中的噪声，噪声指的是环境中一些奇奇怪怪的因素所产生的干扰。如果不加平滑项，那么求出来的响应曲线可能不“丝滑”，可能连续的几个 g 值上下反复跳动，这显然是我们不想要见到的（我们希望它单调）。所以加入了平滑项，它令相邻的三个g值不会相差太远，（4）式中把进行 $g''(z)^2$ 求和，这样做的话，用 SVD 解出来的结果会自动地尽量符合 $g(Z_{ij}) = \ln{E_i} + \ln{Δt_j}$ ，同时使响应曲线尽量平滑，（4）式子中 $g''(z)^2$ 求和前面的系数 λ 是一个人为设置的超参数，可以理解为平滑度常数，需要根据环境中的噪声进行人为手动选择。算法总得掺点玄学，没点人工调参都不好意思发论文。下面继续变。我们来考虑一下，是否照片中的所有像素都同等重要？什么意思呢，考虑照片中有一部分是非常黑或者非常白，它能给予我们的信息其实是非常有限的，当我们观赏一张照片时，我们往往是盯着那些既不太亮又不太暗的部分，那些区域能给予我们更多的信息。从另一角度来说，因为像素值范围是有限的，当它取边界值时，往往不能反映真实世界中的光照，于是我们可以认为这些太黑或太白的像素是不那么重要的。这里需要区分绘画和照相，绘画中，使用纯黑或纯白来增强表现力是常用技巧，而不同于照相。那么就有一个很简单的想法，我们给每个像素加权，让它体现不同的重要性即可。所以，我们对 0~255 这 256 个像素值赋以不同的权重，越靠中间（127）的权重越高，越靠两边（0 或 255）的权重越低。那么 Debevec 算法的加权函数如下：$$ w(z)= \begin{cases} z - Z_{min} & for \quad z\le\frac{1}{2}(Z_{min} + Z_{max}) \\ Z_{max} - z & for \quad z\gt\frac{1}{2}(Z_{min} + Z_{max}) \end{cases} $$上式中，$z$ 是某一个像素值，$w(z)$ 是这个像素值的权重，$Z_{min}$ 和 $Z_{max}$ 是照片中所有像素的最小像素值和最大像素值，直接取 0 和 255 也可以。将权重函数加入到之前的最小二乘 O 值表达式就可以得到最终的式子了：$$ O = \sum_{i=1}^{N}\sum_{j=1}^{P}\{w(Z_{ij})[g(Z_{ij}) - \ln{E_i} - \ln{Δt_j}]\}^2+\lambda\sum_{z=Z_{min}+1}^{Z_{max}-1}[w(z)g''(z)]^2 \tag{5} $$我们要设法使得 O 值最小。下面将介绍如何用线性代数的知识来实现，方法是解线性方程组 $A\vec{x}=\vec{b}$ 。重申一遍，未知数为 $E_i$ 和 $\{~g(0),g(1),\cdots,g(255)~\}$，它们也是我们所要求出的方程组的解。因此，我们要想办法构造矩阵 A 和 向量 $\vec{b}$ 。构造线性方程组显然，未知数要放在 $\vec{x}$ 向量里面，所以 $\vec{x}=[g(0)~g(1) \cdots g(255)~\ln{E_1}~\ln{E_2} \cdots \ln{E_n}]$，而方程是 $$w(Z_{ij})g(Z_{ij}) - w(Z_{ij})\ln{E_i} = w(Z_{ij})\ln{Δt_j}\tag{a}$$，该方程对应（5）式中的 $\sum_{i=1}^{N}\sum_{j=1}^{P}\{w(Z_{ij})[g(Z_{ij}) - \ln{E_i} - \ln{Δt_j}]\}^2$ 部分，也就是加权后的（2）式。未知数和它的系数已经放在了等式的左边，如果学过线性代数的小伙伴应该知道，我们很轻松就能使用一个像素构造矩阵 A 的一行。假设当我们使用一张曝光时间为 0.2 秒的照片的第一个像素来构造方程时，假设它的像素值是6，则代入后的方程就是：$$w(6)g(6) - w(6)\ln{E_i} = w(6)\ln{0.2}$$将其具体展开就是：$$0·g(0)+\cdots+w(6)g(6)+\cdots+0·g(255) - w(6)\ln{E_1} - 0·\ln{E_2}- \cdots -0·\ln{E_2} = w(6)\ln{0.2}$$上面的线性方程放入增广矩阵 A|b 的一行就是：$$[0\cdots0~w(6)~0\cdots0~-\!\!w(6)~0\cdots0~|~w(6)\!\ln{0.2}]$$也就是说，每张照片的每个像素都可以构造矩阵 A|b 的一行，每一行只需要修改 3 个位置的值。以上面的例子来说，就是把 $w(6)$ 填入下标为 6 的位置（对应$g(6)$），把 $-w(6)$ 填入下标为 256 的位置（对应$\ln{E_1}$），并把 $w(6)\ln{0.2}$ 填入最后一列（对应向量$\vec{b}$）。其余的像素都仿照这样的方式填入矩阵 A|b 即可。（a）方程对应了（5）式的前半部分，我们还需要构造一些方程对应（5）式的后半部分，也就是平滑项。$$\lambda·w(z)g(z-1) - 2\lambda·w(z)g(z) + \lambda·w(z)g(z+1)=0\tag{b}$$（b）方程对应了（5）式中的 $\sum_{z=Z_{min}+1}^{Z_{max}-1}[\lambda w(z)g''(z)]^2$ 部分，你可能注意到我把 $\lambda$ 乘进去了，也就是把 $\lambda$ 变成了 $\lambda^2$，这其实没所谓，因为它只是一个人为决定的超参数，平不平方也就是影响我们手动调参的过程而已，只要你喜欢，也可以把（b）方程中的 $\lambda$ 开平方。至于（b）方程如何插入矩阵 A，其实跟前面的（a）方程是一样的，我们只需要在下标分别为 z-1、z、z+1 的地方填上系数 $\lambda·w(z)$、$-2\lambda·w(z)$、$\lambda·w(z)$即可。用 python 代码来构造 A|b 的话差不多就是下面这样（还不是可运行代码）：k = 0 for i in range(p_num): for j in range(img_num): zij = Z[j][(int)((int)(pixel_num / p_num) * i / col)][(int)(pixel_num / p_num) * i % col][channel] wij = w(zij + 1, Zmin, Zmax) A[k][zij] = wij A[k][n + i] = -wij B[k][0] = wij * exposure_times[j] k = k + 1 A[k][(int)((Zmin + Zmax) / 2)] = 1 k = k + 1 for i in range(Zmin + 1, Zmax): A[k][i - 1] = l * w(i + 1, Zmin, Zmax) A[k][i] = -2 * l * w(i + 1, Zmin, Zmax) A[k][i + 1] = l * w(i + 1, Zmin, Zmax) k = k + 1至此，我们就构造好了 $A\vec{x}=\vec{b}$ 中的矩阵 A 和向量 $\vec{b}$，而向量 $\vec{x}$ 是我们所求。一般情况下，矩阵 A 的行数远远大于其列数，也就是我们之前所说的超定方程组。使用线性代数的知识可以轻松解出 $\vec{x}$，并保证 $\vec{x}$ 是满足（5）式的最小二乘解，我们只需要取出 $\vec{x}$ 前 256 个数，即 $g(0)$ 到 $g(255)$，就能得到相机响应曲线了。如何利用相机响应曲线将多张图片融合成一张hdr图片我们上一步求出了函数 g 的 256 个值 g(0)~g(255)，他满足之前的（2）式：$$ g(Z_{ij}) = \ln{E_i} + \ln{Δt_j} \tag{2}$$移个项，即$$\ln{E_i} = g(Z_{ij}) - \ln{Δt_j} $$解开 $\ln$，即$$E_i = exp(g(Z_{ij}) - \ln{Δt_j}) $$将每张照片的每个像素代入上式，我们可以求出某一个像素位置的辐照度 $E_i$，这是一个小数，我们将辐照度 $E_i$ 作为最终生成的 hdr 图片的像素值即可。具体的方法其实也是加权平均：$$\ln{E_i}=\frac{\sum_{j=1}^{P}w(Z_{ij})(g(Z_{ij})-\ln{\Delta t_j})}{\sum_{j=1}^{P}w(Z_{ij})}$$其中 P 是照片的数量。也就是说，针对某一个位置的像素点，将不同曝光图片的这个位置的辐照度进行加权平均，求出最终 hdr 图片的这个位置的像素值。你也许有疑问，我们之前求 $\vec{x}$ 时不是把每个点的 $E_i$ 都求出来了吗，为什么现在又重新求一遍呢？原因之一是：我们往往不会将所有的像素点用于构造 $A\vec{x}=\vec{b}$ 这个线性方程组，毕竟一般情况下只要矩阵 A 的行数大于列数就存在最小二乘解，而求解的时间复杂度是很高的。如今随随便便一张照片都不止 100 万像素，几张图片的像素总量可能上千万，假如所有像素都用于构造 $A\vec{x}=\vec{b}$，解这个方程组可能得花上一年半载。实际操作时，我们往往是只选其中若干个位置的像素来构造矩阵，这样就减少了 $\vec{x}$ 的维度，同时也降低了矩阵 A 的行数和列数，降到能在短时间内求出解的程度，所以我们还要多花一部来求每个点的 $E_i$ 。那些选取的像素点可能是随机选的，也可能是有规律的（比如每间隔多少就选一个），甚至可能是人为挑选的，该算法的原论文就是人手动选点（搁这学术造假呢），选点的好坏也会影响相机响应曲线的好坏，从而影响最终图片的效果，这也是玄学之一。到这里，我们已经介绍完了Debevec算法的两个最精髓的部分——求响应曲线和融合图片，请容许我再说一遍合成图片的步骤：① 求响应曲线② 融合出 HDR 图片③ 色调映射，将 HDR 图片的像素值转化成 0~255 的整数显然，我们现在剩下最后一个步骤：色调映射。遗憾的是，Debevec算法并不包括色调映射有关的内容，我们演示时，将直接调用色调映射函数的接口，而暂时不关注色调映射算法具体的实现。而且，色调映射实际上是一个不错的研究方向，有许多人在这领域中研究，所以也存在多种色调映射算法。以下代码就是一个完整的高动态范围成像示例：import numpy as np import cv2 np.set_printoptions(suppress=True) # 读图 img_fn = ["img_0.033.jpg", "img_0.25.jpg", "img_2.5.jpg", "img_15.jpg"] img_list = [cv2.imread(fn) for fn in img_fn] exposure_times = np.array([0.033, 0.25, 2.5, 15.0], dtype=np.float32) # 对齐 alignMTB = cv2.createAlignMTB() alignMTB.process(img_list, img_list) # 计算响应曲线 calibrateDebevec = cv2.createCalibrateDebevec() responseDebevec = calibrateDebevec.process(img_list, exposure_times) print(type(responseDebevec),responseDebevec.shape) print(responseDebevec) # 融合 mergeDebevec = cv2.createMergeDebevec() hdrDebevec = mergeDebevec.process(img_list, exposure_times, responseDebevec) cv2.imwrite("hdrDebevec.hdr", hdrDebevec) # 色调映射 tonemapMantiuk = cv2.createTonemapMantiuk(2.2, 0.85, 1.2) ldrMantiuk = tonemapMantiuk.process(hdrDebevec) ldrMantiuk = 3 * ldrMantiuk cv2.imwrite("ldr-Mantiuk.jpg", ldrMantiuk * 255)代码执行结果如下，把多张明暗不一的照片组合成一张照片：
Shader 的中文翻译是着色器，它的作用是，将场景中的物体经过一些列计算，最终呈现在屏幕上的一个个像素点。要学会编写 Unity Shader，需要一些图形学的前置基础知识，包括渲染管线、线性代数、一点编程经验。在 Unity 中，如果想要呈现绚丽的画面，我们就要学会写 Unity Shader，本篇主要讲述了 Unity Shader 中的一些关键基础概念。一个单独的 Unity Shader 是无法发挥任何作用的，它必须和材质结合起来。Unity Shader 支持多种 shader 语言，但我们一般使用 CG 语言来写，即 C for Graphic。ShaderLabUnity 提供了一种专门为 Unity Shader 服务的语言——ShaderLab。在传统的编写着色器方式中，程序员往往要处理很多文件和设置，才能让画面呈现出想要的效果，比如载入模型、根据平台选择不同接口、设置着色器的输入、把资源加载到GPU、设置渲染状态、对渲染状态进行排序等等，而使用 ShaderLab 语言编写的 Unity Shader 可以完成这些所有的工作。ShaderLab 是一种说明性语言，它使用了一些嵌套在花括号内部的语义（syntax）来描述一个 Unity Shader 文件的结构Unity Shader 的结构Unity Shader 里有多种语义，比如 Properties、SubShader、Fallback等。这些语义定义了 Unity Shader 的结构，从而帮助 Unity 分析该 Unity Shader 文件，以便进行正确的编译。名字每个 Unity Shader 文件的第一行都需要通过 Shader 语义来指定该 Unity Shader 的名字。这个名字由一个字符串来定义，例如“QwQ”。当为材质选择使用的 Unity Shader 时，这些名称就会出现在材质面板的下拉列表里。通过在字符串中添加斜杠“/”，可以控制 Unity Shader 在材质面板出现的位置。例如：Shader "wuren/QwQ" { }材质和 Unity Shader 的桥梁: Properties在编写 Shader 时，我们往往需要一些“外来”的属性，比如场景中出现了两个球，我们想要它们使用同一种光照算法，那自然而然地，就会只写一个 Unity Shader，然后绑定到那两个球上。而如果我们想要两个球分别是红色和蓝色的，那么显然，“红”和“蓝”就要作为材质传给 Shader 的一个变量，然后 Shader 再根据这个变量进行一些列的运算。从 C++、Python 那些高级编程语言的角度来看，我们可以把 Unity Shader 看成一个大函数，Properties 就是这个函数的传入形参，只不过在 Unity Shader 中我们将其称为属性。Properties 语义块的定义通常如下：Properties{ Name ("display name", PropertyType) = DefaultValue Name ("display name", PropertyType) = DefaultValue }在上面的 Properties 语义块中，Name 指的是属性名，也是我们在 Shader 代码中使用的变量名；display name 指的是出现在材质面板上的名称，也就是说，一个属性其实有两个名，一个对内使用，一个对外作描述作用；PropertyType 指的是属性的类型，DefaultValue 指的是属性的默认值。如果对应 C++ 的话，C++ 函数的1个传入形参有变量类型、变量名和默认值，就像 void print (int a = 1) 这样，那么 Properties 语义块的 PropertyType 就对应了变量类型，Name 对应了变量名，DefaultValue 对应了默认值。下面是 Properties 语义块支持的属性类型：属性类型默认值的定义语法IntnumberFloatnumberRange(min,max)numberColor(number,number,number,number)Vector(number,number,number,number)2D"defaulttexture"{}Cube"defaulttexture"{}3D"defaulttexture"{}Int、Float、Range 是数字类型的属性，它们的值就是一个单独的数字；Color 和 Vector的值是四维向量，也就是 4 个数字；2D、Cube、3D 这 3 种是纹理类型，默认值是一个字符串后跟一个花括号，字符串要么是空的，要么是内置的纹理名称，如white、bump等，花括号本来用于指定纹理属性，但 Untiy 5.0 以后把这个功能移除了，可以视为残留的无用语法。重量级成员: SubShader从中文的角度来看的话，Shader 是着色器，SubShader 就是子着色器。一个 Unity Shader 可以有多个 Subshader，为什么呢？这是因为考虑不同平台、不同显卡的差异，只写一段 shader 代码可能无法在所有平台上运行。假如场景中有一个球，我们为它绑定了一个 Unity Shader 文件，这个文件包含多个 SubShader 语义块，则加载运行时，Unity 会扫描所有的 SubShader 语义块，然后选择第一个能够在目标平台上运行的 Subshader。所以，一个 SubShader 应当包含了一个材质的所有的渲染计算。一个 Unity Shader 文件即使包含多个 SubShader，渲染时也只有一个 SubShader 在执行。SubShader 语义块中包含的定义通常如下：SubShader { //可选的 [Tags] //可选的 [RenderSetup] Pass{ } //其他的Pass }SubShader 中定义了一系列 Pass 以及可选的状态（[RenderSetup]）和标签（[Tags]）设置。状态指的是渲染状态，比如是否开启深度测试、是否开启混合模式等等，标签用来告诉 Unity 的渲染引擎——自己希望怎样以及何时渲染这个对象。每个 Pass 定义了一次完整的渲染流程。这个 Pass 又是什么意思呢，让我们设想一个场景，里面有多种光源，包括平行光、环境光、点光源、聚光灯等等，每种光源也可能有多个，而每个光源又有不同的属性（比如亮度），且都会对场景中的物体产生影响。显然，在渲染某个物体时，我们在 Shader 中一般没办法知道场景中究竟设置了多少光源，那么一种通俗的想法就是：依次计算每个光源对物体产生的影响，然后把所有光源的影响叠加起来。例如，在场景中有 A 光源和 B 光源，以及一个球，当我们要把这个球渲染到屏幕上时，对 A 光源运行一次球的 SubShader，得到球的颜色亮度是 0.1，对 B 光源也运行一次球的 SubShader，得到球的颜色亮度是 0.2，那么最后在屏幕上的球的颜色亮度就是 0.3（只是举例，不一定是这样算），也就是说，光源越多，球越亮，也符合我们的一般常识。而且，一般来说，对于不同类型的光源，我们使用不同的计算方法。例如点光源需要考虑光源的位置，而平行光却不需要，那么 Unity 在渲染时，虽然它们都会运行同一个 SubShader，但会运行不同的 Pass 中的代码，这也就从一个角度理解了为什么 SubShader 中有多个 Pass 且每个 Pass 定义了一次完整的渲染流程。另外，在渲染时，对于面的正面和反面，我们也会采取不一样的计算方式，这也会分成不同的 Pass。总的来说，一个物体，或者说一个材质，在呈现到屏幕之前可能会经过多次渲染。每次渲染可能对应了不同的光源，可能对应了正反面，也可能是其他的不同情况。SubShader 中的一个 Pass 就是一次渲染，往往会根据情况的不同而选择特定的 Pass 来执行，而一种情况也可能对应多个 Pass （比如闲得无聊把漫反射和镜面反射分开到两个 Pass 中），每次渲染都能得到一个结果，最后把多个结果按照某种方式结合起来就是屏幕上呈现的画面。Pass 语义块包含的语义如下:Pass { [Name] [Tags] [RenderSetup] }可以定义 Pass 的名称，这样就能在别的 Shader 中直接引用，就像调用函数一样。Pass 中的标签 Tags 不同于 SubShader 中的标签，但也是用于告诉渲染引擎要怎样来渲染该物体。留一条后路: Fallback紧跟在各个 SubShader 语义块后面的，可以是一个 Fallback 指令。它是兜底用的，假如我们自己写的所有 SubShader 都在某块显卡上都不能运行，那就使用一个 Unity 内置的最低级的 Shader。语义如下：Fallback "name" //或者 Fallback Off我们可以通过一个字符串来告诉 Unity 这个“最低级的 Unity Shader ”是谁，也可以任性地关闭 Falback 功能，但一旦这么做，意思大概就是:“如果一块显卡跑不了上面所有的 SubShader，那就不要管它了!”结语一开始没有打算写这篇基础知识，想直接从代码开始介绍，但是想到，Unity Shader 即使是 Helloworld 也非常复杂，不作解释的话基本看不懂，不像 C 语言那种两三行就写一个最简单的程序。书和文档的介绍过于详细了，于是我打算把 Helloworld 中会出现的东西先在本篇介绍一遍，然后再慢慢扩展。毕竟，万丈高楼平地起，磨刀不误砍柴工。希望你喜欢这篇博客~
【五仁python】来给你的朋友做一个随机涩图程序
本篇仅供学习，请自觉遵守我国法律法规。科学上网，从我做起。偶然发现，有一个可以返回随机涩图的API https://api.lolicon.app/#/setu，不仅可以涩涩，还涉及了比较有意思的网络知识。这就又能水一篇博客了。 本篇有涉及 json 格式的文本，但没有对 json 作详细介绍，有兴趣的可以了解一下，不知道也不影响阅读。什么是API首先要知道什么是API，我从维基百科里找了一段解释：应用程序接口（英語：application programming interface），缩写为API，是一种计算接口，它定义多个软件中介之间的交互，以及可以进行的调用（call）或请求（request）的种类，如何进行调用或发出请求，应使用的数据格式，应遵循的惯例等。简单的来说，API 就是一种看不见的工具，它会告诉你怎么去使用这个 API，以及使用 API 后会得到什么样的结果，而我们并不知道其内部是怎么实现的。就好比我们都知道按下电饭煲的按钮就能煮好一锅饭，而不需要知道电饭煲内部的电路结构、电阻电压之类的具体实现。于是，对于一个 API 来说，我们只关心两个东西：用法和结果。API 可以分很多大类，我们将要用到的属于 Web API。可以试着选择下面的一个链接，复制粘贴到浏览器上，并进行访问，看看会的到什么结果。然后重复操作，重新粘贴再访问（无重定向的情况可以直接刷新），看看得到的结果有什么不同。https://v1.hitokoto.cn/https://api.9jojo.cn/hitokotohttps://api.9jojo.cn/acgpichttps://api.9jojo.cn/acgmp3这里以 https://v1.hitokoto.cn/ 为例，我们用浏览器访问这个链接之后，得到了如下所示的内容：不难看出，这是一段符合 JSON 语法的文本，将其展开如下：{ "id":476, "uuid":"7601bbef-a8a5-4d04-929e-05d07d0e793d", "hitokoto":"君生我未生，我生君已老。 君恨我生迟，我恨君生早。", "type":"g", "from":"全唐诗续拾", "from_who":null, "creator":"Sai", "creator_uid":0, "reviewer":0, "commit_from":"web", "created_at":"1468950842", "length":25 }https://v1.hitokoto.cn/ 是 一言 的 API 接口，随便百度搜索“一言”就能搜到，这是一个可以返回随机句子的 API，前面说过，我们只关心 API 的用法和结果，那么用浏览器访问这个链接就是一种用法，所返回的 json 对象就是结果，其中包括了句子的内容、长度、来源和其他多种属性。再具体一点，用浏览器访问可以看作是一个 get 请求，那么这就可以运用到我们的程序设计语言中，比如 python，我们只需向这个链接发送一个 get 请求，然后将返回的 json 对象进行解析，就能在程序运行时得到随机句子。用 python 发 get 请求并解析 json 对象我们要使用的随机涩图 API 的接口链接是 https://api.lolicon.app/setu/v2，跟前面一样，直接用浏览器访问就能看到效果，返回的也是一个 json 对象，我们先用 python 进行第一步处理:import requests response = requests.get("https://api.lolicon.app/setu/v2") datas = response.json() print(datas)在上述代码中，我们使用了 python 的 requests 库，如果没有的话可以使用命令行 pip install requests 进行安装。库如其名，这个库的主要功能就是发送 HTTP 请求和接收其响应，requests.get()函数传入一个链接，响起发送一个 get 请求，然后返回一个 response 对象。我们用浏览器访问时已经知道返回的是一个 json 格式的文本，于是可以调用 json() 函数将其解析，该函数返回一个 python 字典，可以直接用 print() 看到其内容。这里就不得不吐槽 python 的机制了，字典里可以套列表，列表里面又能套字典，print 又不分行，里面那么多个东西，一眼看过去是很难知道怎么用下标访问的，可以使用数括号的方法，也可以格式化上图中的字典：{ 'error': '', 'data': [{ 'pid': 85163311, 'p': 0, 'uid': 9009855, 'title': '爽', 'author': 'Xo', 'r18': False, 'width': 3507, 'height': 2480, 'tags': ['明日方舟', 'Arknights', 'W', '裸足', '赤脚', '足指', '脚指', 'ペディキュア', '美甲（脚趾）', '美脚', '美腿', 'アークナイツ', 'ギリシャ型', 'Greek foot', 'マニキュア', '美甲'], 'ext': 'jpg', 'uploadDate': 1603324391000, 'urls': { 'original': 'https://i.pixiv.cat/img-original/img/2020/10/22/08/53/11/85163311_p0.jpg' } }] }可以看到，API 返回的字典中有很多的信息，基本都是键值对的形式，请重点关注其中的urls。我们可以在 API 的官网 https://api.lolicon.app/#/setu 找到各属性的说明，截取其中部分如下图所示。在这么多属性中，最重要的一个是 urls 中的图片地址，也就是上面的 https://i.pixiv.cat/img-original/img/2020/10/22/08/53/11/85163311_p0.jpg，它的 .jpg 后缀十分清晰地告诉我们，这个链接指向了一个图片文件，那么它就是我们所要找的随机涩图。根据python语法，我们可以用字典的["data"][0]["urls"]["original"]下标来访问链接。import requests response = requests.get("https://api.lolicon.app/setu/v2") datas = response.json() print(datas["data"][0]["urls"]["original"])但是，事情还没有这么快结束，假如我们在中国大陆内，在浏览器中输入上述的图片链接，会发现无法访问。我们需要把链接中的主域名 i.pixiv.cat 换成 i.pixiv.re，才能访问。这就涉及到了代理和反向代理的知识。正向代理和反向代理代理可以理解为经纪人，我们以客户端-服务端的结构来理解正向代理和反向代理。当没有代理的情况下，我们访问一个链接时，就是作为客户端的身份向服务端发送请求。对于正向代理，我们考虑一种情况：疫情封校了，同学 A 在校内，而奶茶店在校外，于是同学 A 无法出去买奶茶，但是同学 A 叫了校外的一个路人 B 去买奶茶，领到之后将奶茶通过围栏交到同学 A 手上，于是同学 A 就成功取到了奶茶。在这个过程中，路人 B 就扮演正向代理的角色，对于买奶茶这个行为，路人 B 代替同学 A 做这个事，而奶茶店老板不知道真正买奶茶的人是谁，这点非常关键。我们常说的代理也就是指正向代理，正向代理的过程，它隐藏了真实的请求客户端，服务端不知道真实的客户端是谁，客户端请求的服务都被代理服务器代替来请求，某些科学上网工具扮演的就是典型的正向代理角色。用浏览器访问 https://www.google.com/ 时，被残忍的block，于是我们可以在国外或香港搭建一台代理服务器，让代理帮我们去请求 google.com，代理把请求返回的相应结构再返回给我们。对于反向代理：大家都有过这样的经历，拨打10086客服电话，可能一个地区的10086客服有几个或者几十个，你永远都不需要关心在电话那头的是哪一个，叫什么，男的，还是女的，漂亮的还是帅气的，你都不关心，你关心的是你的问题能不能得到专业的解答，你只需要拨通了10086的总机号码，电话那头总会有人会回答你，只是有时慢有时快而已。那么这里的10086总机号码就是我们说的反向代理。客户不知道真正提供服务人的是谁。反向代理隐藏了真实的服务端，当我们请求 www.baidu.com 的时候，就像拨打10086一样，背后可能有成千上万台服务器为我们服务，但具体是哪一台，你不知道，也不需要知道，你只需要知道反向代理服务器是谁就好了，www.baidu.com 就是我们的反向代理服务器，反向代理服务器会帮我们把请求转发到真实的服务器那里去。Nginx就是性能非常好的反向代理服务器，用来做负载均衡。用一句话总结就是，正向代理隐藏真实客户端，反向代理隐藏真实服务端。来点涩图！那代理跟我们的涩图有什么关系呢？有兴趣的小伙伴可以试着搜索一下GFW，维基百科上有解释，简单地说就是，在中国境内，某些国外网站是会被屏蔽的，俗称被“墙”，也就是说，在大陆内无法直接访问某些国外网站，必须要通过代理（正向和反向都可以）。通常会使用香港或台湾上的服务器作代理服务器，让它充当一个中间人的角色，从而实现科学上网。从随机涩图 API https://api.lolicon.app/#/setu 的官网描述中可知，图片来源于 pixiv 插图网，其图库的官方域名为 i.pximg.net ，该网站含有大量不健康的图片，理所当然的被墙，所以要寻找 i.pximg.net 的代理。使用科学上网访问 https://pixiv.cat/ 可以发现两个反向代理，一个是 i.pixiv.cat，一个是 i.pixiv.re，前者在中国大陆被墙（是的，代理也是能被墙的），后者在大陆内还能用。这也就解释了为什么我们之前访问 https://i.pixiv.cat/img-original/img/2020/10/22/08/53/11/85163311_p0.jpg 不成功，我们需要将其中的 i.pixiv.cat 替换成 i.pixiv.re，就能成功访问。想用 python 下载网站图片文件的方式有很多，可以自己尝试搜索一下，包括但不限于使用 requests、urllib、wget、aiohttp 等库，以及不同的方式使用（比如显示进度条）。在本篇前面我使用了 requests 库，这里也不打算引入新库了，使用 get 函数配合一般的 python 文件读写操作也能做到下载文件，就像下面的代码一样：import requests response = requests.get("https://api.lolicon.app/setu/v2") datas = response.json() img_url = datas["data"][0]["urls"]["original"] img_url = img_url.replace('i.pixiv.cat', 'i.pixiv.re') print(img_url) img = requests.get(img_url, stream = True, timeout = 10) with open("1.jpg", "wb") as f: f.write(img.content)我把代码分成了三部分：访问 API 获取图片属性→使用反向代理（替换图片链接url的主域名）→下载图片保存到文件中，在第二次 get 函数的使用中，我添加了 stream 参数和 timeout 参数，它们是跟网站速度、防卡死有关的，这里不细说。后面就是简单地把图片保存到 1.jpg 中。运行上面的代码就能在当前目录的 1.jpg 中看到涩图啦！更好的涩图！这部分只是单纯地修改 HTTP 请求的参数而已，从随机涩图 API https://api.lolicon.app/#/setu 的官网描述中可知，我们可以调整参数来限制随机涩图的范围，包括但不限于作者、图片标签、图片规格、时间等等，这个属于网络的基础知识，直接看如何在 python 中运用，如下。import requests params = { "r18" : 0, "tag" : ["崩坏3|原神"], "dateAfter" : 1600000000000 - 200 * 86400000,#一天是86,400,000毫秒，一年是31,536,000,000毫秒 } response = requests.get("https://api.lolicon.app/setu/v2", params=params) datas = response.json() img_url = datas["data"][0]["urls"]["original"] img_url = img_url.replace('i.pixiv.cat', 'i.pixiv.re') print(img_url) img = requests.get(img_url, stream = True, timeout = 10) with open("1.jpg", "wb") as f: f.write(img.content)可以看到，我们可以自行定义一个字典，将字典传入第一次 get 函数，就能影响返回的随机涩图。更多的涩图！当我们已经完全掌握如何生成一张自己喜欢的涩图之后，我们就可以运用自己的 python 技巧，通过循环来使程序运行之后不断生成涩图，然后再加入一点合理的人机交互，好让朋友学会使用你的程序。import requests import os r18 = input("0为非 R18，1为 R18，2为混合，请输入 0 或 1 或 2: ") while r18 not in ["0","1","2"]: print("\n非法输入，请重新输入") r18 = input("0为非 R18，1为 R18，2为混合，请输入 0 或 1 或 2: ") r18 = eval(r18) download_num = eval(input("请输入需要获取的图片数,输入-1则无限下载，期间可按下Ctrl+C或关闭窗口以停止: ")) print("\nr18:", r18) tag = "崩坏3|原神" print("tag:",tag) params = { "r18" : r18, "tag" : [tag], "dateAfter" : 1600000000000 - 200 * 86400000,#一天是86,400,000毫秒，一年是31,536,000,000毫秒 # "proxy" : 0 } dirs = os.listdir("./") #获取当前目录下所有目录和文件名 img_i = 1 while download_num != 0: download_num -= 1 while str(img_i) + ".jpg" in dirs or str(img_i) + ".png" in dirs: img_i += 1 print("\nget url from api...") response = requests.get("https://api.lolicon.app/setu/v2", params=params) datas = response.json() # print(response.url) # print(datas["data"]) img_url = datas["data"][0]["urls"]["original"] print("image-url:", img_url) # img_url = img_url.replace('i.pixiv.cat', 'i.pximg.net') img_url = img_url.replace('i.pixiv.cat', 'i.pixiv.re') #i.pixiv.cat在中国被墙了 # print(img_url) print("using reverse proxy, downloading...") img_response = requests.get(img_url, timeout = 10) img_format = img_url[-3:] img_format = "jpg" #这一行固定生成jpg图片，可以注释掉这一行，就会实际调整jpg或png with open(str(img_i)+"."+img_format, "wb") as f: f.write(img_response.content) print("已将图片保存至当前目录，命名为", str(img_i)+"."+img_format,", r18?",datas["data"][0]["r18"]) img_i += 1 os.system("pause")使用pyinstall打包python程序记得本篇的标题吗，给朋友做一个随机涩图程序，这个朋友的电脑上可能没有安装 python，它也许不懂任何的计算机知识，只知道双击打开文件，那么我们如何跟朋友一起涩涩呢。这里介绍一个很有趣的工具—— pyinstall，使用它可以将 python 程序生成可直接运行的 .exe 程序，这个程序就可以被分发到其他人的电脑上独立运行。python 默认并不包含 pyinstaller 模块，因此需要自行安装 pyinstaller 模块。安装 pyinstaller 模块与安装其他 python 模块一样，使用 pip 命令安装即可。在命令行输入如下命令：pip install pyinstaller假设我的 python 代码保存在 setu.py 中，那么使用 pyinstaller 生成对应的可执行文件的命令行如下，记得要替换自己的代码文件名。pyinstaller -F --distpath . setu.pypyinstaller 的具体使用可以上网搜索。执行完上述命令行后，在当前目录下会生成一个跟代码文件名字相同的可执行文件 setu.exe，以及一个 build 目录和一个 .spec 文件，除了可执行文件外的都是可以删除的，它们属于中间文件，删除后并不影响 .exe 的正常运行。此时，双击 .exe 可执行文件，就能运行啦，效果跟原代码文件一模一样。需要注意的是，pyinstaller 虽然在多平台上都可以使用，但是它不能生成跨平台的可执行文件，也就是说，在 windows 上使用 pyinstaller 打包的文件只能在 windows 上执行，同理，在 macOS 上使用 pyinstaller 打包的文件只能在 macOS 上执行。结语i.pixiv.re 这个反向代理，虽然在中国大陆内可用，但是它有点不稳定，有时会变得很卡，于是下载不了图片，遇到这种情况等一会重试就好了。最后奉劝一句，小涩怡情，大涩伤身，希望各位以学习为重，祝各位身体健康，万事如意。
这是我在写博客时遇到的问题，网页加载图片的速度并不快，想要网页显示图片又不拖慢速度的话，就要限制图片的大小。我们平时为了追求高质量图片，对于自己一见钟情的图片，通常都会选择保存原图，而原图大小几乎都是以 Mb 为单位的，肯定是不能直接放到网页上的，否则就会加载非常非常非常久。于是我百度了一下“压缩图片”，出来的全是广告，又要下载这个那个的，还不如自己写个程序，于是就有了这篇文章。不过，本篇只讨论函数接口的调用，并不打算详细研究其实现，也不深入探究各种图像压缩算法，也就是说，只讲使用，不讲原理。使用 python 的原因很简单：环境配置容易，代码简洁，有成熟的函数直接供我们使用。环境准备（安装python包）我们将会用到两个库，一个是 opencv，这个是著名的计算机视觉库了，另一个是 pillow，也就是 PIL(Python Image Library)，这个也是经典的 python 图像处理库。执行下面两句命令行，利用 pip 工具安装两个工具库：pip install opencv-python==3.4.17.61pip install pillow压缩分辨率以 .jpg 图片为例，图片文件的大小由很多因素决定，其中比较重要的有两个，一个是图像的分辨率，另一个是图像的质量。图像的分辨率决定了像素的总量，而图像的质量往往跟色彩有关。我们可以从这两方面去压缩图片的大小，下面先介绍如何改分辨率，先上代码：import cv2 img = cv2.imread("img.jpg") # 读取原图 print(img.shape) # 查看原图分辨率 new_img = cv2.resize(img, (1080, 1920)) # 修改图片分辨率 print(new_img.shape) # 查看修改后的图片分辨率 cv2.imwrite("new.jpg", new_img) # 保存修改后的图片上面的代码非常简单，只有三个步骤：读取原图→改分辨率→保存新图。python 中的 opencv 在使用时叫 cv2，所以在使用 opencv 的函数前，要先写一句 import cv2 导入 opencv 库。imread 函数用于读取图片，传入的是图片文件名（后缀名记得写对），返回一个多维数组，其中存储了图片的像素值。需要注意的是，我们平时描述图片的分辨率习惯用行数×列数的形式，比如 1920×1080、1280×720 之类的都是行×列的形式，但是 opencv 读取图片后的多维数组是列数×行数的形式，所以，在调用 resize 函数来修改分辨率时，我们要小心行数和列数的位置，不要写错。resize 函数的第一个传入参数是原图，第二个参数是一个元组，以（列，行）的形式表示新图的分辨率，返回新图的多维数组。imwrite 函数用于写入图片，第一个参数是文件名，第二个参数是多维数组，其中保存了图片的像素值。上面的代码执行完毕后，会把 img.jpg 修改成 1920×1080 的分辨率然后保存在 new.jpg 中。但是这显然有一个问题，因为 1920×1080 的图片的横纵比是 16:9，但是并不是所有的图片都是 16:9 ，如果每张图片都按这样的方法压缩的话，就会改变图片的横纵比，使图片变形。想要保持图片的横纵比的话，我们可以等比例压缩，使得新图的横纵比跟原图保持一致。实现等比例压缩有两种方法，一是我们自己手动算出最终的分辨率，然后代入上面的代码，比如上面的 resize 函数调用改成 new_img = cv2.resize(img, (img.shape[0]*0.5, img.shape[1]*0.5))，这样就把图片的行数和列数都变成原来的一半。前面所说的方法，都是以固定分辨率的形式进行传参，而 resize 函数有更直接的方式让我们按比例改变图片分辨率，我们将代码修改为下面的形式：import cv2 img = cv2.imread("img.jpg") # 读取原图 print(img.shape) # 查看原图分辨率 rate = 0.5 # 修改分辨率的比例 new_img = cv2.resize(img, (0, 0), fx = rate, fy = rate) # 修改图片分辨率 print(new_img.shape) # 查看修改后的图片分辨率 cv2.imwrite("new.jpg", new_img) # 保存修改后的图片可以看出，resize 函数允许我们用 fx 参数和 fy 参数来用百分比的形式修改图片的分辨率，这两个参数会分别乘上原图的行数和列数来得到新图的分辨率，当给 fx 和 fy 赋值之后，前面的第二项参数就失效了，所以可以直接用 (0,0) 来传参。压缩图片质量jpg 图片是经典的图片文件格式，它是自带压缩的，而这种压缩的程度是可选的。当我们选择保存为 .jpg 图片时，即使分辨率相同，最终图片的大小也会因为 jpg 压缩程度的不同而不同。我们可以在调用 imwrite 时，有很多参数可以选，其中一项参数cv2.IMWRITE_JPEG_QUALITY就是用于调整 jpg 的压缩程度，从而影响最终图片质量和大小。我们可以像下面这样来试验一下：import cv2 img = cv2.imread("img.jpg") cv2.imwrite("new.jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 80]) 上面代码就是普通的读取图片和保存图片，并没有改变图片的分辨率，不过在 imwrite 中指明了 cv2.IMWRITE_JPEG_QUALITY 的参数是 80，这个参数的取值范围是 0 到 100，0 表示最差质量，压缩程度最高，图片文件最小；100 表示最高质量，压缩程度最低，图片文件最大。可以自己试着去修改其中的值，然后保存图片，直观感受一下，不同的压缩程度对图片质量的影响其实是挺大的。下图是 cv2.IMWRITE_JPEG_QUALITY 参数为 0 的效果:检测图片大小前面说过，写这篇文章的初衷是想把图片放到网页上，那就要求图片文件的大小要小于一定的值，我自己感觉在 200k 以内的图片的加载速度是很快的（并不权威）。那么显然，我们的目标不仅仅是“压缩图片”，而是“压缩图片到指定大小以内”。显然，我们并不希望每压缩一次就去看看图片文件的大小，然后再回来改参数，我们的期望是一步到位，全自动！所以需要在代码运行是检测图片文件的大小是否满足条件，不满足条件的话继续压缩，直到图片小于 200k 为止。这个其实没有什么技巧，就一个函数，python 的 os 库中有一个函数 os.path.getsize，只需要传入文件名或路径，就可以返回文件的大小，单位为字节。import os os.path.getsize("new.jpg")融合！最终代码！通过之前所述的几个技巧，为了压缩图片文件大小，我们可以降低图片分辨率、降低图片质量，并用 os.path.getsize 函数监控文件大小，把他们组装在一起就能得到最后的代码：import cv2 import os img = cv2.imread("img.jpg") print(img.shape) rate = 1280 / img.shape[1] if rate > 1: rate = 1 new_img = cv2.resize(img, (0, 0), fx = rate, fy = rate) print(new_img.shape) for i in range(100, 0, -1): cv2.imwrite("new.jpg", new_img, [cv2.IMWRITE_JPEG_QUALITY, i]) print(os.path.getsize("new.jpg")) if os.path.getsize("new.jpg") < 200 * 1024: print(i) break 上面的代码依然是 3 部分：读取图片→改分辨率→保存图片，我用 rate 表示分辨率缩放的比例，rate 用 1280 除以原图的 列数得到，这样新图的分辨率的行数就是 1280 左右，并且保持了横纵比，不会变形，而如果原图的列数就小于 1280，那就没必要将其放大了，所以加了个 if ，这样的代码显然有一个缺点，那就是没有考虑原图的行数，不过这很容易调整，就无所谓了。在 imwrite 保存新图时，我们可以递减地选择 jpg 图片的质量，直到图片文件大小低于 200kb 为止，上面代码中的 200 * 1024 指的就是 200kb，这当然也可以轻松调整。生成ico图标ico 是图标，其实跟本篇关系不大，但是这么小部分的内容又不想开多一篇文章，于是就放在这里吧~from PIL import Image default_size_list = [ (256, 256), (128, 128), (64, 64), (48, 48), (32, 32), (24, 24), (16, 16) ] image = Image.open("img.jpg") image.save("new.ico", sizes = default_size_list)就是简单的调用 PIL 库的 Image 模块，生成图标而已，因为一个 ico 文件包含了多个尺寸的图标，所以可以用一个列表来传入我们需要的所有尺寸。结语虽然压缩图片是很简单的事情，本篇所述的代码也非常简单。但是自己可以用代码去实现，而别人则去下载软件，这其实是一件非常有成就感的事情。希望你喜欢这篇博客！
如果你的计算机用的是 windows 系统，那么库文件将遍布电脑的每一个角落。在 windows 上安装的任何一款软件，其中都一定包含有大量的 .dll 文件（下图为原神目录的部分截图）。.dll 文件称为动态链接库，库包含了写好的，现有的，成熟的，可以复用的代码。作为程序员，需要懂得什么是库，如何生成和使用库，下面让我来一一讲解。g++编译基础也许你用过若干种 C++ 编译器，比如 Dev-C++，Visual Studio，这些编译器可以帮我们把写好的代码 .cpp 编译成可执行文件 .exe ，但事实上编译器只是帮我们输入了一句或多句命令，而这些命令就是我们理解文件编译和执行的关键。我们将一句命令称为命令行，命令行可以在很多地方上执行，比如命令提示符cmd，比如VS-code的终端。如果想要把自己写好的 main.cpp 文件用命令行编译的话，只需要输入下列语句就可以了（记得切换到 main.cpp 的目录）:g++ main.cpp -o main执行上面这条语句之后，就会生成 main.exe 文件，这跟点击 Dev-C++ 的“编译”按钮是一样的。对于这条语句来说，g++ 表示执行 g++.exe 文件，它是用来编译 c++ 文件的；main.cpp 是要被编译的文件；-o 是一个选项，它可以指定生成的文件的名字，后面跟着的 main 就是指定的名字，g++ 会自动补全后面的 .exe，所以最后生成 main.exe，当然写 -o main.exe 也没问题。想要执行 main.exe ，既可以双击执行，也可以用命令行 ./main 执行。不过，相信大家多多少少有听过程序编译的一些规范和方法。从代码文件 .cpp 到可执行文件 .exe 其实会经过多个步骤，也会产生多个中间文件。我们重点考虑两个大步骤——编译和链接。无论是C、C++，首先要把源文件编译成中间代码文件，因为 cpp 文件里面是字符，人能看懂，计算机可看不懂，首先要生成一个对应该 cpp 的能让计算机看得懂的二进制文件，即 Object File ，这个动作叫做编译（compile），然后再把大量的 Object File 合成可执行文件，这个动作叫作链接（link）。 于是，上面的g++ main.cpp -o main就可以拆成两句命令：g++ -c main.cpp和g++ main.o -o main这两句命令分别对应了编译和链接，前者的-c选项就是让g++生成中间代码文件 main.o，后者则是把多个 .o 文件链接起来，这里只有一个 main.o，下文的介绍将会出现 3 个 .o 文件。上面这两句命令是本文的核心，全文都会围绕这两句命令进行扩展。后缀的解释正常来说，我们将会接触到 6 种文件的后缀：.o，.a，.so，.obj，.lib，.dll，但其实只有 3 种文件，分别是中间代码文件、静态库文件、动态库文件，只不过这 3 种文件在 windows 系统和 linux 系统上的后缀不一样而已，在 windows 系统上可以兼容所有的 6 种后缀格式，但 linux 系统上只能用 3 种，这 6 种后缀的解释如下：|windows|linux|文件类型||:-:|:-:|:-:||.obj|.o|中间代码文件||.lib|.a|静态库文件||.dll|.so|动态库文件|简述静态库和动态库的异同静态库和动态库都是“库”，库指的是多个中间代码文件的集合，也就是说，一个库包含多个 .o 或 .obj 文件，这些 .o 文件里面有可执行的代码。静态库和动态库的区别在于链接步骤，静态库会在链接时全身心地参与构成可执行文件，链接静态库其实从某种意义上来说也是一种粘贴复制，因为静态库被链接后库就直接嵌入可执行文件中了，整个库的所有代码都会共同构成可执行文件 .exe，假如使用 1 个库生成了 100 个可执行文件，那么就会产生这个库的 100 个副本，这显然非常浪费空间，并且难以修改：一旦发现这个库中的某段代码有 bug，那么那 100 个副本都要修改，即重新生成所有的文件，非常麻烦。而动态库是在程序运行时被链接的，而只在可执行文件中留下接口的信息（比如函数名），当可执行文件调用接口时，会执行动态库中的代码。这就好像超人给每个人留下了一张名片，上面有电话号码，当有人需要超人的帮助时，就拨打电话呼叫超人，这样既能让每个人都能随时得到超人的帮助，也不需要复制很多个超人，而大家只需要在自己家里放留一张名片，而不是留一个超人，非常的节省空间。静态库和动态库各有优劣，复制多个副本不见得是坏事，比如你给不懂计算机的同学写一个很简单的程序，那使用静态库的话就可以只发一份可执行文件 .exe 到他电脑上，但如果使用动态库的话还需要把相关的动态库文件发过去，这是其中一方面的考虑。下面将演示怎么生成和使用两种库，可以模仿着做一做噢！先不使用库让我们先创建 5 个用于演示的文件，分别是a.cpp、a.h、b.cpp、b.h、main.cpp，把他们放在同一个目录中，就像下图一样：然后，再分别往里面填入非常非常非常简单的代码：a.cpp:#include <cstdio> void apple(){ printf("apple!\n"); }a.h:void apple();b.cpp:#include <cstdio> void banana(){ printf("banana!\n"); }b.h:void banana();main.cpp:#include "a.h" #include "b.h" #include <cstdio> int main(){ apple(); banana(); int a; scanf("%d",&a); }填入代码后记得保存，就像下图一样：main.cpp 中加入scanf是为了在程序中加入阻塞，这样在双击运行 .exe 文件的时候就不会一闪而过。然后，我们依次在输入以下 4 句命令行并执行，前 3 句是生成 3 个 .cpp 文件对应的 .o 文件，第4句是将 3 个 .o 文件链接成一个可执行文件。g++ -c a.cppg++ -c b.cppg++ -c main.cppg++ a.o b.o main.o -o main每执行上面的一句命令行，都会在当前目录下生成一个新的文件，4句命令生成的文件分别是 a.o、b.o、main.o、main.exe，并且我们可以正常运行main.exe，输出的结果也是正常的，就像下图一样。linux库（.a和.so）当然，这一步也能在 windows 上做，是兼容的。在上一步的代码中可以看出，main.cpp 使用了a.cpp和b.cpp里的函数，我们把它们分别编译成a.o和b.o，然后链接。但如果有 100 个这样的 .o 文件，且它们都起到类似的作用，我们就可以把这些 .o 文件集中在一个静态库 .a 文件中，就像用一个书包把很多本书装起来一样。让我们先把前面生成的 main.exe删掉，然后执行下面的命令行：ar -crv libfruit.a a.o b.o执行完上面这句，会生成在当前目录中生成 libfruit.a 文件，我们就成功地将a.o和b.o放在了一个库中。ar是 linux 中用于生成静态库的工具，请一定要规范静态库的命名，前面以 lib 开头，后面以 .a 结尾，这是必须的。libfruit.a库的真正名字是中间的 fruit.然后就可以链接这个库来生成可执行文件，让我们执行下面这条命令行：g++ main.o libfruit.a -o main这局命令跟之前的g++ a.o b.o main.o -o main长得很像，只不过把a.o和b.o替换成了libfruit.a，执行完后一样会生成main.exe，并且也能正常执行，跟之前的链接命令产生了同样的效果！不过请注意，我们一般不使用g++ main.o libfruit.a -o main这样的用法来链接，这句命令能成功的原因在于库文件就在当前目录下，而我们进行程序设计时，库文件往往放在比较遥远的某处，那再用这种语法来链接的话，每个库文件都要写上绝对路径，非常麻烦。所以我们会用下面的命令行来替代g++ main.o libfruit.a -o main链接静态库的正宗语法是：g++ main.o -o main -L. -lfruit其中，-L后面跟路径，这样 g++ 就可以到那些目录里面搜索库文件，可以有多个-L，我们这里因为 libfruit.a 就在当前目录下，所以路径用.表示就够了，-l后面跟库名，这是 L 的小写，不是大写的 i，我们前面说过，静态库文件的命名方式是 lib + 库名 + .a，所以libfruit.a的库名是 fruit，g++会自动补齐文件名来寻找库文件，当需要链接多个库时，只需要加多个-l即可。删掉静态库，不影响可执行文件的运行，这跟动态库有明显的区别。 我们可以试着删除libfruit.a，发现main.exe仍然能正常运行，下面我们来使用动态库。执行下面的命令行：g++ -shared -fPIC -o libfruit.so a.o b.o动态库文件名命名规范和静态库文件名命名规范类似，也是在动态库名增加前缀lib，但其文件扩展名为 .so。执行上面这句命令行，就会在当前目录中生成动态库文件libfruit.so，然后我们就可以进行链接。有趣的地方是，链接动态库跟链接静态库的方法是一样的，于是，选择执行下面这两句命令行的其中的任意一句，都能生成main.exeg++ main.o libfruit.so -o maing++ main.o -o main -L. -lfruit让我们直观地感受一下动态库是怎么被使用的，运行 main.exe，记得之前在main.cpp之中有一个scanf函数吗，现在就发挥作用了，它将阻塞程序的运行，阻止程序结束。我们不做输入，让main.exe保持运行状态，然后打开资源监视器，资源监视器可以在任务管理器的“性能”一栏的下方打开，在 win10 中也可以直接在搜索栏中搜索资源监视器打开。打开资源监视器之后，去到“CPU”一栏，在“进程”中找到“main.exe”，将其勾选（因为名称是按字典序排序的，所以其实很好找），勾选之后，在下方“关联的模块”中，就能看到跟 main.exe 关联的动态库文件，其中就有我们刚刚的libfruit.so，如下图：除了libfruit.so之外，依赖的其他 .dll 动态库是跟头文件cstdio 有关的，不用太在意，不过这也正说明了，即使是简简单单的程序也不是独立运行的，绝大多数的程序，运行时都会依赖于库。假如这时我们把 libfruit.so删除，则双击 main.exe 运行时，会报错，提示缺少 libfruit.so无法运行。还有最后一个疑问，链接静态库和动态库的命令都是g++ main.o -o main -L. -lfruit，那假如同时存在静态库 .a 和动态库 .so 时，会链接哪一个呢？答案是会优先链接动态库，找不到动态库才链接静态库。windows库（.lib和.dll）讲完 linux 的静态库和动态库之后，windows 的库只是简单的换个后缀名而已，原理是一模一样的。windows 的静态库的后缀是 .lib，对应前面的 .a；windows 的动态库的后缀是 .dll，对应前面的 .so，下面就只需要单纯地介绍命令行的使用即可。有一点不同的是，windows 的库不需要添加 lib 前缀，也就是说，库名为 fruit 的静态库文件名就是 fruit.lib，动态库文件名就是 fruit.dll，没有前缀 lib.使用下面的命令行生成静态库文件：lib /OUT:fruit.lib a.o b.owindows 是微软的东西，所以要下载微软的 Visual Studio 才能执行上面的命令行。在上面的语句中，lib指的是运行lib.exe，这是下载 Visual Studio 才有的，/OUT:fruit.lib是指定输出的静态库的文件名，后面跟的是要放进库中的代码文件。跟 linux 一样，执行下面两句中的任意一句都能链接 fruit.lib 生成 main.exe。g++ main.o fruit.lib -o maing++ main.o -o main -L. -lfruit同样，删掉 fruit.lib 不影响 main.exe 的正常运行。不过，想要正确生成 dll 动态库的话，我们不仅仅要在命令行中作修改，还要在源文件 .cpp 和 .h 中作修改。请在每个函数前加上 __declspec(dllimport)，具体看后面的截图。这是一种函数声明，具体原理比较复杂，先不在这里作解释。执行下面的命令行可以生成动态库文件 fruit.dll:g++ -shared -fPIC -s -o fruit.dll a.o b.o执行下面两句的任意一句可以链接动态库 fruit.dll，生成可执行文件 main.exe：g++ main.o fruit.dll -o maing++ main.o -o main -L. -lfruit跟之前一样，在资源监视器中可以看到main.exe运行时依赖于动态库文件，且删掉fruit.dll之后无法正常运行。结语其实还有一个地方没有作对比，就是生成的可执行文件的大小。如果回去观察的话可以发现，使用静态库生成的main.exe会比使用动态库的要大，相差大概 30KB。至此，静态库和动态库的生成和使用就基本介绍完毕，本文所用到的代码和命令行都是最基础最简单的那一种，不过也足够普通程序员掌握和使用了。代码库是一门很深的学问，本篇 blog 只是简要地介绍，如果对读者能有所帮助，那是我的莫大荣幸，希望你能喜欢。