本项目是给盲人提供的一款看电影的实时英文字幕读取的软件。
主要采用的技术:
MediaProjection截取 + AccessibilityService + 动态横竖屏切换实时读取 + 百度底层开源OCR + EventBus进程间通信 + 讯飞TTS语音合成
攻克的技术难点:
1,后台截取图片时,每次截取都会弹窗授权
2,获取不到播放页面
3,获取不到视频软件是否打开
4,使用handler导致性能卡顿
5,竖屏截图时,切换横屏时导致横屏截取的是竖屏的大小
6,AccessibilityService的onkeyeven方法始终无反应
7,横竖屏切换数据不同步 导致截图宽高出错
有技术大佬可以一起探讨,自动定位字幕位置的方法,原因有亮点:
我查到了此大佬的方式:
下面是演示视频:
基于paddle的文字互转语音
手势操作版本请查看我另一篇文章:
https://blog.csdn.net/qq_39469700/article/details/123880230 手势执行操作
相关代码贴出来 因为涉及公司秘密 所以不能贴出全部代码 这是我封装的工具类
/**
* 截屏相关工具类
* 录屏没有封装
* 可以一次授权 连续截屏
*/
public class MediaProjrct implements ImageReader.OnImageAvailableListener {
/**
* 截屏管理器
*/
private static MediaProjectionManager systemService;
private MediaProjection mediaProjection;
@SuppressLint("StaticFieldLeak")
public static MediaProjrct mediaProjrct;
private VirtualDisplay virtualDisplay;
@SuppressLint("StaticFieldLeak")
public static Activity activity;
private static int densityDpi;
/**
* 类创建 就初始化相关权限
*/
@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")
public void initCutManger() {
densityDpi = activity.getResources().getDisplayMetrics().densityDpi;
systemService = (MediaProjectionManager) activity.getSystemService(MEDIA_PROJECTION_SERVICE);
Intent screenCaptureIntent = systemService.createScreenCaptureIntent();
activity.startActivityForResult(screenCaptureIntent, ApkNames.PERMISSION_CODE);
EventBus.getDefault().register(this);
}
/**
* 交给actvity 在mainactivity中的相同重载的方法中使用
*/
@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);
}
private int hight;
@Subscribe(threadMode = ThreadMode.MAIN)
public void eventPost(String mhight) {
hight = Integer.parseInt(mhight);
startScreenCapture();
}
int screenW;
int screenH;
/**
* 截屏
* VirtualDisplay表示一个虚拟显示,显示的内容render到 createVirtualDisplay()参数的Surface。
* 因为virtual display内容render到应用程序提供的surface,所以当进程终止时,它将会自动释放,并且所以剩余的窗口都会被强制删除。但是,你仍然需要在使用完后显式地调用release()方法。
* 此处的 with 和 hight 表示
* 截图的宽和高
* 但是由于不匹配会有黑色边框 所以加入三木
* 如果此类的mhight == hight 则直接获取屏幕的高度 否则创建的截图宽和高是匹配的 但是截取的图片是全屏 然后导致有黑色边框
* 反之直接创建屏幕的大小
*
* 问题场景
* 1 竖屏状态时需要截取的是视频view的宽和高 但是surface创建的是屏幕的宽和高 所以导创建的宽和高是匹配的 但是截取的却是整个屏幕 而我们需要的是视频播放的view 所以就不符合我们需求
* 2 横屏状态下 直接截取当前屏幕的宽和高 。
*
* 以上会有一个问题 就是长时间 横屏或者竖屏 突然横屏或者竖屏会导致img buff缓冲区不足
*/
public void startScreenCapture() {
if (mediaProjection != null) {
screenW = WindoesCut.getScreenW(activity);
screenH = WindoesCut.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 = null
* 虽然他会每次使用结束自动释放 但是你还是需要手动释放
* 否则导致 bitmap 花屏
*/
private void stopScreenCapture() {
if (virtualDisplay != null) {
virtualDisplay.release();
virtualDisplay = null;
bitmap = null;
}
}
private Bitmap bitmap;
@Override
public void onImageAvailable(ImageReader reader) {
Image image = reader.acquireLatestImage();
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);
AuButBitmap auButBitmap = null;
if (auButBitmap == null) {
auButBitmap = new AuButBitmap();
}
auButBitmap.setBitmap(bitmap);
auButBitmap.setHight(hight);
auButBitmap.setScreen(WindoesCut.isH(activity));
EventBus.getDefault().post(auButBitmap);
} catch (Exception e) {
int h = WindoesCut.isH(activity);
switch (h) {
case Surface.ROTATION_0:
Log.e("TAG", "onImageAvailable: buffer 异常当前竖屏" + hight);
break;
case Surface.ROTATION_90:
case Surface.ROTATION_270:
Log.e("TAG", "onImageAvailable: buffer 异常当前横屏" + hight);
break;
}
}
image.close();
stopScreenCapture();
}
}
}
/**
* 此处的imageReader 和 okhttp的 body 一样 必须要获取一遍后再使用 否则会导致空指针
* 因为imageReader 只能获取一次 所以要创建一个变量保存下来
* stopScreenCapture()
*
* 因为涉及到横竖屏幕的切换 所以要及时吧bitmap设置未null
* 不然会出现 竖屏状态时 图片完好 但是切换到横屏时 横屏截取的图片却和竖屏一样 反之也是
* 注意 这里的bitmapcreate的宽和高 并没有像上面一样进行判断 所以细节就在这个地方
* 因为我们竖屏状态下截取的不是整个屏幕 所以我们要把surface的宽高 进行截取 截取的就是bitmap的高所以就会符合我们的需求
*
* Buffer not large enough for pixels
*/
}
/**
* ocr识别文字的工具类
* 作用 去重等
*/
public class TextUtils {
/**
* 获取字符串相等的个数
* @param s1
* @param s2
* @return
*/
public static int getEquals(String s1, String s2) {
if (isEmpty(s1) && isEmpty(s2)) {
return getPercent(s1, s2);
}
return 0;
}
private static int getPercent(String s1, String s2) {
int num = 0;
int length1 = s1.length();
int length2 = s2.length();
for (int i = 0; i < length1; i++) {
for (int j = 0; j < length2; j++) {
char c1 = s1.charAt(i);
char c2 = s2.charAt(j);
if (c1 == c2) {
num++;
}
}
}
NumberFormat numberFormat = NumberFormat.getInstance();
numberFormat.setMaximumFractionDigits(0);
return Integer.parseInt(numberFormat.format((float) num / (float) length1 * 100));
}
/**
* 判断字符串是否为空
*
* @param s
* @return
*/
public static boolean isEmpty(String s) {
if (s == null || s.equals("")) {
return true;
} else {
return false;
}
}
/**
* 判断是否包含特殊字符
*
* @param str
* @return
*/
public static boolean isSpecialChar(String str) {
String regEx = "[0o _`~!@#$%^&*()+=|{}':;',\\[\\]<>/?~!@#¥%……&*()——+|{}【】‘;:”“’。,、?]|\n|\r|\t";
Pattern p = Pattern.compile(regEx);
Matcher m = p.matcher(str);
return m.find();
}
//字符串第一个字 是不是英文
public static boolean isFristAZ(String s){
String frist = s.substring(0, 1);
boolean matches = frist.matches("[a-zA-Z]+");
return matches;
}
//字符串第一个字是不是数字
public static boolean isFristNum(String s){
String frist = s.substring(0, 1);
boolean matches = frist.matches("[0-9]+");
return matches;
}
/**
* 判断第一句话和第二句话是否差不多
* @param s1
* @param s2
* @return
*/
public static boolean isContain(String s1, String s2) {
if (!isEmpty(s1) && !isEmpty(s2)) {
if (s1.length() > s2.length()) {
return s1.contains(s2);
} else {
return s2.contains(s1);
}
}
return false;
}
}
/**
* 屏幕相关工具
* user gewu
* time 22031815
*/
public class WindoesCut {
/**
* 获取的是视频view的宽
* @param rect
* @return
*/
public static int getWith(Rect rect) {
return rect.right - rect.left;
}
/**
* 获取的是视频的高
* @param rect
* @return
*/
public static int gethight(Rect rect) {
if (rect == null) {
return 0;
}
return rect.bottom - rect.top;
}
/**
* 裁剪一定高度保留下面
* @param srcBitmap
* @param needHeight
* @return
*/
public static Bitmap cropBitmapBottom(Bitmap srcBitmap, int needwith, int needHeight) {
/**裁剪保留下部分的第一个像素的Y坐标*/
int needY = srcBitmap.getHeight() - needHeight;
/**裁剪关键步骤*/
return Bitmap.createBitmap(srcBitmap, needwith, needY, srcBitmap.getWidth(), needHeight);
}
/**
* 获取的是屏幕
* @param context
* @return
*/
private static DisplayMetrics getDisplayMetrics(Context context) {
DisplayMetrics metrics = new DisplayMetrics();
getDispaly(context).getMetrics(metrics);
return metrics;
}
/**
* 获取屏幕管理器
* @param context
* @return
*/
private static Display getDispaly(Context context) {
WindowManager systemService = (WindowManager) (context.getApplicationContext().getSystemService(Context.WINDOW_SERVICE));
return systemService.getDefaultDisplay();
}
/**
* 获取屏幕宽度
* @param context
* @return
*/
public static int getScreenW(Context context) {
return getDisplayMetrics(context).widthPixels;
}
/**
* 获取屏幕高度
* @param context
* @return
*/
public static int getScreenH(Context context) {
return getDisplayMetrics(context).heightPixels;
}
/**
* 屏幕旋转角度
* 如果屏幕旋转90°或者270°是判断为横屏
*/
public static int isH(Context context) {
int angle = ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay().getRotation();
return angle;
}
}
这一块主要就是判断是否进入自己预定的视屏app 然后根据相关控件信息获取到播放视屏的view的rect 之后就可以获取到是视频view的宽度/高度
/**
* 获取视频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.TextView") && activeWindowChild.getChild(j + 2).getClassName().equals("android.widget.TextView"))
|| (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 = new Rect();
child.getBoundsInScreen(rect);
}
}
}
}
}
}
这一块主要就是判断是否进入视频app 并且监听横竖屏 更改到service里面进行
public void onAccessibilityEvent(AccessibilityEvent event) {
//判断是否在腾讯视频&&是否在播放页面
if (event.getPackageName().toString().equals(ApkNames.QQLIVE)) {
if (event.getEventType() == AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED) {
int isOnVideo = AccessHelper.getActivityName(event, this);
if (isOnVideo == 1) {
if (rect == null){
getVedioView();
}
instion.setObj(WindoesCut.gethight(rect));
}
}
}
}
@Override
public void onConfigurationChanged(Configuration newConfig) {
super.onConfigurationChanged(newConfig);
if (newConfig.orientation == 1) {
instion.stopTimer();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
instion.setObj(WindoesCut.gethight(rect));
instion.startTimer();
} else if (newConfig.orientation == 2) {
instion.stopTimer();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
int screenH = WindoesCut.getScreenH(this);
Log.i("test", "onConfigurationChanged: "+screenH);
instion.setObj(screenH);
instion.startTimer();
}
}
导致handler的性能卡顿的原因就是,字幕是实时的ocr转换也是实时的,所以导致有时候,一个ocr转换的文字被message发送若干遍。后来我采用EVENBUS的方式返回相关的数据,最后放在mainactivity中进行相关数据操作,解决了数据不同步问题
if (getIsRun()) {
EventBus.getDefault().post(String.valueOf(mhight));
} else {
Log.i("TAG", "run: ---------已经暂停......");
}
这里主要是MediaProjrct截屏后返回的bitmap 我们需要用到bitmap之后用ocr转换成文字,但是千万不要忘记销毁bitmap否则会消耗大量的资源。
Bitmap bitmap1 = auButBitmap.getBitmap();
int hight = auButBitmap.getHight();
int screen = auButBitmap.getScreen();
int screenH = WindoesCut.getScreenH(getApplicationContext());
int screenW = WindoesCut.getScreenW(getApplicationContext());
Bitmap bitmapBottom;
switch (screen) {
case Surface.ROTATION_0:
bitmapBottom = Bitmap.createBitmap(bitmap1, 0, hight / 2, bitmap1.getWidth(), hight / 2);
break;
case Surface.ROTATION_90:
case Surface.ROTATION_270:
bitmapBottom = Bitmap.createBitmap(bitmap1, 0, bitmap1.getHeight() / 2, bitmap1.getWidth(), bitmap1.getHeight() / 2);
break;
default:
throw new IllegalStateException("Unexpected value: " + screen);
}
//判断当前截图的高和宽 与 获取屏幕的高和宽是否有区别 如果横屏/竖屏下 截屏的宽/高比获取的屏幕的宽小 就说明截图不对
if (bitmapBottom.getWidth() < screenW || bitmapBottom.getHeight() > screenH){
return;
}
// ImageUtils.saveBitmap2file(bitmapBottom, getApplicationContext(), new Date().toString());
recognitionText(bitmapBottom);
bitmapBottom.recycle();
这里的识别文字用的是百度底层的PaddleLite开源框架,主要执行两个步骤,
1定位文字坐标,2返回文字内容
这里我把返回的文字进行了相关的处理:比如,包含,等于,相等率,非字符,英文等。第一次的想法是获取第一次的文字坐标Potion后根据第一次的坐标进行返回,
后来因为字幕的坐标的长度是不可控的,然后直接获取的视频高的2/3。
runOnUiThread(new Runnable() {
@Override
public void run() {
predictor.setInputImage(bitmap);
boolean runModel = predictor.runModel();
if (runModel) {
List textResult = predictor.getTextResult();
//既然有字幕 那么字幕的坐标一定不为null
if (textResult != null && textResult.size() != 0) {
oldtextResult = textResult.get(0);
if (TextUtils.isFristNum(oldtextResult) || TextUtils.isFristAZ(oldtextResult) || oldtextResult.equals(newtextResult)
|| oldtextResult.length() == 1 || oldtextResult.equals(ApkNames.TXLIVE)) {
} else {
ttsUtils.startSpeech(oldtextResult);
newtextResult = oldtextResult;
Log.e("test", "handleMessage: 转换文字成功------字幕:" + oldtextResult);
}
/**先判断是否包含上一次的文字
* 包含就是文字重复
* 不包含就进行操作
*
* 在进行判断是否包含特殊字符
* 包含就不操作
* 不包含就继续判断概率
*
*/
} else {
Log.i(TAG, "handleMessage: 转换文字失败");
}
}
}
});
如下:
/**
* 截屏
* VirtualDisplay表示一个虚拟显示,显示的内容render到 createVirtualDisplay()参数的Surface。
* 因为virtual display内容render到应用程序提供的surface,所以当进程终止时,它将会自动释放,并且所以剩余的窗口都会被强制删除。但是,你仍然需要在使用完后显式地调用release()方法。
* 此处的 with 和 hight 表示
* 截图的宽和高
* 但是由于不匹配会有黑色边框 所以加入三木
* 如果此类的mhight == hight 则直接获取屏幕的高度 否则创建的截图宽和高是匹配的 但是截取的图片是全屏 然后导致有黑色边框
* 反之直接创建屏幕的大小
*
* 问题场景
* 1 竖屏状态时需要截取的是视频view的宽和高 但是surface创建的是屏幕的宽和高 所以导创建的宽和高是匹配的 但是截取的却是整个屏幕 而我们需要的是视频播放的view 所以就不符合我们需求
* 2 横屏状态下 直接截取当前屏幕的宽和高 。
*
* 以上会有一个问题 就是长时间 横屏或者竖屏 突然横屏或者竖屏会导致img buff缓冲区不足
*/
public void startScreenCapture() {
if (mediaProjection != null) {
screenW = WindoesCut.getScreenW(activity);
screenH = WindoesCut.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);
}
}
查阅相关资料 android 10好像无解 如果您知道 请告诉我一下 谢谢。
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;
}
private ArrayList postprocess(ArrayList results) {
for (OcrResultModel r : results) {
StringBuffer word = new StringBuffer();
for (int index : r.getWordIndex()) {
if (index >= 0 && index < wordLabels.size()) {
word.append(wordLabels.get(index));
} else {
Log.e(TAG, "Word index is not in label list:" + index);
word.append("×");
}
}
r.setLabel(word.toString());
}
return results;
}
public boolean isLoaded() {
return paddlePredictor != null && isLoaded;
}
public void setInputImage(Bitmap image) {
if (image == null) {
return;
}
this.inputImage = image.copy(Bitmap.Config.ARGB_8888, true);
}
private List textResults(ArrayList results) {
List stringList = new ArrayList<>();
for (int i = 0; i < results.size(); i++) {
OcrResultModel result = results.get(i);
stringList.add(result.getLabel());
}
return stringList;
}
private List potinResults(ArrayList results) {
List points = new ArrayList<>();
for (int i = 0; i < results.size(); i++) {
OcrResultModel result = results.get(i);
points.addAll(result.getPoints());
}
return points;
}
public List getTextResult() {
return textResults(results);
}
public List getTextPotion() {
return potinResults(results);
}
/**
* 自定义简单计时器
* 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 int mhight;
public static TimerTasks getInstion() {
if (timer == null) {
timer = new Timer();
}
if (timerTasks == null) {
timerTasks = new TimerTasks();
}
return timerTasks;
}
@Override
public void run() {
if (getIsRun()) {
EventBus.getDefault().post(String.valueOf(mhight));
} else {
Log.i("TAG", "run: ---------已经暂停......");
}
}
/**
* 先获取timer的运行状态
* 如果不在运行 就直接设置为他的反
*/
public void startTimer() {
if (!getIsRun()) {
isRun = true;
}
}
/**
* 暂停
*/
public void stopTimer() {
isRun = false;
}
/**
* 设置定时器的间隔时间
*/
public void setTimer(int delay, int time) {
if (timer == null) {
Log.e("timer", "设置Timer: timer is null......");
return;
}
timer.schedule(timerTasks, delay, time);
}
public void setObj(int hight) {
this.mhight = hight;
}
/**
* 获取当前的定时器状态
*/
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 没有过多的页面绘制,主要的全部在逻辑后台。纯离线方式的实时字幕识别。
打开本项目,里面有apk
1,打开软件
2,点击获取两个权限
3,保持后台运行
4,打开相关视频app就OK了
如果您需要,可在此项目上加入实时翻译功能。
目前正在 训练文字识别模型 uping…