ARTICLE DETAIL

资讯详情

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

ESP32-CAM+Gemini API:自制智能垃圾分类垃圾桶实战

ESP32-CAM+Gemini API:自制智能垃圾分类垃圾桶实战 我家的垃圾桶一直是个纠结点每次丢垃圾都要在手机里查“这玩意到底是可回收还是厨余”查完还要纠结一轮。后来我手头刚好剩下一块ESP32-CAM就动了心思——能不能把它装在垃圾桶上方配合Google的Gemini大模型做一次实时识别让垃圾桶自己判断手里拿的是什么然后给出分类结果甚至自动分拣这个项目最终跑通了识别一次大概3到6秒对常见的果蔬、塑料瓶、纸张、电池等生活垃圾判断得还挺稳。整套方案的关键就一句话ESP32-CAM负责拍照Gemini API负责“看懂”照片舵机负责执行分类动作。这篇文章把从硬件搭建、API接入到整机联动的完整过程都写下来适合想做智能家居小项目、刚接触ESP32-CAM或者想试试“大模型嵌入式硬件”这种玩法的朋友参考。1. 系统思路一块摄像头开发板为什么敢接云端大模型1.1 三个环节拆解这个智能垃圾桶本质上是一个“感知—推理—执行”的闭环。感知层用ESP32-CAM自带的OV2640摄像头拍照推理层把图片上传到Gemini API让大模型识别垃圾类型执行层用舵机翻开对应分类的隔板或者点亮不同颜色的指示灯。很多人的第一反应是一块几十块钱的开发板跑得动AI模型吗答案是跑不动也不需要跑。Gemini这类云端大模型负责的只是“看图说话”ESP32-CAM要做的只是把一张JPEG图片通过Wi-Fi发出去再解析返回的JSON结果。开发板真正消耗资源的地方反而是拍照、Base64编码和网络请求这三个看起来不起眼的环节。这个分工其实特别像“点菜”的场景ESP32-CAM是传菜的店员负责拍菜单、送厨房Gemini是大厨负责辨认食材并给出做法舵机是上菜的服务生按大厨的指示把菜送到对应桌上。每个角色做自己擅长的事系统才能稳定。1.2 为什么不用端侧识别做垃圾分类识别很多人会先想到端侧AI方案比如用Edge Impulse训练一个轻量级图像分类模型直接在ESP32上跑。这条路我以前也试过训练一个能区分“矿泉水瓶、易拉罐、纸巾、香蕉皮”的小模型就花了整整一个周末——拍样本、标注、训练、量化、部署每一步都在折腾。更要命的是泛化能力模型只认训练过的物体换一种包装、换个光源准确率立刻掉下来。家里的垃圾五花八门你不可能给每种垃圾都拍几百张图做训练集。Gemini这样的通用多模态大模型恰好解决了这个问题它对常见物品的认知来自海量预训练数据不需要我自己准备样本零样本也能识别。代价就是必须联网、有一定延迟而且每次调用都消耗API配额。所以我最终的方案是把大模型的强泛化能力用在“识别”上把轻量逻辑留在本地。这也是为什么说“AI垃圾桶”听起来玄乎实际落地反而比想象中的端侧AI方案简单。1.3 选型Flash模型、结构化输出与免费额度Gemini API里我选了gemini-1.5-flash这个模型。Flash系列本身定位就是“快速、低成本、适合高并发任务”对单张图片的识别延迟比Pro模型低一大截。我这边的实测数据是纯模型推理时间一般在1到3秒加上图片上传和网络往返总共3到6秒。如果你现在开通的是新版本也可以把模型名换成gemini-2.0-flash调用格式完全一样。比选模型更重要的是设置结构化输出。我让Gemini返回一个JSON对象包含垃圾类别、物品名称、置信度和投放建议而不是让它自由发挥。这样解析结果的时候就不需要处理一大堆自然语言直接读取字段就能驱动舵机。配额方面Gemini API的免费层对个人调试非常友好日常测试基本不会撞到额度上限。不过为了防止误触发导致的高频调用我在代码里加了最小识别间隔这个后面会细说。2. 硬件搭建ESP32-CAM最小分拣装置2.1 器件与成本清单这个项目的硬件清单非常短大部分零件我都是一次性从常用渠道买的整套下来不到一百块器件用途参考价格ESP32-CAMAI Thinker带PSRAM主控摄像头30-45元SG90舵机驱动分类翻板6-10元/个WS2812B灯环或普通LED分类指示5-15元5V/2A电源适配器整机供电10-20元降压模块/面包板/杜邦线连接与调试10-20元TF卡可选存识别日志15-25元我强烈建议买带PSRAM的ESP32-CAM版本后面讲内存的时候你会理解为什么。另外垃圾桶壳体我用纸板加热熔胶搭了个临时结构能固定摄像头和舵机就行不用太讲究。2.2 接线顺序与供电细节接线看起来简单但这个板子有几个坑值得单独说一下。先看引脚规划摄像头信号线开发板已经固定接到内部引脚不需要你操心舵机信号线接GPIO14补光LED接GPIO4板上自带的闪光灯焊盘就在这指示灯接GPIO12建议串一个220Ω电阻触发按钮或超声波传感器接GPIO13。注意GPIO12是MTDI引脚上电时序比较敏感。如果你手头的模块在GPIO12上接了设备后出现启动异常别怀疑程序先把GPIO12的器件拆掉再看看。舵机供电不要走开发板的3.3V引脚SG90启动瞬间电流能到500mA以上会把板载稳压直接拖垮。正确做法是舵机正负极单独接5V电源信号线接GPIO14同时把舵机电源的GND和ESP32-CAM的GND共地。系统里所有模块要有共同的电位参考点否则信号会乱跳。2.3 一个容易忽视的前提PSRAMESP32-CAM有两个版本带8MB PSRAM和不带PSRAM的。别买不带PSRAM的版本。拍一张VGA分辨率的JPEG照片可能只有30到60KB但Base64编码之后字符串要膨胀约33%再加上JSON请求体、响应缓冲区Arduino环境里如果没有外部RAM很容易直接崩掉或者反复重启。我在代码里用psramFound()做了一次检测if (!psramFound()) { Serial.println(PSRAM not found, please use ESP32-CAM with PSRAM!); }另外摄像头库会默认尝试把帧缓冲分配到PSRAM里。没有PSRAM的版本在上电初始化时就会报malloc failed这几乎是ESP32-CAM项目最常见的启动失败原因。3. 从拍照到调用Gemini核心链路实现3.1 摄像头初始化参数开发环境我用的是Arduino IDE加esp32-camera库选好“AI Thinker ESP32-CAM”开发板型号后初始化代码可以直接从示例里改。关键是这几项参数#include esp_camera.h camera_config_t config; config.ledc_channel LEDC_CHANNEL_0; config.ledc_timer LEDC_TIMER_0; config.pin_d0 Y2_GPIO_NUM; config.pin_d1 Y3_GPIO_NUM; config.pin_d2 Y4_GPIO_NUM; config.pin_d3 Y5_GPIO_NUM; config.pin_d4 Y6_GPIO_NUM; config.pin_d5 Y7_GPIO_NUM; config.pin_d6 Y8_GPIO_NUM; config.pin_d7 Y9_GPIO_NUM; config.pin_xclk XCLK_GPIO_NUM; config.pin_pclk PCLK_GPIO_NUM; config.pin_vsync VSYNC_GPIO_NUM; config.pin_href HREF_GPIO_NUM; config.pin_sccb_sda SIOD_GPIO_NUM; config.pin_sccb_scl SIOC_GPIO_NUM; config.pin_pwdn PWDN_GPIO_NUM; config.pin_reset RESET_GPIO_NUM; config.xclk_freq_hz 20000000; config.pixel_format PIXFORMAT_JPEG; config.frame_size FRAMESIZE_VGA; config.jpeg_quality 12; config.fb_count 1;VGA分辨率640x480是我反复试下来最均衡的选择。再高比如UXGAJPEG文件能到一两百KBBase64之后请求体太大上传时间翻倍而且识别准确率提升非常有限。质量参数不建议低于10太低会让画面出现明显的压缩块Gemini识别小字或者小物体时会吃力。3.2 图片转Base64与内存管理Gemini API接受两种图片传入方式图片URL和Base64内联数据。ESP32-CAM拍出来的照片没有公网URL所以必须转成Base64字符串塞进JSON里。拍照拿到的是camera_fb_t结构体里面直接就是JPEG二进制数据。转Base64我用的是ESP-IDF自带的mbedtls函数不需要额外装库#include mbedtls/base64.h camera_fb_t *fb esp_camera_fb_get(); if (!fb) { Serial.println(Camera capture failed); return; } size_t b64_len 4 * ((fb-len 2) / 3) 1; char *b64 (char *)ps_malloc(b64_len); if (!b64) { esp_camera_fb_return(fb); Serial.println(Failed to allocate base64 buffer); return; } size_t olen 0; int ret mbedtls_base64_encode((unsigned char *)b64, b64_len, olen, fb-buf, fb-len); esp_camera_fb_return(fb); if (ret ! 0) { free(b64); Serial.println(Base64 encode failed); return; }这里用ps_malloc而不是普通的malloc就是为了把大字符串放到PSRAM里避免吃光内部堆内存。Base64字符串构造完成后记得在发送完请求之后free(b64)否则多识别几次内存就满了。3.3 HTTPS POST请求的写法ESP32-CAM调用Gemini API必须走HTTPS。Arduino的HTTPClient库默认用普通WiFiClient不能直接请求HTTPS地址必须换成WiFiClientSecure并关闭证书验证#include WiFi.h #include WiFiClientSecure.h #include HTTPClient.h WiFiClientSecure client; client.setInsecure(); HTTPClient http; String url https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key; url String(GEMINI_API_KEY); http.begin(client, url); http.addHeader(Content-Type, application/json); int httpCode http.POST(payload); String response http.getString(); http.end();setInsecure()的意思是跳过TLS证书校验开发阶段省事生产环境不建议这么干。进阶做法是用setCACert()加载公网根证书但ESP32-CAM的内存和Flash空间有限证书文件管理起来比较麻烦我目前这个项目就停留在开发阶段所以一直用的setInsecure()。这里有个很多人第一次都会踩的坑直接用HTTPClient访问https://开头的URL编译能过但运行时一直报连接失败。原因就是没有替换成WiFiClientSecure。记牢这一条就能省下一个下午。3.4 解析Gemini返回的JSON请求发出后Gemini返回的JSON结构是固定的candidates[0].content.parts[0].text里装着模型生成的文本。由于我在配置里强制了responseMimeType为application/json这个text字段本身就是一个JSON字符串所以需要连续解析两次DynamicJsonDocument respDoc(16384); deserializeJson(respDoc, response); const char *text respDoc[candidates][0][content][parts][0][text]; DynamicJsonDocument resultDoc(2048); deserializeJson(resultDoc, text); const char *category resultDoc[category]; const char *name resultDoc[name]; float confidence resultDoc[confidence];第一层解析拿到模型输出的文本第二层解析才拿到我们真正关心的字段。如果你忘了设置结构化输出text字段就会是一段自然语言描述解析逻辑会变得非常痛苦。这也是为什么我强烈建议用JSON模式。4. 提示词与结构化输出把识别结果变成动作4.1 提示词设计思路大模型识别垃圾这件事提示词写得好不好直接影响返回结果是否稳定。我最终用的提示词大概是这样的你是家庭垃圾分类助手。请识别图片中的物品并给出分类。 你必须严格按JSON格式返回不要输出任何多余内容。 分类枚举值recyclable可回收、kitchen_waste厨余垃圾、hazardous有害垃圾、other其他垃圾、empty空桶、unrecognized无法识别。 返回字段category、name中文名称、confidence置信度0-1、guidance一句中文投放建议。这里有几个设计细节值得说。一是明确给出分类枚举值让模型在限定集合里选答案而不是自由发挥否则它写出“塑料类”“干垃圾”这种描述你就需要再做一层文字匹配。二是加入“空桶”类别因为垃圾桶经常是空的摄像头可能对着桶底拍此时模型应该告诉你“没有垃圾”。三是要求返回中文名称方便调试时直接看串口日志。4.2 用responseSchema锁定JSON字段提示词只能约束真正硬性锁定输出格式的是API侧的配置。Gemini API支持通过generationConfig传入响应模式{ generationConfig: { temperature: 0.2, maxOutputTokens: 1024, responseMimeType: application/json, responseSchema: { type: OBJECT, properties: { category: { type: STRING, enum: [recyclable, kitchen_waste, hazardous, other, empty, unrecognized] }, name: { type: STRING }, confidence: { type: NUMBER }, guidance: { type: STRING } }, required: [category, name, confidence] } } }注意temperature要调低我只用了0.2避免模型每次都生成不同的分类判断。你可能需要根据自己家的垃圾构成微调枚举值比如加入“可回收”和“不可回收”的细分。结构化输出的意义不只是方便解析更重要的是堵死模型“说废话”的路径不然稍不留神它就会在返回文本里加一句道歉或者解释。4.3 分类结果到执行动作的映射拿到category字符串后下一步就是映射到具体动作。我用一个简单的结构体把类别、舵机角度、LED颜色和提示文案绑定在一起struct WasteAction { const char *category; int servo_angle; uint32_t led_color; const char *message; }; WasteAction actions[] { {recyclable, 0, 0xFF0000, 可回收物请投入蓝色桶}, {kitchen_waste, 45, 0x00FF00, 厨余垃圾请投入绿色桶}, {hazardous, 90, 0xFFFF00, 有害垃圾请投入红色桶}, {other, 135, 0x808080, 其他垃圾请投入灰色桶}, {empty, -1, 0x000000, 桶内为空无需操作}, {unrecognized, -1, 0x000000, 无法识别请人工确认} };舵机角度需要根据你实际的机械结构去标定上面这些角度只是示意的映射关系。空桶和无法识别不触发舵机动作只亮灯提醒。把动作和识别结果解耦的好处是以后你想加语音播报、加微信通知或者改成自动分拣只需要在对应category上增加行为不需要改识别链路。5. 分拣执行与整机联动舵机、指示灯与主循环5.1 舵机角度与分拣结构我做的是一版“单舵机双分类”演示结构垃圾桶中间有一块倾斜的翻板舵机转动时翻板往左倒垃圾滑进可回收仓往右倒垃圾滑进厨余仓。如果你要做四分类可以再加一个舵机做二级分流第一个舵机决定“是否进入细分通道”第二个舵机决定“具体进入哪个仓”。舵机控制用ESP32Servo库几分钟就能跑通#include ESP32Servo.h Servo servo; servo.attach(14, 500, 2400); // GPIO14脉冲范围匹配SG90 servo.write(0); // 左通道 servo.write(45); // 右通道注意ESP32Servo库和Arduino自带的Servo库有冲突装库的时候别两个都装。安装之后编译如果报大量重复定义错误检查是不是残留了旧版Servo库。机械结构看似不起眼其实是这个项目里最容易翻车的地方。舵机扭矩只有1.8kg/cm翻板上如果堆了太多垃圾或者垃圾卡在中间舵机会直接堵转。我最后给翻板做了一个倾斜角度让垃圾能靠重力滑落舵机只负责改变滑落方向不负责硬推垃圾整个系统稳定很多。5.2 指示灯与反馈状态机我用了一枚WS2812B灯环做分类指示。识别的category不同灯环颜色就不同可回收蓝色、厨余绿色、有害红色、其他灰色。为了让反馈更明确识别过程中灯环先亮黄色表示“处理中”识别完成再切换到对应颜色。这个逻辑用状态机比较好写避免在loop里堆一堆互相打架的ifenum SystemState { STANDBY, CAPTURING, RECOGNIZING, ACTING };standby状态下等待触发信号触发后进入capturing拍照后进入recognizing并亮黄色灯拿到结果后进入acting执行舵机动作和颜色反馈最后回到standby。这个状态机代码看起来简单但实际调试时帮了大忙尤其是后面加超时保护逻辑的时候你一眼就能看出系统卡在哪个环节。5.3 主循环的触发策略与防抖最常见的触发方式是超声波传感器检测到有人靠近或者有东西投入但我第一版用的是按键触发——按下按钮才拍照识别。原因是按键的逻辑最稳定方便我在开发阶段反复测试而不消耗API配额。实际情况中如果让垃圾桶24小时开着摄像头自动识别很容易出现误触发猫走过去触发一次、光线变化触发一次每触发一次就消耗一次API额度。所以我加了两道保险一是触发必须有明确的物理信号二是两次识别之间至少间隔5秒unsigned long lastRecognitionTime 0; const unsigned long minIntervalMs 5000; void loop() { bool trigger digitalRead(TRIGGER_PIN) LOW; if (trigger !busy millis() - lastRecognitionTime minIntervalMs) { busy true; lastRecognitionTime millis(); runRecognitionCycle(); busy false; } }这里的runRecognitionCycle封装了拍照、编码、网络请求、解析、执行动作的完整流程。如果某一步失败就立刻返回错误状态并亮红色灯而不是卡在那里等待整个系统的容错性会好很多。5.4 本地日志把每一次识别都记录下来调试阶段你会发现光靠串口监视器看日志有时候不够——你没法回溯“刚才那次识别到底返回了什么”。我把识别结果追加写到TF卡上的CSV文件里每次触发记录时间戳、category、name、confidenceFile logFile SD.open(/log.csv, FILE_APPEND); if (logFile) { logFile.printf(%s,%s,%s,%.2f\n, timestamp, category, name, confidence); logFile.close(); }TF卡用SPI接口挂在SD卡槽上和摄像头共用部分引脚。这个功能第一版可以不做但一旦开始反复调参你就知道有日志到底多珍贵。上周我连续识别了几十次统计后发现易拉罐和铝箔盒经常被混淆后来加了一句“注意薄铝制品”的提示词准确率立刻就上来了——没有日志你根本找不到这种规律。6. 实际运行中的坑和排查记录6.1 403/400/429不是玄学先按顺序排掉基本错误很多人在调Gemini API时被错误码劝退我一开始也遇到过。这里把最常见的几个错误码按经验列一下错误码最常见原因排查方式400请求JSON格式错误、图片Base64不完整检查请求体结构确认inlineData字段名和大小写正确403API key无效、未启用、网络不可达先在电脑上用curl验证key和网络再查板子429配额超限降低调用频率加退避重试检查Google AI Studio配额页503/504服务端过载或请求体过大降低图片分辨率增大HTTP读取超时时间403还有一个容易让人误判的情况你在网页版或者命令行工具里看到“your account is not eligible for gemini code assist for individuals at this time”这类提示那是另一套产品Gemini Code Assist个人版的资格限制跟你调用普通Gemini API是完全不相干的。遇到API的403老老实实检查API key有没有复制完整、有没有在URL里正确传参比换账号有用得多。我在电脑上先用curl验证API key没问题然后再烧到板子里调试。这一步看起来多此一举实际能帮你区分“网络问题”“API问题”和“板子代码问题”三种情况curl -X POST \ https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?keyYOUR_API_KEY \ -H Content-Type: application/json \ -d {contents:[{parts:[{text:say ok}]}]}如果这条命令在电脑上能正常返回说明key和网络路径都没问题如果一直卡住或者403那问题在更上游别急着改板子代码。6.2 识别不准对焦、光线和拍摄角度OV2640是一颗定焦镜头出厂对焦距离通常在1米左右。装在垃圾桶上方摄像头离垃圾可能只有20到30厘米拍出来的照片会发虚。解决方法是旋转镜头上的对焦环调到近距离清晰为止。这个操作很精细最好对着电脑屏幕上的文字边调边看调到文字边缘最锐利的位置停手。光线的影响比很多人想象的大。白天靠窗户光倒还好晚上厨房灯光不足时识别准确率明显下降暗光下照片噪点特别多。我在桶盖上加了补光灯用GPIO4控制板载LED或者外接一个白光LED拍照前先点亮200ms再抓帧亮度起来后准确率提升非常明显。还有一个小技巧垃圾桶内壁最好用纯白或者纯黑的背景垃圾放在中间减少背景杂物对模型的干扰。6.3 网络与超时先把curl跑通再谈板子这是我调试过程中最熬人的一个阶段。ESP32-CAM连上Wi-Fi后请求发出去经常卡在http.POST这一步一等就是几十秒然后返回-1。翻来覆去检查代码逻辑其实问题出在网络环境对Google API域名的可达性上。我的经验是先确认运行环境能正常访问generativelanguage.googleapis.com这个域名。可以在电脑上ping和curl判断链路是否通畅、延迟大概多少。如果你的局域网、公司网络或者运营商网络对这类域名有额外限制那就把它当网络可达性问题来考虑换一个可以正常访问该API的网络环境后再测板子。ESP32的Wi-Fi信号强度也值得检查拿另一只手机开热点对比一下往往很快能定位是不是路由器防火墙的问题。HTTP客户端的读超时默认是15秒而Gemini推理最快也要1到3秒加上图片上传和网络抖动15秒其实是够用的但为了保险我把readTimeout调到了60秒client.setTimeout(60);6.4 供电不稳导致的舵机抖动与重启舵机一转动开发板就重启这是我这个项目拍过的最长的坑。SG90堵转或者启动瞬间电流过大会把USB口的电压拉低ESP32一旦欠压立刻重启。解决办法是把舵机的5V电源从USB取电里拆出来单独用一个5V/2A的适配器供电并且就在舵机电源附近并联一个470μF电解电容吸收尖峰。另外一个细节ESP32-CAM本身功耗不低尤其Wi-Fi传输时瞬间电流能到300mA以上。USB线要选粗一点、短一点的劣质细线压降非常明显。用电池供电时还要注意电池电压掉到4.8V以下摄像头初始化就会开始不稳定。稳定的供电是这个项目能连续运行的前提宁可多花十块钱买个好电源也别在这里省。6.5 避免烧配额加一个本地粗筛在加入超声波触发之前我试过让垃圾桶每隔几秒自动拍一张识别一次——既不环保又费API。而且让摄像头怼着一堆静态垃圾拍返回的结果完全一样纯粹浪费配额。后来我加了超声波测距模块检测到桶口上方有物体靠近才触发识别。这个本地粗筛的逻辑很简单却能把API调用次数降到原来的几十分之一也避免了猫路过引起的“幽灵触发”。7. 总成本、使用收益与后续可做的事7.1 一版成本表整套设备我最终花了大约110元如果你手头已经有ESP32-CAM、舵机和电源这些常用配件实际新增支出可能只要二三十块。具体账目如下项目支出ESP32-CAM带PSRAM38元SG90舵机8元WS2812B灯环12元HC-SR04超声波模块6元电机驱动翻板的支架/纸板/热熔胶15元5V/2A电源适配器18元杜邦线、电阻、电容等15元相比买一台成品智能分类垃圾桶几百上千块的价格这个DIY方案的好处其实不是省钱而是所有逻辑都握在自己手里你想加什么分类、想调整识别灵敏度、想接Home Assistant都能自己改。7.2 可以继续扩展的功能这个项目的扩展空间比我预想的大得多。识别结果既然已经进了系统你可以往三个方向继续做一是接一个合成语音模块让垃圾桶直接“说话”提示你该扔到哪个桶二是把结果通过MQTT上报到家里的智能中枢统计一周产生了多少可回收物或者提醒你垃圾袋满了三是建立自己的小型样本库把每次识别结果和人工修正记录存下来等积累了数据之后训练一个只针对自家垃圾场景的端侧小模型降低对云端的依赖。我现在最新的一版加入了连续识别校准当模型置信度低于0.6时垃圾桶会亮黄灯并等待二次确认不会急着打开翻板。这个改动让误开仓的概率明显下降也让我对“AI执行决策”这件事多了一层信任。7.3 给准备复刻的人三个建议最后一节写点实在的。第一别一上来就做四分类自动分拣先把“拍照—识别—反馈”这条链路跑通把提示词和API调用的部分调稳定再考虑舵机和机械结构。第二开发阶段把API key存在代码里没问题但做出来给别人用之前一定要想办法把key放到配置端或者服务端中转避免密钥泄露后被别人刷额度。第三遇到问题先从网络和供电查起我见过太多人花好几个小时debug代码最后发现是USB线不行或者路由器拦截了域名。做这个项目给我最大的感受是大模型真正走进生活不一定要靠什么复杂的机器人平台一块几十块的摄像头开发板加一个云端API就能组合出很实用的工具。技术门槛没有想象中高但每一个细节——补光、触发逻辑、结构化输出——都决定了它到底是“玩具”还是“工具”。希望这篇记录能帮你少踩几个我踩过的坑。
返回列表