基于机器视觉的猫粮碗自动检测系统
该项目使用了Xiao S3,实现了基于机器视觉的猫粮碗自动检测系统的设计,它的主要功能为:未用任何机器学习模型的情况下,通过机器视觉检测猫粮碗中的食物,当食物不足时邮件提醒。
标签
摄像头
ESP32S3
树莓派5
xiao
小熊熊
更新2024-03-28
308

项目简介

本项目使用了Xiao S3,实现了基于机器视觉的猫粮碗自动检测系统的设计,它的主要功能为:未用任何机器学习模型的情况下,通过机器视觉检测猫粮碗中的食物,当食物不足时发邮件进行提醒。


hackster.io项目链接:https://www.hackster.io/gene6/automatic-detection-system-for-cat-food-bowls-based-on-machi-ac3892


设计思路

类似的效果的项目在网上已有很多现成的项目,无外乎都是拍一些有猫粮和无猫粮的照片,打好标签后使用RNN来进行训练,以此完成识别。因此这次虽然实现的东西是一样的,但是想尝试一些不一样的方法。

我计划基于颜色识别来进行判断:我们可以把摄像头固定在猫粮碗的上方,由于猫粮碗的颜色与猫粮的颜色有显著差异,我们可以通过对摄像头拍摄画面中的颜色分布进行分析,如果猫粮的颜色范围占比较大,说明猫粮碗中的猫粮还足够;而如果白色附近颜色占比过大,说明猫粮已经差不多吃完了,拍摄照片大部分是碗底。由于家里现在没有猫粮,因此我先用绿豆来代替。

image.png这样看来,这个程序似乎计算量不是太大,可以直接在ESP32S3上运行。但我尝试了一下,发现在Circuitpython上运行会有诸多的问题,但也勉强可以运行。因此我又尝试了仅仅将xiao S3作为IP摄像头模块,把视频推流到树莓派5,由树莓派5负责数据计算。


开发板介绍

Seeed Studio XIAO系列是小型开发板,共享类似的硬件结构,尺寸实际上是拇指大小。这里的“XIAO”代表它的尺寸特征“小“,另一层意思是指它的功能强大“骁”。 Seeed Studio XIAO ESP32S3 Sense集成了摄像头传感器、数字麦克风和SD卡支持。结合嵌入式ML计算能力和摄影能力,这款开发板可以成为您开始使用智能语音和视觉AI的绝佳工具。

Fp9ScMn1vK2O9Fjl_9f1H7CdwiPr

Seeed Studio XIAO ESP32S3 Sense 采用高集成度的 Xtensa 处理器 ESP32-S3R8 SoC,支持 2.4GHz WiFi 和低功耗蓝牙® BLE 5.0 双模,适用于多种无线应用。它具有锂电池充电管理功能。

作为 Seeed Studio XIAO ESP32S3 的高级版本,该电路板配备了一个插入式 OV2640 摄像头传感器,可显示 1600*1200 的全分辨率。它的底座甚至兼容 OV5640,支持高达 2592*1944 的分辨率。电路板还配有数字麦克风,用于语音感应和音频识别。SenseCraft AI 可为XIAO ESP32S3 Sense 提供各种预训练的人工智能(AI)模型和无代码部署。

该开发板拥有功能强大的 SoC 和内置传感器,芯片上有 8MB PSRAM 和 8MB FLASH,另外还有一个 SD 卡插槽,可支持高达 32GB 的 FAT 存储空间。这些都为开发板提供了更大的编程空间,为嵌入式 ML 应用场景带来了更多可能性。


实现方法

首先我尝试直接在xiao s3上完成全部步骤,幸运的是Circuitpython上支持ulab.numpy,这可以在数据处理上带来极大的便利。Circuitpython使用的是ESP32-S3-DevKitC-1-N8R8的固件,固件自带了ulab和espcamera库。我们可以利用该库来初始化摄像头。需要注意的是,摄像头捕捉到的画面buffer需要专门的内存空间来存放,因此我们需要在settings.toml中添加CIRCUITPY_RESERVED_PSRAM=1048576,来存放buffer。具体分配的大小要根据捕捉的画面大小以及framebuffer_count来修改。

def init_cam():
_i2c = busio.I2C(scl=board.IO39, sda=board.IO40)
_data_pin = [
board.IO15,
board.IO17,
board.IO18,
board.IO16,
board.IO14,
board.IO12,
board.IO11,
board.IO48
]
_cam = espcamera.Camera(
data_pins=_data_pin,
pixel_clock_pin=board.IO13,
vsync_pin=board.IO38,
href_pin=board.IO47,
i2c=_i2c,
external_clock_pin=board.IO10,
# external_clock_frequency=20_000_000,
# powerdown_pin=None,
# reset_pin=None,
pixel_format=espcamera.PixelFormat.RGB565,
frame_size=espcamera.FrameSize.SVGA,
# jpeg_quality = 5,
framebuffer_count = 1,
# grab_mode=espcamera.GrabMode.WHEN_EMPTY
)
_cam.vflip = False
_cam.hmirror = True
return _cam


通过espcamera.Camera方法拍到的bitmap照片也可以进行像素便利和位运算得到RGB颜色,但糟糕的是ulab.numpy中的array并不支持三维数组,最高仅支持到二维,因此这里我们需要把二维的像素坐标压成一维,剩下一维用来保存RGB信息。espcamera捕获到的bitmap原始颜色是16bit的RGB565,这里我们还需要把它转化为24bit的RGB888,方便后续对颜色的计算。

def color(rgb565):
high = rgb565 >> 8
low = rgb565 & 0xFF
rgb565 = (low << 8) | high
R5 = rgb565 >> 11
G6 = (rgb565 >> 5) & 0B111111
B5 = rgb565 & 0B11111
R8 = ( R5 * 527 + 23 ) >> 6
G8 = ( G6 * 259 + 33 ) >> 6
B8 = ( B5 * 527 + 23 ) >> 6
RGB8 = [R8, G8, B8]
return RGB8

def get_array(bitmap):
image_array = np.zeros((bitmap.height*bitmap.width, 3), dtype=np.uint8)
for y in range(bitmap.height):
for x in range(bitmap.width):
image_array[x*bitmap.height+y] = color(bitmap[x,y])
print("\r" + "progress: " + str(int(y * 100 / (bitmap.height - 1))) + "%" + " " * 3, end="")
print("")
return image_array


得到常规的np数组后,我们就可以计算每个像素的颜色与给定的目标颜色的差异了。这里我使用欧几里得距离来衡量颜色之间的差异,计算完差异后,我们可以根据预设阈值,差异高于该阈值的认为是不同颜色,差异低于该阈值的认为是相同颜色。

def calculate(image_array, target_color, tolerance):
# Calculate vector norm
color_diff = np.linalg.norm(image_array - target_color, axis=1)
# Calculate the number of similar pixels
similar_pixels = np.sum(color_diff < tolerance)
# Calculate the proportion
total_pixels = image_array.shape[0]
proportion = similar_pixels / total_pixels
return proportion


但要使用上述方法,我们还需要知道目标颜色和阈值是多少。由于每个摄像头都会有所差异,阴影光线也会影响到传感器颜色的读取。这里我们不能直接按理论来取颜色,而应该选用这个摄像头在实际工作情况下得到的颜色。这里我的方法是拍摄一张只有猫粮的图像,然后计算该图像中平均颜色,以及颜色标准差的欧几里得距离,平均颜色可以作为目标颜色,而标准差的欧几里得距离可以作为阈值的参考。

def get_params(image_array):
avg_color = np.mean(image_array, axis=0)
threshold = np.linalg.norm(np.std(image_array, axis=0))
gc.collect()
print(avg_color, threshold)


那么接下来我们只需要先运行get_params()方法,得到参数后,填入calculate()方法,循环运行即可。


但是实际使用下来遇到了几个问题,首先是数据处理在circuitpython上实在是太慢了,在1280x1024分辨率下,处理一个数组为np.array就需要耗费超过5分钟的时间,导致代码编写和测试进行的异常困难;而致命的问题是,数据处理对于内存的消耗比较高,xiao_s3的psram仅有8M,即使是处理800*600的图像,内存也完全不足以支持欧几里得距离的运算:

image.png

而当我们把FrameSize设置成默认的QQVGA,也就是160*120时,虽然程序可以正常运行,但由于清晰度过低,导致碗里有食物时和无食物时的颜色差异不够显著,很容易出现误判。


因此,我不得不使用另一种方法来完成项目,让xiao s3仅作为一个IP摄像头,计算的任务交给树莓派5来完成。

首先,我们根据官方wiki教程添加好arduino的支持,然后打开arduino的官网例程CameraWebServer。

ESP32-CAM Video Streaming and Face Recognition with Arduino IDE | Random  Nerd Tutorials

接着有几个地方需要修改,首先uncomment #define CAMERA_MODEL_XIAO_ESP32S3 // Has PSRAM,comment其他所有model,接着修改WiFi credentials。官方WIKI上的代码在setup中还增加了一行while(!Serial);切记一定要把它注释掉,否则在没有连接串口时程序不会允许。


修改完成后就可以上传了,上传时注意默认配置并没有开启psram,我们需要自己吧psram选项改为OSPI.

上传后观察串口,如果一切正常,串口会打印出摄像头的ip,我们在电脑上输入,就可以看到摄像头控制面板。

Unable to get ESP IP when doing the example ESP32 CAM project " CameraWebServer" - #23 by ZX80 - Programming Questions - Arduino Forum

How to configure and use ESP32-CAM with Arduino IDE and Linux | olimex


接下来是树莓派部分。跟上面circuitpython的方法完全一样,只有一些细微的差别,比如说我们可以用request来获取最新帧,用pillow库转换为图像,再用numpy来处理计算。这里我就不再赘述了。

def find_color_proportion(image_array, target_color, tolerance):
# Calculate vector norm
color_diff = np.linalg.norm(image_array - target_color, axis=2)
# Calculate the number of similar pixels
similar_pixels = np.sum(color_diff < tolerance)
# Calculate the proportion
total_pixels = image_array.shape[0] * image_array.shape[1]
proportion = similar_pixels / total_pixels
return proportion

def get_params(image_array):
avg_color = np.mean(image_array, axis=(0, 1))
std_color = np.std(image_array, axis=(0, 1))
threshold = np.linalg.norm(std_color)
print(avg_color, threshold)

image_array = np.array(Image.open(io.BytesIO(requests.get("http://192.168.1.91/capture?_cb=0").content)))
# get_params(image_array)
proportion = find_color_proportion(image_array, [25, 29, 21], 30)
print("Color Proportion:", proportion)


经过测试,现在可以对猫粮碗空和满进行一个很好的区分,我们把proportion的阈值设为20%,如果低于20%时,需要发邮件提醒主人。接下来我们需要实现stmp邮件发送。

host = "smtp.qq.com"
port = 465
sender = "xxxxxxxxxxxx@qq.com"
app_password = "xxxxxxxxxxx"
receiver = sender

def send_mail(msg):
content = MIMEMultipart()
content["subject"] = "cat food"
content["from"] = sender
content["to"] = receiver
content.attach(MIMEText(msg))
with smtplib.SMTP_SSL(host=host, port=port) as smtp:
try:
smtp.login(sender, app_password)
smtp.send_message(content)
print("Email Sent!")
except Exception as error:
print("Error: ", error)


这里只要任何支持smtp的邮箱都可以。下面,写好主循环函数,项目就可以跑起来了。

while True:
image_array = np.array(Image.open(io.BytesIO(requests.get("http://192.168.1.91/capture?_cb=0").content)))
# get_params(image_array)
proportion = find_color_proportion(image_array, [25, 29, 21], 30)
print("Color Proportion:", proportion)
if proportion < 0.20:
send_mail("Cat Food Bowl is EMPTY.")
time.sleep(10)


效果展示

可以看到在猫粮碗是满的的时候,输出的proportion为60%左右;而当猫粮碗空的时候,输出的proportion为0%。

image.png

image.pngimage.png

image.png

 

感想

这次的活动让我有机会去完全换一种新的方法去实现一个网络上常见的项目,在ESP32S3上使用numpy跑数据科学类的任务也是一种很新奇的体验。

附件下载
rpi.py
adafruit-circuitpython-espressif_esp32s3_devkitc_1_n8r8-en_US-8.2.10.bin
cpy.py
settings.toml
团队介绍
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号