电子的巨大魅力在于无限的可能性。比如说用3个IO端口驱动6个 LED 或者用三极管、电感、电阻制作能榨干电池剩余电力的“焦耳小偷”。百思不得其解之后看到最终的解决方案总会有醍醐灌顶的感觉,也会非常钦佩第一个想到这样用法的人。和解决数学问题之后的快乐不同,因为电子和生活息息相关,学会了这样的招数转头也可以用在自己的设计上。
本文的起因是某天在网上看到有人用 Teensy 2.X 制作的摄像头【参考1】,2.0版本使用32u4也是Leonardo同款主控芯片,因此,这个项目完全可以使用在Leonardo上。
Arduino Leonardo 是很常见的Arduino开发板,它使用了 32U4 的主控芯片,其中带有了USB Device,因此我们有机会将视频直接投送到PC上,而具体的方法就是将设备报告为 USB Camera,再将要显示的内容生成视频发送出去。Windows 内置了 USB Mass Storage 驱动,因此用户可以直接使用 U盘而无需额外安装驱动。同样的,目前的 Win10 内置了 UVC(USB video device class)的驱动,对于符合这个协议定义的USB 设备可以直接在“摄像头”程序中显示出来。
上面介绍了基本原理,接下来就是具体的实验。为了能够更好的展现内容,实验的目标是滚动显示“祝新年快乐”字样。在实验验证上有很多经验之谈,比如:不要用4个字做实验,因为4刚好是2的2倍,同时也是2的平方。很多适用于此的技巧实际上只是巧合。因此,这次使用5个字。另外还有就是测试音频设备尽量不要使用纯音乐而要使用歌曲,后者更容易让测试人员得知当前的音调是否正常。
先研究一下字模的问题。为了将汉字的字形显示输出,汉字信息处理系统还需要配有汉字字模库,也称字形库,它集中了全部汉字的字形信息。需要显示汉字时,根据汉字内码向字模库检索出该汉字的字形信息,然后输出,再从输出设备得到汉字。汉字点阵字模有16*16点、24*24点、32*32点,48*48点几种,每个汉字字模分别需要32、72、128、288个字节存放,点数愈多,输出的汉字愈美观。从经验上来说,16x16是普通人能够接受的最小字形,虽然这个尺寸的字形信息也有缺少笔画的问题(比如:“量”字,在这个尺寸下会丢掉上面 “曰”的最下面一横),但是少于16X16的汉字字形信息会让观看者有明显的缺少笔画的的观感,有如“第二次简化字”死灰复燃。24x24 的字形信息则是完全不丢失笔画的最小尺寸。但是缺点很明显,每个汉字要比16x16的字形多花1倍的空间来进行存储。这对于内存和处理能力有限的单片机来说,着实是一个负担。因此,大多数情况下,单片机使用最多的是 16x16的自字形库HZK16。这个字库是符合GB2312国家标准的16×16点阵字库,HZK16的GB2312-80支持的汉字有6763个,符号682个。其中一级汉字有 3755个,按声序排列,二级汉字有3008个,按偏旁部首排列。取得字形的过程如下:
- 取得欲查询汉字的GB2312编码,每个汉字由2个Byte 进行编码,第一个字节被称作 “区码”;第二个字节被称作“位码”;
- 接下来计算汉字在HZK16中的绝对偏移位置:offset = (94*(区码-1)+(位码-1))*32
- 读取 HZK16 中 offset 给出的位置连续 32 Bytes 即可得到字形信息
例如:查询得到“奈”的区位码为3646,那么计算 offset=(94*(36-1)+(46-1))*32=106720(D)=0x1A0E0
○○○○○○○●○○○○○○○○ → 0x01,0x00
○○○○○○○●○○○○●○○○ → 0x01,0x08
○●●●●●●●●●●●●●○○ → 0x7F,0xFC
○○○○○○●○●○○○○○○○ → 0x02,0x80
○○○○○●○○○●○○○○○○ → 0x04,0x40
○○○○●○○○○○●●○○○○ → 0x08,0x30
○○○●○○○○○●○○●●●○ → 0x10,0x4E
●●●○●●●●●●●○○●○○ → 0xEF,0xE4
○○○○○○○○○○○○○○○○ → 0x00,0x00
○○○○○○○○○○○●○○○○ → 0x00,0x10
○○●●●●●●●●●●●○○○ → 0x3F,0xF8
○○○○○○○●○○○○○○○○ → 0x01,0x00
○○○○●○○●○○●○○○○○ → 0x09,0x20
○○○●○○○●○○○●●○○○ → 0x11,0x18
○●●○○●○●○○○○●○○○ → 0x65,0x08
○○○○○○●○○○○○○○○○ → 0x02,0x00
有了上面的知识,可以很容易的从字库中取得汉字的字形,比如,用程序取得”祝”字的字形信息:
其中的key[32] = {0x20,0x08,0x13,0xFC,0x12,0x08,0x02,0x08,0xFE,0x08,0x0A,0x08,0x12,0x08,0x3B,0xF8,0x56,0xA8,0x90,0xA0,0x10,0xA0,0x11,0x20,0x11,0x22,0x12,0x22,0x14,0x1E,0x18,0x00}; 就是我们需要的字形信息。
用同样的方法,可以依次取得“新年快乐”的字形信息。滚动的原理可以想象成一个窗户,不断在向右侧滑动。窗户的大小为 16 Bits ,落在这个里面的内容就是需要显示出来的内容。
出现在窗口中的数值有两种情况:
- 刚好是一个完整的字,那么直接输出这个字的信息即可;
- 介于第一个字和第二个字之间,需要取得第一个字形的高位信息,然后和第二个字的低位信息拼接即可;
上面解决了显示内容的问题,下面就是如何显示。模拟出来的摄像头将一帧帧的图像发送给PC,其中采用的是YV12的编码格式。通常我们接触到的都是 RGB 格式,每一个点由Red/Green/Blue 三个颜色信息组成,最常见的是每一个颜色各占1个字节。YV12 则使用的是另外的颜色表示方法,使用Y 命令度(Luminance),U色度(Chrominance)和 浓度(Chroma)。这种表示方法是 是历史原因导致的,它出现在Y'UV的发明是由于彩色电视与黑白电视的过渡时期。黑白视频只有Y(Luma,Luminance)视频,也就是灰阶值。到了彩色电视规格的制定,是以YUV的格式来处理彩色电视图像,把UV视作表示彩度的C(Chrominance或Chroma),如果忽略C信号,那么剩下的Y(Luma)信号就跟之前的黑白电视频号相同,这样一来便解决彩色电视机与黑白电视机的兼容问题。另外,这种编码方式也会使用视觉特性来节省空间。每一个点的 Y 信号是独立的,但是相邻的四个点会共享同一个U和同一个V信息。比如:之前空间上存在相邻的四个点 (Ri,Gi,Bi) 通过某种算法变换后得到 (Yi,U1,V1)这样的四个点信息。之前存放四个点需要 3*4=12Byte;变化之后只需要存储 Y1/Y2/Y3/Y4/U1/V1 6Byte的信息即可。减少了一半的数据量。
R1,G1,B1 | R2,G2,B2 | Y1,U1,V1 | Y2,U1,V1 |
R3,G3,B3 | R4,G4,B4 | Y3,U1,V1 | Y4,U1,V1 |
将上面的过程放在一个帧的图像上来看是下面这样
代码很长,但大部分都只是框架,send_yv12_frame() 是最关键的函数,具体的输出帧(借用计算机图形学的概念可以说是“渲染的过程”)是在其中完成计算的:
static inline void send_yv12_frame(void)
{
uint16_t h,w,lastw;
uint8_t write_hdr = 1;
uint8_t hdr;
uint8_t color, colcnt;
//uint8_t br = LSB(brightness);
uint8_t board[16][16];
int c,low,high;
char *p;
usb_wait_in_ready();
if (k==(sizeof(wordaddr)/2-1)*16) {k=0;}
else k++;
//显示一个完整的字形
for (int i=0;i<16;i++)
{
//k给出当前要显示的窗口起始位置
if (k%16==0) //如果当前指针刚好在一个字上,那么直接取出这个字进行显示
{
//指向要显示的汉字起始地址
p=wordaddr[k/16];
//取出这个字的一行信息
c=((*(p+i*2)&0xFF)<<8)+(*(p+i*2+1)&0xFF);
}
else //指针不在一个字上,就要用两个字来拼成一个字进行显示
{
//指向要显示的第一个汉字
p=wordaddr[k/16];
//取出第一个汉字的一行
low=(((*(p+i*2)&0xFF)<<8)+(*(p+i*2+1)&0xFF))&0xFFFF;
//指向要显示的第二个汉字
p=wordaddr[(k/16+1)%sizeof(wordaddr)];
//取出第二个汉字的一行
high=(((*(p+i*2)&0xFF)<<8)+(*(p+i*2+1)&0xFF));
//用取得的信息拼出来要显示的一行
c=low<<(k%16)|(high&0xFFFF)>>(16-k%16);
}
for (int j=0;j<16;j++)
{
if ((c&0x8000)==0) {
board[j][i]=0;//这个点位置有信息
}
else board[j][i]=1;//这个点位无信息
c=c<<1;
}
}
//下面发送一帧信息
/* Y plane (h*w bytes) */
for(h=0; h < HEIGHT; h++) {
w=0;
color = cur_start_col;
colcnt = COLUMN_WIDTH - cur_col_offset;
do {
if(!usb_rw_allowed()) {
usb_ack_bank();
if(usb_wait_in_ready_timeo(10) < 0)
return;
usb_ack_in();
lastw = w;
write_hdr = 1;
} else {
if(write_hdr) {
/* Write header */
hdr = UVC_PHI_EOH | (fid&1);
UEDATX = 2; // write header len
UEDATX = hdr;
write_hdr = 0;
}
//为了美观,上下各空出12
if ((h<12)||(h>107)) {
UEDATX=0;
}
else {
//检查当前的点阵信息输出黑或者白
//Y:255 U:128 V:128 是白色
//Y:0 U:128 V:128 是黑色
if (board[w/10][(h-12)/6]==1) {UEDATX=0xFF;} // Y
else {UEDATX=0;}
}
w++;
if(--colcnt == 0) {
color = next_color(color);
colcnt = COLUMN_WIDTH;
}
}
} while(w < WIDTH);
}
/* U plane (h/2*w/2 bytes) */
for(h=0; h < HEIGHT/2; h++) {
w=0;
color = cur_start_col;
colcnt = COLUMN_WIDTH - cur_col_offset;
do {
if(!usb_rw_allowed()) {
usb_ack_bank();
if(usb_wait_in_ready_timeo(10) < 0)
return;
usb_ack_in();
lastw = w;
write_hdr = 1;
} else {
if(write_hdr) {
/* Write header */
hdr = UVC_PHI_EOH | (fid&1);
UEDATX = 2; // write header len
UEDATX = hdr;
write_hdr = 0;
}
UEDATX=128;
w++;
if(colcnt <= 2) {
color = next_color(color);
colcnt = COLUMN_WIDTH;
} else {
colcnt -= 2;
}
}
} while(w < WIDTH/2);
}
/* V plane (h/2*w/2 bytes) */
for(h=0; h < HEIGHT/2; h++) {
w=0;
color = cur_start_col;
colcnt = COLUMN_WIDTH - cur_col_offset;
do {
if(!usb_rw_allowed()) {
usb_ack_bank();
if(usb_wait_in_ready_timeo(10) < 0)
return;
usb_ack_in();
lastw = w;
write_hdr = 1;
} else {
if(write_hdr) {
/* Write header */
hdr = UVC_PHI_EOH | (fid&1);
if(h==HEIGHT/2-1 && w < UVC_TX_SIZE)
hdr |= UVC_PHI_EOF;
UEDATX = 2; // write header len
UEDATX = hdr;
write_hdr = 0;
}
UEDATX=128;
w++;
if(colcnt <= 2) {
color = next_color(color);
colcnt = COLUMN_WIDTH;
} else {
colcnt-=2;
}
}
} while(w <WIDTH/2);
}
if(lastw != w) {
usb_ack_bank();
}
fid = ~fid; // flip frame id bit
}
和之前的项目一样,这个需要基于 Lufa 库支持,使用 WinAvr 进行编译。使用 make 即可完成编译。
使用和Arduino刷新一样的的命令,要根据你的Arduino板子重启时占用的串口号调整 –PCOMn 参数。同时,每次刷新时,需要先按下 Arduino Reset键,然后运行刷写命令。
D: \arduino-1.8.4\hardware\tools\avr\bin\avrdude -v –d:\arduino-1.8.4\hardware\tools\avr\etc\avrdude.conf -patmega32u4 -cavr109 -PCOM7 -b57600 -D -V -Uflash:w:./uvc.hex:i
烧写成功后,运行 Camera 即可查看结果:
为了简单起见,只实现了黑白显示,有兴趣的朋友可以发挥想象力显示彩色的汉字,相信这只是一个开始,后面会有更多的玩法。
参考:
1. https://github.com/avivgr/teensy_uvc
I second this motion. SparkFun should make a separate inventors kit for the Leonardo. (Except for the fact that they just switched from Uno to RedBoard, so they're probably not permitted to use another arduino board.)