ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

本地AI数字人实战:树莓派5与Hailo-8L的完整落地指南

本地AI数字人实战:树莓派5与Hailo-8L的完整落地指南 你面前摆着一台树莓派5旁边塞着一张叫做Hailo-8L的AI加速卡屏幕上站着一个低多边形画风的3D小人。它能通过摄像头认出你手里的马克杯能听懂你问“这是什么”然后嘴巴一动一动地回答“看起来是个马克杯还冒着热气”。这套东西没有依赖云端大模型核心AI能力几乎全部跑在本地的这颗小卡片上。这个项目就是把“能看、能听、能说、有形象”四件事串起来全部围绕树莓派5 Hailo-8L展开我会把硬件组装、驱动安装、YOLOv5目标检测、大模型对话、离线语音合成、浏览器端3D数字人这一整条链路完整拆开并附上可以直接跑的代码。如果你手里正好有一台树莓派5或者单纯想了解边缘AI数字人到底怎么落地这篇文章就是给你准备的。1. 项目整体设计与方案选型1.1 这套数字人到底做了什么适合谁玩先把这个项目的边界说清楚。市面上很多“AI数字人”是云端的摄像头画面传到服务器识别结果再传回来服务器生成语音最后前端播一段视频。这套方案的问题很明显延迟不可控、隐私没保障、断网就完蛋。而这里要做的是全部AI能力都在局域网内完成的桌面级数字人树莓派5负责调度Hailo-8L负责跑视觉模型CPU负责跑语音识别、大模型和语音合成浏览器负责渲染3D形象。体验流程是这样的摄像头连续采集画面每一帧都送到Hailo-8L上跑目标检测如果检测到人或者其他物体大模型会根据检测结果生成一句简短回复语音合成模块把回复变成音频文件推送到浏览器浏览器里的3D数字人一边播放语音一边做出说话的口型动作。同时你也可以手动触发语音输入麦克风录下一段问题语音识别转成文字再走大模型和语音合成链路。这个项目适合几类人想入门边缘AI的嵌入式开发者手里有树莓派5但不知道PCIe接口能干什么的玩家以及想给智能家居做一个“带形象的语音助手”的创客。它不会真的做成一个产品级数字人但能把AI数字人的所有核心模块全部跑通后续想换模型、换形象、加人脸识别都是在这个框架上做增量。1.2 为什么是树莓派5 Hailo-8L而不是其他组合先看一组很直观的对比。树莓派5的CPU是4核Cortex-A76主频2.4GHz跑一个YOLOv5s目标检测模型实测大概只有1到3帧每秒做实时视觉基本不可用。但加上Hailo-8L之后同样一个YOLOv5s模型能跑到30帧每秒左右差异不是“快一点”而是“能不能用”的区别。Hailo-8L的算力标称13 TOPS功耗只有2.5W左右正好卡在“性能够用、发热可控、不需要外接电源”这个甜点上。选择树莓派5还有一个很现实的原因就是PCIe接口。树莓派5板载了一个PCIe 2.0 x1接口软件上可以配置成Gen3模式带宽大概8Gbps足以把摄像头视频流喂给加速卡再拿回推理结果。市面上的M.2 HAT转接板就是干这件事的把Hailo-8L的M.2卡插上去再连到树莓派5的PCIe排线接口整个硬件栈就齐了。树莓派官方其实也做过一个AI Kit用的就是Hailo-8L这意味着驱动、系统镜像、示例代码都有官方维护踩坑成本比玩其他小众NPU低很多。我之前也考虑过其他方案。Jetson Orin Nano算力更强但价格贵一大截整板功耗也高需要风扇呼呼转Intel的神经计算棒NCS2已经停更了USB视频加速棒在树莓派上的驱动和性能都不太理想。综合来看树莓派5 Hailo-8L是这个预算段里最容易出结果的一套组合。而且这套软件架构并不绑死硬件只要你把HailoYOLO推理类换成别的NPU推理实现其他模块基本不用动。1.3 整体软件链路怎么串一句话概括树莓派5是导演Hailo-8L是视觉演员CPU是大脑和口舌浏览器是舞台。数据流大致有两路。第一路是视觉链路摄像头采集640x640的RGB图像送到Hailo-8L上跑YOLOv5推理得到检测框和类别标签这些结构化结果会拼进大模型的提示词里。第二路是语音链路用户说话麦克风录音得到音频文件语音识别转成文字文字进入大模型。两条路汇合之后大模型生成适合“说”出来的回复文本语音合成转成音频通过WebSocket推送到浏览器3D数字人播放音频并同步做口型动画。选择“浏览器渲染3D形象”而不是在树莓派桌面上开3D窗口是经过实际测试的决定。树莓派5的GPU性能应付桌面环境还行但跑一个完整3D数字人场景会明显发烫帧率也不稳定。浏览器承担这部分工作后树莓派只负责逻辑和AI推理CPU负载反而更集中。而且这样天然支持手机访问你用手机打开浏览器输入树莓派的局域网IP也能看到同一个数字人这在后面“手机适配”这个点上非常加分。2. 硬件组装、系统准备与Hailo驱动落地2.1 硬件清单与安装顺序需要的硬件其实不多我列了一个表格方便照着买硬件型号/规格用途备注树莓派5建议8GB内存版本主控4GB版跑大模型会吃力Hailo-8LM.2 Key M接口AI推理加速所在设备要认得这张卡M.2 HAT转接板树莓派5 PCIe转M.2承载Hailo-8L注意走线方向摄像头Raspberry Pi Camera Module 3或USB摄像头视觉输入推荐官方摄像头麦克风USB麦克风或USB声卡语音输入不要用耳机孔直插音箱USB音箱或3.5mm音箱语音输出需支持Linux免驱电源5V/5A USB-C整机供电供电不足会出现各种奇怪问题散热官方主动散热器或第三方风扇压住CPU和NPU温度Hailo发热量不小存储microSD卡建议A2级64GB以上系统模型A1卡后期会卡IO显示器/HDMI线任意首次调试跑通后可去掉安装顺序有讲究。先把Hailo-8L M.2卡插到转接板上注意防呆缺口插到底后拧上螺丝固定。然后转接板本身通过排线连接到树莓派5的PCIe接口树莓派5的PCIe接口是板载的FPC排线插座排线一定要按标记方向插入插反了会烧硬件。接着装风扇、摄像头最后再接电源。这里要说一下Hailo-8L工作起来温度不低散热片和风扇是必须的不要省。2.2 刷系统与安装Hailo驱动系统方面我强烈建议用Raspberry Pi官方Imager烧录Raspberry Pi OS Bookworm 64位桌面版和Lite版都行第一次调试建议桌面版方便开浏览器看3D数字人效果。烧录时把用户名、密码、WiFi、SSH都提前配置好省得再插键盘。开机后先更新系统sudo apt update sudo apt full-upgrade -y然后打开启动配置文件启用PCIe并尝试提升到Gen3模式sudo nano /boot/firmware/config.txt在文件末尾加上dtparampciex1_gen3如果你的转接板或Hailo卡对Gen3信号要求高遇到不稳定可以改成dtparampciex1_gen2牺牲一点带宽换稳定性。接下来安装Hailo相关驱动sudo apt install hailo-pcie hailo-all sudo reboot重启后检查PCIe总线是否识别到了Hailolspci | grep -i hailo正常情况下你会看到类似这样的输出01:00.0 Co-processor: Hailo Technologies Ltd. Hailo-8L AI Processor看到“Co-processor: Hailo Technologies”就说明驱动已经加载了。如果没有输出先确认dtparampciex1_gen3是否生效再确认安装的是64位系统因为驱动只支持arm64。2.3 搭建Python环境和三方库项目我放在/home/pi/ai-digital-human然后建一个独立虚拟环境避免污染系统自带Pythonsudo apt install -y python3-venv git mkdir -p ~/ai-digital-human cd ~/ai-digital-human python3 -m venv venv source venv/bin/activate需要安装的核心库如下pip install --upgrade pip pip install opencv-python pip install hailo_platform4.18 pip install faster-whisper pip install piper-tts pip install fastapi uvicorn[standard] numpy requests这里要特意强调一下hailo_platform的版本。HailoRT的Python绑定和驱动版本必须匹配如果你是按照上面hailo-pcie hailo-all方式安装的驱动对应HailoRT一般就是4.18左右。安装完可以跑一下hailortcli fw-control identify能正常输出设备信息就说明软件栈是通的。摄像头驱动不需要额外装树莓派OS自带libcamera。但在Python里调用时建议用picamera2库OpenCV的cv2.VideoCapture直接调用摄像头经常出现读不到图像的问题换成picamera2后稳定得多pip install picamera23. 视觉模块把YOLOv5跑在Hailo-8L上3.1 不重复造轮子先用官方示例验证AI卡在你把我后面写的手写推理代码跑通之前我强烈建议先跑一遍树莓派官方提供的Hailo示例把整条链路验证一遍。这一步很关键它能帮你区分“硬件问题”和“代码问题”。cd ~ git clone https://github.com/raspberrypi/hailo-rpi5-examples cd hailo-rpi5-examples ./download_models.sh下载脚本会从Hailo Model Zoo拉取预编译好的HEF模型文件。HEF是Hailo专属的模型格式相当于把YOLO权重和模型结构打包成一个只要交给NPU就能跑的二进制文件。下载完成后跑一个官方目标检测例程python basic_pipelines/detection.py --model yolov8s如果画面里出现实时检测框并且帧率能到20到30 FPS说明Hailo-8L没白装摄像头也没问题。这个例程本身就是一个很好的参考代码后续你自己写的推理类本质上就是在做同一件事把图像送入Hailo拿回检测框坐标和类别。3.2 手写一个HailoRT推理类官方示例代码封装得比较重不便集成到自己的数字人项目里。我带大家一步步写一个轻量化的HailoYOLO类。这里用的是HailoRT的Python API版本不同API会有差异如果发现方法和名称对不上参照你系统里安装的HailoRT文档替换即可。先建一个vision.py文件import numpy as np import cv2 from hailo_platform import VDevice, HEF class HailoYOLO: def __init__(self, hef_path): self.hef HEF(hef_path) self.vdevice VDevice() self.infer_model self.vdevice.create_infer_model(hef_path) self.infer_model.set_batch_size(1) self.configured_model self.infer_model.configure() self.session self.configured_model.activate() self.input_name self.infer_model.input_names[0] self.output_names self.infer_model.output_names self.input_shape self.infer_model.input_shapes[self.input_name] def infer(self, bgr_frame): # 输入shape一般是 [1, 640, 640, 3] expected_h self.input_shape[1] expected_w self.input_shape[2] resized cv2.resize(bgr_frame, (expected_w, expected_h)) rgb cv2.cvtColor(resized, cv2.COLOR_BGR2RGB) input_tensor np.expand_dims(rgb, axis0).astype(np.uint8) results self.session.infer({self.input_name: input_tensor}) return list(results.values())[0] def letterbox(img, new_shape(640, 640), color(114, 114, 114)): # 保持宽高比缩放并填充到目标尺寸 shape img.shape[:2] r min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad (int(round(shape[1] * r)), int(round(shape[0] * r))) dw (new_shape[1] - new_unpad[0]) / 2 dh (new_shape[0] - new_unpad[1]) / 2 if shape[::-1] ! new_unpad: img cv2.resize(img, new_unpad, interpolationcv2.INTER_LINEAR) top, bottom int(round(dh - 0.1)), int(round(dh 0.1)) left, right int(round(dw - 0.1)), int(round(dw 0.1)) return cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, valuecolor), r, dw, dh def nms(boxes, scores, iou_threshold0.45): idxs cv2.dnn.NMSBoxes(boxes, scores, score_threshold0.0, nms_thresholdiou_threshold) return [] if len(idxs) 0 else idxs.flatten().tolist() def parse_yolo_output(output, conf_threshold0.45, iou_threshold0.45): # 假设输出shape为 [1, N, 84] 或者 [1, 1, N, 84]N代表所有锚点数量 output np.squeeze(output) if output.ndim 2: output output[np.newaxis, :, :] num_anchors output.shape[0] # 解码框坐标有些HEF输出是解码后的坐标有些需要额外处理 box_xy output[:, :2] box_wh output[:, 2:4] obj_conf output[:, 4:5] class_scores output[:, 5:] class_ids np.argmax(class_scores, axis1) max_class_scores np.max(class_scores, axis1) confidences obj_conf.flatten() * max_class_scores keep confidences conf_threshold if not np.any(keep): return [] # 从中心点坐标转成左上角/右下角形式 boxes [] for i in np.where(keep)[0]: cx, cy box_xy[i] w, h box_wh[i] x1 int(cx - w / 2) y1 int(cy - h / 2) x2 int(cx w / 2) y2 int(cy h / 2) boxes.append([x1, y1, x2, y2]) scores confidences[keep].tolist() idxs nms(boxes, scores, iou_threshold) results [] for i in idxs: idx_original np.where(keep)[0][i] results.append({ label: int(class_ids[idx_original]), confidence: float(scores[i]), box: boxes[i], }) return results有几个点要特别说明。Hailo编译出来的YOLOv5s HEF输出格式在不同版本里可能有差异有的输出是[1, 253, 84]有的会带一个额外的维度甚至坐标的编码方式也不同。我这里的parse_yolo_output按最常看到的格式处理如果跑的时候发现检测框全乱先打印一下output.shape再根据实际形状调整np.squeeze和坐标解码那两行。nms函数我直接用OpenCV的cv2.dnn.NMSBoxes传入的boxes是[x1, y1, x2, y2]格式NMS会按交并比把重叠的框去掉最终只保留最可信的检测结果。调用方式很简单yolo HailoYOLO(models/yolov5s.hef) frame camera_capture() # 这里得到一帧BGR图像 output yolo.infer(frame) objects parse_yolo_output(output, conf_threshold0.5)3.3 怎么跑自己训练的YOLOv5模型很多同学问得最多的问题就是“我不想要COCO的80类想识别自己的东西”。这个完全可行只是编译HEF这一步不推荐在树莓派上做因为Hailo数据流编译器对CPU和内存要求高在树莓派上编译会非常慢。标准流程是这样的。第一步在你的PC上准备一个带GPU的环境使用ultralytics或yolov5官方仓库训练自己的数据集得到best.pt。训练时建议只用一个类别比如“猫”“狗”“工牌”这样推理结果更精准模型也更容易编译通过。第二步导出ONNX格式python export.py --weights best.pt --include onnx --opset 13第三步在x86 Linux机器上安装Hailo Dataflow Compiler运行编译命令hailo dataflow compile --model best.onnx --hw-arch hailo8l --output yolov5_custom.hef编译过程中会自动做量化如果某些算子在Hailo上不支持需要把模型里的激活函数换掉比如把SiLU替换成ReLU或者换一个更简洁的骨干网络。第四步把编译好的HEF文件拷贝到树莓派上替换HailoYOLO类里的hef_path同时把vision.py里的类别数量改成自己模型的类别数。如果训练时的检测类别不是COCO标签这个信息要自己维护好别在识别阶段对不上号。4. 会听会说语音识别、大模型与语音合成三连击4.1 语音识别用faster-whisper把录音变成文字数字人“能听”这步我用的是faster-whisper。它是openai-whisper的一个加速版本底层基于CTranslate2在CPU上推理速度比原始whisper快好几倍内存占用也低不少。树莓派5上用base模型识别中文短句实时率能做到1倍左右也就是说你录5秒的话它大约需要5秒识别体验能接受。安装和加载模型from faster_whisper import WhisperModel asr_model WhisperModel(base, devicecpu, compute_typeint8)录音这一步我用的是系统命令arecord简单可靠。先用arecord -l查一下麦克风设备编号然后录5秒16kHz单声道音频arecord -D plughw:1,0 -f S16_LE -r 16000 -c 1 -d 5 question.wav识别代码def transcribe_wav(pathquestion.wav): segments, info asr_model.transcribe(path, languagezh) text .join(seg.text for seg in segments).strip() return text第一版交互可以在终端里按回车触发录音后面如果要做得更自然可以在浏览器端用MediaRecorder把用户语音录下来再发送给后端这样用户操作成本更低。不过那会带来麦克风权限、实时传输、VAD检测等一系列问题建议第一步先把终端版跑通再考虑升级。4.2 对话大脑本地Ollama和API两种方式数字人能不能“说人话”靠的是大模型。这个项目里有两个选择一是完全离线的本地部署二是调用云端API。本地方案我推荐用Ollama在树莓派5上跑小参数模型。安装很简单curl -fsSL https://ollama.com/install.sh | sh ollama pull qwen2.5:1.5b如果你对推理型小模型感兴趣也可以拉DeepSeek-R1蒸馏版ollama pull deepseek-r1:1.5b这两个模型在树莓派5上都能跑但速度有限。实际测试下来qwen2.5:1.5b大概每秒生成4到6个token回答20个字的句子需要3到5秒。这个延迟对“对话型数字人”来说勉强及格如果嫌慢可以降到0.5b版本或者直接用云端API。API方案就是调用DeepSeek开放平台这类线上大模型接口。优点是回复快、质量高缺点是必须联网而且每轮调用都有成本。我的建议是如果只是演示本地模型足够如果要做长时间对话或者体验要求高上API。调用代码封装成通用接口本地用Ollama的/api/chatAPI用OpenAI兼容格式import requests def ask_llm(prompt, use_apiFalse): if use_api: api_key 你的API Key resp requests.post( https://api.deepseek.com/chat/completions, headers{Authorization: fBearer {api_key}}, json{ model: deepseek-chat, messages: [{role: user, content: prompt}], max_tokens: 50, }, timeout30, ) return resp.json()[choices][0][message][content].strip() resp requests.post( http://localhost:11434/api/chat, json{ model: qwen2.5:1.5b, messages: [{role: user, content: prompt}], stream: False, }, timeout60, ) return resp.json()[message][content].strip()Prompt设计非常关键。实测下来必须明确告诉大模型“你是数字人、回答要短、要口语化”。我在系统提示词里是这样写的你是桌面上的一位3D数字人说话简短、口语化、有亲和力。 当前你在摄像头里看到这些物体{objects} 用户的问题{question} 请用不超过20个字回答。加了“不超过20个字”这条限制后TTS生成语音的时间会明显变短整套交互节奏也自然很多。4.3 语音合成用piper离线生成中文语音TTS我选的是piper它是一个完全离线的轻量神经网络语音合成引擎在树莓派5的CPU上生成一句话只需要百毫秒级别远快于实时播放速度。对比espeak那种“机器人腔”piper的听感要好非常多。安装pip install piper-tts然后需要下载中文语音模型。piper的中文模型叫zh_CN-huayan-medium包含一个.onnx模型文件和一个.json配置文件两个文件要放在同一个目录下。我是这样组织目录的models/ ├── zh_CN-huayan-medium.onnx ├── zh_CN-huayan-medium.onnx.json下载好模型后命令行先试一下echo 你好我是你的桌面数字人。 | piper \ --model models/zh_CN-huayan-medium.onnx \ --output_file hello.wav能听到语音说明piper没问题。在Python里调用时我用subprocess把文本喂给piper进程然后拿到生成的wav文件路径import subprocess def tts_speak(text, output_pathresponse.wav): cmd [ piper, --model, models/zh_CN-huayan-medium.onnx, --output_file, output_path, ] proc subprocess.Popen( cmd, stdinsubprocess.PIPE, stdoutsubprocess.DEVNULL, stderrsubprocess.DEVNULL, ) proc.communicate(inputtext.encode(utf-8)) return output_path5. 数字人形象浏览器里的Three.js 3D小机器人5.1 为什么把3D画面放到浏览器而不是树莓派桌面数字人的“形象”这步我一开始想的是直接在树莓派桌面上开一个窗口渲染3D角色但实测下来效果不好。树莓派5的GPU要同时驱动桌面和实时3D渲染CPU和GPU负载双双拉满画面还不流畅。后来我把渲染移到浏览器端树莓派只通过WebSocket推送检测结果、文本和音频地址浏览器的Three.js负责画角色和播放声音整个体验瞬间顺畅了。这么做还有一个额外的好处任何能开浏览器的设备都能当数字人的“脸”。你可以在树莓派接的显示器上看也可以用手机、平板访问同一个IP地址数字人跨端可用。这对以后把数字人嵌进智能家居面板、车载屏幕或者开源硬件项目都很有利。5.2 实现一个低多边形3D数字人并驱动口型前端是一个独立的HTML页面放在项目的static/index.html。我用Three.js简单拼了一个低多边形风格的机器人角色头部是个方块两只眼睛是球体嘴是一个扁立方体。重点在于“说话时嘴部缩放”这个效果配合音频播放感觉就像真的在说话。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title桌面AI数字人/title style html, body { margin: 0; overflow: hidden; background: #111; font-family: sans-serif; } #status { position: absolute; bottom: 20px; left: 50%; transform: translateX(-50%); color: #fff; background: rgba(0,0,0,0.6); padding: 8px 16px; border-radius: 20px; font-size: 14px; text-align: center; pointer-events: none; min-width: 60%; } #caption { position: absolute; top: 20px; left: 50%; transform: translateX(-50%); color: #ffd966; background: rgba(0,0,0,0.5); padding: 8px 16px; border-radius: 12px; font-size: 16px; max-width: 80%; text-align: center; } /style /head body div idcaption等待数字人说话.../div div idstatus连接中.../div script src/static/three.min.js/script script let scene, camera, renderer, mouth, statusEl, captionEl; let isSpeaking false; let wsUrl ws://${location.host}/ws; function init() { statusEl document.getElementById(status); captionEl document.getElementById(caption); scene new THREE.Scene(); camera new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 100); camera.position.set(0, 1.5, 6); camera.lookAt(0, 1.2, 0); renderer new THREE.WebGLRenderer({ antialias: true }); renderer.setSize(window.innerWidth, window.innerHeight); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); document.body.appendChild(renderer.domElement); scene.add(new THREE.AmbientLight(0x404060)); const dirLight new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(2, 4, 3); scene.add(dirLight); // ---- 机器人身体 ---- const bodyMat new THREE.MeshStandardMaterial({ color: 0x4a90d9 }); const body new THREE.Mesh(new THREE.BoxGeometry(1.0, 1.0, 0.6), bodyMat); body.position.y 0.7; scene.add(body); const head new THREE.Mesh(new THREE.BoxGeometry(0.8, 0.8, 0.7), new THREE.MeshStandardMaterial({ color: 0xf4d03f })); head.position.y 1.75; scene.add(head); const eyeMat new THREE.MeshStandardMaterial({ color: 0x111111 }); const eyeL new THREE.Mesh(new THREE.SphereGeometry(0.09, 12, 12), eyeMat); eyeL.position.set(-0.16, 1.82, 0.36); scene.add(eyeL); const eyeR eyeL.clone(); eyeR.position.x 0.16; scene.add(eyeR); mouth new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.06, 0.1), new THREE.MeshStandardMaterial({ color: 0xbb4430 })); mouth.position.set(0, 1.55, 0.36); scene.add(mouth); // 左右手臂 const armMat new THREE.MeshStandardMaterial({ color: 0x8e44ad }); const armL new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.7, 0.16), armMat); armL.position.set(-0.62, 0.75, 0); scene.add(armL); const armR armL.clone(); armR.position.x 0.62; scene.add(armR); connectWebSocket(); animate(); } function setSpeaking(state) { isSpeaking state; statusEl.textContent state ? 数字人正在说话... : 数字人聆听中...; } function setCaption(text) { captionEl.textContent text; } function animate() { requestAnimationFrame(animate); // idle 轻微摇摆 scene.rotation.y Math.sin(Date.now() * 0.0005) * 0.15; scene.position.y Math.sin(Date.now() * 0.001) * 0.03; // 说话时嘴巴缩放 if (isSpeaking) { const scaleY 1.4 Math.abs(Math.sin(Date.now() * 0.02)) * 1.2; mouth.scale.y scaleY; } else { mouth.scale.y 1.0; } renderer.render(scene, camera); } function connectWebSocket() { const ws new WebSocket(wsUrl); ws.onopen () statusEl.textContent 已连接; ws.onmessage (evt) { const msg JSON.parse(evt.data); if (msg.type speak) { setCaption(msg.text || ); const audio new Audio(msg.audio_url); audio.play(); setSpeaking(true); audio.onended () setSpeaking(false); } else if (msg.type detect) { if (msg.objects msg.objects.length 0) { const labels msg.objects.map(o o.label).join(、); setCaption(我看到了${labels}); } } }; ws.onclose () { statusEl.textContent 连接断开正在重连...; setTimeout(connectWebSocket, 3000); }; } window.addEventListener(resize, () { camera.aspect window.innerWidth / window.innerHeight; camera.updateProjectionMatrix(); renderer.setSize(window.innerWidth, window.innerHeight); }); init(); /script /body /html这段代码的核心在于mouth.scale.y随Date.now()变化产生类似嘴张合的效果。实际项目中当然有更精细的口型同步方案可以按音素驱动但作为数字人第一版“有口型”比“精准口型”重要得多。先把交互跑通再去优化细节。Three.js的本体需要下载到本地。可以从Three.js官网下载three.min.js放到static/three.min.js。这样即使树莓派没联网浏览器也能正常渲染。5.3 手机和电脑都能访问的通信协议前后端通信我用的是WebSocket消息格式是JSON。目前定义了三种消息typedata字段方向用途speaktext, audio_url后端到前端推送要说的文本和音频地址detectobjects后端到前端推送视觉检测结果hellomessage双向连接测试后端用FastAPI实现代码如下from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.staticfiles import StaticFiles import uvicorn app FastAPI() app.mount(/static, StaticFiles(directorystatic), namestatic) class ConnectionManager: def __init__(self): self.active_connections [] async def connect(self, websocket): await websocket.accept() self.active_connections.append(websocket) def disconnect(self, websocket): if websocket in self.active_connections: self.active_connections.remove(websocket) async def broadcast(self, message: dict): for conn in self.active_connections: try: await conn.send_json(message) except Exception: await self.disconnect(conn) manager ConnectionManager() app.websocket(/ws) async def websocket_endpoint(websocket: WebSocket): await manager.connect(websocket) try: while True: data await websocket.receive_json() # 目前前端主要是接收消息后续可以在这里处理前端发来的语音/文本 await websocket.send_json({type: hello, message: pong}) except WebSocketDisconnect: manager.disconnect(websocket) if __name__ __main__: uvicorn.run(app, host0.0.0.0, port8000)6. 整机联调、性能实测与常见问题排查6.1 完整工程结构与会话流程到了联调这一步代码文件已经不少了我按模块拆开组织这样出问题容易定位。我的工程目录长这样ai-digital-human/ ├── main.py # FastAPI入口启动服务 ├── vision.py # HailoYOLO推理类和后处理 ├── voice.py # ASR TTS封装 ├── llm.py # Ollama/API封装 ├── interaction.py # 主循环视觉检测 决策 ├── static/ │ ├── index.html # 3D数字人页面 │ ├── three.min.js │ └── audio/ # 生成的音频文件 ├── models/ │ ├── yolov5s.hef │ ├── zh_CN-huayan-medium.onnx │ └── zh_CN-huayan-medium.onnx.json └── venv/启动顺序是这样的先启动FastAPI服务再启动一个后台线程跑视觉检测主循环浏览器打开树莓派IP地址就能看到数字人。这时候我建议先不要急着说语音用一个纯文本的问题做测试把“视觉检测 大模型 TTS WebSocket推送”整条链路打通再加录音识别。interaction.py的主循环逻辑可以简化成import time from vision import HailoYOLO, parse_yolo_output from llm import ask_llm from voice import tts_speak yolo HailoYOLO(models/yolov5s.hef) COCO_LABELS [person, bicycle, car, cat, dog, cup, bottle, ...] def build_prompt(objects, question): names [COCO_LABELS[o[label]] for o in objects] return f你是数字人。你看到{names}。用户问{question}。请用20字内回答。 def interaction_loop(get_frame, manager): last_prompt while True: frame get_frame() output yolo.infer(frame) objects parse_yolo_output(output) if objects: prompt build_prompt(objects[:3], 介绍一下你看到的东西) else: time.sleep(0.1) continue if abs(len(prompt) - len(last_prompt)) 3 and time.time() - last_time 5: # 简单的去重逻辑避免同一场景反复上报 continue last_prompt prompt answer ask_llm(prompt, use_apiFalse) audio_path static/audio/response.wav tts_speak(answer, output_pathaudio_path) asyncio.run(manager.broadcast({ type: speak, text: answer, audio_url: /static/audio/response.wav, })) time.sleep(3)这套循环会不断拿摄像头画面去推理一旦检测到物体就触发大模型回答。实际项目中要去重不然检测到一只猫会连续说话说个不停。限流和冷却时间是必须的。6.2 端到端延迟实测与优化思路我把几个关键环节的实际耗时列出来方便你心里有底环节耗时说明摄像头采集 Hailo推理20-30ms640x640输入稳定在30 FPS左右YOLO后处理 NMS8-15ms树莓派CPU处理候选框多时会偏高faster-whisper识别5秒音频4-7秒base模型int8量化本地大模型生成20字回答2-5秒qwen2.5:1.5b云端API生成20字回答0.5-1秒取决于网络piper生成20字语音100-300ms基本无感如果走完整语音链路一次交互要10秒左右体验偏慢。所以我实际演示时通常开“视觉自动触发”模式摄像头看到人就主动介绍不需要录音这样端到端延迟大概就是“推理 LLM TTS”约3到5秒比较舒服。如果要语音对话建议用云端API把LLM那一环的延迟压下去或者接受本地小模型的慢速。6.3 踩坑速查表这几条都是我在部署过程中真实踩过的坑单独列出来希望能帮你省下查资料的时间问题可能原因解决方法lspci看不到Hailo设备没启用PCIe / 驱动没装 / 系统不是arm64检查config.txt的dtparampciex1_gen3确认装的是64位系统HailoRT加载HEF报版本不兼容HailoRT驱动版本和HEF编译版本不匹配升级hailo_platform到与驱动一致或重新编译HEF摄像头画面黑屏libcamera服务异常 / 代码用错接口先跑libcamera-hello验证摄像头再用picamera2OpenCV的VideoCapture读不到帧OpenCV没有走GStreamer管道不要用cv2.VideoCapture(0)改用picamera2piper生成中文是乱码模型文件或配置缺失确认.onnx和.json在同一个目录且文件名完全匹配浏览器连不上WebSocketuvicorn绑定地址不对启动时用host0.0.0.0浏览器访问树莓派局域网IP回复太长TTS念半天大模型没有遵守字数限制prompt里加“20字以内”并在代码里截断回答树莓派频繁重启供电不足换5V/5A官方电源减少外设功耗Hailo设备温度过高散热不到位加装主动散热风扇确认转接板上有导热硅贴有一个细节要特别提醒树莓派5的PCIe接口和M.2 HAT的排线连接是整个硬件链路里最容易出错的地方。排线插反或者没插到底轻则设备找不到重则烧坏接口。安装时多看几遍转接板说明书的图示确认金色触点方向正确。整机跑起来之后你会看到摄像头画面里出现检测框“人”“杯子”这些标签出现在屏幕上浏览器里的3D小人开始张嘴说话。那一声“你好我是你的桌面数字人”从音箱里传出来的时候还是很有成就感的。我在实际项目里最大的体会是“别急着合体”。先把每个模块单独验证一遍摄像头能出图、Hailo能跑模型、Ollama能回话、piper能出声再开始写主循环。这样出了问题你知道该查哪一块而不是在一堆报错里抓瞎。还有一个经验是把视觉检测和WebSocket推送做成独立线程以后整机稳定性会提升一个档次因为大模型回答慢的时候不会卡住摄像头采集。后面如果你想继续扩展可以试试把ASR也挪到浏览器里用MediaRecorder采集用户语音再实时传回后端或者把数字人形象换成你自己的模型这些都是在现有框架上加增量。
返回列表