From 43b1e890fd7bcedb3778b761be8449c64025e583 Mon Sep 17 00:00:00 2001 From: SuperMonster003 Date: Sat, 14 Feb 2026 14:22:58 +0800 Subject: [PATCH] =?UTF-8?q?6.7.0=20-=20Alpha20=20-=20Paddle=20OCR=20?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8E=9F=E5=A7=8B=E5=9B=BE=E5=83=8F=E4=BC=A0?= =?UTF-8?q?=E8=BE=93=E4=BB=A5=E6=8F=90=E5=8D=87=E6=80=A7=E8=83=BD,=20?= =?UTF-8?q?=E5=B9=B6=E6=94=AF=E6=8C=81=E6=9B=B4=E7=81=B5=E6=B4=BB=E7=9A=84?= =?UTF-8?q?=E9=80=89=E9=A1=B9=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/plugin/ocr/PaddleOcrPluginHost.kt | 496 +++++++++++++++--- .../runtime/api/augment/ocr/OcrPaddle.kt | 127 ++++- .../paddle/lite/ocr/PredictorNativeBridge.kt | 14 + version.properties | 6 +- 4 files changed, 548 insertions(+), 95 deletions(-) diff --git a/app/src/main/java/org/autojs/autojs/core/plugin/ocr/PaddleOcrPluginHost.kt b/app/src/main/java/org/autojs/autojs/core/plugin/ocr/PaddleOcrPluginHost.kt index ff7f5a15..8900b5bc 100644 --- a/app/src/main/java/org/autojs/autojs/core/plugin/ocr/PaddleOcrPluginHost.kt +++ b/app/src/main/java/org/autojs/autojs/core/plugin/ocr/PaddleOcrPluginHost.kt @@ -1,5 +1,6 @@ package org.autojs.autojs.core.plugin.ocr +import android.annotation.SuppressLint import android.content.ComponentName import android.content.Context import android.content.Intent @@ -10,24 +11,31 @@ import android.content.pm.ServiceInfo import android.graphics.Bitmap import android.graphics.Bitmap.CompressFormat import android.os.Build +import android.os.Bundle import android.os.Handler import android.os.IBinder +import android.os.Looper import android.os.ParcelFileDescriptor +import android.os.RemoteException +import android.os.SharedMemory import android.os.SystemClock.uptimeMillis import android.util.Log +import androidx.annotation.RequiresApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.coroutines.withContext +import kotlinx.coroutines.CancellableContinuation import org.autojs.autojs.core.plugin.center.PluginEnableStore import org.autojs.plugin.paddle.ocr.api.IOcrPlugin import org.autojs.plugin.paddle.ocr.api.OcrOptions import org.autojs.plugin.paddle.ocr.api.OcrResult import org.autojs.plugin.paddle.ocr.api.PluginInfo -import java.io.File -import java.io.FileOutputStream +import java.io.FileDescriptor +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. @@ -40,9 +48,20 @@ object PaddleOcrPluginHost { private const val DEFAULT_BIND_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 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() data class Discovered( val serviceInfo: ServiceInfo, @@ -78,8 +97,9 @@ object PaddleOcrPluginHost { options: OcrOptions = OcrOptions(), callTimeoutMs: Long = DEFAULT_CALL_TIMEOUT_MS, ): List { + ensureRawSupport(target, options) val start = uptimeMillis() - return createTempPfd(context, bitmap).use { pfd -> + return createTempPfd(bitmap, options).use { pfd -> withService(context, target.serviceInfo, DEFAULT_BIND_TIMEOUT_MS) { proxy -> val remain = callTimeoutMs - (uptimeMillis() - start) if (remain <= 0) error("AIDL call timeout in ${callTimeoutMs / 1000} seconds") @@ -97,8 +117,9 @@ object PaddleOcrPluginHost { options: OcrOptions = OcrOptions(), callTimeoutMs: Long = DEFAULT_CALL_TIMEOUT_MS, ): List { + ensureRawSupport(target, options) val start = uptimeMillis() - return createTempPfd(context, bitmap).use { pfd -> + return createTempPfd(bitmap, options).use { pfd -> withService(context, target.serviceInfo, DEFAULT_BIND_TIMEOUT_MS) { proxy -> val remain = callTimeoutMs - (uptimeMillis() - start) if (remain <= 0) error("AIDL call timeout") @@ -139,17 +160,147 @@ object PaddleOcrPluginHost { // Convert temporary file to read-only FD. // zh-CN: 临时文件转换为只读 FD. - private fun createTempPfd(context: Context, bmp: Bitmap): ParcelFileDescriptor { - val dir = File(context.cacheDir, "ocr_ipc").apply { if (!exists()) mkdirs() } - val f = File.createTempFile("img_", ".bin", dir) - FileOutputStream(f).use { fos -> - val format = when { - bmp.hasAlpha() -> CompressFormat.PNG - else -> CompressFormat.JPEG - } - require(bmp.compress(format, 100, fos)) { "Failed to encode bitmap" } + private fun createTempPfd(bmp: Bitmap, options: OcrOptions): ParcelFileDescriptor { + val useRaw = options.extras?.getBoolean(EXTRA_RAW_IMAGE, false) == true + if (useRaw && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + return runCatching { createRawPfd(bmp, options) } + .getOrElse { + Log.w(TAG, "raw image transfer failed: ${it.message}") + disableRawExtras(options) + createEncodedPfd(bmp, options) + } + } + 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 { + 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 { @@ -191,93 +342,268 @@ object PaddleOcrPluginHost { serviceInfo: ServiceInfo, bindTimeoutMs: Long = DEFAULT_BIND_TIMEOUT_MS, block: suspend (IOcrPlugin) -> T, - ): T = suspendCancellableCoroutine { cont -> - val cn = ComponentName(serviceInfo.packageName, serviceInfo.name) - val intent = Intent().setComponent(cn) + ): T { 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 - var jobRef: Job? = null + private suspend fun retryAfterBinderFailure( + 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 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 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>() + 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) { Log.i(TAG, "onServiceConnected: $name") val proxy = IOcrPlugin.Stub.asInterface(binder) - val self = this - - jobRef = CoroutineScope(cont.context + Dispatchers.IO).launch { - val result = runCatching { block(proxy) } - try { - resolved = true - if (!cont.isCompleted) cont.resumeWith(result) - Log.i(TAG, "resume continuation: success=${result.isSuccess}") - } finally { - withContext(Dispatchers.Main) { - runCatching { appCtx.unbindService(self) } - Log.i(TAG, "unbindService: $name") - } + val pending = synchronized(lock) { + this@PooledConnection.proxy = proxy + bound = true + connecting = false + lastUsed = uptimeMillis() + bindTimeoutRunnable?.let { poolHandler.removeCallbacks(it) } + bindTimeoutRunnable = null + val list = waiters.toList() + waiters.clear() + list + } + pending.forEach { waiter -> + if (!waiter.isCompleted) { + waiter.resume(proxy) } } } override fun onServiceDisconnected(name: ComponentName) { 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) - var ok = try { - Log.i(TAG, "bindService: $cn") - appCtx.bindService(intent, conn, bindFlags) - } 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 and the Service uses this permission.", se - ) - ) - ) - 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 and the Service uses this permission.", se - ) - ) - ) - return@suspendCancellableCoroutine + private fun handleDisconnect(name: ComponentName, reason: String) { + val pending = synchronized(lock) { + proxy = null + bound = false + connecting = false + bindTimeoutRunnable?.let { poolHandler.removeCallbacks(it) } + bindTimeoutRunnable = null + val list = waiters.toList() + waiters.clear() + list } - } - if (!ok) { - val msg = buildBindFailureMessage(appCtx, serviceInfo, cn) - Log.e(TAG, msg) - cont.resumeWith(Result.failure(IllegalStateException(msg))) - 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) } + if (pending.isNotEmpty()) { + val error = IllegalStateException("$reason: $name") + pending.forEach { waiter -> + if (!waiter.isCompleted) { + waiter.resumeWithException(error) + } + } } } - 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) diff --git a/app/src/main/java/org/autojs/autojs/runtime/api/augment/ocr/OcrPaddle.kt b/app/src/main/java/org/autojs/autojs/runtime/api/augment/ocr/OcrPaddle.kt index 88951a40..5f2970f3 100644 --- a/app/src/main/java/org/autojs/autojs/runtime/api/augment/ocr/OcrPaddle.kt +++ b/app/src/main/java/org/autojs/autojs/runtime/api/augment/ocr/OcrPaddle.kt @@ -1,5 +1,8 @@ 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 org.autojs.autojs.AbstractAutoJs.Companion.isInrt 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.exception.WrappedIllegalArgumentException 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.coerceString import org.autojs.autojs6.R import org.autojs.plugin.paddle.ocr.api.OcrOptions import org.mozilla.javascript.NativeArray 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") class OcrPaddle(private val scriptRuntime: ScriptRuntime) : Augmentable(scriptRuntime), Invokable { @@ -35,8 +45,14 @@ class OcrPaddle(private val scriptRuntime: ScriptRuntime) : Augmentable(scriptRu companion object { private const val DEFAULT_CPU_THREAD_NUM = 4 + 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_MERGE_LINE = false + + private const val EXTRA_RAW_IMAGE = "rawImage" @JvmStatic @RhinoRuntimeFunctionInterface @@ -52,11 +68,15 @@ class OcrPaddle(private val scriptRuntime: ScriptRuntime) : Augmentable(scriptRu fun recognizeTextImpl(scriptRuntime: ScriptRuntime, image: ImageWrapper, options: NativeObject): List { 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 { this.cpuThreadNum = cpuThreadNum this.useSlim = useSlim this.useOpenCL = useOpenCL + this.detLongSize = parsed.detLongSize + this.scoreThreshold = parsed.scoreThreshold + this.extras = parsed.extras } return if (!isInrt) { runBlocking(scriptRuntime.coroutineContext) { @@ -73,13 +93,17 @@ class OcrPaddle(private val scriptRuntime: ScriptRuntime) : Augmentable(scriptRu fun detectImpl(scriptRuntime: ScriptRuntime, image: ImageWrapper, options: NativeObject): List { 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 { this.cpuThreadNum = cpuThreadNum this.useSlim = useSlim 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) { val target = PaddleOcrPluginHost.select(globalContext) ?: 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) } } + 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 useSlim = options.inquire("useSlim", ::coerceBoolean, DEFAULT_USE_SLIM) 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): List { + if (results.size <= 1) return results + val sorted = results.sorted() + val merged = ArrayList(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 + } } -} \ No newline at end of file +} diff --git a/plugin-api/paddle-ocr-engine/src/main/java/com/baidu/paddle/lite/ocr/PredictorNativeBridge.kt b/plugin-api/paddle-ocr-engine/src/main/java/com/baidu/paddle/lite/ocr/PredictorNativeBridge.kt index ef92fbb4..be3dc92d 100644 --- a/plugin-api/paddle-ocr-engine/src/main/java/com/baidu/paddle/lite/ocr/PredictorNativeBridge.kt +++ b/plugin-api/paddle-ocr-engine/src/main/java/com/baidu/paddle/lite/ocr/PredictorNativeBridge.kt @@ -14,6 +14,7 @@ import org.autojs.plugin.paddle.ocr.api.OcrResult * * Created by JetBrains AI Assistant (GPT-5.2) on Jan 17, 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 { @@ -110,6 +111,7 @@ class PredictorNativeBridge : NativeBridge { } override fun recognizeText(bitmap: Bitmap, options: OcrOptions): List { + applyRuntimeOptions(options) val results = predictor.runOcr(bitmap) val out = ArrayList(results.size) for (r in results) out.add(r.label) @@ -124,6 +126,7 @@ class PredictorNativeBridge : NativeBridge { } override fun detect(bitmap: Bitmap, options: OcrOptions): List { + applyRuntimeOptions(options) val results = predictor.runOcr(bitmap) return results.map { r -> 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) + } + } + } diff --git a/version.properties b/version.properties index 01582e72..3e8a327f 100644 --- a/version.properties +++ b/version.properties @@ -1,5 +1,5 @@ -#Fri Feb 13 15:23:40 CST 2026 -BUILD_TIME=1770967420765 +#Sat Feb 14 14:01:58 CST 2026 +BUILD_TIME=1771048918130 COMPILE_SDK_VERSION=36 IMAGE_QUANT_CMAKE_VERSION=3.22.1 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 TARGET_SDK_VERSION=36 TARGET_SDK_VERSION_INRT=29 -VERSION_BUILD=3738 +VERSION_BUILD=3742 VERSION_NAME=6.7.0 Alpha20 VSCODE_EXT_REQUIRED_VERSION=1.0.13