badapple
在我高中时,那个时候东方还特别火,当时B站上的野生技术协会里就几乎被BadApple屠版了,从一开始的原版视频到后来的控制台动画,从记事本动画再到的任务管理器动画,可以说大佬们在尝试使用各种原本不可能的东西放着烂苹果了!
控制台和记事本的BadApple还好说,原理是先把BadApple的视频逐帧输出为图像,然后再把输出的帧画面转换为字符画(每一个像素点都使用一个符号替代,不同风格的像素点选取不同的符号,这样当你输出的“符号像素”足够密集时,摘下你的眼镜,一张字符编码的图片就出现了!)
这有点类似于图像的二值化处理,相信玩过验证码识别或者图像处理的同学对于“二值化”这个概念并不陌生。取图像上每一个像素点取其灰度值,当像素点的灰度值大于某一个阈值时,将其灰度值设为255,否则设置为0。这样便能将图片很快的转换为“非黑即白”的“二值图片”了。在字符画化图像时,我们也可以根据此原理,将灰度值大于阈值的像素输出符号“#”,否则输出空格。这样如上图所示的画面就绘制完成了!
同样,还能多玩点儿花样,根据灰度值的不同输出一些不同的符号(如下图所示),这点也是很好用代码实现的。(如果不知道如何用代码实现的小伙伴可以使用“ASCII Generator”这个工具)
(多符号输出的字符画)
然后剩下的便是编程实现:将这些字符画逐帧打到控制台或者记事本中去 的功能了。只要对好时间轴,配合好BGM,字符动画就成型了。
咳咳,扯远了,差点忘记了本文的正题。。。重点还是要讲讲如何实现任务管理器播放BadApple!下面先上一张效果图:
众所周知任务管理器中的CPU图表是无法显示字符的(废话。。) ,所以当初我是怎么也没想通这个效果是咋实现的!直到后来,自己接触到了Win32API编程,了解到了一些Win32的API,然后存在脑子中的疑惑才稍稍有些被解开了的迹象~
在详细讲述实现方式之前,先要普及一些东西:句柄 、回调函数和Spy++
一、句柄
在Windows编程中句柄是整个编程的基础,一个句柄是使用一个唯一的整数值来表示不同的对象或者对象中不同的实例,例如可以表示不同的窗口,窗口内不同的Button、文本域、显示图表等等。应用程序能够通过句柄来访问对象。
二、回调函数
回调函数在百度百科上的定义是:一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。
说人话就是:假如有一个A模块需要调用B模块中的b()方法,但是b()方法本身又不能完成所有的任务,有需要反过来调用A模块中的a()方法,那么我们就称a()方法为回调函数,而这个过程我们称为回调。详细地介绍可以看这里:什么是回调函数
三、Spy++
首先说明Spy++并不是个概念,而是一个工具。用过Visual Studio的小朋友们可能知道这个牛逼轰轰的东西,他能查找并显示各窗口、进程、线程甚至是内部消息,能图形化展示各窗口、进程、线程之间的关系图形树。作为VS附带的工具,大家可以在VS的“工具”栏找到找到它。
好了,普及完一些基础知识,接着就该开始进行实际操作了!
对于这个项目我的实现思路是这样的:首先想办法拿到任务管理器中CPU图表的句柄,然后调用绘图函数,直接把截取的视频图片逐帧绘制在CPU的图表位置,最后再调整好时间间隔,配上BGM,这段动画就大功告成了!
0x00 准备阶段
首先我们打开Spy++
可以看到Spy++对当前系统中运行的进程们进行了快照 ,并很直观地显示了出来。
然后我们在其中搜索任务管理器的进程:
点击①这个小望远镜 ,在弹出的小窗口中,拖动②中的指针到任务管理器窗口上,然后松手。
然后我们就可以看到任务管理器的窗口对象就被搜索到了。此时我们再点击“确定”按钮:
可以看到Spy++帮我们将任务管理器窗口及其内部的所有子控件、线程等等都列了出来。
同理,我们直接查找任务管理器中的CPU图表控件:
Spy++也很快搜索了出来。
0x01 使用代码获取目标句柄
通过Spy++查找到了目标之后我们需要在代码中拿到这个目标的句柄,以方便后续对该目标进行操作。这里我们需要用到Win32 API中的FindWindow函数。
FindWindow函数原型是这样的:
其中第一个参数传入的是所属类名,第二个参数传入的是标题名字,返回值是一个窗口的句柄,如果查询不到则返回NULL。再结合上面的这张Spy++的图,相信大家应该就会知道我们一开始使用Spy++的意图了。
因为我本身是写Java的,稍微懂点C#,对别的就不是很在行了。所以这个任务管理器版的BadApple我是用C#实现的。FindWindow被封装在user32.dll中,因此在C#中需要使用调用非托管dll的方法调用:
在C#中使用IntPtr保存目标句柄。函数使用方式如下:
在user32.dll中还有个FindWindowEx函数是用来获得窗口下子控件的句柄的,一开始我是使用的FindWindowEx函数,但是发现这个函数查找到的目标缺不对,返回的句柄根本就不是我所需要的CPU图表的。稍微迷糊了一会儿,后来在MSDN里面查到,原来FindWindowEx是返回查找到的第一个结果,而通过上面Spy++的截图我们可以看到,在任务管理器中有大量同类名的控件,而FindWindowEx只返回第一个,所以有很大几率返回的根本不是我们想要的那一个了。这里说一下也算是记录一下自己遇到的小坑吧。。。
这里我们最后用到的方法是:EnumChildWindows,枚举当前窗口下所有子控件。这里函数原型我就不放图了,大家有兴趣可以去MSDN上查一查。我直接放C#中的代码实现。
它参数中有一个回调函数 CallBack,用于在找到一个子控件之后,我们对这个子控件进行操作,代码是这样的:
声明:
实现:
实例化并使用:
这里稍微解释一下回调函数的实现中那个计算区域大小的部分,主要还是因为任务管理器中有着大量类名为CtrlNotifySink的Layout包裹着各种显示用的图表,然后我也想不出有什么特征值能够帮助我找到CPU的那个图,所以我就用了比较傻的方式,使用一个Rect类表示目标区域长方形面积,然后判断区域的长宽值,找到其中区域较大的那一块儿,就有很大几率是CPU图表了(也有一定几率会拿到硬盘或者内存的图表。。。)
0x02 在目标区域进行绘图
在C#中可以用Graphics类中的DrawImage方法,将一张完整的图片绘制到目标区域去。
DrawImage需要的Image对象我们可以使用IO直接从文件中读取,代码实现如下:
其中imgPath是一个string类型的文件地址。
然后后续的设置延迟,校对时间轴操作就很基础了。原视频是30帧/秒的帧率,因此我们需要设置的每帧播放的间隔就是33.33毫秒,这里不多赘述。
0x03 对图像进行二值化处理
如果仅仅只做前两步操作,而且好巧不巧你截的图也仅仅只是从原视频中截取的话,那么你播放出来的动画也会是原版的视频的感觉。这样就会产生满满的违和感!所以我们还需要再对图片进行一些处理。上面我们提到了图像二值化这个概念。在本章我会详细解释一下这个操作。
二值化图像首先要取得像素点的灰度值,这里我就比较直接地写了:
/// <summary>
/// 求图像的灰度值
/// </summary>
/// <param name="color"></param>
/// <returns></returns>
private static int greyVal(Color color)
{
//int grey = (int)(color.R + color.G + color.B) / 3;//平均值法求灰度值
int grey = (int)(0.299 * color.R + 0.587 * color.G + 0.114 * color.B);//微调
return grey;
}
本来是打算直接用平均值法将灰度值设为这个点RGB参数的平均值,但是在后续出图微调的时候发现有部分图有点不美观,显示的不对,所以后面自己改了些参数。
自定义的二值化代码如下:
/// <summary>
/// 二值化输出图像
/// </summary>
/// <param name="img"></param>
/// <param name="ft"></param>
public static Bitmap BinaryzationImage(Bitmap img, FrameTimer ft)
{
int w = mRect.Right - mRect.Left;
int h = mRect.Bottom - mRect.Top;
int UpPixel = 0;
for (int y = 0; y < img.Height; y++) //把图像二值化
{
for (int x = 0; x < img.Width; x++)
{
int grayVal = greyVal(img.GetPixel(x, y)) > 192 ? 255 : 0;
if (grayVal < 128)
{
img.SetPixel(x, y, Color.FromArgb(grayVal, grayVal, grayVal));
}
}
}
for (int x = 0; x < img.Width; x++) //边缘检测
{
for (int y = 0; y < img.Height; y++)
{
int grayVal = greyVal(img.GetPixel(x, y)) > 192 ? 255 : 0;
if (Math.Abs(grayVal - UpPixel) > 250)
{
img.SetPixel(x, y, Color.FromArgb(17, 152, 187));
}
UpPixel = grayVal;
}
}
for (int y = 0; y < img.Height; y++) //边缘检测
{
for (int x = 0; x < img.Width; x++)
{
int grayVal = greyVal(img.GetPixel(x, y)) > 192 ? 255 : 0;
if (Math.Abs(grayVal - UpPixel) > 250)
{
img.SetPixel(x, y, Color.FromArgb(17, 152, 187));
}
if ((x == 0 || x == img.Width - 1) || (y == 0 || y == img.Height - 1)) //深蓝色边框
{
img.SetPixel(x, y, Color.FromArgb(17, 125, 187));
}
UpPixel = grayVal;
}
}
for (int y = 0; y < img.Height; y++) //背景处理
{
for (int x = 0; x < img.Width; x++)
{
int grayVal = greyVal(img.GetPixel(x, y));
if (grayVal < 128) //背景设为浅蓝色
{
img.SetPixel(x, y, Color.FromArgb(241, 246, 250));
}
if ((y % (h / 10) == 0) && y != 0) //画方格的横线
{
img.SetPixel(x, y, Color.FromArgb(206, 226, 240));
}
if ((x % (w / 20) == (ft.tick() / 1000) * ((w / 40)) % (w / 20)) && x != 0) //画方格的竖线
{
int rx = x;
img.SetPixel(rx, y, Color.FromArgb(206, 226, 240));
}
}
}
return img;
}
FrameTimer是一个自定义的计算时间的类。因为为了模拟任务管理器的动作,所以还需要在原图上画出横纵的表格线。而在FrameTimer中通过tick()方法,则可以判断当前播放到了第几秒,从而在相应的位置画出图像。
FrameTimer类定义如下:
/// <summary>
/// 帧动画定时器类
/// </summary>
class FrameTimer
{
/// <summary>
/// 开始时间
/// </summary>
public DateTime startTime;
public FrameTimer()
{
//设置开始时间
this.startTime = DateTime.Now;
}
public int tick()
{
DateTime now = DateTime.Now;
//计算差值
TimeSpan ts = now - this.startTime;
int differenceValue = ts.Seconds;
//返回差值
return differenceValue;
}
};
这样,当我们循环调用BinaryzationImage方法时,在图片文件读完之前就能一直拿到经过处理的图像而进行输出了。
0x04 实际操作中遇到的一点小坑
按道理来说做到上一步为止,我们就已经能够实现想要的效果了。但是如果电脑前的小朋友们按这个流程来就会发现,输出的画面显得巨卡无比。
原因很简单,是因为在二值化图片时使用到了Bitmap类的GetPixel和SetPixel方法,而这两个方法时间开销是非常大的,起码在这个算法里,在33毫秒内是无法处理完一帧图像的。所以在播放的时候延迟就非常严重,看起来就超级卡。
既然明确了问题所在,那么剩下的就是解决思路了。
这里提供两个解决思路:
- 将Bitmap图转换为byte[]数组,在内存中直接对byte[]数组进行操作,操作完成后再将byte[]数组转换成Bitmap输出,这样就不再需要不停地GetPixel和SetPixel了。
- 使用指针遍历像素点,直接对像素点进行修改操作。
这里比较推荐第一种做法,因为在C#中,直接通过指针操作内存被认为是不安全的行为,除了需要使用unsafe关键字之外还需要在后期编译代码时勾上“允许不安全代码”选项。本来C#好不容易取消了指针这一令萌新头疼的东西,这儿又要用回指针,这也算是一种本末倒置了吧。。。
一开始我确实是想做直接输出的,但是最后因为手上又有些别的事儿了,就没有完成这部分代码。时隔多日之后再回想起来这回事儿的时候已经不想再去处理了(因为懒hhhhhh),所以就直接把功能共一个整体划分成了2部分,批量二值化图片和播放,最后播放部分是直接读取的已经转换好并保存在本地的图片。
有兴趣的小伙伴可以自己去研究一下将二值化和播放合在一起吖,这里给一个参考思路:解决 C# GetPixel 和 SetPixel 效率问题
0x05 测试效果
我将功能逻辑部分封装成了一个dll,然后简单地写了个控制台程序去调用dll中的方法。当当前目录下无二值化后的图片文件时,程序会自动先将原始资源文件进行二值化编辑并保存;当当前目录下有二值化后的图片时,则直接进行播放。
运行效果如下 :
BadApple是原始资源文件夹,Covert是二值化之后的资源文件。这里需要保证“BadApple”和“Covert”不被删除,否则如果删除了前者,则会抛出文件读取异常,删除后者你可能就要等程序二值化等好久啦!
0x06 发布、测试及修改
按照惯例这里附上程序demo和源代码,方便有需要的人拿去修改优化。
作为一个萌新,因为是随便写着好玩儿的东西,所以代码封装的不是很好,制作稍显粗糙。还请各位大佬轻喷~
源码及demo