FastBond2阶段2——基于ESP32-S2和机器视觉打造钢铁侠的智能助手-贾维斯
硬件由ESP32-S2,摄像头,笔记本组成,结合web服务器,视频实时推流,CNN卷积神经网络,Whisper语音识别模型,ChatGPT以及edge-tts,实现给ChatGPT装上无线眼睛,并实现语音交互功能。
标签
PCB设计
AI
神经网络
机器视觉
FastBond第二季
GPT
web camera
StreakingJerry
更新2023-09-11
2152

项目介绍

本项目以PC作为主要算力平台(程序通用,可直接在树莓派等linux板卡上运行,实现可穿戴),利用ESP32-S2制作一个网络摄像头模块,做为整个系统视觉输入的硬件。

 

工作流程是这样的:

  1. 首先使用speech_recognition进行语音输入,完成后将语音数据导入Whisper语音识别模型中进行语音识别。
  2. 与此同时,在语音输入完成后,PC会从ESP32-S2的视频流中截取最新的一帧,并利用YOLO或是RetinaNet,FPN等神经网络模型进行图像识别。
  3. 图像识别的信息与语音识别都得到后进行整合,通过GPT 提示词训练,将所有信息整合成文本并输入ChatGPT。
  4. 最后,将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

 

先看一下项目完整的原理图:

FinR_-1Tl_Q62yI1s4JrwGqL1caZ

整个硬件设计一共由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更大。

FinyuJCz5a15zqqDeWJicvBXJ7Tg

 

接下来是摄像头外设部分。摄像头需要用2.8V和1.2V来进行供电,因此这里使用了一块有两路输出的LDO芯片进行供电。摄像头部分的引脚选用和阶段一中有一些不一致,这主要是出于布局的考虑修改的。需要强调的是,摄像头上的SOIC和SOID是I2C引脚,负责配置摄像头寄存器。因此不要忘记给这两个引脚加上上拉电阻。

Fn3ehPajHMQHsKuPIqBc9xZCJybK

 

下面是闪光灯驱动部分。由于闪光灯我使用的是1W的2835 LED,功率比较大,需要直接使用板载5V输入进行供电。那么还用普通限流电阻的方案就不是特别合适,电阻发热量会非常大。因此这里我们使用了一颗专门的300mA LED驱动芯片AMC7135进行限流,然后使用三极管来控制开关和占空比。这里额外添加了一组三极管控制引脚,接到摄像头的闪光灯引脚,因为OV2640规格书里是写支持闪光灯控制。但我找了很多的驱动都没有找到有驱动中包含闪光灯控制,因此这一路的电阻不焊接,仅仅用作拓展。这里的三极管也可以用低压导通的NMOS管替代。唯一需要注意的是,由于IO45是strapping引脚,默认下拉,不可外接任何上拉电阻,不适合作为输入引脚使用。

FsOLnqMuTqNouh0Qa5l3nj9dUocF

 

接着是屏幕部分电路。屏幕使用的是1.14寸的240*135 IPS屏,驱动ST7789。为了确保运行速度,这里使用的SPI口是ESP32-S3的硬件SPI。特别注意的是,这里背光控制使用的NMOS管不可以换成三极管,因为BOOT引脚是strapping引脚默认上拉,如果换成NPN三极管的话会导致BOOT被钳位在低位,导致无限进入下载模式。

FtVatit4lO8Qhi6pJ_3qBT8Iukc4

 

接下来我们再讲讲串口部分,串口使用的是CH343P,体积小,特别适合这次的项目。CH343P可以配置不同的IO口电压,IO口通过VIO进行供电。这里我并没有使用板载LDO供电给VIO,因为这会让电路板走线变得比较困难。CH343P内部有一个3.3V LDO,输出脚是V3,我们使用这个引脚来给IO口进行供电。

USB的CC1和CC2都加了5.1K下拉电阻,以表示自己是从设备。这两个电阻其实可以不加,因为目前默认没有配置电阻的老设备都是从设备。这里画上只是为了设计规范,以及应对未来可能出现的变化。

自动下载电路部分在最上面已经有过解释,这里就不再赘述了。

Fp66xOBokFvSjldiH_-RSlHi2FDV

 

最后就是添加ESP32-S2的原生USB接口,并把剩余未用的引脚引出。我特别留下了硬件SPI,JATG和两路DAC来引出,并没有用在板载外设上,以实现尽可能大的拓展能力。

FnxQ_3fVpG89dtXLjY0r_LvDi7GC

 

PCB绘制打板介绍

作为一个便携式开发模块,为了尽可能小体积,我把电路板设计的非常紧凑,这对布线带来了不少的困难,尤其是板载摄像头部分,线路非常多,而且为了信号质量,又不能让走线反复走过孔换层。最终成品如下,所有的走线都仅有单次换层,USB差分信号线做了等长处理。

FjzuizAsEr_ZoyBb8JkXmsourBqX

摄像头设计的位置可以支持双向使用。默认是把摄像头折过来,贴在板子上使用,那么摄像头与LCD就在同一个面上,相当于前置摄像头;如果想要用后置摄像头模式,只需要把摄像头如上图这样直接插上就可以。

打样的电路板收到数了下,一共是五块:

Fg8WCVGWl1v4ZLnc2-7WtyCoQmgI

由于电路板双面都有元件,给焊接带来了不少麻烦。我用的是ESP32-S2-MINI模块,由于不是邮票孔封装,所以必须要用热焊台进行焊接。所以我的做法是把板子一分为二,ESP32-S2-MINI模块用热焊台焊接正面,烙铁焊接反面;另外的地方用热焊台焊接反面,烙铁焊接正面。反面焊接好后是这样:

FsAmzJw3Wk9HaEVz5w8p2HcaeYnW

可以看到我反面的右下角部分加了两颗电阻和一根飞线,这就是我前面讲原理图时强调的摄像头SOIC和SOID要和I2C一样的硬件配置。我打的板子忘了加,项目上传的文件都已经加上了这两颗电阻。

正面用双面胶固定好屏幕和摄像头后是这样:

FgOO1sDj8UjVOVrRGbtQutmpp7Sz

 

代码说明:

首先我们先讲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,如下图:

FuuYUTbm1PUP01YXWBsPXD5oah_q

上传后,去路由器检查模块的IP地址,输入IP地址后,如果能看到下面画面,那就算大功告成。

FiQUdXv_6ph4UHLjw5zFXzy42yI6

 

接着我们讲重头戏,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测试代码后,可以看到所有外设都可以正常工作:

Fr_yf782HATkppij6yK3faRi5Mo0

当把闪光灯完全开启后,可以看到亮度相当猛,这也验证了这颗1W的LED作为闪光灯使用是绰绰有余的。

FgDkoKnc3thB48hvDBhR7H5zSdZ4

再来测试一下物品识别:

FkICouMGmkvfPoLDrl1qQPq5AiYVFr8Lx2Wf38UzYM4W9s_7hmSJCvE9

可以看到工作一切正常,可以识别出笔记本电脑,手机和人手。

完整的展示可以参考开头的视频。

 

心得体会

这个项目是我做过的较为复杂的项目之一。其中硬件部分相对于常规开发板的设计更为复杂,由于有摄像头的关系,若想实现紧凑设计则走线比较困难;而软件部分,将语音识别,ChatGPT,OpenCV,CNN,TTS整合在一起协同工作,对我来说也有一定的挑战性。完成这个项目后,我觉得自身能力有了很大的提升,也让我更加了解很多功能的实现过程。

 

附件下载
ESP32-S2-CAM-LCD.7z
团队介绍
个人
评论
0 / 100
查看更多
目录
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2023 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号