1
笔者一直信奉这样一句话:
没有什么事是理所当然的。
最近两周的经历再次验证了这句话。故事还得从一张图片说起......
某日,笔者走在街上,看到路边躺着一只猫猫,腿就像灌了铅似的迈不动了,兴奋地搓了搓小手手就想上去撸一番。可小家伙警惕得很,瞅着笔者靠近了,立马翻了个身子撅起屁股,随时准备逃走。
哎呀,今天遇到了只贞洁烈猫啊。
没办法,只好掏出手机,拍了张照片发到朋友圈,悻悻而归。
就在按下【发表】的一瞬间,脑中突然出现一个闪念:这个照片到底是怎么拍出来的啊?(这和撸猫到底有什么关系啊喂!捂脸笑)恰巧单片机又是笔者的业余爱好,那这次就来做个网络摄像头吧。
想法就这样萌生了。
本以为搞起来会很轻松(不就是摄像头拍出画面上传到网络么),没想到拉开了长达两周噩梦的帷幕。打个比方,你以为前面只是一个小水洼,本想上去踩一踩,没想到整个人就下去了。
那么希望这篇文章,能让你瞥见那些,习以为常的表象背后发生的事情,毕竟,没有什么事是理所当然的。
FBI Warning
笔者也只是菜鸡,亦是第一次涉猎这一领域,出现纰漏在所难免。希望读者保留自己的判断,尽信书不如无书。
2
灯光熄灭,聚光灯亮起。
咳咳。
一阵短暂的回啸,观众们安静了下来。
女士们,先生们,欢迎观赏本次演出!下面,有请本期的嘉宾,登~场~(汽笛声X4):
不被人理解,却渴望被人理解的单片机开发板——Arduino UNO R3!
拥有能穿透人心,直达灵魂深处眼眸的摄像头,OV7670!
身体虽然变小,但头脑依然灵活的Wifi模块,ESP01!
最后出场的是,平平无奇的母对公杜邦线!
杜邦线:就我没资格配图是吧!!!(对!哦,对了,这些线越短越好,不为什么!)
今天的嘉宾会为我们带来怎样精彩的演出呢?ARE YOU READYYYYYY?
3
首先来攻克最困难的部分,图像传感器。
别急,正所谓知其然,知其所以然,这之前,还是先简单聊聊图像传感器的工作原理。
但在这之前,首先抛出两个概念(STOP!禁止套娃):数字信号,模拟信号。
概念笔者就不抄了,这里只需要知道,计算机不能直接处理模拟信号,只能处理数字信号就行。而图像传感器的作用,正是将模拟信号转换为数字信号。
知道人的眼睛是如何看到颜色的吗?人的视网膜上有两种感官细胞,视秆细胞,视锥细胞。视秆细胞能感受明暗,视锥细胞则有三种,分别用来感应红,绿,蓝。看到这儿是不是有一种恍然大悟的感觉?没错,自然界所有颜色都可以由这三种颜色组合形成,也因此,这三种颜色被成为光学三原色,但它有一个更家喻户晓的名字,那就是RGB!
有时候,人和机器之间的界限,是相当模糊的。人体器官的工作,大部分机器是可以模仿的,这也意味着大部分人体器官也可以被机器代替。
图像传感器大抵也是模拟眼球的工作方式。
这里简要概括一下转换过程吧(胡诌警告!这里特指CMOS传感器),闭上眼睛想象一下:
在一层正方形的大楼里,整整齐齐地划分为若干小的正方形的工作隔间,像一个正方形的表格。
每个隔间有一名程序员(感光二极管)。
每隔一段时间(机器时钟),有产品经理会一行一行地找到程序员,催促进度(寻址,并接通水平开关)。
又有老板一列一列地找到程序员:饮茶时间饮茶,做工时间做工,今天的代码什么时候交?(接通垂直开关)
哇靠,你们两个合起来搞我?程序员压力山大(由于同时接通了水平,垂直开关,产生了偏压)!
此时,天上降下天使(光线),她张开双臂将程序员拥入怀中,这让程序员感到慰藉,于是开始疯狂提交代码(偏压二极管遇到光子产生电流)。
产品经理和老板满意地点了点头,笑着,带着数据(RGB,光线强弱等)离开了。
每一行每一列依次重复这个过程。
等所有程序员都提交了代码,老板把分支一合并,远远看去,竟凑成了一副《春树秋霜图》!
这一切只发生在一瞬,而瞬间即是永恒。
稍稍把时间放慢一点的话,大概可以比喻成,从左上角向右下角倒塌的多米诺骨牌吧。滴水成河,聚沙成塔,虽然一支感光二极管什么都做不到,但成千上万支二极管,就能组成包含整个世界的图象。
当然,还有很多工作也在同步进行,比如浮动扩散,信号放大,消除噪音等等,展开来说的话,又是另外一篇文章了。
纸上得来终觉浅,绝知此事要躬行。是时候展示真正的技术啦。
笔者使用了一个第三方库来操作OV767(https://github.com/indrekluuk/LiveOV7670)。这个库定死了引脚连接:
VS - PIN2
XLK - PIN3
PLK - PIN12
SD - A4 还需要用连接3.3V单独供电,请在中间安装一个10K电阻
SC - A5 还需要用连接3.3V单独供电,请在中间安装一个10K电阻
D0 ~ D3 - A0 ~ A3 依次对应
D4 ~ D7 - PIN4 ~ PIN7 同上
3.3V - 3.3V
RESET - 3.3V
GND - GND
PWDN - GND
还记得OV7670左侧的脚针吗?不记得的请退回查看图片~其中VS和HS就是控制水平,垂直开关的脚针!了解理论还是有用的。
Arduino调用层代码就很简单了。逻辑和上面的步骤一致,这里只给出Fake代码展示过程。
#include <CameraOV7670.h>
CameraOV7670 camera(CameraOV7670::RESOLUTION_QQVGA_160x120, CameraOV7670::PIXEL_RGB565, 35);
void setup() {
// 摄像头初始化
camera.init();
noInterrupts();
}
void loop() {
// 发送起始帧标识,0x01可随意更换
UDR0 = 0x01;
// 空循环,直到上一条数据被发送完毕
commit();
// 等待老板合并代码
camera.waitForVsync();
// 循环列
for (uint16_t y = 0; y < COL; y++) {
// 行为单位发送数据,所以每行开始时清空容器,重置下标
BUFFER[0] = 0;
INDEX = 0;
uint8_t counter = 0;
// 循环行,产品经理行为
// READ = ROW * 2 + 1,因为一个像素点需要高光和低光2个数据合成,+1是因为容器头有一个0
for (uint16_t x = 1; x < READ; x++) {
// 不必等待一行全部读出才发,边读边发
if (counter) {
counter--;
} else {
// 发送数据,清除BUFFER
sendByte();
counter = 4;
}
// 读取数据,存入BUFFER
camera.waitForPixelClockRisingEdge();
camera.readPixelByte(BUFFER[x]);
}
// SEND = ROW * 2
while (INDEX < SEND) {
sendByte();
}
// 发送行结束标识,0x02可随意更换
UDR0 = 0x02;
commit();
}
}
快上传代码,打开串口监视器,看看有没有数据在咕噜咕噜地滚动吧。
解释一下为什么没自己写。
在了解传感器工作原理和每个脚针的作用后,其实完全可以自己写代码去寄存器读取数据的。可这需要高超的寻址能力和娴熟的内存管理,笔者能力有限,只能在巨人的肩膀上瑟瑟发抖。
留下了没有技术的泪水.jpg
4
蛤?就这?拜托,我想看的是图片,谁想去看这些二进制数据?
笔者很难能理解这种心情,因为笔者自己也忍不了啊(为了掩盖看不懂这个事实)!!!做事做到底,送佛送到西。接着就用Processing来捣鼓了一个视频播放器。
这次真的把笔者压箱底的宝贝都拿出来啦,各位观众姥爷请务必点个赞,谢谢!
Processing代码是为Arduino代码量身定做的,Arduino代码逻辑变更可能会导致Processing不能正常工作哟~ 还是给Fake代码吧,便于理解。
final int row = 160;
final int col = 120;
PImage screen;
boolean start = false;
// 行游标
int x = 0;
// 列游标
int y = 0;
IntList tmper = new IntList();
void setup()
{
size(160, 120);
// 波特率要与山的内边,海的内边一致
port = new Serial(this, "{your device}", 115200);
// 指定RGB格式
screen = createImage(row, col, RGB);
}
void draw()
{
image(screen, 0, 0);
}
void serialEvent(Serial port) {
if (port.available() < 1) {
return;
}
int input = port.read();
// 新的一帧开始了
if (input == 0x01) {
start = true;
x = 0;
return;
}
// 旧的一行结束了
if (input == 0x02) {
screen.updatePixels();
return;
}
// 如果是中途开始的,丢弃这一帧
if (!start) {
return;
}
tmper.append(input);
if (tmper.size() < 2) {
return;
}
int row = ((tmper.get(0) & 0xff) << 8) + (tmper.get(1) & 0xff);
int r = (row >> 8) & 0xf8;
int g = (row >> 3) & 0xfc;
int b = (row << 3) & 0xf8;
// 填充图片
screen.pixels[x++] = color(r, g, b);
tmper.clear();
}
嘿,看什么呢?看得这么出神?
看到画面啦,笔者从座位上跳了起来。
但过一会儿发现,图象是一行一行在刷新的,有明显的撕裂感。玩过游戏的都应该知道有一个设置叫垂直同步吧。很遗憾,由于笔者的开发板算力不足(还记得之前笔者说过Arduino在业界被称为玩具吗),并且使用了较长的杜邦线(也不是所有东西都是越长越好),造成数据读取,传输都很慢,无法达到视频帧数的最低标准。只能当做PPT看了。
若想在自制单片机上获得最佳体验,可购买更强算力的主板,盆油你听过树莓派吗?也可购买TFT摄像头一体板,此刻尽丝滑哦。或自行设计PCB板。道路千万条,原理第一条。根因找不到,做工两行泪。嗯,好诗好诗。
现实生活中,只要达到20帧左右一秒,就可以看作视频了。
我们使用的手机摄像头,要么有比较宽的排线(传输量大),要么直接焊接在主板上(传输速度快),再加之现代手机芯片算力都已经很强了,所以不会有这种问题。
5
网络摄像头,网络摄像头,网络呢?!
老婆饼里面有老婆么???
开个玩笑,笔者是从来不忘初心的,最后就来整Wifi模块。
老样子,简单过一下信号送出的过程:
一段无线电波正从ESP01的硬件传出。
笔者:施主从何而来?
电波:方才接了硬件哥哥命令,从东土发射器而来,去往西天传递信息。
笔者:命令?
电波:这你有所不知?似BIOS,ESP01的ROM里也住着一位妖怪(控制程序),ta有一个宝贝(AT命令集),只要将它举过头顶,大喊一声:我叫你一声你敢答应么?!硬件哥哥就会对ta唯命是从!
笔者:那这妖怪又听谁的呢?
电波:天外有天,人外有人。这九霄之上,还有神仙(逻辑代码/第三方库),妖怪们在ta面前那是服服体贴,不敢怠慢。
笔者:阿弥陀佛,善哉善哉,受教受教,一路顺风。
逻辑代码/第三方库调用AT指令集,AT指令集操作硬件。
开始接线:
3.3V - 3.3V
EN - 3.3V
GND - GNND
RT - 任意PIN
XT - 任意PIN
不像某些传感器,开水烫死猪,接对接错都没反应(图像传感器:就差报身份证号码了是吧!)。当接线无误时,ESP01的电源灯会亮起。
ESP可用作好几种模式,服务器模式,客户端模式等等,这里只是为了把摄像头的数据传出去,所以选用客户端模式。笔者用的神仙就是WiFiEsp(https://github.com/bportaluri/WiFiEsp)。
#include <WiFiEsp.h>
#ifndef HAVE_HWSERIAL1
#include<SoftwareSerial.h>
SoftwareSerial Serial1({RX PIN}, {TX PIN}); // RX, TX 换成自己插的引脚
#endif
int status = WL_IDLE_STATUS;
const char ssid[] = "{wifi ssid}";
const char pass[] = "{password}";
// 如果初始化成功,就可以调用这个句柄发送数据啦
WiFiEspClient client;
void setup()
{
Serial1.begin(9600);
WiFi.init(&Serial1);
if (WiFi.status() == WL_NO_SHIELD) {
// 如果连接失败了就卡在这里
while (true);
}
while (status != WL_CONNECTED) {
status = WiFi.begin(ssid, pass);
}
}
大功告成了!
啪!现实马上给了笔者左脸一记响亮的耳光。
到目前为止,笔者只是让硬件连上了Wifi,数据要送去哪里,怎么处理都还没着落呢。
做人还是要低调啊。
程序员的逻辑一向是发现问题,解决问题。缺什么就搞什么。
先来冷静分析一波:连上自家的Wifi就能访问公网了,笔者恰好有自己一台服务器(当时怎么就没想到连同一个Wifi也相当于在一个小局域网里呢......)。如果是普通应用,随手写一个接口丢上去接收处理数据就行啦,但这个应用对响应时间要求特别高,思来想去,还真得弄一个websoket服务器来处理,越搞越复杂,笔者还是太年轻了。
Websocket是基于TCP的全双工通信协议。
要快速实现,那肯定用PHP呀,毕竟世界上最好的语言(误)。加上笔者之前用PHP也做过一个IM系统,30分钟用Workerman(https://github.com/walkor/workerman)搭了一个websocket服务器。
代码逻辑没有逻辑,将收到的数据广播给所有已经连接的客户端。
这里要注意,平时大家用websoket几乎都是处理文本格式的数据,这里需要调整参数,原封不动地转发二进制数据。
$connection->websocketType = Websocket::BINARY_TYPE_ARRAYBUFFER;
当然,为了服务器安全,笔者没有直接暴露服务端口,而是用Nginx做了转发。Nginx,YYDS!
服务器有了,这下氵。
啪!现实又给了笔者右脸一记响亮的耳光。
有话不能好好说吗!
因为惯性思维,之前代码发送数据是以HTTP协议发出的,但现在改成WS协议了,旧Arduino代码也就失效了。还是那句老话,一物降一物,别以为神仙就至高无上了,笔者这次还真请来元始天尊了呢!(https://github.com/arduino-libraries/ArduinoHttpClient)
第三方Websoket库调用Wifi第三方库,Wifi第三方库调用AT指令集,AT指令集操作硬件。
#include <ArduinoHttpClient.h>
const char host[] = "{your host}";
// 这里的client就是上边的client,呃,总觉得这句话说了等于没说
WebSocketClient ws = WebSocketClient(client, host, 80);
void loop()
{
ws.begin();
while (ws.connected()) {
ws.beginMessage(TYPE_BINARY);
// 再见了,数据
ws.print(data);
ws.endMessage();
}
}
使用websoket发送数据,不需要每次都新建连接,因此非常快,如果一切正常工作,ESP01的蓝色指示灯会不停地闪烁。
6
历经九九八十一难,终于来到小雷音寺,心中百感交集。但长征还未结束,同志任需努力。
最后的最后,只需要写一个web页面,接收websoket推送,显示画面就行啦。代码逻辑可以参考Processing。
犹豫了一下,还是简单说说canvas吧。别的笔者也就不班门弄斧了,这次只用到了几个API,如果读者感兴趣,可自行查阅谷歌。
let camera = Document.getElementById("{your element}");
// 分辨率
camera.width = {width};
camera.height = {height};
let ctx = camera.getContext("2d");
// 定义一个长条,因为是一行一行绘制的
let row = ctx.createImageData({width}, 1);
// index是行游标
row.data[{index}] = {R};
row.data[{index} + 1] = {G};
row.data[{index} + 2] = {B};
row.data[{index} + 3] = 255; // 这个是透明度,可以写死
// col是列游标
ctx.putImageData(row, 0, {col});
运行效果如下。
ESP01疯狂地闪烁着蓝灯,芯片也开始滚烫起来。
打开浏览器的开发者工具,在Network的Message一栏,可以看到刷刷刷地下载着推送数据。
估摸着菩萨掐指一算,哎呀,九九八十一难,还差一难!
之前不是说过在串口通信下只能看PPT吗,在加入Wifi模块后,你猜怎么着?完全加载一行需要1分钟!笔者使用的分辨率是160x120,也就是说,在web页面上想看到一张完整的图片需要约2小时......
想来也是,Wifi发送数据,自家路由器收到,再走公网发送给websocket服务器,服务器再走公网把数据推给web页面。不过核心问题还是在于Arduino板处理得太慢了。
当头一棒,笔者本来还打算再给摄像头下面加一个舵机,远程控制摄像头左右摆动呢,无奈只得放弃了:制作一个可以远程控制,却看不到画面的网络摄像头,无异于买椟还珠。
7
经此种种,产品一句话,开发做十年,还是有道理的。
你看,当初笔者不也仅仅是拍了张照片发送到网上去吗?然而为了复刻这个过程,需要用多少知识?知道得越多,就越发觉得自己知道得越少,这就是,知识的诅咒。
还是那句话,没有什么事是理所当然的,只不过有人替你负重前行罢了。
最后附上涉及的领域,算是抛砖引玉吧。
生理学:视觉细胞。
信息与通信工程:数字信号模拟信号互转。
光学:光学三原色,色图的发展演进(咳咳,正经的),滤波。
电学:电子,光电转换,信号放大,偏压电路。
计算机科学:晶振,二进制,位移运算,内存寻址,寄存器,脉冲信号,数字信号,CMOS阵列,串口通信,无线电通信,网络通信,通信协议,高级语言,系统架构。