前一阵看鸿洋公众号日推,看到一个几年前就感觉有意思的一个技术,那就是图片转Ascii码,记得上大学时玩过windows的图片或视频转ascii码,可惜那个软件不好用,有bug,转视频的时候动不动就卡死,5分钟的视频,转码百分之7,8十的时候有一半概率卡死- -,总有意犹未尽的感觉。
去年的时候,自己从java移植过一种算法到android,大概思路如下:首先固定字号,然后计算这个字号下绘制出一个字母需要的像素(长x宽),然后对于图片:取出同等大小的图片碎片,然后列出每一个备选的字母绘制出来以后的像素rgb值(一般是ascii码,当然也可以是汉字,不过肯定效果不好),计算每个替换字的rgb转灰色像素数组 相对 图片碎片像素数组的标准差(还有几个备选算法不记得了,这不是重点~),标准差最小的,作为图片碎片的替换字。最后像国际象棋格子一样,一块一块的替换掉,由于计算相对比较复杂,所以耗时比较长,因此当时那个demo也让我搁置了。最近看到这篇日推,不由得眼前一亮,因为很少有人在android端做这种东西,因为算法方案是一大堆,不过很少有感兴趣的人去移植到android- -,我就参考了这篇文章的方案,不由得赞叹这个方法的巧妙,避免了大量的计算,图片转化率大大提高了,可以看看效果图 :
哈哈哈,是不是很酷炫?为了看清每一个字母,特意上传了一个大图(ps:抖音上竟然有人手动敲的ascii码,而且敲了几天,真是丧心病狂)。好了,下面进入正题~
巧妇难为无米之炊,既然要图片/视频转化 ascii码,要有对应的媒体文件,选择一个图片,相信每一个android开发者都或多或少有个趁手的图片选择库,这里使用了 'com.github.LuckSiege.PictureSelector:picture_library:v2.2.3',持续更新的库,比较好用。
用法大概如下~
public static void choosePhoto(Activity context, int requestCode) {
PictureSelector.create(context)
.openGallery(PictureMimeType.ofAll())//全部.PictureMimeType.ofAll()、图片.ofImage()、视频.ofVideo()、音频.ofAudio()
// .theme()//主题样式(不设置为默认样式) 也可参考demo values/styles下 例如:R.style.picture.white.style
.maxSelectNum(1)// 最大图片选择数量 int
// .minSelectNum()// 最小选择数量 int
.imageSpanCount(4)// 每行显示个数 int
.selectionMode(PictureConfig.SINGLE)// 多选 or 单选 PictureConfig.MULTIPLE or PictureConfig.SINGLE
// .previewImage()// 是否可预览图片 true or false
// .previewVideo()// 是否可预览视频 true or false
// .enablePreviewAudio() // 是否可播放音频 true or false
.isCamera(true)// 是否显示拍照按钮 true or false
.imageFormat(PictureMimeType.PNG)// 拍照保存图片格式后缀,默认jpeg
.isZoomAnim(true)// 图片列表点击 缩放效果 默认true
.sizeMultiplier(0.5f)// glide 加载图片大小 0~1之间 如设置 .glideOverride()无效
// .setOutputCameraPath("/CustomPath")// 自定义拍照保存路径,可不填
// .enableCrop(true)// 是否裁剪 true or false
// .compress(false)// 是否压缩 true or false
// .glideOverride()// int glide 加载宽高,越小图片列表越流畅,但会影响列表图片浏览的清晰度
// .withAspectRatio(1, 1)// int 裁剪比例 如16:9 3:2 3:4 1:1 可自定义
// .hideBottomControls()// 是否显示uCrop工具栏,默认不显示 true or false
// .isGif()// 是否显示gif图片 true or false
// .compressSavePath(context.getFilesDir().getAbsolutePath())//压缩图片保存地址
// .freeStyleCropEnabled(true)// 裁剪框是否可拖拽 true or false
// .circleDimmedLayer(true)// 是否圆形裁剪 true or false
// .showCropFrame(false)// 是否显示裁剪矩形边框 圆形裁剪时建议设为false true or false
// .showCropGrid(false)// 是否显示裁剪矩形网格 圆形裁剪时建议设为false true or false
.openClickSound(true)// 是否开启点击声音 true or false
// .selectionMedia()// 是否传入已选图片 List<LocalMedia> list
// .previewEggs()// 预览图片时 是否增强左右滑动图片体验(图片滑动一半即可看到上一张是否选中) true or false
// .cropCompressQuality(90)// 裁剪压缩质量 默认90 int
.minimumCompressSize(500)// 小于100kb的图片不压缩
// .synOrAsy(true)//同步true或异步false 压缩 默认同步
// .cropWH()// 裁剪宽高比,设置如果大于图片本身宽高则无效 int
// .rotateEnabled() // 裁剪是否可旋转图片 true or false
// .scaleEnabled(true)// 裁剪是否可放大缩小图片 true or false
// .videoQuality()// 视频录制质量 0 or 1 int
// .videoMaxSecond(15)// 显示多少秒以内的视频or音频也可适用 int
// .videoMinSecond(10)// 显示多少秒以内的视频or音频也可适用 int
// .recordVideoSecond()//视频秒数录制 默认60s int
// .isDragFrame(false)// 是否可拖动裁剪框(固定)
.forResult(requestCode);//结果回调onActivityResult code
}
接着进行下一步操作,上代码:
public static Bitmap createAsciiPic(final String path, Context context) {
final String base = "#8XOHLTI)i=+;:,.";// 字符串由复杂到简单
// final String base = "#,.0123456789:;@ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";// 字符串由复杂到简单
StringBuilder text = new StringBuilder();
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics dm = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(dm);
int width = dm.widthPixels;
int height = dm.heightPixels;
Bitmap image = BitmapFactory.decodeFile(path); //读取图片
int width0 = image.getWidth();
int height0 = image.getHeight();
int width1, height1;
int scale = 7;
if (width0 <= width / scale) {
width1 = width0;
height1 = height0;
} else {
width1 = width / scale;
height1 = width1 * height0 / width0;
}
image = scale(path, width1, height1); //读取图片
//输出到指定文件中
for (int y = 0; y < image.getHeight(); y += 2) {
for (int x = 0; x < image.getWidth(); x++) {
final int pixel = image.getPixel(x, y);
final int r = (pixel & 0xff0000) >> 16, g = (pixel & 0xff00) >> 8, b = pixel & 0xff;
final float gray = 0.299f * r + 0.578f * g + 0.114f * b;
final int index = Math.round(gray * (base.length() + 1) / 255);
String s = index >= base.length() ? " " : String.valueOf(base.charAt(index));
text.append(s);
}
text.append("\n");
}
return textAsBitmap(text, context);
// return image;
}
我来说下代码的意义~
首先会得到屏幕宽高,接着正规操作,对图片进行缩放,如果图片大小过大,就对图片进行缩放,最大是屏幕的1/7,接着就是for循环嵌套长宽,这里为什么y是y+=2呢?因为ascii码一般都比较长吧~,按照android的标准来看ascii码绘制出来的效果比较长。
我们看for循环里面做了什么:对拿到的每个像素点进行灰度转化,这里就用到图像学的知识了,为什么是0.229:0.578:0.114呢?因为据研究(不是我研究的~),按照这样的配比rgb转化以后,人眼看到的是灰度图像。。。。。开个玩笑,这就是rgb转灰度的公式之一。然后根据灰度值,在0到255之间的位置,来配对应的ascii码,这里 final String base = "#8XOHLTI)i=+;:,.";(字符串由复杂到简单) 所谓的简单到复杂其实想的不用那么复杂,就是相同体积内,绘制出这些字母,哪一个黑色像素更多,仅此而已。直到遍历所有的像素点以后,拼成一个Stringbuffer,这里每次读取一个width的像素以后都要加上一个换行以区分一行。接着放到一个text转bitmap的方法里:
public static Bitmap textAsBitmap(StringBuilder text, Context context) {
TextPaint textPaint = new TextPaint();
// textPaint.setARGB(0x31, 0x31, 0x31, 0);
textPaint.setColor(Color.BLACK);
textPaint.setAntiAlias(true);
textPaint.setTypeface(Typeface.MONOSPACE);
textPaint.setTextSize(12);
WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE);
DisplayMetrics dm = new DisplayMetrics();
wm.getDefaultDisplay().getMetrics(dm);
int width = dm.widthPixels;
StaticLayout layout = new StaticLayout(text, textPaint, width,
Layout.Alignment.ALIGN_CENTER, 1f, 0.0f, true);
Bitmap bitmap = Bitmap.createBitmap(layout.getWidth() + 20,
layoutgetHeight() + 20, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(bitmap);
canvas.translate(10, 10);
canvas.drawColor(Color.WHITE);
// canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);//绘制透明色
layout.draw(canvas);
Log.d("textAsBitmap", String.format("1:%d %d", layout.getWidth(), layout.getHeight()));
return bitmap;
}
这里用到了StaticLayout去绘制文字,textpaint 设置单间隔的文字,设置好参数以后,在canvas上绘制,通过bitmap初始化的canvas,其实也会反应在bitmap上。(我一年前应该是没设置好这样的参数,所以当时画出来的ascii码图片,文字间隔比较大,当时就弃坑了)得到bitmap以后,可以显示在界面上了,也可以输出到文字里,对于图片转ascii码的步骤就到此为止了。
接下来是视频转ascii码的步骤:
其实视频可看做是一帧一帧的图片,那么接下来的思路就清晰了吧~
首先将视频抓帧,可以按照你设定好的每秒抓多少帧,这样得到一堆图像序列,而这里得到视频帧用到了android原生的api,需要android5.0以上:MediaMetadataRetriever 这个类可以得到视频的时长,以及第多少毫秒的图片预览帧,于是我先拿到视频的时长,比如10000毫秒,也就是10秒,那么接下来如果我每秒要取15张图片,那么就每(1000/15)毫秒取一张预览帧,直到10000毫秒为止,首先需要强调下,这个操作是十分耗时的,因此必须将这个操作放到线程里将这些图片保存到一个路径下,具体代码如下(MediaDecoder是对于MediaMetadataRetriever 稍微封装了一下)
@Override
public void run() {
mediaDecoder = new MediaDecoder(path);
String videoFileLength = mediaDecoder.getVideoFileLength();
if (videoFileLength != null) {
try {
int length = Integer.parseInt(videoFileLength);
encodeTotalCount = length / (1000 / fps);
} catch (NumberFormatException e) {
e.printStackTrace();
}
}
for (int i = 0; i < encodeTotalCount; i++) {
Log.i("icv", "第" + i + "张解码开始----------------\n");
Bitmap bitmap = mediaDecoder.decodeFrame(i * (1000 / fps));
if (bitmap == null) continue;
Log.i("icv", "第" + i + "张解码结束\n");
Log.i("icv", "第" + i + "张转换开始\n");
if (weakReference == null || weakReference.get() == null) return;
bitmapTemp = CommonUtil.createAsciiPic(bitmap, weakReference.get());
Log.i("icv", "第" + i + "张转换结束\n");
FileOutputStream fos;
try {
String format = String.format("%05d", i);
fos = new FileOutputStream(MainActivity.picListPath + File.separator + "test" + format + ".png", false);
bitmapTemp.compress(Bitmap.CompressFormat.PNG, 100, fos);
fos.flush();
fos.close();
if (onEncoderListener != null) {
onEncoderListener.onProgress(((int) (100 * ((float) i / encodeTotalCount))));
}
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
mHandler.post(new Runnable() {
@Override
public void run() {
if (onEncoderListener != null) {
onEncoderListener.showImg(bitmapTemp);
}
}
});
}
Log.i("icv", "处理完成");
mHandler.post(new Runnable() {
@Override
public void run() {
if (onEncoderListener != null) {
onEncoderListener.onComplish();
}
}
});
}
这里我直接保存的转换成ascii码图片之后的文件了,图片转ascii码的步骤见文章上半部分
接下来就是最后一步了,将分割转换的图片再合成成视频,合成视频的方法我网上也找了很多,不过基本都是2个方式:第一个就是javacodec这个库,可是这个库发现控制不了帧率,也就是说一个视频如果你转化成图片设置的fps比较少的话,比如fps=5,那么合成视频的时候,他会按照fps = 25默认的去合成视频,那么会出现的问题就是合成的视频的播放速度会是原先的5倍- -,当然也可以改这个库的源码,不过因为这个项目以后还有可能加其他的好玩的功能,于是选择了第二种方案:用ffmpeg进行合成,ffmpeg是一个用c写的跨平台的视频处理库,里面包含了强大的,视频编解码,推流,加水印,滤镜等强大的功能,这也是我选择它的原因,由于编译ffmpeg也是个大坑,所以直接拿来了别人编好的移植过来了。
这里使用了ffmpeg库里ffmpeg.c的run方法去跑你拼接的命令,他也是通过java层传递过来一个数组,这个数组装有ffmpeg的要执行的命令,再传到jni里,在这里面变成一个char数组传递到ffmpeg的run方法,,jni文件如下:
JNIEXPORT jint JNICALL Java_codepig_ffmpegcldemo_FFmpegKit_run
(JNIEnv *env, jclass obj, jobjectArray commands){
//FFmpeg av_log() callback
int argc = (*env)->GetArrayLength(env, commands);
char *argv[argc];
LOGD("Kit argc %d\n", argc);
int i;
for (i = 0; i < argc; i++) {
jstring js = (jstring) (*env)->GetObjectArrayElement(env, commands, i);
argv[i] = (char*) (*env)->GetStringUTFChars(env, js, 0);
LOGD("Kit argv %s\n", argv[i]);
}
return run(argc, argv);
}
而java拼成ffmpeg的命令的方法如下:
public static String[] concatVideo(String _filePath, String _outPath,String fps) {//-f concat -i list.txt -c copy concat.mp4
ArrayList<String> _commands = new ArrayList<>();
{
_commands.add("ffmpeg");
_commands.add("-f");
_commands.add("image2");
_commands.add("-framerate");
_commands.add(fps);
_commands.add("-i");
_commands.add(_filePath+"/test%05d.png");
// _commands.add("-filter_complex");
// _commands.add("[1:v]scale=1920:1080[s];[0:v][s]overlay=0:0");
_commands.add("-b");
_commands.add("1000k");
// _commands.add("-s");
// _commands.add("640x360");
_commands.add("-ss");
_commands.add("0:00:00");
_commands.add("-r");
_commands.add("50");
_commands.add(_outPath);
}
String[] commands = new String[_commands.size()];
String _pr = "";
for (int i = 0; i < _commands.size(); i++) {
commands[i] = _commands.get(i);
_pr += commands[i];
}
Log.d("LOGCAT", "ffmpeg command:" + _pr + "-" + commands.length);
return commands;
}
简略的说下各种参数 -f是他规定的图片格式,-framerate就是帧率啦,fps就是一个int值,一般5到25都行,太少会影响视频的流畅,太多会导致视频播放过快,当然这个fps一定要和当时分割成图片的fps是一模一样的,当时分割的如果太细,会导致后来合成视频的文件过大,因为按照视觉残留原理,15fps就会看做是连续的画面了,无停顿感。这里我默认选择5fps是因为200毫秒取一帧省时间,帧数少,一会转化视频耗时时间少啊。-i表示输入的媒体文件,一般是avi或mp4的视频.-b是码率,这个可以设置小一点,就是1秒的媒体所占的大小限制,-ss是开始的时间,-r是输出的帧率控制,这里是硬控制,这里我设置个大于framerate的数就行了,拼好命令以后,就可以传给ffmpeg进行合成了。合成过程比较慢,因为一涉及到视频处理一般都会慢,静静等待执行完之后就行了,到对应目录上查看合成之后的文件。
效果图如下:
这个demo的不足以及以后将会改进的地方:
- 视频分割成图片使用的是系统的api,并没有,相当于重复调用android native的接口,反复的创建,销毁资源,耗时比较多。过一阵将会改成使用ffmpeg来进行帧分解,我已经跑过单独的测试demo,效率是目前的10倍 - -。
- 以后会增加彩色ascii码的功能,现在是黑白的ascii码,其实在图片成ascii码图片之后,再增加一步就行了,和原先的图片进行相交处理,如果是黑色的,就取原先图片的彩色rgb,如果是白色的,就不做处理。
目前支持视频avi,mp4等常见格式转化成avi,mp4,gif。后续会支持gif转ascii 的gif或视频。
项目地址:https://github.com/LineCutFeng/PlayPicdio
欢迎star,你的收藏是我更新的动力
系列文章:
Android平台下的图片/视频转Ascii码图片/视频 (一)
Android平台下的图片/视频转Ascii码图片/视频 (二)
参考文章:
在Android中使用FFmpeg(android studio环境)
极乐净土----Android实现图片转ascii码字符图的一些尝试
android_图片转视频_image2video