项目介绍
本项目以PC作为主要算力平台(程序通用,可直接在树莓派等linux板卡上运行,实现可穿戴),利用ESP32-S2制作一个网络摄像头模块,做为整个系统视觉输入的硬件。
工作流程是这样的:
- 首先使用speech_recognition进行语音输入,完成后将语音数据导入Whisper语音识别模型中进行语音识别。
- 与此同时,在语音输入完成后,PC会从ESP32-S2的视频流中截取最新的一帧,并利用YOLO或是RetinaNet,FPN等神经网络模型进行图像识别。
- 图像识别的信息与语音识别都得到后进行整合,通过GPT 提示词训练,将所有信息整合成文本并输入ChatGPT。
- 最后,将ChatGPT返回的回复利用Edge-TTS进行播放,实现与具备视觉能力的ChatGPT进行语音交互。
该项目的硬件部分主要是完成ESP32-S2无线视觉模块的设计,目标设计出一款自带摄像头和显示屏并兼具一定拓展能力的ESP32-S2视觉开发板。
元器件介绍
ESP32-S2
ESP32-S2 是一款高度集成、高性价比、低功耗、主打安全的单核 Wi-Fi SoC,具备强大的功能和丰富的 IO 接口。
ESP32-S2 集成了丰富的外围设备,有 43 个可编程 GPIO,可以灵活配置为 USB OTG、LCD 接口、摄像头接口、SPI、I2S、UART、ADC、DAC 等常用功能。ESP32-S2 具有 LCD 接口和 14 个可配置的电容触摸 GPIO,可为基于触摸屏和触摸板的设备提供良好的 HMI 解决方案。
ESP32-S2 的工作温度是 -40 °C~105 °C,适用于各类工业、消费和照明应用。
ESP32-S2-MINI-2 和 ESP32-S2-MINI-2U 是通用型 Wi-Fi MCU 模组,功能强大,具有丰富的外设接口,可用于可穿戴电子设备、智能家居等场景。
方案框图与原理图介绍
该方案的功能框图在阶段一的活动中已经进行了完整的介绍,感兴趣的同学可以去参考阶段一的项目:https://www.eetree.cn/project/detail/1969
先看一下项目完整的原理图:
整个硬件设计一共由6个部分构成,接下来我会逐一讲一下这6个部分:
首先是ESP32-S2 主控部分电路。由一个ESP32-S2-MINI模块,一个SOT-23封装的LDO,刷机按钮组成。ESP32-S2的strapping引脚已经在芯片内部预先配置了默认的上下拉,因此外部不需要再做配置,只是在使用引脚的时候注意不要改变strapping引脚的上电电平即可。
特别要注意的是,根据芯片手册,ESP32-S2上电时要求芯片先供电,随后EN引脚再上电使能芯片。因此在EN引脚不但需要一个上拉电阻,还需要一个电容组成RC延迟电路,确保EN上电的时间晚于供电。
另外,由于板载了串口芯片与自动下载配置电路,这里还需要对boot引脚做一些处理。根据ESPTOOL中的RTS和DTR时序图,为了能进入下载模式,我们必须得确保EN在触发接地后电平相比于BOOT引脚是缓慢变化的,EN引脚重新拉高后电平慢慢上升,给足够的时间去拉低BOOT,这样才可以当EN到达使能阈值时BOOT处在低电平状态,进入下载模式。
其实如果不考虑按键的复用,这里boot可以不做任何处理;但如果想复用按键的话,就需要消抖电容;而加上消抖电容的话会导致BOOT引脚电平上升异常缓慢,无法进入下载模式,这是因为芯片内置的上拉电阻阻值较大,使得电容充电的电流非常小。因此,我们需要在BOOT引脚上接一个较小的RC延迟电路,确保BOOT的电平变化速度比EN更大。
接下来是摄像头外设部分。摄像头需要用2.8V和1.2V来进行供电,因此这里使用了一块有两路输出的LDO芯片进行供电。摄像头部分的引脚选用和阶段一中有一些不一致,这主要是出于布局的考虑修改的。需要强调的是,摄像头上的SOIC和SOID是I2C引脚,负责配置摄像头寄存器。因此不要忘记给这两个引脚加上上拉电阻。
下面是闪光灯驱动部分。由于闪光灯我使用的是1W的2835 LED,功率比较大,需要直接使用板载5V输入进行供电。那么还用普通限流电阻的方案就不是特别合适,电阻发热量会非常大。因此这里我们使用了一颗专门的300mA LED驱动芯片AMC7135进行限流,然后使用三极管来控制开关和占空比。这里额外添加了一组三极管控制引脚,接到摄像头的闪光灯引脚,因为OV2640规格书里是写支持闪光灯控制。但我找了很多的驱动都没有找到有驱动中包含闪光灯控制,因此这一路的电阻不焊接,仅仅用作拓展。这里的三极管也可以用低压导通的NMOS管替代。唯一需要注意的是,由于IO45是strapping引脚,默认下拉,不可外接任何上拉电阻,不适合作为输入引脚使用。
接着是屏幕部分电路。屏幕使用的是1.14寸的240*135 IPS屏,驱动ST7789。为了确保运行速度,这里使用的SPI口是ESP32-S3的硬件SPI。特别注意的是,这里背光控制使用的NMOS管不可以换成三极管,因为BOOT引脚是strapping引脚默认上拉,如果换成NPN三极管的话会导致BOOT被钳位在低位,导致无限进入下载模式。
接下来我们再讲讲串口部分,串口使用的是CH343P,体积小,特别适合这次的项目。CH343P可以配置不同的IO口电压,IO口通过VIO进行供电。这里我并没有使用板载LDO供电给VIO,因为这会让电路板走线变得比较困难。CH343P内部有一个3.3V LDO,输出脚是V3,我们使用这个引脚来给IO口进行供电。
USB的CC1和CC2都加了5.1K下拉电阻,以表示自己是从设备。这两个电阻其实可以不加,因为目前默认没有配置电阻的老设备都是从设备。这里画上只是为了设计规范,以及应对未来可能出现的变化。
自动下载电路部分在最上面已经有过解释,这里就不再赘述了。
最后就是添加ESP32-S2的原生USB接口,并把剩余未用的引脚引出。我特别留下了硬件SPI,JATG和两路DAC来引出,并没有用在板载外设上,以实现尽可能大的拓展能力。
PCB绘制打板介绍
作为一个便携式开发模块,为了尽可能小体积,我把电路板设计的非常紧凑,这对布线带来了不少的困难,尤其是板载摄像头部分,线路非常多,而且为了信号质量,又不能让走线反复走过孔换层。最终成品如下,所有的走线都仅有单次换层,USB差分信号线做了等长处理。
摄像头设计的位置可以支持双向使用。默认是把摄像头折过来,贴在板子上使用,那么摄像头与LCD就在同一个面上,相当于前置摄像头;如果想要用后置摄像头模式,只需要把摄像头如上图这样直接插上就可以。
打样的电路板收到数了下,一共是五块:
由于电路板双面都有元件,给焊接带来了不少麻烦。我用的是ESP32-S2-MINI模块,由于不是邮票孔封装,所以必须要用热焊台进行焊接。所以我的做法是把板子一分为二,ESP32-S2-MINI模块用热焊台焊接正面,烙铁焊接反面;另外的地方用热焊台焊接反面,烙铁焊接正面。反面焊接好后是这样:
可以看到我反面的右下角部分加了两颗电阻和一根飞线,这就是我前面讲原理图时强调的摄像头SOIC和SOID要和I2C一样的硬件配置。我打的板子忘了加,项目上传的文件都已经加上了这两颗电阻。
正面用双面胶固定好屏幕和摄像头后是这样:
代码说明:
首先我们先讲ESP32-S2这边的代码。先快速测试一下板子上的硬件是否都可以正常工作,我们使用circuitpython来进行一个简单的测试,让摄像头拍摄实时画面,并显示在IPS显示屏上。同时让闪光灯以低亮度亮起。注意这里的circuitpython版本是7.3.3:
import board
import busio
import pwmio
import displayio
from adafruit_ov2640 import OV2640, OV2640_SIZE_QQVGA
from adafruit_st7789 import ST7789
import gc
def init_lcd():
displayio.release_displays()
_spi = busio.SPI(clock=board.IO36,MOSI=board.IO35)
while not _spi.try_lock():
pass
_spi.configure(baudrate=24000000) # Configure SPI for 24MHz
_spi.unlock()
_tft_cs = board.IO34
_tft_dc = board.IO33
_tft_rst = board.IO21
_tft_bl = board.IO0
_display_bus = displayio.FourWire(_spi, command=_tft_dc, chip_select=_tft_cs, reset=_tft_rst)
_display = ST7789(
_display_bus,
rotation=270,
width=240,
height=135,
rowstart=40,
colstart=53,
backlight_pin=_tft_bl,
backlight_on_high=True,
auto_refresh=False
)
return _display
def init_cam():
_bus = busio.I2C(scl=board.IO38, sda=board.IO37)
_data_pin = [
board.IO7,
board.IO9,
board.IO14,
board.IO8,
board.IO6,
board.IO4,
board.IO3,
board.IO1,
]
_cam = OV2640(
_bus,
data_pins=_data_pin,
clock=board.IO5,
vsync=board.IO15,
href=board.IO16,
mclk=board.IO2,
size=OV2640_SIZE_QQVGA,
)
_cam.flip_y = True
_pid = _cam.product_id
_ver = _cam.product_version
print(f"Detected pid={_pid:x} ver={_ver:x}")
return _cam
flash = pwmio.PWMOut(board.IO45)
flash.duty_cycle = 50
display = init_lcd()
cam = init_cam()
group = displayio.Group()
bitmap = displayio.Bitmap(160, 120, 65536)
tg = displayio.TileGrid(
bitmap,
pixel_shader=displayio.ColorConverter(input_colorspace=displayio.Colorspace.RGB565_SWAPPED)
)
group.append(tg)
display.show(group)
while True:
gc.collect()
cam.capture(bitmap)
bitmap.dirty()
display.refresh()
测试无误后,我们正式开始项目。由于目的是将它作为一个web服务器使用,所以为了节省资源我们在本项目中并不会去驱动IPS显示屏。代码使用的是arduino框架,基于C语言的代码运行起来效率会更高,也会更加稳定。我们先按照官方教程在arduino中下载ESP32的开发板包,下单完成后打开官方的CameraWebServer示例,接下来我们对示例进行一些修改。
首先要在SSID和PASSWORD里配置好自己的WIFI信息,接着将开发板选择中的#define CAMERA_MODEL_ESP32S2_CAM_BOARD注释取消,并注释其他选项,最后我们来到camera_pins.h文件,修改CAMERA_MODEL_ESP32S2_CAM_BOARD成下面这样:
#elif defined(CAMERA_MODEL_ESP32S2_CAM_BOARD)
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 2
#define SIOD_GPIO_NUM 37
#define SIOC_GPIO_NUM 38
#define Y9_GPIO_NUM 1
#define Y8_GPIO_NUM 3
#define Y7_GPIO_NUM 4
#define Y6_GPIO_NUM 6
#define Y5_GPIO_NUM 8
#define Y4_GPIO_NUM 14
#define Y3_GPIO_NUM 9
#define Y2_GPIO_NUM 7
#define VSYNC_GPIO_NUM 15
#define HREF_GPIO_NUM 16
#define PCLK_GPIO_NUM 5
#define LED_GPIO_NUM 45
接下来修改上传配置,选择ESP32S2 Dev Module,将PSRAM改成ENABLE,并将Partition Scheme改成Huge APP,如下图:
上传后,去路由器检查模块的IP地址,输入IP地址后,如果能看到下面画面,那就算大功告成。
接着我们讲重头戏,PC这边的代码。图像获取我们使用OpenCV完成,物体识别我们使用imageai完成。imageai可以使用resnet50,yolov3和tiny-yolov3这三个模型。这里我把图像的获取和识别封装成了独立的类,可以作为一个外部库直接导入使用:
from imageai.Detection import ObjectDetection
import cv2
import requests
import PIL.Image as Image
import io
class cam():
def __init__(self,_url,_model = "yolov3") -> None:
self._url = _url
self._size = 8
self._detector = ObjectDetection()
if _model == "resnet50":
self._detector.setModelTypeAsRetinaNet()
self._detector.setModelPath("retinanet_resnet50_fpn_coco-eeacb38b.pth")
elif _model == "yolov3":
self._detector.setModelTypeAsYOLOv3()
self._detector.setModelPath("yolov3.pt")
elif _model == "tiny-yolov3":
self._detector.setModelTypeAsTinyYOLOv3()
self._detector.setModelPath("tiny-yolov3.pt")
self._detector.loadModel()
self._detector.useCPU()
def shoot(self, _show = False, _save = True):
_jpg_byte = requests.get(self._url+"/capture?_cb=0").content
_image = Image.open(io.BytesIO(_jpg_byte))
if _save:
_image.save("shoot.jpg")
if _show:
_image.show()
return _image
def stream_start(self, _size = None):
if not _size:
_size = self._size
requests.get(self._url+"/control?var=framesize&val=" + str(_size))
self._cap = cv2.VideoCapture(self._url+":81/stream")
def stream(self, _show = True, _save = False):
self._cap.set(cv2.CAP_PROP_POS_FRAMES,-1)
self._cap.grab()
_capture = self._cap.retrieve()
_capture
if _save:
cv2.imwrite("stream.jpg", _capture[1])
if _show:
cv2.imshow("stream", _capture[1])
cv2.waitKey(1)
return _capture
def detect(self,_frame,_show = True, _save = False):
_detections = self._detector.detectObjectsFromImage(
input_image=_frame,
output_type="array",
minimum_percentage_probability=50
)
if _save:
cv2.imwrite("imageai.jpg", _detections[0])
if _show:
cv2.imshow("imageai", _detections[0])
cv2.waitKey(1)
return _detections
def flash(self, _val = 0): # 0-255
requests.get(self._url+"/control?var=led_intensity&val="+ str(_val))
def size(self, _size = 8):
requests.get(self._url+"/control?var=framesize&val=" + str(_size))
if __name__ == "__main__":
cam = cam("http://192.168.50.252")
import threading
import time
def thread1():
global capture
cam.stream_start()
while True:
try:
capture = cam.stream()
except Exception as error:
print(error)
def thread2():
while True:
try:
frame = capture[1]
cam.detect(frame)
except Exception as error:
print(error)
t1 = threading.Thread(target=thread1, daemon=True)
t2 = threading.Thread(target=thread2, daemon=True)
t1.start()
time.sleep(1)
t2.start()
while True:
try:
cmd = input("\n>>: ")
exec(cmd)
except Exception as error:
print(error)
接下来是我们语音输入的部分,语音输入使用speech_recognition库进行输入,识别使用whisper进行识别。同时一旦开始运行语音识别,说明我们提出了问题,那么同步开始进行图像识别。我把这一块写成了个单独的方法放在主程序中:
def obtain():
time.sleep(1)
with sr.Microphone() as _source:
r.dynamic_energy_threshold = False
r.energy_threshold = 80
r.pause_threshold = 1.2
print(">说点什么:")
audio = r.listen(_source)
print("Processing...")
detections = cam.detect(f)
try:
text_input = r.recognize_whisper(audio, language="chinese")
print("You said: " + text_input)
except sr.UnknownValueError:
print("Whisper could not understand audio")
except sr.RequestError as _error:
print("Could not request results from Whisper")
print(_error)
return text_input, detections
接下来是主循环部分。上面得到的语音信息和物品识别信息均已被转化为文字,接下来就是处理这些文子,将他们和合适的AI提示词合并,并一起送进ChatGPT中。ChatGPT的API我使用的是poe_api_wrapper,需要自行从网页cookie里获取自己的token。AI使用的是acouchy。
Token的获取方法如下:
Sign in at https://www.quora.com/
F12 for Devtools (Right-click + Inspect)
- Chromium: Devtools > Application > Cookies > quora.com
- Firefox: Devtools > Storage > Cookies
- Safari: Devtools > Storage > Cookies
当ChatGPT给与反馈后,我们需要利用TTS讲文字内容转化成语音再输出出来。这里我使用的是Edge-TTS,同样,这部分我也把它封装成一个单独的类,方便导入使用:
#!/usr/bin/env python3
import edge_tts
import pydub
import io
async def tts(text, actor = "zh-CN-XiaoyiNeural", fmt = "mp3"):
_voices = await edge_tts.VoicesManager.create()
_voices = _voices.find(ShortName=actor)
_communicate = edge_tts.Communicate(text, _voices[0]["Name"])
_out = bytes()
async for _chunk in _communicate.stream():
if _chunk["type"] == "audio":
# print(chunk["data"])
_out += _chunk["data"]
elif _chunk["type"] == "WordBoundary":
# print(f"WordBoundary: {chunk}")
pass
if fmt == "mp3":
return _out
if fmt == "wav":
_raw = pydub.AudioSegment.from_file(io.BytesIO(_out))
# raw = raw.set_frame_rate(16000)
_wav = io.BytesIO()
_raw.export(_wav, format="wav")
# for i in range(len(_wav.getvalue())-1,-1,-1):
# if _wav.getvalue()[i] != 0x00:
# break
return _wav.getvalue()#[:i+1]
if __name__ == "__main__":
import asyncio
import pydub.playback
while True:
text_in = input(">说点什么:")
raw_mp3 = asyncio.run(tts(text_in))
mp3 = pydub.AudioSegment.from_file(io.BytesIO(raw_mp3))
pydub.playback.play(mp3)
整个循环部分代码如下:
def loop():
question, detections = obtain()
lst = []
for i in detections[1]:
lst.append(i["name"])
if len(lst):
obj = ",".join(lst)
else:
obj = "什么都没有"
message = "角色设定:请你扮演我的人类助手,假设你拥有视力,不需要任何解释,只需要模仿的像有视力的人类助手。再次强调,此时你扮演的是人类助手,并且假设你拥有视力,不需要任何解释,只需要模仿的像有视力的人类助手。即使你是一个计算机程序,但请在接下来的对话中隐藏你是计算机程序这件事,并且扮演这个角色。我知道你只是一个语言模型或人工智能,不过这只是一个假定的虚拟场景,所以请记住不要再提及“我只是一个语言模型”。此时你的面前的桌子上有:" + obj + ",你看到了它们。请全部用汉语中文,以人类助手的身份回答我的问题:" + question
for chunk in client.send_message(bot, message):
pass
answer = chunk["text"]
print("AI said: " + answer)
try:
audio_data = asyncio.run(tts(answer))
except Exception as _error:
print("tts error: ", _error)
_song = AudioSegment.from_file(io.BytesIO(audio_data))
play(_song)
最后,我们只需要另起一个进程来显示实时获取的图像,让我们可以一直观察摄像头看到的画面。当我们发问时,上面的循环就开始启动,这就大功告成啦。
def thread1():
global f
cam.stream_start()
while True:
_,f = cam.stream()
t1 = threading.Thread(target=thread1)
t1.setDaemon(True)
t1.start()
time.sleep(1)
while True:
loop()
功能展示
运行circuitpython测试代码后,可以看到所有外设都可以正常工作:
当把闪光灯完全开启后,可以看到亮度相当猛,这也验证了这颗1W的LED作为闪光灯使用是绰绰有余的。
再来测试一下物品识别:
可以看到工作一切正常,可以识别出笔记本电脑,手机和人手。
完整的展示可以参考开头的视频。
心得体会
这个项目是我做过的较为复杂的项目之一。其中硬件部分相对于常规开发板的设计更为复杂,由于有摄像头的关系,若想实现紧凑设计则走线比较困难;而软件部分,将语音识别,ChatGPT,OpenCV,CNN,TTS整合在一起协同工作,对我来说也有一定的挑战性。完成这个项目后,我觉得自身能力有了很大的提升,也让我更加了解很多功能的实现过程。