6.7.0 - Alpha20 - Paddle OCR 支持原始图像传输以提升性能, 并支持更灵活的选项配置

This commit is contained in:
SuperMonster003
2026-02-14 14:22:58 +08:00
parent ff478c0c0c
commit 43b1e890fd
4 changed files with 548 additions and 95 deletions

View File

@@ -1,5 +1,6 @@
package org.autojs.autojs.core.plugin.ocr package org.autojs.autojs.core.plugin.ocr
import android.annotation.SuppressLint
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@@ -10,24 +11,31 @@ import android.content.pm.ServiceInfo
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Bitmap.CompressFormat import android.graphics.Bitmap.CompressFormat
import android.os.Build import android.os.Build
import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper
import android.os.ParcelFileDescriptor import android.os.ParcelFileDescriptor
import android.os.RemoteException
import android.os.SharedMemory
import android.os.SystemClock.uptimeMillis import android.os.SystemClock.uptimeMillis
import android.util.Log import android.util.Log
import androidx.annotation.RequiresApi
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext import kotlinx.coroutines.CancellableContinuation
import org.autojs.autojs.core.plugin.center.PluginEnableStore import org.autojs.autojs.core.plugin.center.PluginEnableStore
import org.autojs.plugin.paddle.ocr.api.IOcrPlugin import org.autojs.plugin.paddle.ocr.api.IOcrPlugin
import org.autojs.plugin.paddle.ocr.api.OcrOptions import org.autojs.plugin.paddle.ocr.api.OcrOptions
import org.autojs.plugin.paddle.ocr.api.OcrResult import org.autojs.plugin.paddle.ocr.api.OcrResult
import org.autojs.plugin.paddle.ocr.api.PluginInfo import org.autojs.plugin.paddle.ocr.api.PluginInfo
import java.io.File import java.io.FileDescriptor
import java.io.FileOutputStream import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
/** /**
* Modified by JetBrains AI Assistant (GPT-5.2-Codex (xhigh)) as of Feb 13, 2026. * Modified by JetBrains AI Assistant (GPT-5.2-Codex (xhigh)) as of Feb 13, 2026.
@@ -40,9 +48,20 @@ object PaddleOcrPluginHost {
private const val DEFAULT_BIND_TIMEOUT_MS = 60_000L private const val DEFAULT_BIND_TIMEOUT_MS = 60_000L
private const val DEFAULT_CALL_TIMEOUT_MS = 60_000L private const val DEFAULT_CALL_TIMEOUT_MS = 60_000L
private const val EXTRA_IMAGE_FORMAT = "imageFormat"
private const val EXTRA_IMAGE_QUALITY = "imageQuality"
private const val EXTRA_RAW_IMAGE = "rawImage"
private const val EXTRA_RAW_WIDTH = "rawWidth"
private const val EXTRA_RAW_HEIGHT = "rawHeight"
private const val EXTRA_RAW_STRIDE = "rawStride"
private const val EXTRA_RAW_CONFIG = "rawConfig"
private const val IDLE_UNBIND_MS = 30_000L
private val externalServiceFlag = runCatching { ServiceInfo::class.java.getField("FLAG_EXTERNAL_SERVICE").getInt(null) }.getOrNull() ?: 0 private val externalServiceFlag = runCatching { ServiceInfo::class.java.getField("FLAG_EXTERNAL_SERVICE").getInt(null) }.getOrNull() ?: 0
private val bindExternalServiceFlag = runCatching { Context::class.java.getField("BIND_EXTERNAL_SERVICE").getInt(null) }.getOrNull() ?: 0 private val bindExternalServiceFlag = runCatching { Context::class.java.getField("BIND_EXTERNAL_SERVICE").getInt(null) }.getOrNull() ?: 0
private val poolHandler = Handler(Looper.getMainLooper())
private val poolLock = Any()
private val connectionPool = LinkedHashMap<ComponentName, PooledConnection>()
data class Discovered( data class Discovered(
val serviceInfo: ServiceInfo, val serviceInfo: ServiceInfo,
@@ -78,8 +97,9 @@ object PaddleOcrPluginHost {
options: OcrOptions = OcrOptions(), options: OcrOptions = OcrOptions(),
callTimeoutMs: Long = DEFAULT_CALL_TIMEOUT_MS, callTimeoutMs: Long = DEFAULT_CALL_TIMEOUT_MS,
): List<String> { ): List<String> {
ensureRawSupport(target, options)
val start = uptimeMillis() val start = uptimeMillis()
return createTempPfd(context, bitmap).use { pfd -> return createTempPfd(bitmap, options).use { pfd ->
withService(context, target.serviceInfo, DEFAULT_BIND_TIMEOUT_MS) { proxy -> withService(context, target.serviceInfo, DEFAULT_BIND_TIMEOUT_MS) { proxy ->
val remain = callTimeoutMs - (uptimeMillis() - start) val remain = callTimeoutMs - (uptimeMillis() - start)
if (remain <= 0) error("AIDL call timeout in ${callTimeoutMs / 1000} seconds") if (remain <= 0) error("AIDL call timeout in ${callTimeoutMs / 1000} seconds")
@@ -97,8 +117,9 @@ object PaddleOcrPluginHost {
options: OcrOptions = OcrOptions(), options: OcrOptions = OcrOptions(),
callTimeoutMs: Long = DEFAULT_CALL_TIMEOUT_MS, callTimeoutMs: Long = DEFAULT_CALL_TIMEOUT_MS,
): List<OcrResult> { ): List<OcrResult> {
ensureRawSupport(target, options)
val start = uptimeMillis() val start = uptimeMillis()
return createTempPfd(context, bitmap).use { pfd -> return createTempPfd(bitmap, options).use { pfd ->
withService(context, target.serviceInfo, DEFAULT_BIND_TIMEOUT_MS) { proxy -> withService(context, target.serviceInfo, DEFAULT_BIND_TIMEOUT_MS) { proxy ->
val remain = callTimeoutMs - (uptimeMillis() - start) val remain = callTimeoutMs - (uptimeMillis() - start)
if (remain <= 0) error("AIDL call timeout") if (remain <= 0) error("AIDL call timeout")
@@ -139,17 +160,147 @@ object PaddleOcrPluginHost {
// Convert temporary file to read-only FD. // Convert temporary file to read-only FD.
// zh-CN: 临时文件转换为只读 FD. // zh-CN: 临时文件转换为只读 FD.
private fun createTempPfd(context: Context, bmp: Bitmap): ParcelFileDescriptor { private fun createTempPfd(bmp: Bitmap, options: OcrOptions): ParcelFileDescriptor {
val dir = File(context.cacheDir, "ocr_ipc").apply { if (!exists()) mkdirs() } val useRaw = options.extras?.getBoolean(EXTRA_RAW_IMAGE, false) == true
val f = File.createTempFile("img_", ".bin", dir) if (useRaw && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
FileOutputStream(f).use { fos -> return runCatching { createRawPfd(bmp, options) }
val format = when { .getOrElse {
bmp.hasAlpha() -> CompressFormat.PNG Log.w(TAG, "raw image transfer failed: ${it.message}")
else -> CompressFormat.JPEG disableRawExtras(options)
} createEncodedPfd(bmp, options)
require(bmp.compress(format, 100, fos)) { "Failed to encode bitmap" } }
}
if (useRaw) {
disableRawExtras(options)
}
return createEncodedPfd(bmp, options)
}
private fun createEncodedPfd(bmp: Bitmap, options: OcrOptions): ParcelFileDescriptor {
val (format, quality) = resolveEncodeOptions(bmp, options)
val pipe = ParcelFileDescriptor.createPipe()
val readFd = pipe[0]
val writeFd = pipe[1]
CoroutineScope(Dispatchers.IO).launch {
runCatching {
ParcelFileDescriptor.AutoCloseOutputStream(writeFd).use { out ->
require(bmp.compress(format, quality, out)) { "Failed to encode bitmap" }
}
}.onFailure { t ->
Log.e(TAG, "encode bitmap failed: ${t.message}")
}
}
return readFd
}
@RequiresApi(Build.VERSION_CODES.O_MR1)
private fun createRawPfd(bmp: Bitmap, options: OcrOptions): ParcelFileDescriptor {
val src = if (bmp.config == Bitmap.Config.ARGB_8888) bmp else bmp.copy(Bitmap.Config.ARGB_8888, false)
val shouldRecycle = src !== bmp
val rowBytes = src.rowBytes
val size = rowBytes * src.height
val extras = options.extras ?: Bundle().also { options.extras = it }
extras.putBoolean(EXTRA_RAW_IMAGE, true)
extras.putInt(EXTRA_RAW_WIDTH, src.width)
extras.putInt(EXTRA_RAW_HEIGHT, src.height)
extras.putInt(EXTRA_RAW_STRIDE, rowBytes)
extras.putString(EXTRA_RAW_CONFIG, Bitmap.Config.ARGB_8888.name)
runCatching {
val shm = SharedMemory.create("ocr_ipc_raw", size)
val buffer = shm.mapReadWrite()
buffer.order(ByteOrder.nativeOrder())
src.copyPixelsToBuffer(buffer)
SharedMemory.unmap(buffer)
val pfd = dupSharedMemoryFd(shm)
shm.close()
if (pfd != null) {
if (shouldRecycle) {
src.recycle()
}
return pfd
}
}.onFailure { t ->
Log.w(TAG, "shared memory unavailable, fallback to raw pipe: ${t.message}")
}
return createRawPipePfd(src, size, shouldRecycle)
}
private fun createRawPipePfd(
bmp: Bitmap,
size: Int,
shouldRecycle: Boolean,
): ParcelFileDescriptor {
val pipe = ParcelFileDescriptor.createPipe()
val readFd = pipe[0]
val writeFd = pipe[1]
val raw = ByteArray(size)
val buffer = ByteBuffer.wrap(raw).order(ByteOrder.nativeOrder())
bmp.copyPixelsToBuffer(buffer)
if (shouldRecycle) {
bmp.recycle()
}
CoroutineScope(Dispatchers.IO).launch {
runCatching {
ParcelFileDescriptor.AutoCloseOutputStream(writeFd).use { out ->
out.write(raw, 0, size)
}
}.onFailure { t ->
Log.e(TAG, "write raw pipe failed: ${t.message}")
}
}
return readFd
}
@SuppressLint("SoonBlockedPrivateApi")
private fun dupSharedMemoryFd(shm: SharedMemory): ParcelFileDescriptor? {
val method = shm.javaClass.methods.firstOrNull { it.name == "getFdDup" && it.parameterCount == 0 }
?: shm.javaClass.methods.firstOrNull { it.name == "getFileDescriptor" && it.parameterCount == 0 }
val result = method?.let { runCatching { it.invoke(shm) }.getOrNull() }
return when (result) {
is ParcelFileDescriptor -> result
is FileDescriptor -> ParcelFileDescriptor.dup(result)
else -> null
}
}
private fun resolveEncodeOptions(bmp: Bitmap, options: OcrOptions): Pair<CompressFormat, Int> {
val extras = options.extras
val formatName = extras?.getString(EXTRA_IMAGE_FORMAT)?.trim()?.lowercase().orEmpty()
val quality = extras?.getInt(EXTRA_IMAGE_QUALITY, 100) ?: 100
val resolvedQuality = quality.coerceIn(1, 100)
val resolvedFormat = when (formatName) {
"png" -> CompressFormat.PNG
"jpg", "jpeg" -> CompressFormat.JPEG
"webp" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) CompressFormat.WEBP_LOSSY else CompressFormat.WEBP
"webp_lossless" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) CompressFormat.WEBP_LOSSLESS else CompressFormat.WEBP
else -> if (bmp.hasAlpha()) CompressFormat.PNG else CompressFormat.JPEG
}
return resolvedFormat to resolvedQuality
}
private fun disableRawExtras(options: OcrOptions) {
val extras = options.extras ?: return
extras.remove(EXTRA_RAW_IMAGE)
extras.remove(EXTRA_RAW_WIDTH)
extras.remove(EXTRA_RAW_HEIGHT)
extras.remove(EXTRA_RAW_STRIDE)
extras.remove(EXTRA_RAW_CONFIG)
}
private fun ensureRawSupport(target: Discovered, options: OcrOptions) {
val extras = options.extras ?: return
if (!extras.getBoolean(EXTRA_RAW_IMAGE, false)) return
val supportsRaw = target.pluginInfo?.capabilities?.getBoolean("supportsRawImage", false) == true
if (!supportsRaw) {
disableRawExtras(options)
} }
return ParcelFileDescriptor.open(f, ParcelFileDescriptor.MODE_READ_ONLY)
} }
private fun queryOcrServices(context: Context, packageName: String? = null): List<ServiceInfo> { private fun queryOcrServices(context: Context, packageName: String? = null): List<ServiceInfo> {
@@ -191,93 +342,268 @@ object PaddleOcrPluginHost {
serviceInfo: ServiceInfo, serviceInfo: ServiceInfo,
bindTimeoutMs: Long = DEFAULT_BIND_TIMEOUT_MS, bindTimeoutMs: Long = DEFAULT_BIND_TIMEOUT_MS,
block: suspend (IOcrPlugin) -> T, block: suspend (IOcrPlugin) -> T,
): T = suspendCancellableCoroutine { cont -> ): T {
val cn = ComponentName(serviceInfo.packageName, serviceInfo.name)
val intent = Intent().setComponent(cn)
val appCtx = context.applicationContext val appCtx = context.applicationContext
val entry = getOrCreateEntry(appCtx, serviceInfo)
val proxy = awaitProxy(entry, bindTimeoutMs)
return try {
block(proxy)
} catch (e: RemoteException) {
retryAfterBinderFailure(entry, bindTimeoutMs, e, block)
} finally {
touch(entry)
}
}
var resolved = false private suspend fun <T> retryAfterBinderFailure(
var jobRef: Job? = null entry: PooledConnection,
bindTimeoutMs: Long,
error: RemoteException,
block: suspend (IOcrPlugin) -> T,
): T {
invalidateEntry(entry, error)
val proxy = awaitProxy(entry, bindTimeoutMs)
return block(proxy)
}
val conn = object : ServiceConnection { private fun touch(entry: PooledConnection) {
synchronized(entry.lock) {
entry.lastUsed = uptimeMillis()
}
scheduleIdleUnbind(entry)
}
private suspend fun awaitProxy(entry: PooledConnection, bindTimeoutMs: Long): IOcrPlugin =
suspendCancellableCoroutine { cont ->
var shouldBind = false
synchronized(entry.lock) {
entry.lastUsed = uptimeMillis()
cancelIdleUnbindLocked(entry)
val existing = entry.proxy
if (existing != null && entry.bound) {
cont.resume(existing)
return@suspendCancellableCoroutine
}
entry.waiters.add(cont)
if (!entry.connecting) {
entry.connecting = true
shouldBind = true
}
}
if (shouldBind) {
startBind(entry, bindTimeoutMs)
}
cont.invokeOnCancellation {
synchronized(entry.lock) {
entry.waiters.remove(cont)
}
}
}
private fun startBind(entry: PooledConnection, bindTimeoutMs: Long) {
val intent = Intent().setComponent(entry.cn)
val bindFlags = buildBindFlags(entry.serviceInfo)
var ok = try {
Log.i(TAG, "bindService: ${entry.cn}")
entry.appCtx.bindService(intent, entry.connection, bindFlags)
} catch (se: SecurityException) {
failWaiters(
entry,
IllegalStateException(
"bindService SecurityException: ${entry.cn}. Please make sure the plugin declares <uses-permission android:name=\"org.autojs.permission.PLUGIN\"/> and the Service uses this permission.",
se
)
)
return
}
if (!ok && bindFlags != Context.BIND_AUTO_CREATE) {
ok = try {
entry.appCtx.bindService(intent, entry.connection, Context.BIND_AUTO_CREATE)
} catch (se: SecurityException) {
failWaiters(
entry,
IllegalStateException(
"bindService SecurityException: ${entry.cn}. Please make sure the plugin declares <uses-permission android:name=\"org.autojs.permission.PLUGIN\"/> and the Service uses this permission.",
se
)
)
return
}
}
if (!ok) {
val msg = buildBindFailureMessage(entry.appCtx, entry.serviceInfo, entry.cn)
Log.e(TAG, msg)
failWaiters(entry, IllegalStateException(msg))
return
}
val timeout = Runnable {
val pending = synchronized(entry.lock) {
entry.connecting = false
val list = entry.waiters.toList()
entry.waiters.clear()
entry.bindTimeoutRunnable = null
list
}
if (pending.isNotEmpty()) {
Log.e(TAG, "bindService timeout: ${entry.cn}")
pending.forEach { waiter ->
if (!waiter.isCompleted) {
waiter.resumeWithException(TimeoutException("bindService timeout: ${entry.cn}"))
}
}
runCatching { entry.appCtx.unbindService(entry.connection) }
}
}
synchronized(entry.lock) {
entry.bindTimeoutRunnable?.let { poolHandler.removeCallbacks(it) }
entry.bindTimeoutRunnable = timeout
}
poolHandler.postDelayed(timeout, bindTimeoutMs)
}
private fun invalidateEntry(entry: PooledConnection, error: Throwable) {
val shouldUnbind = synchronized(entry.lock) {
entry.proxy = null
entry.bound = false
entry.connecting = false
entry.bindTimeoutRunnable?.let { poolHandler.removeCallbacks(it) }
entry.bindTimeoutRunnable = null
entry.idleRunnable?.let { poolHandler.removeCallbacks(it) }
entry.idleRunnable = null
true
}
if (shouldUnbind) {
poolHandler.post {
runCatching { entry.appCtx.unbindService(entry.connection) }
Log.w(TAG, "invalidateEntry: ${entry.cn} | ${error.message}")
}
}
}
private fun failWaiters(entry: PooledConnection, error: Throwable) {
val pending = synchronized(entry.lock) {
entry.connecting = false
entry.bindTimeoutRunnable?.let { poolHandler.removeCallbacks(it) }
entry.bindTimeoutRunnable = null
val list = entry.waiters.toList()
entry.waiters.clear()
list
}
pending.forEach { waiter ->
if (!waiter.isCompleted) {
waiter.resumeWithException(error)
}
}
}
private fun scheduleIdleUnbind(entry: PooledConnection) {
val runnable = Runnable {
val shouldUnbind = synchronized(entry.lock) {
val idleEnough = uptimeMillis() - entry.lastUsed >= IDLE_UNBIND_MS
idleEnough && entry.bound && entry.proxy != null && !entry.connecting
}
if (shouldUnbind) {
runCatching { entry.appCtx.unbindService(entry.connection) }
synchronized(entry.lock) {
entry.proxy = null
entry.bound = false
}
}
}
synchronized(entry.lock) {
entry.idleRunnable?.let { poolHandler.removeCallbacks(it) }
entry.idleRunnable = runnable
}
poolHandler.postDelayed(runnable, IDLE_UNBIND_MS)
}
private fun cancelIdleUnbindLocked(entry: PooledConnection) {
entry.idleRunnable?.let { poolHandler.removeCallbacks(it) }
entry.idleRunnable = null
}
private fun getOrCreateEntry(appCtx: Context, serviceInfo: ServiceInfo): PooledConnection {
val cn = ComponentName(serviceInfo.packageName, serviceInfo.name)
synchronized(poolLock) {
return connectionPool.getOrPut(cn) {
PooledConnection(appCtx, serviceInfo)
}
}
}
private class PooledConnection(
val appCtx: Context,
val serviceInfo: ServiceInfo,
) {
val cn: ComponentName = ComponentName(serviceInfo.packageName, serviceInfo.name)
val lock = Any()
val waiters = ArrayList<CancellableContinuation<IOcrPlugin>>()
var proxy: IOcrPlugin? = null
var bound: Boolean = false
var connecting: Boolean = false
var lastUsed: Long = 0L
var idleRunnable: Runnable? = null
var bindTimeoutRunnable: Runnable? = null
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) { override fun onServiceConnected(name: ComponentName, binder: IBinder) {
Log.i(TAG, "onServiceConnected: $name") Log.i(TAG, "onServiceConnected: $name")
val proxy = IOcrPlugin.Stub.asInterface(binder) val proxy = IOcrPlugin.Stub.asInterface(binder)
val self = this val pending = synchronized(lock) {
this@PooledConnection.proxy = proxy
jobRef = CoroutineScope(cont.context + Dispatchers.IO).launch { bound = true
val result = runCatching { block(proxy) } connecting = false
try { lastUsed = uptimeMillis()
resolved = true bindTimeoutRunnable?.let { poolHandler.removeCallbacks(it) }
if (!cont.isCompleted) cont.resumeWith(result) bindTimeoutRunnable = null
Log.i(TAG, "resume continuation: success=${result.isSuccess}") val list = waiters.toList()
} finally { waiters.clear()
withContext(Dispatchers.Main) { list
runCatching { appCtx.unbindService(self) } }
Log.i(TAG, "unbindService: $name") pending.forEach { waiter ->
} if (!waiter.isCompleted) {
waiter.resume(proxy)
} }
} }
} }
override fun onServiceDisconnected(name: ComponentName) { override fun onServiceDisconnected(name: ComponentName) {
Log.w(TAG, "onServiceDisconnected: $name") Log.w(TAG, "onServiceDisconnected: $name")
handleDisconnect(name, "onServiceDisconnected")
}
override fun onBindingDied(name: ComponentName) {
Log.w(TAG, "onBindingDied: $name")
handleDisconnect(name, "onBindingDied")
}
override fun onNullBinding(name: ComponentName) {
Log.w(TAG, "onNullBinding: $name")
handleDisconnect(name, "onNullBinding")
} }
} }
val bindFlags = buildBindFlags(serviceInfo) private fun handleDisconnect(name: ComponentName, reason: String) {
var ok = try { val pending = synchronized(lock) {
Log.i(TAG, "bindService: $cn") proxy = null
appCtx.bindService(intent, conn, bindFlags) bound = false
} catch (se: SecurityException) { connecting = false
Log.e(TAG, "bindService SecurityException: $cn | ${se.message}") bindTimeoutRunnable?.let { poolHandler.removeCallbacks(it) }
cont.resumeWith( bindTimeoutRunnable = null
Result.failure( val list = waiters.toList()
IllegalStateException( waiters.clear()
"bindService SecurityException: $cn. Please make sure the plugin declares <uses-permission android:name=\"org.autojs.permission.PLUGIN\"/> and the Service uses this permission.", se list
)
)
)
return@suspendCancellableCoroutine
}
if (!ok && bindFlags != Context.BIND_AUTO_CREATE) {
ok = try {
appCtx.bindService(intent, conn, Context.BIND_AUTO_CREATE)
} catch (se: SecurityException) {
Log.e(TAG, "bindService SecurityException: $cn | ${se.message}")
cont.resumeWith(
Result.failure(
IllegalStateException(
"bindService SecurityException: $cn. Please make sure the plugin declares <uses-permission android:name=\"org.autojs.permission.PLUGIN\"/> and the Service uses this permission.", se
)
)
)
return@suspendCancellableCoroutine
} }
} if (pending.isNotEmpty()) {
if (!ok) { val error = IllegalStateException("$reason: $name")
val msg = buildBindFailureMessage(appCtx, serviceInfo, cn) pending.forEach { waiter ->
Log.e(TAG, msg) if (!waiter.isCompleted) {
cont.resumeWith(Result.failure(IllegalStateException(msg))) waiter.resumeWithException(error)
return@suspendCancellableCoroutine }
} }
val cancel = Runnable {
if (!resolved && !cont.isCompleted) {
Log.e(TAG, "bindService timeout: $cn")
cont.resumeWith(Result.failure(TimeoutException("bindService timeout: $cn")))
runCatching { appCtx.unbindService(conn) }
} }
} }
val h = Handler(appCtx.mainLooper)
h.postDelayed(cancel, bindTimeoutMs)
cont.invokeOnCancellation {
Log.w(TAG, "continuation cancelled: $cn")
h.removeCallbacks(cancel)
jobRef?.cancel()
runCatching { appCtx.unbindService(conn) }
}
} }
private class TimeoutException(msg: String) : RuntimeException(msg) private class TimeoutException(msg: String) : RuntimeException(msg)

View File

@@ -1,5 +1,8 @@
package org.autojs.autojs.runtime.api.augment.ocr package org.autojs.autojs.runtime.api.augment.ocr
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.autojs.autojs.AbstractAutoJs.Companion.isInrt import org.autojs.autojs.AbstractAutoJs.Companion.isInrt
import org.autojs.autojs.annotation.RhinoRuntimeFunctionInterface import org.autojs.autojs.annotation.RhinoRuntimeFunctionInterface
@@ -14,12 +17,19 @@ import org.autojs.autojs.runtime.api.augment.Invokable
import org.autojs.autojs.runtime.api.augment.ocr.Ocr.Companion.OcrMode import org.autojs.autojs.runtime.api.augment.ocr.Ocr.Companion.OcrMode
import org.autojs.autojs.runtime.exception.WrappedIllegalArgumentException import org.autojs.autojs.runtime.exception.WrappedIllegalArgumentException
import org.autojs.autojs.util.RhinoUtils.coerceBoolean import org.autojs.autojs.util.RhinoUtils.coerceBoolean
import org.autojs.autojs.util.RhinoUtils.coerceFloatNumber
import org.autojs.autojs.util.RhinoUtils.coerceIntNumber import org.autojs.autojs.util.RhinoUtils.coerceIntNumber
import org.autojs.autojs.util.RhinoUtils.coerceString
import org.autojs.autojs6.R import org.autojs.autojs6.R
import org.autojs.plugin.paddle.ocr.api.OcrOptions import org.autojs.plugin.paddle.ocr.api.OcrOptions
import org.mozilla.javascript.NativeArray import org.mozilla.javascript.NativeArray
import org.mozilla.javascript.NativeObject import org.mozilla.javascript.NativeObject
import kotlin.math.abs
import kotlin.math.max
/**
* Modified by JetBrains AI Assistant (GPT-5.2-Codex (xhigh)) as of Feb 13, 2026.
*/
@Suppress("unused") @Suppress("unused")
class OcrPaddle(private val scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), Invokable { class OcrPaddle(private val scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), Invokable {
@@ -35,8 +45,14 @@ class OcrPaddle(private val scriptRuntime: ScriptRuntime) : Augmentable(scriptRu
companion object { companion object {
private const val DEFAULT_CPU_THREAD_NUM = 4 private const val DEFAULT_CPU_THREAD_NUM = 4
private const val DEFAULT_USE_SLIM = true private const val DEFAULT_USE_SLIM = true
private const val DEFAULT_USE_RAW = true
private const val DEFAULT_USE_OPENCL = false private const val DEFAULT_USE_OPENCL = false
private const val DEFAULT_MERGE_LINE = false
private const val EXTRA_RAW_IMAGE = "rawImage"
@JvmStatic @JvmStatic
@RhinoRuntimeFunctionInterface @RhinoRuntimeFunctionInterface
@@ -52,11 +68,15 @@ class OcrPaddle(private val scriptRuntime: ScriptRuntime) : Augmentable(scriptRu
fun recognizeTextImpl(scriptRuntime: ScriptRuntime, image: ImageWrapper, options: NativeObject): List<String> { fun recognizeTextImpl(scriptRuntime: ScriptRuntime, image: ImageWrapper, options: NativeObject): List<String> {
ApkBuilder.Lib.PADDLE_OCR.ensureLibFiles(OcrMode.PADDLE.value) ApkBuilder.Lib.PADDLE_OCR.ensureLibFiles(OcrMode.PADDLE.value)
val (cpuThreadNum, useSlim, useOpenCL) = getOcrOptions(options) val parsed = getOcrOptions(options)
val (cpuThreadNum, useSlim, useOpenCL) = parsed
val ocrOptions = OcrOptions().apply { val ocrOptions = OcrOptions().apply {
this.cpuThreadNum = cpuThreadNum this.cpuThreadNum = cpuThreadNum
this.useSlim = useSlim this.useSlim = useSlim
this.useOpenCL = useOpenCL this.useOpenCL = useOpenCL
this.detLongSize = parsed.detLongSize
this.scoreThreshold = parsed.scoreThreshold
this.extras = parsed.extras
} }
return if (!isInrt) { return if (!isInrt) {
runBlocking(scriptRuntime.coroutineContext) { runBlocking(scriptRuntime.coroutineContext) {
@@ -73,13 +93,17 @@ class OcrPaddle(private val scriptRuntime: ScriptRuntime) : Augmentable(scriptRu
fun detectImpl(scriptRuntime: ScriptRuntime, image: ImageWrapper, options: NativeObject): List<OcrResult> { fun detectImpl(scriptRuntime: ScriptRuntime, image: ImageWrapper, options: NativeObject): List<OcrResult> {
ApkBuilder.Lib.PADDLE_OCR.ensureLibFiles(OcrMode.PADDLE.value) ApkBuilder.Lib.PADDLE_OCR.ensureLibFiles(OcrMode.PADDLE.value)
val (cpuThreadNum, useSlim, useOpenCL) = getOcrOptions(options) val parsed = getOcrOptions(options)
val (cpuThreadNum, useSlim, useOpenCL) = parsed
val ocrOptions = OcrOptions().apply { val ocrOptions = OcrOptions().apply {
this.cpuThreadNum = cpuThreadNum this.cpuThreadNum = cpuThreadNum
this.useSlim = useSlim this.useSlim = useSlim
this.useOpenCL = useOpenCL this.useOpenCL = useOpenCL
this.detLongSize = parsed.detLongSize
this.scoreThreshold = parsed.scoreThreshold
this.extras = parsed.extras
} }
return if (!isInrt) { val results = if (!isInrt) {
runBlocking(scriptRuntime.coroutineContext) { runBlocking(scriptRuntime.coroutineContext) {
val target = PaddleOcrPluginHost.select(globalContext) val target = PaddleOcrPluginHost.select(globalContext)
?: throw WrappedIllegalArgumentException(globalContext.getString(R.string.error_no_paddle_ocr_plugins_available)) ?: throw WrappedIllegalArgumentException(globalContext.getString(R.string.error_no_paddle_ocr_plugins_available))
@@ -92,17 +116,106 @@ class OcrPaddle(private val scriptRuntime: ScriptRuntime) : Augmentable(scriptRu
OcrResult(it.text, it.confidence, it.bounds) OcrResult(it.text, it.confidence, it.bounds)
} }
} }
return if (parsed.mergeLine) mergeByLine(results) else results
} }
private fun getOcrOptions(options: NativeObject): OcrOptions { private fun getOcrOptions(options: NativeObject): ParsedOptions {
val cpuThreadNum = options.inquire("cpuThreadNum", ::coerceIntNumber, DEFAULT_CPU_THREAD_NUM) val cpuThreadNum = options.inquire("cpuThreadNum", ::coerceIntNumber, DEFAULT_CPU_THREAD_NUM)
val useSlim = options.inquire("useSlim", ::coerceBoolean, DEFAULT_USE_SLIM) val useSlim = options.inquire("useSlim", ::coerceBoolean, DEFAULT_USE_SLIM)
val useOpenCL = options.inquire("useOpenCL", ::coerceBoolean, DEFAULT_USE_OPENCL) val useOpenCL = options.inquire("useOpenCL", ::coerceBoolean, DEFAULT_USE_OPENCL)
return OcrOptions(cpuThreadNum, useSlim, useOpenCL) val detLongSize = options.inquire("detLongSize", ::coerceIntNumber, 0)
val scoreThreshold = options.inquire("scoreThreshold", ::coerceFloatNumber, -1f)
val mergeLine = options.inquire("mergeLine", ::coerceBoolean, DEFAULT_MERGE_LINE)
val splitWords = options.inquire("splitWords", ::coerceBoolean, false)
val useWordSegmentation = options.inquire("useWordSegmentation", ::coerceBoolean, false)
val useRaw = options.inquire("useRaw", ::coerceBoolean, DEFAULT_USE_RAW) ||
options.inquire("raw", ::coerceBoolean, false)
val imageQuality = options.inquire("imageQuality", ::coerceIntNumber, -1)
val imageFormat = options.inquire("imageFormat", ::coerceString, "")
val extras = Bundle()
if (useRaw && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
extras.putBoolean(EXTRA_RAW_IMAGE, true)
}
if (imageQuality > 0) {
extras.putInt("imageQuality", imageQuality)
}
if (imageFormat.isNotBlank()) {
extras.putString("imageFormat", imageFormat)
}
return ParsedOptions(
cpuThreadNum = cpuThreadNum,
useSlim = useSlim,
useOpenCL = useOpenCL,
detLongSize = detLongSize,
scoreThreshold = scoreThreshold,
mergeLine = mergeLine && !splitWords && !useWordSegmentation,
extras = extras.takeIf { !it.isEmpty } ?: Bundle(),
)
} }
private data class OcrOptions(val cpuThreadNum: Int, val useSlim: Boolean, val useOpenCL: Boolean) private data class ParsedOptions(
val cpuThreadNum: Int,
val useSlim: Boolean,
val useOpenCL: Boolean,
val detLongSize: Int,
val scoreThreshold: Float,
val mergeLine: Boolean,
val extras: Bundle,
)
private fun mergeByLine(results: List<OcrResult>): List<OcrResult> {
if (results.size <= 1) return results
val sorted = results.sorted()
val merged = ArrayList<OcrResult>(sorted.size)
var text = StringBuilder()
var bounds = Rect()
var weightSum = 0
var confidenceSum = 0f
var last = sorted.first()
fun add(r: OcrResult) {
if (text.isEmpty()) {
bounds = Rect(r.bounds)
} else {
bounds.union(r.bounds)
}
text.append(r.text)
val weight = max(1, r.text.length)
weightSum += weight
confidenceSum += r.confidence * weight
last = r
}
fun flush() {
if (text.isNotEmpty()) {
val confidence = if (weightSum > 0) confidenceSum / weightSum else 0f
merged.add(OcrResult(text.toString(), confidence, Rect(bounds)))
}
text = StringBuilder()
weightSum = 0
confidenceSum = 0f
}
add(last)
for (i in 1 until sorted.size) {
val r = sorted[i]
val deviation = max(last.bounds.height(), r.bounds.height()) / 2
val lastCenter = (last.bounds.top + last.bounds.bottom) / 2
val rCenter = (r.bounds.top + r.bounds.bottom) / 2
if (abs(lastCenter - rCenter) < deviation) {
add(r)
} else {
flush()
add(r)
}
}
flush()
return merged
}
} }
} }

View File

@@ -14,6 +14,7 @@ import org.autojs.plugin.paddle.ocr.api.OcrResult
* *
* Created by JetBrains AI Assistant (GPT-5.2) on Jan 17, 2026. * Created by JetBrains AI Assistant (GPT-5.2) on Jan 17, 2026.
* Modified by SuperMonster003 as of Jan 18, 2026. * Modified by SuperMonster003 as of Jan 18, 2026.
* Modified by JetBrains AI Assistant (GPT-5.2-Codex (xhigh)) as of Feb 13, 2026.
*/ */
class PredictorNativeBridge : NativeBridge { class PredictorNativeBridge : NativeBridge {
@@ -110,6 +111,7 @@ class PredictorNativeBridge : NativeBridge {
} }
override fun recognizeText(bitmap: Bitmap, options: OcrOptions): List<String> { override fun recognizeText(bitmap: Bitmap, options: OcrOptions): List<String> {
applyRuntimeOptions(options)
val results = predictor.runOcr(bitmap) val results = predictor.runOcr(bitmap)
val out = ArrayList<String>(results.size) val out = ArrayList<String>(results.size)
for (r in results) out.add(r.label) for (r in results) out.add(r.label)
@@ -124,6 +126,7 @@ class PredictorNativeBridge : NativeBridge {
} }
override fun detect(bitmap: Bitmap, options: OcrOptions): List<OcrResult> { override fun detect(bitmap: Bitmap, options: OcrOptions): List<OcrResult> {
applyRuntimeOptions(options)
val results = predictor.runOcr(bitmap) val results = predictor.runOcr(bitmap)
return results.map { r -> return results.map { r ->
OcrResult().apply { OcrResult().apply {
@@ -135,4 +138,15 @@ class PredictorNativeBridge : NativeBridge {
} }
} }
private fun applyRuntimeOptions(options: OcrOptions) {
val detLongSize = options.detLongSize
if (detLongSize > 0) {
predictor.setDetLongSize(detLongSize)
}
val scoreThreshold = options.scoreThreshold
if (scoreThreshold >= 0f) {
predictor.setScoreThreshold(scoreThreshold)
}
}
} }

View File

@@ -1,5 +1,5 @@
#Fri Feb 13 15:23:40 CST 2026 #Sat Feb 14 14:01:58 CST 2026
BUILD_TIME=1770967420765 BUILD_TIME=1771048918130
COMPILE_SDK_VERSION=36 COMPILE_SDK_VERSION=36
IMAGE_QUANT_CMAKE_VERSION=3.22.1 IMAGE_QUANT_CMAKE_VERSION=3.22.1
IMAGE_QUANT_NDK_VERSION=26.1.10909125 IMAGE_QUANT_NDK_VERSION=26.1.10909125
@@ -27,6 +27,6 @@ RAPID_OCR_OPENCV_MOBILE_LABEL_VERSION=13
RAPID_OCR_OPENCV_MOBILE_VERSION=4.5.3 RAPID_OCR_OPENCV_MOBILE_VERSION=4.5.3
TARGET_SDK_VERSION=36 TARGET_SDK_VERSION=36
TARGET_SDK_VERSION_INRT=29 TARGET_SDK_VERSION_INRT=29
VERSION_BUILD=3738 VERSION_BUILD=3742
VERSION_NAME=6.7.0 Alpha20 VERSION_NAME=6.7.0 Alpha20
VSCODE_EXT_REQUIRED_VERSION=1.0.13 VSCODE_EXT_REQUIRED_VERSION=1.0.13