
1. 先想清楚虚拟数字人直播到底在直播什么做虚拟数字人直播这件事我前后折腾了差不多一个月。很多人一听“虚拟数字人”就觉得高不可攀又是捏脸又是动捕其实对于个人开发者来说完全可以用 python pygame opencv gpt 拼出一个能跑的最小系统。核心逻辑很简单摄像头负责捕捉环境画面opencv 做图像处理pygame 负责把数字人形象和 UI 渲染到窗口里gpt 负责生成对话内容最后把窗口画面当作直播源推出去。这篇文章先讲骨架适合那些想自己动手做 AI 主播、又不想一开始就买商业数字人 SaaS 的朋友。我见过不少教程一上来就让人用 UE5 或者 Unity成本太高动不动几十个 G 的工程文件。而 python 这套组合半天就能跑出一个“能对话、能动、能直播”的原型。虽然是原型但扩展性并不差后面想加动作、表情、语音、弹幕互动都是在这个骨架上做加法。1.1 一个最小可用的虚拟数字人系统包含哪些模块拆开来看一套可用的虚拟数字人直播系统至少要有四个部分视频源可以是真人摄像头也可以是图片、视频文件甚至直接是绿色的背景图。形象渲染一个数字人形象在画面中显示可以是透明 PNG 序列帧也可以是单张立绘加简单缩放位移。对话引擎接入 gpt 接口把用户的问题或弹幕发给大模型拿回文本回复。推流输出把 pygame 渲染好的完整画面推给直播平台或本地录屏软件。这一篇只做前三个模块的最小实现推流部分我会在后续文章里单独讲因为这里有个关键点直播推流不是“把窗口录下来”这么简单涉及帧率匹配和音频同步需要单独开一篇说。1.2 为什么选 pythonpygameopencvgpt 这套组合选 python 是因为它上手快生态全几乎每个人都有了一点基础就能写。选 pygame 而不是 Tkinter、Qt是因为 pygame 天生适合做实时画面渲染游戏循环的概念和直播画面逐帧渲染非常匹配而且它能直接从 numpy 数组创建 Surface这就让 opencv 的图像处理结果可以无缝对接。选 opencv 是因为摄像头采集、颜色转换、抠图、人脸检测这些操作它都有成熟函数不需要自己造轮子。选 gpt 则是为了对话能力目前大模型 API 已经稳定几百行代码就能获得一个聪明的聊天大脑。这套组合最舒服的地方在于每一层都是解耦的。opencv 管“看到什么”pygame 管“画什么”gpt 管“说什么”。如果以后换了更好的渲染引擎或者接了自家微调的模型都能单独替换不需要推翻重来。1.3 系列规划与本文边界我准备把这个项目拆成一个系列。第一篇也就是本文搭好工程骨架跑通“摄像头画面 形象显示 gpt 回复”的最小闭环。第二篇专门讲数字人形象的表情、张嘴动作和语音播放。第三篇讲弹幕监听与自动回复。第四篇讲直播推流和稳定运行。这一篇不打算一次性把代码堆完因为我知道初学者很容易被大段代码劝退。我会按模块拆开讲最后拼成一个主循环。你只要能跑通最后那个完整代码就已经超过大多数看过教程没动手的人了。2. 环境搭建与依赖安装这个项目的依赖不算多但版本坑不少。我建议你直接用 python 3.9 或 3.10不要用最新的 3.13因为 opencv 和 pygame 的二进制包对新版本 Python 的跟进总是慢半拍容易遇到“装不上”的尴尬。我用的是 python 3.10.11下面所有代码都基于这个版本验证过。2.1 Python版本与虚拟环境我强烈建议你建一个独立虚拟环境别把项目装进系统 Python 里。原因很简单你电脑上可能还有别的项目依赖互相打架的时候非常痛苦。创建虚拟环境只需要三步python -m venv digital_human_envWindows 下激活digital_human_env\Scripts\activatemacOS / Linux 下激活source digital_human_env/bin/activate激活后终端会提示当前环境名接下来安装的包都只属于这个项目。2.2 安装pygame、opencv-python和openai主要安装三个包pip install pygame pip install opencv-python pip install openai如果你是 Windows 且不打算用 opencv 的 GUI 窗口功能可以换成 opencv-python-headless体积更小也不会和桌面环境冲突。但因为我们后续要用摄像头采集headless 依然支持视频捕获只是没有 cv2.imshow 那部分界面对 pygame 方案来说完全够用。我实际安装时用的是固定版本避免以后升级带来不可预期的问题pip install pygame2.5.2 opencv-python4.8.1.78 openai1.30.1openai 库版本非常重要。旧版 0.28 和新版 1.x 的调用方式完全不一样。网上搜到的大量教程可能还是旧语法你照着敲会报错。下面代码我全部用新版写法。2.3 安装过程中最常见的三个坑第一个坑是 pip 安装很慢或者超时。在国内网络环境下这是常态可以临时换镜像源pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple第二个坑是 import cv2 时报 ModuleNotFoundError。通常是装了 opencv-python-headless 后又装了一遍 opencv-python两个包的文件名存在冲突会出现 cv2 找不到或无法加载的错误。解决办法是先卸载干净再装其中一个pip uninstall opencv-python opencv-python-headless pip install opencv-python第三个坑是 pygame 初始化时提示 pygame.error: video system not initialized。别急着看代码先检查你写的窗口初始化语句是不是放在 pygame.init() 之前了。这个错误很多新手都会遇到后面我会在完整代码里给出正确顺序。注意如果你的系统没有摄像头或者是在服务器上跑不要强行用 cv2.VideoCapture(0) 初始化和退出程序会卡死或者内存溢出。后面我会给一个兜底方案。3. 用opencv打开摄像头画面并做基础处理虚拟数字人直播的第一帧画面来自 opencv 摄像头采集。你可以把它理解成“数字人的眼睛”摄像头拍到什么程序才能基于这个画面做人脸追踪、背景替换或者动作识别。不过第一版我们不做那么复杂先把画面读出来转换颜色然后传给 pygame 显示。3.1 读取摄像头与颜色空间转换OpenCV 读取摄像头用 VideoCapture 类import cv2 cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) ret, frame cap.read() if not ret: print(摄像头打开失败)重点来了cv2 读到的帧是 BGR 顺序但 pygame 绘制时需要 RGB 顺序。如果你不转换直接往 pygame 里丢整个画面会偏蓝偏红颜色完全失真。转换代码很简单frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)3.2 将cv2画面实时转成pygame Surface转换完之后还要把 numpy 数组变成 pygame 的 Surface 对象。pygame 没有直接提供一个“从数组创建 Surface”的公开函数但可以用 pygame.image.frombuffer 实现import pygame import numpy as np def cv2_frame_to_surface(frame): frame_rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) frame_rgb np.ascontiguousarray(frame_rgb) surface pygame.image.frombuffer(frame_rgb, (frame_rgb.shape[1], frame_rgb.shape[0]), RGB) return surface这里有个细节frame 的宽度和高度必须和数组维度一致所以我用 frame_rgb.shape[1] 和 shape[0] 动态获取。不推荐硬编码尺寸因为摄像头可能支持不同的分辨率硬编码容易导致数组长度不匹配报错。3.3 摄像头失败时的兜底方案如果 ret 一直是 False有两种可能摄像头被占用或者设备索引不对。你可以尝试 cap cv2.VideoCapture(1) 继续试。如果都没有摄像头那就用一张图片做兜底fallback_img cv2.imread(background.jpg) fallback_img cv2.resize(fallback_img, (640, 480))这样整个程序不会因为摄像头不可用而崩溃。直播的时候也建议这样做防止设备偶发失效导致黑屏。4. pygame窗口与数字人形象渲染pygame 在这里的角色是画布。它把摄像头画面、数字人形象、聊天气泡等所有视觉元素叠到同一个窗口里最终这个窗口就是你直播的画面。pygame 的性能对于 720p 级别的画面渲染完全够用但如果你要做 4K 和复杂特效那就得考虑写 shader 了本文不涉及。4.1 窗口、时钟与事件循环任何 pygame 程序的核心都是初始化 主循环。初始化窗口pygame.init() screen pygame.display.set_mode((960, 540)) pygame.display.set_caption(Virtual Digital Human) clock pygame.time.Clock()主循环里要有三件事处理事件、更新逻辑、绘制画面。running True while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False # 更新逻辑 # 绘制画面 pygame.display.flip() clock.tick(30)clock.tick(30) 的意思是让循环尽量稳定在 30 FPS。数字人直播不需要太高帧率25 到 30 帧足够高帧率反而对推流带宽压力大。4.2 加载透明PNG形象并合成到背景数字人形象最方便的做法是准备一张透明背景的 PNG 立绘。我用的是网上找的虚拟形象素材也可以用 VTuber 立绘生成工具做。pygame 加载 PNG 时会保留 alpha 透明通道character_img pygame.image.load(character.png).convert_alpha()然后把它绘制在窗口的某个位置screen.blit(character_img, (400, 150))如果要对形象做平滑缩放用 pygame.transform.smoothscalescaled_character pygame.transform.smoothscale(character_img, (300, 450))这里要注意缩放会改变表面大小如果你需要点击检测比如点击形象切换动作就必须同步更新角色的矩形区域rect。4.3 画一个聊天气泡和字幕聊天气泡是在原图上叠加一个半透明矩形加上文字。pygame 里半透明矩形需要在临时 Surface 上绘制再整体 blitbubble_surf pygame.Surface((400, 100), pygame.SRCALPHA) bubble_surf.fill((255, 255, 255, 200)) screen.blit(bubble_surf, (20, 20))文字部分要特别注意中文字体。pygame 默认字体不支持中文直接 render 中文会显示成方块。解决方法是加载系统字体font pygame.font.Font(msyh.ttc, 24) # Windows 下用微软雅黑如果你在 macOS 下可以用 PingFang 字体路径通常是 /System/Library/Fonts/PingFang.ttc。建议准备一个 fonts 目录把字体文件放到项目里避免不同系统路径不一致。4.4 简单的角色移动与缩放数字人形象不需要一开始就做骨骼动画简单的移动和缩放就能带来“活”的感觉。你可以在主循环里记录一个角色位置根据时钟周期做左右晃动或者根据 GPT 回复长度变化呼吸节奏。我个人在第一版里用了正弦函数做轻微上下浮动import math float_offset int(10 * math.sin(pygame.time.get_ticks() / 500)) character_y base_y float_offset这种轻微浮动会让画面立刻生动起来但注意幅度不要太大否则直播画面会显得“晕”。5. 接入gpt对话能力这一步是整个虚拟数字人最核心的“灵魂”。pygame 和 opencv 解决的是画面问题而 gpt 解决的是“说什么”的问题。我用的 openai 库是 1.x 版本调用方式和新版文档同步。5.1 调用openai接口的基本姿势先设置 API Key。我建议不要把 Key 直接写死在代码里而是用环境变量export OPENAI_API_KEY你的key然后在代码里初始化客户端from openai import OpenAI client OpenAI()发送第一条消息的完整调用如下response client.chat.completions.create( modelgpt-3.5-turbo, messages[ {role: system, content: 你是一个虚拟直播间的主播性格活泼说话简短}, {role: user, content: 你好} ], temperature0.8, max_tokens200 ) reply response.choices[0].message.content print(reply)这里有一点需要注意新版 openai 库不是 response.choices[0].text而是 response.choices[0].message.content。如果你看到 AttributeError 或 KeyError多半是版本语法用混了。5.2 设计虚拟主播人设prompt系统提示词决定了数字人说话的风格。我总结了一个模板你可以在此基础上扩展你的名字是“小灵”是一个虚拟数字人主播现在在直播间和观众聊天。你的性格 1. 活泼热情说话用短句经常使用感叹词。 2. 回答问题时先直接用口语回答不要输出任何排版和列表。 3. 如果观众的问题你不了解就开玩笑混过去不要承认自己是 AI 模型。 4. 每隔几句话可以主动询问观众问题活跃氛围。把这个提示词放在 messages 的第一条 system 消息里后面 append 用户输入的弹幕或问题。好的提示词能明显提升直播间的互动感。如果观众问的问题比较长你可以先在代码里做截断只保留前 100 个字符避免 token 浪费。5.3 避免卡顿用线程处理gpt请求直接在主循环里同步调用 gpt 接口会造成灾难每次请求等 2 秒期间 pygame 窗口会完全卡死直播画面直接冻结。解决办法是把 gpt 请求放到后台线程里。我用的是一种非常简单的“问-答”状态机import threading class GptWorker: def __init__(self): self.lock threading.Lock() self.pending_question None self.result None def ask(self, question): with self.lock: self.pending_question question def update(self): with self.lock: if self.pending_question is None: return question self.pending_question self.pending_question None def run(): reply call_gpt(question) with self.lock: self.result reply threading.Thread(targetrun, daemonTrue).start()在主循环里每帧调用 worker.update()当用户输入问题时调用 worker.ask(问题)。gpt 返回后会写入 worker.result画面层检测到 result 有值就显示到气泡里。5.4 对话历史管理为了让对话有连续性需要维护一个 messages 列表。但不能无限增长否则 token 会爆。我用的策略是只保留最近 10 条历史def append_conversation(messages, role, content): messages.append({role: role, content: content}) if len(messages) 10: messages.pop(2) # 保留 system丢弃最早的对话这里的 pop(2) 意思是删掉索引 2也就是除了 system 和最近一轮之外最旧的一条用户消息。实际项目里我建议用一个队列或者直接用 token 数判断超过 2000 tokens 时裁剪历史。6. 把视频画面、形象和gpt串成一个主循环前面几节讲了每个模块怎么单独用现在把它们串起来。这一节的完整代码可以直接复制运行但你需要改两处你的 API Key以及数字人 PNG 的路径。为了能跑通我会把摄像头画面缩小放到窗口左下角数字人放右边聊天气泡放上方形成一个简单的直播布局。6.1 完整代码骨架import cv2 import math import pygame import threading import numpy as np from openai import OpenAI # 初始化gpt客户端 client OpenAI() # 用线程安全的队列管理gpt回复 reply_result None pending_question None lock threading.Lock() def call_gpt(question): messages [ {role: system, content: 你是虚拟主播小灵回答简洁活泼}, {role: user, content: question} ] try: resp client.chat.completions.create( modelgpt-3.5-turbo, messagesmessages, temperature0.9, max_tokens150 ) return resp.choices[0].message.content except Exception as e: return f抱歉我现在有点卡顿{e} def ask_gpt(question): global pending_question with lock: pending_question question def update_gpt_worker(): global pending_question, reply_result with lock: if pending_question is None: return q pending_question pending_question None def run(): global reply_result ans call_gpt(q) with lock: reply_result ans threading.Thread(targetrun, daemonTrue).start() # 摄像头与画面 cap cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) # 初始化pygame pygame.init() screen pygame.display.set_mode((960, 540)) pygame.display.set_caption(Virtual Digital Human - Demo) clock pygame.time.Clock() font pygame.font.Font(msyh.ttc, 24) # 加载形象 try: character_img pygame.image.load(character.png).convert_alpha() character_img pygame.transform.smoothscale(character_img, (260, 460)) except Exception as e: print(形象加载失败请确认 character.png 存在) character_img None base_y 50 running True allowed_input while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False elif event.type pygame.KEYDOWN: if event.key pygame.K_RETURN: if allowed_input.strip(): ask_gpt(allowed_input.strip()) allowed_input elif event.key pygame.K_BACKSPACE: allowed_input allowed_input[:-1] else: allowed_input event.unicode update_gpt_worker() # 摄像头帧转surface ret, frame cap.read() cam_surface None if ret: frame_resized cv2.resize(frame, (240, 180)) frame_rgb cv2.cvtColor(frame_resized, cv2.COLOR_BGR2RGB) frame_rgb np.ascontiguousarray(frame_rgb) cam_surface pygame.image.frombuffer(frame_rgb, (240, 180), RGB) # 背景填充 screen.fill((30, 30, 60)) # 左下角摄像头小窗 if cam_surface: screen.blit(cam_surface, (0, 360)) # 数字人形象居中偏右带浮动 if character_img: offset_y int(8 * math.sin(pygame.time.get_ticks() / 800)) screen.blit(character_img, (560, base_y offset_y)) # 聊天气泡显示最近回复 with lock: current_reply reply_result if current_reply: bubble pygame.Surface((420, 140), pygame.SRCALPHA) bubble.fill((0, 0, 0, 150)) screen.blit(bubble, (20, 20)) lines [] the_text current_reply while len(the_text) 22: lines.append(the_text[:22]) the_text the_text[22:] lines.append(the_text) for i, line in enumerate(lines[:4]): text_surf font.render(line, True, (255, 255, 255)) screen.blit(text_surf, (30, 25 i * 30)) # 底部输入提示 prompt_surf font.render(按回车发送问题: allowed_input, True, (200, 200, 200)) screen.blit(prompt_surf, (20, 510)) pygame.display.flip() clock.tick(30) cap.release() pygame.quit()这个代码里我故意没有做对话历史管理只演示最简逻辑。等你能跑通再往里面加记忆功能。6.2 各模块之间怎么通信代码里最重要的通信机制是 pending_question 和 reply_result 两个变量配合 lock 锁来保证线程安全。pygame 主循环每帧去查看 gpt 是否有新回复有就显示没有就继续渲染画面。这样即使 gpt 接口很慢画面也不会卡顿。一个容易忽略的点主循环里不要读 gpt 返回结果后清空 reply_result否则动画效果会很奇怪。最好保留回复直到用户发送下一条问题。你可以设计一个回复队列连续显示多条对话模拟直播弹幕效果。6.3 直播推流可以怎么做如果你的最终目标是推到 B 站或抖音第一版最简单的方案是用 OBS。把 pygame 窗口作为“窗口采集”源添加进 OBS在 OBS 里叠加麦克风、背景音乐然后推流到平台。这种方式不需要写任何推流代码适合前期验证。如果你一定要用 python 自带推流可以用 ffmpeg 拉取窗口画面命令大致是ffmpeg -f gdigrab -i titleVirtual Digital Human - Demo -c:v libx264 -preset ultrafast -f flv rtmp://你的直播地址但这条命令需要额外安装 ffmpeg而且不同平台对窗口捕获的支持不一样我不建议新手在这一篇就折腾先把画面跑通更实际。7. 常见问题排查与避坑记录这个项目虽然代码量不大但初学者踩坑率极高。我把这一路实践中遇到最多的几个问题列出来你如果卡住了可以直接对照排查。7.1 摄像头打不开或画面全黑打开失败大多是设备索引问题。笔记本自带摄像头一般是 0USB 摄像头有可能是 1 或更高。你可以写个循环尝试for i in range(3): cap cv2.VideoCapture(i) if cap.isOpened(): print(f使用摄像头 {i}) break如果摄像头灯亮了但画面全黑检查是不是被其他应用占用比如 Zoom 或微信视频。我原来调试时一直以为代码写错了最后发现是钉钉会议还开着占用了摄像头。7.2 pygame窗口闪退和中文乱码闪退大概率是 convert_alpha() 或 font 路径问题。convert_alpha 要求图片确实是带 alpha 通道的 PNG如果你的素材是 JPG就会报错。中文乱码就是字体问题按我前面写的把字体文件放到项目目录并配置完整路径不要依赖系统默认。还有一个很少人注意的坑pygame 窗口在 Windows 上如果长时间运行可能会被系统识别为“未响应”这是 pygame 事件循环里没有及时处理鼠标事件导致的。建议每帧至少调用 pygame.event.pump()防止窗口假死。7.3 gpt接口报错和超时openai 库常见的报错有 AuthenticationError、RateLimitError、APIConnectionError。逐个说AuthenticationErrorAPI Key 配置错误检查环境变量是否生效。RateLimitError请求太频繁大概率是你在调试中反复调用触发了并发限制。解决方法是加一个简单的时间间隔限制比如两次请求之间至少隔 5 秒。APIConnectionError网络不稳定。这种错误不要在直播时直接往界面上抛应该捕获异常后生成一句兜底台词比如“哎呀网络开小差了我们换个话题吧”。我给 call_gpt 函数加了 try-except就是为了防止异常导致整个 pygame 窗口崩溃。7.4 内存与性能优化笔记OpenCV 摄像头采集和 pygame 渲染本质是两个独立循环中间用数组拷贝会非常快但要注意别每帧都申请新数组。比如 cv2.resize 每次都会创建新数组配合 30 FPS 运行一小时内存会缓慢增长。你可以开启视频帧的复用frame_resized np.empty((240, 180, 3), dtypenp.uint8) cv2.resize(frame, (240, 180), dstframe_resized)但注意这种方式不可靠因为摄像头分辨率可能变化更好的方案是每 100 帧手动 gc.collect() 一次或者用队列限制缓存最多 5 帧。实际直播时建议把摄像头分辨率固定在 640x480能大幅减少内存压力。8. 下一步可以做的扩展跑通上面这套骨架后你已经有了一个可以对话、可以看、可以动的数字人。接下来有两条路可以走。一条是往“更像真人”的方向走给数字人加上眨眼、张嘴、头部转向这些动作这些可以用 opencv 做人脸关键点检测后驱动素材变化。另一条是往“更能互动”的方向走监听直播间的弹幕把弹幕内容发给 gpt再把回复自动播出来。我个人的体会是先别急着上高复杂度的功能把基本盘做稳。尤其是 gpt 对话的延迟和回复风格对直播观感的影响最大。如果回复太慢观众会觉得“这个主播反应迟钝”如果回复一眼就能看出是 AI 写的观众又会失去兴趣。所以我后来在 prompt 里加了很多口语化的约束例如禁止使用“作为一名人工智能”“首先呢”这类开场白效果立竿见影。另外你还可以把数字人的形象换成自己画的两帧差分图比如一个闭嘴图和一个张嘴图。当 gpt 回复内容到达时根据语音播放的状态切换嘴型这样在视觉上会产生“她在说话”的错觉。这个技术不需要太高深只需要两个 PNG 素材和简单的定时切换。最后再分享一个小技巧调试的时候把 pygame 窗口标题改成你自己的名字这样用 OBS 捕捉窗口时不会误选到浏览器或者其他程序。等直播稳定后再在 OBS 里设置一个全局快捷键一键切换直播场景。这套方案我自己用了很长一段时间成本几乎为零但效果其实能唬住不少朋友。希望这一篇的代码和思路能成为你折腾虚拟数字人的起点下一篇我们专门讲嘴型和声音同步到时候见。