基于飞浆Paddle的Android硬字幕实时提取
介绍
本项目是给盲人提供的一款看电影的实时英文字幕读取的软件;主要采用的技术:MediaProjection截取屏幕 + AccessibilityService监听手势 + 开源OCR飞浆Paddle + EventBus进程间通信 + 讯飞TTS语音合成
需要自动化执行的可以去看我另一篇文章,主要代码无区别,这个主要是为了更好的简单操作但是又不想适配所以增加了一个操作手势进行打开操作。
下面是演示视频:
有技术大佬可以一起探讨,自动定位字幕位置的方法,原因有亮点:
- 一视频播放页面无效文字太多,比如左上角影名,右下角广告,右上角动态广告并且这些不是固定的也就是说有的视频有有的没有。
- 二视频字幕不固定,比如:综艺节目演员字幕在左下角,导演字幕在中间,还有后期剪辑上去的歪歪扭扭的字幕,电影在中间,还有在上部分屏幕中间,还有在视频右下角的不是字幕的版本号
- 三如何去除无效文字,我们知道无效文字的位置但是机器不知道,并且ocr每次返回的文字坐标也不固定所以我现在采取的是找规律只找有效。
我查到了此大佬的方式:
- https://blog.csdn.net/flavioy/article/details/120218378?spm=1001.2101.3001.6650.1&utm_medium=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1.pc_relevant_default&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2%7Edefault%7ECTRLIST%7ERate-1.pc_relevant_default&utm_relevant_index=2
- 但是我也不会用他的方式哈哈哈
- 若有大佬支持我将感激万分。gitee联系我拉你进去
点击我第一篇文字识别方式
一,对接PaddleLite编译相关文件
1,下载paddle官网的相关demo
官网文档 https://github.com/PaddlePaddle/PaddleOCR
2,配置AndroidStudio的NDK
- 把相关文件拷贝到自己的项目后,下载相关NDK,MARK用来编译我们拷贝的文件
- 主要文件:
- 1 OpenCV
- 2 PaddleLite
- 将1和2文件放在项目app包之下
- 3 cpp
- 将3放在app/src/main您的包名下
- 4 assets
- 将4放在src/main之下 并设置为source root(选中文件右键Mark dir as)
- 5 java文件包名ocr
3,下载NDK和MARK
- 下载相关匹配的NDK这里就不多说了
- 配置您的build.gradle文件/参考官方demo的相同文件
- 修改Predictor.java文件的相关方法,使他适合您自己使用
二,新建无障碍服务
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (newConfig.orientation == 1) {
ttsUtils.startSpeech("已进入竖屏,读屏已经暂停");
} else if (newConfig.orientation == 2) {
ttsUtils.startSpeech("已进入横屏,请使用手势开始读屏");
}
timerInstance.stopDefaultTimer();
}
@RequiresApi(api = Build.VERSION_CODES.N)
@Override
protected boolean onGesture(int gestureId) {
if (gestureId == GESTURE_SWIPE_DOWN_AND_UP) {
int h = ScreenUtils.isH(getApplicationContext());
if (h == Surface.ROTATION_270 || h == Surface.ROTATION_90) {
timerInstance.starDefaultTimer();
ttsUtils.startSpeech("已经开始读屏");
} else {
ttsUtils.startSpeech("当前竖屏状态,请进入视频播放页面横屏下使用读屏");
timerInstance.stopDefaultTimer();
}
}
return true;
}
-
第一个函数主要是实时监测手机横屏或者竖屏 -
第二个函数就是监测我们的手势
- 当我们在屏幕上执行(先下后上手势)时此onGesture函数会执行,我这里由于需求的原因需要检测横竖屏,竖屏状态下不执行。
-
第二个函数如何配置执行
-
全部如下
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric|feedbackSpoken|feedbackVisual"
android:accessibilityFlags="flagReportViewIds|flagIncludeNotImportantViews|flagRequestTouchExplorationMode|flagRequestFingerprintGestures"
android:canRetrieveWindowContent="true"
android:canRequestFilterKeyEvents="true"
android:canPerformGestures="true"
android:notificationTimeout="100"
android:canRequestTouchExplorationMode = "true"
android:canRequestFingerprintGestures="true"
tools:targetApi="o" />
三,定时器的模版设计
public abstract class TimerAction {
abstract void initTimer();
abstract void starDefaultTimer();
abstract void stopDefaultTimer();
abstract void setTimer(long delay,long period);
}
- 这里由于我后期需要和主包进行合并,所以我把相关函数抽象出来,交给子类去实现。
/**
* 开始执行默认任务
*/
@Override
public void starDefaultTimer() {
run();
initTimer();
timers.schedule(timerTask, timerDelay == 0 ? 1000 : timerDelay, timerPeriod == 0 ? 1000 : timerPeriod);
}
/**
* 一旦销毁 所有的就结束
*/
@Override
public void stopDefaultTimer() {
if (timers != null) {
timers.purge();
timers.cancel();
timers = null;
}
if (timerTask != null) {
timerTask.cancel();
timerTask = null;
}
}
- 这是子类实现的部分方法,主要就是开启定时器执行下一步骤的截屏
四,截屏的功能实现
-
截屏的相关接口
- 谷歌给我们提供了一个截屏和录屏的对外类,我们可以使用这个类执行相关的操作
- MediaProjection
-
MediaProjection的封装
- 由于java基础薄弱,所以采用了单例模式进行封装
- 主要对外开放的就是,开始截屏,和结束截屏
- ImageReader.newInstance(screenW, screenH, 0x1, 1)
- 主要是这个:这个主要是创建想要获取的屏幕大小,后面两个都一样不用多操心
/**
* 交给actvity 在mainactivity中的相同重载的方法中使用
*/
@RequiresApi(api = Build.VERSION_CODES.M)
public void onActivityReust(int requestCode, int resultCode, Intent data) {
if (requestCode != VariableUtils.PERMISSION_CODE) {
return;
}
if (resultCode != RESULT_OK) {
return;
}
mediaProjection = systemService.getMediaProjection(resultCode, data);
}
private int screenW;
private int screenH;
@Subscribe(threadMode = ThreadMode.MAIN)
public void startScreenCapture(Bundle bundle) {
if (mediaProjection != null) {
screenW = ScreenUtils.getScreenW(activity);
screenH = ScreenUtils.getScreenH(activity);
ImageReader mImageReader = ImageReader.newInstance(screenW, screenH, 0x1, 1);
mImageReader.setOnImageAvailableListener(this, null);
virtualDisplay = mediaProjection.createVirtualDisplay("ScreenCapture", screenW, screenH,
densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, mImageReader.getSurface(), null, null);
}
}
/**
* 截屏
* VirtualDisplay表示一个虚拟显示,显示的内容render到 createVirtualDisplay()参数的Surface。
* 因为virtual display内容render到应用程序提供的surface,所以当进程终止时,它将会自动释放,并且所以剩余的窗口都会被强制删除。但是,你仍然需要在使用完后显式地调用release()方法。
* 此处的 with 和 hight 表示
* 截图的宽和高
* 但是由于不匹配会有黑色边框 所以加入三木
* 如果此类的mhight == hight 则直接获取屏幕的高度 否则创建的截图宽和高是匹配的 但是截取的图片是全屏 然后导致有黑色边框
* 反之直接创建屏幕的大小
* <p>
* 问题场景
* 1 竖屏状态时需要截取的是视频view的宽和高 但是surface创建的是屏幕的宽和高 所以导创建的宽和高是匹配的 但是截取的却是整个屏幕 而我们需要的是视频播放的view 所以就不符合我们需求
* 2 横屏状态下 直接截取当前屏幕的宽和高 。
* <p>
* 以上会有一个问题 就是长时间 横屏或者竖屏 突然横屏或者竖屏会导致img buff缓冲区不足
*/
private Bitmap bitmap;
@Override
public void onImageAvailable(ImageReader reader) {
Image image = reader.acquireNextImage();
if (image != null) {
final Image.Plane[] planes = image.getPlanes();
if (planes.length > 0) {
ByteBuffer buffer = planes[0].getBuffer();
//每个像素的间距
int pixelStride = planes[0].getPixelStride();
//总的间距
int rowStride = planes[0].getRowStride();
int rowPadding1 = rowStride - pixelStride * screenW;
if (bitmap == null) {
bitmap = Bitmap.createBitmap((screenW + (rowPadding1 / pixelStride)), screenH, Bitmap.Config.ARGB_8888);
}
try {
bitmap.copyPixelsFromBuffer(buffer);
EventBus.getDefault().post(bitmap);
} catch (Exception e) {
Log.e("TAG", "onImageAvailable: " + e);
}
image.close();
reader.close();//这个竟然忘记了 不然ImageReader一直有可用图像时将会一直调用
stopScreenCapture();
}
}
}
- 这里截屏后的回掉一定要注意,否则手机发烫严重。
- 1 Image image = reader.acquireNextImage();
- 这里为什么不进行直接使用,因为这里只可以获取一次,这行代码的意思是,获取截屏后返回的Image
- 个人认为有点类似与OkHttp的Body返回也是只可以获取一次
- 2 image.close(); reader.close();
- 这里一定要记得关闭,否则你会发现当你执行结束截屏的时候此回掉依然会返回图片。
五,文字识别
/**
* 识别图片文字
*/
public synchronized void recognitionText(Bitmap bitmap) {
//标记的是否为第一次,因为第一次截取屏幕获取位置
if (isFirstOcr) {
//isToOrBottom : true 上 /false下
Bitmap topOrBottomBitmap = Bitmap.createBitmap(bitmap, 0, isToOrBottom ? 0 : cenScreenH, cenScreenW * 2, cenScreenH);
predictor.setInputImage(topOrBottomBitmap);
} else {
cenScreenW = ScreenUtils.getScreenW(getApplicationContext()) / 2;
cenScreenH = ScreenUtils.getScreenH(getApplicationContext()) / 2;
twoCenH = cenScreenH / 2;
twoCenW = cenScreenW / 2;
predictor.setInputImage(bitmap);
}
boolean runModel = predictor.runModel();
if (runModel) {
List<String> textResult = predictor.getTextResult();
//既然有字幕 那么字幕的坐标一定不为null
if (textResult != null && textResult.size() != 0) {
List<Point> textPotion = predictor.getTextPotion();
//计算ocr返回的一共几组数据
int textBody = textPotion.size() / 4;
//遍历每一组 规律:因为每一组四个坐标 所以每组之后乘每组的个数
for (int i = 0; i < textBody; i++) {
//计算每组数据的中心坐标
int textCenter = (textPotion.get(i * 4).x + textPotion.get(i * 4 + 1).x) / 2;
//判断每组数据的中心坐标 是否在规定的中心区域内 因为有误差 所以设置的是屏幕中心的-10/+10
if ((cenScreenW - 10) < textCenter && textCenter < (cenScreenW + 10)) {
Log.e(TAG, "recognitionText:中心 .......");
//第一次进来不执行 因为isFirst为false
if (isFirstOcr) {
Log.e(TAG, "recognitionText:第二次 .......");
if (TextUtils.isSmooth(textResult.get(i), newtextResult)) {
newtextResult = textResult.get(i);
ttsUtils.startSpeech(newtextResult);
Log.i(TAG, "run: " + textResult.get(i));
}
//第一次进来走这里 当获取到剧中的字幕时在获取字幕位置(上/下),最后还要读取第一次的文字
} else {
int y0 = textPotion.get(i * 4).y;
int y2 = textPotion.get(i * 4 + 2).y;
//在上半屏
if (y0 < cenScreenH && y2 < cenScreenH) {
Log.e(TAG, "recognitionText:在上半屏 .......");
isToOrBottom = true;
//在下半屏
} else if (y0 > cenScreenH && y2 > cenScreenH) {
Log.e(TAG, "recognitionText:在下半屏 .......");
isToOrBottom = false;
} else {
Log.e(TAG, "recognitionText: 在屏幕中心区域不处理.......");
}
if (TextUtils.isSmooth(textResult.get(i), newtextResult)) {
newtextResult = textResult.get(i);
ttsUtils.startSpeech(newtextResult);
isFirstOcr = true;
Log.i(TAG, "run: " + textResult.get(i));
}
}
}
}
} else {
Log.i(TAG, "handleMessage: 转换文字失败");
}
}
}
- 这里我写的很清楚了
- ScreenUtils.getScreenW(getApplicationContext())
- 获取屏幕宽度
- predictor.getTextResult()
- 获取OCR返回文字组,是个集合
- predictor.getTextPotion()
- 获取OCR返回的文字坐标,也是个Potion集合,每个potion是四个点的坐标。
- ttsUtils.startSpeech(textResult.get(i));
- 读取全部返回文字
- TextUtils.isSmooth(textResult.get(i), newtextResult)
- 判断返回字符是否符合逻辑
- 由于是字幕 这里我判断了是否剧中
总结
- 每个类之间的通信/线程通信/切换线程用的是EVENTBUS
- 在上一个版本后觉的手势操作比较符合操作。
软件架构
- 最好的设计就是没有设计,本项目是mvc架构。主要就是服务层和数据层获取到相关内容通知Activity进行更新。
- 没有过多的页面绘制,主要的全部在逻辑后台。纯离线方式的实时字幕识别。
使用说明
- 1,打开软件
- 2,点击获取两个权限
- 3,保持后台运行
- 4,打开相关视频app进入播放页面使用手势开始读取字幕
- 未再横屏会提示先横屏在次操作就可以了
参与贡献
如果您需要,可在此项目上加入实时翻译功能。
|