OcrDemo
介绍
本项目是给盲人提供的一款看电影的实时英文字幕读取的软件。 主要采用的技术:MediaProjection截取 + AccessibilityService + 动态横竖屏切换实时读取 + 百度底层开源OCR 攻克的技术难点: 1,后台截取图片时,每次截取都会弹窗授权 2,获取不到播放页面 3,获取不到视频软件是否打开 4,使用handler导致性能卡顿 5,竖屏截图时,切换横屏时导致横屏截取的是竖屏的大小 6,AccessibilityService的onkeyeven方法始终无反应
git地址
https://gitee.com/gewussj/OcrDemo.git
APK
https://download.csdn.net/download/qq_39469700/85013042
界面展示
1:后台截图
相关代码贴出来 因为涉及公司秘密 所以不能贴出全部代码 这是我封装的工具类
* 截屏相关工具类
* 录屏没有封装
* 可以一次授权 连续截屏
*/
public class MediaProjrct implements ImageReader.OnImageAvailableListener {
/**
* 截屏管理器
*/
private MediaProjectionManager systemService;
private MediaProjection mediaProjection;
@SuppressLint("StaticFieldLeak")
public static MediaProjrct mediaProjrct;
/**
* 这个可以理解为截取的屏幕
*/
private VirtualDisplay virtualDisplay;
/**
* 这个是截取的屏幕返回的img
*/
private ImageReaders imageReaders;
@SuppressLint("StaticFieldLeak")
public static Activity activity;
private int densityDpi;
/**
* 需要裁剪的宽高
* 这里我直接赋值屏幕宽高
*/
int mWith;
int mHight;
/**
* 类创建 就初始化相关权限
* @param activity
*/
@RequiresApi(api = Build.VERSION_CODES.M)
public void with(Activity activity) {
if (mediaProjrct == null) {
MediaProjrct.activity = activity;
mediaProjrct = new MediaProjrct();
}
initCutManger();
}
/**
* 获取截瓶权限
* @return
*/
@RequiresApi(api = Build.VERSION_CODES.M)
@SuppressLint("WrongConstant")
private void initCutManger() {
densityDpi = activity.getResources().getDisplayMetrics().densityDpi;
systemService = (MediaProjectionManager) activity.getSystemService(MEDIA_PROJECTION_SERVICE);
Intent screenCaptureIntent = systemService.createScreenCaptureIntent();
activity.startActivityForResult(screenCaptureIntent, ApkNames.PERMISSION_CODE);
}
/**
* 交给actvity 在mainactivity中的相同重载的方法中使用
* @param requestCode
* @param resultCode
* @param data
*/
@RequiresApi(api = Build.VERSION_CODES.M)
public void onActivityReust(int requestCode, int resultCode, Intent data) {
if (requestCode != ApkNames.PERMISSION_CODE) {
return;
}
if (resultCode != RESULT_OK) {
return;
}
mediaProjection = systemService.getMediaProjection(resultCode, data);
}
/**
* 截屏
* VirtualDisplay表示一个虚拟显示,显示的内容render到 createVirtualDisplay()参数的Surface。
* 因为virtual display内容render到应用程序提供的surface,所以当进程终止时,它将会自动释放,并且所以剩余的窗口都会被强制删除。但是,你仍然需要在使用完后显式地调用release()方法。
* 此处的 with 和 hight 表示
* 截图的宽和高
* 但是由于不匹配会有黑色边框 所以加入三木
* 如果此类的mhight == hight 则直接获取屏幕的高度 否则创建的截图宽和高是匹配的 但是截取的图片是全屏 然后导致有黑色边框
* 反之直接创建屏幕的大小
* <p>
* 问题场景
* 1 竖屏状态时需要截取的是视频view的宽和高 但是surface创建的是屏幕的宽和高 所以导创建的宽和高是匹配的 但是截取的却是整个屏幕 而我们需要的是视频播放的view 所以就不符合我们需求
* 2 横屏状态下 直接截取当前屏幕的宽和高 。
* <p>
* 以上会有一个问题 就是长时间 横屏或者竖屏 突然横屏或者竖屏会导致img buff缓冲区不足
*/
public void startScreenCapture(int with, int hight) {
this.mWith = with;
this.mHight = hight;
if (null != mediaProjection) {
@SuppressLint("WrongConstant")
ImageReader mImageReader = ImageReader.newInstance(with, mHight == hight ? WindoesCut.getScreenH(activity) : hight, 0x1, 1);
mImageReader.setOnImageAvailableListener(this, null);
virtualDisplay = mediaProjection.createVirtualDisplay("ScreenCapture", with, mHight == hight ? WindoesCut.getScreenH(activity) : hight, densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, mImageReader.getSurface(), null, null);
}
}
/**
* 这里的一定要设置为virtualDisplay = null
* 虽然他会每次使用结束自动释放 但是你还是需要手动释放
* 否则导致 bitmap 花屏
*/
private void stopScreenCapture() {
if (virtualDisplay != null) {
virtualDisplay.release();
virtualDisplay = null;
bitmap = null;
}
}
private Bitmap bitmap;
/**
* 此处的imageReader 和 okhttp的 body 一样 必须要获取一遍后再使用 否则会导致空指针
* 因为imageReader 只能获取一次 所以要创建一个变量保存下来
* stopScreenCapture()
* <p>
* 因为涉及到横竖屏幕的切换 所以要及时吧bitmap设置未null
* 不然会出现 竖屏状态时 图片完好 但是切换到横屏时 横屏截取的图片却和竖屏一样 反之也是
* 注意 这里的bitmapcreate的宽和高 并没有像上面一样进行判断 所以细节就在这个地方
* 因为我们竖屏状态下截取的不是整个屏幕 所以我们要把surface的宽高 进行截取 截取的就是bitmap的高所以就会符合我们的需求
*
* @param imageReader
*/
@Override
public void onImageAvailable(ImageReader imageReader) {
Image image = imageReader.acquireLatestImage();
if (image != null) {
final Image.Plane[] planes = image.getPlanes();
if (planes.length > 0) {
final ByteBuffer buffer = planes[0].getBuffer();
//每个像素的间距
int pixelStride = planes[0].getPixelStride();
//总的间距
int rowStride = planes[0].getRowStride();
int rowPadding1 = rowStride - pixelStride * mWith;
if (bitmap == null) {
bitmap = Bitmap.createBitmap((mWith + (rowPadding1 / pixelStride)), mHight, Bitmap.Config.ARGB_8888);
}
/**裁剪的是当前视频播放view的大小*/
bitmap.copyPixelsFromBuffer(buffer);
imageReaders.ImageReaderCallBackOK(bitmap);
image.close();
stopScreenCapture();
}
}
}
/**
* 接口主要是把截取的bitmap 回掉到mainactivity
*/
public interface ImageReaders {
void ImageReaderCallBackOK(Bitmap bitmap);
}
public void setCallBack(ImageReaders imageReaders) {
this.imageReaders = imageReaders;
}
}
2:获取视频的播放页面
这一块主要就是判断是否进入自己预定的视屏app 然后根据相关控件信息获取到播放视屏的view的rect 之后就可以获取到是视频view的宽度/高度
private TimerTasks instion;
@Override
public void onCreate() {
instion = TimerTasks.getInstion(AccessibilityViewService.this);
instion.setTimer(500, 1200);
}
@SuppressLint("NewApi")
@RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN_MR2)
@Override
public void onAccessibilityEvent(AccessibilityEvent event) {
//判断是否在腾讯视频&&是否在播放页面
if (event.getPackageName().toString().equals(ApkNames.QQLIVE)) {
if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
boolean isOnVideo = AccessHelper.getActivityName(event, this);
if (isOnVideo) {
getVedioView();
Log.e("TAG", "onAccessibilityEvent: 在播放页面" );
} else {
instion.setObj(null,ApkNames.EVENVDEIO_TWO);
instion.startTimer();
Log.e("TAG", "onAccessibilityEvent: 不在播放页面" );
}
}
}
}
/**
* 获取视频view的rect
*/
private void getVedioView() {
AccessibilityNodeInfo rootInActiveWindow = getRootInActiveWindow();
if (rootInActiveWindow != null && rootInActiveWindow.getChildCount() != 0) {
for (int i = 0; i < rootInActiveWindow.getChildCount(); i++) {
if (rootInActiveWindow.getChild(i).getChildCount() != 0) {
AccessibilityNodeInfo activeWindowChild = rootInActiveWindow.getChild(i);
int childCount = activeWindowChild.getChildCount();
for (int j = 0; j < childCount; j++) {
if (j + 2 > childCount || activeWindowChild.getChild(j).getClassName() == null) {
return;
}
//这里的判断是 判断是否为播放视频的控件
if (activeWindowChild.getChild(j).getClassName().equals("android.widget.RelativeLayout") && activeWindowChild.getChild(j + 1).getClassName().equals("android.widget.ImageView")
&& activeWindowChild.getChild(j + 2).getClassName().equals("android.widget.TextView")) {
AccessibilityNodeInfo child = activeWindowChild.getChild(j);
Rect rect = new Rect();
child.getBoundsInScreen(rect);
instion.setObj(rect, ApkNames.EVENVDEI_ONE);
instion.startTimer();
rootInActiveWindow.recycle();
}
}
}
}
}
}
/**
* 服务终结时 销毁定时器
*/
@Override
public void onInterrupt() {
instion.cancelTimer();
}
}
3:判断视频软件是否打开
这一块主要就是判断是否进入视频app
public void onAccessibilityEvent(AccessibilityEvent event) {
//判断是否在腾讯视频&&是否在播放页面
if (event.getPackageName().toString().equals(ApkNames.QQLIVE)) {
if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
boolean isOnVideo = AccessHelper.getActivityName(event, this);
if (isOnVideo) {
getVedioView();
Log.e("TAG", "onAccessibilityEvent: 在播放页面" );
} else {
instion.setObj(null,ApkNames.EVENVDEIO_TWO);
instion.startTimer();
Log.e("TAG", "onAccessibilityEvent: 不在播放页面" );
}
}
}
}
4:handler导致性能卡顿
导致handler的性能卡顿的原因就是,字幕是实时的ocr转换也是实时的,所以导致有时候,一个ocr转换的文字被message发送若干遍。 后来我采用EVENBUS的方式,自己定义的接口,可返回相关的数据,最后放在mainactivity中进行相关数据操作。
/**
* 定时器的回掉
* @param type 标记是否在播放页面
* @param screeH 需要截取的高度
* @param screenJiaoDu 屏幕旋转的角度
* 这里一定要切换线程 否则会你懂的
*/
@Override
public void AoboutScreen(int type, int screeH, int screenJiaoDu) {
Log.i("TAG", "AoboutScreen: " + screenJiaoDu + screeH);
runOnUiThread(new Runnable() {
@Override
public void run() {
switch (type) {
//表示未横屏的时候 返回的是视频的高度
case ApkNames.EVENVDEI_ONE:
mediaProjrct.startScreenCapture(WindoesCut.getScreenW(getApplicationContext()), screeH);
break;
//表示是横屏的时候 返回的是全部屏幕
case ApkNames.EVENVDEIO_TWO:
Log.i("TAG", "run: 服务已经暂停");
break;
}
}
});
}
这里主要是MediaProjrct截屏后返回的bitmap 我们需要用到bitmap之后用ocr转换成文字,但是千万不要忘记销毁bitmap否则会消耗大量的资源。
@Override
public void ImageReaderCallBackOK(Bitmap bitmap) {
Bitmap bitmapBottom = WindoesCut.cropBitmapBottom(bitmap, 0, (int) (bitmap.getHeight() / 2));
ImageUtils.saveBitmap2file(bitmapBottom, this, new Date().toString());
recognitionText(bitmapBottom);
bitmapBottom.recycle();
}
这里的识别文字用的是百度底层的PaddleLite开源框架,主要执行两个步骤,1,定位,2返回文字 这里我把返回的文字进行了相关的处理:比如,包含,等于,相等率,非字符,英文等。第一次的想法是获取第一次的文字坐标Potion后根据第一次的坐标进行返回, 后来因为字幕的坐标的长度是不可控的,然后直接获取的视频高的2/3。
/**
* 识别图片文字
*
* @param bitmapBottom
* @return
*/
private void recognitionText(Bitmap bitmapBottom) {
predictor.setInputImage(bitmapBottom);
boolean runModel = predictor.runModel();
if (runModel) {
List<String> textResult = predictor.getTextResult();
//既然有字幕 那么字幕的坐标一定不为null
if (textResult != null && textResult.size() != 0) {
oldtextResult = textResult.get(0);
/**先判断是否包含上一次的文字
* 包含就是文字重复
* 不包含就进行操作
* */
if (!oldtextResult.equals(newtextResult)) {
/**在进行判断是否包含特殊字符
* 包含就不操作
* 不包含就继续判断概率
* */
boolean specialChar = TextUtils.isSpecialChar(oldtextResult);
if (!specialChar) {
/**
* 在进行判断和上一次的字符相等的字的总概率
* 20% 是一个很好的度 不会多显示 也不会少显示 当然不排除例外
* <20% 进行相关操作
* >20% 不进行操作
*/
int equals = TextUtils.getEquals(oldtextResult, newtextResult);
Log.i("sms", "handleMessage: 转换文字成功---->相等率--:" + equals + "%");
if (equals <= 20) {
Log.e("sms", "handleMessage: 转换文字成功---->相等率--:" + equals + "%--字幕--:" + predictor.getTextResult().get(0));
}
} else {
Log.i("sms", "handleMessage: 转换文字成功---->包含特殊字符:" + predictor.getTextResult().get(0));
}
newtextResult = oldtextResult;
} else {
Log.i("old", "handleMessage: 转换文字重复---->" + predictor.getTextResult().get(0));
}
} else {
Log.i("sms", "handleMessage: 转换文字失败");
}
} else {
Log.i("sms", "handleMessage: ocr 运行失败");
}
}
5:竖屏截图时,切换横屏时导致横屏截取的是竖屏的大小
如下:
/**
* 截屏
* VirtualDisplay表示一个虚拟显示,显示的内容render到 createVirtualDisplay()参数的Surface。
* 因为virtual display内容render到应用程序提供的surface,所以当进程终止时,它将会自动释放,并且所以剩余的窗口都会被强制删除。但是,你仍然需要在使用完后显式地调用release()方法。
* 此处的 with 和 hight 表示
* 截图的宽和高
* 但是由于不匹配会有黑色边框 所以加入三木
* 如果此类的mhight == hight 则直接获取屏幕的高度 否则创建的截图宽和高是匹配的 但是截取的图片是全屏 然后导致有黑色边框
* 反之直接创建屏幕的大小
* <p>
* 问题场景
* 1 竖屏状态时需要截取的是视频view的宽和高 但是surface创建的是屏幕的宽和高 所以导创建的宽和高是匹配的 但是截取的却是整个屏幕 而我们需要的是视频播放的view 所以就不符合我们需求
* 2 横屏状态下 直接截取当前屏幕的宽和高 。
* <p>
* 以上会有一个问题 就是长时间 横屏或者竖屏 突然横屏或者竖屏会导致img buff缓冲区不足
*/
public void startScreenCapture(int with, int hight) {
this.mWith = with;
this.mHight = hight;
if (null != mediaProjection) {
@SuppressLint("WrongConstant")
ImageReader mImageReader = ImageReader.newInstance(with, mHight == hight ? WindoesCut.getScreenH(activity) : hight, 0x1, 1);
mImageReader.setOnImageAvailableListener(this, null);
virtualDisplay = mediaProjection.createVirtualDisplay("ScreenCapture", with, mHight == hight ? WindoesCut.getScreenH(activity) : hight, densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, mImageReader.getSurface(), null, null);
}
}
6:AccessibilityService的onkeyeven方法始终无反应
查阅相关资料 android 10好像无解 如果您知道 请告诉我一下 谢谢。
7:其他相关代码
JNI层的java代码 主要就是获取文字坐标和文字
public boolean runModel() {
if (inputImage == null || !isLoaded()) {
return false;
}
Bitmap scaleImage = Utils.resizeWithStep(inputImage, Long.valueOf(inputShape[2]).intValue(), 32);
int channels = (int) inputShape[1];
int width = scaleImage.getWidth();
int height = scaleImage.getHeight();
float[] inputData = new float[channels * width * height];
if (channels == 3) {
int[] channelIdx = null;
if (inputColorFormat.equalsIgnoreCase("RGB")) {
channelIdx = new int[]{0, 1, 2};
} else if (inputColorFormat.equalsIgnoreCase("BGR")) {
channelIdx = new int[]{2, 1, 0};
} else {
return false;
}
int[] channelStride = new int[]{width * height, width * height * 2};
int[] pixels = new int[width * height];
scaleImage.getPixels(pixels, 0, scaleImage.getWidth(), 0, 0, scaleImage.getWidth(), scaleImage.getHeight());
for (int i = 0; i < pixels.length; i++) {
int color = pixels[i];
float[] rgb = new float[]{(float) red(color) / 255.0f, (float) green(color) / 255.0f,
(float) blue(color) / 255.0f};
inputData[i] = (rgb[channelIdx[0]] - inputMean[0]) / inputStd[0];
inputData[i + channelStride[0]] = (rgb[channelIdx[1]] - inputMean[1]) / inputStd[1];
inputData[i + channelStride[1]] = (rgb[channelIdx[2]] - inputMean[2]) / inputStd[2];
}
} else if (channels == 1) {
int[] pixels = new int[width * height];
scaleImage.getPixels(pixels, 0, scaleImage.getWidth(), 0, 0, scaleImage.getWidth(), scaleImage.getHeight());
for (int i = 0; i < pixels.length; i++) {
int color = pixels[i];
float gray = (float) (red(color) + green(color) + blue(color)) / 3.0f / 255.0f;
inputData[i] = (gray - inputMean[0]) / inputStd[0];
}
} else {
return false;
}
for (int i = 0; i < warmupIterNum; i++) {
paddlePredictor.runImage(inputData, width, height, channels, inputImage);
}
warmupIterNum = 0;
results = paddlePredictor.runImage(inputData, width, height, channels, inputImage);
results = postprocess(results);
if (inputImage!=null){
inputImage .recycle();
}
return true;
}
自定义定时器
/**
* 自定义简单计时器
* user : gewu
* time : 22031709
*/
public class TimerTasks extends java.util.TimerTask {
private static boolean isRun;
public static Timer timer;
@SuppressLint("StaticFieldLeak")
private static TimerTasks timerTasks;
@SuppressLint("StaticFieldLeak")
private static Context thisContext;
private static Handler myHandel;
private int thisType;
private Rect rects;
private CallBack callBack;
public static TimerTasks getInstion(Context context) {
if (timer == null) {
timer = new Timer();
}
if (timerTasks == null) {
timerTasks = new TimerTasks();
}
if (myHandel == null) {
myHandel = new Handler();
}
thisContext = context;
return timerTasks;
}
public interface CallBack {
void AoboutScreen(int type, int screeH, int screeJiaoDu);
}
public void setScreenCallbanck(CallBack callbanck) {
this.callBack = callbanck;
}
@Override
public void run() {
if (isRun) {
switch (thisType) {
//表示未横屏的时候 返回的是视频的高度
case ApkNames.EVENVDEI_ONE:
int h = WindoesCut.isH(thisContext);
switch (h){
case Surface.ROTATION_0:
//表示未横屏 手机旋转0度 返回的是当前视频的view的高度
callBack.AoboutScreen(thisType, WindoesCut.gethight(rects), WindoesCut.isH(thisContext));
break;
case Surface.ROTATION_90:
//表示手机向右横屏 手机旋转90度
case Surface.ROTATION_270:
//表示手机向左横屏 手机旋转270度
callBack.AoboutScreen(thisType, WindoesCut.getScreenH(thisContext), WindoesCut.isH(thisContext));
break;
}
break;
//表示是横屏的时候 返回的是全部屏幕
case ApkNames.EVENVDEIO_TWO:
stopTimer();
break;
default:
throw new IllegalStateException("Unexpected value: " + thisType);
}
}
}
/**
* 先获取timer的运行状态
* 如果不在运行 就直接设置为他的反
*/
public void startTimer() {
if (!getIsRun()) {
isRun = true;
}
}
/**
* 暂停
*/
public void stopTimer() {
if (getIsRun()) {
isRun = false;
}
}
/**
* 设置定时器的间隔时间
*
* @param delay
* @param time
*/
public void setTimer(int delay, int time) {
if (timer == null) {
Log.e("timer", "设置Timer: timer is null......");
return;
}
timer.schedule(timerTasks, delay, time);
}
public synchronized void setObj(Rect rect, int type) {
if (rect == null && type == 0) {
Log.e("timer", "设置数据Timer: Object is null......");
} else {
stopTimer();
rects = rect;
thisType = type;
}
}
/**
* 获取当前的定时器状态
*
* @return
*/
protected boolean getIsRun() {
return isRun;
}
/**
* 直接销毁定时器
*/
public void cancelTimer() {
if (timer == null) {
Log.e("timer", "销毁Timer: timer is null......");
return;
}
stopTimer();
timer.purge();
timer.cancel();
timer = null;
timerTasks = null;
}
}
软件架构
最好的设计就是没有设计,本项目是mvc架构。主要就是服务层和数据层获取到相关内容通知Activity进行更新。 没有UI 没有过多的页面绘制,主要的全部在逻辑后台。纯离线方式的实时字幕识别。 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CaglDE39-1647847438411)(/Users/gewu/Desktop/截屏2022-03-21 15.15.36.png)]
安装教程
打开本项目,里面有apk
使用说明
1,打开软件 2,点击获取两个权限 3,保持后台运行 4,打开相关视频app就OK了
参与贡献
如果您需要,可在此项目上加入实时翻译功能。
|