├── 1 └── 龙.py ├── 2 └── 虚境.py ├── 3 ├── 深度.yaml ├── 深度2.yaml ├── 虚境.py └── 虚境2.py ├── 4 ├── 深度.yaml ├── 现实.py └── 虚境.py ├── 6 ├── 信息.yaml ├── 变形.yaml ├── 现实.py └── 虚境.py ├── 7 ├── 信息.yaml ├── 变形.yaml ├── 现实.py └── 虚境.py ├── 2.md ├── 3.md ├── 4.md ├── 5.md ├── 6.md ├── LICENSE ├── readme.md ├── res ├── std_face.jpg ├── 莉沫酱较简单版.psd └── 莉沫酱过于简单版.psd ├── utils ├── 截图.py └── 虚拟摄像头.py └── 图 ├── 1-1.jpg ├── 1-2.jpg ├── 1-3.jpg ├── 1-4-1.jpg ├── 1-4-2.jpg ├── 1-5.jpg ├── 2-1.jpg ├── 2-2.jpg ├── 2-3-1.jpg ├── 2-3-2.jpg ├── 2-4.jpg ├── 2-5.jpg ├── 3-1.jpg ├── 3-2.jpg ├── 3-3.webp ├── 3-4-1.jpg ├── 3-4-2.jpg ├── 3-4-3.jpg ├── 4-1.webp ├── 4-2.jpg ├── 4-3.jpg ├── 4-4.jpg ├── 4-5.jpg ├── 5-1.jpg ├── 5-2.jpg ├── 5-3.jpg ├── 6-1.jpg ├── 6-2.jpg ├── 6-3.jpg ├── 6-4.jpg └── 6-5.webp /1/龙.py: -------------------------------------------------------------------------------- 1 | import cv2 2 | import dlib 3 | import numpy as np 4 | 5 | 6 | detector = dlib.get_frontal_face_detector() 7 | 8 | 9 | def 人脸定位(img): 10 | dets = detector(img, 0) 11 | if not dets: 12 | return None 13 | return max(dets, key=lambda det: (det.right() - det.left()) * (det.bottom() - det.top())) 14 | 15 | 16 | predictor = dlib.shape_predictor('../res/shape_predictor_68_face_landmarks.dat') 17 | 18 | 19 | def 提取关键点(img, 脸位置): 20 | landmark_shape = predictor(img, 脸位置) 21 | 关键点 = [] 22 | for i in range(68): 23 | pos = landmark_shape.part(i) 24 | 关键点.append(np.array([pos.x, pos.y], dtype=np.float32)) 25 | return 关键点 26 | 27 | 28 | def 生成构造点(关键点): 29 | def 中心(索引数组): 30 | return sum([关键点[i] for i in 索引数组]) / len(索引数组) 31 | 左眉 = [18, 19, 20, 21] 32 | 右眉 = [22, 23, 24, 25] 33 | 下巴 = [6, 7, 8, 9, 10] 34 | 鼻子 = [29, 30] 35 | return 中心(左眉 + 右眉), 中心(下巴), 中心(鼻子) 36 | 37 | 38 | def 生成特征(构造点): 39 | 眉中心, 下巴中心, 鼻子中心 = 构造点 40 | 中线 = 眉中心 - 下巴中心 41 | 斜边 = 眉中心 - 鼻子中心 42 | 横旋转量 = np.cross(中线, 斜边) / np.linalg.norm(中线)**2 43 | 竖旋转量 = 中线 @ 斜边 / np.linalg.norm(中线)**2 44 | return np.array([横旋转量, 竖旋转量]) 45 | 46 | 47 | def 画图(横旋转量, 竖旋转量): 48 | img = np.ones([512, 512], dtype=np.float32) 49 | 脸长 = 200 50 | 中心 = 256, 256 51 | 左眼 = int(220 + 横旋转量 * 脸长), int(249 + 竖旋转量 * 脸长) 52 | 右眼 = int(292 + 横旋转量 * 脸长), int(249 + 竖旋转量 * 脸长) 53 | 嘴 = int(256 + 横旋转量 * 脸长 / 2), int(310 + 竖旋转量 * 脸长 / 2) 54 | cv2.circle(img, 中心, 100, 0, 1) 55 | cv2.circle(img, 左眼, 15, 0, 1) 56 | cv2.circle(img, 右眼, 15, 0, 1) 57 | cv2.circle(img, 嘴, 5, 0, 1) 58 | return img 59 | 60 | 61 | def 提取图片特征(img): 62 | 脸位置 = 人脸定位(img) 63 | if not 脸位置: 64 | cv2.imshow('self', img) 65 | cv2.waitKey(1) 66 | return None 67 | 关键点 = 提取关键点(img, 脸位置) 68 | # for i, (px, py) in enumerate(关键点): 69 | # cv2.putText(img, str(i), (int(px),int(py)), cv2.FONT_HERSHEY_COMPLEX, 0.25, (255, 255, 255)) 70 | 构造点 = 生成构造点(关键点) 71 | # for i, (px, py) in enumerate(构造点): 72 | # cv2.putText(img, str(i), (int(px),int(py)), cv2.FONT_HERSHEY_COMPLEX, 0.25, (255, 255, 255)) 73 | 旋转量组 = 生成特征(构造点) 74 | # cv2.putText(img, '%.3f' % 旋转量, 75 | # (int(构造点[-1][0]), int(构造点[-1][1])), cv2.FONT_HERSHEY_COMPLEX, 0.5, (255, 255, 255)) 76 | cv2.imshow('self', img) 77 | return 旋转量组 78 | 79 | 80 | if __name__ == '__main__': 81 | cap = cv2.VideoCapture(0) 82 | 原点特征组 = 提取图片特征(cv2.imread('../res/std_face.jpg')) 83 | 特征组 = 原点特征组 - 原点特征组 84 | while True: 85 | ret, img = cap.read() 86 | # img = cv2.flip(img, 1) 87 | 新特征组 = 提取图片特征(img) 88 | if 新特征组 is not None: 89 | 特征组 = 新特征组 - 原点特征组 90 | 横旋转量, 竖旋转量 = 特征组 91 | cv2.imshow('Vtuber', 画图(横旋转量, 竖旋转量)) 92 | cv2.waitKey(1) 93 | -------------------------------------------------------------------------------- /2.md: -------------------------------------------------------------------------------- 1 | # 从零开始的自制Vtuber: 2.画图和绘图 2 | 3 | 在第二节里,我们将会使用一个PSD立绘,并将它原封不动地用OpenGL画出来。 4 | 5 | 此外,这一节并不会用到上一节的程序,所以如果你没有把上一节搞定也可以接着学习。 6 | 7 | 这一节的代码理解起来没有什么难点,但是有许多繁琐的细节,如果你调不出来的话可以干脆去照抄仓库的代码。 8 | 9 | ## 警告 10 | 11 | 这个章节还没有完成校订,因此可能有和谐内容。 12 | 13 | 请您收好鸡儿,文明观球。 14 | 15 | ## 准备 16 | 17 | 在这个章节,你需要准备: 18 | 19 | + 电脑 20 | + Photoshop 21 | + 称手的画图工具 22 | + 基本的图形学知识 23 | + Python3 24 | + psd-tools 25 | + NumPy 26 | + OpenCV 27 | + OpenGL 28 | 29 | 30 | ## 画画 31 | 32 | 要让Vtuber好看,最根本的方法是把立绘画得好看一些。 33 | 如果你没办法画得好看,可能是色图看得少了,平时一定要多看萝莉图片! 34 | 35 | 首先,我们用称手的画图工具画一个女孩子,然后转换为PSD格式。 36 | 37 | ![./图/2-1.jpg](./图/2-1.jpg) 38 | 39 | 画出来大概是这样。 40 | 41 | 注意要把你的图层分好,大概的原则是如果你觉得这个物件是会动的,就把它分一个图层。 42 | 也可以参考我上面这个图层分法,总之图层拆得越细,动起来就越真实。 43 | 44 | 如果你不会画画,也可以找你的画师朋友帮忙。 45 | 如果你不会画画而且没有朋友,那就用手随便画几个正方形三角形之类,假装是机器人Vtuber。 46 | 47 | ## 读取PSD 48 | 49 | 我们用psd-tools来把PSD文件读进Python程序里。 50 | 51 | 我习惯用先序遍历把图层全部拆散,如果你觉得这样不合适也可以搞点visitor模式之类的。 52 | 我还顺便交换了值和图层的顺序,如果你不习惯就自己把它们删掉…… 53 | 54 | 根据psd-tool的遍历顺序,先遍历到的子树对应的图层总是在后遍历到的子树的图层的下层,所以它必定是有序的。 55 | 56 | ```python 57 | def 提取图层(psd): 58 | 所有图层 = [] 59 | def dfs(图层, path=''): 60 | if 图层.is_group(): 61 | for i in 图层: 62 | dfs(i, path + 图层.name + '/') 63 | else: 64 | a, b, c, d = 图层.bbox 65 | npdata = 图层.numpy() 66 | npdata[:, :, 0], npdata[:, :, 2] = npdata[:, :, 2].copy(), npdata[:, :, 0].copy() 67 | 所有图层.append({'名字': path + 图层.name, '位置': (b, a, d, c), 'npdata': npdata}) 68 | for 图层 in psd: 69 | dfs(图层) 70 | return 所有图层 71 | ``` 72 | 73 | 接下来,你可以试着用OpenCV把这些图层组合回来,检查一下有没有问题。 74 | 75 | 检查的方法很简单,只要按顺序写入图层,把它们都叠在对应的位置上就好了。 76 | 77 | ```python 78 | def 测试图层叠加(所有图层): 79 | img = np.ones([500, 500, 4], dtype=np.float32) 80 | for 图层数据 in 所有图层: 81 | a, b, c, d = 图层数据['位置'] 82 | 新图层 = 图层数据['npdata'] 83 | img[a:c, b:d] = 新图层 84 | cv2.imshow('', img) 85 | cv2.waitKey() 86 | ``` 87 | 88 | 如果你真的这么干的话就会出现奇怪的画面,这样叠起来实际上是不行的。 89 | 这是因为最后一个通道表示的是图层的透明度,应该由它来决定前面的颜色通道如何混合。因此我们得把叠加的语句稍作修改。 90 | 91 | 你可以想象一下,如果要叠上来的图层比较透明,那它对原本的颜色的影响就比较小,反之就比较大。 92 | 实际上,我们只要以`(1-alpha, alpha)`对新旧图层取一个加权平均数,就可以得到正确的结果。 93 | 94 | ```python 95 | alpha = 新图层[:, :, 3] 96 | for i in range(3): 97 | img[a:c, b:d, i] = img[a:c, b:d, i] * (1 - alpha) + 新图层[:, :, i] * alpha 98 | ``` 99 | 100 | ![./图/2-2.jpg](./图/2-2.jpg) 101 | 102 | 看起来和Photoshop里的效果一模一样! 103 | 修改之后,我们可以确认我们成功读入了图像。 104 | 105 | 106 | ## 使用OpenGL绘图 107 | 108 | 虽然我们刚才已经随便用OpenCV把它画出来了,但是为了接下来要做一些骚操作,我们还是得用OpenGL绘图。 109 | 110 | OpenGL中的座标是四维座标`(x, y, z, w)`,在这里我们将`(x, y)`用作屏幕座标,`z`用作深度座标。 111 | 112 | 因为这张立绘的大小是`1000px*1000px`,而OpenGL的平面范围是`(-1, 1)`,此外XY轴和我的设定还是相反的。 113 | 所以我们先把立绘中每个图层的位置向量乘上变换矩阵,让它们到对应的位置去。 114 | 115 | 出于易读性考虑,我就用旧版OpenGL API来绘图吧,如果你能自己把它改为新版API的话就更好了。 116 | 117 | ```python 118 | glPolygonMode(GL_FRONT_AND_BACK, GL_LINE) 119 | for 图层数据 in 所有图层: 120 | a, b, c, d = 图层数据['位置'] 121 | p1 = np.array([a, b, 0, 1]) 122 | p2 = np.array([a, d, 0, 1]) 123 | p3 = np.array([c, d, 0, 1]) 124 | p4 = np.array([c, b, 0, 1]) 125 | model = matrix.scale(2 / psd尺寸[0], 2 / psd尺寸[1], 1) @ \ 126 | matrix.translate(-1, -1, 0) @ \ 127 | matrix.rotate_ax(-math.pi/2, axis=(0, 1)) 128 | glBegin(GL_QUADS) 129 | for p in [p1, p2, p3, p4]: 130 | p = p @ model 131 | glVertex4f(*p) 132 | glEnd() 133 | ``` 134 | 135 | ![./图/2-3-1.jpg](./图/2-3-1.jpg) 136 | 137 | 看起来很像Minecraft里的村民! 138 | 不过图中有身体和头的轮廓,我们姑且还能认出这个莉沫酱的框架是没问题的。 139 | 140 | 对了,上面的`matrix`是我随手写的一个变换矩阵库,我会把它放在这个项目代码库里。 141 | 如果你的线性代数学得很好,应该可以自己把它写出来,因为它确实很简单,比如缩放矩阵`matrix.scale`只是这样定义的—— 142 | 143 | ```python 144 | def scale(x, y, z): 145 | a = np.eye(4, dtype=np.float32) 146 | a[0, 0] = x 147 | a[1, 1] = y 148 | a[2, 2] = z 149 | return a 150 | ``` 151 | 152 | 接下来我们要为莉沫酱框架贴上纹理。 153 | 154 | 首先,我们启用OpenGL的纹理和混合功能,然后把每个图层都绑定好对应的纹理。 155 | 156 | ```python 157 | glEnable(GL_TEXTURE_2D) 158 | glEnable(GL_BLEND) 159 | glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 160 | for 图层数据 in 所有图层: 161 | 纹理编号 = glGenTextures(1) 162 | glBindTexture(GL_TEXTURE_2D, 纹理编号) 163 | 纹理 = cv2.resize(图层数据['npdata'], (1024, 1024)) 164 | width, height = 纹理.shape[:2] 165 | glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA, GL_FLOAT, 纹理) 166 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR) 167 | glGenerateMipmap(GL_TEXTURE_2D) 168 | 图层数据['纹理编号'] = 纹理编号 169 | ``` 170 | 171 | 然后在绘制每个图层的时候,将纹理绑定到对应的纹理编号上,这个步骤就算是完成了。 172 | 173 | 最后OpenGL窗口中的图像看起来应该像是这样—— 174 | 175 | ![./图/2-3-2.jpg](./图/2-3-2.jpg) 176 | 177 | 你会发现这张图有点模糊(尤其是眉毛和眼睛),接下来我们来解决这个问题。 178 | 179 | 180 | ## 优化纹理 181 | 182 | OpenGL要求纹理是正方形,而且边长是2的整数次幂,而我们为了图省事把所有的纹理都缩放到了`512px*512px`。 183 | 184 | 由于缩放算法并不那么聪明,因此在放大后缩小的过程中没法取样回原本的点,因此看起来莉沫酱变得很模糊了。 185 | 186 | 就是上面的代码的这个地方—— 187 | 188 | ```python 189 | 纹理 = cv2.resize(图层数据['npdata'], (1024, 1024)) 190 | ``` 191 | 192 | 为了让莉沫酱变清楚,这回我们不在这里用缩放了,而是生成一个比原图层大一点的纹理,然后把原图层丢进去,这样就可以避免一次缩放。 193 | 194 | ```python 195 | def 生成纹理(img): 196 | w, h = img.shape[:2] 197 | d = 2**int(max(math.log2(w), math.log2(h))+1) 198 | 纹理 = np.zeros([d,d,4], dtype=img.dtype) 199 | 纹理[:w,:h] = img 200 | return 纹理, (w/d, h/d) 201 | ``` 202 | 203 | 额外返回的是座标,把它们在设置纹理座标的地方就行了,像是这样—— 204 | 205 | 206 | ```python 207 | q, w = 纹理座标 208 | p1 = np.array([a, b, 0, 1, 0, 0]) 209 | p2 = np.array([a, d, 0, 1, w, 0]) 210 | p3 = np.array([c, d, 0, 1, w, q]) 211 | p4 = np.array([c, b, 0, 1, 0, q]) 212 | ``` 213 | 214 | ![./图/2-4.jpg](./图/2-4.jpg) 215 | 216 | 莉沫酱真是太清楚了! 217 | 218 | ## 结束之前 219 | 220 | 顺便说一下,新版的莉沫酱立绘不是我画的,是我的画师朋友(在收取了高昂的保护费以后)帮我画的。 221 | 222 | 上个版本我自己画的时候,画风是这样—— 223 | 224 | ![./图/2-5.webp](./图/2-5.jpg) 225 | 226 | 欸好像我画得还不错嘛2333 227 | 228 | 莉沫酱: 为什么我的欧派缩水了??? 229 | 230 | 231 | ## 结束 232 | 233 | 如果我的某些操作让你非常迷惑,你也可以去这个项目的GitHub仓库查看源代码。 234 | 莉沫酱立绘的PSD也一起放在仓库里了,如果你画不出画的话可以用上。 235 | 不过要注意莉沫酱立绘并不在开源许可的范围内,所以不要用来做其他的事情。 236 | 237 | 最后祝各位鸡儿放假。 238 | 239 | 下一节: 240 | + [从零开始的自制Vtuber: 3.进入虚空](3.md) 241 | -------------------------------------------------------------------------------- /2/虚境.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | import glfw 7 | from OpenGL.GL import * 8 | from OpenGL.GLU import * 9 | from psd_tools import PSDImage 10 | from rimo_utils import matrix 11 | 12 | 13 | def 提取图层(psd): 14 | 所有图层 = [] 15 | def dfs(图层, path=''): 16 | if 图层.is_group(): 17 | for i in 图层: 18 | dfs(i, path + 图层.name + '/') 19 | else: 20 | a, b, c, d = 图层.bbox 21 | npdata = 图层.numpy() 22 | npdata[:, :, 0], npdata[:, :, 2] = npdata[:, :, 2].copy(), npdata[:, :, 0].copy() 23 | 所有图层.append({'名字': path + 图层.name, '位置': (b, a, d, c), 'npdata': npdata}) 24 | for 图层 in psd: 25 | dfs(图层) 26 | return 所有图层, psd.size 27 | 28 | 29 | def 测试图层叠加(所有图层): 30 | img = np.ones([2048, 2048, 4], dtype=np.float32) 31 | for 图层数据 in 所有图层: 32 | a, b, c, d = 图层数据['位置'] 33 | 新图层 = 图层数据['npdata'] 34 | alpha = 新图层[:, :, 3] 35 | for i in range(3): 36 | img[a:c, b:d, i] = img[a:c, b:d, i] * (1 - alpha) + 新图层[:, :, i] * alpha 37 | cv2.imshow('', img) 38 | cv2.imwrite('1.jpg', (img*255).astype(np.uint8)) 39 | cv2.waitKey() 40 | 41 | 42 | def opengl绘图循环(所有图层, psd尺寸): 43 | Vtuber尺寸 = 512, 512 44 | 45 | glfw.init() 46 | glfw.window_hint(glfw.RESIZABLE, False) 47 | window = glfw.create_window(*Vtuber尺寸, 'Vtuber', None, None) 48 | glfw.make_context_current(window) 49 | glViewport(0, 0, *Vtuber尺寸) 50 | 51 | glEnable(GL_TEXTURE_2D) 52 | glEnable(GL_BLEND) 53 | glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 54 | 55 | for 图层数据 in 所有图层: 56 | 纹理编号 = glGenTextures(1) 57 | glBindTexture(GL_TEXTURE_2D, 纹理编号) 58 | 纹理 = cv2.resize(图层数据['npdata'], (1024, 1024)) 59 | width, height = 纹理.shape[:2] 60 | glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA, GL_FLOAT, 纹理) 61 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR) 62 | glGenerateMipmap(GL_TEXTURE_2D) 63 | 图层数据['纹理编号'] = 纹理编号 64 | 65 | while not glfw.window_should_close(window): 66 | glfw.poll_events() 67 | glClearColor(1, 1, 1, 1) 68 | glClear(GL_COLOR_BUFFER_BIT) 69 | glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 70 | for 图层数据 in 所有图层: 71 | a, b, c, d = 图层数据['位置'] 72 | p1 = np.array([a, b, 0, 1, 1, 0]) 73 | p2 = np.array([a, d, 0, 1, 1, 1]) 74 | p3 = np.array([c, d, 0, 1, 0, 1]) 75 | p4 = np.array([c, b, 0, 1, 0, 0]) 76 | model = matrix.scale(2 / psd尺寸[0], 2 / psd尺寸[1], 1) @ \ 77 | matrix.translate(-1, -1, 0) @ \ 78 | matrix.rotate_ax(-math.pi / 2, axis=(0, 1)) 79 | glBindTexture(GL_TEXTURE_2D, 图层数据['纹理编号']) 80 | glColor4f(1, 1, 1, 1) 81 | glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 82 | glBegin(GL_QUADS) 83 | for p in [p1, p2, p3, p4]: 84 | a = p[:4] 85 | b = p[4:6] 86 | a = a @ model 87 | glVertex4f(*a) 88 | glTexCoord2f(*b) 89 | glEnd() 90 | glfw.swap_buffers(window) 91 | 92 | 93 | def opengl清楚绘图循环(所有图层, psd尺寸): 94 | def 生成纹理(img): 95 | w, h = img.shape[:2] 96 | d = 2**int(max(math.log2(w), math.log2(h)) + 1) 97 | 纹理 = np.zeros([d, d, 4], dtype=img.dtype) 98 | 纹理[:w, :h] = img 99 | return 纹理, (w / d, h / d) 100 | 101 | Vtuber尺寸 = 512, 512 102 | 103 | glfw.init() 104 | glfw.window_hint(glfw.RESIZABLE, False) 105 | window = glfw.create_window(*Vtuber尺寸, 'Vtuber', None, None) 106 | glfw.make_context_current(window) 107 | glViewport(0, 0, *Vtuber尺寸) 108 | 109 | glEnable(GL_TEXTURE_2D) 110 | glEnable(GL_BLEND) 111 | glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 112 | 113 | for 图层数据 in 所有图层: 114 | 纹理编号 = glGenTextures(1) 115 | glBindTexture(GL_TEXTURE_2D, 纹理编号) 116 | 纹理, 纹理座标 = 生成纹理(图层数据['npdata']) 117 | width, height = 纹理.shape[:2] 118 | glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA, GL_FLOAT, 纹理) 119 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR) 120 | glGenerateMipmap(GL_TEXTURE_2D) 121 | 图层数据['纹理编号'] = 纹理编号 122 | 图层数据['纹理座标'] = 纹理座标 123 | 124 | while not glfw.window_should_close(window): 125 | glfw.poll_events() 126 | glClearColor(1, 1, 1, 1) 127 | glClear(GL_COLOR_BUFFER_BIT) 128 | glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 129 | for 图层数据 in 所有图层: 130 | a, b, c, d = 图层数据['位置'] 131 | q, w = 图层数据['纹理座标'] 132 | p1 = np.array([a, b, 0, 1, 0, 0]) 133 | p2 = np.array([a, d, 0, 1, w, 0]) 134 | p3 = np.array([c, d, 0, 1, w, q]) 135 | p4 = np.array([c, b, 0, 1, 0, q]) 136 | model = matrix.scale(2 / psd尺寸[0], 2 / psd尺寸[1], 1) @ \ 137 | matrix.translate(-1, -1, 0) @ \ 138 | matrix.rotate_ax(-math.pi / 2, axis=(0, 1)) 139 | glBindTexture(GL_TEXTURE_2D, 图层数据['纹理编号']) 140 | glColor4f(1, 1, 1, 1) 141 | glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 142 | glBegin(GL_QUADS) 143 | for p in [p1, p2, p3, p4]: 144 | a = p[:4] 145 | b = p[4:6] 146 | a = a @ model 147 | glTexCoord2f(*b) 148 | glVertex4f(*a) 149 | glEnd() 150 | glfw.swap_buffers(window) 151 | 152 | 153 | if __name__ == '__main__': 154 | psd = PSDImage.open('../res/莉沫酱过于简单版.psd') 155 | 所有图层, size = 提取图层(psd) 156 | # 测试图层叠加(所有图层) 157 | opengl清楚绘图循环(所有图层, size) 158 | -------------------------------------------------------------------------------- /3.md: -------------------------------------------------------------------------------- 1 | # 从零开始的自制Vtuber: 3.进入虚空 2 | 3 | 在这一节里,我们惟一要做的就是用OpenGL将我们的立绘画成立体的、动态的。 4 | 5 | 我们并不会使用Live2D,没错,这意味着我们接下来要做的就是发明一个Live2D。 6 | 7 | 这一节的代码非常少,但是需要你有比较好的图形学基础知识,所以理解起来可能有些困难。 8 | 9 | ## 警告 10 | 11 | 这个章节还没有完成校订,因此可能有和谐内容。 12 | 13 | 请您收好鸡儿,文明观球。 14 | 15 | ## 准备 16 | 17 | 在这个章节,你需要准备: 18 | 19 | + 电脑 20 | + 基本的图形学知识 21 | + 基本的线性代数知识 22 | + Python3 23 | + NumPy 24 | + OpenGL 25 | 26 | ## 进入3D 27 | 28 | 终于到了让Vtuber动起来的步骤了。 29 | 30 | 为了让人物动起来有3D的感觉,我们首先得为各个图层指定深度。 31 | 我建立了一个YAML文档,把每个图层的深度都写进去了,如果你不喜欢YAML也可以随便写个JSON代替。 32 | 33 | 它看起来是这样: 34 | 35 | ```yaml 36 | 身体: 37 | 0.9 38 | 脸: 39 | 0.7 40 | 前发: 41 | 0.6 42 | ... 43 | ``` 44 | 45 | 我把各个图层的深度设置在1附近,越靠近观众的位置数值越小。 46 | 47 | 在解包PSD之后从YAML读取各个图层的数据,加入深度。 48 | 49 | ```python 50 | def 添加深度信息(所有图层): 51 | with open('深度.yaml', encoding='utf8') as f: 52 | 深度信息 = yaml.load(f) 53 | for 图层信息 in 所有图层: 54 | if 图层信息['名字'] in 深度信息: 55 | 图层信息['深度'] = 深度信息[图层信息['名字']] 56 | ``` 57 | 58 | 有了深度信息我们只需要乘上一个透视矩阵,就能让画面呈现出3D感。 59 | 60 | 不过在这之前我们得先来说一点座标变换的理论—— 61 | 62 | 我们知道,3D感来自于透视投影,也就是把一个锥形内的3D物件拍平,压到平面上出现的近大远小的效果。 63 | 64 | 在一个2D的画面中我们观察角色使用的是平行投影,也就是无论我们把镜头拉近拉远,看到的图像只有整体的大小差别而已。 65 | 但它也可以理解为在固定深度下的垂直的透视投影。从透视的角度来想,加入了Z座标以后,即使我们现在什么都不做,也会发生近大远小的现象,观察到的图层大小发生变化并不符合我们的预期。 66 | 67 | 因此,我们把z座标先附到图层上—— 68 | 69 | ```python 70 | z = 图层数据['深度'] 71 | p1 = np.array([a, b, z, 1, 0, 0]) 72 | p2 = np.array([a, d, z, 1, w, 0]) 73 | p3 = np.array([c, d, z, 1, w, q]) 74 | p4 = np.array([c, b, z, 1, 0, q]) 75 | ``` 76 | 77 | 这个时候,如果你直接乘上一个透视投影矩阵的话,大小就会错乱,像是这样—— 78 | 79 | (这几张图片看起来都比较辣眼睛,因此我把它们藏在了details里面) 80 | 81 |
82 | 83 | ![./图/3-1.jpg](./图/3-1.jpg) 84 | 85 |
86 | 87 | 所以我们先做一个还原操作,把远处的图层放大,越远放得越大,这样一来图层乘上透视投影矩阵以后就会刚好变成和原来一样大。 88 | 89 | ```python 90 | a[0:2] *= a[2] 91 | a = a @ matrix.perspective(999) 92 | ``` 93 | 94 | 这个时候如果你重新使用OpenGL绘图,窗口内容应该看起来反而没有任何变化。 95 | 96 | 但是,如果我们从侧面看,就会发现莉沫酱的纸片在三维空间中是分层的—— 97 | 98 | ``` 99 | a = a @ matrix.scale(1,1,3) \ 100 | @ matrix.rotate_ax(1.2, axis=(0, 2)) \ 101 | @ matrix.translate(2.1, 0, 0.8) 102 | ``` 103 | 104 |
105 | 106 | ![./图/3-2.jpg](./图/3-2.jpg) 107 | 108 |
109 | 110 | 111 | 接下来,我们找出所有需要动的图层的座标,把它们再乘上一个绕轴旋转矩阵的话—— 112 | 113 | ```python 114 | a = a @ \ 115 | matrix.translate(0, 0, -1) @ \ 116 | matrix.rotate_ax(横旋转量, axis=(0, 2)) @ \ 117 | matrix.translate(0, 0, 1) 118 | ``` 119 | 120 | ![./图/3-3.webp](./图/3-3.webp) 121 | 122 | 莉沫酱动起来了! 123 | 124 | 不过她现在动得很粗糙,摇头的角度大一点就露馅了,接下来我们得好好考虑怎么把她强化一下。 125 | 126 | 127 | ## 深度渐变 128 | 129 | 前几节的莉沫酱虽然会摇头摆尾(?),但是动作还是很有纸片感。 130 | 131 | 出现这个问题,一个很重要的原因就是每个图层的深度都是确定的,因此角色看起来像是立体绘本,呃,就是机关书,你们小时候说不定见过这东西? 132 | 133 | 总之我们先把图层改成四个顶点可以使用不同深度的吧,做了这个操作以后Vtuber就会比较逼真了。 134 | 在之后的章节,我们会用类似的方法来做一个通用的深度网格,现在我们先用四个点凑合一下吧! 135 | 136 | 我们希望能读取这样的深度信息—— 137 | 138 | ```yaml 139 | 脸: 140 | 0.7 141 | 前发: 142 | - [0.7, 0.7] 143 | - [0.6, 0.6] 144 | ``` 145 | 146 | 其中`脸`是平面,深度为`0.7`,而`前发`是斜面,上边的深度是`0.7`,下边的深度是`0.6`。 147 | 148 | 首先把一个深度信息的数据分成四个: 149 | 150 | ```python 151 | z = 图层数据['深度'] 152 | if type(z) in [int, float]: 153 | z1, z2, z3, z4 = [z, z, z, z] 154 | else: 155 | [z1, z2], [z3, z4] = z 156 | ``` 157 | 158 | 然后在给OpenGL指定时使用不同的深度就行了。 159 | 160 | 接下来还是用原本的方法绘图,然后我们稍微转一点角度来观察—— 161 | 162 | ![./图/3-4-1.jpg](./图/3-4-1.jpg) 163 | 164 | 似乎有成效了,但是这个头发是不是怪怪的?好像有一个尖尖角?而且头发好像离脸变远了? 165 | 166 | 这个角在前发的右上方,就是我标了「角」字的左边,有一个导数不连续的点……如果你自己找到了说明你的视力很好。 167 | 168 | 为了解释这个问题,我们试着在头发前面贴上一层国际象棋——现在莉沫酱看起来很像怪盗(并不)。 169 | 170 | ![./图/3-4-2.jpg](./图/3-4-2.jpg) 171 | 172 | (别的图形学教程这种时候都是贴个黑白格子来偷工减料,只有我们是贴真的国际象棋好吧2333) 173 | 174 | 沿着棋盘的右上上到左下的对角线观察,就是那条红色的半透明线,是不是可以看到空间好像被扭曲了? 175 | 176 | 177 | 其实我们的空间变换并没有出问题,你可以验证一下无论变换前后四个顶点都在同一个平面上。 178 | 真正被扭曲的是那张贴图。 179 | 180 | 这是因为我们的图层不是矩形而是梯形。 181 | 你可能会觉得奇怪「明明就是矩形啊」,但问题出在我们刚才做了一个骚操作—— 182 | 183 | > 所以我们先做一个还原操作,把远处的图层放大,越远放得越大,这样一来图层乘上透视投影矩阵以后就会刚好变成和原来一样大。 184 | > a[0:2] *= a[2] 185 | > a = a @ matrix.perspective(999) 186 | 187 | 这么一搞以后如果上面的边的深度更大,四个顶点乘上不同的放大倍数,矩形就成了梯形!而矩形的纹理在贴上梯形之前会先被三角剖分,梯形贴上两个三角形变成了不对称的,于是它们就错位了。 188 | 189 | 解决办法是使用4维纹理座标—— 190 | 191 | ``` 192 | p1 = np.array([a, b, z1, 1, 0, 0, 0, 1]) 193 | p2 = np.array([a, d, z2, 1, w, 0, 0, 1]) 194 | p3 = np.array([c, d, z3, 1, w, q, 0, 1]) 195 | p4 = np.array([c, b, z4, 1, 0, q, 0, 1]) 196 | ``` 197 | 198 | 之后,在渲染时将`p[4:]`乘上对应的`z`。 199 | 200 | 前4个数是空间座标,后4个数是纹理座标,纹理座标的最后一维相当于空间座标的`w`维度。 201 | 202 | 它的原理是这样,OpenGL在渲染三角形时,如果三角形的三个顶点拥有不同的额外属性(比如颜色、纹理座标),那三角形内的每一个点的属性如何决定呢?不是取决于这个点在屏幕空间中到三个顶点的距离,而是在三维空间中到三个顶点的距离。这两个距离并不相等,它们是随着深度变化的。在这里,因为图层在空间中的三维座标是由二维座标还原得到的,因此我们需要再为纹理指定深度。 203 | 204 | 做了这个修理之后,你可以再用国际象棋测试一下它的扭曲是不是消失了,然后我们重新渲染—— 205 | 206 | ![./图/3-4-3.jpg](./图/3-4-3.jpg) 207 | 208 | 角没有了! 209 | 210 | 这样一来,带有倾斜图层的莉沫酱终于渲染好了,之后我们会来研究一下软件结构,好把会动的莉沫酱和面部捕捉结合起来。 211 | 212 | 213 | ## 结束 214 | 215 | 如果我的某些操作让你非常迷惑,你也可以去这个项目的GitHub仓库查看源代码。 216 | 217 | 最后祝各位妻妾成群。 218 | 219 | 下一节: 220 | + [从零开始的自制Vtuber: 4.合成进化](4.md) 221 | -------------------------------------------------------------------------------- /3/深度.yaml: -------------------------------------------------------------------------------- 1 | 五官/鼻子: 2 | 0.55 3 | 五官/嘴: 4 | 0.65 5 | 五官/眼睛: 6 | 0.65 7 | 五官/眉毛: 8 | 0.65 9 | 后发: 10 | 0.8 11 | 身体: 12 | 0.9 13 | 脸: 14 | 0.7 15 | 前发: 16 | 0.6 17 | 侧马尾: 18 | 0.6 19 | -------------------------------------------------------------------------------- /3/深度2.yaml: -------------------------------------------------------------------------------- 1 | 五官/鼻子: 2 | 0.55 3 | 五官/嘴: 4 | 0.65 5 | 五官/眼睛: 6 | 0.65 7 | 五官/眉毛: 8 | 0.65 9 | 后发: 10 | 0.8 11 | 身体: 12 | 0.9 13 | 脸: 14 | 0.7 15 | 前发: 16 | - [0.7, 0.7] 17 | - [0.6, 0.6] 18 | 侧马尾: 19 | 0.6 20 | -------------------------------------------------------------------------------- /3/虚境.py: -------------------------------------------------------------------------------- 1 | import time 2 | import math 3 | 4 | import yaml 5 | import numpy as np 6 | 7 | import glfw 8 | from OpenGL.GL import * 9 | from OpenGL.GLU import * 10 | from psd_tools import PSDImage 11 | from rimo_utils import matrix 12 | 13 | 14 | def 提取图层(psd): 15 | 所有图层 = [] 16 | def dfs(图层, path=''): 17 | if 图层.is_group(): 18 | for i in 图层: 19 | dfs(i, path + 图层.name + '/') 20 | else: 21 | a, b, c, d = 图层.bbox 22 | npdata = 图层.numpy() 23 | npdata[:, :, 0], npdata[:, :, 2] = npdata[:, :, 2].copy(), npdata[:, :, 0].copy() 24 | 所有图层.append({'名字': path + 图层.name, '位置': (b, a, d, c), 'npdata': npdata}) 25 | for 图层 in psd: 26 | dfs(图层) 27 | return 所有图层, psd.size 28 | 29 | 30 | def opengl循环(所有图层, psd尺寸): 31 | def 生成纹理(img): 32 | w, h = img.shape[:2] 33 | d = 2**int(max(math.log2(w), math.log2(h)) + 1) 34 | 纹理 = np.zeros([d, d, 4], dtype=img.dtype) 35 | 纹理[:w, :h] = img 36 | return 纹理, (w / d, h / d) 37 | 38 | Vtuber尺寸 = 512, 512 39 | 40 | glfw.init() 41 | glfw.window_hint(glfw.RESIZABLE, False) 42 | window = glfw.create_window(*Vtuber尺寸, 'Vtuber', None, None) 43 | glfw.make_context_current(window) 44 | glViewport(0, 0, *Vtuber尺寸) 45 | 46 | glEnable(GL_TEXTURE_2D) 47 | glEnable(GL_BLEND) 48 | glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 49 | 50 | for 图层数据 in 所有图层: 51 | 纹理编号 = glGenTextures(1) 52 | glBindTexture(GL_TEXTURE_2D, 纹理编号) 53 | 纹理, 纹理座标 = 生成纹理(图层数据['npdata']) 54 | width, height = 纹理.shape[:2] 55 | glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA, GL_FLOAT, 纹理) 56 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR) 57 | glGenerateMipmap(GL_TEXTURE_2D) 58 | 图层数据['纹理编号'] = 纹理编号 59 | 图层数据['纹理座标'] = 纹理座标 60 | 61 | while not glfw.window_should_close(window): 62 | glfw.poll_events() 63 | glClearColor(1, 1, 1, 1) 64 | glClear(GL_COLOR_BUFFER_BIT) 65 | glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 66 | for 图层数据 in 所有图层: 67 | a, b, c, d = 图层数据['位置'] 68 | z = 图层数据['深度'] 69 | q, w = 图层数据['纹理座标'] 70 | p1 = np.array([a, b, z, 1, 0, 0]) 71 | p2 = np.array([a, d, z, 1, w, 0]) 72 | p3 = np.array([c, d, z, 1, w, q]) 73 | p4 = np.array([c, b, z, 1, 0, q]) 74 | model = matrix.scale(2 / psd尺寸[0], 2 / psd尺寸[1], 1) @ \ 75 | matrix.translate(-1, -1, 0) @ \ 76 | matrix.rotate_ax(-math.pi / 2, axis=(0, 1)) 77 | glBindTexture(GL_TEXTURE_2D, 图层数据['纹理编号']) 78 | glColor4f(1, 1, 1, 1) 79 | glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 80 | glBegin(GL_QUADS) 81 | for p in [p1, p2, p3, p4]: 82 | a = p[:4] 83 | b = p[4:6] 84 | a = a @ model 85 | a[0:2] *= a[2] 86 | 横旋转量 = 0 87 | if not 图层数据['名字'] == '身体': 88 | 横旋转量 = math.sin(time.time() * 5) / 30 89 | a = a @ matrix.translate(0, 0, -1) \ 90 | @ matrix.rotate_ax(横旋转量, axis=(0, 2)) \ 91 | @ matrix.translate(0, 0, 1) 92 | # a = a @ matrix.scale(1,1,3) \ 93 | # @ matrix.rotate_ax(1.2, axis=(0, 2)) \ 94 | # @ matrix.translate(2.1, 0, 0.8) 95 | a = a @ matrix.perspective(999) 96 | glTexCoord2f(*b) 97 | glVertex4f(*a) 98 | glEnd() 99 | glfw.swap_buffers(window) 100 | 101 | 102 | def 添加深度信息(所有图层): 103 | with open('深度.yaml', encoding='utf8') as f: 104 | 深度信息 = yaml.load(f) 105 | for 图层信息 in 所有图层: 106 | if 图层信息['名字'] in 深度信息: 107 | 图层信息['深度'] = 深度信息[图层信息['名字']] 108 | 109 | 110 | if __name__ == '__main__': 111 | psd = PSDImage.open('../res/莉沫酱过于简单版.psd') 112 | 所有图层, size = 提取图层(psd) 113 | 添加深度信息(所有图层) 114 | opengl循环(所有图层, size) 115 | -------------------------------------------------------------------------------- /3/虚境2.py: -------------------------------------------------------------------------------- 1 | import time 2 | import math 3 | 4 | import yaml 5 | import numpy as np 6 | 7 | import glfw 8 | from OpenGL.GL import * 9 | from OpenGL.GLU import * 10 | from psd_tools import PSDImage 11 | from rimo_utils import matrix 12 | 13 | 14 | def 提取图层(psd): 15 | 所有图层 = [] 16 | def dfs(图层, path=''): 17 | if 图层.is_group(): 18 | for i in 图层: 19 | dfs(i, path + 图层.name + '/') 20 | else: 21 | a, b, c, d = 图层.bbox 22 | npdata = 图层.numpy() 23 | npdata[:, :, 0], npdata[:, :, 2] = npdata[:, :, 2].copy(), npdata[:, :, 0].copy() 24 | 所有图层.append({'名字': path + 图层.name, '位置': (b, a, d, c), 'npdata': npdata}) 25 | for 图层 in psd: 26 | dfs(图层) 27 | return 所有图层, psd.size 28 | 29 | 30 | def 生成纹理(img): 31 | w, h = img.shape[:2] 32 | d = 2**int(max(math.log2(w), math.log2(h)) + 1) 33 | 纹理 = np.zeros([d, d, 4], dtype=img.dtype) 34 | 纹理[:w, :h] = img 35 | return 纹理, (w / d, h / d) 36 | 37 | def opengl循环(所有图层, psd尺寸): 38 | Vtuber尺寸 = 512, 512 39 | 40 | glfw.init() 41 | glfw.window_hint(glfw.RESIZABLE, False) 42 | window = glfw.create_window(*Vtuber尺寸, 'Vtuber', None, None) 43 | glfw.make_context_current(window) 44 | glViewport(0, 0, *Vtuber尺寸) 45 | 46 | glEnable(GL_TEXTURE_2D) 47 | glEnable(GL_BLEND) 48 | glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) 49 | 50 | for 图层数据 in 所有图层: 51 | 纹理编号 = glGenTextures(1) 52 | glBindTexture(GL_TEXTURE_2D, 纹理编号) 53 | 纹理, 纹理座标 = 生成纹理(图层数据['npdata']) 54 | width, height = 纹理.shape[:2] 55 | glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA, GL_FLOAT, 纹理) 56 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR) 57 | glGenerateMipmap(GL_TEXTURE_2D) 58 | 图层数据['纹理编号'] = 纹理编号 59 | 图层数据['纹理座标'] = 纹理座标 60 | 61 | while not glfw.window_should_close(window): 62 | glfw.poll_events() 63 | glClearColor(1, 1, 1, 0) 64 | glClear(GL_COLOR_BUFFER_BIT) 65 | for 图层数据 in 所有图层: 66 | a, b, c, d = 图层数据['位置'] 67 | z = 图层数据['深度'] 68 | if type(z) in [int, float]: 69 | z1, z2, z3, z4 = [z, z, z, z] 70 | else: 71 | [z1, z2], [z3, z4] = z 72 | q, w = 图层数据['纹理座标'] 73 | p1 = np.array([a, b, z1, 1, 0, 0, 0, 1]) 74 | p2 = np.array([a, d, z2, 1, w, 0, 0, 1]) 75 | p3 = np.array([c, d, z3, 1, w, q, 0, 1]) 76 | p4 = np.array([c, b, z4, 1, 0, q, 0, 1]) 77 | 78 | model = matrix.scale(2 / psd尺寸[0], 2 / psd尺寸[1], 1) @ \ 79 | matrix.translate(-1, -1, 0) @ \ 80 | matrix.rotate_ax(-math.pi / 2, axis=(0, 1)) 81 | glBindTexture(GL_TEXTURE_2D, 图层数据['纹理编号']) 82 | glColor4f(1, 1, 1, 1) 83 | glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 84 | glBegin(GL_QUADS) 85 | for p in [p1, p2, p3, p4]: 86 | a = p[:4] 87 | b = p[4:] 88 | a = a @ model 89 | z = a[2] 90 | a[0:2] *= z 91 | b *= z 92 | 横旋转量 = 0 93 | if not 图层数据['名字'] == '身体': 94 | 横旋转量 = math.sin(time.time() * 5) / 30 95 | a = a @ matrix.translate(0, 0, -1) \ 96 | @ matrix.rotate_ax(横旋转量, axis=(0, 2)) \ 97 | @ matrix.translate(0, 0, 1) 98 | a = a @ matrix.perspective(999) 99 | glTexCoord4f(*b) 100 | glVertex4f(*a) 101 | glEnd() 102 | glfw.swap_buffers(window) 103 | 104 | 105 | def 添加深度信息(所有图层): 106 | with open('深度2.yaml', encoding='utf8') as f: 107 | 深度信息 = yaml.load(f) 108 | for 图层信息 in 所有图层: 109 | if 图层信息['名字'] in 深度信息: 110 | 图层信息['深度'] = 深度信息[图层信息['名字']] 111 | 112 | 113 | if __name__ == '__main__': 114 | psd = PSDImage.open('../res/莉沫酱过于简单版.psd') 115 | 所有图层, size = 提取图层(psd) 116 | 添加深度信息(所有图层) 117 | opengl循环(所有图层, size) 118 | -------------------------------------------------------------------------------- /4.md: -------------------------------------------------------------------------------- 1 | # 从零开始的自制Vtuber: 4.合成进化 2 | 3 | 这一节之所以叫「合成进化」是因为……怎么说呢,一要动手写教程,脑子里就会突然跳出来一些名字,心里想着「就是它了」,之后想要换个正规的名字又不舍得…… 4 | 5 | 这个章节并不会让你把神经模式转移到Vtuber的身体里——在上一节里,我们已经做出了可以左摇右晃的立绘,现在是时候把脸部捕捉和动态立绘结合了。 6 | 7 | 此外,我们还要处理一些细枝末节,让Vtuber进化得更漂亮一些,以及顺便让Vtuber显示在你的桌面上。 8 | 9 | ## 警告 10 | 11 | 这个章节还没有完成校订,因此可能有和谐内容。 12 | 13 | 请您收好鸡儿,文明观球。 14 | 15 | 16 | ## 准备 17 | 18 | 在这个章节,你需要准备: 19 | 20 | + 电脑 21 | + 前面两节的代码 22 | + 简单的线程知识 23 | + Python3 24 | + NumPy 25 | + OpenGL 26 | 27 | 28 | ## 分离线程 29 | 30 | 之所以要这么做是因为面部捕捉的帧率和绘图的帧率并不相同,因此它们不能同步。 31 | 32 | 拆分出线程也方便我们做其他的修改。 33 | 34 | 首先我们把面部捕捉和Vtuber绘图拆为两个文件,随便起名叫`现实`和`虚境`好了。 35 | 36 | 接下来把面部捕捉循环提取到函数里,然后用一个副线程来运行它。 37 | 因为从直觉来看没有数据错误的问题,所以这里我们不用加锁。 38 | 39 | ```python 40 | 特征组 = [0, 0] 41 | def 捕捉循环(): 42 | global 原点特征组 43 | global 特征组 44 | 原点特征组 = 提取图片特征(cv2.imread('../res/std_face.jpg')) 45 | 特征组 = 原点特征组 - 原点特征组 46 | cap = cv2.VideoCapture(0) 47 | while True: 48 | ret, img = cap.read() 49 | 新特征组 = 提取图片特征(img) 50 | if 新特征组 is not None: 51 | 特征组 = 新特征组 - 原点特征组 52 | time.sleep(1 / 60) 53 | t = threading.Thread(target=捕捉循环) 54 | t.setDaemon(True) 55 | t.start() 56 | ``` 57 | 58 | 我留了一个全局变量和一个接口以便通信。 59 | 60 | 在绘图代码里,每次绘图之前调用`现实.获取特征组()`获取各个角度,就可以绘图了。 61 | 62 | 63 | ## 添加缓冲 64 | 65 | 这个缓冲不是电脑的缓冲而是真的缓冲…… 66 | 67 | 也许你已经发现了,我们进行了多线程改造以后绘图画面还是很卡,它甚至更卡了。 68 | 69 | 这是因为我们的绘图信息完全取决于面部捕捉,如果它不回报新的信息,下一帧画出的图就和上一帧是完全一样的。 70 | 71 | 因此,我们对特征做一个缓冲,让它像是SAI和Photoshop里的平滑画笔一样有一个逐渐改变的过程。 72 | 当Vtuber的头要转到某个位置时,我们并不让头直接过去,而是让我们原本的旋转量快速趋向目标的旋转量。 73 | 74 | 此外,每一次人脸识别出的特征都或多或少含有一些噪声,这会让莉沫酱有一些不自然的抖动。缓冲操作对消除抖动也有很大帮助。 75 | 76 | ```python 77 | def 特征缓冲(): 78 | global 缓冲特征 79 | 缓冲比例 = 0.8 80 | 新特征 = 现实.获取特征组() 81 | 缓冲特征 = 缓冲特征 * 缓冲比例 + 新特征 * (1 - 缓冲比例) 82 | return 缓冲特征 83 | ``` 84 | 85 | 这样一来,画面立刻变得流畅了不少。 86 | 87 | 以前物述有栖就有很明显的这个问题,看来他们的程序员不像我这么聪明。 88 | 89 | 现在,我们可以把自己的脸的`横旋转量`和`竖旋转量`绑定到莉沫酱的图层上了—— 90 | 91 | ```python 92 | 横旋转量, 竖旋转量 = 特征缓冲() 93 | ``` 94 | 95 | ```python 96 | a = a @ matrix.translate(0, 0, -1) \ 97 | @ matrix.rotate_ax(横旋转量, axis=(0, 2)) \ 98 | @ matrix.rotate_ax(竖旋转量, axis=(2, 1)) \ 99 | @ matrix.translate(0, 0, 1) 100 | ``` 101 | 102 | 然后,你可以试着在摄像机前左顾右盼,莉沫酱动起来应该会像是这样—— 103 | 104 | ![./图/4-1.webp](./图/4-1.webp) 105 | 106 | 稳定而且流畅! 107 | 108 | 109 | ## 融合之门 110 | 111 | 现在差不多是时候让莉沫酱像真正的Vtuber一样在桌面上动了! 112 | (怎么听起来有点色情……) 113 | 114 | 总之我们要把OpenGL的底色设为透明,然后给窗口设置一些透明啊、悬浮在最上层啊之类的标记,最后放到屏幕的右下角就行了。 115 | 116 | ```python 117 | Vtuber大小 = 500, 500 118 | glfw.init() 119 | glfw.window_hint(glfw.RESIZABLE, False) 120 | glfw.window_hint(glfw.DECORATED, False) 121 | glfw.window_hint(glfw.TRANSPARENT_FRAMEBUFFER, True) 122 | glfw.window_hint(glfw.FLOATING, True) 123 | window = glfw.create_window(*Vtuber大小, 'Vtuber', None, None) 124 | glfw.make_context_current(window) 125 | monitor_size = glfw.get_video_mode(glfw.get_primary_monitor()).size 126 | glfw.set_window_pos(window, monitor_size.width - Vtuber大小[0], monitor_size.height - Vtuber大小[1]) 127 | ``` 128 | 129 | 以及`glClearColor(0, 0, 0, 0)`。 130 | 131 | 你可以自己去看库的文档,或者直接复制我的代码也行。 132 | 133 | ![./图/4-2.jpg](./图/4-2.jpg) 134 | 135 | 莉沫酱真是太棒了! 136 | 还有以后录个屏就可以直播了! 137 | 138 | ## 复仇 139 | 140 | 其实到这里并没有好,现在莉沫酱看起来是正常的,但如果把她放在白色背景下—— 141 | 142 | ![./图/4-3.jpg](./图/4-3.jpg) 143 | 144 | 咦?莉沫酱在发光??? 145 | 146 | 可以猜测,这应该是一个透明度的问题。我们把此时的渲染画面截图,送进Photoshop看看。 147 | (如果你不知道怎么给opengl截图,可以看看utils中的截图代码) 148 | 149 | ![./图/4-4.jpg](./图/4-4.jpg) 150 | 151 | 果然各种不该透的地方都透过去了! 152 | 153 | 这是因为我们在第二章中将透明度混合设置为了`glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA)`——你在互联网上查OpenGL如何透明叠加,得到的主要都是这个答案,但它用在这个场景下其实是错的。 154 | 155 | 这句话的意思是,假如我要把像素`a: vector<4>`叠加在像素`b: vector<4>`上,最终得到的是—— 156 | 157 | ``` 158 | c = a*a[3] + b*(1-a[3]) 159 | ``` 160 | 161 | 举个例子,当`a = [1, 1, 1, 0.5]`和`b = [1, 1, 1, 1]` 162 | 163 | ``` 164 | c = a*0.5 + b*0.5 165 | c = [1, 1, 1, 0.75] 166 | ``` 167 | 168 | 等等,`c`的透明度怎么会是0.75,这「半透明+不透明=半透明」是什么情况? 169 | 170 | 实际上,在许多OpenGL应用中,它们只关注颜色通道。这些渲染的结果不会被用于二次渲染,而最终输出时的alpha通道又被丢弃了,所以那些时候它的最终效果看起来是对的。 171 | 172 | 正确的做法应当是为透明度和颜色指定不同的混合方式,像这样—— 173 | 174 | ``` 175 | glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA,GL_ONE, GL_ONE_MINUS_SRC_ALPHA) 176 | ``` 177 | 178 | 修改了混合函数之后,我们重新渲染一次莉沫酱,再截图看看—— 179 | 180 | ![./图/4-5.jpg](./图/4-5.jpg) 181 | 182 | 奇怪的透过没有了,真好! 183 | 184 | 185 | ## 结束 186 | 187 | 如果我的某些操作让你非常迷惑,你也可以去这个项目的GitHub仓库查看源代码。 188 | 189 | 最后祝各位妻妾成群。 190 | 191 | 下一节: 192 | + [从零开始的自制Vtuber: 5.一时休战](5.md) 193 | 194 | -------------------------------------------------------------------------------- /4/深度.yaml: -------------------------------------------------------------------------------- 1 | 五官/鼻子: 2 | 0.55 3 | 五官/嘴: 4 | 0.65 5 | 五官/眼睛: 6 | 0.65 7 | 五官/眉毛: 8 | 0.65 9 | 后发: 10 | 0.8 11 | 身体: 12 | 0.9 13 | 脸: 14 | 0.7 15 | 前发: 16 | - [0.7, 0.7] 17 | - [0.6, 0.6] 18 | 侧马尾: 19 | 0.6 20 | -------------------------------------------------------------------------------- /4/现实.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | import threading 4 | 5 | import cv2 6 | import dlib 7 | import numpy as np 8 | 9 | 10 | detector = dlib.get_frontal_face_detector() 11 | 12 | 13 | def 人脸定位(img): 14 | dets = detector(img, 0) 15 | if not dets: 16 | return None 17 | return max(dets, key=lambda det: (det.right() - det.left()) * (det.bottom() - det.top())) 18 | 19 | 20 | predictor = dlib.shape_predictor('../res/shape_predictor_68_face_landmarks.dat') 21 | 22 | 23 | def 提取关键点(img, 脸位置): 24 | landmark_shape = predictor(img, 脸位置) 25 | 关键点 = [] 26 | for i in range(68): 27 | pos = landmark_shape.part(i) 28 | 关键点.append(np.array([pos.x, pos.y], dtype=np.float32)) 29 | return 关键点 30 | 31 | 32 | def 生成构造点(关键点): 33 | def 中心(索引数组): 34 | return sum([关键点[i] for i in 索引数组]) / len(索引数组) 35 | 左眉 = [18, 19, 20, 21] 36 | 右眉 = [22, 23, 24, 25] 37 | 下巴 = [6, 7, 8, 9, 10] 38 | 鼻子 = [29, 30] 39 | return 中心(左眉 + 右眉), 中心(下巴), 中心(鼻子) 40 | 41 | 42 | def 生成特征(构造点): 43 | 眉中心, 下巴中心, 鼻子中心 = 构造点 44 | 中线 = 眉中心 - 下巴中心 45 | 斜边 = 眉中心 - 鼻子中心 46 | 横旋转量 = np.cross(中线, 斜边) / np.linalg.norm(中线)**2 47 | 竖旋转量 = 中线 @ 斜边 / np.linalg.norm(中线)**2 48 | return np.array([横旋转量, 竖旋转量]) 49 | 50 | 51 | def 提取图片特征(img): 52 | 脸位置 = 人脸定位(img) 53 | if not 脸位置: 54 | return None 55 | 关键点 = 提取关键点(img, 脸位置) 56 | 构造点 = 生成构造点(关键点) 57 | 旋转量组 = 生成特征(构造点) 58 | return 旋转量组 59 | 60 | 61 | def 捕捉循环(): 62 | global 原点特征组 63 | global 特征组 64 | 原点特征组 = 提取图片特征(cv2.imread('../res/std_face.jpg')) 65 | 特征组 = 原点特征组 - 原点特征组 66 | cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) 67 | end_time = time.time() 68 | logging.warning('开始捕捉了!') 69 | logging.warning(f'捕捉线程启动耗时:{end_time - start_time:.2f}s') 70 | while True: 71 | ret, img = cap.read() 72 | 新特征组 = 提取图片特征(img) 73 | if 新特征组 is not None: 74 | 特征组 = 新特征组 - 原点特征组 75 | time.sleep(1 / 60) 76 | 77 | 78 | def 获取特征组(): 79 | return 特征组 80 | 81 | 82 | 83 | t = threading.Thread(target=捕捉循环) 84 | t.setDaemon(True) 85 | t.start() 86 | start_time = time.time() 87 | logging.warning('捕捉线程启动中……') 88 | 89 | if __name__ == '__main__': 90 | while True: 91 | time.sleep(0.1) 92 | print(特征组) 93 | -------------------------------------------------------------------------------- /4/虚境.py: -------------------------------------------------------------------------------- 1 | import math 2 | 3 | import yaml 4 | import numpy as np 5 | 6 | import glfw 7 | import OpenGL 8 | from OpenGL.GL import * 9 | from OpenGL.GLU import * 10 | from psd_tools import PSDImage 11 | from rimo_utils import matrix 12 | 13 | import 现实 14 | 15 | 16 | 17 | def 提取图层(psd): 18 | 所有图层 = [] 19 | def dfs(图层, path=''): 20 | if 图层.is_group(): 21 | for i in 图层: 22 | dfs(i, path + 图层.name + '/') 23 | else: 24 | a, b, c, d = 图层.bbox 25 | npdata = 图层.numpy() 26 | npdata[:, :, 0], npdata[:, :, 2] = npdata[:, :, 2].copy(), npdata[:, :, 0].copy() 27 | 所有图层.append({'名字': path + 图层.name, '位置': (b, a, d, c), 'npdata': npdata}) 28 | for 图层 in psd: 29 | dfs(图层) 30 | return 所有图层, psd.size 31 | 32 | 33 | def 添加深度信息(所有图层): 34 | with open('深度.yaml', encoding='utf8') as f: 35 | 深度信息 = yaml.load(f,Loader=yaml.FullLoader) 36 | for 图层信息 in 所有图层: 37 | if 图层信息['名字'] in 深度信息: 38 | 图层信息['深度'] = 深度信息[图层信息['名字']] 39 | 40 | 41 | 缓冲特征 = None 42 | def 特征缓冲(): 43 | global 缓冲特征 44 | 缓冲比例 = 0.8 45 | 新特征 = 现实.获取特征组() 46 | if 缓冲特征 is None: 47 | 缓冲特征 = 新特征 48 | else: 49 | 缓冲特征 = 缓冲特征 * 缓冲比例 + 新特征 * (1 - 缓冲比例) 50 | return 缓冲特征 51 | 52 | 53 | def 超融合(): 54 | glfw.window_hint(glfw.DECORATED, False) 55 | glfw.window_hint(glfw.TRANSPARENT_FRAMEBUFFER, True) 56 | glfw.window_hint(glfw.FLOATING, True) 57 | 58 | 59 | def opengl绘图循环(所有图层, psd尺寸): 60 | def 生成纹理(img): 61 | w, h = img.shape[:2] 62 | d = 2**int(max(math.log2(w), math.log2(h)) + 1) 63 | 纹理 = np.zeros([d, d, 4], dtype=img.dtype) 64 | 纹理[:w, :h] = img 65 | return 纹理, (w / d, h / d) 66 | 67 | Vtuber尺寸 = 512, 512 68 | 69 | glfw.init() 70 | 超融合() 71 | glfw.window_hint(glfw.RESIZABLE, False) 72 | window = glfw.create_window(*Vtuber尺寸, 'Vtuber', None, None) 73 | glfw.make_context_current(window) 74 | monitor_size = glfw.get_video_mode(glfw.get_primary_monitor()).size 75 | glfw.set_window_pos(window, monitor_size.width - Vtuber尺寸[0], monitor_size.height - Vtuber尺寸[1]) 76 | 77 | glViewport(0, 0, *Vtuber尺寸) 78 | 79 | glEnable(GL_TEXTURE_2D) 80 | glEnable(GL_BLEND) 81 | glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA) 82 | 83 | for 图层数据 in 所有图层: 84 | 纹理编号 = glGenTextures(1) 85 | glBindTexture(GL_TEXTURE_2D, 纹理编号) 86 | 纹理, 纹理座标 = 生成纹理(图层数据['npdata']) 87 | width, height = 纹理.shape[:2] 88 | glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA, GL_FLOAT, 纹理) 89 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR) 90 | glGenerateMipmap(GL_TEXTURE_2D) 91 | 图层数据['纹理编号'] = 纹理编号 92 | 图层数据['纹理座标'] = 纹理座标 93 | 94 | while not glfw.window_should_close(window): 95 | glfw.poll_events() 96 | glClearColor(0, 0, 0, 0) 97 | glClear(GL_COLOR_BUFFER_BIT) 98 | 横旋转量, 竖旋转量 = 特征缓冲() 99 | for 图层数据 in 所有图层: 100 | a, b, c, d = 图层数据['位置'] 101 | z = 图层数据['深度'] 102 | if type(z) in [int, float]: 103 | z1, z2, z3, z4 = [z, z, z, z] 104 | else: 105 | [z1, z2], [z3, z4] = z 106 | q, w = 图层数据['纹理座标'] 107 | p1 = np.array([a, b, z1, 1, 0, 0, 0, z1]) 108 | p2 = np.array([a, d, z2, 1, z2 * w, 0, 0, z2]) 109 | p3 = np.array([c, d, z3, 1, z3 * w, z3 * q, 0, z3]) 110 | p4 = np.array([c, b, z4, 1, 0, z4 * q, 0, z4]) 111 | 112 | model = matrix.scale(2 / psd尺寸[0], 2 / psd尺寸[1], 1) @ \ 113 | matrix.translate(-1, -1, 0) @ \ 114 | matrix.rotate_ax(-math.pi / 2, axis=(0, 1)) 115 | glBindTexture(GL_TEXTURE_2D, 图层数据['纹理编号']) 116 | glColor4f(1, 1, 1, 1) 117 | glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 118 | glBegin(GL_QUADS) 119 | for p in [p1, p2, p3, p4]: 120 | a = p[:4] 121 | b = p[4:8] 122 | a = a @ model 123 | a[0:2] *= a[2] 124 | if not 图层数据['名字'][:2] == '身体': 125 | a = a @ matrix.translate(0, 0, -1) \ 126 | @ matrix.rotate_ax(横旋转量, axis=(0, 2)) \ 127 | @ matrix.rotate_ax(竖旋转量, axis=(2, 1)) \ 128 | @ matrix.translate(0, 0, 1) 129 | a = a @ matrix.perspective(999) 130 | glTexCoord4f(*b) 131 | glVertex4f(*a) 132 | glEnd() 133 | glfw.swap_buffers(window) 134 | 135 | 136 | if __name__ == '__main__': 137 | psd = PSDImage.open('../res/莉沫酱过于简单版.psd') 138 | 所有图层, size = 提取图层(psd) 139 | 添加深度信息(所有图层) 140 | opengl绘图循环(所有图层, size) 141 | -------------------------------------------------------------------------------- /5.md: -------------------------------------------------------------------------------- 1 | # 从零开始的自制Vtuber: 5.一时休战 2 | 3 | 这个章节的名字看起来就是要放鸽子……其实不是,现在我们要休息一下,这节写点让人放松的代码。 4 | 5 | 在这个章节里,我们要整理一下以前的代码,顺便拓展一下深度功能,让Vtuber变得更加真实。 6 | 7 | 8 | ## 警告 9 | 10 | 这个章节还没有完成校订,因此可能有和谐内容。 11 | 12 | 请您收好鸡儿,文明观球。 13 | 14 | 15 | ## 准备 16 | 17 | 在这个章节,你需要准备: 18 | 19 | + 电脑 20 | + 前面两节的代码 21 | + Python3 22 | + NumPy 23 | + OpenGL 24 | 25 | 26 | ## 整理代码 27 | 28 | 之前几个章节的功能都是从零开始一点点加进来的,经过了许多章节的堆砌后,它们变得有些混乱。 29 | 30 | 因此我们得重新整理一下代码,首先,我想把图层的相关的功能抽出来做成类,让它负责图层内容的管理。 31 | 32 | ```python 33 | class 图层类: 34 | def __init__(self, 名字, bbox, z, npdata): 35 | ... 36 | def 生成opengl纹理(self): 37 | ... 38 | def 导出(self): 39 | ... 40 | ``` 41 | 42 | `深度.yaml`被重新命名为了`信息.yaml`,这样每个图层包含的信息除了深度以外还可以有一些别的东西……究竟是什么呢?我们留到下一节再说吧。 43 | 44 | 接下来,我又新建了vtuber类。 45 | 46 | ```python 47 | class vtuber: 48 | def __init__(self, psd路径, yaml路径='信息.yaml'): 49 | ... 50 | def opengl绘图循环(self, ...): 51 | ... 52 | ``` 53 | 54 | 原本的函数`添加深度信息`和`提取图层`被合并,作为vtuber的`__init__`。 55 | 56 | `opengl绘图循环`现在是vtuber的方法了。 57 | 58 | 我也把`opengl绘图循环`中绘制图层的函数提取了出来,做成局部函数`draw`。它一次输入一个图层,然后把这个图层的顶点全部在窗口上画完。 59 | 此外,我也为绘图循环添加了一点计时代码。 60 | 61 | 此外,从这一节开始,样例所用的莉沫酱立绘psd要更换成`莉沫酱较简单版.psd`了。这个文件的图层分得更细一些,可以支援一些更高级的操作。 62 | 63 | 64 | ## 图层切分 65 | 66 | 接下来,我们要对图层类进行改造。原本一个图层就对应OpenGL中的一个GL_QUADS,只能是一个平面,我们要让它变成多个GL_QUADS,从而达到模拟曲面的效果。 67 | 68 | 我们原本支持的深度格式包括一个数字,或者一个2×2的矩阵。现在,我们把它泛化到任意n×m的矩阵。 69 | 70 | 举个例子: 71 | 72 | ```yaml 73 | 侧马尾: 74 | 深度: 75 | - [0.8, 0.8] 76 | - [0.65, 0.65] 77 | - [0.7, 0.7] 78 | ``` 79 | 80 | 我们希望在这样设置的时候,图层从上到下的深度是「后-前-略后」,像是一个向前弯曲的曲面。 81 | 82 | 为了达到这个效果,我们要通过四个顶点的座标,算出中间n×m-4个点的座标。 83 | 84 | 这很容易,只要稍微用上一点向量乘法的性质—— 85 | 86 | ```python 87 | x, y = self.shape 88 | self.顶点组 = np.zeros(shape=[x, y, 8]) 89 | for i in range(x): 90 | for j in range(y): 91 | self.顶点组[i, j] = p1 + (p4-p1)*i/(x-1) + (p2-p1)*j/(y-1) 92 | self.顶点组[i, j, 2] = 深度[i, j] 93 | ``` 94 | 95 | 需要注意的是,尽管我们定义了四个点,但这个算法只用到了`p1`、`p2`、`p3`,并没有用到`p3`。这是因为我们假设四个点是在同一个平面上(OpenGL也是这么假设的),如果这个四面体有明显的体积,渲染出来的结果可能会有些不对劲。 96 | 97 | 绘图的时候,绘制的就不是原本的顶点组,而是把(n-1)×(m-1)个GL_QUADS统统绘制一遍。 98 | 99 | ```python 100 | for i in range(x-1): 101 | for j in range(y-1): 102 | 绘制方块( 103 | [[源[i, j], 源[i, j+1]], 104 | [源[i+1, j], 源[i+1, j+1]]] 105 | ) 106 | ``` 107 | 108 | 绘图的效果是这样—— 109 | 110 | ![./图/5-1.jpg](./图/5-1.jpg) 111 | 112 | 不过这个时候我们不太容易看出效果。 113 | 为了看清楚,我们可以把前发挑出来,把第二节里用过的线框绘图模式再叠加一层上去。 114 | 115 | ![./图/5-2.jpg](./图/5-2.jpg) 116 | 117 | 看起来很成功! 118 | 119 | 顺便说一下后发的深度问题——后发类似一个鼓起来的球面,而不是凹进去的,也就是后发中心反而更靠近观众。 120 | 这似乎和直觉相反,但你可以想象一下无论我们从哪个面观察人头,看到的一定是凸的那一面。 121 | 122 | 深度网格看起来应该像是这样—— 123 | 124 | ![./图/5-3.jpg](./图/5-3.jpg) 125 | 126 | 不过也许你会觉得奇怪,我们的面都是长方形,要怎么做出一个漂亮的曲面呢? 127 | 有一个简单的方法是另外写一段代码,用函数来生成深度的`np.array`,然后再把它粘贴回yaml里。 128 | 129 | (其实是我懒得再把用函数生成的功能加进来2333) 130 | 131 | 132 | ## 多线程 133 | 134 | 我一开始以为用Dlib提取特征不会占用GIL……但是也不知道是GIL改了还是我想的不对,现在程序无法使用多个CPU核心。 135 | 136 | 总之我们现在必须把特征提取从多线程改成多进程。 137 | 138 | 大概把`捕捉循环`放到副进程里就行了。 139 | 140 | 像是这样—— 141 | 142 | ```python 143 | pipe = multiprocessing.Pipe() 144 | def 启动(): 145 | p = multiprocessing.Process(target=捕捉循环, args=(pipe[0],)) 146 | p.daemon = True 147 | p.start() 148 | ``` 149 | 150 | 其实我也没怎么写过多进程所以不要问我,上一次写多进程还是2017年的时候,能跑就行了,不能跑的话就去抄代码吧…… 151 | 152 | 153 | ## 加速! 154 | 155 | 当我们分割出较多的面之后,如果你的电脑的配置不好的话,应该差不多要感到卡顿了。 156 | 157 | 「接下来我们把旧式的OpenGL API换成VBO」——也许这个时候你会以为接下来的内容是这个,但其实不是。 158 | 159 | 实际上,在渲染循环中,真正慢的是我们多次调用Numpy进行的矩阵乘法。 160 | 其实它们本来应该写成GLSL,但是在这个教程我想的是降低OpenGL的存在感,仅仅把它当作绘图工具,而更多从向量的基础方式来解释这些事情。这样一来一方面省得读者再去多学一门语言,另一方面Numpy调试起来也比GLSL简单快捷许多。 161 | 162 | 但是我们还是有办法优化矩阵乘法的! 163 | 164 | 仔细观察一下我们的写法—— 165 | 166 | ```python 167 | for p in [p1, p2, p3, p4]: 168 | 位置 = p[:4] @ model 169 | ``` 170 | 171 | model矩阵是放在循环里面乘的? 172 | 173 | 如果我们预先把`[p1, p2, p3, p4]`叠在一起,变成一个4×8的矩阵,然后再乘model,只要做一次大的矩阵乘法就行了。 174 | 甚至我们把一个图层的所有顶点全部合并成为一个n×8的矩阵,再乘model,这样岂不是只要调用O(n)次矩乘就行了! 175 | 176 | ```python 177 | 源 = 图层.顶点组导出() 178 | x, y, _ = 源.shape 179 | 所有顶点 = 源.reshape(x*y, 8) 180 | 181 | a, b = 所有顶点[:, :4], 所有顶点[:, 4:] 182 | a = a @ model 183 | ... 184 | ``` 185 | 186 | 最后绘图的时候再把它们组合回来—— 187 | 188 | ```python 189 | 所有顶点[:, :4], 所有顶点[:, 4:] = a, b 190 | 所有顶点 = 所有顶点.reshape([x, y, 8]) 191 | for i in range(x-1): 192 | for j in range(y-1): 193 | for p in [所有顶点[i, j], 所有顶点[i, j+1], 所有顶点[i+1, j+1], 所有顶点[i+1, j]]: 194 | glTexCoord4f(*p[4:]) 195 | glVertex4f(*p[:4]) 196 | ``` 197 | 198 | (结果最后还是没用上VBO) 199 | 200 | 经过修改之后,帧率可以提高到原本的数倍,这样就不会卡住了。 201 | 202 | 203 | ## 结束 204 | 205 | 如果我的某些操作让你非常迷惑,你也可以去这个项目的GitHub仓库查看源代码。 206 | 207 | 最后祝各位萝莉缠身。 208 | 209 | 210 | 下一节: 211 | + [从零开始的自制Vtuber: 6.与神之假身的接触](6.md) 212 | -------------------------------------------------------------------------------- /6.md: -------------------------------------------------------------------------------- 1 | # 从零开始的自制Vtuber: 6.与神之假身的接触 2 | 3 | 本来章节的名字又要放飞自我,叫什么「与神之假身的接触」之类的,还好我刹车踩住了。 4 | 5 | 对不起最后还是没踩住2333 6 | 7 | 在第六节里,我们要为莉沫酱添加一些不同的表情。 8 | 9 | 10 | ## 警告 11 | 12 | 这个章节还是草稿,它会在之后的commit中加入和谐内容,请您不要收好鸡儿,不要文明观球。 13 | 14 | 15 | ## 准备 16 | 17 | 在这个章节,你需要准备: 18 | 19 | + 更好的电脑 20 | + 前面所有的代码 21 | + 全靠蒙的知识 22 | + Python3 23 | + NumPy 24 | + OpenGL 25 | 26 | 27 | ## 简单变形-眉毛 28 | 29 | 在之前的章节里,莉沫酱的五官位置都是固定的,现在我们来让她做一些不同的表情! 30 | 31 | 我们先来做一些简单的事情,把眉毛弯下来一点,做成一个嘲讽(?)的形态 32 | 33 | 首先,我们用上一节定义深度网格的方法把眉毛分割成三格—— 34 | 35 | ```yaml 36 | 深度: 37 | - [0.65, 0.65, 0.65, 0.65] 38 | - [0.65, 0.65, 0.65, 0.65] 39 | ``` 40 | 41 | 如果把网格绘制出来,看起来应该是这样—— 42 | 43 | ![./图/6-1.jpg](./图/6-1.jpg) 44 | 45 | 接下来,我们要让莉沫酱做出讽刺的表情。 46 | 47 | 我们同样用一个yaml文件来记录每个图层的变形信息,比如以这样的形式定义一个变形—— 48 | 49 | ```yaml 50 | 讽刺: 51 | 头/五官/眉毛/左: 52 | 位置: 53 | - [[0, 0.01], [0, -0.01], [0, -0.02], [0, -0.02]] 54 | - [[0, 0.01], [0, -0.01], [0, -0.02], [0, -0.02]] 55 | ``` 56 | 57 | 这个矩阵中的每个`(dx, dy)`即表示莉沫酱的左眉偏移的座标,比如第一个`[0, 0.01]`即为让眉毛左上角的顶点横向移动0单位,纵向移动0.01单位。 58 | 59 | 然后这个yaml加载到你的代码里,在对应的图层(眉毛)上画出来看看—— 60 | 61 | ```python 62 | if '位置' in 变形[图层名]: 63 | d = np.array(变形[图层名]['位置']) 64 | a[:, :2] += d.reshape(a.shape[0], 2) 65 | ``` 66 | 67 | ![./图/6-2.jpg](./图/6-2.jpg) 68 | 69 | 眉毛变弯啦!(另一边我也偷偷加上了) 70 | 71 | 它看起来稍微有点抖……这是因为现在只分了三段,你可以适当地多分一点让它看起来更加自然。 72 | 73 | 74 | ## 麻烦变形-张嘴 75 | 76 | 接下来我们来给嘴巴也做上开合的效果—— 77 | 78 | 一般来说,嘴是由三个图层构成的,包括`上`、`下`、`颜色`,像是这样—— 79 | 80 | ![./图/6-3.jpg](./图/6-3.jpg) 81 | 82 | 以上嘴唇为例,先把其他的图层隐藏起来,像是这样—— 83 | 84 | ![./图/6-4.jpg](./图/6-4.jpg) 85 | 86 | 你可以让你的画师朋友在嘴巴上画一条线,来确定闭嘴时上下嘴唇的位置。然后你就能以它为基准,对上下嘴唇分别添加变形,让它们都刚好重合到这条线的位置上。 87 | 88 | 顺便说一下,因为顶点多起来以后变形很难写,所以我加了点法术—— 89 | 90 | > 魔法会侵蚀你的灵魂! 91 | 92 | ```python 93 | d = 变形[图层名]['位置'] 94 | if type(d) is str: 95 | d = eval(d) 96 | ``` 97 | 98 | 这样一来,就可以用几个二次函数来控制嘴唇的变化了—— 99 | 100 | ```yaml 101 | 闭嘴: 102 | 头/五官/嘴/下: 103 | 位置: 104 | '[[-0.001, -abs(i-4)**2/1280] for i in range(9)], [[-(i-4)/100, -abs(i-4)**2/1280 + 0.02] for i in range(9)]' 105 | 头/五官/嘴/上: 106 | 位置: 107 | '[[0, abs(i-4)**2/960-0.033] for i in range(9)], [[0, abs(i-4)**2/960-0.027] for i in range(9)]' 108 | ``` 109 | 110 | 调整到这个重合的位置之后,我们就来测试一下张嘴-闭嘴的效果吧。 111 | 112 | 我们刚才把张嘴的状态加上一个变形矩阵后得到了闭嘴的状态,那怎么得到张嘴和闭嘴中间的状态呢——只要给变形矩阵乘一个0~1间的小数,就可以实现不同强度的变形插值啦。 113 | 114 | 像是这样,我们先用一个`cos`函数控制强度,渲染出来测试一下—— 115 | 116 | ```python 117 | f = (math.cos(time.time()*k)+1)/2 118 | a[:, :2] += d.reshape(a.shape[0], 2) * f 119 | ``` 120 | 121 | ![./图/6-5.webp](./图/6-5.webp) 122 | 123 | 太棒了,看起来就像是莉沫酱真的在说话一样! 124 | 125 | (对了,别忘了把颜色图层的变形也补上) 126 | 127 | 128 | ## 绑定 129 | 130 | 介绍完嘴巴的变形之后,你可以试着把眼睛的开合、眼球运动等变形耶实现一遍,有了这些变形之后,接下来我们就可以让莉沫酱的表情和自己的表情同步了。 131 | 132 | 添加表情有几种方法,一种是通过表情识别模型来控制,另一种是使用嘴的大小、眼睛的大小、眉毛的高度等特征分别驱动各个五官。 133 | 134 | > 还有其他奇怪的方法比如按键盘…… 135 | 136 | 因为我们没有表情识别模型,所以我们就搞用特征分别驱动的方法吧。 137 | 138 | 你可以用类似于第一章所说的简单的计算几何方法,计算出自己的脸的各种特征——在选择特征的计算方法时,别忘了缩放不变性。 139 | 140 | 有了这些特征之后,在渲染时就可以根据各个图层和这个特征有没有关联,来调整顶点位置—— 141 | 142 | ```python 143 | def 附加变形(self, 变形名, 图层名, a, b, f): 144 | 变形 = self.变形组[变形名] 145 | if 图层名 not in 变形: 146 | return a, b 147 | if '位置' in 变形[图层名]: 148 | d = 变形[图层名]['位置'] 149 | if type(d) is str: 150 | d = eval(d) 151 | d = np.array(d) 152 | a[:, :2] += d.reshape(a.shape[0], 2) * f 153 | return a, b 154 | def 多重附加变形(self, 变形组, 图层名, a, b): 155 | for 变形名, 强度 in 变形组: 156 | a, b = self.附加变形(变形名, 图层名, a, b, 强度) 157 | return a, b 158 | a, b = self.多重附加变形([ 159 | ['眉上', 眉上度], 160 | ['左眼远离', 眼睛左右], 161 | ['右眼远离', -眼睛左右], 162 | ['左眼闭', 闭眼强度], 163 | ['右眼闭', 闭眼强度], 164 | ['闭嘴', 闭嘴强度], 165 | ], 图层.名字, a, b) 166 | ``` 167 | 168 | 比如`下唇`这个图层,可能会同时进行以上6种变形,在绘制时逐个检查该变形是否包含`下唇`图层,如果没有就跳过,这样就可以让各个图层绑定在各自的变形上啦。 169 | 170 | 最终的效果像是这样—— 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | -------------------------------------------------------------------------------- /6/信息.yaml: -------------------------------------------------------------------------------- 1 | 头/五官/鼻子: 2 | 深度: 3 | - [0.58, 0.58] 4 | - [0.56, 0.56] 5 | 6 | 头/五官/嘴/上: 7 | 深度: 8 | '[0.63]*9, [0.64]*9' 9 | 头/五官/嘴/下: 10 | 深度: 11 | '[0.64]*9, [0.65]*9' 12 | 头/五官/嘴/舌: 13 | 深度: 14 | '[0.63]*9, [0.65]*9' 15 | 16 | _眼球深度: &a 17 | 深度: 18 | - [0.65, 0.65] 19 | - [0.67, 0.67] 20 | 21 | 头/五官/眼球/左: *a 22 | 头/五官/眼球/右: *a 23 | 头/五官/眼球/高光左: *a 24 | 头/五官/眼球/高光右: *a 25 | 头/五官/眼白/左: *a 26 | 头/五官/眼白/右: *a 27 | 28 | 头/五官/眼皮/左: &aa 29 | 深度: 30 | '[0.65]*4, [0.67]*4' 31 | 头/五官/眼皮/右: *aa 32 | 33 | _睫毛深度: &b 34 | 深度: 35 | 0.65 36 | 头/五官/睫毛/左/主: 37 | 深度: 38 | '[0.65]*15, [0.65]*15' 39 | 头/五官/睫毛/左/尾: *b 40 | 头/五官/睫毛/左/眼睑: *b 41 | 头/五官/睫毛/左/下: 42 | 深度: 0.67 43 | 头/五官/睫毛/右/主: 44 | 深度: 45 | '[0.65]*15, [0.65]*15' 46 | 头/五官/睫毛/右/尾: *b 47 | 头/五官/睫毛/右/眼睑: *b 48 | 头/五官/睫毛/右/下: 49 | 深度: 0.67 50 | 51 | 头/五官/眉毛/左: 52 | 深度: 53 | '[0.65]*4, [0.65]*4' 54 | 头/五官/眉毛/右: 55 | 深度: 56 | '[0.65]*4, [0.65]*4' 57 | 58 | 头/耳朵/左: 59 | 深度: 60 | - [0.7, 0.85] 61 | - [0.7, 0.85] 62 | 63 | 头/耳朵/右: 64 | 深度: 65 | - [0.85, 0.7] 66 | - [0.85, 0.7] 67 | 68 | 头/脸/基: 69 | 深度: 70 | - [0.66, 0.60, 0.66] 71 | - [0.72, 0.66, 0.72] 72 | 头/阴影: 73 | 深度: 74 | - [0.66, 0.60, 0.66] 75 | - [0.69, 0.63, 0.69] 76 | 头/脸/红晕: 77 | 深度: 78 | 0.7 79 | 80 | 头/后发/尾: 81 | 深度: 82 | 0.8 83 | 头/后发/下侧: 84 | 深度: 85 | 0.8 86 | 头/后发/主: 87 | 深度: 88 | - [1.543,1.322,1.207,1.162,1.152,1.151,1.152,1.162,1.207,1.322,1.543] 89 | - [1.322,1.068,0.923,0.862,0.847,0.846,0.847,0.862,0.923,1.068,1.322] 90 | - [1.207,0.923,0.741,0.651,0.627,0.625,0.627,0.651,0.741,0.923,1.207] 91 | - [1.162,0.862,0.651,0.524,0.500,0.500,0.500,0.524,0.651,0.862,1.162] 92 | - [1.152,0.847,0.627,0.500,0.500,0.500,0.500,0.500,0.627,0.847,1.152] 93 | - [1.151,0.846,0.625,0.500,0.500,0.500,0.500,0.500,0.625,0.846,1.151] 94 | - [1.152,0.847,0.627,0.500,0.500,0.500,0.500,0.500,0.627,0.847,1.152] 95 | - [1.162,0.862,0.651,0.524,0.500,0.500,0.500,0.524,0.651,0.862,1.162] 96 | - [1.207,0.923,0.741,0.651,0.627,0.625,0.627,0.651,0.741,0.923,1.207] 97 | - [1.322,1.068,0.923,0.862,0.847,0.846,0.847,0.862,0.923,1.068,1.322] 98 | - [1.543,1.322,1.207,1.162,1.152,1.151,1.152,1.162,1.207,1.322,1.543] 99 | 100 | 101 | 头/前发/左: 102 | 深度: 103 | - [0.71, 0.75] 104 | - [0.62, 0.66] 105 | - [0.65, 0.69] 106 | - [0.69, 0.73] 107 | 头/前发/右: 108 | 深度: 109 | - [0.75, 0.71] 110 | - [0.66, 0.62] 111 | - [0.69, 0.65] 112 | - [0.73, 0.69] 113 | 头/前发/右右: 114 | 深度: 115 | - [0.8, 0.7] 116 | - [0.8, 0.7] 117 | 头/前发/中: 118 | 深度: 119 | - [0.78, 0.74, 0.78] 120 | - [0.64, 0.60, 0.64] 121 | - [0.66, 0.62, 0.66] 122 | 123 | 头/侧马尾/主: 124 | 深度: 125 | - [0.8, 0.8] 126 | - [0.65, 0.65] 127 | - [0.7, 0.7] 128 | 头/侧马尾/花: 129 | 深度: 130 | - [0.76, 0.88] 131 | - [0.76, 0.88] 132 | 头/侧马尾/绳子a: 133 | 深度: 134 | - [0.8, 0.85] 135 | - [0.8, 0.85] 136 | 头/侧马尾/绳子b: 137 | 深度: 138 | 0.81 139 | 140 | 141 | 身体: 142 | 深度: 143 | 0.9 144 | 脖子: 145 | 深度: 146 | 0.9 147 | 148 | 项链/项链1: 149 | 深度: 150 | 0.9 151 | 项链/项链2: 152 | 深度: 153 | 0.9 154 | 项链/阴影: 155 | 深度: 156 | 0.9 157 | 158 | 腰部蝴蝶结/花: 159 | 深度: 160 | 0.75 161 | 腰部蝴蝶结/绳子: 162 | 深度: 163 | 0.75 164 | 腰部蝴蝶结/ribbon: 165 | 深度: 166 | 0.8 167 | 腰部蝴蝶结/ribbon2: 168 | 深度: 169 | 0.8 170 | 腰部蝴蝶结/锁链: 171 | 深度: 172 | - [0.8, 0.8] 173 | - [0.75, 0.75] 174 | 175 | 上臂/左: 176 | 深度: 177 | - [0.9, 0.9] 178 | - [0.8, 0.8] 179 | 上臂/右: 180 | 深度: 181 | - [0.9, 0.9] 182 | - [0.8, 0.8] 183 | 小臂/绳结左: 184 | 深度: 185 | 0.7 186 | 小臂/绳结右: 187 | 深度: 188 | 0.7 189 | 小臂/袖子左: 190 | 深度: 191 | - [0.7, 0.8] 192 | - [0.7, 0.8] 193 | 小臂/袖子右: 194 | 深度: 195 | - [0.8, 0.7] 196 | - [0.8, 0.7] 197 | 小臂/左手: 198 | 深度: 199 | 0.7 200 | 小臂/右手: 201 | 深度: 202 | 0.7 -------------------------------------------------------------------------------- /6/变形.yaml: -------------------------------------------------------------------------------- 1 | 永远: 2 | 头/五官/眼皮/左: 3 | 位置: 4 | - [[0, 0.00], [0, 0.01], [0, 0.01], [0, 0.00]] 5 | - [[0, 0.02], [0, 0.06], [0, 0.06], [0, 0.02]] 6 | 头/五官/眼皮/右: 7 | 位置: 8 | - [[0, 0.00], [0, 0.01], [0, 0.01], [0, 0.00]] 9 | - [[0, 0.02], [0, 0.06], [0, 0.06], [0, 0.02]] 10 | 头/五官/眼球/左: &a0 11 | 位置: 12 | - [[0.0072, 0], [0.0072, 0]] 13 | - [[0.0072, 0], [0.0072, 0]] 14 | 头/五官/眼球/高光左: *a0 15 | 头/五官/眼球/右: &b0 16 | 位置: 17 | - [[-0.0072, 0], [-0.0072, 0]] 18 | - [[-0.0072, 0], [-0.0072, 0]] 19 | 头/五官/眼球/高光右: *b0 20 | 头/五官/眉毛/左: 21 | 位置: 22 | - [[0, -0.015], [0, -0.015], [0, -0.015], [0, -0.015]] 23 | - [[0, -0.015], [0, -0.015], [0, -0.015], [0, -0.015]] 24 | 头/五官/眉毛/右: 25 | 位置: 26 | - [[0, -0.015], [0, -0.015], [0, -0.015], [0, -0.015]] 27 | - [[0, -0.015], [0, -0.015], [0, -0.015], [0, -0.015]] 28 | 29 | 讽刺: 30 | 头/五官/眉毛/左: 31 | 位置: 32 | - [[0, 0.01], [0, -0.01], [0, -0.02], [0, -0.02]] 33 | - [[0, 0.01], [0, -0.01], [0, -0.02], [0, -0.02]] 34 | 头/五官/眉毛/右: 35 | 位置: 36 | - [[0, -0.02], [0, -0.02], [0, -0.01], [0, 0.01]] 37 | - [[0, -0.02], [0, -0.02], [0, -0.01], [0, 0.01]] 38 | 39 | 眉上: 40 | 头/五官/眉毛/左: 41 | 位置: 42 | - [[0, 0.02], [0, 0.02], [0, 0.02], [0, 0.02]] 43 | - [[0, 0.02], [0, 0.02], [0, 0.02], [0, 0.02]] 44 | 头/五官/眉毛/右: 45 | 位置: 46 | - [[0, 0.02], [0, 0.02], [0, 0.02], [0, 0.02]] 47 | - [[0, 0.02], [0, 0.02], [0, 0.02], [0, 0.02]] 48 | 49 | 闭嘴: 50 | 头/五官/嘴/下: 51 | 位置: 52 | '[[-0.001, -abs(i-4)**2/1280] for i in range(9)], [[-(i-4)/100, -abs(i-4)**2/1280 + 0.02] for i in range(9)]' 53 | 头/五官/嘴/上: 54 | 位置: 55 | '[[0, abs(i-4)**2/960-0.033] for i in range(9)], [[0, abs(i-4)**2/960-0.027] for i in range(9)]' 56 | 头/五官/嘴/舌: 57 | 位置: 58 | '[[0, abs(i-4)**2/1440-0.042] for i in range(9)], [[0, abs(i-4)**2/1440+0.016] for i in range(9)]' 59 | 60 | 左眼远离: 61 | 头/五官/眼球/左: &a 62 | 位置: 63 | - [[0.0168, 0], [0.0168, 0]] 64 | - [[0.0168, 0], [0.0168, 0]] 65 | 头/五官/眼球/高光左: *a 66 | 右眼远离: 67 | 头/五官/眼球/右: &b 68 | 位置: 69 | - [[-0.0168, 0], [-0.0168, 0]] 70 | - [[-0.0168, 0], [-0.0168, 0]] 71 | 头/五官/眼球/高光右: *b 72 | 左眼上: 73 | 头/五官/眼球/左: &as 74 | 位置: 75 | - [[0, 0.004], [0, 0.004]] 76 | - [[0, 0.004], [0, 0.004]] 77 | 头/五官/眼球/高光左: *as 78 | 右眼上: 79 | 头/五官/眼球/右: *as 80 | 头/五官/眼球/高光右: *as 81 | 82 | 左眼闭: 83 | 头/五官/睫毛/左/主: 84 | 位置: 85 | '[[0, 0.85*((i-7)**2/4900*6-0.06) - (13-i)*0.0015] for i in range(15)], [[0, 0.85*((i-7)**2/4900*6-0.06) + 0.007] for i in range(15)]' 86 | 头/五官/眼白/左: &c 87 | 位置: 88 | - [[0, -0.01], [0, -0.01]] 89 | - [[0, 0.021], [0, 0.021]] 90 | 头/五官/眼球/左: *c 91 | 头/五官/眼球/高光左: *c 92 | 头/五官/眼皮/左: 93 | 位置: 94 | - [[0, 0.00], [0, -0.01], [0, -0.01], [0, 0.00]] 95 | - [[0, -0.02], [0, -0.06], [0, -0.06], [0, -0.02]] 96 | 头/五官/睫毛/左/下: 97 | 位置: 98 | - [[0, 0.021], [0, 0.021]] 99 | - [[0, 0.021], [0, 0.021]] 100 | 101 | 右眼闭: 102 | 头/五官/睫毛/右/主: 103 | 位置: 104 | '[[0, 0.85*((i-7)**2/4900*6-0.06) - (13-i)*0.0015] for i in range(15)][::-1], [[0, 0.85*((i-7)**2/4900*6-0.06) + 0.007] for i in range(15)][::-1]' 105 | 头/五官/眼白/右: &d 106 | 位置: 107 | - [[0, -0.01], [0, -0.01]] 108 | - [[0, 0.021], [0, 0.021]] 109 | 头/五官/眼球/右: *d 110 | 头/五官/眼球/高光右: *d 111 | 头/五官/眼皮/右: 112 | 位置: 113 | - [[0, 0.00], [0, -0.01], [0, -0.01], [0, 0.00]] 114 | - [[0, -0.02], [0, -0.06], [0, -0.06], [0, -0.02]] 115 | 头/五官/睫毛/右/下: 116 | 位置: 117 | - [[0, 0.021], [0, 0.021]] 118 | - [[0, 0.021], [0, 0.021]] -------------------------------------------------------------------------------- /6/现实.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | import threading 4 | import multiprocessing 5 | 6 | import cv2 7 | import dlib 8 | import numpy as np 9 | from rimo_utils import 计时 10 | 11 | 12 | detector = dlib.get_frontal_face_detector() 13 | 14 | 15 | def 多边形面积(a): 16 | a = np.array(a) 17 | x = a[:, 0] 18 | y = a[:, 1] 19 | return 0.5*np.abs(np.dot(x, np.roll(y, 1))-np.dot(y, np.roll(x, 1))) 20 | 21 | 22 | def 人脸定位(img): 23 | dets = detector(img, 0) 24 | if not dets: 25 | return None 26 | return max(dets, key=lambda det: (det.right() - det.left()) * (det.bottom() - det.top())) 27 | 28 | 29 | predictor = dlib.shape_predictor('../res/shape_predictor_68_face_landmarks.dat') 30 | 31 | 32 | def 提取关键点(img, 脸位置): 33 | landmark_shape = predictor(img, 脸位置) 34 | 关键点 = [] 35 | for i in range(68): 36 | pos = landmark_shape.part(i) 37 | 关键点.append(np.array([pos.x, pos.y], dtype=np.float32)) 38 | return np.array(关键点) 39 | 40 | 41 | def 计算旋转量(关键点): 42 | def 中心(索引数组): 43 | return sum([关键点[i] for i in 索引数组]) / len(索引数组) 44 | 左眉 = [18, 19, 20, 21] 45 | 右眉 = [22, 23, 24, 25] 46 | 下巴 = [6, 7, 8, 9, 10] 47 | 鼻子 = [29, 30] 48 | 眉中心, 下巴中心, 鼻子中心 = 中心(左眉 + 右眉), 中心(下巴), 中心(鼻子) 49 | 中线 = 眉中心 - 下巴中心 50 | 斜边 = 眉中心 - 鼻子中心 51 | 中线长 = np.linalg.norm(中线) 52 | 横旋转量 = np.cross(中线, 斜边) / 中线长**2 53 | 竖旋转量 = 中线 @ 斜边 / 中线长**2 54 | Z旋转量 = np.cross(中线, [0, 1]) / 中线长 55 | return np.array([横旋转量, 竖旋转量, Z旋转量]) 56 | 57 | 58 | def 计算嘴大小(关键点): 59 | 边缘 = 关键点[0:17] 60 | 嘴边缘 = 关键点[48:60] 61 | 嘴大小 = 多边形面积(嘴边缘) / 多边形面积(边缘) 62 | return np.array([嘴大小]) 63 | 64 | 65 | def 计算相对位置(img, 脸位置): 66 | x = (脸位置.top() + 脸位置.bottom())/2/img.shape[0] 67 | y = (脸位置.left() + 脸位置.right())/2/img.shape[1] 68 | y = 1 - y 69 | 相对位置 = np.array([x, y]) 70 | return 相对位置 71 | 72 | 73 | def 计算脸大小(关键点): 74 | 边缘 = 关键点[0:17] 75 | t = 多边形面积(边缘)**0.5 76 | return np.array([t]) 77 | 78 | 79 | def 计算眼睛大小(关键点): 80 | 边缘 = 关键点[0:17] 81 | 左 = 多边形面积(关键点[36:42]) / 多边形面积(边缘) 82 | 右 = 多边形面积(关键点[42:48]) / 多边形面积(边缘) 83 | return np.array([左, 右]) 84 | 85 | 86 | def 计算眉毛高度(关键点): 87 | 边缘 = 关键点[0:17] 88 | 左 = 多边形面积([*关键点[18:22]]+[关键点[38], 关键点[37]]) / 多边形面积(边缘) 89 | 右 = 多边形面积([*关键点[22:26]]+[关键点[44], 关键点[43]]) / 多边形面积(边缘) 90 | return np.array([左, 右]) 91 | 92 | 93 | def 提取图片特征(img): 94 | 脸位置 = 人脸定位(img) 95 | if not 脸位置: 96 | return None 97 | 相对位置 = 计算相对位置(img, 脸位置) 98 | 关键点 = 提取关键点(img, 脸位置) 99 | 旋转量组 = 计算旋转量(关键点) 100 | 脸大小 = 计算脸大小(关键点) 101 | 眼睛大小 = 计算眼睛大小(关键点) 102 | 嘴大小 = 计算嘴大小(关键点) 103 | 眉毛高度 = 计算眉毛高度(关键点) 104 | 105 | img //= 2 106 | img[脸位置.top():脸位置.bottom(), 脸位置.left():脸位置.right()] *= 2 107 | for i, (px, py) in enumerate(关键点): 108 | cv2.putText(img, str(i), (int(px), int(py)), cv2.FONT_HERSHEY_COMPLEX, 0.25, (255, 255, 255)) 109 | 110 | return np.concatenate([旋转量组, 相对位置, 嘴大小, 脸大小, 眼睛大小, 眉毛高度]) 111 | 112 | 113 | 原点特征组 = 提取图片特征(cv2.imread('../res/std_face.jpg')) 114 | 特征组 = 原点特征组 - 原点特征组 115 | 116 | 117 | def 捕捉循环(pipe): 118 | global 原点特征组 119 | global 特征组 120 | cap = cv2.VideoCapture(0) 121 | logging.warning('开始捕捉了!') 122 | while True: 123 | with 计时.帧率计('提特征'): 124 | ret, img = cap.read() 125 | 新特征组 = 提取图片特征(img) 126 | cv2.imshow('', img[:, ::-1]) 127 | cv2.waitKey(1) 128 | if 新特征组 is not None: 129 | 特征组 = 新特征组 - 原点特征组 130 | pipe.send(特征组) 131 | 132 | 133 | def 获取特征组(): 134 | global 特征组 135 | return 特征组 136 | 137 | 138 | def 转移(): 139 | global 特征组 140 | logging.warning('转移线程启动了!') 141 | while True: 142 | 特征组 = pipe[1].recv() 143 | 144 | 145 | pipe = multiprocessing.Pipe() 146 | 147 | 148 | def 启动(): 149 | t = threading.Thread(target=转移) 150 | t.setDaemon(True) 151 | t.start() 152 | logging.warning('捕捉进程启动中……') 153 | p = multiprocessing.Process(target=捕捉循环, args=(pipe[0],)) 154 | p.daemon = True 155 | p.start() 156 | 157 | 158 | if __name__ == '__main__': 159 | 启动() 160 | np.set_printoptions(precision=3, suppress=True) 161 | while True: 162 | time.sleep(0.1) 163 | # print(获取特征组()) 164 | -------------------------------------------------------------------------------- /6/虚境.py: -------------------------------------------------------------------------------- 1 | import time 2 | import math 3 | import random 4 | import logging 5 | import functools 6 | 7 | import numpy as np 8 | import yaml 9 | 10 | import glfw 11 | import OpenGL 12 | from OpenGL.GL import * 13 | from OpenGL.GLU import * 14 | from rimo_utils import matrix 15 | from rimo_utils import 计时 16 | import psd_tools 17 | 18 | import 现实 19 | 20 | 21 | Vtuber尺寸 = 1000, 1000 22 | 23 | 24 | class 图层类: 25 | def __init__(self, 名字, bbox, z, npdata): 26 | self.名字 = 名字 27 | self.npdata = npdata 28 | self.纹理编号, 纹理座标 = self.生成opengl纹理() 29 | self.变形 = [] 30 | q, w = 纹理座标 31 | a, b, c, d = bbox 32 | if type(z) is str: 33 | z = eval(z) 34 | if type(z) in [int, float]: 35 | 深度 = np.array([[z, z], [z, z]]) 36 | else: 37 | 深度 = np.array(z) 38 | assert len(深度.shape) == 2 39 | self.shape = 深度.shape 40 | 41 | [[p1, p2], 42 | [p4, p3]] = np.array([ 43 | [[a, b, 0, 1, 0, 0, 0, 1], [a, d, 0, 1, w, 0, 0, 1]], 44 | [[c, b, 0, 1, 0, q, 0, 1], [c, d, 0, 1, w, q, 0, 1]], 45 | ]) 46 | x, y = self.shape 47 | self.顶点组 = np.zeros(shape=[x, y, 8]) 48 | for i in range(x): 49 | for j in range(y): 50 | self.顶点组[i, j] = p1 + (p4-p1)*i/(x-1) + (p2-p1)*j/(y-1) 51 | self.顶点组[i, j, 2] = 深度[i, j] 52 | 53 | def 生成opengl纹理(self): 54 | w, h = self.npdata.shape[:2] 55 | d = 2**int(max(math.log2(w), math.log2(h)) + 1) 56 | 纹理 = np.zeros([d, d, 4], dtype=self.npdata.dtype) 57 | 纹理[:, :, :3] = 255 58 | 纹理[:w, :h] = self.npdata 59 | 纹理座标 = (w / d, h / d) 60 | 61 | width, height = 纹理.shape[:2] 62 | 纹理编号 = glGenTextures(1) 63 | glBindTexture(GL_TEXTURE_2D, 纹理编号) 64 | glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA, GL_FLOAT, 纹理) 65 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR) 66 | glGenerateMipmap(GL_TEXTURE_2D) 67 | 68 | return 纹理编号, 纹理座标 69 | 70 | def 顶点组导出(self): 71 | return self.顶点组.copy() 72 | 73 | 74 | class vtuber: 75 | def __init__(self, psd路径, 切取范围=(1024, 1024), 信息路径='信息.yaml', 变形路径='变形.yaml'): 76 | psd = psd_tools.PSDImage.open(psd路径) 77 | with open(信息路径, encoding='utf8') as f: 78 | 信息 = yaml.safe_load(f) 79 | with open(变形路径, encoding='utf8') as f: 80 | self.变形组 = yaml.safe_load(f) 81 | 82 | def 再装填(): 83 | while True: 84 | time.sleep(1) 85 | try: 86 | with open(变形路径, encoding='utf8') as f: 87 | self.变形组 = yaml.safe_load(f) 88 | except Exception as e: 89 | logging.exception(e) 90 | import threading 91 | t = threading.Thread(target=再装填) 92 | t.setDaemon(True) 93 | t.start() 94 | 95 | self.所有图层 = [] 96 | self.psd尺寸 = psd.size 97 | self.切取范围 = 切取范围 98 | 99 | def dfs(图层, path=''): 100 | if 图层.is_group(): 101 | for i in 图层: 102 | dfs(i, path + 图层.name + '/') 103 | else: 104 | 名字 = path+图层.name 105 | if 名字 not in 信息: 106 | logging.warning(f'图层「{名字}」找不到信息,丢了!') 107 | return 108 | a, b, c, d = 图层.bbox 109 | npdata = 图层.numpy() 110 | npdata[:, :, :3] = npdata[:, :, :3][:, :, ::-1] 111 | self.所有图层.append(图层类( 112 | 名字=名字, 113 | z=信息[名字]['深度'], 114 | bbox=(b, a, d, c), 115 | npdata=npdata 116 | )) 117 | for 图层 in psd: 118 | dfs(图层) 119 | self.截图 = None 120 | self.启用截图 = False 121 | 122 | def 获取截图(self, 反转颜色=True): 123 | while True: 124 | self.启用截图 = True 125 | if self.截图: 126 | img = np.frombuffer(self.截图, dtype=np.uint8).reshape((*Vtuber尺寸, 4)).copy() 127 | if 反转颜色: 128 | img[:, :, :3] = img[:, :, :3][:, :, ::-1] 129 | img = img[::-1] 130 | return img 131 | time.sleep(0.01) 132 | 133 | def 附加变形(self, 变形名, 图层名, a, b, f): 134 | 变形 = self.变形组[变形名] 135 | if 图层名 not in 变形: 136 | return a, b 137 | if '位置' in 变形[图层名]: 138 | d = 变形[图层名]['位置'] 139 | if type(d) is str: 140 | d = eval(d) 141 | d = np.array(d) 142 | a[:, :2] += d.reshape(a.shape[0], 2) * f 143 | return a, b 144 | 145 | def 多重附加变形(self, 变形组, 图层名, a, b): 146 | for 变形名, 强度 in 变形组: 147 | a, b = self.附加变形(变形名, 图层名, a, b, 强度) 148 | return a, b 149 | 150 | def opengl绘图循环(self, window, 数据源, line_box=False): 151 | def 没有状态但是却能均匀变化的随机数(范围=(0, 1), 速度=1): 152 | now = time.time()*速度 153 | a, b = int(now), int(now)+1 154 | random.seed(a) 155 | f0 = random.random() 156 | random.seed(b) 157 | f1 = random.random() 158 | f = f0 * (b-now) + f1 * (now-a) 159 | return 范围[0] + (范围[1]-范围[0])*f 160 | 161 | def 锚击(x, a, b): 162 | x = sorted([x, a, b])[1] 163 | return (x-a)/(b-a) 164 | 165 | @functools.lru_cache(maxsize=16) 166 | def model(xz, zy, xy, 脸大小, x偏移, y偏移): 167 | model_p = \ 168 | matrix.translate(0, 0, -0.9) @ \ 169 | matrix.rotate_ax(xz, axis=(0, 2)) @ \ 170 | matrix.rotate_ax(zy, axis=(2, 1)) @ \ 171 | matrix.translate(0, 0.9, 0.9) @ \ 172 | matrix.rotate_ax(xy, axis=(0, 1)) @ \ 173 | matrix.translate(0, -0.9, 0) @ \ 174 | matrix.perspective(999) 175 | f = 750/(800-脸大小) 176 | extra = matrix.translate(x偏移*0.6, -y偏移*0.8, 0) @ \ 177 | matrix.scale(f, f, 1) 178 | return model_p @ extra 179 | 180 | model_g = \ 181 | matrix.scale(2 / self.切取范围[0], 2 / self.切取范围[1], 1) @ \ 182 | matrix.translate(-1, -1, 0) @ \ 183 | matrix.rotate_ax(-math.pi / 2, axis=(0, 1)) 184 | 185 | def draw(图层): 186 | 源 = 图层.顶点组导出() 187 | x, y, _ = 源.shape 188 | 189 | 所有顶点 = 源.reshape(x*y, 8) 190 | 191 | a, b = 所有顶点[:, :4], 所有顶点[:, 4:] 192 | a = a @ model_g 193 | z = a[:, 2:3] 194 | z -= 0.1 195 | a[:, :2] *= z 196 | 眼睛左右 = 横旋转量*4 + 没有状态但是却能均匀变化的随机数((-0.2, 0.2), 速度=1.6) 197 | 眼睛上下 = 竖旋转量*7 + 没有状态但是却能均匀变化的随机数((-0.1, 0.1), 速度=2) 198 | 闭眼强度 = 锚击(左眼大小+右眼大小, -0.001, -0.008) 199 | 眉上度 = 锚击(左眉高+右眉高, -0.03, 0.01) - 闭眼强度*0.1 200 | 闭嘴强度 = 锚击(嘴大小, 0.05, 0) * 1.1 - 0.1 201 | a, b = self.多重附加变形([ 202 | ['永远', 1], 203 | ['眉上', 眉上度], 204 | ['左眼远离', 眼睛左右], 205 | ['右眼远离', -眼睛左右], 206 | ['左眼上', 眼睛上下], 207 | ['右眼上', 眼睛上下], 208 | ['左眼闭', 闭眼强度], 209 | ['右眼闭', 闭眼强度], 210 | ['闭嘴', 闭嘴强度], 211 | ], 图层.名字, a, b) 212 | 213 | xz = 横旋转量 / 1.3 214 | zy = 竖旋转量 / 1.5 215 | xy = Z旋转量 / 5 216 | if not 图层.名字.startswith('头/'): 217 | xz /= 8 218 | zy = 0 219 | 220 | a = a @ model(xz, zy, xy, 脸大小, x偏移, y偏移) 221 | 222 | b *= z 223 | 224 | 所有顶点[:, :4], 所有顶点[:, 4:] = a, b 225 | 所有顶点 = 所有顶点.reshape([x, y, 8]) 226 | glBegin(GL_QUADS) 227 | for i in range(x-1): 228 | for j in range(y-1): 229 | for p in [所有顶点[i, j], 所有顶点[i, j+1], 所有顶点[i+1, j+1], 所有顶点[i+1, j]]: 230 | glTexCoord4f(*p[4:]) 231 | glVertex4f(*p[:4]) 232 | glEnd() 233 | 234 | while not glfw.window_should_close(window): 235 | with 计时.帧率计('绘图'): 236 | glfw.poll_events() 237 | glClearColor(0, 0, 0, 0) 238 | glClear(GL_COLOR_BUFFER_BIT) 239 | 横旋转量, 竖旋转量, Z旋转量, y偏移, x偏移, 嘴大小, 脸大小, 左眼大小, 右眼大小, 左眉高, 右眉高 = 数据源() 240 | for 图层 in self.所有图层: 241 | glEnable(GL_TEXTURE_2D) 242 | glBindTexture(GL_TEXTURE_2D, 图层.纹理编号) 243 | glColor4f(1, 1, 1, 1) 244 | glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 245 | draw(图层) 246 | if line_box: 247 | glDisable(GL_TEXTURE_2D) 248 | glColor4f(0.3, 0.3, 1, 0.2) 249 | glPolygonMode(GL_FRONT_AND_BACK, GL_LINE) 250 | draw(图层) 251 | glfw.swap_buffers(window) 252 | if self.启用截图: 253 | glReadBuffer(GL_FRONT) 254 | self.截图 = glReadPixels(0, 0, *Vtuber尺寸, GL_RGBA, GL_UNSIGNED_BYTE) 255 | 256 | 257 | 缓冲特征 = None 258 | 259 | 260 | def 特征缓冲(缓冲比例=0.8): 261 | global 缓冲特征 262 | 新特征 = 现实.获取特征组() 263 | if 缓冲特征 is None: 264 | 缓冲特征 = 新特征 265 | else: 266 | 缓冲特征 = 缓冲特征 * 缓冲比例 + 新特征 * (1 - 缓冲比例) 267 | return 缓冲特征 268 | 269 | 270 | def init_window(): 271 | def 超融合(): 272 | glfw.window_hint(glfw.DECORATED, False) 273 | glfw.window_hint(glfw.TRANSPARENT_FRAMEBUFFER, True) 274 | glfw.window_hint(glfw.FLOATING, True) 275 | glfw.init() 276 | 超融合() 277 | glfw.window_hint(glfw.SAMPLES, 4) 278 | # glfw.window_hint(glfw.RESIZABLE, False) 279 | window = glfw.create_window(*Vtuber尺寸, 'Vtuber', None, None) 280 | glfw.make_context_current(window) 281 | monitor_size = glfw.get_video_mode(glfw.get_primary_monitor()).size 282 | glfw.set_window_pos(window, monitor_size.width - Vtuber尺寸[0], monitor_size.height - Vtuber尺寸[1]) 283 | glViewport(0, 0, *Vtuber尺寸) 284 | glEnable(GL_TEXTURE_2D) 285 | glEnable(GL_BLEND) 286 | glEnable(GL_MULTISAMPLE) 287 | glEnable(GL_CULL_FACE) 288 | glCullFace(GL_FRONT) 289 | glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA) 290 | return window 291 | 292 | 293 | if __name__ == '__main__': 294 | 现实.启动() 295 | window = init_window() 296 | 297 | 莉沫酱 = vtuber('../res/莉沫酱较简单版.psd') 298 | 莉沫酱.opengl绘图循环(window, 数据源=特征缓冲) 299 | -------------------------------------------------------------------------------- /7/信息.yaml: -------------------------------------------------------------------------------- 1 | 头/五官/鼻子: 2 | 深度: 3 | - [0.58, 0.58] 4 | - [0.56, 0.56] 5 | 6 | 头/五官/嘴/上: 7 | 深度: 8 | '[0.63]*9, [0.64]*9' 9 | 头/五官/嘴/下: 10 | 深度: 11 | '[0.64]*9, [0.65]*9' 12 | 头/五官/嘴/舌: 13 | 深度: 14 | '[0.63]*9, [0.65]*9' 15 | 16 | _眼球深度: &a 17 | 深度: 18 | - [0.65, 0.65] 19 | - [0.67, 0.67] 20 | 21 | 头/五官/眼球/左: *a 22 | 头/五官/眼球/右: *a 23 | 头/五官/眼球/高光左: *a 24 | 头/五官/眼球/高光右: *a 25 | 头/五官/眼白/左: *a 26 | 头/五官/眼白/右: *a 27 | 28 | 头/五官/眼皮/左: &aa 29 | 深度: 30 | '[0.65]*4, [0.67]*4' 31 | 头/五官/眼皮/右: *aa 32 | 33 | _睫毛深度: &b 34 | 深度: 35 | 0.65 36 | 头/五官/睫毛/左/主: 37 | 深度: 38 | '[0.65]*15, [0.65]*15' 39 | 头/五官/睫毛/左/尾: *b 40 | 头/五官/睫毛/左/眼睑: *b 41 | 头/五官/睫毛/左/下: 42 | 深度: 0.67 43 | 头/五官/睫毛/右/主: 44 | 深度: 45 | '[0.65]*15, [0.65]*15' 46 | 头/五官/睫毛/右/尾: *b 47 | 头/五官/睫毛/右/眼睑: *b 48 | 头/五官/睫毛/右/下: 49 | 深度: 0.67 50 | 51 | 头/五官/眉毛/左: 52 | 深度: 53 | '[0.65]*4, [0.65]*4' 54 | 头/五官/眉毛/右: 55 | 深度: 56 | '[0.65]*4, [0.65]*4' 57 | 58 | 头/耳朵/左: 59 | 深度: 60 | - [0.7, 0.85] 61 | - [0.7, 0.85] 62 | 63 | 头/耳朵/右: 64 | 深度: 65 | - [0.85, 0.7] 66 | - [0.85, 0.7] 67 | 68 | 头/脸/基: 69 | 深度: 70 | - [0.66, 0.60, 0.66] 71 | - [0.72, 0.66, 0.72] 72 | 头/阴影: 73 | 深度: 74 | - [0.66, 0.60, 0.66] 75 | - [0.69, 0.63, 0.69] 76 | 物理: 77 | - [0.99, 0.99, 0.99] 78 | - [0.94, 0.94, 0.94] 79 | 头/脸/红晕: 80 | 深度: 81 | 0.7 82 | 83 | 头/后发/尾: 84 | 深度: 85 | 0.8 86 | 物理: 87 | - [1, 1] 88 | - [0.85, 0.85] 89 | 头/后发/下侧: 90 | 深度: 91 | 0.8 92 | 头/后发/主: 93 | 深度: 94 | - [1.543,1.322,1.207,1.162,1.152,1.151,1.152,1.162,1.207,1.322,1.543] 95 | - [1.322,1.068,0.923,0.862,0.847,0.846,0.847,0.862,0.923,1.068,1.322] 96 | - [1.207,0.923,0.741,0.651,0.627,0.625,0.627,0.651,0.741,0.923,1.207] 97 | - [1.162,0.862,0.651,0.524,0.500,0.500,0.500,0.524,0.651,0.862,1.162] 98 | - [1.152,0.847,0.627,0.500,0.500,0.500,0.500,0.500,0.627,0.847,1.152] 99 | - [1.151,0.846,0.625,0.500,0.500,0.500,0.500,0.500,0.625,0.846,1.151] 100 | - [1.152,0.847,0.627,0.500,0.500,0.500,0.500,0.500,0.627,0.847,1.152] 101 | - [1.162,0.862,0.651,0.524,0.500,0.500,0.500,0.524,0.651,0.862,1.162] 102 | - [1.207,0.923,0.741,0.651,0.627,0.625,0.627,0.651,0.741,0.923,1.207] 103 | - [1.322,1.068,0.923,0.862,0.847,0.846,0.847,0.862,0.923,1.068,1.322] 104 | - [1.543,1.322,1.207,1.162,1.152,1.151,1.152,1.162,1.207,1.322,1.543] 105 | 106 | 头/前发/左: 107 | 深度: 108 | - [0.71, 0.75] 109 | - [0.62, 0.66] 110 | - [0.65, 0.69] 111 | - [0.69, 0.73] 112 | 物理: 113 | - [1, 1] 114 | - [0.99, 0.99] 115 | - [0.94, 0.94] 116 | - [0.89, 0.89] 117 | 头/前发/右: 118 | 深度: 119 | - [0.75, 0.71] 120 | - [0.66, 0.62] 121 | - [0.69, 0.65] 122 | - [0.73, 0.69] 123 | 物理: 124 | - [1, 1] 125 | - [0.99, 0.99] 126 | - [0.94, 0.94] 127 | - [0.89, 0.89] 128 | 头/前发/右右: 129 | 深度: 130 | - [0.8, 0.7] 131 | - [0.8, 0.7] 132 | 物理: 133 | - [1, 1] 134 | - [0.87, 0.87] 135 | 头/前发/中: 136 | 深度: 137 | - [0.78, 0.74, 0.78] 138 | - [0.64, 0.60, 0.64] 139 | - [0.66, 0.62, 0.66] 140 | 物理: 141 | - [1, 1, 1] 142 | - [0.995, 0.99, 0.995] 143 | - [0.97, 0.96, 0.97] 144 | 145 | 头/侧马尾/主: 146 | 深度: 147 | - [0.8, 0.8] 148 | - [0.65, 0.65] 149 | - [0.7, 0.7] 150 | 物理: 151 | - [1, 1] 152 | - [0.91, 0.91] 153 | - [0.84, 0.84] 154 | 头/侧马尾/花: 155 | 深度: 156 | - [0.76, 0.88] 157 | - [0.76, 0.88] 158 | 头/侧马尾/绳子a: 159 | 深度: 160 | - [0.8, 0.85] 161 | - [0.8, 0.85] 162 | 头/侧马尾/绳子b: 163 | 深度: 164 | 0.81 165 | 166 | 167 | 身体: 168 | 深度: 169 | 0.9 170 | 脖子: 171 | 深度: 172 | 0.9 173 | 174 | 项链/项链1: 175 | 深度: 176 | 0.9 177 | 项链/项链2: 178 | 深度: 179 | 0.9 180 | 项链/阴影: 181 | 深度: 182 | 0.9 183 | 184 | 腰部蝴蝶结/花: 185 | 深度: 186 | 0.75 187 | 腰部蝴蝶结/绳子: 188 | 深度: 189 | 0.75 190 | 腰部蝴蝶结/ribbon: 191 | 深度: 192 | 0.8 193 | 腰部蝴蝶结/ribbon2: 194 | 深度: 195 | 0.8 196 | 腰部蝴蝶结/锁链: 197 | 深度: 198 | - [0.8, 0.8] 199 | - [0.75, 0.75] 200 | 201 | 上臂/左: 202 | 深度: 203 | - [0.9, 0.9] 204 | - [0.8, 0.8] 205 | 上臂/右: 206 | 深度: 207 | - [0.9, 0.9] 208 | - [0.8, 0.8] 209 | 小臂/绳结左: 210 | 深度: 211 | 0.7 212 | 小臂/绳结右: 213 | 深度: 214 | 0.7 215 | 小臂/袖子左: 216 | 深度: 217 | - [0.7, 0.8] 218 | - [0.7, 0.8] 219 | 小臂/袖子右: 220 | 深度: 221 | - [0.8, 0.7] 222 | - [0.8, 0.7] 223 | 小臂/左手: 224 | 深度: 225 | 0.7 226 | 小臂/右手: 227 | 深度: 228 | 0.7 -------------------------------------------------------------------------------- /7/变形.yaml: -------------------------------------------------------------------------------- 1 | 永远: 2 | 头/五官/眼皮/左: 3 | 位置: 4 | - [[0, 0.00], [0, 0.01], [0, 0.01], [0, 0.00]] 5 | - [[0, 0.02], [0, 0.06], [0, 0.06], [0, 0.02]] 6 | 头/五官/眼皮/右: 7 | 位置: 8 | - [[0, 0.00], [0, 0.01], [0, 0.01], [0, 0.00]] 9 | - [[0, 0.02], [0, 0.06], [0, 0.06], [0, 0.02]] 10 | 头/五官/眼球/左: &a0 11 | 位置: 12 | - [[0.0072, 0], [0.0072, 0]] 13 | - [[0.0072, 0], [0.0072, 0]] 14 | 头/五官/眼球/高光左: *a0 15 | 头/五官/眼球/右: &b0 16 | 位置: 17 | - [[-0.0072, 0], [-0.0072, 0]] 18 | - [[-0.0072, 0], [-0.0072, 0]] 19 | 头/五官/眼球/高光右: *b0 20 | 头/五官/眉毛/左: 21 | 位置: 22 | - [[0, -0.015], [0, -0.015], [0, -0.015], [0, -0.015]] 23 | - [[0, -0.015], [0, -0.015], [0, -0.015], [0, -0.015]] 24 | 头/五官/眉毛/右: 25 | 位置: 26 | - [[0, -0.015], [0, -0.015], [0, -0.015], [0, -0.015]] 27 | - [[0, -0.015], [0, -0.015], [0, -0.015], [0, -0.015]] 28 | 29 | 讽刺: 30 | 头/五官/眉毛/左: 31 | 位置: 32 | - [[0, 0.01], [0, -0.01], [0, -0.02], [0, -0.02]] 33 | - [[0, 0.01], [0, -0.01], [0, -0.02], [0, -0.02]] 34 | 头/五官/眉毛/右: 35 | 位置: 36 | - [[0, -0.02], [0, -0.02], [0, -0.01], [0, 0.01]] 37 | - [[0, -0.02], [0, -0.02], [0, -0.01], [0, 0.01]] 38 | 39 | 眉上: 40 | 头/五官/眉毛/左: 41 | 位置: 42 | - [[0, 0.02], [0, 0.02], [0, 0.02], [0, 0.02]] 43 | - [[0, 0.02], [0, 0.02], [0, 0.02], [0, 0.02]] 44 | 头/五官/眉毛/右: 45 | 位置: 46 | - [[0, 0.02], [0, 0.02], [0, 0.02], [0, 0.02]] 47 | - [[0, 0.02], [0, 0.02], [0, 0.02], [0, 0.02]] 48 | 49 | 闭嘴: 50 | 头/五官/嘴/下: 51 | 位置: 52 | '[[-0.001, -abs(i-4)**2/1280] for i in range(9)], [[-(i-4)/100, -abs(i-4)**2/1280 + 0.02] for i in range(9)]' 53 | 头/五官/嘴/上: 54 | 位置: 55 | '[[0, abs(i-4)**2/960-0.033] for i in range(9)], [[0, abs(i-4)**2/960-0.027] for i in range(9)]' 56 | 头/五官/嘴/舌: 57 | 位置: 58 | '[[0, abs(i-4)**2/1440-0.042] for i in range(9)], [[0, abs(i-4)**2/1440+0.016] for i in range(9)]' 59 | 60 | 左眼远离: 61 | 头/五官/眼球/左: &a 62 | 位置: 63 | - [[0.0168, 0], [0.0168, 0]] 64 | - [[0.0168, 0], [0.0168, 0]] 65 | 头/五官/眼球/高光左: *a 66 | 右眼远离: 67 | 头/五官/眼球/右: &b 68 | 位置: 69 | - [[-0.0168, 0], [-0.0168, 0]] 70 | - [[-0.0168, 0], [-0.0168, 0]] 71 | 头/五官/眼球/高光右: *b 72 | 左眼上: 73 | 头/五官/眼球/左: &as 74 | 位置: 75 | - [[0, 0.004], [0, 0.004]] 76 | - [[0, 0.004], [0, 0.004]] 77 | 头/五官/眼球/高光左: *as 78 | 右眼上: 79 | 头/五官/眼球/右: *as 80 | 头/五官/眼球/高光右: *as 81 | 82 | 左眼闭: 83 | 头/五官/睫毛/左/主: 84 | 位置: 85 | '[[0, 0.85*((i-7)**2/4900*6-0.06) - (13-i)*0.0015] for i in range(15)], [[0, 0.85*((i-7)**2/4900*6-0.06) + 0.007] for i in range(15)]' 86 | 头/五官/眼白/左: &c 87 | 位置: 88 | - [[0, -0.01], [0, -0.01]] 89 | - [[0, 0.021], [0, 0.021]] 90 | 头/五官/眼球/左: *c 91 | 头/五官/眼球/高光左: *c 92 | 头/五官/眼皮/左: 93 | 位置: 94 | - [[0, 0.00], [0, -0.01], [0, -0.01], [0, 0.00]] 95 | - [[0, -0.02], [0, -0.06], [0, -0.06], [0, -0.02]] 96 | 头/五官/睫毛/左/下: 97 | 位置: 98 | - [[0, 0.021], [0, 0.021]] 99 | - [[0, 0.021], [0, 0.021]] 100 | 101 | 右眼闭: 102 | 头/五官/睫毛/右/主: 103 | 位置: 104 | '[[0, 0.85*((i-7)**2/4900*6-0.06) - (13-i)*0.0015] for i in range(15)][::-1], [[0, 0.85*((i-7)**2/4900*6-0.06) + 0.007] for i in range(15)][::-1]' 105 | 头/五官/眼白/右: &d 106 | 位置: 107 | - [[0, -0.01], [0, -0.01]] 108 | - [[0, 0.021], [0, 0.021]] 109 | 头/五官/眼球/右: *d 110 | 头/五官/眼球/高光右: *d 111 | 头/五官/眼皮/右: 112 | 位置: 113 | - [[0, 0.00], [0, -0.01], [0, -0.01], [0, 0.00]] 114 | - [[0, -0.02], [0, -0.06], [0, -0.06], [0, -0.02]] 115 | 头/五官/睫毛/右/下: 116 | 位置: 117 | - [[0, 0.021], [0, 0.021]] 118 | - [[0, 0.021], [0, 0.021]] -------------------------------------------------------------------------------- /7/现实.py: -------------------------------------------------------------------------------- 1 | import time 2 | import logging 3 | import threading 4 | import multiprocessing 5 | 6 | import cv2 7 | import dlib 8 | import numpy as np 9 | from rimo_utils import 计时 10 | 11 | 12 | detector = dlib.get_frontal_face_detector() 13 | 14 | 15 | def 多边形面积(a): 16 | a = np.array(a) 17 | x = a[:, 0] 18 | y = a[:, 1] 19 | return 0.5*np.abs(np.dot(x, np.roll(y, 1))-np.dot(y, np.roll(x, 1))) 20 | 21 | 22 | def 人脸定位(img): 23 | dets = detector(img, 0) 24 | if not dets: 25 | return None 26 | return max(dets, key=lambda det: (det.right() - det.left()) * (det.bottom() - det.top())) 27 | 28 | 29 | predictor = dlib.shape_predictor('../res/shape_predictor_68_face_landmarks.dat') 30 | 31 | 32 | def 提取关键点(img, 脸位置): 33 | landmark_shape = predictor(img, 脸位置) 34 | 关键点 = [] 35 | for i in range(68): 36 | pos = landmark_shape.part(i) 37 | 关键点.append(np.array([pos.x, pos.y], dtype=np.float32)) 38 | return np.array(关键点) 39 | 40 | 41 | def 计算旋转量(关键点): 42 | def 中心(索引数组): 43 | return sum([关键点[i] for i in 索引数组]) / len(索引数组) 44 | 左眉 = [18, 19, 20, 21] 45 | 右眉 = [22, 23, 24, 25] 46 | 下巴 = [6, 7, 8, 9, 10] 47 | 鼻子 = [29, 30] 48 | 眉中心, 下巴中心, 鼻子中心 = 中心(左眉 + 右眉), 中心(下巴), 中心(鼻子) 49 | 中线 = 眉中心 - 下巴中心 50 | 斜边 = 眉中心 - 鼻子中心 51 | 中线长 = np.linalg.norm(中线) 52 | 横旋转量 = np.cross(中线, 斜边) / 中线长**2 53 | 竖旋转量 = 中线 @ 斜边 / 中线长**2 54 | Z旋转量 = np.cross(中线, [0, 1]) / 中线长 55 | return np.array([横旋转量, 竖旋转量, Z旋转量]) 56 | 57 | 58 | def 计算嘴大小(关键点): 59 | 边缘 = 关键点[0:17] 60 | 嘴边缘 = 关键点[48:60] 61 | 嘴大小 = 多边形面积(嘴边缘) / 多边形面积(边缘) 62 | return np.array([嘴大小]) 63 | 64 | 65 | def 计算相对位置(img, 脸位置): 66 | x = (脸位置.top() + 脸位置.bottom())/2/img.shape[0] 67 | y = (脸位置.left() + 脸位置.right())/2/img.shape[1] 68 | y = 1 - y 69 | 相对位置 = np.array([x, y]) 70 | return 相对位置 71 | 72 | 73 | def 计算脸大小(关键点): 74 | 边缘 = 关键点[0:17] 75 | t = 多边形面积(边缘)**0.5 76 | return np.array([t]) 77 | 78 | 79 | def 计算眼睛大小(关键点): 80 | 边缘 = 关键点[0:17] 81 | 左 = 多边形面积(关键点[36:42]) / 多边形面积(边缘) 82 | 右 = 多边形面积(关键点[42:48]) / 多边形面积(边缘) 83 | return np.array([左, 右]) 84 | 85 | 86 | def 计算眉毛高度(关键点): 87 | 边缘 = 关键点[0:17] 88 | 左 = 多边形面积([*关键点[18:22]]+[关键点[38], 关键点[37]]) / 多边形面积(边缘) 89 | 右 = 多边形面积([*关键点[22:26]]+[关键点[44], 关键点[43]]) / 多边形面积(边缘) 90 | return np.array([左, 右]) 91 | 92 | 93 | def 提取图片特征(img): 94 | 脸位置 = 人脸定位(img) 95 | if not 脸位置: 96 | return None 97 | 相对位置 = 计算相对位置(img, 脸位置) 98 | 关键点 = 提取关键点(img, 脸位置) 99 | 旋转量组 = 计算旋转量(关键点) 100 | 脸大小 = 计算脸大小(关键点) 101 | 眼睛大小 = 计算眼睛大小(关键点) 102 | 嘴大小 = 计算嘴大小(关键点) 103 | 眉毛高度 = 计算眉毛高度(关键点) 104 | 105 | img //= 2 106 | img[脸位置.top():脸位置.bottom(), 脸位置.left():脸位置.right()] *= 2 107 | for i, (px, py) in enumerate(关键点): 108 | cv2.putText(img, str(i), (int(px), int(py)), cv2.FONT_HERSHEY_COMPLEX, 0.25, (255, 255, 255)) 109 | 110 | return np.concatenate([旋转量组, 相对位置, 嘴大小, 脸大小, 眼睛大小, 眉毛高度]) 111 | 112 | 113 | 原点特征组 = 提取图片特征(cv2.imread('../res/std_face.jpg')) 114 | 特征组 = 原点特征组 - 原点特征组 115 | 116 | 117 | def 捕捉循环(pipe): 118 | global 原点特征组 119 | global 特征组 120 | with 计时.计时('打开摄像头'): 121 | cap = cv2.VideoCapture(0) 122 | logging.warning('开始捕捉了!') 123 | while True: 124 | with 计时.帧率计('提特征'): 125 | ret, img = cap.read() 126 | 新特征组 = 提取图片特征(img) 127 | cv2.imshow('', img[:, ::-1]) 128 | cv2.waitKey(1) 129 | if 新特征组 is not None: 130 | 特征组 = 新特征组 - 原点特征组 131 | pipe.send(特征组) 132 | 133 | 134 | def 获取特征组(): 135 | global 特征组 136 | return 特征组 137 | 138 | 139 | def 转移(): 140 | global 特征组 141 | logging.warning('转移线程启动了!') 142 | while True: 143 | 特征组 = pipe[1].recv() 144 | 145 | 146 | pipe = multiprocessing.Pipe() 147 | 148 | 149 | def 启动(): 150 | t = threading.Thread(target=转移) 151 | t.setDaemon(True) 152 | t.start() 153 | logging.warning('捕捉进程启动中……') 154 | p = multiprocessing.Process(target=捕捉循环, args=(pipe[0],)) 155 | p.daemon = True 156 | p.start() 157 | 158 | 159 | if __name__ == '__main__': 160 | 启动() 161 | np.set_printoptions(precision=3, suppress=True) 162 | while True: 163 | time.sleep(0.1) 164 | # print(获取特征组()) 165 | -------------------------------------------------------------------------------- /7/虚境.py: -------------------------------------------------------------------------------- 1 | import time 2 | import math 3 | import random 4 | import logging 5 | import functools 6 | 7 | import numpy as np 8 | import yaml 9 | 10 | import glfw 11 | import OpenGL 12 | from OpenGL.GL import * 13 | from OpenGL.GLU import * 14 | from rimo_utils import matrix 15 | from rimo_utils import 计时 16 | import psd_tools 17 | 18 | import 现实 19 | 20 | 21 | Vtuber尺寸 = 720, 720 22 | 23 | 24 | def 相位转移(x): 25 | if x is None: 26 | return x 27 | if type(x) is str: 28 | return 相位转移(eval(x)) 29 | if type(x) in [int, float]: 30 | return np.array([[x, x], [x, x]]) 31 | else: 32 | return np.array(x) 33 | 34 | 35 | class 图层类: 36 | def __init__(self, 名字, bbox, z, 物理, npdata): 37 | self.名字 = 名字 38 | self.npdata = npdata 39 | self.纹理编号, 纹理座标 = self.生成opengl纹理() 40 | self.变形 = [] 41 | self.物理 = 相位转移(物理) 42 | 深度 = 相位转移(z) 43 | assert len(深度.shape) == 2 44 | self.shape = 深度.shape 45 | 46 | q, w = 纹理座标 47 | a, b, c, d = bbox 48 | [[p1, p2], 49 | [p4, p3]] = np.array([ 50 | [[a, b, 0, 1, 0, 0, 0, 1], [a, d, 0, 1, w, 0, 0, 1]], 51 | [[c, b, 0, 1, 0, q, 0, 1], [c, d, 0, 1, w, q, 0, 1]], 52 | ]) 53 | x, y = self.shape 54 | self.顶点组 = np.zeros(shape=[x, y, 8]) 55 | for i in range(x): 56 | for j in range(y): 57 | self.顶点组[i, j] = p1 + (p4-p1)*i/(x-1) + (p2-p1)*j/(y-1) 58 | self.顶点组[i, j, 2] = 深度[i, j] 59 | 60 | def 生成opengl纹理(self): 61 | w, h = self.npdata.shape[:2] 62 | d = 2**int(max(math.log2(w), math.log2(h)) + 1) 63 | 纹理 = np.zeros([d, d, 4], dtype=self.npdata.dtype) 64 | 纹理[:, :, :3] = 255 65 | 纹理[:w, :h] = self.npdata 66 | 纹理座标 = (w / d, h / d) 67 | 68 | width, height = 纹理.shape[:2] 69 | 纹理编号 = glGenTextures(1) 70 | glBindTexture(GL_TEXTURE_2D, 纹理编号) 71 | glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, width, height, 0, GL_BGRA, GL_FLOAT, 纹理) 72 | glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR) 73 | glGenerateMipmap(GL_TEXTURE_2D) 74 | 75 | return 纹理编号, 纹理座标 76 | 77 | def 顶点组导出(self): 78 | return self.顶点组.copy() 79 | 80 | 81 | class vtuber: 82 | def __init__(self, psd路径, 切取范围=(1024, 1024), 信息路径='信息.yaml', 变形路径='变形.yaml'): 83 | psd = psd_tools.PSDImage.open(psd路径) 84 | with open(信息路径, encoding='utf8') as f: 85 | 信息 = yaml.safe_load(f) 86 | with open(变形路径, encoding='utf8') as f: 87 | self.变形组 = yaml.safe_load(f) 88 | 89 | def 再装填(): 90 | while True: 91 | time.sleep(1) 92 | try: 93 | with open(变形路径, encoding='utf8') as f: 94 | self.变形组 = yaml.safe_load(f) 95 | except Exception as e: 96 | logging.exception(e) 97 | import threading 98 | t = threading.Thread(target=再装填) 99 | t.setDaemon(True) 100 | t.start() 101 | 102 | self.所有图层 = [] 103 | self.psd尺寸 = psd.size 104 | self.切取范围 = 切取范围 105 | 106 | def dfs(图层, path=''): 107 | if 图层.is_group(): 108 | for i in 图层: 109 | dfs(i, path + 图层.name + '/') 110 | else: 111 | 名字 = path+图层.name 112 | if 名字 not in 信息: 113 | logging.warning(f'图层「{名字}」找不到信息,丢了!') 114 | return 115 | a, b, c, d = 图层.bbox 116 | npdata = 图层.numpy() 117 | npdata[:, :, :3] = npdata[:, :, :3][:, :, ::-1] 118 | self.所有图层.append(图层类( 119 | 名字=名字, 120 | z=信息[名字]['深度'], 121 | 物理=信息[名字].get('物理'), 122 | bbox=(b, a, d, c), 123 | npdata=npdata 124 | )) 125 | for 图层 in psd: 126 | dfs(图层) 127 | self.截图 = None 128 | self.启用截图 = False 129 | self._记忆 = {} 130 | 131 | def 获取截图(self, 反转颜色=True): 132 | while True: 133 | self.启用截图 = True 134 | if self.截图: 135 | img = np.frombuffer(self.截图, dtype=np.uint8).reshape((*Vtuber尺寸, 4)).copy() 136 | if 反转颜色: 137 | img[:, :, :3] = img[:, :, :3][:, :, ::-1] 138 | img = img[::-1] 139 | return img 140 | time.sleep(0.01) 141 | 142 | def 附加变形(self, 变形名, 图层名, a, b, f): 143 | 变形 = self.变形组[变形名] 144 | if 图层名 not in 变形: 145 | return a, b 146 | if '位置' in 变形[图层名]: 147 | d = 变形[图层名]['位置'] 148 | if type(d) is str: 149 | d = eval(d) 150 | d = np.array(d) 151 | a[:, :2] += d.reshape(a.shape[0], 2) * f 152 | return a, b 153 | 154 | def 多重附加变形(self, 变形组, 图层名, a, b): 155 | for 变形名, 强度 in 变形组: 156 | a, b = self.附加变形(变形名, 图层名, a, b, 强度) 157 | return a, b 158 | 159 | def 动(self, 图层, t): 160 | if 图层.物理 is None: 161 | return t 162 | res = t 163 | q, 上次时间 = self._记忆.get(id(图层), (None, 0)) 164 | 现在时间 = time.time() 165 | if q is not None: 166 | 时间差 = min(0.1, 现在时间-上次时间) 167 | 物理缩小 = 0.05 168 | w = 图层.物理.reshape(t.shape[0], 1) 169 | w = w * 物理缩小 + 1 * (1-物理缩小) 170 | ww = -((1-w)**时间差)+1 171 | v = t - q 172 | res = q + v * ww 173 | self._记忆[id(图层)] = res, 现在时间 174 | return res 175 | 176 | def opengl绘图循环(self, window, 数据源, line_box=False): 177 | def 没有状态但是却能均匀变化的随机数(范围=(0, 1), 速度=1): 178 | now = time.time()*速度 179 | a, b = int(now), int(now)+1 180 | random.seed(a) 181 | f0 = random.random() 182 | random.seed(b) 183 | f1 = random.random() 184 | f = f0 * (b-now) + f1 * (now-a) 185 | return 范围[0] + (范围[1]-范围[0])*f 186 | 187 | def 锚击(x, a, b): 188 | x = sorted([x, a, b])[1] 189 | return (x-a)/(b-a) 190 | 191 | @functools.lru_cache(maxsize=16) 192 | def model(xz, zy, xy, 脸大小, x偏移, y偏移): 193 | model_p = \ 194 | matrix.translate(0, 0, -0.9) @ \ 195 | matrix.rotate_ax(xz, axis=(0, 2)) @ \ 196 | matrix.rotate_ax(zy, axis=(2, 1)) @ \ 197 | matrix.translate(0, 0.9, 0.9) @ \ 198 | matrix.rotate_ax(xy, axis=(0, 1)) @ \ 199 | matrix.translate(0, -0.9, 0) @ \ 200 | matrix.perspective(999) 201 | f = 750/(800-脸大小) 202 | extra = matrix.translate(x偏移*0.6, -y偏移*0.8, 0) @ \ 203 | matrix.scale(f, f, 1) 204 | return model_p, extra 205 | 206 | model_g = \ 207 | matrix.scale(2 / self.切取范围[0], 2 / self.切取范围[1], 1) @ \ 208 | matrix.translate(-1, -1, 0) @ \ 209 | matrix.rotate_ax(-math.pi / 2, axis=(0, 1)) 210 | 211 | def draw(图层): 212 | 源 = 图层.顶点组导出() 213 | x, y, _ = 源.shape 214 | 215 | 所有顶点 = 源.reshape(x*y, 8) 216 | 217 | a, b = 所有顶点[:, :4], 所有顶点[:, 4:] 218 | a = a @ model_g 219 | z = a[:, 2:3] 220 | z -= 0.1 221 | a[:, :2] *= z 222 | 眼睛左右 = 横旋转量*4 + 没有状态但是却能均匀变化的随机数((-0.2, 0.2), 速度=1.6) 223 | 眼睛上下 = 竖旋转量*7 + 没有状态但是却能均匀变化的随机数((-0.1, 0.1), 速度=2) 224 | 闭眼强度 = 锚击(左眼大小+右眼大小, -0.001, -0.008) 225 | 眉上度 = 锚击(左眉高+右眉高, -0.03, 0.01) - 闭眼强度*0.1 226 | 闭嘴强度 = 锚击(嘴大小, 0.05, 0) * 1.1 - 0.1 227 | a, b = self.多重附加变形([ 228 | ['永远', 1], 229 | ['眉上', 眉上度], 230 | ['左眼远离', 眼睛左右], 231 | ['右眼远离', -眼睛左右], 232 | ['左眼上', 眼睛上下], 233 | ['右眼上', 眼睛上下], 234 | ['左眼闭', 闭眼强度], 235 | ['右眼闭', 闭眼强度], 236 | ['闭嘴', 闭嘴强度], 237 | ], 图层.名字, a, b) 238 | 239 | xz = 横旋转量 / 1.2 240 | zy = 竖旋转量 / 1.4 241 | xy = Z旋转量 / 5 242 | if not 图层.名字.startswith('头/'): 243 | xz /= 8 244 | zy = 0 245 | 246 | model_p, extra = model(xz, zy, xy, 脸大小, x偏移, y偏移) 247 | a = a @ model_p 248 | a = self.动(图层, a) 249 | a = a @ extra 250 | 251 | b *= z 252 | 253 | 所有顶点 = np.concatenate([a, b], axis=1).reshape([x, y, 8]) 254 | 255 | glBegin(GL_QUADS) 256 | for i in range(x-1): 257 | for j in range(y-1): 258 | for p in [所有顶点[i, j], 所有顶点[i, j+1], 所有顶点[i+1, j+1], 所有顶点[i+1, j]]: 259 | glTexCoord4f(*p[4:]) 260 | glVertex4f(*p[:4]) 261 | glEnd() 262 | 263 | while not glfw.window_should_close(window): 264 | with 计时.帧率计('绘图'): 265 | glfw.poll_events() 266 | glClearColor(0, 0, 0, 0) 267 | glClear(GL_COLOR_BUFFER_BIT) 268 | 横旋转量, 竖旋转量, Z旋转量, y偏移, x偏移, 嘴大小, 脸大小, 左眼大小, 右眼大小, 左眉高, 右眉高 = 数据源() 269 | for 图层 in self.所有图层: 270 | glEnable(GL_TEXTURE_2D) 271 | glBindTexture(GL_TEXTURE_2D, 图层.纹理编号) 272 | glColor4f(1, 1, 1, 1) 273 | glPolygonMode(GL_FRONT_AND_BACK, GL_FILL) 274 | draw(图层) 275 | if line_box: 276 | glDisable(GL_TEXTURE_2D) 277 | glColor4f(0.3, 0.3, 1, 0.2) 278 | glPolygonMode(GL_FRONT_AND_BACK, GL_LINE) 279 | draw(图层) 280 | glfw.swap_buffers(window) 281 | if self.启用截图: 282 | glReadBuffer(GL_FRONT) 283 | self.截图 = glReadPixels(0, 0, *Vtuber尺寸, GL_RGBA, GL_UNSIGNED_BYTE) 284 | 285 | 286 | 缓冲特征 = None 287 | 288 | 289 | def 特征缓冲(缓冲比例=0.8): 290 | global 缓冲特征 291 | 新特征 = 现实.获取特征组() 292 | if 缓冲特征 is None: 293 | 缓冲特征 = 新特征 294 | else: 295 | 缓冲特征 = 缓冲特征 * 缓冲比例 + 新特征 * (1 - 缓冲比例) 296 | return 缓冲特征 297 | 298 | 299 | def init_window(): 300 | def 超融合(): 301 | glfw.window_hint(glfw.DECORATED, False) 302 | glfw.window_hint(glfw.TRANSPARENT_FRAMEBUFFER, True) 303 | glfw.window_hint(glfw.FLOATING, True) 304 | glfw.init() 305 | 超融合() 306 | glfw.window_hint(glfw.SAMPLES, 4) 307 | # glfw.window_hint(glfw.RESIZABLE, False) 308 | window = glfw.create_window(*Vtuber尺寸, 'Vtuber', None, None) 309 | glfw.make_context_current(window) 310 | monitor_size = glfw.get_video_mode(glfw.get_primary_monitor()).size 311 | glfw.set_window_pos(window, monitor_size.width - Vtuber尺寸[0], monitor_size.height - Vtuber尺寸[1]) 312 | glViewport(0, 0, *Vtuber尺寸) 313 | glEnable(GL_TEXTURE_2D) 314 | glEnable(GL_BLEND) 315 | glEnable(GL_MULTISAMPLE) 316 | glEnable(GL_CULL_FACE) 317 | glCullFace(GL_FRONT) 318 | glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ONE, GL_ONE_MINUS_SRC_ALPHA) 319 | return window 320 | 321 | 322 | if __name__ == '__main__': 323 | 现实.启动() 324 | window = init_window() 325 | 326 | 莉沫酱 = vtuber('../res/莉沫酱较简单版.psd') 327 | import sys 328 | sys.path.append('..') 329 | from utils import 虚拟摄像头 330 | 虚拟摄像头.start(莉沫酱, (1280, 720)) 331 | 莉沫酱.opengl绘图循环(window, 数据源=特征缓冲) 332 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 绝对遵守☆强制子作许可证 0x0 版本 2 | 3 | 版权所有 (C) 莉沫酱 4 | 5 | 0. 你看到这行字就要乖乖给我怀孕啦! 6 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # 从零开始的自制Vtuber: 1.面部捕捉初级 2 | 3 | 这是一个自制Vtuber的教程,这个教程包含从面部捕捉到图形渲染的一条龙服务。 4 | 5 | 这个教程是迭代式的,因此每当你看完一节时都可以做出一个Vtuber,它会随着你的学习而越发强大。 6 | 7 | 也许你能从中学到很多知识,但请不要跟着这个教程做,因为我有一些操作过于神奇以至于成了反面教材。 8 | 9 | 在第一节里,我们将会简单地体验一下面部捕捉技术,然后用一些数学计算追踪头部的角度,最后用一个简单的角色来展示今天的成果。 10 | 11 | ## 目录 12 | 13 | 1. 面部捕捉初级 (就是这个文件所以没有链接) 14 | 2. [画图和绘图](2.md) 15 | 3. [进入虚空](3.md) (从这里开始命名开始放飞自我了) 16 | 4. [合成进化](4.md) 17 | 5. [一时休战](5.md) 18 | 6. [与神之假身的接触](6.md) (现在还是草稿) 19 | 7. [还没写!](7.md) (你以为有链接所以这真是个章节但实际上不是) 20 | 8. [真的还没写!](8.md) (没想到还没写居然会多出一个吧!) 21 | 22 | ## 警告 23 | 24 | 这个章节还没有完成校订,因此可能有和谐内容。 25 | 26 | 请您收好鸡儿,文明观球。 27 | 28 | ## 准备 29 | 30 | 在这个章节,你需要准备: 31 | 32 | + 电脑 33 | + 摄像头 34 | + 基本的图形图像概念 35 | + 高中的计算几何知识 36 | + Python3 37 | + Dlib 38 | + OpenCV 39 | + NumPy 40 | 41 | 明明是从零开始却用了好多库! 42 | 43 | 事情是这样的,根据皮亚诺公理,凡是你认为是0的东西就是0。 44 | 45 | ## 读取视频流 46 | 47 | 首先我们使用OpenCV从摄像头读取视频流,并尝试着把它播放在窗口上。 48 | 49 | ```python 50 | cap = cv2.VideoCapture(0) 51 | while True: 52 | ret, img = cap.read() 53 | img = cv2.flip(img, 1) 54 | cv2.imshow('self', img) 55 | cv2.waitKey(1) 56 | ``` 57 | 58 | > OpenCV 4.4.0.40 会出现 [Segment fault when call imshow](https://github.com/opencv/opencv/issues/18079) 的错误,最好使用其他版本。 59 | 60 | `cv2.VideoCapture(0)`表示默认摄像头,如果用外置摄像头可能得把它改成1。 61 | 62 | `cv2.flip`的作用是把帧左右翻转,使你看起来像在照镜子一样。 63 | 64 | ![./图/1-1.jpg](./图/1-1.jpg) 65 | 66 | 完成之后,你应当会获得一个这样的窗口。 67 | 如果你的窗口里的人和长得和上面的不一样,这是正常的。 68 | 69 | ## 提取面部关键点 70 | 71 | 接下来我们将使用Dlib提取面部关键点。因为Dlib挺好用的,所以我们暂时不用去想它是怎么做到的。 72 | 73 | 在这之前,我们得先确保`img`中至少有一张人脸,并找到最大的那张脸。 74 | 75 | 之所以要这么做并不是为了防止萝莉在旁边捣乱(她的脸比较小),而是防止有时把环境里某些看起来像人头的小物件识别成脸而引起面部捕捉的错误。 76 | 77 | ```python 78 | detector = dlib.get_frontal_face_detector() 79 | def 人脸定位(img): 80 | dets = detector(img, 0) 81 | if not dets: 82 | return None 83 | return max(dets, key=lambda det: (det.right() - det.left()) * (det.bottom() - det.top())) 84 | ``` 85 | 86 | (代码逐渐缺德2333) 87 | 88 | 接下来,我们就可以对刚才找到的脸位置提取关键点了。为了让接下来的向量运算方便一些,你可以考虑把他们通通转换成`np.array`。 89 | 90 | 如果你没有`shape_predictor_68_face_landmarks.dat`,可以去 [GitHub](https://github.com/AKSHAYUBHAT/TensorFace/blob/master/openface/models/dlib/shape_predictor_68_face_landmarks.dat) 或 [dlib](http://dlib.net/files/shape_predictor_68_face_landmarks.dat.bz2) 下载。 91 | 92 | ```python 93 | predictor = dlib.shape_predictor('shape_predictor_68_face_landmarks.dat') 94 | def 提取关键点(img, 脸位置): 95 | landmark_shape = predictor(img, 脸位置) 96 | 关键点 = [] 97 | for i in range(68): 98 | pos = landmark_shape.part(i) 99 | 关键点.append(np.array([pos.x, pos.y], dtype=np.float32)) 100 | return 关键点 101 | ``` 102 | 103 | 接下来试着把这些关键点在你的摄像头画面上绘制出来—— 104 | 105 | ```python 106 | for i, (px, py) in enumerate(关键点): 107 | cv2.putText(img, str(i), (int(px), int(py)), cv2.FONT_HERSHEY_COMPLEX, 0.25, (255, 255, 255)) 108 | ``` 109 | 110 | ![./图/1-2.jpg](./图/1-2.jpg) 111 | 112 | 如果一切正确的话,你的窗口将会像上面这样,有种戴上了奇怪的面具的感觉。 113 | 114 | ## 计算面部特征 115 | 116 | 第一天我们先来试着提取一点简单的特征吧,就做「头的左右旋转」好了。 117 | 118 | 这个原理非常简单以至于我们可以用手来实现它。 119 | 我们知道,越远离旋转轴的点,受到旋转的影响就越大,也就是说,我把头向左转的时候,尽管我的眉毛和鼻子都向左移动了,但是鼻子一定在比眉毛还左的左边。 120 | 121 | 接下来我们根据关键点计算一下它们的座标,就可以计算出旋转角度了。 122 | 123 | ```python 124 | def 生成构造点(关键点): 125 | def 中心(索引数组): 126 | return sum([关键点[i] for i in 索引数组]) / len(索引数组) 127 | 左眉 = [18, 19, 20, 21] 128 | 右眉 = [22, 23, 24, 25] 129 | 下巴 = [6, 7, 8, 9, 10] 130 | 鼻子 = [29, 30] 131 | return 中心(左眉 + 右眉), 中心(下巴), 中心(鼻子) 132 | ``` 133 | 134 | 你可以模仿上面画关键点的代码在图上把它们画出来,它们应该长这样—— 135 | 136 | ![./图/1-3.jpg](./图/1-3.jpg) 137 | 138 | 可以看出`眉中心` `下巴中心` `鼻子中心`组成了一个三角形,我们希望能从这个三角形里提取出一些有用的特征。而且因为各种不同的摄像头的画面大小都不太一样,最好这些特征都具有缩放不变性。 139 | 140 | 这个三角形的形状是和脸部的角度有关的。三角形越是瘦长,脸部就越是中正。因此你只要用一点向量知识来计算它的胖瘦就可以得到脸部的旋转量。 141 | 142 | 任意两条边做叉乘就能得到三角形的有向面积,除以底边长就能得到对应底边上的有向高,再除一次底边得到的就是高与底的比值了。 143 | 144 | ```python 145 | def 生成特征(构造点): 146 | 眉中心, 下巴中心, 鼻子中心 = 构造点 147 | 中线 = 眉中心 - 下巴中心 148 | 斜边 = 眉中心 - 鼻子中心 149 | 横旋转量 = np.cross(中线, 斜边) / np.linalg.norm(中线)**2 150 | return 横旋转量 151 | ``` 152 | 153 | 我没有用到三角函数,这是因为我们的检测器没法检测太大的角度,而sin(x)在原点附近的导数几乎是1,所以这样就够了。 154 | 155 | 把旋转量画在鼻子上,它看起来是这样—— 156 | 157 | ![./图/1-4-1.jpg](./图/1-4-1.jpg) 158 | ![./图/1-4-2.jpg](./图/1-4-2.jpg) 159 | 160 | 如果你觉得只有这样的左右旋转看起来有点无聊,我们也可以再来添加一个检测一个上下旋转的特征。 161 | 162 | 它的数学表达式和刚才的横旋转量很接近,你们可能已经猜出来了,这个特征只需要求斜边在底边上的投影长度与底边长的比值—— 163 | 164 | ``` 165 | 横旋转量 = np.cross(中线, 斜边) / np.linalg.norm(中线)**2 166 | 竖旋转量 = 中线 @ 斜边 / np.linalg.norm(中线)**2 167 | ``` 168 | 169 | 如果你用起来的效果很差,说明你的鼻子太扁了,你可以考虑整容来解决这个问题。 170 | 171 | ## 添加标准脸 172 | 173 | 你肯定发现了这样一个问题,我们刚才检测了`横旋转量`和`竖旋转量`,可是因为普通人的脸是左右对称的,不是上下对称的,所以正对镜头时的`竖旋转量`并不是0。 174 | 175 | 为了解决这个问题,我们可以给竖旋转量减去固定一个数值,不过更好的方法是添加一个标准脸图片。 176 | 你只要正对镜头的时候拍个照,把它的特征保存下来作为一个相对的原点,之后把每次新检测到的特征减去原点就行了。 177 | 178 | ```Python 179 | 原点特征组 = 提取图片特征(cv2.imread('std_face.jpg')) 180 | while True: 181 | ret, img = cap.read() 182 | 新特征组 = 提取图片特征(img) 183 | if 新特征组 is not None: 184 | 特征组 = 新特征组 - 原点特征组 185 | ``` 186 | 187 | ## 绘图 188 | 189 | 现在我们马上就要做出第一个会动的Vtuber了,因为画画很麻烦所以先用圆圈凑合一下。 190 | 191 | 只要画上几个圈圈,让它们的座标取决于脸部的旋转量,这张圆圈脸看起来就好像跟着人脸在旋转一样—— 192 | 193 | ```python 194 | def 画图(横旋转量, 竖旋转量): 195 | img = np.ones([512, 512], dtype=np.float32) 196 | 脸长 = 200 197 | 中心 = 256, 256 198 | 左眼 = int(220 + 横旋转量 * 脸长), int(249 + 竖旋转量 * 脸长) 199 | 右眼 = int(292 + 横旋转量 * 脸长), int(249 + 竖旋转量 * 脸长) 200 | 嘴 = int(256 + 横旋转量 * 脸长 / 2), int(310 + 竖旋转量 * 脸长 / 2) 201 | cv2.circle(img, 中心, 100, 0, 1) 202 | cv2.circle(img, 左眼, 15, 0, 1) 203 | cv2.circle(img, 右眼, 15, 0, 1) 204 | cv2.circle(img, 嘴, 5, 0, 1) 205 | return img 206 | ``` 207 | 208 | ![./图/1-5.jpg](./图/1-5.jpg) 209 | 210 | 这一节就到此为止了……这东西真的能叫Vtuber吗(笑)。 211 | 212 | 这样吧,下一节我们先来画一张立绘。 213 | 只要让真正的立绘动起来,就能做出一个可爱的Vtuber。 214 | 215 | ## 结束 216 | 217 | 如果我的某些操作让你非常迷惑,你也可以去这个项目的GitHub仓库查看源代码。 218 | 219 | 最后祝各位鸡儿放假。 220 | 221 | 下一节: 222 | + [从零开始的自制Vtuber: 2.画图和绘图](2.md) 223 | -------------------------------------------------------------------------------- /res/std_face.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/res/std_face.jpg -------------------------------------------------------------------------------- /res/莉沫酱较简单版.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/res/莉沫酱较简单版.psd -------------------------------------------------------------------------------- /res/莉沫酱过于简单版.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/res/莉沫酱过于简单版.psd -------------------------------------------------------------------------------- /utils/截图.py: -------------------------------------------------------------------------------- 1 | import time 2 | import os 3 | 4 | import numpy as np 5 | import cv2 6 | import imageio 7 | from OpenGL.GL import * 8 | from OpenGL.GLU import * 9 | 10 | 11 | def opengl截图(size, 反转颜色=True): 12 | glReadBuffer(GL_FRONT) 13 | h, w = size 14 | data = glReadPixels(0, 0, h, w, GL_RGBA, GL_UNSIGNED_BYTE) 15 | img = np.frombuffer(data, dtype=np.uint8).reshape((h, w, 4)).copy() 16 | if 反转颜色: 17 | img[:, :, :3] = img[:, :, :3][:, :, ::-1] 18 | img = img[::-1, :, :] 19 | return img 20 | 21 | 22 | def opengl截图一闪(size): 23 | cv2.imwrite('x.jpg', opengl截图(size)) 24 | exit() 25 | 26 | 开始时间 = None 27 | 图组 = [] 28 | def opengl连续截图(size, 时间, 缓冲=0): 29 | global 开始时间 30 | global 图组 31 | now = time.time() 32 | if 开始时间 is None: 33 | 开始时间 = now 34 | if now-开始时间 > 时间+缓冲: 35 | 图组 = [cv2.cvtColor(图, cv2.COLOR_BGR2RGB) for 图 in 图组] 36 | 图组 = 图组[::5] 37 | print(f'fps: {len(图组)/时间}') 38 | imageio.mimsave("test.gif", 图组, fps=len(图组)/时间) 39 | os.system('gif2webp test.gif -lossy -min_size -m 6 -mt -o test.webp') 40 | exit() 41 | if now-开始时间>缓冲: 42 | 图组.append(opengl截图(size)) 43 | -------------------------------------------------------------------------------- /utils/虚拟摄像头.py: -------------------------------------------------------------------------------- 1 | import time 2 | import threading 3 | 4 | import numpy as np 5 | import pyvirtualcam 6 | 7 | 8 | def start(vtuber, size): 9 | r, c = size 10 | def q(): 11 | with pyvirtualcam.Camera(width=r, height=c, fps=30) as cam: 12 | base = np.zeros(shape=(c, r, 3), dtype=np.uint8) 13 | while True: 14 | img = vtuber.获取截图(False) 15 | base[:, (r-c)//2:(r-c)//2+c] = img[:, :, :3] 16 | cam.send(base) 17 | time.sleep(0.01) 18 | t = threading.Thread(target=q) 19 | t.setDaemon(True) 20 | t.start() 21 | -------------------------------------------------------------------------------- /图/1-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/1-1.jpg -------------------------------------------------------------------------------- /图/1-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/1-2.jpg -------------------------------------------------------------------------------- /图/1-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/1-3.jpg -------------------------------------------------------------------------------- /图/1-4-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/1-4-1.jpg -------------------------------------------------------------------------------- /图/1-4-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/1-4-2.jpg -------------------------------------------------------------------------------- /图/1-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/1-5.jpg -------------------------------------------------------------------------------- /图/2-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/2-1.jpg -------------------------------------------------------------------------------- /图/2-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/2-2.jpg -------------------------------------------------------------------------------- /图/2-3-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/2-3-1.jpg -------------------------------------------------------------------------------- /图/2-3-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/2-3-2.jpg -------------------------------------------------------------------------------- /图/2-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/2-4.jpg -------------------------------------------------------------------------------- /图/2-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/2-5.jpg -------------------------------------------------------------------------------- /图/3-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/3-1.jpg -------------------------------------------------------------------------------- /图/3-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/3-2.jpg -------------------------------------------------------------------------------- /图/3-3.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/3-3.webp -------------------------------------------------------------------------------- /图/3-4-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/3-4-1.jpg -------------------------------------------------------------------------------- /图/3-4-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/3-4-2.jpg -------------------------------------------------------------------------------- /图/3-4-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/3-4-3.jpg -------------------------------------------------------------------------------- /图/4-1.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/4-1.webp -------------------------------------------------------------------------------- /图/4-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/4-2.jpg -------------------------------------------------------------------------------- /图/4-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/4-3.jpg -------------------------------------------------------------------------------- /图/4-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/4-4.jpg -------------------------------------------------------------------------------- /图/4-5.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/4-5.jpg -------------------------------------------------------------------------------- /图/5-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/5-1.jpg -------------------------------------------------------------------------------- /图/5-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/5-2.jpg -------------------------------------------------------------------------------- /图/5-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/5-3.jpg -------------------------------------------------------------------------------- /图/6-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/6-1.jpg -------------------------------------------------------------------------------- /图/6-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/6-2.jpg -------------------------------------------------------------------------------- /图/6-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/6-3.jpg -------------------------------------------------------------------------------- /图/6-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/6-4.jpg -------------------------------------------------------------------------------- /图/6-5.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RimoChan/Vtuber_Tutorial/c12fb0b8c32e4daef223f81a032aac3f72dd2d82/图/6-5.webp --------------------------------------------------------------------------------