From 557bc6709d1ac85204c5d9e2a6f76de2df37b10b Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 2 Aug 2026 22:05:20 +0800 Subject: [PATCH 01/31] refactor: remove KNearest OCR pipeline from core - delete OcrDigits.kt (HOG feature extraction + findNearest helpers) - remove kNearestModel parameter from DeviceOcr constructor - remove pfl/pure/far/lost/score/maxRecall methods (dead code since ONNX migration) - remove FixRects (only used by removed KNN digit pipeline) --- .../arcaeaoffline/core/ocr/OcrDigits.kt | 108 ---------------- .../sevive/arcaeaoffline/core/ocr/Utils.kt | 115 ------------------ .../core/ocr/device/DeviceOcr.kt | 71 ----------- 3 files changed, 294 deletions(-) delete mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/OcrDigits.kt diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/OcrDigits.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/OcrDigits.kt deleted file mode 100644 index e48c95c8..00000000 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/OcrDigits.kt +++ /dev/null @@ -1,108 +0,0 @@ -package xyz.sevive.arcaeaoffline.core.ocr - -import org.opencv.core.Core -import org.opencv.core.Mat -import org.opencv.core.MatOfFloat -import org.opencv.core.MatOfPoint -import org.opencv.core.Size -import org.opencv.imgproc.Imgproc -import org.opencv.ml.KNearest -import org.opencv.objdetect.HOGDescriptor -import kotlin.math.ceil -import kotlin.math.max -import kotlin.math.min - -fun resizeFillSquare( - img: Mat, - target: Int = 20, -): Mat { - val h = img.size().height - val w = img.size().width - - val newSize: Size = - if (h > w) { - Size(w * (target / h), target.toDouble()) - } else { - Size(target.toDouble(), h * (target / w)) - } - val resized = Mat() - Imgproc.resize(img, resized, newSize) - - val borderSize = - ceil( - (max(newSize.width, newSize.height) - min(newSize.width, newSize.height)) / 2, - ).toInt() - val bordered = Mat() - if (newSize.width < newSize.height) { - Core.copyMakeBorder(resized, bordered, 0, 0, borderSize, borderSize, Core.BORDER_CONSTANT) - } else { - Core.copyMakeBorder(resized, bordered, borderSize, borderSize, 0, 0, Core.BORDER_CONSTANT) - } - val final = Mat() - Imgproc.resize(bordered, final, Size(target.toDouble(), target.toDouble())) - return final -} - -fun preprocessHog(digitRois: List): Mat { - // https://learnopencv.com/handwritten-digits-classification-an-opencv-c-python-tutorial/ - val samples = mutableListOf() - for (digit in digitRois) { - val hog = - HOGDescriptor( - Size(20.0, 20.0), - Size(10.0, 10.0), - Size(5.0, 5.0), - Size(10.0, 10.0), - 9, - ) - val hist = MatOfFloat() - hog.compute(digit, hist) - samples.add(hist) - } - val mat = Mat() - Core.hconcat(samples.reversed(), mat) - Core.rotate(mat, mat, Core.ROTATE_90_COUNTERCLOCKWISE) - return mat -} - -fun ocrDigitSamplesKnn( - samples: Mat, - knnModel: KNearest, - k: Int = 4, -): Int { - val results = Mat() - knnModel.findNearest(samples, k, results) - var resultStr = "" - for (row in 0 until results.rows()) { - val data = results.get(row, 0) - if (data == null || data[0] < 0.0) continue - resultStr += data[0].toInt().toString() - } - return resultStr.toInt(10) -} - -fun ocrDigitsByContourGetSamples( - roiGray: Mat, - size: Int, -): Mat { - val roi = roiGray.clone() - val contours = ArrayList() - val hierarchy = Mat() - Imgproc.findContours(roi, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_NONE) - var rects = contours.map { Imgproc.boundingRect(it) } - rects = FixRects.connectBroken(rects, roi.width().toDouble(), roi.height().toDouble()) - rects = FixRects.splitConnected(roi, rects) - val sortedRects = rects.sortedBy { it.x } - val digitRois = sortedRects.map { resizeFillSquare(roi.submat(it), size) } - return preprocessHog(digitRois) -} - -fun ocrDigitsByContourKnn( - roiGray: Mat, - knnModel: KNearest, - k: Int = 4, - size: Int = 20, -): Int { - val samples = ocrDigitsByContourGetSamples(roiGray, size) - return ocrDigitSamplesKnn(samples, knnModel, k) -} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt index feaedc19..f0bcbb26 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt @@ -3,12 +3,7 @@ package xyz.sevive.arcaeaoffline.core.ocr import org.opencv.core.Core import org.opencv.core.CvType import org.opencv.core.Mat -import org.opencv.core.Rect import org.opencv.core.Scalar -import kotlin.math.abs -import kotlin.math.floor -import kotlin.math.round -import kotlin.math.roundToInt import kotlin.math.sqrt fun matMedian(mat: Mat): Double { @@ -105,113 +100,3 @@ fun collectionMedian(list: List) = it[it.size / 2] } } - -class FixRects { - companion object { - fun connectBroken( - rects: List, - imgWidth: Double, - imgHeight: Double, - overrideTolerance: Int? = null, - ): List { - val tolerance: Int = overrideTolerance ?: floor(imgWidth * 0.08).toInt() - - val newRects = mutableListOf() - val consumedRects = mutableListOf() - - for (rect in rects) { - if (consumedRects.indexOf(rect) > -1) continue - - // filter out large rects - if (!(imgHeight * 0.1 <= rect.height && rect.height <= imgHeight * 0.6)) continue - - val group = mutableListOf() - // see if there's other rects that have near left & right borders - for (otherRect in rects) { - if (rect == otherRect) continue - - if (abs(rect.x - otherRect.x) < tolerance && abs((rect.x + rect.width) - (otherRect.x + otherRect.width)) < tolerance) { - group.add(otherRect) - } - } - - if (group.size > 0) { - group.add(rect) - consumedRects.addAll(group) - // calculate new rect - val newX = group.minBy { it.x }.x - val newY = group.minBy { it.y }.y - val newRightRect = group.maxBy { it.x + it.width } - val newRight = newRightRect.x + newRightRect.width - val newBottomRect = group.maxBy { it.y + it.height } - val newBottom = newBottomRect.y + newBottomRect.height - val newW = newRight - newX - val newH = newBottom - newY - newRects.add(Rect(newX, newY, newW, newH)) - } - } - - val returnRects = rects.filter { consumedRects.indexOf(it) == -1 }.toMutableList() - returnRects.addAll(newRects) - return returnRects.toList() - } - - fun splitConnected( - imgMasked: Mat, - rects: List, - rectWHRatio: Double = 1.05, - widthRangeRatio: Double = 0.1, - ): List { - val connectedRects = mutableListOf() - val newRects = mutableListOf() - - for (rect in rects) { - if ((rect.width.toDouble() / rect.height.toDouble()) <= rectWHRatio) continue - - connectedRects.add(rect) - - // find the thinnest part - val borderIgnore = round(rect.width * widthRangeRatio).toInt() - - val imgCropped = - imgMasked - .submat( - Rect( - borderIgnore, - rect.y, - rect.width - borderIgnore, - rect.height, - ), - ).clone() - val whitePixels = mutableMapOf() - for (i in 0 until imgCropped.rows()) { - val col = imgCropped.submat(i, i + 1, 0, imgCropped.cols()).clone() - whitePixels[rect.x + borderIgnore + i] = Core.countNonZero(col) - } - - if (whitePixels.values.all { it == 0 }) return rects - - val leastWhitePixels = whitePixels.values.minBy { it } - val xValuesMap = whitePixels.filter { it.value == leastWhitePixels } - val xValues = xValuesMap.keys.map { it.toDouble() } - - // select only middle values - val xMean = xValues.average() - val xStd = collectionStandardDeviation(xValues) - val xValuesInRange = - xValues.filter { - xMean - xStd * 1.5 <= it && it <= xMean + xStd * 1.5 - } - val xMid = collectionMedian(xValuesInRange).roundToInt() - - // split rect - newRects.add(Rect(rect.x, rect.y, xMid - rect.x, rect.height)) - newRects.add(Rect(xMid, rect.y, rect.x + rect.width - xMid, rect.height)) - } - - val returnRects = rects.filter { connectedRects.indexOf(it) == -1 }.toMutableList() - returnRects.addAll(newRects) - return returnRects.toList() - } - } -} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt index 63bc5535..93dc8168 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt @@ -8,21 +8,15 @@ import org.opencv.core.MatOfPoint import org.opencv.core.Point import org.opencv.core.Scalar import org.opencv.imgproc.Imgproc -import org.opencv.ml.KNearest import xyz.sevive.arcaeaoffline.core.ArcaeaPartnerModifiers import xyz.sevive.arcaeaoffline.core.clearStatusToClearType import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult -import xyz.sevive.arcaeaoffline.core.ocr.FixRects import xyz.sevive.arcaeaoffline.core.ocr.ImageHashItem import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.rois.extractor.DeviceRoisExtractor import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMasker import xyz.sevive.arcaeaoffline.core.ocr.getMostConfidentItem -import xyz.sevive.arcaeaoffline.core.ocr.ocrDigitSamplesKnn -import xyz.sevive.arcaeaoffline.core.ocr.ocrDigitsByContourKnn -import xyz.sevive.arcaeaoffline.core.ocr.preprocessHog -import xyz.sevive.arcaeaoffline.core.ocr.resizeFillSquare import kotlin.time.Instant import kotlin.uuid.Uuid @@ -80,7 +74,6 @@ fun DeviceOcrResult.toPlayResult( class DeviceOcr( private val extractor: DeviceRoisExtractor, private val masker: DeviceRoisMasker, - private val kNearestModel: KNearest, private val ortSession: OrtSession, private val hashesDb: ImageHashesDatabase, ) { @@ -117,63 +110,6 @@ class DeviceOcr( } } - private fun pfl( - roiGray: Mat, - factor: Double = 1.0, - ): Int { - val contours = ArrayList() - Imgproc.findContours( - roiGray, - contours, - Mat(), - Imgproc.RETR_EXTERNAL, - Imgproc.CHAIN_APPROX_NONE, - ) - val filteredContours = contours.filter { Imgproc.contourArea(it) >= 5 * factor } - var rects = filteredContours.map { Imgproc.boundingRect(it) } - rects = - FixRects.connectBroken(rects, roiGray.width().toDouble(), roiGray.height().toDouble()) - - var filteredRects = rects.filter { it.width >= 5 * factor && it.height >= 6 * factor } - filteredRects = FixRects.splitConnected(roiGray, filteredRects) - filteredRects = filteredRects.sortedBy { it.x } - - val roiOcr = roiGray.clone() - for (contour in contours) { - if (filteredContours.indexOf(contour) > -1) continue - Imgproc.fillPoly(roiOcr, listOf(contour), Scalar(0.0)) - } - - val digitRois = - filteredRects.map { rect -> resizeFillSquare(roiOcr.submat(rect).clone(), 20) } - val samples = preprocessHog(digitRois) - return ocrDigitSamplesKnn(samples, this.kNearestModel) - } - - fun pure() = pfl(masker.pure(extractor.pure)) - - fun far() = pfl(masker.far(extractor.far)) - - fun lost() = pfl(masker.lost(extractor.lost)) - - fun score(): Int { - val roi = masker.score(extractor.score) - val contours = ArrayList() - Imgproc.findContours( - roi, - contours, - Mat(), - Imgproc.RETR_EXTERNAL, - Imgproc.CHAIN_APPROX_NONE, - ) - for (contour in contours) { - if (Imgproc.boundingRect(contour).height < roi.height() * 0.6) { - Imgproc.fillPoly(roi, listOf(contour), Scalar(0.0)) - } - } - return ocrDigitsByContourKnn(roi, kNearestModel) - } - fun ratingClass(): ArcaeaRatingClass { val roi = extractor.ratingClass val results = @@ -187,8 +123,6 @@ class DeviceOcr( return ArcaeaRatingClass.fromInt(results.indices.maxBy { Core.countNonZero(results[it]) }) } - fun maxRecall(): Int = ocrDigitsByContourKnn(masker.maxRecall(extractor.maxRecall), kNearestModel) - private fun clearStatus(): Int { val roi = extractor.clearStatus val results = @@ -216,11 +150,6 @@ class DeviceOcr( fun ocr(): DeviceOcrResult = DeviceOcrResult( ratingClass = ratingClass(), -// pure = pure(), -// far = far(), -// lost = lost(), -// score = score(), -// maxRecall = maxRecall(), pure = DeviceOcrOnnxHelper.ocrBgrMat(extractor.pure, ortSession).toInt(), far = DeviceOcrOnnxHelper.ocrBgrMat(extractor.far, ortSession).toInt(), lost = DeviceOcrOnnxHelper.ocrBgrMat(extractor.lost, ortSession).toInt(), From a4903d982ab8f80d888a456fdefbc465606dbbfc Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 2 Aug 2026 22:05:40 +0800 Subject: [PATCH 02/31] refactor: drop kNearestModel from OCR call chain - remove kNearestModel parameter from DeviceOcrHelper.ocrImage - stop loading KNN model in OcrQueueOcrImageTaskExecutor and OcrFromShareViewModel.startOcr --- .../xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt | 3 --- .../arcaeaoffline/jobs/OcrQueueProcessingJobTaskExecutor.kt | 3 --- .../ui/activities/ocrfromshare/OcrFromShareViewModel.kt | 6 ------ 3 files changed, 12 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt index dcd9ba76..4c750928 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt @@ -21,7 +21,6 @@ import kotlinx.datetime.toInstant import kotlinx.io.buffered import org.opencv.core.MatOfByte import org.opencv.imgcodecs.Imgcodecs -import org.opencv.ml.KNearest import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.CropBlackEdges @@ -56,7 +55,6 @@ object DeviceOcrHelper { suspend fun ocrImage( imageUri: Uri, - kNearestModel: KNearest, imageHashesDatabase: ImageHashesDatabase, ortSession: OrtSession, ): DeviceOcrResult { @@ -91,7 +89,6 @@ object DeviceOcrHelper { return DeviceOcr( extractor = extractor, masker = masker, - kNearestModel = kNearestModel, ortSession = ortSession, hashesDb = imageHashesDatabase, ).ocr() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJobTaskExecutor.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJobTaskExecutor.kt index 3f058d3b..0c46e59c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJobTaskExecutor.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJobTaskExecutor.kt @@ -41,8 +41,6 @@ class OcrQueueOcrImageTaskExecutor( private val imageHashesDatabase = ImageHashesDatabase(imageHashesSQLiteDatabase) - private val kNearestModel = OcrDependencyLoader.kNearestModel() - override fun close() { ortSession.close() imageHashesSQLiteDatabase.close() @@ -60,7 +58,6 @@ class OcrQueueOcrImageTaskExecutor( val ocrResult = DeviceOcrHelper.ocrImage( uri, - kNearestModel, imageHashesDatabase, ortSession = ortSession, ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt index 37f56569..c7a58b0f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareViewModel.kt @@ -38,7 +38,6 @@ import xyz.sevive.arcaeaoffline.helpers.OcrDependencyStatusBuilder import xyz.sevive.arcaeaoffline.permissions.storage.SaveBitmapToGallery import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusUiState import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusUiState -import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyKNearestModelStatusUiState import kotlin.time.Clock class OcrFromShareViewModel( @@ -47,7 +46,6 @@ class OcrFromShareViewModel( private val ocrHistoryRepo: OcrHistoryRepository, ) : ViewModel() { class OcrDependencyViewersUiState( - val kNearestModel: OcrDependencyKNearestModelStatusUiState = OcrDependencyKNearestModelStatusUiState(), val imageHashesDatabase: OcrDependencyImageHashesDatabaseStatusUiState = OcrDependencyImageHashesDatabaseStatusUiState(), val crnnModel: OcrDependencyCrnnModelStatusUiState = OcrDependencyCrnnModelStatusUiState(), ) @@ -63,13 +61,11 @@ class OcrFromShareViewModel( fun reloadOcrDependencyViewersUiState(context: Context) { viewModelScope.launch(Dispatchers.IO) { - val kNearest = OcrDependencyStatusBuilder.kNearest() val imageHashesDatabase = OcrDependencyStatusBuilder.imageHashesDatabase() val crnnModel = OcrDependencyStatusBuilder.crnnModel(context) _ocrDependencyViewersUiState.value = OcrDependencyViewersUiState( - kNearestModel = OcrDependencyKNearestModelStatusUiState(kNearest), imageHashesDatabase = OcrDependencyImageHashesDatabaseStatusUiState(statusDetail = imageHashesDatabase), crnnModel = OcrDependencyCrnnModelStatusUiState(crnnModel), ) @@ -210,7 +206,6 @@ class OcrFromShareViewModel( withContext(Dispatchers.IO) { try { - val kNearestModel = OcrDependencyLoader.kNearestModel() val imageHashesSQLiteDatabase = OcrDependencyLoader.imageHashesSQLiteDatabase() @@ -219,7 +214,6 @@ class OcrFromShareViewModel( val imageHashesDatabase = ImageHashesDatabase(sqliteDb) DeviceOcrHelper.ocrImage( imageUri, - kNearestModel, imageHashesDatabase, ortSession = ortSession, ) From c40f693a7825a4d78d3de1b22d028f1c16cc6099 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 2 Aug 2026 22:06:11 +0800 Subject: [PATCH 03/31] refactor: remove KNearest model dependency management - drop kNearestModel() loaders, KNearestModelStatusDetail and status builder - remove knnModelFile path constant and its deletion in emergency mode - add LegacyKnnModelCleanUpTask to clean up leftover digits.knn.dat on devices that installed before the removal --- .../xyz/sevive/arcaeaoffline/data/Paths.kt | 1 - .../maintenance/AppDataMaintenanceManager.kt | 2 ++ .../tasks/LegacyKnnModelCleanUpTask.kt | 31 +++++++++++++++++++ .../helpers/OcrDependencyLoader.kt | 7 ----- .../helpers/OcrDependencyStatus.kt | 25 --------------- .../helpers/OcrDependencyStatusBuilder.kt | 12 ------- .../EmergencyModeActivityViewModel.kt | 1 - 7 files changed, 33 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/tasks/LegacyKnnModelCleanUpTask.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/data/Paths.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/data/Paths.kt index 7bbc80e2..7ac35daf 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/data/Paths.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/data/Paths.kt @@ -9,7 +9,6 @@ import kotlinx.io.files.Path class OcrDependencyPaths { val parentDir = Path(FileKit.filesDir.absolutePath()) / "ocr" / "dependencies" - val knnModelFile = parentDir / "digits.knn.dat" val phashDatabaseFile = parentDir / "image-phash.db" val imageHashesDatabaseFile = parentDir / "image-hashes.db" } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/AppDataMaintenanceManager.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/AppDataMaintenanceManager.kt index 53c328f1..a2966ecc 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/AppDataMaintenanceManager.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/AppDataMaintenanceManager.kt @@ -3,6 +3,7 @@ package xyz.sevive.arcaeaoffline.data.maintenance import android.content.Context import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException +import xyz.sevive.arcaeaoffline.data.maintenance.tasks.LegacyKnnModelCleanUpTask import xyz.sevive.arcaeaoffline.data.maintenance.tasks.ProtoDataStoreCleanUpTask class AppDataMaintenanceManager( @@ -13,6 +14,7 @@ class AppDataMaintenanceManager( private val tasks = listOf( ProtoDataStoreCleanUpTask(context), + LegacyKnnModelCleanUpTask(), ) suspend fun runAllTasks() = diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/tasks/LegacyKnnModelCleanUpTask.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/tasks/LegacyKnnModelCleanUpTask.kt new file mode 100644 index 00000000..a90f20b8 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/tasks/LegacyKnnModelCleanUpTask.kt @@ -0,0 +1,31 @@ +package xyz.sevive.arcaeaoffline.data.maintenance.tasks + +import co.touchlab.kermit.Logger +import io.github.vinceglb.filekit.FileKit +import io.github.vinceglb.filekit.absolutePath +import io.github.vinceglb.filekit.utils.div +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import xyz.sevive.arcaeaoffline.data.maintenance.AppDataMaintenanceTask + +class LegacyKnnModelCleanUpTask : AppDataMaintenanceTask { + override val id = "legacy_knn_model_cleanup" + override val version = 1 + + private val logger = Logger.withTag("LegacyKnnModelCleanUpTask") + + // Path is kept independent from OcrDependencyPaths.knnModelFile, which was removed + // together with the KNearest OCR pipeline. This task cleans up the leftover file + // on devices that installed the app before the removal. + private val legacyFile: Path = + Path(FileKit.filesDir.absolutePath()) / "ocr" / "dependencies" / "digits.knn.dat" + + override suspend fun execute() = + withContext(Dispatchers.IO) { + if (!SystemFileSystem.exists(legacyFile)) return@withContext + SystemFileSystem.delete(legacyFile) + logger.i { "Deleted legacy KNN model file: $legacyFile" } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyLoader.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyLoader.kt index 7b7835c4..e242cb38 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyLoader.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyLoader.kt @@ -4,16 +4,9 @@ import androidx.sqlite.SQLiteConnection import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.SQLITE_OPEN_READONLY import kotlinx.io.files.Path -import org.opencv.ml.KNearest import xyz.sevive.arcaeaoffline.data.OcrDependencyPaths object OcrDependencyLoader { - fun kNearestModel(path: Path): KNearest = KNearest.load(path.toString()) - - fun kNearestModel(ocrDependencyPaths: OcrDependencyPaths) = kNearestModel(ocrDependencyPaths.knnModelFile) - - fun kNearestModel() = kNearestModel(OcrDependencyPaths()) - fun imageHashesSQLiteDatabase(path: Path): SQLiteConnection = BundledSQLiteDriver().open(path.toString(), SQLITE_OPEN_READONLY) fun imageHashesSQLiteDatabase(ocrDependencyPaths: OcrDependencyPaths) = diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt index d7b7ace6..2ee91528 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt @@ -20,31 +20,6 @@ interface OcrDependencyStatusDetail { } } -data class KNearestModelStatusDetail( - override val absence: Boolean = false, - override val exception: Exception? = null, - val varCount: Int? = null, - val isTrained: Boolean = false, -) : OcrDependencyStatusDetail { - override fun status(): OcrDependencyStatus { - if (absence) return OcrDependencyStatus.ABSENCE - if (exception != null || !isTrained) return OcrDependencyStatus.ERROR - - return when (varCount) { - null -> OcrDependencyStatus.UNKNOWN - 81 -> OcrDependencyStatus.OK - else -> OcrDependencyStatus.WARNING - } - } - - override fun summary(): String? = - when { - exception != null -> exception::class.simpleName ?: "Error" - varCount != null -> "varCount $varCount" - else -> null - } -} - data class ImageHashesDatabaseStatusDetail( override val absence: Boolean = false, override val exception: Exception? = null, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt index adf20250..dd5a42a5 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt @@ -8,18 +8,6 @@ import xyz.sevive.arcaeaoffline.data.OcrDependencyPaths import kotlin.use object OcrDependencyStatusBuilder { - fun kNearest(): KNearestModelStatusDetail { - try { - val paths = OcrDependencyPaths() - if (!SystemFileSystem.exists(paths.knnModelFile)) return KNearestModelStatusDetail(absence = true) - - val model = OcrDependencyLoader.kNearestModel() - return KNearestModelStatusDetail(varCount = model.varCount, isTrained = model.isTrained) - } catch (e: Exception) { - return KNearestModelStatusDetail(exception = e) - } - } - fun imageHashesDatabase(): ImageHashesDatabaseStatusDetail { try { val paths = OcrDependencyPaths() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/EmergencyModeActivityViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/EmergencyModeActivityViewModel.kt index 81e16d6b..a45a7d2c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/EmergencyModeActivityViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/EmergencyModeActivityViewModel.kt @@ -89,7 +89,6 @@ class EmergencyModeActivityViewModel( val paths = OcrDependencyPaths() viewModelScope.launch(Dispatchers.IO) { - if (SystemFileSystem.exists(paths.knnModelFile)) SystemFileSystem.delete(paths.knnModelFile) if (SystemFileSystem.exists(paths.phashDatabaseFile)) SystemFileSystem.delete(paths.phashDatabaseFile) if (SystemFileSystem.exists(paths.imageHashesDatabaseFile)) SystemFileSystem.delete(paths.imageHashesDatabaseFile) } From 87f786ede1990ea035df85cb8d7d1c71268707a1 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 2 Aug 2026 22:06:52 +0800 Subject: [PATCH 04/31] refactor: remove KNearest model UI - delete OcrDependencyKNearestModelStatusViewer (status card + ui state) - remove KNearest status cards from OcrNavEntry, OcrFromShare card and OcrDependenciesScreen - remove importKNearestModel and related state from OcrDependenciesScreenViewModel --- .../OcrFromShareOcrDependencyStatusCard.kt | 2 - .../OcrDependencyKNearestModelStatusViewer.kt | 33 --------------- .../ui/screens/ocr/OcrNavEntry.kt | 3 -- .../ocr/dependencies/OcrDependenciesScreen.kt | 20 --------- .../OcrDependenciesScreenViewModel.kt | 42 ------------------- 5 files changed, 100 deletions(-) delete mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ocr/OcrDependencyKNearestModelStatusViewer.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt index d87c3b8e..23276b97 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/activities/ocrfromshare/OcrFromShareOcrDependencyStatusCard.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusViewer import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusViewer -import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyKNearestModelStatusViewer @Composable internal fun OcrFromShareOcrDependencyStatusCard( @@ -13,7 +12,6 @@ internal fun OcrFromShareOcrDependencyStatusCard( modifier: Modifier = Modifier, ) { Card(modifier) { - OcrDependencyKNearestModelStatusViewer(uiState = uiState.kNearestModel) OcrDependencyImageHashesDatabaseStatusViewer(uiState = uiState.imageHashesDatabase) OcrDependencyCrnnModelStatusViewer(uiState = uiState.crnnModel) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ocr/OcrDependencyKNearestModelStatusViewer.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ocr/OcrDependencyKNearestModelStatusViewer.kt deleted file mode 100644 index be4aadb0..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ocr/OcrDependencyKNearestModelStatusViewer.kt +++ /dev/null @@ -1,33 +0,0 @@ -package xyz.sevive.arcaeaoffline.ui.components.ocr - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.res.vectorResource -import xyz.sevive.arcaeaoffline.R -import xyz.sevive.arcaeaoffline.helpers.KNearestModelStatusDetail - -data class OcrDependencyKNearestModelStatusUiState( - val statusDetail: KNearestModelStatusDetail = KNearestModelStatusDetail(), -) - -@Composable -fun OcrDependencyKNearestModelStatusViewer( - uiState: OcrDependencyKNearestModelStatusUiState, - modifier: Modifier = Modifier, -) { - val status = remember(uiState) { uiState.statusDetail.status() } - val summary = remember(uiState) { uiState.statusDetail.summary() } - val details = remember(uiState) { uiState.statusDetail.details() } - - OcrDependencyStatusViewer( - icon = ImageVector.vectorResource(R.drawable.ic_knearest_model), - title = stringResource(R.string.ocr_dependency_knn_model), - status = status, - summary = summary, - details = details, - modifier = modifier, - ) -} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt index 6d3bd25c..d25b4f09 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt @@ -21,7 +21,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusViewer import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusViewer -import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyKNearestModelStatusViewer import xyz.sevive.arcaeaoffline.ui.navigation.LocalListDetailNavigationContext import xyz.sevive.arcaeaoffline.ui.navigation.MainScreen import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen @@ -33,7 +32,6 @@ import xyz.sevive.arcaeaoffline.ui.screens.ocr.dependencies.OcrDependenciesScree fun OcrNavEntry(modifier: Modifier = Modifier) { val navContext = LocalListDetailNavigationContext.current val vm = koinViewModel() - val kNearestModelUiState by vm.kNearestModelUiState.collectAsStateWithLifecycle() val imageHashesDatabaseUiState by vm.imageHashesDatabaseUiState.collectAsStateWithLifecycle() val crnnModelUiState by vm.crnnModelUiState.collectAsStateWithLifecycle() @@ -52,7 +50,6 @@ fun OcrNavEntry(modifier: Modifier = Modifier) { ) { item { Column { - OcrDependencyKNearestModelStatusViewer(kNearestModelUiState) OcrDependencyImageHashesDatabaseStatusViewer(imageHashesDatabaseUiState) OcrDependencyCrnnModelStatusViewer(crnnModelUiState) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreen.kt index bdae5af2..b2182861 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreen.kt @@ -21,7 +21,6 @@ import xyz.sevive.arcaeaoffline.ui.SubScreenContainer import xyz.sevive.arcaeaoffline.ui.components.ArcaeaAppIcon import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusViewer import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusViewer -import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyKNearestModelStatusViewer import xyz.sevive.arcaeaoffline.ui.components.preferences.TextPreferencesWidget import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen @@ -32,17 +31,12 @@ fun OcrDependenciesScreen( ) { val context = LocalContext.current - val kNearestModelUiState by viewModel.kNearestModelUiState.collectAsStateWithLifecycle() val imageHashesDatabaseUiState by viewModel.imageHashesDatabaseUiState.collectAsStateWithLifecycle() val crnnModelUiState by viewModel.crnnModelUiState.collectAsStateWithLifecycle() val canBuildHashesDatabase by ArcaeaResourcesStateHolder.canBuildHashesDatabase.collectAsStateWithLifecycle() val buildHashesDatabaseButtonEnabled by viewModel.buildHashesDatabaseButtonEnabled.collectAsStateWithLifecycle() - val kNearestModelFileChooserLauncher = - rememberFileChooserLauncher { uri -> - uri?.let { viewModel.importKNearestModel(it, context) } - } val imageHashesDatabaseFileChooserLauncher = rememberFileChooserLauncher { uri -> uri?.let { viewModel.importImageHashesDatabase(it, context) } @@ -57,20 +51,6 @@ fun OcrDependenciesScreen( }, ) { LazyColumn(modifier) { - item { - OcrDependencyKNearestModelStatusViewer(kNearestModelUiState) - } - - item { - TextPreferencesWidget( - onClick = { kNearestModelFileChooserLauncher.launch("*/*") }, - title = stringResource(R.string.general_import), - leadingIcon = Icons.Default.FileOpen, - ) - } - - item { HorizontalDivider() } - item { OcrDependencyImageHashesDatabaseStatusViewer(imageHashesDatabaseUiState) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreenViewModel.kt index 78e0e95e..1ecb0e4d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/dependencies/OcrDependenciesScreenViewModel.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.io.buffered import kotlinx.io.files.SystemFileSystem -import org.opencv.ml.KNearest import xyz.sevive.arcaeaoffline.core.Progress import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.data.OcrDependencyPaths @@ -35,7 +34,6 @@ import xyz.sevive.arcaeaoffline.helpers.fromWorkInfo import xyz.sevive.arcaeaoffline.jobs.ImageHashesDatabaseBuilderJob import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyCrnnModelStatusUiState import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyImageHashesDatabaseStatusUiState -import xyz.sevive.arcaeaoffline.ui.components.ocr.OcrDependencyKNearestModelStatusUiState import java.io.IOException class OcrDependenciesScreenViewModel( @@ -50,10 +48,6 @@ class OcrDependenciesScreenViewModel( private val logger = Logger.withTag(LOG_TAG) private val workManager = WorkManager.getInstance(context.applicationContext) - private val _kNearestModelUiState = - MutableStateFlow(OcrDependencyKNearestModelStatusUiState()) - val kNearestModelUiState = _kNearestModelUiState.asStateFlow() - private val imageHashesDatabaseBuilderJobInfo = workManager .getWorkInfosForUniqueWorkFlow(ImageHashesDatabaseBuilderJob.NAME) @@ -118,34 +112,6 @@ class OcrDependenciesScreenViewModel( return true } - fun importKNearestModel( - uri: Uri, - context: Context, - ) { - val paths = OcrDependencyPaths() - if (!mkOcrDependencyParentDirs(paths)) return - - viewModelScope.launch(Dispatchers.IO) { - if (isFileTooLarge(uri, context, logName = "KNearest")) return@launch - - val cacheFile = context.copyToCache(uri, "knearest_model_import_temp") ?: return@launch - try { - KNearest.load(cacheFile.toString()) - SystemFileSystem.source(cacheFile).buffered().use { src -> - SystemFileSystem.sink(paths.knnModelFile).buffered().use { dst -> - src.transferTo(dst) - } - } - } catch (e: Exception) { - logger.e(e) { "Error importing KNearest model" } - } finally { - SystemFileSystem.delete(cacheFile) - } - - reloadKNearestModelStatusDetailUiState() - } - } - fun importImageHashesDatabase( uri: Uri, context: Context, @@ -182,13 +148,6 @@ class OcrDependenciesScreenViewModel( } } - private fun reloadKNearestModelStatusDetailUiState() { - viewModelScope.launch(Dispatchers.IO) { - _kNearestModelUiState.value = - OcrDependencyKNearestModelStatusUiState(OcrDependencyStatusBuilder.kNearest()) - } - } - private fun reloadImageHashesDatabaseStatusDetailUiState() { viewModelScope.launch(Dispatchers.IO) { imagesHashesDatabaseStatusDetail.value = @@ -228,7 +187,6 @@ class OcrDependenciesScreenViewModel( } fun reloadAll(context: Context) { - reloadKNearestModelStatusDetailUiState() reloadImageHashesDatabaseStatusDetailUiState() reloadCrnnModelStatusDetailUiState(context) } From f4f09b4f80214dd4d55254277ebc3de649356fa6 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 2 Aug 2026 22:21:17 +0800 Subject: [PATCH 05/31] chore: remove KNearest model resources - delete ocr_dependency_knn_model strings (app + shared composeResources, zh-rCN) - delete ic_knearest_model drawable --- .../main/res/drawable/ic_knearest_model.xml | 77 ------------------- app/src/main/res/values-zh-rCN/strings.xml | 1 - app/src/main/res/values/strings.xml | 1 - .../values-zh-rCN/strings.xml | 1 - .../composeResources/values/strings.xml | 1 - 5 files changed, 81 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_knearest_model.xml diff --git a/app/src/main/res/drawable/ic_knearest_model.xml b/app/src/main/res/drawable/ic_knearest_model.xml deleted file mode 100644 index f489e38e..00000000 --- a/app/src/main/res/drawable/ic_knearest_model.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 90eef879..404dd67c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -68,7 +68,6 @@ 保存图片 - KNearest 模型 图像哈希数据库 CRNN OCR 模型 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4922cf06..51f9c6ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,7 +80,6 @@ CC BY-SA 3.0 Save image - KNearest model Image hashes database CRNN OCR model diff --git a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml index 6bfb2852..eea6fda0 100644 --- a/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/shared/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -67,7 +67,6 @@ 保存图片 - KNearest 模型 图像哈希数据库 CRNN OCR 模型 diff --git a/shared/src/commonMain/composeResources/values/strings.xml b/shared/src/commonMain/composeResources/values/strings.xml index 928fbe5e..051378c5 100644 --- a/shared/src/commonMain/composeResources/values/strings.xml +++ b/shared/src/commonMain/composeResources/values/strings.xml @@ -73,7 +73,6 @@ Save image - KNearest model Image hashes database CRNN OCR model From 8c2c6a0a9f19d7adfd3c3d68eec5cd20e71c2dbb Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 2 Aug 2026 22:21:18 +0800 Subject: [PATCH 06/31] fix: add filesDir import to LegacyKnnModelCleanUpTask --- .../data/maintenance/tasks/LegacyKnnModelCleanUpTask.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/tasks/LegacyKnnModelCleanUpTask.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/tasks/LegacyKnnModelCleanUpTask.kt index a90f20b8..2591b903 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/tasks/LegacyKnnModelCleanUpTask.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/data/maintenance/tasks/LegacyKnnModelCleanUpTask.kt @@ -3,6 +3,7 @@ package xyz.sevive.arcaeaoffline.data.maintenance.tasks import co.touchlab.kermit.Logger import io.github.vinceglb.filekit.FileKit import io.github.vinceglb.filekit.absolutePath +import io.github.vinceglb.filekit.filesDir import io.github.vinceglb.filekit.utils.div import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -12,11 +13,11 @@ import xyz.sevive.arcaeaoffline.data.maintenance.AppDataMaintenanceTask class LegacyKnnModelCleanUpTask : AppDataMaintenanceTask { override val id = "legacy_knn_model_cleanup" - override val version = 1 + override val version = 2 private val logger = Logger.withTag("LegacyKnnModelCleanUpTask") - // Path is kept independent from OcrDependencyPaths.knnModelFile, which was removed + // Path is kept independent of OcrDependencyPaths.knnModelFile, which was removed // together with the KNearest OCR pipeline. This task cleans up the leftover file // on devices that installed the app before the removal. private val legacyFile: Path = From f378307eb6627000dda7a11fb7b00b23819c4f67 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 2 Aug 2026 22:29:08 +0800 Subject: [PATCH 07/31] build(deps): bump opencv from 4.13.0 to 5.0.0.1 - OpenCV 5.0 removes ml (KNearest) and HOG from the main repo; both were already removed from this codebase, so no migration needed - usage limited to stable core/imgproc/imgcodecs/android APIs, verified by Android Studio build (no problems) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f10c89e8..ca5f95c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ compose-multiplatform = "1.11.1" compose-material3 = "1.9.0" compose-material-icons-extended = "1.7.3" -opencv = "4.13.0" +opencv = "5.0.0.1" apache-commons-compress = "1.28.0" io-sentry-sentryAndroid = "8.17.0" From b7e9f7c7188b0aad97e14eb72c4180079929e9d6 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 2 Aug 2026 22:39:22 +0800 Subject: [PATCH 08/31] fix(app.db): remove non-existent 3->4 AutoMigration Database version jumped from 2 to 4 in 80139db; version 3 was never published, but the AutoMigration was generated against a fictional 3.json (enqueue_buffer with uri_type column) which crashes on real v2 databases (no such column: uri_type). Remove the AutoMigration and the spec; old databases now hit the existing fallbackToDestructiveMigration(dropAllTables = true) path and are rebuilt cleanly as v4. --- .../3.json | 182 ------------------ .../database/OcrQueueDatabase.kt | 5 - .../migrations/OcrQueueMigration3To4.kt | 8 - 3 files changed, 195 deletions(-) delete mode 100644 app/schemas/xyz.sevive.arcaeaoffline.database.OcrQueueDatabase/3.json delete mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/database/migrations/OcrQueueMigration3To4.kt diff --git a/app/schemas/xyz.sevive.arcaeaoffline.database.OcrQueueDatabase/3.json b/app/schemas/xyz.sevive.arcaeaoffline.database.OcrQueueDatabase/3.json deleted file mode 100644 index b9ab7565..00000000 --- a/app/schemas/xyz.sevive.arcaeaoffline.database.OcrQueueDatabase/3.json +++ /dev/null @@ -1,182 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 3, - "identityHash": "d2d3b16df860c5197c174a0d39691162", - "entities": [ - { - "tableName": "ocr_queue_tasks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `inserted_at` INTEGER NOT NULL, `file_uri` TEXT NOT NULL, `status` INTEGER NOT NULL, `result` TEXT, `play_result` TEXT, `error_type` TEXT, `error_message` TEXT)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "insertedAt", - "columnName": "inserted_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "fileUri", - "columnName": "file_uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "result", - "columnName": "result", - "affinity": "TEXT" - }, - { - "fieldPath": "playResult", - "columnName": "play_result", - "affinity": "TEXT" - }, - { - "fieldPath": "errorType", - "columnName": "error_type", - "affinity": "TEXT" - }, - { - "fieldPath": "errorMessage", - "columnName": "error_message", - "affinity": "TEXT" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_ocr_queue_tasks_file_uri", - "unique": true, - "columnNames": [ - "file_uri" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ocr_queue_tasks_file_uri` ON `${TABLE_NAME}` (`file_uri`)" - } - ] - }, - { - "tableName": "ocr_queue_enqueue_buffer", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `batch_id` INTEGER NOT NULL, `uri` TEXT NOT NULL, `uri_type` INTEGER NOT NULL, `checked` INTEGER NOT NULL, `should_insert` INTEGER NOT NULL, FOREIGN KEY(`batch_id`) REFERENCES `ocr_queue_enqueue_batches`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "batchId", - "columnName": "batch_id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "uri", - "columnName": "uri", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "uriType", - "columnName": "uri_type", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "checked", - "columnName": "checked", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "shouldInsert", - "columnName": "should_insert", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_ocr_queue_enqueue_buffer_uri", - "unique": true, - "columnNames": [ - "uri" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_ocr_queue_enqueue_buffer_uri` ON `${TABLE_NAME}` (`uri`)" - } - ], - "foreignKeys": [ - { - "table": "ocr_queue_enqueue_batches", - "onDelete": "CASCADE", - "onUpdate": "CASCADE", - "columns": [ - "batch_id" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "ocr_queue_enqueue_batches", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `inserted_at` INTEGER NOT NULL, `options` TEXT NOT NULL)", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "insertedAt", - "columnName": "inserted_at", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "options", - "columnName": "options", - "affinity": "TEXT", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd2d3b16df860c5197c174a0d39691162')" - ] - } -} \ No newline at end of file diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/database/OcrQueueDatabase.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/database/OcrQueueDatabase.kt index f2b6d643..9eaf799c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/database/OcrQueueDatabase.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/database/OcrQueueDatabase.kt @@ -1,7 +1,6 @@ package xyz.sevive.arcaeaoffline.database import android.content.Context -import androidx.room.AutoMigration import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase @@ -21,15 +20,11 @@ import xyz.sevive.arcaeaoffline.database.daos.OcrQueueTaskDao import xyz.sevive.arcaeaoffline.database.entities.OcrQueueStagingBatch import xyz.sevive.arcaeaoffline.database.entities.OcrQueueStagingItem import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask -import xyz.sevive.arcaeaoffline.database.migrations.OcrQueueMigration3To4 @Database( entities = [OcrQueueTask::class, OcrQueueStagingItem::class, OcrQueueStagingBatch::class], version = 4, exportSchema = true, - autoMigrations = [ - AutoMigration(from = 3, to = 4, spec = OcrQueueMigration3To4::class), - ], ) @TypeConverters( UriConverters::class, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/database/migrations/OcrQueueMigration3To4.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/database/migrations/OcrQueueMigration3To4.kt deleted file mode 100644 index f7d1ddaf..00000000 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/database/migrations/OcrQueueMigration3To4.kt +++ /dev/null @@ -1,8 +0,0 @@ -package xyz.sevive.arcaeaoffline.database.migrations - -import androidx.room.RenameTable -import androidx.room.migration.AutoMigrationSpec - -@RenameTable(fromTableName = "ocr_queue_enqueue_buffer", toTableName = "ocr_queue_staging_item") -@RenameTable(fromTableName = "ocr_queue_enqueue_batches", toTableName = "ocr_queue_staging_batch") -class OcrQueueMigration3To4 : AutoMigrationSpec From f3fc0d5d56c3f7c24d33427f88755b5356eb7918 Mon Sep 17 00:00:00 2001 From: 283375 Date: Mon, 3 Aug 2026 00:31:44 +0800 Subject: [PATCH 09/31] chore: bump ocr model cache key to 1.0.3 --- .github/actions/setup-ocr-model/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-ocr-model/action.yml b/.github/actions/setup-ocr-model/action.yml index 40441e31..a112dcf0 100644 --- a/.github/actions/setup-ocr-model/action.yml +++ b/.github/actions/setup-ocr-model/action.yml @@ -9,7 +9,7 @@ runs: uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0 with: path: app/src/main/assets/ocr - key: ocr-models_crnn-1.0.2 + key: ocr-models_crnn-1.0.3 - name: Ensure CRNN OCR model asset folder exists if: steps.cache-ocr.outputs.cache-hit != 'true' From 2fcfcea917a588eadfcaa64bc5223e94babd43ef Mon Sep 17 00:00:00 2001 From: 283375 Date: Mon, 3 Aug 2026 19:22:29 +0800 Subject: [PATCH 10/31] refactor: use custom-built onnxruntime/opencv artifacts Switch to the local maven repo (maven-local/) that overrides official onnxruntime 1.26.0 and opencv 5.0.0 with custom-built AARs. Parse dual-block model_info.json for the dependency status card instead of OnnxModelMetadata, disable graph optimization (NO_OPT) for the reduced-op runtime, and read INT32 model output directly. --- .gitignore | 3 ++ .../helpers/OcrDependencyStatus.kt | 36 +++++++------- .../helpers/OcrDependencyStatusBuilder.kt | 22 ++++++--- .../core/ocr/device/DeviceOcrOnnxHelper.kt | 49 ++++++++++++++----- gradle/libs.versions.toml | 2 +- settings.gradle.kts | 8 +++ 6 files changed, 83 insertions(+), 37 deletions(-) diff --git a/.gitignore b/.gitignore index 15e5dd26..c4647eb7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ app/src/main/assets/ocr/model*.onnx app/src/main/assets/ocr/model*.ort app/src/main/assets/ocr/model_info.json +# Local maven repo (custom-built artifacts: onnxruntime, opencv) +/maven-local/ + # Android Studio added /.idea/caches /.idea/libraries diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt index 2ee91528..cb16cb37 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt @@ -1,7 +1,5 @@ package xyz.sevive.arcaeaoffline.helpers -import ai.onnxruntime.OnnxModelMetadata -import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrOnnxHelper import kotlin.time.Instant enum class OcrDependencyStatus { OK, ERROR, WARNING, ABSENCE, UNKNOWN } @@ -54,34 +52,36 @@ data class ImageHashesDatabaseStatusDetail( data class CrnnModelStatusDetail( override val absence: Boolean = false, override val exception: Exception? = null, - val modelMetadata: OnnxModelMetadata? = null, + val modelVersion: List? = null, + val producerName: String? = null, + val producerVersion: String? = null, + val domain: String? = null, + val graphName: String? = null, val inputNames: Set? = null, val outputNames: Set? = null, + val builtTimestamp: Instant? = null, + val patchedTimestamp: Instant? = null, ) : OcrDependencyStatusDetail { override fun status(): OcrDependencyStatus { if (absence) return OcrDependencyStatus.ABSENCE if (exception != null) return OcrDependencyStatus.ERROR return when { - modelMetadata == null -> OcrDependencyStatus.UNKNOWN - modelMetadata.version == 0L -> OcrDependencyStatus.WARNING + modelVersion.isNullOrEmpty() -> OcrDependencyStatus.UNKNOWN else -> OcrDependencyStatus.OK } } - private val builtTimestampRaw get() = modelMetadata?.customMetadata?.get("built_timestamp") - private val builtTimestamp = builtTimestampRaw?.let { Instant.fromEpochSeconds(it.toLong()) } - private val builtTimestampReadable = builtTimestamp?.formatAsLocalizedDateTime() + private val modelVersionString = modelVersion?.takeIf { it.isNotEmpty() }?.let { "v" + it.joinToString(".") } override fun summary(): String? { if (absence) return null if (exception != null) return exception::class.simpleName ?: "Error" - if (modelMetadata == null) return null val parts = mutableListOf() - parts.add(DeviceOcrOnnxHelper.modelVersionString(modelMetadata.version)) - builtTimestampReadable?.let { parts.add(it) } + modelVersionString?.let { parts.add(it) } + builtTimestamp?.let { parts.add(it.formatAsLocalizedDateTime()) } return parts.filter { it.isNotEmpty() }.joinToString(", ") } @@ -92,17 +92,19 @@ data class CrnnModelStatusDetail( val parts = mutableListOf() - modelMetadata?.let { - parts.add("version: ${it.version} (${DeviceOcrOnnxHelper.modelVersion(it.version)})") - parts.add("producer: ${it.producerName}") - parts.add("domain: ${it.domain}") - parts.add("graph_name: ${it.graphName}") + modelVersion?.takeIf { it.isNotEmpty() }?.let { + parts.add("version: $it ($modelVersionString)") } + producerName?.let { parts.add("producer: $it") } + producerVersion?.let { parts.add("producer_version: $it") } + domain?.let { parts.add("domain: $it") } + graphName?.let { parts.add("graph_name: $it") } parts.add("inputs: $inputNames") parts.add("outputs: $outputNames") - builtTimestampRaw?.let { parts.add("built_timestamp: $it ($builtTimestamp)") } + builtTimestamp?.let { parts.add("built: $it") } + patchedTimestamp?.let { parts.add("patched: $it") } return when (val res = parts.joinToString("\n")) { "" -> null diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt index dd5a42a5..8b990ff7 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt @@ -5,6 +5,7 @@ import kotlinx.io.files.SystemFileSystem import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrOnnxHelper import xyz.sevive.arcaeaoffline.data.OcrDependencyPaths +import kotlin.time.Instant import kotlin.use object OcrDependencyStatusBuilder { @@ -32,15 +33,22 @@ object OcrDependencyStatusBuilder { } fun crnnModel(context: Context): CrnnModelStatusDetail = - DeviceOcrOnnxHelper.createOrtSession(context).use { - try { + try { + val info = DeviceOcrOnnxHelper.loadModelInfoFile(context) + with(info) { CrnnModelStatusDetail( - modelMetadata = it.metadata, - inputNames = it.inputNames.toSet(), // make a copy, same for below - outputNames = it.outputNames.toSet(), + modelVersion = patch?.version?.takeIf { it.isNotEmpty() }, + producerName = patch?.producerName, + producerVersion = patch?.producerVersion, + domain = patch?.domain, + graphName = patch?.graphName, + inputNames = patch?.inputNames?.toSet(), + outputNames = patch?.outputNames?.toSet(), + builtTimestamp = training.builtTimestamp.takeIf { it != 0L }?.let { Instant.fromEpochSeconds(it) }, + patchedTimestamp = patch?.patchedTimestamp?.takeIf { it != 0L }?.let { Instant.fromEpochSeconds(it) }, ) - } catch (e: Exception) { - CrnnModelStatusDetail(exception = e) } + } catch (e: Exception) { + CrnnModelStatusDetail(exception = e) } } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt index 376e4734..4eca011f 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt @@ -32,24 +32,47 @@ object DeviceOcrOnnxHelper { @SuppressLint("UnsafeOptInUsageError") @Serializable - private data class ModelInfo( + data class ModelInfo( @SerialName("image_height") val imageHeight: Long, @SerialName("image_width") val imageWidth: Long, @SerialName("labels") val labels: List, @SerialName("blank_token") val blankToken: String, @SerialName("pad_token") val padToken: String, + @SerialName("built_timestamp") val builtTimestamp: Long = 0, + ) + + @SuppressLint("UnsafeOptInUsageError") + @Serializable + data class ModelInfoFile( + val training: ModelInfo, + val patch: ModelPatchInfo? = null, + ) + + @SuppressLint("UnsafeOptInUsageError") + @Serializable + data class ModelPatchInfo( + @SerialName("version") val version: List = emptyList(), + @SerialName("producer_name") val producerName: String? = null, + @SerialName("producer_version") val producerVersion: String? = null, + @SerialName("domain") val domain: String? = null, + @SerialName("graph_name") val graphName: String? = null, + @SerialName("input_names") val inputNames: List = emptyList(), + @SerialName("output_names") val outputNames: List = emptyList(), + @SerialName("patched_timestamp") val patchedTimestamp: Long = 0, ) private fun getOrtEnvironment(): OrtEnvironment = OrtEnvironment.getEnvironment("ocr") + fun loadModelInfoFile(context: Context): ModelInfoFile = + jsonSerializer.decodeFromString( + context.assets + .open("ocr/model_info.json") + .bufferedReader() + .use { it.readText() }, + ) + fun loadModelInfo(context: Context) { - val modelInfo = - jsonSerializer.decodeFromString( - context.assets - .open("ocr/model_info.json") - .bufferedReader() - .use { it.readText() }, - ) + val modelInfo = loadModelInfoFile(context).training logger.d { "Loaded model info $modelInfo" } @@ -67,21 +90,23 @@ object DeviceOcrOnnxHelper { * * @return arrayOf(major, minor, patch) */ - fun modelVersion(version: Long): List { + @Suppress("UNUSED") + private fun modelVersion(version: Long): List { val major = ((version shr 48) and 0xFFFF).toInt() val minor = ((version shr 32) and 0xFFFF).toInt() val patch = (version and 0xFFFFFFFF).toInt() return listOf(major, minor, patch) } - fun modelVersionString(version: Long): String = "v" + modelVersion(version).joinToString(".") - fun createOrtSession(context: Context): OrtSession { val ortEnvironment = getOrtEnvironment() val onnxModelBytes = readOnnxModelBytes(context) return OrtSession.SessionOptions().use { it.setIntraOpNumThreads(Runtime.getRuntime().availableProcessors() / 2) + // Custom build onnxruntime cannot ensure optimized node exists, + // so the optimization must be disabled, otherwise ORT_NOT_IMPLEMENTED may occur. + it.setOptimizationLevel(OrtSession.SessionOptions.OptLevel.NO_OPT) ortEnvironment.createSession(onnxModelBytes, it) } @@ -107,7 +132,7 @@ object DeviceOcrOnnxHelper { private fun modelDecodedOutputToString(onnxTensor: OnnxTensor): String { val rawPredictions = mutableListOf() for (i in 0 until onnxTensor.info.shape[0]) { - rawPredictions.add(onnxTensor.longBuffer.get(i.toInt()).toInt()) + rawPredictions.add(onnxTensor.intBuffer.get(i.toInt())) } val predictions = rawPredictions.map { labels[it] } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ca5f95c0..a95e5686 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ compose-multiplatform = "1.11.1" compose-material3 = "1.9.0" compose-material-icons-extended = "1.7.3" -opencv = "5.0.0.1" +opencv = "5.0.0" apache-commons-compress = "1.28.0" io-sentry-sentryAndroid = "8.17.0" diff --git a/settings.gradle.kts b/settings.gradle.kts index d29b9587..d2e2fb04 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,14 @@ dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) @Suppress("UnstableApiUsage") repositories { + maven { + setUrl( + providers + .gradleProperty("localMavenRepo") + .getOrElse(rootDir.resolve("maven-local").absolutePath), + ) + } + google() mavenCentral() From 06bdf56ff29d8f480cea8d87eed38b1dc4336e86 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 4 Aug 2026 16:52:45 +0800 Subject: [PATCH 11/31] ci: fetch custom onnxruntime/opencv artifacts Add setup-custom-maven action that downloads maven_repo.zip from the custom-lib-builds stable release tags (onnxruntime-1.26.0, opencv-5.0.0) into maven-local/, overriding official artifacts with identical GAV. Wire it into build_unstable, check, and connected-android-test workflows. No cache: stable tags are overwritten on publish (URL stable, content changes), so version-based cache keys would serve stale artifacts. --- .github/actions/setup-custom-maven/action.yml | 29 +++++++++++++++++++ .github/workflows/build_unstable.yml | 3 ++ .github/workflows/check.yml | 3 ++ .github/workflows/connected-android-test.yml | 3 ++ 4 files changed, 38 insertions(+) create mode 100644 .github/actions/setup-custom-maven/action.yml diff --git a/.github/actions/setup-custom-maven/action.yml b/.github/actions/setup-custom-maven/action.yml new file mode 100644 index 00000000..13a118fa --- /dev/null +++ b/.github/actions/setup-custom-maven/action.yml @@ -0,0 +1,29 @@ +name: Setup Custom Maven Repo +description: Downloads custom-built onnxruntime/opencv maven artifacts from custom-lib-builds releases into maven-local/ + +runs: + using: composite + steps: + # Cache may not applicable for onnxruntime, as it may change its opset while + # not changing its version. + # Since GitHub Actions should have quite fast Internet access to its own + # release service, we don't set up caches here. + - name: Download and extract custom maven repo + run: | + set -euo pipefail + + mkdir -p maven-local + + curl -fL -o /tmp/ort-maven.zip \ + https://github.com/ArcaeaOffline/custom-lib-builds/releases/download/onnxruntime-1.26.0/maven_repo.zip + unzip -q -o /tmp/ort-maven.zip -d maven-local + + curl -fL -o /tmp/opencv-maven.zip \ + https://github.com/ArcaeaOffline/custom-lib-builds/releases/download/opencv-5.0.0/maven_repo.zip + unzip -q -o /tmp/opencv-maven.zip -d maven-local + + rm -f /tmp/ort-maven.zip /tmp/opencv-maven.zip + + test -f maven-local/com/microsoft/onnxruntime/onnxruntime-android/1.26.0/onnxruntime-android-1.26.0.aar + test -f maven-local/org/opencv/opencv/5.0.0/opencv-5.0.0.aar + shell: bash diff --git a/.github/workflows/build_unstable.yml b/.github/workflows/build_unstable.yml index 3a12c0e4..6f927529 100644 --- a/.github/workflows/build_unstable.yml +++ b/.github/workflows/build_unstable.yml @@ -26,6 +26,9 @@ jobs: - name: Setup Toolchain uses: ./.github/actions/setup-toolchain + - name: Setup Custom Maven + uses: ./.github/actions/setup-custom-maven + - name: Release keystore file run: base64 -d <<< "${{ secrets.KEYSTORE_FILE_BASE64 }}" > arcaea_offline.jks diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index eb2e7e04..71839b6e 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -29,6 +29,9 @@ jobs: - name: Setup Toolchain uses: ./.github/actions/setup-toolchain + - name: Setup Custom Maven + uses: ./.github/actions/setup-custom-maven + - name: Release local.properties file run: cp local.defaults.properties local.properties diff --git a/.github/workflows/connected-android-test.yml b/.github/workflows/connected-android-test.yml index ebed0e47..3d6c49aa 100644 --- a/.github/workflows/connected-android-test.yml +++ b/.github/workflows/connected-android-test.yml @@ -35,6 +35,9 @@ jobs: - name: Setup Toolchain uses: ./.github/actions/setup-toolchain + - name: Setup Custom Maven + uses: ./.github/actions/setup-custom-maven + - name: Release local.properties file run: cp local.defaults.properties local.properties From 64fff6f69294c1d78dc85c819e37ad1aaaf17289 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 4 Aug 2026 17:31:26 +0800 Subject: [PATCH 12/31] ci: verify APK bundles custom-built libs, soft-fail with job summary Compare .so sha256 between maven-local/ AARs and built APKs, guarding against silent fallback to official onnxruntime/opencv artifacts. Warnings go to annotations and the job summary; continue-on-error keeps artifact upload and release drafting unblocked. --- .github/scripts/verify-custom-libs.sh | 72 +++++++++++++++++++++++++++ .github/workflows/build_unstable.yml | 4 ++ 2 files changed, 76 insertions(+) create mode 100755 .github/scripts/verify-custom-libs.sh diff --git a/.github/scripts/verify-custom-libs.sh b/.github/scripts/verify-custom-libs.sh new file mode 100755 index 00000000..4a0f6240 --- /dev/null +++ b/.github/scripts/verify-custom-libs.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# Verifies the built APKs use the custom-built onnxruntime/opencv +# from maven-local/ instead of silently falling back to the +# official artifacts. +# +# Soft-fail: mismatches are reported as warnings in the job summary, +# but don't terminate the build. +set -euo pipefail + +# .github/scripts/ -> repo root +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MAVEN_LOCAL="${MAVEN_LOCAL:-$ROOT/maven-local}" +APK_DIR="${APK_DIR:-$ROOT/app/build/outputs/apk/unstable/release}" + +# | +# Keep in sync with .github/actions/setup-custom-maven/action.yml +LIBS=( + "libonnxruntime.so|com/microsoft/onnxruntime/onnxruntime-android/1.26.0/onnxruntime-android-1.26.0.aar" + "libopencv_java5.so|org/opencv/opencv/5.0.0/opencv-5.0.0.aar" +) +# universal APK is the union of these four, so no special checks for it +ABIS=(arm64-v8a armeabi-v7a x86 x86_64) + +# sha256 of a file inside a zip; empty output if the entry is missing +# $1=zip file, $2=path inside zip +file_in_zip_sha256() { + if ! unzip -l "$1" "$2" >/dev/null 2>&1; then + return 1 + fi + unzip -p "$1" "$2" | sha256sum | cut -d' ' -f1 +} + +warnings=() +for abi in "${ABIS[@]}"; do + apk="$APK_DIR/app-unstable-$abi-release.apk" + if [ ! -f "$apk" ]; then + warnings+=("missing APK: $apk") + continue + fi + for entry in "${LIBS[@]}"; do + so="${entry%%|*}" + aar="$MAVEN_LOCAL/${entry#*|}" + custom="$(file_in_zip_sha256 "$aar" "jni/$abi/$so" || true)" + packed="$(file_in_zip_sha256 "$apk" "lib/$abi/$so" || true)" + if [ -z "$custom" ]; then + warnings+=("$abi/$so: custom .so not found in $aar") + elif [ "$custom" != "$packed" ]; then + warnings+=("$abi/$so: maven-local ${custom:0:12}.. != APK ${packed:0:12}..") + fi + done +done + +if [ "${#warnings[@]}" -gt 0 ]; then + for w in "${warnings[@]}"; do + echo "::warning::$w" + done + { + echo "## Custom build verification failed" + echo "" + echo "The APKs do not match the custom-built libraries in maven-local/." + echo "" + echo "| Check |" + echo "| --- |" + for w in "${warnings[@]}"; do + echo "| $w |" + done + } >>"${GITHUB_STEP_SUMMARY:-/dev/null}" + # Non-zero exit marks the step yellow; the workflow step uses + # continue-on-error so this never blocks artifact upload/release. + exit 1 +fi +exit 0 diff --git a/.github/workflows/build_unstable.yml b/.github/workflows/build_unstable.yml index 6f927529..a0895f07 100644 --- a/.github/workflows/build_unstable.yml +++ b/.github/workflows/build_unstable.yml @@ -54,6 +54,10 @@ jobs: apksigner verify --verbose --min-sdk-version 24 app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk apksigner verify --verbose --min-sdk-version 24 app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk + - name: Verify custom-built libs in APK + continue-on-error: true + run: bash .github/scripts/verify-custom-libs.sh + - name: Upload build result artifact (arm64-v8a) uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: From 6de55b06f784055efe922129e3b1483f332ecce7 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 4 Aug 2026 17:42:18 +0800 Subject: [PATCH 13/31] ci: extract maven setup and APK signing to scripts Move the download/extract logic out of the composite action and the apksigner checks out of the workflow, so both are runnable and testable locally. setup-custom-maven and verify-apk-signing keep hard-fail semantics; verify-custom-libs remains soft-fail. Cleaned up shellcheck findings along the way. --- .github/actions/setup-custom-maven/action.yml | 18 +-------- .github/scripts/setup-custom-maven.sh | 39 +++++++++++++++++++ .github/scripts/verify-apk-signing.sh | 21 ++++++++++ .github/workflows/build_unstable.yml | 12 +----- 4 files changed, 62 insertions(+), 28 deletions(-) create mode 100755 .github/scripts/setup-custom-maven.sh create mode 100755 .github/scripts/verify-apk-signing.sh diff --git a/.github/actions/setup-custom-maven/action.yml b/.github/actions/setup-custom-maven/action.yml index 13a118fa..4f4f084d 100644 --- a/.github/actions/setup-custom-maven/action.yml +++ b/.github/actions/setup-custom-maven/action.yml @@ -9,21 +9,5 @@ runs: # Since GitHub Actions should have quite fast Internet access to its own # release service, we don't set up caches here. - name: Download and extract custom maven repo - run: | - set -euo pipefail - - mkdir -p maven-local - - curl -fL -o /tmp/ort-maven.zip \ - https://github.com/ArcaeaOffline/custom-lib-builds/releases/download/onnxruntime-1.26.0/maven_repo.zip - unzip -q -o /tmp/ort-maven.zip -d maven-local - - curl -fL -o /tmp/opencv-maven.zip \ - https://github.com/ArcaeaOffline/custom-lib-builds/releases/download/opencv-5.0.0/maven_repo.zip - unzip -q -o /tmp/opencv-maven.zip -d maven-local - - rm -f /tmp/ort-maven.zip /tmp/opencv-maven.zip - - test -f maven-local/com/microsoft/onnxruntime/onnxruntime-android/1.26.0/onnxruntime-android-1.26.0.aar - test -f maven-local/org/opencv/opencv/5.0.0/opencv-5.0.0.aar + run: bash .github/scripts/setup-custom-maven.sh shell: bash diff --git a/.github/scripts/setup-custom-maven.sh b/.github/scripts/setup-custom-maven.sh new file mode 100755 index 00000000..2624f467 --- /dev/null +++ b/.github/scripts/setup-custom-maven.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# setup-custom-maven.sh +# +# Downloads the custom-built onnxruntime/opencv maven artifacts from the +# custom-lib-builds stable release tags and extracts them into maven-local/. +# maven-local/ uses the same GAVs as the official artifacts, so Gradle +# resolves the custom builds first (settings.gradle.kts local repo block). +# +# Hard-fail: a download error aborts the build. Falling back to the official +# artifacts would ship an APK that differs from what was verified locally. +set -euo pipefail + +# .github/scripts/ -> repo root +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +MAVEN_LOCAL="${MAVEN_LOCAL:-$ROOT/maven-local}" + +# Stable tags, overwritten on publish (URL stays the same, content changes). +# No actions/cache: a version-based cache key would serve stale artifacts +# after a republish. GitHub Actions has fast access to its own releases, +# so we just re-download (~125MB) on every run. +ORT_TAG="${ORT_TAG:-onnxruntime-1.26.0}" +OPENCV_TAG="${OPENCV_TAG:-opencv-5.0.0}" +BASE_URL="https://github.com/ArcaeaOffline/custom-lib-builds/releases/download" + +mkdir -p "$MAVEN_LOCAL" + +download_and_extract() { # $1=tag, $2=local zip path + curl -fL -o "$2" "$BASE_URL/$1/maven_repo.zip" + unzip -q -o "$2" -d "$MAVEN_LOCAL" + rm -f "$2" +} + +download_and_extract "$ORT_TAG" /tmp/ort-maven.zip +download_and_extract "$OPENCV_TAG" /tmp/opencv-maven.zip + +# The two zips have com/ and org/ roots; they merge into maven-local/ +# without conflicts. Verify the expected AARs actually landed. +test -f "$MAVEN_LOCAL/com/microsoft/onnxruntime/onnxruntime-android/1.26.0/onnxruntime-android-1.26.0.aar" +test -f "$MAVEN_LOCAL/org/opencv/opencv/5.0.0/opencv-5.0.0.aar" diff --git a/.github/scripts/verify-apk-signing.sh b/.github/scripts/verify-apk-signing.sh new file mode 100755 index 00000000..bc36ae70 --- /dev/null +++ b/.github/scripts/verify-apk-signing.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# verify-apk-signing.sh +# +# Verifies signatures of all unstableRelease APKs. +# Hard-fail: unsigned or invalidly signed APKs must abort the build. +set -euo pipefail + +# Use the newest build-tools for this run +BUILD_TOOLS_VERSION="$(find "$ANDROID_HOME/build-tools" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' | sort -V | tail -n 1)" +PATH="$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION:$PATH" + +APK_DIR="${APK_DIR:-app/build/outputs/apk/unstable/release}" + +for apk in \ + "$APK_DIR/app-unstable-arm64-v8a-release.apk" \ + "$APK_DIR/app-unstable-armeabi-v7a-release.apk" \ + "$APK_DIR/app-unstable-x86_64-release.apk" \ + "$APK_DIR/app-unstable-x86-release.apk" \ + "$APK_DIR/app-unstable-universal-release.apk"; do + apksigner verify --verbose --min-sdk-version 24 "$apk" +done diff --git a/.github/workflows/build_unstable.yml b/.github/workflows/build_unstable.yml index a0895f07..41c6e211 100644 --- a/.github/workflows/build_unstable.yml +++ b/.github/workflows/build_unstable.yml @@ -42,17 +42,7 @@ jobs: run: ./gradlew assembleUnstableRelease - name: Verify APK signing - run: | - # Locate the latest build tools - BUILD_TOOLS_VERSION=$(ls $ANDROID_HOME/build-tools/ | sort -V | tail -n 1) - # Adding build tools to PATH for this step only - PATH=$ANDROID_HOME/build-tools/$BUILD_TOOLS_VERSION:$PATH - - apksigner verify --verbose --min-sdk-version 24 app/build/outputs/apk/unstable/release/app-unstable-arm64-v8a-release.apk - apksigner verify --verbose --min-sdk-version 24 app/build/outputs/apk/unstable/release/app-unstable-armeabi-v7a-release.apk - apksigner verify --verbose --min-sdk-version 24 app/build/outputs/apk/unstable/release/app-unstable-x86_64-release.apk - apksigner verify --verbose --min-sdk-version 24 app/build/outputs/apk/unstable/release/app-unstable-x86-release.apk - apksigner verify --verbose --min-sdk-version 24 app/build/outputs/apk/unstable/release/app-unstable-universal-release.apk + run: bash .github/scripts/verify-apk-signing.sh - name: Verify custom-built libs in APK continue-on-error: true From e7590f7b3cdb405b7d87e2f41533f7bd8bb18d33 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 4 Aug 2026 18:20:55 +0800 Subject: [PATCH 14/31] ci: verify custom libs by size with hash for reference --- .github/scripts/verify-custom-libs.sh | 57 ++++++++++++++++++++------- 1 file changed, 43 insertions(+), 14 deletions(-) diff --git a/.github/scripts/verify-custom-libs.sh b/.github/scripts/verify-custom-libs.sh index 4a0f6240..dfed876d 100755 --- a/.github/scripts/verify-custom-libs.sh +++ b/.github/scripts/verify-custom-libs.sh @@ -3,6 +3,11 @@ # from maven-local/ instead of silently falling back to the # official artifacts. # +# Size is the primary signal: the custom .so files are ~50% the size +# of the official ones, far outside the +/-10% tolerance. +# sha256 hashes are reported for reference only: release build may +# strip on the .so files, so hashes legitimately differ there. +# # Soft-fail: mismatches are reported as warnings in the job summary, # but don't terminate the build. set -euo pipefail @@ -30,7 +35,17 @@ file_in_zip_sha256() { unzip -p "$1" "$2" | sha256sum | cut -d' ' -f1 } +# size of a file inside a zip; empty output if the entry is missing +# $1=zip file, $2=path inside zip +file_in_zip_size() { + if ! unzip -l "$1" "$2" >/dev/null 2>&1; then + return 1 + fi + unzip -p "$1" "$2" | wc -c +} + warnings=() +rows=() for abi in "${ABIS[@]}"; do apk="$APK_DIR/app-unstable-$abi-release.apk" if [ ! -f "$apk" ]; then @@ -42,29 +57,43 @@ for abi in "${ABIS[@]}"; do aar="$MAVEN_LOCAL/${entry#*|}" custom="$(file_in_zip_sha256 "$aar" "jni/$abi/$so" || true)" packed="$(file_in_zip_sha256 "$apk" "lib/$abi/$so" || true)" - if [ -z "$custom" ]; then + custom_size="$(file_in_zip_size "$aar" "jni/$abi/$so" || true)" + packed_size="$(file_in_zip_size "$apk" "lib/$abi/$so" || true)" + + result="OK" + if [ -z "$custom" ] || [ -z "$custom_size" ]; then warnings+=("$abi/$so: custom .so not found in $aar") - elif [ "$custom" != "$packed" ]; then - warnings+=("$abi/$so: maven-local ${custom:0:12}.. != APK ${packed:0:12}..") + result="NOT FOUND" + elif [ -z "$packed" ] || [ -z "$packed_size" ]; then + warnings+=("$abi/$so: .so not found in APK") + result="NOT FOUND" + elif [ "$packed_size" -lt $((custom_size * 90 / 100)) ] || \ + [ "$packed_size" -gt $((custom_size * 110 / 100)) ]; then + warnings+=("$abi/$so: size ${packed_size}B, expected ~${custom_size}B") + result="SIZE MISMATCH" fi + rows+=("| $abi | $so | $custom_size | $packed_size | ${custom:0:12}.. | ${packed:0:12}.. | $result |") done done +{ + if [ "${#warnings[@]}" -gt 0 ]; then + echo "## Custom build verification failed" + else + echo "## Custom build verification passed" + fi + echo "" + echo "| ABI | Library | Size (maven-local, B) | Size (APK, B) | sha256 (maven-local) | sha256 (APK) | Result |" + echo "| --- | --- | --- | --- | --- | --- | --- |" + for r in "${rows[@]}"; do + echo "$r" + done +} >>"${GITHUB_STEP_SUMMARY:-/dev/null}" + if [ "${#warnings[@]}" -gt 0 ]; then for w in "${warnings[@]}"; do echo "::warning::$w" done - { - echo "## Custom build verification failed" - echo "" - echo "The APKs do not match the custom-built libraries in maven-local/." - echo "" - echo "| Check |" - echo "| --- |" - for w in "${warnings[@]}"; do - echo "| $w |" - done - } >>"${GITHUB_STEP_SUMMARY:-/dev/null}" # Non-zero exit marks the step yellow; the workflow step uses # continue-on-error so this never blocks artifact upload/release. exit 1 From f4dd26260c2df656b9a91dd7083f9a338064af89 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 4 Aug 2026 18:32:21 +0800 Subject: [PATCH 15/31] ci: group verification summary by library --- .github/scripts/verify-custom-libs.sh | 72 +++++++++++++-------------- 1 file changed, 35 insertions(+), 37 deletions(-) diff --git a/.github/scripts/verify-custom-libs.sh b/.github/scripts/verify-custom-libs.sh index dfed876d..49bfbf98 100755 --- a/.github/scripts/verify-custom-libs.sh +++ b/.github/scripts/verify-custom-libs.sh @@ -45,48 +45,46 @@ file_in_zip_size() { } warnings=() -rows=() -for abi in "${ABIS[@]}"; do - apk="$APK_DIR/app-unstable-$abi-release.apk" - if [ ! -f "$apk" ]; then - warnings+=("missing APK: $apk") - continue + +{ + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + echo "## Custom build verification" + echo "" fi for entry in "${LIBS[@]}"; do so="${entry%%|*}" aar="$MAVEN_LOCAL/${entry#*|}" - custom="$(file_in_zip_sha256 "$aar" "jni/$abi/$so" || true)" - packed="$(file_in_zip_sha256 "$apk" "lib/$abi/$so" || true)" - custom_size="$(file_in_zip_size "$aar" "jni/$abi/$so" || true)" - packed_size="$(file_in_zip_size "$apk" "lib/$abi/$so" || true)" - - result="OK" - if [ -z "$custom" ] || [ -z "$custom_size" ]; then - warnings+=("$abi/$so: custom .so not found in $aar") - result="NOT FOUND" - elif [ -z "$packed" ] || [ -z "$packed_size" ]; then - warnings+=("$abi/$so: .so not found in APK") - result="NOT FOUND" - elif [ "$packed_size" -lt $((custom_size * 90 / 100)) ] || \ - [ "$packed_size" -gt $((custom_size * 110 / 100)) ]; then - warnings+=("$abi/$so: size ${packed_size}B, expected ~${custom_size}B") - result="SIZE MISMATCH" - fi - rows+=("| $abi | $so | $custom_size | $packed_size | ${custom:0:12}.. | ${packed:0:12}.. | $result |") - done -done + echo "### $so" + echo "" + echo "| ABI | Size (maven-local, B) | Size (APK, B) | sha256 (maven-local) | sha256 (APK) | Result |" + echo "| --- | --- | --- | --- | --- | --- |" + for abi in "${ABIS[@]}"; do + apk="$APK_DIR/app-unstable-$abi-release.apk" + if [ ! -f "$apk" ]; then + warnings+=("missing APK: $apk") + echo "| $abi | - | - | - | - | APK missing |" + continue + fi + custom="$(file_in_zip_sha256 "$aar" "jni/$abi/$so" || true)" + packed="$(file_in_zip_sha256 "$apk" "lib/$abi/$so" || true)" + custom_size="$(file_in_zip_size "$aar" "jni/$abi/$so" || true)" + packed_size="$(file_in_zip_size "$apk" "lib/$abi/$so" || true)" -{ - if [ "${#warnings[@]}" -gt 0 ]; then - echo "## Custom build verification failed" - else - echo "## Custom build verification passed" - fi - echo "" - echo "| ABI | Library | Size (maven-local, B) | Size (APK, B) | sha256 (maven-local) | sha256 (APK) | Result |" - echo "| --- | --- | --- | --- | --- | --- | --- |" - for r in "${rows[@]}"; do - echo "$r" + result="OK" + if [ -z "$custom" ] || [ -z "$custom_size" ]; then + warnings+=("$abi/$so: custom .so not found in $aar") + result="NOT FOUND" + elif [ -z "$packed" ] || [ -z "$packed_size" ]; then + warnings+=("$abi/$so: .so not found in APK") + result="NOT FOUND" + elif [ "$packed_size" -lt $((custom_size * 90 / 100)) ] || \ + [ "$packed_size" -gt $((custom_size * 110 / 100)) ]; then + warnings+=("$abi/$so: size ${packed_size}B, expected ~${custom_size}B") + result="SIZE MISMATCH" + fi + echo "| $abi | ${custom_size:--} | ${packed_size:--} | ${custom:0:12}.. | ${packed:0:12}.. | $result |" + done + echo "" done } >>"${GITHUB_STEP_SUMMARY:-/dev/null}" From d6427182f0ecba2aa508bd1be20ec5d3d3d3bda8 Mon Sep 17 00:00:00 2001 From: 283375 Date: Tue, 4 Aug 2026 18:55:57 +0800 Subject: [PATCH 16/31] chore: add README --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 00000000..1758c764 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# Arcaea Offline Android Client + +## Custom Maven Repo (Optional) + +To reduce APK size, this project builds custom ONNX Runtime and OpenCV in [ArcaeaOffline/custom-lib-builds](https://github.com/ArcaeaOffline/custom-lib-builds), with unused features removed. + +`settings.gradle.kts` gives the local maven repo the highest priority, simply download the matching artifacts from the custom-lib-builds Releases and unpack them. + +If the local maven repo does not exist, Gradle falls back to Maven Central. This only affects APK size, not functionality. + +## License + +[GPL-3.0](LICENSE) From 0e49dbe351b3a84dbf85bec122cbeaf34d01511a Mon Sep 17 00:00:00 2001 From: 283375 Date: Sun, 16 Aug 2026 17:09:31 +0800 Subject: [PATCH 17/31] feat(app.ui): add basic ocr performance evaluation page --- .../xyz/sevive/arcaeaoffline/di/AppModule.kt | 2 + .../sevive/arcaeaoffline/ui/navigation/Ocr.kt | 1 + .../ui/screens/ocr/OcrEntryScreen.kt | 2 + .../ui/screens/ocr/OcrNavEntry.kt | 10 + .../ocr/performance/OcrPerformanceScreen.kt | 421 ++++++++++++++++++ .../OcrPerformanceScreenViewModel.kt | 176 ++++++++ app/src/main/res/values-zh-rCN/strings.xml | 27 ++ app/src/main/res/values/strings.xml | 28 ++ .../ocr/device/OcrPerformanceBenchmark.kt | 143 ++++++ 9 files changed, 810 insertions(+) create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt create mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt index 148e1ac0..f7576e33 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/di/AppModule.kt @@ -40,6 +40,7 @@ import xyz.sevive.arcaeaoffline.ui.screens.database.manage.DatabaseManageViewMod import xyz.sevive.arcaeaoffline.ui.screens.database.playresultlist.DatabasePlayResultListViewModel import xyz.sevive.arcaeaoffline.ui.screens.database.r30list.DatabaseR30ListViewModel import xyz.sevive.arcaeaoffline.ui.screens.ocr.dependencies.OcrDependenciesScreenViewModel +import xyz.sevive.arcaeaoffline.ui.screens.ocr.performance.OcrPerformanceScreenViewModel import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.OcrQueueScreenViewModel import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.preferences.OcrQueuePreferencesViewModel import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.staging.OcrQueueStagingViewModel @@ -109,6 +110,7 @@ val appModule = viewModel() viewModel() viewModel() + viewModel() viewModel() viewModel() viewModel() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Ocr.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Ocr.kt index 266cdad6..d4bd4f1f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Ocr.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/navigation/Ocr.kt @@ -11,4 +11,5 @@ enum class OcrSubScreen( ) { Dependencies("$OCR_NAV_ROUTE_ROOT/dependencies", R.string.ocr_dependencies_title), Queue("$OCR_NAV_ROUTE_ROOT/queue", R.string.ocr_queue_title), + Performance("$OCR_NAV_ROUTE_ROOT/performance", R.string.ocr_performance_title), } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt index b42604db..8800bc8c 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrEntryScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Composable import xyz.sevive.arcaeaoffline.ui.AdaptiveEntryScreen import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen import xyz.sevive.arcaeaoffline.ui.screens.ocr.dependencies.OcrDependenciesScreen +import xyz.sevive.arcaeaoffline.ui.screens.ocr.performance.OcrPerformanceScreen import xyz.sevive.arcaeaoffline.ui.screens.ocr.queue.OcrQueueScreen @Composable @@ -14,6 +15,7 @@ fun OcrEntryScreen() = when (route) { OcrSubScreen.Dependencies.route -> OcrDependenciesScreen() OcrSubScreen.Queue.route -> OcrQueueScreen() + OcrSubScreen.Performance.route -> OcrPerformanceScreen() } }, ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt index d25b4f09..66f7baee 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/OcrNavEntry.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Api import androidx.compose.material.icons.filled.Queue +import androidx.compose.material.icons.filled.Speed import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Scaffold @@ -76,6 +77,15 @@ fun OcrNavEntry(modifier: Modifier = Modifier) { navContext.navigateToDetail(OcrSubScreen.Queue.route) } } + + item { + NavEntryNavigateButton( + titleResId = OcrSubScreen.Performance.title, + icon = Icons.Default.Speed, + ) { + navContext.navigateToDetail(OcrSubScreen.Performance.route) + } + } } } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt new file mode 100644 index 00000000..21b0dbea --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt @@ -0,0 +1,421 @@ +package xyz.sevive.arcaeaoffline.ui.screens.ocr.performance + +import android.content.ClipData +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Sort +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Stop +import androidx.compose.material3.Card +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.toClipEntry +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.koin.compose.viewmodel.koinViewModel +import xyz.sevive.arcaeaoffline.R +import xyz.sevive.arcaeaoffline.core.Progress +import xyz.sevive.arcaeaoffline.core.ocr.device.OcrPerformanceBenchmark +import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedDate +import xyz.sevive.arcaeaoffline.helpers.formatAsLocalizedTime +import xyz.sevive.arcaeaoffline.helpers.secondaryItemAlpha +import xyz.sevive.arcaeaoffline.ui.SubScreenContainer +import xyz.sevive.arcaeaoffline.ui.components.IconRow +import xyz.sevive.arcaeaoffline.ui.components.LinearProgressIndicatorWrapper +import xyz.sevive.arcaeaoffline.ui.components.ListGroupHeader +import xyz.sevive.arcaeaoffline.ui.components.preferences.BasePreferencesWidget +import xyz.sevive.arcaeaoffline.ui.components.preferences.SliderPreferencesWidget +import xyz.sevive.arcaeaoffline.ui.components.preferences.TextPreferencesWidget +import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen + +@Composable +fun OcrPerformanceScreen( + modifier: Modifier = Modifier, + viewModel: OcrPerformanceScreenViewModel = koinViewModel(), +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + val imagePickerLauncher = + rememberLauncherForActivityResult( + ActivityResultContracts.PickMultipleVisualMedia(maxItems = 10), + ) { uris -> + if (uris.isNotEmpty()) { + viewModel.onImagesPicked(uris) + } + } + + SubScreenContainer( + title = stringResource(OcrSubScreen.Performance.title), + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { + LazyColumn(modifier) { + item { + ListGroupHeader(stringResource(R.string.ocr_performance_images_title)) + } + + item { + TextPreferencesWidget( + title = stringResource(R.string.ocr_performance_pick_images_button), + content = + uiState.selectedImageUris.takeIf { it.isNotEmpty() }?.let { + pluralStringResource( + R.plurals.ocr_performance_picked_images, + it.size, + it.size, + ) + }, + leadingSlot = { + Icon( + Icons.Default.Image, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + }, + enabled = !uiState.running, + onClick = { + imagePickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly), + ) + }, + trailingSlot = + uiState.selectedImageUris.takeIf { it.isNotEmpty() }?.let { + { + IconButton( + onClick = viewModel::clearImages, + enabled = !uiState.running, + colors = + IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon( + Icons.Default.Delete, + contentDescription = stringResource(R.string.ocr_performance_clear_selection), + ) + } + } + }, + ) + } + + if (uiState.imageLoadError) { + item { + Text( + stringResource(R.string.ocr_performance_image_load_failed), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + item { HorizontalDivider() } + + item { + ListGroupHeader(stringResource(R.string.ocr_performance_concurrency_title)) + } + + item { + AnimatedContent( + targetState = uiState.running, + label = "benchmarkRunningState", + ) { running -> + if (!running) { + SliderPreferencesWidget( + value = uiState.parallelCount.toFloat(), + onValueChange = viewModel::onParallelCountChange, + icon = Icons.AutoMirrored.Default.Sort, + title = stringResource(R.string.ocr_queue_queue_options_parallel_count), + description = uiState.parallelCount.toString(), + valueRange = OcrPerformanceScreenViewModel.parallelCountSliderRange, + steps = OcrPerformanceScreenViewModel.parallelCountSliderSteps, + trailingSlot = { + OutlinedButton( + onClick = { viewModel.runBenchmark() }, + enabled = uiState.selectedImageUris.isNotEmpty(), + ) { + IconRow { + Icon(Icons.Default.PlayArrow, contentDescription = null) + Text(stringResource(R.string.ocr_performance_run_button)) + } + } + }, + ) + } else { + BasePreferencesWidget( + title = { + uiState.runningParallel?.let { parallel -> + Text( + stringResource(R.string.ocr_performance_single_progress, parallel), + ) + } + }, + content = { + LinearProgressIndicatorWrapper( + progress = Progress(uiState.progress, uiState.progressTotal), + ) + }, + trailingSlot = { + OutlinedButton(onClick = { viewModel.cancelBenchmark() }) { + IconRow { + Icon(Icons.Default.Stop, contentDescription = null) + Text(stringResource(R.string.ocr_performance_cancel_button)) + } + } + }, + ) + } + } + } + + uiState.errorMessage?.let { message -> + item { + Text( + stringResource(R.string.ocr_performance_benchmark_error, message), + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } + } + + uiState.result?.let { result -> + item { HorizontalDivider() } + + item { + ListGroupHeader(stringResource(R.string.ocr_performance_result_title)) + } + + item { + ResultCard( + parallel = uiState.resultParallel ?: uiState.parallelCount, + result = result, + snackbarHostState = snackbarHostState, + modifier = Modifier.padding(bottom = dimensionResource(R.dimen.list_padding)), + ) + } + } + + if (uiState.history.isNotEmpty()) { + item { HorizontalDivider() } + + item { + ListGroupHeader(stringResource(R.string.ocr_performance_history_title)) + } + + // Newest first, easier to compare recent runs + items(uiState.history.asReversed(), key = { it.uuid }) { entry -> + HistoryRow( + entry = entry, + modifier = Modifier.animateItem(), + ) + } + } + } + } +} + +@Composable +private fun ResultCard( + parallel: Int, + result: OcrPerformanceBenchmark.Result, + snackbarHostState: SnackbarHostState, + modifier: Modifier = Modifier, +) { + val clipboard = LocalClipboard.current + val coroutineScope = rememberCoroutineScope() + val resources = LocalResources.current + + Card(modifier = modifier.then(Modifier.padding(horizontal = 16.dp))) { + Column( + verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.list_padding)), + modifier = Modifier.padding(dimensionResource(R.dimen.card_padding)), + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + stringResource(R.string.ocr_performance_result_parallel, parallel), + style = MaterialTheme.typography.titleMedium, + ) + IconButton( + onClick = { + coroutineScope.launch { + val text = buildReportText(parallel, result) + val clipData = ClipData.newPlainText("OCR Performance", text) + clipboard.setClipEntry(clipData.toClipEntry()) + snackbarHostState.showSnackbar( + resources.getString(R.string.ocr_performance_report_copied), + ) + } + }, + ) { + Icon( + Icons.Default.ContentCopy, + contentDescription = stringResource(R.string.ocr_performance_copy_report), + ) + } + } + Spacer(Modifier.height(dimensionResource(R.dimen.list_padding))) + KeyValueRow( + label = stringResource(R.string.ocr_performance_result_median_label), + value = stringResource(R.string.ocr_performance_result_median_value, result.medianPerImageMs), + valueStyle = MaterialTheme.typography.titleMedium, + ) + KeyValueRow( + label = stringResource(R.string.ocr_performance_result_throughput_label), + value = stringResource(R.string.ocr_performance_result_throughput_value, result.throughputPerSecond), + valueStyle = MaterialTheme.typography.titleMedium, + ) + KeyValueRow( + label = stringResource(R.string.ocr_performance_result_batches_label), + value = stringResource(R.string.ocr_performance_result_batches_value, result.batchTimesMs.joinToString("/")), + valueStyle = MaterialTheme.typography.bodyMedium, + ) + KeyValueRow( + label = stringResource(R.string.ocr_performance_result_output_label), + value = + stringResource( + if (result.resultsConsistent) { + R.string.ocr_performance_result_output_consistent + } else { + R.string.ocr_performance_result_output_inconsistent + }, + ), + valueStyle = MaterialTheme.typography.bodyMedium, + valueColor = if (result.resultsConsistent) Color.Unspecified else MaterialTheme.colorScheme.error, + ) + } + } +} + +/** + * Key-value row: label left-aligned (muted), value right-aligned + */ +@Composable +private fun KeyValueRow( + label: String, + value: String, + valueStyle: TextStyle, + valueColor: Color = Color.Unspecified, +) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + label, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.secondaryItemAlpha(), + ) + Text(value, style = valueStyle, color = valueColor) + } +} + +/** + * Generate pure text report for copying + */ +private fun buildReportText( + parallel: Int, + result: OcrPerformanceBenchmark.Result, +): String = + buildString { + appendLine("OCR Performance (p$parallel)") + appendLine("median: %.0f ms/image".format(result.medianPerImageMs)) + appendLine("throughput: %.1f it/s".format(result.throughputPerSecond)) + appendLine("batches: ${result.batchTimesMs.joinToString("/")} ms") + append("consistent: ${result.resultsConsistent}") + } + +@Composable +private fun HistoryRow( + entry: OcrPerformanceScreenViewModel.HistoryEntry, + modifier: Modifier = Modifier, +) { + val timestampText = + remember(entry.timestamp) { + entry.timestamp.formatAsLocalizedDate() + "\n" + entry.timestamp.formatAsLocalizedTime() + } + + Row( + horizontalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.list_padding)), + verticalAlignment = Alignment.Top, + modifier = + modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = dimensionResource(R.dimen.list_padding)), + ) { + Text( + timestampText, + textAlign = TextAlign.End, + style = MaterialTheme.typography.bodySmall, + ) + + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.weight(1f), + ) { + Text( + "p%d, %.1f it/s".format( + entry.parallel, + entry.result.throughputPerSecond, + ), + style = MaterialTheme.typography.bodyMedium, + ) + Text( + stringResource( + R.string.ocr_performance_result_batches_value, + entry.result.batchTimesMs.joinToString("/"), + ), + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.secondaryItemAlpha(), + ) + if (!entry.result.resultsConsistent) { + Text( + stringResource(R.string.ocr_performance_result_output_inconsistent), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt new file mode 100644 index 00000000..030d7c4f --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt @@ -0,0 +1,176 @@ +package xyz.sevive.arcaeaoffline.ui.screens.ocr.performance + +import android.content.Context +import android.net.Uri +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.opencv.core.Mat +import org.opencv.core.MatOfByte +import org.opencv.imgcodecs.Imgcodecs +import xyz.sevive.arcaeaoffline.core.ocr.device.OcrPerformanceBenchmark +import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesRepository +import java.io.IOException +import kotlin.math.round +import kotlin.time.Clock +import kotlin.uuid.Uuid + +class OcrPerformanceScreenViewModel( + context: Context, + private val preferencesRepository: OcrQueuePreferencesRepository, +) : ViewModel() { + companion object { + private const val LOG_TAG = "OcrPerfScreenVM" + + // Keep in sync with OcrQueuePreferencesViewModel's slider range + private val parallelCountIntRange = 1..(Runtime.getRuntime().availableProcessors() * 2).coerceAtLeast(2) + val parallelCountSliderRange = parallelCountIntRange.first.toFloat()..parallelCountIntRange.last.toFloat() + val parallelCountSliderSteps = parallelCountIntRange.count() - 1 + } + + data class HistoryEntry( + val uuid: Uuid = Uuid.generateV4(), + val timestamp: kotlin.time.Instant, + val parallel: Int, + val result: OcrPerformanceBenchmark.Result, + ) + + data class UiState( + val selectedImageUris: List = emptyList(), + val imageLoadError: Boolean = false, + val parallelCount: Int = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1), + val parallelCountInitialized: Boolean = false, + val running: Boolean = false, + val runningParallel: Int? = null, + val progress: Int = 0, + val progressTotal: Int = 0, + val result: OcrPerformanceBenchmark.Result? = null, + val resultParallel: Int? = null, + val history: List = emptyList(), + val errorMessage: String? = null, + ) + + private val applicationContext = context.applicationContext + private val logger = Logger.withTag(LOG_TAG) + private val _uiState = MutableStateFlow(UiState()) + val uiState = _uiState.asStateFlow() + + private var benchmarkJob: Job? = null + + init { + viewModelScope.launch { + preferencesRepository.preferencesFlow.collectLatest { preferences -> + _uiState.update { + // Initial slider value follows the production config + it.copy(parallelCount = if (it.parallelCountInitialized) it.parallelCount else preferences.parallelCount) + } + _uiState.update { it.copy(parallelCountInitialized = true) } + } + } + } + + fun onImagesPicked(uris: List) { + _uiState.update { it.copy(selectedImageUris = uris, imageLoadError = false, errorMessage = null) } + } + + fun clearImages() { + _uiState.update { it.copy(selectedImageUris = emptyList(), imageLoadError = false) } + } + + fun onParallelCountChange(value: Float) { + _uiState.update { it.copy(parallelCount = round(value).toInt().coerceIn(parallelCountIntRange)) } + } + + fun runBenchmark() { + val state = _uiState.value + if (state.running) return + + benchmarkJob = + viewModelScope.launch { + _uiState.update { + it.copy( + running = true, + runningParallel = it.parallelCount, + progress = 0, + progressTotal = 0, + result = null, + resultParallel = null, + errorMessage = null, + ) + } + try { + // On decode failure: clear the selection and ask the user to pick again + val images = decodeImages(state.selectedImageUris) + val parallel = _uiState.value.parallelCount + val result = + withContext(Dispatchers.Default) { + OcrPerformanceBenchmark.runBenchmark( + context = applicationContext, + images = images, + parallel = parallel, + ) { completed, total -> + _uiState.update { it.copy(progress = completed, progressTotal = total) } + } + } + _uiState.update { + it.copy( + running = false, + runningParallel = null, + result = result, + resultParallel = parallel, + history = + it.history + + HistoryEntry( + timestamp = Clock.System.now(), + parallel = parallel, + result = result, + ), + ) + } + } catch (e: CancellationException) { + _uiState.update { it.copy(running = false, runningParallel = null) } + throw e + } catch (e: ImageLoadException) { + logger.e(e) { "Failed to decode benchmark images" } + _uiState.update { + it.copy( + running = false, + runningParallel = null, + selectedImageUris = emptyList(), + imageLoadError = true, + ) + } + } catch (e: Exception) { + logger.e(e) { "Benchmark failed" } + _uiState.update { it.copy(running = false, runningParallel = null, errorMessage = e.message) } + } + } + } + + fun cancelBenchmark() { + benchmarkJob?.cancel() + } + + private class ImageLoadException : IOException() + + private suspend fun decodeImages(uris: List): List = + withContext(Dispatchers.IO) { + uris.map { uri -> + val bytes = + applicationContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw ImageLoadException() + val mat = Imgcodecs.imdecode(MatOfByte(*bytes), Imgcodecs.IMREAD_COLOR) + if (mat.empty()) throw ImageLoadException() + mat + } + } +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 404dd67c..5befb0b0 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -258,6 +258,33 @@ OCR 依赖项 + OCR 性能 + 图片 + 参数 + 结果 + 复制报告 + 报告已复制 + 并发数 %d + 单任务中位 + %.0f ms + 吞吐 + %.1f it/s + 批耗时 + %s ms + 输出 + 一致 + 不一致 + 运行 + 正在测试并发数 %d + 清空选择 + 选择截图 + + 已选择 %d 张图片 + + 无法读取所选图片,请重新选择 + 取消 + 历史记录(本次会话) + 基准测试失败: %s 发送崩溃报告 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 51f9c6ba..4e2c574a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -290,6 +290,34 @@ CC BY-SA 3.0 OCR Dependencies + OCR Performance + Images + Parameters + Result + Copy report + Report copied + Parallel Count %d + Median per image + %.0f ms + Throughput + %.1f it/s + Batch times + %s ms + Output + Consistent + Inconsistent + Run + Testing parallel count %d + Clear selection + Select screenshot(s) + + %d image selected + %d images selected + + Failed to read the selected image(s), please select again + Cancel + History (this session) + Benchmark failed: %s Send crash reports diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt new file mode 100644 index 00000000..2ccd1269 --- /dev/null +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt @@ -0,0 +1,143 @@ +package xyz.sevive.arcaeaoffline.core.ocr.device + +import ai.onnxruntime.OrtSession +import android.content.Context +import co.touchlab.kermit.Logger +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.opencv.core.Mat +import xyz.sevive.arcaeaoffline.core.ocr.device.rois.DeviceRoisAutoSelector +import xyz.sevive.arcaeaoffline.core.ocr.device.rois.DeviceRoisAutoSelectorResult +import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT1 +import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT2 +import xyz.sevive.arcaeaoffline.core.ocr.device.rois.extractor.DeviceRoisExtractor +import kotlin.system.measureTimeMillis + +/** + * OCR performance benchmark. Mirrors the production queue's Channel-based + * concurrency model (OcrQueueProcessingJob): runs fixed task batches at a + * given parallel count and reports timings/throughput, so users can pick a + * suitable OcrQueuePreferences.parallelCount. + * + * Inference path matches production (DeviceOcrOnnxHelper.createOrtSession + ocrBgrMat). + */ +object OcrPerformanceBenchmark { + private const val LOG_TAG = "OcrPerfBench" + private val logger = Logger.withTag(LOG_TAG) + + const val DEFAULT_TASKS_PER_BATCH: Int = 40 + const val DEFAULT_WARMUP_BATCHES: Int = 1 + const val DEFAULT_TIMED_BATCHES: Int = 5 + + data class Result( + val batchTimesMs: List, + val medianPerImageMs: Double, + val throughputPerSecond: Double, + val resultsConsistent: Boolean, + ) + + fun extractRois(img: Mat): List { + val imgCropped = CropBlackEdges.crop(img) + val rois = + when (DeviceRoisAutoSelector.select(img)) { + DeviceRoisAutoSelectorResult.T1 -> DeviceRoisAutoT1(imgCropped.width(), imgCropped.height()) + else -> DeviceRoisAutoT2(imgCropped.width(), imgCropped.height()) + } + val extractor = DeviceRoisExtractor(rois, imgCropped) + return listOf( + extractor.pure, + extractor.far, + extractor.lost, + extractor.score, + extractor.maxRecall, + ) + } + + suspend fun runBenchmark( + context: Context, + images: List, + parallel: Int, + tasksPerBatch: Int = DEFAULT_TASKS_PER_BATCH, + warmupBatches: Int = DEFAULT_WARMUP_BATCHES, + timedBatches: Int = DEFAULT_TIMED_BATCHES, + onProgress: (completed: Int, total: Int) -> Unit = { _, _ -> }, + ): Result { + require(images.isNotEmpty()) { "At least one image is required" } + require(parallel >= 1) { "Parallel count must be >= 1" } + + val tasks = List(tasksPerBatch) { extractRois(images[it % images.size]) } + val session = DeviceOcrOnnxHelper.createOrtSession(context) + session.use { session -> + val totalBatches = warmupBatches + timedBatches + repeat(warmupBatches) { i -> + processBatch(tasks, parallel, session) + onProgress(i + 1, totalBatches) + } + + val batchTimes = mutableListOf() + var reference: List>? = null + var consistent = true + repeat(timedBatches) { i -> + val batch = processBatch(tasks, parallel, session) + batchTimes += batch.elapsedMs + val ref = reference ?: batch.results.also { reference = it } + if (batch.results != ref) consistent = false + onProgress(warmupBatches + i + 1, totalBatches) + } + + // Median resists single-run spikes (DVFS/scheduling noise), used instead of the mean + val sortedBatchTimes = batchTimes.sorted() + val medianBatchMs = sortedBatchTimes[sortedBatchTimes.size / 2].toDouble() + logger.i { + "Benchmark done: parallel %d, taskPerBatch %d, batchTimes(ms) %s, throughput %.1f it/s, consistent %s".format( + parallel, + tasksPerBatch, + batchTimes.joinToString("/"), + tasksPerBatch * 1000.0 / medianBatchMs, + consistent, + ) + } + return Result( + batchTimesMs = batchTimes, + medianPerImageMs = medianBatchMs / tasksPerBatch, + throughputPerSecond = tasksPerBatch * 1000.0 / medianBatchMs, + resultsConsistent = consistent, + ) + } + } + + private data class BatchResult( + val elapsedMs: Long, + val results: List>, + ) + + private suspend fun processBatch( + tasks: List>, + parallel: Int, + session: OrtSession, + ): BatchResult { + val results = MutableList(tasks.size) { emptyList() } + val elapsedMs = + measureTimeMillis { + coroutineScope { + val channel = Channel(parallel) + + launch { + tasks.indices.forEach { channel.send(it) } + channel.close() + } + + repeat(parallel) { + launch(Dispatchers.IO) { + for (idx in channel) { + results[idx] = tasks[idx].map { DeviceOcrOnnxHelper.ocrBgrMat(it, session) } + } + } + } + } + } + return BatchResult(elapsedMs, results) + } +} From cf476b1bba40bd1a6f2907595e20925aa41fede6 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 19 Aug 2026 01:20:35 +0800 Subject: [PATCH 18/31] fix: unify parallel count source --- .../arcaeaoffline/datastore/OcrQueuePreferences.kt | 12 ++++++++++-- .../arcaeaoffline/jobs/OcrQueueProcessingJob.kt | 3 ++- .../ocr/performance/OcrPerformanceScreenViewModel.kt | 6 +++--- .../preferences/OcrQueuePreferencesViewModel.kt | 3 ++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt index 1d60c31c..7e7b79a6 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt @@ -21,8 +21,16 @@ data class OcrQueuePreferences( @SerialName("check_is_arcaea_image") val checkIsArcaeaImage: Boolean = OcrQueueStagingOptions.DEFAULTS.checkIsArcaeaImage, @SerialName("parallel_count") - val parallelCount: Int = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1), -) + val parallelCount: Int = defaultParallelCount(), +) { + companion object { + // Single source of truth shared by the datastore default, the queue + // processing job fallback, and the queue/performance ViewModels. + fun defaultParallelCount(): Int = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + + fun parallelCountRange(): IntRange = 1..(Runtime.getRuntime().availableProcessors() * 2).coerceAtLeast(2) + } +} object OcrQueuePreferencesSerializer : OkioSerializer { override val defaultValue: OcrQueuePreferences = OcrQueuePreferences() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt index 042a6de3..7bd0e2a5 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt @@ -29,6 +29,7 @@ import xyz.sevive.arcaeaoffline.data.notification.Notifications import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTaskStatus import xyz.sevive.arcaeaoffline.database.repositories.OcrQueueTaskRepository +import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferences import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.helpers.toWorkData import kotlin.time.Duration.Companion.milliseconds @@ -89,7 +90,7 @@ class OcrQueueProcessingJob( inputData .getInt( DATA_PARALLEL_COUNT, - Runtime.getRuntime().availableProcessors() / 2, + OcrQueuePreferences.defaultParallelCount(), ).coerceAtLeast(1), ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt index 030d7c4f..072c0b67 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt @@ -18,6 +18,7 @@ import org.opencv.core.Mat import org.opencv.core.MatOfByte import org.opencv.imgcodecs.Imgcodecs import xyz.sevive.arcaeaoffline.core.ocr.device.OcrPerformanceBenchmark +import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferences import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesRepository import java.io.IOException import kotlin.math.round @@ -31,8 +32,7 @@ class OcrPerformanceScreenViewModel( companion object { private const val LOG_TAG = "OcrPerfScreenVM" - // Keep in sync with OcrQueuePreferencesViewModel's slider range - private val parallelCountIntRange = 1..(Runtime.getRuntime().availableProcessors() * 2).coerceAtLeast(2) + private val parallelCountIntRange = OcrQueuePreferences.parallelCountRange() val parallelCountSliderRange = parallelCountIntRange.first.toFloat()..parallelCountIntRange.last.toFloat() val parallelCountSliderSteps = parallelCountIntRange.count() - 1 } @@ -47,7 +47,7 @@ class OcrPerformanceScreenViewModel( data class UiState( val selectedImageUris: List = emptyList(), val imageLoadError: Boolean = false, - val parallelCount: Int = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1), + val parallelCount: Int = OcrQueuePreferences.defaultParallelCount(), val parallelCountInitialized: Boolean = false, val running: Boolean = false, val runningParallel: Int? = null, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/preferences/OcrQueuePreferencesViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/preferences/OcrQueuePreferencesViewModel.kt index 36e3a8db..d81cb441 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/preferences/OcrQueuePreferencesViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/preferences/OcrQueuePreferencesViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferences import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesRepository import kotlin.time.Duration.Companion.seconds @@ -16,7 +17,7 @@ class OcrQueuePreferencesViewModel( val checkIsImage: Boolean = false, val checkIsArcaeaImage: Boolean = false, val parallelCount: Int = -1, - val parallelCountIntRange: IntRange = 1..Runtime.getRuntime().availableProcessors() * 2, + val parallelCountIntRange: IntRange = OcrQueuePreferences.parallelCountRange(), ) { val parallelCountSliderRange = parallelCountIntRange.first.toFloat()..parallelCountIntRange.last.toFloat() val parallelCountSliderSteps = parallelCountIntRange.count() - 1 From 6758429bdb358b40d2663207803aca971a66c89d Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 19 Aug 2026 01:23:26 +0800 Subject: [PATCH 19/31] fix: add crnn model asset file check --- .../arcaeaoffline/helpers/OcrDependencyStatus.kt | 9 ++++++++- .../helpers/OcrDependencyStatusBuilder.kt | 4 ++++ .../core/ocr/device/DeviceOcrOnnxHelper.kt | 15 ++++++++++++++- 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt index cb16cb37..1f73291a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatus.kt @@ -14,7 +14,14 @@ interface OcrDependencyStatusDetail { fun details(): String? { if (exception == null) return null - return exception!!.message ?: exception.toString() + + return exception?.let { + buildString { + append(it::class.simpleName ?: "Exception") + append(": ") + append(it.message) + } + } ?: exception.toString() } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt index 8b990ff7..bd1412e2 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt @@ -34,6 +34,10 @@ object OcrDependencyStatusBuilder { fun crnnModel(context: Context): CrnnModelStatusDetail = try { + // model_info.json can parse fine while the model asset itself is missing + // or empty (e.g. incomplete local debug assets); check the asset file + // too before reporting OK. + DeviceOcrOnnxHelper.checkModelAsset(context) val info = DeviceOcrOnnxHelper.loadModelInfoFile(context) with(info) { CrnnModelStatusDetail( diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt index 4eca011f..63b8dbd1 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt @@ -13,12 +13,14 @@ import kotlinx.serialization.json.Json import org.opencv.core.Mat import org.opencv.core.Size import org.opencv.imgproc.Imgproc +import java.io.IOException import java.nio.ByteBuffer import kotlin.jvm.optionals.getOrElse import kotlin.properties.Delegates object DeviceOcrOnnxHelper { private const val LOG_TAG = "OnnxHelper" + private const val MODEL_ASSET_PATH = "ocr/model_patched.onnx" private val logger = Logger.withTag(LOG_TAG) private var imageSize by Delegates.notNull() @@ -83,7 +85,18 @@ object DeviceOcrOnnxHelper { padToken = modelInfo.padToken } - private fun readOnnxModelBytes(context: Context): ByteArray = context.assets.open("ocr/model_patched.onnx").readBytes() + private fun readOnnxModelBytes(context: Context): ByteArray = context.assets.open(MODEL_ASSET_PATH).readBytes() + + /** + * Lightweight sanity check for the bundled model asset: the entry must exist + * and be non-empty. This does not load or validate the model itself (a + * truncated file still passes); use [createOrtSession] for a full check. + */ + fun checkModelAsset(context: Context) { + context.assets.open(MODEL_ASSET_PATH).use { stream -> + if (stream.read(ByteArray(1)) == -1) throw IOException("OCR model asset is empty: $MODEL_ASSET_PATH") + } + } /** * @see ONNX documentation From 01a6eaaeeee718a1b341793f01e42b6aeac443ca Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 19 Aug 2026 03:06:43 +0800 Subject: [PATCH 20/31] fix: optimize memory usage of ocr benchmark page and ocr core --- .../arcaeaoffline/helpers/DeviceOcrHelper.kt | 64 +++++----- .../OcrPerformanceScreenViewModel.kt | 42 +++++-- .../sevive/arcaeaoffline/core/ocr/Utils.kt | 17 +++ .../core/ocr/device/CropBlackEdges.kt | 53 ++++---- .../core/ocr/device/DeviceOcrOnnxHelper.kt | 68 ++++++----- .../ocr/device/OcrPerformanceBenchmark.kt | 115 ++++++++++-------- 6 files changed, 214 insertions(+), 145 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt index 4c750928..81fd2d2a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt @@ -34,6 +34,7 @@ import xyz.sevive.arcaeaoffline.core.ocr.device.rois.extractor.DeviceRoisExtract import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMaskerAutoT1 import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMaskerAutoT2 import xyz.sevive.arcaeaoffline.core.ocr.device.toPlayResult +import xyz.sevive.arcaeaoffline.core.ocr.use import xyz.sevive.arcaeaoffline.helpers.context.getFilename import kotlin.time.Instant @@ -59,39 +60,44 @@ object DeviceOcrHelper { ortSession: OrtSession, ): DeviceOcrResult { val byteArray = PlatformFile(imageUri).readBytes() - val img = Imgcodecs.imdecode(MatOfByte(*byteArray), Imgcodecs.IMREAD_COLOR) - val imgCropped = CropBlackEdges.crop(img) - val roisAutoType = DeviceRoisAutoSelector.select(img) - val rois = - when (roisAutoType) { - DeviceRoisAutoSelectorResult.T1 -> { - DeviceRoisAutoT1( - imgCropped.width(), - imgCropped.height(), - ) - } + return MatOfByte(*byteArray).use { matOfBytes -> + Imgcodecs.imdecode(matOfBytes, Imgcodecs.IMREAD_COLOR).use { img -> + val roisAutoType = DeviceRoisAutoSelector.select(img) + + CropBlackEdges.crop(img).use { imgCropped -> + val rois = + when (roisAutoType) { + DeviceRoisAutoSelectorResult.T1 -> { + DeviceRoisAutoT1( + imgCropped.width(), + imgCropped.height(), + ) + } + + else -> { + DeviceRoisAutoT2( + imgCropped.width(), + imgCropped.height(), + ) + } + } + val extractor = DeviceRoisExtractor(rois, imgCropped) + val masker = + when (roisAutoType) { + DeviceRoisAutoSelectorResult.T1 -> DeviceRoisMaskerAutoT1() + else -> DeviceRoisMaskerAutoT2() + } - else -> { - DeviceRoisAutoT2( - imgCropped.width(), - imgCropped.height(), - ) + DeviceOcr( + extractor = extractor, + masker = masker, + ortSession = ortSession, + hashesDb = imageHashesDatabase, + ).ocr() } } - val extractor = DeviceRoisExtractor(rois, imgCropped) - val masker = - when (roisAutoType) { - DeviceRoisAutoSelectorResult.T1 -> DeviceRoisMaskerAutoT1() - else -> DeviceRoisMaskerAutoT2() - } - - return DeviceOcr( - extractor = extractor, - masker = masker, - ortSession = ortSession, - hashesDb = imageHashesDatabase, - ).ocr() + } } fun readImageDateFromExif( diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt index 072c0b67..090d4a2f 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt @@ -18,6 +18,7 @@ import org.opencv.core.Mat import org.opencv.core.MatOfByte import org.opencv.imgcodecs.Imgcodecs import xyz.sevive.arcaeaoffline.core.ocr.device.OcrPerformanceBenchmark +import xyz.sevive.arcaeaoffline.core.ocr.use import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferences import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesRepository import java.io.IOException @@ -108,14 +109,17 @@ class OcrPerformanceScreenViewModel( ) } try { - // On decode failure: clear the selection and ask the user to pick again - val images = decodeImages(state.selectedImageUris) + // On decode failure: clear the selection and ask the user to pick again. + // decodeAndExtractRois and runBenchmark are called back-to-back with no + // suspension point in between: runBenchmark takes ownership of the ROI + // Mats and releases them on every exit path, so nothing leaks even if + // the coroutine is cancelled mid-benchmark. val parallel = _uiState.value.parallelCount val result = withContext(Dispatchers.Default) { OcrPerformanceBenchmark.runBenchmark( context = applicationContext, - images = images, + roiSets = decodeAndExtractRois(state.selectedImageUris), parallel = parallel, ) { completed, total -> _uiState.update { it.copy(progress = completed, progressTotal = total) } @@ -162,15 +166,31 @@ class OcrPerformanceScreenViewModel( private class ImageLoadException : IOException() - private suspend fun decodeImages(uris: List): List = + /** + * Decodes each image and immediately extracts its OCR ROIs; the decoded + * full-size Mats are released in this pass, only the small ROI clones + * survive. On any failure the already-extracted ROIs are released before + * rethrowing. + */ + private suspend fun decodeAndExtractRois(uris: List): List> = withContext(Dispatchers.IO) { - uris.map { uri -> - val bytes = - applicationContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: throw ImageLoadException() - val mat = Imgcodecs.imdecode(MatOfByte(*bytes), Imgcodecs.IMREAD_COLOR) - if (mat.empty()) throw ImageLoadException() - mat + val roiSets = mutableListOf>() + try { + uris.forEach { uri -> + val bytes = + applicationContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw ImageLoadException() + MatOfByte(*bytes).use { matOfBytes -> + Imgcodecs.imdecode(matOfBytes, Imgcodecs.IMREAD_COLOR).use { img -> + if (img.empty()) throw ImageLoadException() + roiSets.add(OcrPerformanceBenchmark.extractRois(img)) + } + } + } + roiSets + } catch (e: Exception) { + roiSets.flatten().forEach { it.release() } + throw e } } } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt index f0bcbb26..0f726d8a 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt @@ -4,6 +4,9 @@ import org.opencv.core.Core import org.opencv.core.CvType import org.opencv.core.Mat import org.opencv.core.Scalar +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract import kotlin.math.sqrt fun matMedian(mat: Mat): Double { @@ -100,3 +103,17 @@ fun collectionMedian(list: List) = it[it.size / 2] } } + +/** + * OpenCV Mats are backed by native memory and only reclaimed by GC + * finalization otherwise, which delays reclamation unpredictably. + */ +@OptIn(ExperimentalContracts::class) +inline fun Mat.use(block: (Mat) -> T): T { + contract { callsInPlace(block, InvocationKind.EXACTLY_ONCE) } + return try { + block(this) + } finally { + release() + } +} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt index 63bac669..6fe8c687 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt @@ -7,6 +7,7 @@ import org.opencv.core.Rect import org.opencv.core.Scalar import org.opencv.imgproc.Imgproc import xyz.sevive.arcaeaoffline.core.ocr.device.CropBlackEdges.Companion.cropOrOriginal +import xyz.sevive.arcaeaoffline.core.ocr.use class CropBlackEdges { companion object { @@ -17,17 +18,17 @@ class CropBlackEdges { imgGraySlice: Mat, blackPixelThreshold: Int, ratio: Double = 0.6, - ): Boolean { - val pixelsCompared = Mat() - Core.compare( - imgGraySlice, - Scalar(blackPixelThreshold.toDouble()), - pixelsCompared, - Core.CMP_LT, - ) + ): Boolean = + Mat().use { pixelsCompared -> + Core.compare( + imgGraySlice, + Scalar(blackPixelThreshold.toDouble()), + pixelsCompared, + Core.CMP_LT, + ) - return Core.countNonZero(pixelsCompared) > (imgGraySlice.width() * imgGraySlice.height()) * ratio - } + Core.countNonZero(pixelsCompared) > (imgGraySlice.width() * imgGraySlice.height()) * ratio + } private fun getCropRect( imgGray: Mat, @@ -41,31 +42,29 @@ class CropBlackEdges { var top = 0 var bottom = height + // submat views are passed directly instead of pixel-copying clones; + // isBlackEdge only reads the slice for (i in 0..width) { - val rect = Rect(i, 0, 1, height) - val column = imgGray.submat(rect).clone() - if (!isBlackEdge(column, blackPixelThreshold)) break + val isBlack = imgGray.submat(Rect(i, 0, 1, height)).use { isBlackEdge(it, blackPixelThreshold) } + if (!isBlack) break left += 1 } for (i in width downTo 0) { - val rect = Rect(i - 1, 0, 1, height) - val column = imgGray.submat(rect).clone() - if (!isBlackEdge(column, blackPixelThreshold)) break + val isBlack = imgGray.submat(Rect(i - 1, 0, 1, height)).use { isBlackEdge(it, blackPixelThreshold) } + if (!isBlack) break right -= 1 } for (i in 0..height) { - val rect = Rect(0, i, width, 1) - val row = imgGray.submat(rect).clone() - if (!isBlackEdge(row, blackPixelThreshold)) break + val isBlack = imgGray.submat(Rect(0, i, width, 1)).use { isBlackEdge(it, blackPixelThreshold) } + if (!isBlack) break top += 1 } for (i in height downTo 0) { - val rect = Rect(0, i - 1, width, 1) - val row = imgGray.submat(rect).clone() - if (!isBlackEdge(row, blackPixelThreshold)) break + val isBlack = imgGray.submat(Rect(0, i - 1, width, 1)).use { isBlackEdge(it, blackPixelThreshold) } + if (!isBlack) break bottom -= 1 } @@ -86,9 +85,13 @@ class CropBlackEdges { blackPixelThreshold: Int = 25, ): Mat { val imgGray = Mat() - Imgproc.cvtColor(img, imgGray, convertFlag) - val rect = getCropRect(imgGray, blackPixelThreshold) - return img.submat(rect).clone() + try { + Imgproc.cvtColor(img, imgGray, convertFlag) + val rect = getCropRect(imgGray, blackPixelThreshold) + return img.submat(rect).clone() + } finally { + imgGray.release() + } } /** diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt index 63b8dbd1..d6b05e4b 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt @@ -13,6 +13,7 @@ import kotlinx.serialization.json.Json import org.opencv.core.Mat import org.opencv.core.Size import org.opencv.imgproc.Imgproc +import xyz.sevive.arcaeaoffline.core.ocr.use import java.io.IOException import java.nio.ByteBuffer import kotlin.jvm.optionals.getOrElse @@ -88,9 +89,9 @@ object DeviceOcrOnnxHelper { private fun readOnnxModelBytes(context: Context): ByteArray = context.assets.open(MODEL_ASSET_PATH).readBytes() /** - * Lightweight sanity check for the bundled model asset: the entry must exist - * and be non-empty. This does not load or validate the model itself (a - * truncated file still passes); use [createOrtSession] for a full check. + * Lightweight sanity check for the bundled model asset. Does not load or + * validate the model itself (a truncated file still passes); use + * [createOrtSession] for a full check. */ fun checkModelAsset(context: Context) { context.assets.open(MODEL_ASSET_PATH).use { stream -> @@ -125,22 +126,22 @@ object DeviceOcrOnnxHelper { } } - private fun matToModelInput(rgbMat: Mat): OnnxTensor { - val ortMat = Mat() - Imgproc.resize(rgbMat, ortMat, imageSize) - - // convert cv.Mat into ByteBuffer - val size = ortMat.total() * ortMat.elemSize() - val byteBuffer: ByteBuffer = ByteBuffer.allocate(size.toInt()) - ortMat.get(0, 0, byteBuffer.array()) - - return OnnxTensor.createTensor( - getOrtEnvironment(), - byteBuffer, - imageShape, - OnnxJavaType.UINT8, - ) - } + private fun matToModelInput(rgbMat: Mat): OnnxTensor = + Mat().use { ortMat -> + Imgproc.resize(rgbMat, ortMat, imageSize) + + // convert cv.Mat into ByteBuffer + val size = ortMat.total() * ortMat.elemSize() + val byteBuffer: ByteBuffer = ByteBuffer.allocate(size.toInt()) + ortMat.get(0, 0, byteBuffer.array()) + + OnnxTensor.createTensor( + getOrtEnvironment(), + byteBuffer, + imageShape, + OnnxJavaType.UINT8, + ) + } private fun modelDecodedOutputToString(onnxTensor: OnnxTensor): String { val rawPredictions = mutableListOf() @@ -167,16 +168,25 @@ object DeviceOcrOnnxHelper { bgrMat: Mat, ortSession: OrtSession, ): String { - val rgbMat = Mat() - Imgproc.cvtColor(bgrMat, rgbMat, Imgproc.COLOR_BGR2RGB) - - val inputTensor = matToModelInput(rgbMat) - val result = ortSession.run(mapOf("raw_image" to inputTensor)) - val decodedOutput = - result - .get("decoded_output") - .getOrElse { throw NullPointerException("ONNX model output null!") } - val finalResult = modelDecodedOutputToString(decodedOutput as OnnxTensor) + // Ownership notes (per ORT Java API): + // - the input OnnxTensor is NOT owned by OrtSession.Result and must be + // closed by the caller; + // - Result.close() owns and closes the output tensors it contains, and + // the decoded output must be read before Result closes. + val finalResult = + Mat().use { rgbMat -> + Imgproc.cvtColor(bgrMat, rgbMat, Imgproc.COLOR_BGR2RGB) + + matToModelInput(rgbMat).use { inputTensor -> + ortSession.run(mapOf("raw_image" to inputTensor)).use { result -> + val decodedOutput = + result + .get("decoded_output") + .getOrElse { throw NullPointerException("ONNX model output null!") } + modelDecodedOutputToString(decodedOutput as OnnxTensor) + } + } + } var placeholderCount = 0 return buildString { for (char in finalResult) { diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt index 2ccd1269..f48fe262 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt @@ -13,6 +13,7 @@ import xyz.sevive.arcaeaoffline.core.ocr.device.rois.DeviceRoisAutoSelectorResul import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT1 import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT2 import xyz.sevive.arcaeaoffline.core.ocr.device.rois.extractor.DeviceRoisExtractor +import xyz.sevive.arcaeaoffline.core.ocr.use import kotlin.system.measureTimeMillis /** @@ -38,73 +39,85 @@ object OcrPerformanceBenchmark { val resultsConsistent: Boolean, ) - fun extractRois(img: Mat): List { - val imgCropped = CropBlackEdges.crop(img) - val rois = - when (DeviceRoisAutoSelector.select(img)) { - DeviceRoisAutoSelectorResult.T1 -> DeviceRoisAutoT1(imgCropped.width(), imgCropped.height()) - else -> DeviceRoisAutoT2(imgCropped.width(), imgCropped.height()) + /** + * Returns the OCR ROIs for one image as cloned Mats that own their data; + * ownership transfers to the caller. + */ + fun extractRois(img: Mat): List = + CropBlackEdges.crop(img).use { imgCropped -> + val rois = + when (DeviceRoisAutoSelector.select(img)) { + DeviceRoisAutoSelectorResult.T1 -> DeviceRoisAutoT1(imgCropped.width(), imgCropped.height()) + else -> DeviceRoisAutoT2(imgCropped.width(), imgCropped.height()) + } + val extractor = DeviceRoisExtractor(rois, imgCropped) + + // extractor properties return submat views of imgCropped; clone them + // so the returned Mats stay valid after imgCropped is released. + listOf(extractor.pure, extractor.far, extractor.lost, extractor.score, extractor.maxRecall).map { view -> + view.use { it.clone() } } - val extractor = DeviceRoisExtractor(rois, imgCropped) - return listOf( - extractor.pure, - extractor.far, - extractor.lost, - extractor.score, - extractor.maxRecall, - ) - } + } + /** + * Takes ownership of [roiSets]: the ROI Mats are released on every exit + * path (completion, failure, cancellation). Reuses the same ROI Mats for + * all batches and warmups; inference reads them without mutating, which + * is safe concurrently. + */ suspend fun runBenchmark( context: Context, - images: List, + roiSets: List>, parallel: Int, tasksPerBatch: Int = DEFAULT_TASKS_PER_BATCH, warmupBatches: Int = DEFAULT_WARMUP_BATCHES, timedBatches: Int = DEFAULT_TIMED_BATCHES, onProgress: (completed: Int, total: Int) -> Unit = { _, _ -> }, ): Result { - require(images.isNotEmpty()) { "At least one image is required" } + require(roiSets.isNotEmpty()) { "At least one image is required" } require(parallel >= 1) { "Parallel count must be >= 1" } - val tasks = List(tasksPerBatch) { extractRois(images[it % images.size]) } - val session = DeviceOcrOnnxHelper.createOrtSession(context) - session.use { session -> - val totalBatches = warmupBatches + timedBatches - repeat(warmupBatches) { i -> - processBatch(tasks, parallel, session) - onProgress(i + 1, totalBatches) - } + val tasks = List(tasksPerBatch) { roiSets[it % roiSets.size] } + try { + DeviceOcrOnnxHelper.createOrtSession(context).use { session -> + val totalBatches = warmupBatches + timedBatches + repeat(warmupBatches) { i -> + processBatch(tasks, parallel, session) + onProgress(i + 1, totalBatches) + } - val batchTimes = mutableListOf() - var reference: List>? = null - var consistent = true - repeat(timedBatches) { i -> - val batch = processBatch(tasks, parallel, session) - batchTimes += batch.elapsedMs - val ref = reference ?: batch.results.also { reference = it } - if (batch.results != ref) consistent = false - onProgress(warmupBatches + i + 1, totalBatches) - } + val batchTimes = mutableListOf() + var reference: List>? = null + var consistent = true + repeat(timedBatches) { i -> + val batch = processBatch(tasks, parallel, session) + batchTimes += batch.elapsedMs + val ref = reference ?: batch.results.also { reference = it } + if (batch.results != ref) consistent = false + onProgress(warmupBatches + i + 1, totalBatches) + } - // Median resists single-run spikes (DVFS/scheduling noise), used instead of the mean - val sortedBatchTimes = batchTimes.sorted() - val medianBatchMs = sortedBatchTimes[sortedBatchTimes.size / 2].toDouble() - logger.i { - "Benchmark done: parallel %d, taskPerBatch %d, batchTimes(ms) %s, throughput %.1f it/s, consistent %s".format( - parallel, - tasksPerBatch, - batchTimes.joinToString("/"), - tasksPerBatch * 1000.0 / medianBatchMs, - consistent, + // Median resists single-run spikes (DVFS/scheduling noise), used instead of the mean + val sortedBatchTimes = batchTimes.sorted() + val medianBatchMs = sortedBatchTimes[sortedBatchTimes.size / 2].toDouble() + logger.i { + "Benchmark done: parallel %d, taskPerBatch %d, batchTimes(ms) %s, throughput %.1f it/s, consistent %s".format( + parallel, + tasksPerBatch, + batchTimes.joinToString("/"), + tasksPerBatch * 1000.0 / medianBatchMs, + consistent, + ) + } + return Result( + batchTimesMs = batchTimes, + medianPerImageMs = medianBatchMs / tasksPerBatch, + throughputPerSecond = tasksPerBatch * 1000.0 / medianBatchMs, + resultsConsistent = consistent, ) } - return Result( - batchTimesMs = batchTimes, - medianPerImageMs = medianBatchMs / tasksPerBatch, - throughputPerSecond = tasksPerBatch * 1000.0 / medianBatchMs, - resultsConsistent = consistent, - ) + } finally { + roiSets.flatten().forEach { it.release() } } } From 5668ab50a52e48cd6a7259b2149eaca6e05803da Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 19 Aug 2026 03:07:00 +0800 Subject: [PATCH 21/31] chore: refining comments --- .../arcaeaoffline/datastore/OcrQueuePreferences.kt | 2 -- .../helpers/OcrDependencyStatusBuilder.kt | 5 ++--- .../performance/OcrPerformanceScreenViewModel.kt | 14 +++++--------- 3 files changed, 7 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt index 7e7b79a6..b76e1a33 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt @@ -24,8 +24,6 @@ data class OcrQueuePreferences( val parallelCount: Int = defaultParallelCount(), ) { companion object { - // Single source of truth shared by the datastore default, the queue - // processing job fallback, and the queue/performance ViewModels. fun defaultParallelCount(): Int = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) fun parallelCountRange(): IntRange = 1..(Runtime.getRuntime().availableProcessors() * 2).coerceAtLeast(2) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt index bd1412e2..9d97db54 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt @@ -34,9 +34,8 @@ object OcrDependencyStatusBuilder { fun crnnModel(context: Context): CrnnModelStatusDetail = try { - // model_info.json can parse fine while the model asset itself is missing - // or empty (e.g. incomplete local debug assets); check the asset file - // too before reporting OK. + // model_info.json can parse fine while the model asset itself is + // missing or empty (e.g. incomplete local debug assets) DeviceOcrOnnxHelper.checkModelAsset(context) val info = DeviceOcrOnnxHelper.loadModelInfoFile(context) with(info) { diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt index 090d4a2f..77db34f0 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt @@ -109,11 +109,9 @@ class OcrPerformanceScreenViewModel( ) } try { - // On decode failure: clear the selection and ask the user to pick again. - // decodeAndExtractRois and runBenchmark are called back-to-back with no - // suspension point in between: runBenchmark takes ownership of the ROI - // Mats and releases them on every exit path, so nothing leaks even if - // the coroutine is cancelled mid-benchmark. + // no suspension point between decode and run, so ownership + // of the ROI Mats transfers to runBenchmark without a + // cancellation window val parallel = _uiState.value.parallelCount val result = withContext(Dispatchers.Default) { @@ -167,10 +165,8 @@ class OcrPerformanceScreenViewModel( private class ImageLoadException : IOException() /** - * Decodes each image and immediately extracts its OCR ROIs; the decoded - * full-size Mats are released in this pass, only the small ROI clones - * survive. On any failure the already-extracted ROIs are released before - * rethrowing. + * Decodes each image and extracts its OCR ROIs; only the ROI clones + * survive this pass. Already-extracted ROIs are released on failure. */ private suspend fun decodeAndExtractRois(uris: List): List> = withContext(Dispatchers.IO) { From 0c056d4d9cd71dee1e84f145f3ce4cf335971c38 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 19 Aug 2026 03:10:41 +0800 Subject: [PATCH 22/31] fix: remove FixRect leftovers --- .../sevive/arcaeaoffline/core/ocr/Utils.kt | 99 ------------------- 1 file changed, 99 deletions(-) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt index 0f726d8a..4dd509c8 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt @@ -1,108 +1,9 @@ package xyz.sevive.arcaeaoffline.core.ocr -import org.opencv.core.Core -import org.opencv.core.CvType import org.opencv.core.Mat -import org.opencv.core.Scalar import kotlin.contracts.ExperimentalContracts import kotlin.contracts.InvocationKind import kotlin.contracts.contract -import kotlin.math.sqrt - -fun matMedian(mat: Mat): Double { - // Convert the matrix to a single row vector - val arr = mat.clone().reshape(1, mat.rows() * mat.cols()) - - val arrSorted = Mat() - Core.sort(arr, arrSorted, Core.SORT_EVERY_COLUMN + Core.SORT_ASCENDING) - - val rows = arr.rows() - return if (rows % 2 == 0) { - val midIndex1 = rows / 2 - val midIndex2 = midIndex1 - 1 - - val midValue1 = arrSorted.get(midIndex1, 0)[0] - val midValue2 = arrSorted.get(midIndex2, 0)[0] - (midValue1 + midValue2) / 2.0 - } else { - val middleIndex = rows / 2 - arrSorted.get(middleIndex, 0)[0] - } -} - -/** - * Port of numpy.bincount. - * - * [Original documentation](https://numpy.org/doc/stable/reference/generated/numpy.bincount.html#numpy-bincount) - * - * @param x array_like, 1 dimension, nonnegative ints. - * @param weights array_like, same shape as x. - * @param minLength int, minimum number of bins for the output array. - */ -@Suppress("unused") -fun binCount( - x: Mat, - weights: Mat? = null, - minLength: Int = 0, -): Mat { - assert(x.cols() == 1) { "binCount: `x` should be a one dimension array" } - if (weights != null) { - assert(weights.size() == x.size()) { "binCount: `weights` should have the same size as `x`" } - } - val checkMat = Mat() - Core.compare(x, Scalar(0.0), checkMat, Core.CMP_GE) - assert(Core.countNonZero(checkMat) == x.rows()) { "binCount: `x` should not have negative values" } - - // determine bin size - val binSizeFromMat = Core.minMaxLoc(x).maxVal.toInt() + 1 - val binSize = if (minLength > binSizeFromMat) minLength else binSizeFromMat - - // extract values - val matValues = mutableListOf() - for (i in 0 until x.rows()) { - matValues.add(x.get(i, 0)[0].toInt()) - } - - val binArr = (0 until binSize).map { index -> matValues.count { value -> index == value } } - var binResultArr = binArr.map { it.toDouble() } - - if (weights != null) { - val weightValues = mutableListOf() - for (i in 0 until weights.rows()) { - weightValues.add(weights.get(i, 0)[0]) - } - val binWeights = (0 until binSize).map { 0.0 }.toMutableList() - for ((index, weight) in matValues.zip(weightValues)) { - binWeights[index] = binWeights[index] + weight - } - binResultArr = binResultArr.zip(binWeights).map { (value, weight) -> value * weight } - } - - val resultMat = Mat.zeros(binSize, 1, CvType.CV_32F) - for (i in 0 until binSize) { - resultMat.put(i, 0, binResultArr[i]) - } - return resultMat -} - -fun collectionStandardDeviation(list: Collection): Double { - // https://www.programmingcube.com/write-a-kotlin-program-to-calculate-standard-deviation - val mean = list.average() - - val squaredDifferences = list.map { (it - mean) * (it - mean) } - val meanOfSquaredDifferences = squaredDifferences.average() - - return sqrt(meanOfSquaredDifferences) -} - -fun collectionMedian(list: List) = - list.sorted().let { - if (it.size % 2 == 0) { - (it[it.size / 2] + it[(it.size - 1) / 2]) / 2 - } else { - it[it.size / 2] - } - } /** * OpenCV Mats are backed by native memory and only reclaimed by GC From 39ca3131ddc16bd3a3442d628c7eec184795be11 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 19 Aug 2026 04:23:41 +0800 Subject: [PATCH 23/31] chore: optimize Mat initialization from image --- .../arcaeaoffline/helpers/DeviceOcrHelper.kt | 18 ++--- .../arcaeaoffline/helpers/OcrQueueHelper.kt | 20 ++--- .../OcrPerformanceScreenViewModel.kt | 15 ++-- .../arcaeaoffline/core/ocr/ImageHashers.kt | 47 ++++++++---- .../core/ocr/device/DeviceOcr.kt | 75 +++++++++++-------- .../core/ocr/device/ImageDecode.kt | 38 ++++++++++ .../device/rois/masker/DeviceRoisMasker.kt | 1 + 7 files changed, 137 insertions(+), 77 deletions(-) create mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ImageDecode.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt index 81fd2d2a..045d6c40 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt @@ -8,7 +8,6 @@ import de.stefan_oltmann.kim.format.tiff.constant.ExifTag import de.stefan_oltmann.kim.input.ByteReader import de.stefan_oltmann.kim.input.KotlinIoSourceByteReader import io.github.vinceglb.filekit.PlatformFile -import io.github.vinceglb.filekit.readBytes import io.github.vinceglb.filekit.size import io.github.vinceglb.filekit.source import kotlinx.datetime.LocalDateTime @@ -18,14 +17,14 @@ import kotlinx.datetime.asTimeZone import kotlinx.datetime.format.char import kotlinx.datetime.parseOrNull import kotlinx.datetime.toInstant +import kotlinx.io.asInputStream import kotlinx.io.buffered -import org.opencv.core.MatOfByte -import org.opencv.imgcodecs.Imgcodecs import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.CropBlackEdges import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcr import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrResult +import xyz.sevive.arcaeaoffline.core.ocr.device.ImageDecode import xyz.sevive.arcaeaoffline.core.ocr.device.rois.DeviceRoisAutoSelector import xyz.sevive.arcaeaoffline.core.ocr.device.rois.DeviceRoisAutoSelectorResult import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT1 @@ -36,6 +35,7 @@ import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMaskerAuto import xyz.sevive.arcaeaoffline.core.ocr.device.toPlayResult import xyz.sevive.arcaeaoffline.core.ocr.use import xyz.sevive.arcaeaoffline.helpers.context.getFilename +import java.io.IOException import kotlin.time.Instant object DeviceOcrHelper { @@ -54,15 +54,16 @@ object DeviceOcrHelper { second() } - suspend fun ocrImage( + fun ocrImage( imageUri: Uri, imageHashesDatabase: ImageHashesDatabase, ortSession: OrtSession, - ): DeviceOcrResult { - val byteArray = PlatformFile(imageUri).readBytes() + ): DeviceOcrResult = + PlatformFile(imageUri).source().buffered().asInputStream().use { stream -> + val img = + ImageDecode.decode(stream) ?: throw IOException("Failed to decode image: $imageUri") - return MatOfByte(*byteArray).use { matOfBytes -> - Imgcodecs.imdecode(matOfBytes, Imgcodecs.IMREAD_COLOR).use { img -> + img.use { img -> val roisAutoType = DeviceRoisAutoSelector.select(img) CropBlackEdges.crop(img).use { imgCropped -> @@ -98,7 +99,6 @@ object DeviceOcrHelper { } } } - } fun readImageDateFromExif( byteReader: ByteReader, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt index d49add21..98fe6f30 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt @@ -3,7 +3,6 @@ package xyz.sevive.arcaeaoffline.helpers import android.graphics.BitmapFactory import android.net.Uri import io.github.vinceglb.filekit.PlatformFile -import io.github.vinceglb.filekit.readBytes import io.github.vinceglb.filekit.source import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -11,10 +10,10 @@ import kotlinx.coroutines.withContext import kotlinx.io.asInputStream import kotlinx.io.buffered import org.opencv.core.Mat -import org.opencv.core.MatOfByte -import org.opencv.imgcodecs.Imgcodecs import org.opencv.imgproc.Imgproc +import xyz.sevive.arcaeaoffline.core.ocr.device.ImageDecode import xyz.sevive.arcaeaoffline.core.ocr.device.ScreenshotDetect +import xyz.sevive.arcaeaoffline.core.ocr.use object OcrQueueHelper { suspend fun isUriImage(uri: Uri): Boolean = @@ -36,13 +35,14 @@ object OcrQueueHelper { suspend fun isUriArcaeaImage(uri: Uri): Boolean = withContext(Dispatchers.IO) { async { - val byteArray = PlatformFile(uri).readBytes() - - val img = Imgcodecs.imdecode(MatOfByte(*byteArray), Imgcodecs.IMREAD_COLOR) - val imgHsv = Mat() - Imgproc.cvtColor(img, imgHsv, Imgproc.COLOR_BGR2HSV) - - ScreenshotDetect.isArcaeaScreenshot(imgHsv) + PlatformFile(uri).source().buffered().asInputStream().use { stream -> + ImageDecode.decode(stream)?.use { img -> + Mat().use { imgHsv -> + Imgproc.cvtColor(img, imgHsv, Imgproc.COLOR_BGR2HSV) + ScreenshotDetect.isArcaeaScreenshot(imgHsv) + } + } ?: false + } }.await() } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt index 77db34f0..6d0b9583 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt @@ -15,8 +15,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.opencv.core.Mat -import org.opencv.core.MatOfByte -import org.opencv.imgcodecs.Imgcodecs +import xyz.sevive.arcaeaoffline.core.ocr.device.ImageDecode import xyz.sevive.arcaeaoffline.core.ocr.device.OcrPerformanceBenchmark import xyz.sevive.arcaeaoffline.core.ocr.use import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferences @@ -173,14 +172,12 @@ class OcrPerformanceScreenViewModel( val roiSets = mutableListOf>() try { uris.forEach { uri -> - val bytes = - applicationContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } + val inputStream = + applicationContext.contentResolver.openInputStream(uri) ?: throw ImageLoadException() - MatOfByte(*bytes).use { matOfBytes -> - Imgcodecs.imdecode(matOfBytes, Imgcodecs.IMREAD_COLOR).use { img -> - if (img.empty()) throw ImageLoadException() - roiSets.add(OcrPerformanceBenchmark.extractRois(img)) - } + inputStream.use { stream -> + val img = ImageDecode.decode(stream) ?: throw ImageLoadException() + img.use { roiSets.add(OcrPerformanceBenchmark.extractRois(it)) } } } roiSets diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashers.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashers.kt index c8756298..d9aee9cb 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashers.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashers.kt @@ -58,10 +58,13 @@ object ImageHashers { ): Mat { val imgSize = Size(hashSize, hashSize) val imgResized = resizeImage(imgGray, imgSize) - val hashMat = Mat() - Core.compare(imgResized, imgResized._mean(), hashMat, Core.CMP_GT) - return hashMat + try { + Core.compare(imgResized, imgResized._mean(), hashMat, Core.CMP_GT) + return hashMat + } finally { + imgResized.release() + } } /** @@ -83,13 +86,16 @@ object ImageHashers { ): Mat { val imgSize = Size(hashSize + 1.0, hashSize) val imgResized = resizeImage(imgGray, imgSize) - - val previous = imgResized.submat(0, imgResized.rows(), 0, imgResized.cols() - 1) - val current = imgResized.submat(0, imgResized.rows(), 1, imgResized.cols()) - - val hashMat = Mat() - Core.compare(previous, current, hashMat, Core.CMP_GT) - return hashMat + try { + val previous = imgResized.submat(0, imgResized.rows(), 0, imgResized.cols() - 1) + val current = imgResized.submat(0, imgResized.rows(), 1, imgResized.cols()) + + val hashMat = Mat() + Core.compare(previous, current, hashMat, Core.CMP_GT) + return hashMat + } finally { + imgResized.release() + } } /** @@ -113,12 +119,20 @@ object ImageHashers { val imgSize = Size(imgSizeBase, imgSizeBase) val imgResized = resizeImage(imgGray, imgSize) - imgResized.convertTo(imgResized, CvType.CV_32FC1) - val dctMat = Mat() - Core.dct(imgResized, dctMat) - val hashMat = dctMat.submat(0, hashSize.toInt(), 0, hashSize.toInt()).clone() - Core.compare(hashMat, hashMat._median(), hashMat, Core.CMP_GT) - return hashMat + try { + imgResized.convertTo(imgResized, CvType.CV_32FC1) + val dctMat = Mat() + try { + Core.dct(imgResized, dctMat) + val hashMat = dctMat.submat(0, hashSize.toInt(), 0, hashSize.toInt()).clone() + Core.compare(hashMat, hashMat._median(), hashMat, Core.CMP_GT) + return hashMat + } finally { + dctMat.release() + } + } finally { + imgResized.release() + } } /** @@ -138,6 +152,7 @@ object ImageHashers { /** * Return the hamming distance between [hash1] and [hash2]. */ + @Suppress("unused") fun compare( hash1: Mat, hash2: Mat, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt index 93dc8168..4790e6c7 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt @@ -17,6 +17,7 @@ import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.rois.extractor.DeviceRoisExtractor import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMasker import xyz.sevive.arcaeaoffline.core.ocr.getMostConfidentItem +import xyz.sevive.arcaeaoffline.core.ocr.use import kotlin.time.Instant import kotlin.uuid.Uuid @@ -110,42 +111,50 @@ class DeviceOcr( } } - fun ratingClass(): ArcaeaRatingClass { - val roi = extractor.ratingClass - val results = - listOf( - masker.ratingClassPst(roi), - masker.ratingClassPrs(roi), - masker.ratingClassFtr(roi), - masker.ratingClassByd(roi), - masker.ratingClassEtr(roi), - ) - return ArcaeaRatingClass.fromInt(results.indices.maxBy { Core.countNonZero(results[it]) }) - } + fun ratingClass(): ArcaeaRatingClass = + extractor.ratingClass.use { roi -> + val results = + listOf( + masker.ratingClassPst(roi), + masker.ratingClassPrs(roi), + masker.ratingClassFtr(roi), + masker.ratingClassByd(roi), + masker.ratingClassEtr(roi), + ) + try { + ArcaeaRatingClass.fromInt(results.indices.maxBy { Core.countNonZero(results[it]) }) + } finally { + results.forEach { it.release() } + } + } - private fun clearStatus(): Int { - val roi = extractor.clearStatus - val results = - listOf( - masker.clearStatusTrackLost(roi), - masker.clearStatusTrackComplete(roi), - masker.clearStatusFullRecall(roi), - masker.clearStatusPureMemory(roi), - ) - return results.indices.maxBy { Core.countNonZero(results[it]) } - } + private fun clearStatus(): Int = + extractor.clearStatus.use { roi -> + val results = + listOf( + masker.clearStatusTrackLost(roi), + masker.clearStatusTrackComplete(roi), + masker.clearStatusFullRecall(roi), + masker.clearStatusPureMemory(roi), + ) + try { + results.indices.maxBy { Core.countNonZero(results[it]) } + } finally { + results.forEach { it.release() } + } + } - private fun lookupSongId(): List { - val roiGray = Mat() - Imgproc.cvtColor(extractor.jacket, roiGray, Imgproc.COLOR_BGR2GRAY) - return hashesDb.lookupJacket(roiGray) - } + private fun lookupSongId(): List = + Mat().use { roiGray -> + Imgproc.cvtColor(extractor.jacket, roiGray, Imgproc.COLOR_BGR2GRAY) + hashesDb.lookupJacket(roiGray) + } - private fun lookupPartnerId(): List { - val roiGray = Mat() - Imgproc.cvtColor(extractor.partnerIcon, roiGray, Imgproc.COLOR_BGR2GRAY) - return hashesDb.lookupPartnerIcon(preprocessPartnerIcon(roiGray)) - } + private fun lookupPartnerId(): List = + Mat().use { roiGray -> + Imgproc.cvtColor(extractor.partnerIcon, roiGray, Imgproc.COLOR_BGR2GRAY) + preprocessPartnerIcon(roiGray).use { hashesDb.lookupPartnerIcon(it) } + } fun ocr(): DeviceOcrResult = DeviceOcrResult( diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ImageDecode.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ImageDecode.kt new file mode 100644 index 00000000..af9ba19c --- /dev/null +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ImageDecode.kt @@ -0,0 +1,38 @@ +package xyz.sevive.arcaeaoffline.core.ocr.device + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import org.opencv.android.Utils +import org.opencv.core.Mat +import org.opencv.imgproc.Imgproc +import java.io.InputStream + +object ImageDecode { + /** + * Decodes an image into a BGR Mat via the Android bitmap pipeline: pixel + * data stays in Skia's native heap and never passes through a Java + * byte[] (whole-file arrays in the Java heap may cause severe GC pressure). + * Returns null if the stream is not a decodable image. + */ + fun decode(inputStream: InputStream): Mat? { + val options = BitmapFactory.Options() + options.inPreferredConfig = Bitmap.Config.ARGB_8888 + val bitmap = BitmapFactory.decodeStream(inputStream, null, options) ?: return null + + val rgba = Mat() + try { + Utils.bitmapToMat(bitmap, rgba) + } finally { + bitmap.recycle() + } + + val bgr = Mat() + try { + // bitmapToMat yields RGBA; convert to match Imgcodecs.IMREAD_COLOR + Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) + return bgr + } finally { + rgba.release() + } + } +} diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/rois/masker/DeviceRoisMasker.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/rois/masker/DeviceRoisMasker.kt index 473e4f9e..1d09c728 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/rois/masker/DeviceRoisMasker.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/rois/masker/DeviceRoisMasker.kt @@ -2,6 +2,7 @@ package xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker import org.opencv.core.Mat +/** Every method returns a newly allocated Mat; ownership passes to the caller. */ interface DeviceRoisMasker { fun pure(roiBgr: Mat): Mat From 5d3594894dd92bc3d9b571819f0e2883250df3ec Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 19 Aug 2026 04:28:57 +0800 Subject: [PATCH 24/31] fix: drastically optimize OCR performance --- .../core/ocr/ImageHashesDatabase.kt | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabase.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabase.kt index a57a58bd..81f9292d 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabase.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabase.kt @@ -11,7 +11,13 @@ private fun hammingDistance( byteArray2: ByteArray, ): Int { assert(byteArray1.size == byteArray2.size) { "hash size does not match!" } - return byteArray1.zip(byteArray2).count { (b1, b2) -> b1 != b2 } + // Must remain allocation-free: this method is called for EVERY entry in allHashes on EVERY lookup. + // Creating objects here will cause severe GC pressure. + var distance = 0 + for (i in byteArray1.indices) { + if (byteArray1[i] != byteArray2[i]) distance++ + } + return distance } class ImageHashesDatabase( @@ -178,9 +184,15 @@ class ImageHashesDatabase( val dHash = ImageHashers.difference(image, this.hashSize) val pHash = ImageHashers.dct(image, this.hashSize, this.highFreqFactor) - items.addAll(lookupAHash(type, aHash.toHashByteArray())) - items.addAll(lookupDHash(type, dHash.toHashByteArray())) - items.addAll(lookupPHash(type, pHash.toHashByteArray())) + try { + items.addAll(lookupAHash(type, aHash.toHashByteArray())) + items.addAll(lookupDHash(type, dHash.toHashByteArray())) + items.addAll(lookupPHash(type, pHash.toHashByteArray())) + } finally { + aHash.release() + dHash.release() + pHash.release() + } return items } From 45247f9baf566f23d2c8d1b3ad73298c2d7d7b32 Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 19 Aug 2026 04:34:10 +0800 Subject: [PATCH 25/31] chore: suppress unused warning --- .../xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabase.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabase.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabase.kt index 81f9292d..710eee4b 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabase.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashesDatabase.kt @@ -37,6 +37,8 @@ class ImageHashesDatabase( private set var partnerIconHashesCount: Int by Delegates.notNull() private set + + @Suppress("unused") val hashesCount: Int get() = jacketHashesCount + partnerIconHashesCount private class HashEntry( From 84ce46958d122e6e5dbb74564e2ee0966a2121ce Mon Sep 17 00:00:00 2001 From: 283375 Date: Wed, 19 Aug 2026 04:51:29 +0800 Subject: [PATCH 26/31] fix: simplify Mat initialization - Previous commit was mislead, this commit actually just fixes it. --- .../arcaeaoffline/helpers/DeviceOcrHelper.kt | 18 ++++----- .../arcaeaoffline/helpers/OcrQueueHelper.kt | 12 ++++-- .../OcrPerformanceScreenViewModel.kt | 15 +++++--- .../core/ocr/device/ImageDecode.kt | 38 ------------------- 4 files changed, 26 insertions(+), 57 deletions(-) delete mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ImageDecode.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt index 045d6c40..81fd2d2a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt @@ -8,6 +8,7 @@ import de.stefan_oltmann.kim.format.tiff.constant.ExifTag import de.stefan_oltmann.kim.input.ByteReader import de.stefan_oltmann.kim.input.KotlinIoSourceByteReader import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.readBytes import io.github.vinceglb.filekit.size import io.github.vinceglb.filekit.source import kotlinx.datetime.LocalDateTime @@ -17,14 +18,14 @@ import kotlinx.datetime.asTimeZone import kotlinx.datetime.format.char import kotlinx.datetime.parseOrNull import kotlinx.datetime.toInstant -import kotlinx.io.asInputStream import kotlinx.io.buffered +import org.opencv.core.MatOfByte +import org.opencv.imgcodecs.Imgcodecs import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.CropBlackEdges import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcr import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrResult -import xyz.sevive.arcaeaoffline.core.ocr.device.ImageDecode import xyz.sevive.arcaeaoffline.core.ocr.device.rois.DeviceRoisAutoSelector import xyz.sevive.arcaeaoffline.core.ocr.device.rois.DeviceRoisAutoSelectorResult import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT1 @@ -35,7 +36,6 @@ import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMaskerAuto import xyz.sevive.arcaeaoffline.core.ocr.device.toPlayResult import xyz.sevive.arcaeaoffline.core.ocr.use import xyz.sevive.arcaeaoffline.helpers.context.getFilename -import java.io.IOException import kotlin.time.Instant object DeviceOcrHelper { @@ -54,16 +54,15 @@ object DeviceOcrHelper { second() } - fun ocrImage( + suspend fun ocrImage( imageUri: Uri, imageHashesDatabase: ImageHashesDatabase, ortSession: OrtSession, - ): DeviceOcrResult = - PlatformFile(imageUri).source().buffered().asInputStream().use { stream -> - val img = - ImageDecode.decode(stream) ?: throw IOException("Failed to decode image: $imageUri") + ): DeviceOcrResult { + val byteArray = PlatformFile(imageUri).readBytes() - img.use { img -> + return MatOfByte(*byteArray).use { matOfBytes -> + Imgcodecs.imdecode(matOfBytes, Imgcodecs.IMREAD_COLOR).use { img -> val roisAutoType = DeviceRoisAutoSelector.select(img) CropBlackEdges.crop(img).use { imgCropped -> @@ -99,6 +98,7 @@ object DeviceOcrHelper { } } } + } fun readImageDateFromExif( byteReader: ByteReader, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt index 98fe6f30..4836f9c0 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt @@ -3,6 +3,7 @@ package xyz.sevive.arcaeaoffline.helpers import android.graphics.BitmapFactory import android.net.Uri import io.github.vinceglb.filekit.PlatformFile +import io.github.vinceglb.filekit.readBytes import io.github.vinceglb.filekit.source import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async @@ -10,8 +11,9 @@ import kotlinx.coroutines.withContext import kotlinx.io.asInputStream import kotlinx.io.buffered import org.opencv.core.Mat +import org.opencv.core.MatOfByte +import org.opencv.imgcodecs.Imgcodecs import org.opencv.imgproc.Imgproc -import xyz.sevive.arcaeaoffline.core.ocr.device.ImageDecode import xyz.sevive.arcaeaoffline.core.ocr.device.ScreenshotDetect import xyz.sevive.arcaeaoffline.core.ocr.use @@ -35,13 +37,15 @@ object OcrQueueHelper { suspend fun isUriArcaeaImage(uri: Uri): Boolean = withContext(Dispatchers.IO) { async { - PlatformFile(uri).source().buffered().asInputStream().use { stream -> - ImageDecode.decode(stream)?.use { img -> + val byteArray = PlatformFile(uri).readBytes() + + MatOfByte(*byteArray).use { matOfBytes -> + Imgcodecs.imdecode(matOfBytes, Imgcodecs.IMREAD_COLOR).use { img -> Mat().use { imgHsv -> Imgproc.cvtColor(img, imgHsv, Imgproc.COLOR_BGR2HSV) ScreenshotDetect.isArcaeaScreenshot(imgHsv) } - } ?: false + } } }.await() } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt index 6d0b9583..77db34f0 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt @@ -15,7 +15,8 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.opencv.core.Mat -import xyz.sevive.arcaeaoffline.core.ocr.device.ImageDecode +import org.opencv.core.MatOfByte +import org.opencv.imgcodecs.Imgcodecs import xyz.sevive.arcaeaoffline.core.ocr.device.OcrPerformanceBenchmark import xyz.sevive.arcaeaoffline.core.ocr.use import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferences @@ -172,12 +173,14 @@ class OcrPerformanceScreenViewModel( val roiSets = mutableListOf>() try { uris.forEach { uri -> - val inputStream = - applicationContext.contentResolver.openInputStream(uri) + val bytes = + applicationContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: throw ImageLoadException() - inputStream.use { stream -> - val img = ImageDecode.decode(stream) ?: throw ImageLoadException() - img.use { roiSets.add(OcrPerformanceBenchmark.extractRois(it)) } + MatOfByte(*bytes).use { matOfBytes -> + Imgcodecs.imdecode(matOfBytes, Imgcodecs.IMREAD_COLOR).use { img -> + if (img.empty()) throw ImageLoadException() + roiSets.add(OcrPerformanceBenchmark.extractRois(img)) + } } } roiSets diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ImageDecode.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ImageDecode.kt deleted file mode 100644 index af9ba19c..00000000 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ImageDecode.kt +++ /dev/null @@ -1,38 +0,0 @@ -package xyz.sevive.arcaeaoffline.core.ocr.device - -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import org.opencv.android.Utils -import org.opencv.core.Mat -import org.opencv.imgproc.Imgproc -import java.io.InputStream - -object ImageDecode { - /** - * Decodes an image into a BGR Mat via the Android bitmap pipeline: pixel - * data stays in Skia's native heap and never passes through a Java - * byte[] (whole-file arrays in the Java heap may cause severe GC pressure). - * Returns null if the stream is not a decodable image. - */ - fun decode(inputStream: InputStream): Mat? { - val options = BitmapFactory.Options() - options.inPreferredConfig = Bitmap.Config.ARGB_8888 - val bitmap = BitmapFactory.decodeStream(inputStream, null, options) ?: return null - - val rgba = Mat() - try { - Utils.bitmapToMat(bitmap, rgba) - } finally { - bitmap.recycle() - } - - val bgr = Mat() - try { - // bitmapToMat yields RGBA; convert to match Imgcodecs.IMREAD_COLOR - Imgproc.cvtColor(rgba, bgr, Imgproc.COLOR_RGBA2BGR) - return bgr - } finally { - rgba.release() - } - } -} From 87d04e2212d440892826742c43f792d698e94743 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 28 Aug 2026 17:45:41 +0800 Subject: [PATCH 27/31] refactor: move OCR queue parallelism defaults out of OcrQueuePreferences Addresses PR #57 review: the device-derived parallelCount default and range now live in a dedicated OcrQueueParallelism object instead of the companion of the @Serializable preferences class, keeping runtime strategy separate from the preferences schema. No behavior change. --- .../arcaeaoffline/datastore/OcrQueueParallelism.kt | 12 ++++++++++++ .../arcaeaoffline/datastore/OcrQueuePreferences.kt | 10 ++-------- .../arcaeaoffline/jobs/OcrQueueProcessingJob.kt | 4 ++-- .../ocr/performance/OcrPerformanceScreenViewModel.kt | 6 +++--- .../preferences/OcrQueuePreferencesViewModel.kt | 4 ++-- 5 files changed, 21 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueueParallelism.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueueParallelism.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueueParallelism.kt new file mode 100644 index 00000000..5c175f30 --- /dev/null +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueueParallelism.kt @@ -0,0 +1,12 @@ +package xyz.sevive.arcaeaoffline.datastore + +/** + * Device-dependent parallelism strategy for the OCR queue. Kept separate from + * [OcrQueuePreferences] (a serializable data class) so the "single source" for + * these runtime-derived values is not tied to the preferences schema itself. + */ +object OcrQueueParallelism { + fun defaultCount(): Int = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + + fun countRange(): IntRange = 1..(Runtime.getRuntime().availableProcessors() * 2).coerceAtLeast(2) +} diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt index b76e1a33..a9532333 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/datastore/OcrQueuePreferences.kt @@ -21,14 +21,8 @@ data class OcrQueuePreferences( @SerialName("check_is_arcaea_image") val checkIsArcaeaImage: Boolean = OcrQueueStagingOptions.DEFAULTS.checkIsArcaeaImage, @SerialName("parallel_count") - val parallelCount: Int = defaultParallelCount(), -) { - companion object { - fun defaultParallelCount(): Int = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) - - fun parallelCountRange(): IntRange = 1..(Runtime.getRuntime().availableProcessors() * 2).coerceAtLeast(2) - } -} + val parallelCount: Int = OcrQueueParallelism.defaultCount(), +) object OcrQueuePreferencesSerializer : OkioSerializer { override val defaultValue: OcrQueuePreferences = OcrQueuePreferences() diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt index 7bd0e2a5..3bf7f28a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/jobs/OcrQueueProcessingJob.kt @@ -29,7 +29,7 @@ import xyz.sevive.arcaeaoffline.data.notification.Notifications import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTask import xyz.sevive.arcaeaoffline.database.entities.OcrQueueTaskStatus import xyz.sevive.arcaeaoffline.database.repositories.OcrQueueTaskRepository -import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferences +import xyz.sevive.arcaeaoffline.datastore.OcrQueueParallelism import xyz.sevive.arcaeaoffline.helpers.ArcaeaPlayResultValidator import xyz.sevive.arcaeaoffline.helpers.toWorkData import kotlin.time.Duration.Companion.milliseconds @@ -90,7 +90,7 @@ class OcrQueueProcessingJob( inputData .getInt( DATA_PARALLEL_COUNT, - OcrQueuePreferences.defaultParallelCount(), + OcrQueueParallelism.defaultCount(), ).coerceAtLeast(1), ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt index 77db34f0..2a78c476 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt @@ -19,7 +19,7 @@ import org.opencv.core.MatOfByte import org.opencv.imgcodecs.Imgcodecs import xyz.sevive.arcaeaoffline.core.ocr.device.OcrPerformanceBenchmark import xyz.sevive.arcaeaoffline.core.ocr.use -import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferences +import xyz.sevive.arcaeaoffline.datastore.OcrQueueParallelism import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesRepository import java.io.IOException import kotlin.math.round @@ -33,7 +33,7 @@ class OcrPerformanceScreenViewModel( companion object { private const val LOG_TAG = "OcrPerfScreenVM" - private val parallelCountIntRange = OcrQueuePreferences.parallelCountRange() + private val parallelCountIntRange = OcrQueueParallelism.countRange() val parallelCountSliderRange = parallelCountIntRange.first.toFloat()..parallelCountIntRange.last.toFloat() val parallelCountSliderSteps = parallelCountIntRange.count() - 1 } @@ -48,7 +48,7 @@ class OcrPerformanceScreenViewModel( data class UiState( val selectedImageUris: List = emptyList(), val imageLoadError: Boolean = false, - val parallelCount: Int = OcrQueuePreferences.defaultParallelCount(), + val parallelCount: Int = OcrQueueParallelism.defaultCount(), val parallelCountInitialized: Boolean = false, val running: Boolean = false, val runningParallel: Int? = null, diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/preferences/OcrQueuePreferencesViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/preferences/OcrQueuePreferencesViewModel.kt index d81cb441..927b5f0d 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/preferences/OcrQueuePreferencesViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/queue/preferences/OcrQueuePreferencesViewModel.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferences +import xyz.sevive.arcaeaoffline.datastore.OcrQueueParallelism import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesRepository import kotlin.time.Duration.Companion.seconds @@ -17,7 +17,7 @@ class OcrQueuePreferencesViewModel( val checkIsImage: Boolean = false, val checkIsArcaeaImage: Boolean = false, val parallelCount: Int = -1, - val parallelCountIntRange: IntRange = OcrQueuePreferences.parallelCountRange(), + val parallelCountIntRange: IntRange = OcrQueueParallelism.countRange(), ) { val parallelCountSliderRange = parallelCountIntRange.first.toFloat()..parallelCountIntRange.last.toFloat() val parallelCountSliderSteps = parallelCountIntRange.count() - 1 From 74925b100f67ffc5ad193d235d8e77f1238e46b5 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 28 Aug 2026 23:56:10 +0800 Subject: [PATCH 28/31] feat: verify OCR model asset metadata against model_info.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #57 review: replace the byte-level checkModelAsset with verifyModelAsset, which creates a throwaway ORT session (validating the file itself and the custom build's op coverage) and cross-checks its metadata against model_info.json — version (semver-decoded), producer, domain, graph name, input/output names, and the custom metadata entries written by the exporter. All mismatches are collected into a single IllegalStateException surfaced on the dependency status card. The comparison logic lives in a pure collectMetadataMismatches function covered by JVM unit tests (8 cases); the removed modelVersion dead code is revived as onnxModelVersion and put to use. --- .../helpers/OcrDependencyStatusBuilder.kt | 6 +- .../core/ocr/device/DeviceOcrOnnxHelper.kt | 147 +++++++++++++++--- .../device/ModelMetadataVerificationTest.kt | 122 +++++++++++++++ 3 files changed, 253 insertions(+), 22 deletions(-) create mode 100644 core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ModelMetadataVerificationTest.kt diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt index 9d97db54..bc8fc065 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrDependencyStatusBuilder.kt @@ -35,9 +35,9 @@ object OcrDependencyStatusBuilder { fun crnnModel(context: Context): CrnnModelStatusDetail = try { // model_info.json can parse fine while the model asset itself is - // missing or empty (e.g. incomplete local debug assets) - DeviceOcrOnnxHelper.checkModelAsset(context) - val info = DeviceOcrOnnxHelper.loadModelInfoFile(context) + // missing, empty or drifted from the json description (e.g. + // incomplete local debug assets, or a stale CI model cache) + val info = DeviceOcrOnnxHelper.verifyModelAsset(context) with(info) { CrnnModelStatusDetail( modelVersion = patch?.version?.takeIf { it.isNotEmpty() }, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt index d6b05e4b..4627b83c 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt @@ -14,7 +14,6 @@ import org.opencv.core.Mat import org.opencv.core.Size import org.opencv.imgproc.Imgproc import xyz.sevive.arcaeaoffline.core.ocr.use -import java.io.IOException import java.nio.ByteBuffer import kotlin.jvm.optionals.getOrElse import kotlin.properties.Delegates @@ -89,27 +88,37 @@ object DeviceOcrOnnxHelper { private fun readOnnxModelBytes(context: Context): ByteArray = context.assets.open(MODEL_ASSET_PATH).readBytes() /** - * Lightweight sanity check for the bundled model asset. Does not load or - * validate the model itself (a truncated file still passes); use - * [createOrtSession] for a full check. + * Verifies the bundled model asset against model_info.json. Creating the + * ORT session validates the file itself and the custom ORT build's op + * coverage; the session metadata is then cross-checked against the json + * description. Returns the parsed info file on success; throws + * [IllegalStateException] listing all mismatches otherwise. */ - fun checkModelAsset(context: Context) { - context.assets.open(MODEL_ASSET_PATH).use { stream -> - if (stream.read(ByteArray(1)) == -1) throw IOException("OCR model asset is empty: $MODEL_ASSET_PATH") + fun verifyModelAsset(context: Context): ModelInfoFile { + val infoFile = loadModelInfoFile(context) + + createOrtSession(context).use { session -> + val metadata = session.metadata + val mismatches = + collectMetadataMismatches( + modelVersion = metadata.version, + producerName = metadata.producerName, + domain = metadata.domain, + graphName = metadata.graphName, + customMetadata = metadata.customMetadata, + inputNames = session.inputNames, + outputNames = session.outputNames, + infoFile = infoFile, + ) + + if (mismatches.isNotEmpty()) { + throw IllegalStateException( + "OCR model asset does not match model_info.json:\n" + mismatches.joinToString("\n"), + ) + } } - } - /** - * @see ONNX documentation - * - * @return arrayOf(major, minor, patch) - */ - @Suppress("UNUSED") - private fun modelVersion(version: Long): List { - val major = ((version shr 48) and 0xFFFF).toInt() - val minor = ((version shr 32) and 0xFFFF).toInt() - val patch = (version and 0xFFFFFFFF).toInt() - return listOf(major, minor, patch) + return infoFile } fun createOrtSession(context: Context): OrtSession { @@ -203,3 +212,103 @@ object DeviceOcrOnnxHelper { } } } + +/** + * Cross-checks the parsed model_info.json against the model asset's + * metadata. Returns one message per mismatch; empty when they match. + * + * Pure function (no Android or ORT types in the signature) so mismatch + * scenarios can be unit-tested on the JVM. + */ +internal fun collectMetadataMismatches( + modelVersion: Long, + producerName: String?, + domain: String?, + graphName: String?, + customMetadata: Map, + inputNames: Set, + outputNames: Set, + infoFile: DeviceOcrOnnxHelper.ModelInfoFile, +): List { + val mismatches = mutableListOf() + + val patch = infoFile.patch + if (patch == null) { + mismatches += "model_info.json is missing the `patch` block" + return mismatches + } + + val modelVersionList = onnxModelVersion(modelVersion) + if (patch.version != modelVersionList) { + mismatches += "version: json ${patch.version}, model $modelVersionList" + } + + if (patch.producerName != producerName) { + mismatches += "producer_name: json ${patch.producerName}, model $producerName" + } + if (patch.domain != domain) { + mismatches += "domain: json ${patch.domain}, model $domain" + } + if (patch.graphName != graphName) { + mismatches += "graph_name: json ${patch.graphName}, model $graphName" + } + + if (patch.inputNames.toSet() != inputNames) { + mismatches += "input_names: json ${patch.inputNames}, model $inputNames" + } + if (patch.outputNames.toSet() != outputNames) { + mismatches += "output_names: json ${patch.outputNames}, model $outputNames" + } + + val training = infoFile.training + mismatches += + listOfNotNull( + customFieldMismatch(customMetadata, "image_width", training.imageWidth.toString()), + customFieldMismatch(customMetadata, "image_height", training.imageHeight.toString()), + customFieldMismatch(customMetadata, "blank_token", training.blankToken), + customFieldMismatch(customMetadata, "pad_token", training.padToken), + // 0 is the "not written" marker on the json side, matching an absent key + customTimestampMismatch(customMetadata, "built_timestamp", training.builtTimestamp), + customTimestampMismatch(customMetadata, "patched_timestamp", patch.patchedTimestamp), + ) + + return mismatches +} + +private fun customFieldMismatch( + customMetadata: Map, + key: String, + jsonValue: String, +): String? { + val modelValue = customMetadata[key] + return when { + modelValue == null -> "`$key`: json has \"$jsonValue\" but missing in model metadata" + modelValue != jsonValue -> "`$key`: json \"$jsonValue\", model \"$modelValue\"" + else -> null + } +} + +private fun customTimestampMismatch( + customMetadata: Map, + key: String, + jsonValue: Long, +): String? { + val modelValue = customMetadata[key] + return when { + jsonValue == 0L && modelValue == null -> null + jsonValue == 0L -> "`$key`: missing in json but model has \"$modelValue\"" + modelValue == null -> "`$key`: json has $jsonValue but missing in model metadata" + modelValue.toLongOrNull() != jsonValue -> "`$key`: json $jsonValue, model \"$modelValue\"" + else -> null + } +} + +/** + * @see ONNX documentation + */ +private fun onnxModelVersion(version: Long): List { + val major = ((version shr 48) and 0xFFFF).toInt() + val minor = ((version shr 32) and 0xFFFF).toInt() + val patch = (version and 0xFFFFFFFF).toInt() + return listOf(major, minor, patch) +} diff --git a/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ModelMetadataVerificationTest.kt b/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ModelMetadataVerificationTest.kt new file mode 100644 index 00000000..9a34ec90 --- /dev/null +++ b/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/ModelMetadataVerificationTest.kt @@ -0,0 +1,122 @@ +package xyz.sevive.arcaeaoffline.core.ocr.device + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * JVM unit tests for [collectMetadataMismatches], using the field values of + * the real bundled model assets as the matching baseline. + */ +class ModelMetadataVerificationTest { + private val training = + DeviceOcrOnnxHelper.ModelInfo( + imageHeight = 50, + imageWidth = 220, + labels = listOf("∅", "-", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9"), + blankToken = "∅", + padToken = "-", + builtTimestamp = 1728488367L, + ) + + private val patch = + DeviceOcrOnnxHelper.ModelPatchInfo( + version = listOf(1, 0, 3), + producerName = "pytorch", + producerVersion = "2.4.1", + domain = "", + graphName = "crnn_patched", + inputNames = listOf("raw_image"), + outputNames = listOf("model_output", "decoded_output"), + patchedTimestamp = 1785689264L, + ) + + // encode_semver(1, 0, 3) + private val encodedVersion = (1L shl 48) or (0L shl 32) or 3L + + private fun customMetadata( + builtTimestamp: String? = "1728488367", + patchedTimestamp: String? = "1785689264", + ): Map = + buildMap { + put("image_width", "220") + put("image_height", "50") + put("blank_token", "∅") + put("pad_token", "-") + builtTimestamp?.let { put("built_timestamp", it) } + patchedTimestamp?.let { put("patched_timestamp", it) } + } + + private fun mismatches( + patchBlock: DeviceOcrOnnxHelper.ModelPatchInfo? = patch, + trainingBlock: DeviceOcrOnnxHelper.ModelInfo = training, + metadata: Map = customMetadata(), + version: Long = encodedVersion, + producerName: String? = "pytorch", + domain: String? = "", + graphName: String? = "crnn_patched", + inputNames: Set = setOf("raw_image"), + outputNames: Set = setOf("model_output", "decoded_output"), + ): List = + collectMetadataMismatches( + modelVersion = version, + producerName = producerName, + domain = domain, + graphName = graphName, + customMetadata = metadata, + inputNames = inputNames, + outputNames = outputNames, + infoFile = DeviceOcrOnnxHelper.ModelInfoFile(training = trainingBlock, patch = patchBlock), + ) + + @Test + fun matchingAssetProducesNoMismatches() { + assertEquals(emptyList(), mismatches()) + } + + @Test + fun missingPatchBlockIsReported() { + assertEquals( + listOf("model_info.json is missing the `patch` block"), + mismatches(patchBlock = null), + ) + } + + @Test + fun versionMismatchIsReported() { + val result = mismatches(version = (1L shl 48) or 4L) + assertTrue(result.any { it.startsWith("version:") }) + } + + @Test + fun producerNameMismatchIsReported() { + val result = mismatches(producerName = "other-producer") + assertTrue(result.any { it.startsWith("producer_name:") }) + } + + @Test + fun inputNamesMismatchIsReported() { + val result = mismatches(inputNames = setOf("image")) + assertTrue(result.any { it.startsWith("input_names:") }) + } + + @Test + fun missingCustomMetadataKeyIsReported() { + val result = mismatches(metadata = customMetadata(builtTimestamp = null)) + assertTrue(result.any { it.startsWith("`built_timestamp`:") }) + } + + @Test + fun nonNumericTimestampIsReported() { + val result = mismatches(metadata = customMetadata(patchedTimestamp = "not-a-number")) + assertTrue(result.any { it.startsWith("`patched_timestamp`:") }) + } + + @Test + fun bothSidesMissingTimestampIsNotReported() { + // json built_timestamp defaults to 0 (not written), matching an + // absent model metadata key + val result = mismatches(trainingBlock = training.copy(builtTimestamp = 0L), metadata = customMetadata(builtTimestamp = null)) + assertTrue(result.isEmpty()) + } +} From 51bc36b56e2d07116f1456b4ce7132ff23a957ad Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 29 Aug 2026 00:46:57 +0800 Subject: [PATCH 29/31] chore: move OpenCV Mat `use` util package for readability --- .../java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt | 2 +- .../java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt | 2 +- .../ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt | 2 +- .../xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt | 2 +- .../xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt | 2 +- .../sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt | 2 +- .../arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt | 2 +- .../xyz/sevive/arcaeaoffline/core/ocr/{ => opencv}/Utils.kt | 2 +- 8 files changed, 8 insertions(+), 8 deletions(-) rename core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/{ => opencv}/Utils.kt (91%) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt index 81fd2d2a..279c90ba 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/DeviceOcrHelper.kt @@ -34,7 +34,7 @@ import xyz.sevive.arcaeaoffline.core.ocr.device.rois.extractor.DeviceRoisExtract import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMaskerAutoT1 import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMaskerAutoT2 import xyz.sevive.arcaeaoffline.core.ocr.device.toPlayResult -import xyz.sevive.arcaeaoffline.core.ocr.use +import xyz.sevive.arcaeaoffline.core.ocr.opencv.use import xyz.sevive.arcaeaoffline.helpers.context.getFilename import kotlin.time.Instant diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt index 4836f9c0..aaf34dc6 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/helpers/OcrQueueHelper.kt @@ -15,7 +15,7 @@ import org.opencv.core.MatOfByte import org.opencv.imgcodecs.Imgcodecs import org.opencv.imgproc.Imgproc import xyz.sevive.arcaeaoffline.core.ocr.device.ScreenshotDetect -import xyz.sevive.arcaeaoffline.core.ocr.use +import xyz.sevive.arcaeaoffline.core.ocr.opencv.use object OcrQueueHelper { suspend fun isUriImage(uri: Uri): Boolean = diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt index 2a78c476..05561ffe 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt @@ -18,7 +18,7 @@ import org.opencv.core.Mat import org.opencv.core.MatOfByte import org.opencv.imgcodecs.Imgcodecs import xyz.sevive.arcaeaoffline.core.ocr.device.OcrPerformanceBenchmark -import xyz.sevive.arcaeaoffline.core.ocr.use +import xyz.sevive.arcaeaoffline.core.ocr.opencv.use import xyz.sevive.arcaeaoffline.datastore.OcrQueueParallelism import xyz.sevive.arcaeaoffline.datastore.OcrQueuePreferencesRepository import java.io.IOException diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt index 6fe8c687..da549a28 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt @@ -7,7 +7,7 @@ import org.opencv.core.Rect import org.opencv.core.Scalar import org.opencv.imgproc.Imgproc import xyz.sevive.arcaeaoffline.core.ocr.device.CropBlackEdges.Companion.cropOrOriginal -import xyz.sevive.arcaeaoffline.core.ocr.use +import xyz.sevive.arcaeaoffline.core.ocr.opencv.use class CropBlackEdges { companion object { diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt index 4790e6c7..7d6d6741 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt @@ -17,7 +17,7 @@ import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase import xyz.sevive.arcaeaoffline.core.ocr.device.rois.extractor.DeviceRoisExtractor import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMasker import xyz.sevive.arcaeaoffline.core.ocr.getMostConfidentItem -import xyz.sevive.arcaeaoffline.core.ocr.use +import xyz.sevive.arcaeaoffline.core.ocr.opencv.use import kotlin.time.Instant import kotlin.uuid.Uuid diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt index 4627b83c..ab6bc138 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt @@ -13,7 +13,7 @@ import kotlinx.serialization.json.Json import org.opencv.core.Mat import org.opencv.core.Size import org.opencv.imgproc.Imgproc -import xyz.sevive.arcaeaoffline.core.ocr.use +import xyz.sevive.arcaeaoffline.core.ocr.opencv.use import java.nio.ByteBuffer import kotlin.jvm.optionals.getOrElse import kotlin.properties.Delegates diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt index f48fe262..79b4e4a9 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt @@ -13,7 +13,7 @@ import xyz.sevive.arcaeaoffline.core.ocr.device.rois.DeviceRoisAutoSelectorResul import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT1 import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT2 import xyz.sevive.arcaeaoffline.core.ocr.device.rois.extractor.DeviceRoisExtractor -import xyz.sevive.arcaeaoffline.core.ocr.use +import xyz.sevive.arcaeaoffline.core.ocr.opencv.use import kotlin.system.measureTimeMillis /** diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/opencv/Utils.kt similarity index 91% rename from core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt rename to core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/opencv/Utils.kt index 4dd509c8..cb7ea058 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/Utils.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/opencv/Utils.kt @@ -1,4 +1,4 @@ -package xyz.sevive.arcaeaoffline.core.ocr +package xyz.sevive.arcaeaoffline.core.ocr.opencv import org.opencv.core.Mat import kotlin.contracts.ExperimentalContracts From 44801ee53b5c357719bf37bcff6507803e45d1a3 Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 29 Aug 2026 01:24:34 +0800 Subject: [PATCH 30/31] fix: Mat and stream releasing --- .../arcaeaoffline/core/ocr/ImageHashers.kt | 52 ++++++++----------- .../core/ocr/device/CropBlackEdges.kt | 10 ++-- .../core/ocr/device/DeviceOcr.kt | 22 ++++---- .../core/ocr/device/DeviceOcrOnnxHelper.kt | 2 +- 4 files changed, 37 insertions(+), 49 deletions(-) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashers.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashers.kt index d9aee9cb..01742f50 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashers.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/ImageHashers.kt @@ -6,6 +6,7 @@ import org.opencv.core.Mat import org.opencv.core.Scalar import org.opencv.core.Size import org.opencv.imgproc.Imgproc +import xyz.sevive.arcaeaoffline.core.ocr.opencv.use @Suppress("FunctionName") private fun Mat._mean(): Scalar = Core.mean(this) @@ -55,17 +56,12 @@ object ImageHashers { private fun average( imgGray: Mat, hashSize: Double, - ): Mat { - val imgSize = Size(hashSize, hashSize) - val imgResized = resizeImage(imgGray, imgSize) - val hashMat = Mat() - try { + ): Mat = + resizeImage(imgGray, Size(hashSize, hashSize)).use { imgResized -> + val hashMat = Mat() Core.compare(imgResized, imgResized._mean(), hashMat, Core.CMP_GT) - return hashMat - } finally { - imgResized.release() + hashMat } - } /** * Computes a simple hash comparing the intensity of each pixel in @@ -83,20 +79,20 @@ object ImageHashers { private fun difference( imgGray: Mat, hashSize: Double, - ): Mat { - val imgSize = Size(hashSize + 1.0, hashSize) - val imgResized = resizeImage(imgGray, imgSize) - try { - val previous = imgResized.submat(0, imgResized.rows(), 0, imgResized.cols() - 1) - val current = imgResized.submat(0, imgResized.rows(), 1, imgResized.cols()) - + ): Mat = + resizeImage(imgGray, Size(hashSize + 1.0, hashSize)).use { imgResized -> val hashMat = Mat() - Core.compare(previous, current, hashMat, Core.CMP_GT) - return hashMat - } finally { - imgResized.release() + imgResized + .submat(0, imgResized.rows(), 0, imgResized.cols() - 1) + .use { previous -> + imgResized + .submat(0, imgResized.rows(), 1, imgResized.cols()) + .use { current -> + Core.compare(previous, current, hashMat, Core.CMP_GT) + } + } + hashMat } - } /** * A hash based on the differences between adjacent pixels. @@ -118,20 +114,14 @@ object ImageHashers { val imgSizeBase = hashSize * highFreqFactor val imgSize = Size(imgSizeBase, imgSizeBase) - val imgResized = resizeImage(imgGray, imgSize) - try { + return resizeImage(imgGray, imgSize).use { imgResized -> imgResized.convertTo(imgResized, CvType.CV_32FC1) - val dctMat = Mat() - try { + Mat().use { dctMat -> Core.dct(imgResized, dctMat) - val hashMat = dctMat.submat(0, hashSize.toInt(), 0, hashSize.toInt()).clone() + val hashMat = dctMat.submat(0, hashSize.toInt(), 0, hashSize.toInt()).use { it.clone() } Core.compare(hashMat, hashMat._median(), hashMat, Core.CMP_GT) - return hashMat - } finally { - dctMat.release() + hashMat } - } finally { - imgResized.release() } } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt index da549a28..cf04df84 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/CropBlackEdges.kt @@ -83,16 +83,12 @@ class CropBlackEdges { img: Mat, convertFlag: Int = Imgproc.COLOR_BGR2GRAY, blackPixelThreshold: Int = 25, - ): Mat { - val imgGray = Mat() - try { + ): Mat = + Mat().use { imgGray -> Imgproc.cvtColor(img, imgGray, convertFlag) val rect = getCropRect(imgGray, blackPixelThreshold) - return img.submat(rect).clone() - } finally { - imgGray.release() + img.submat(rect).clone() } - } /** * This function would try returning the cropped image. diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt index 7d6d6741..caf2cd38 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcr.kt @@ -97,16 +97,18 @@ class DeviceOcr( val w = iconSquared.width().toDouble() val h = iconSquared.height().toDouble() - Imgproc.fillPoly( - iconSquared, + val contours = listOf( MatOfPoint(Point(0.0, 0.0), Point(w / 2, 0.0), Point(0.0, h / 2)), MatOfPoint(Point(w, 0.0), Point(w / 2, 0.0), Point(w, h / 2)), MatOfPoint(Point(0.0, h), Point(w / 2, h), Point(0.0, h / 2)), MatOfPoint(Point(w, h), Point(w / 2, h), Point(w, h / 2)), - ), - Scalar(128.0), - ) + ) + try { + Imgproc.fillPoly(iconSquared, contours, Scalar(128.0)) + } finally { + contours.forEach { it.release() } + } return iconSquared } } @@ -159,11 +161,11 @@ class DeviceOcr( fun ocr(): DeviceOcrResult = DeviceOcrResult( ratingClass = ratingClass(), - pure = DeviceOcrOnnxHelper.ocrBgrMat(extractor.pure, ortSession).toInt(), - far = DeviceOcrOnnxHelper.ocrBgrMat(extractor.far, ortSession).toInt(), - lost = DeviceOcrOnnxHelper.ocrBgrMat(extractor.lost, ortSession).toInt(), - score = DeviceOcrOnnxHelper.ocrBgrMat(extractor.score, ortSession).toInt(), - maxRecall = DeviceOcrOnnxHelper.ocrBgrMat(extractor.maxRecall, ortSession).toInt(), + pure = extractor.pure.use { DeviceOcrOnnxHelper.ocrBgrMat(it, ortSession).toInt() }, + far = extractor.far.use { DeviceOcrOnnxHelper.ocrBgrMat(it, ortSession).toInt() }, + lost = extractor.lost.use { DeviceOcrOnnxHelper.ocrBgrMat(it, ortSession).toInt() }, + score = extractor.score.use { DeviceOcrOnnxHelper.ocrBgrMat(it, ortSession).toInt() }, + maxRecall = extractor.maxRecall.use { DeviceOcrOnnxHelper.ocrBgrMat(it, ortSession).toInt() }, songIdResults = lookupSongId(), clearStatus = clearStatus(), partnerIdResults = lookupPartnerId(), diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt index ab6bc138..838778df 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/DeviceOcrOnnxHelper.kt @@ -85,7 +85,7 @@ object DeviceOcrOnnxHelper { padToken = modelInfo.padToken } - private fun readOnnxModelBytes(context: Context): ByteArray = context.assets.open(MODEL_ASSET_PATH).readBytes() + private fun readOnnxModelBytes(context: Context): ByteArray = context.assets.open(MODEL_ASSET_PATH).use { it.readBytes() } /** * Verifies the bundled model asset against model_info.json. Creating the From c4eac3c0d4f02fb778c6db69959852a0b831867b Mon Sep 17 00:00:00 2001 From: 283375 Date: Sat, 29 Aug 2026 15:15:42 +0800 Subject: [PATCH 31/31] fix: address review findings in OCR performance benchmark - format report/history/log numbers with Locale.ROOT so decimal separators stay consistent across device locales - use a strict median over timed batches instead of the upper-middle element for even batch counts - read the parallel count once and reuse it for runningParallel, runBenchmark and history, instead of two independent reads - collapse preferences init into a single atomic state update - unify the running-state reset in a finally block across exit paths - poll cancellation between image decodes, which contain no suspension points - remove What-style comments; document why the report is not localized --- .../ocr/performance/OcrPerformanceScreen.kt | 22 ++++++++++------- .../OcrPerformanceScreenViewModel.kt | 24 +++++++++++-------- .../ocr/device/OcrPerformanceBenchmark.kt | 9 ++++++- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt index 21b0dbea..39e8d2ab 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreen.kt @@ -64,6 +64,7 @@ import xyz.sevive.arcaeaoffline.ui.components.preferences.BasePreferencesWidget import xyz.sevive.arcaeaoffline.ui.components.preferences.SliderPreferencesWidget import xyz.sevive.arcaeaoffline.ui.components.preferences.TextPreferencesWidget import xyz.sevive.arcaeaoffline.ui.navigation.OcrSubScreen +import java.util.Locale @Composable fun OcrPerformanceScreen( @@ -92,10 +93,12 @@ fun OcrPerformanceScreen( } item { + val selectedUris = uiState.selectedImageUris + val hasSelection = selectedUris.isNotEmpty() TextPreferencesWidget( title = stringResource(R.string.ocr_performance_pick_images_button), content = - uiState.selectedImageUris.takeIf { it.isNotEmpty() }?.let { + selectedUris.takeIf { hasSelection }?.let { pluralStringResource( R.plurals.ocr_performance_picked_images, it.size, @@ -116,7 +119,7 @@ fun OcrPerformanceScreen( ) }, trailingSlot = - uiState.selectedImageUris.takeIf { it.isNotEmpty() }?.let { + if (hasSelection) { { IconButton( onClick = viewModel::clearImages, @@ -132,6 +135,8 @@ fun OcrPerformanceScreen( ) } } + } else { + null }, ) } @@ -262,7 +267,7 @@ private fun ResultCard( val coroutineScope = rememberCoroutineScope() val resources = LocalResources.current - Card(modifier = modifier.then(Modifier.padding(horizontal = 16.dp))) { + Card(modifier = modifier.padding(horizontal = 16.dp)) { Column( verticalArrangement = Arrangement.spacedBy(dimensionResource(R.dimen.list_padding)), modifier = Modifier.padding(dimensionResource(R.dimen.card_padding)), @@ -327,9 +332,6 @@ private fun ResultCard( } } -/** - * Key-value row: label left-aligned (muted), value right-aligned - */ @Composable private fun KeyValueRow( label: String, @@ -352,7 +354,8 @@ private fun KeyValueRow( } /** - * Generate pure text report for copying + * Plain-text report for pasting into issue reports; deliberately not localized, + * and formatted with Locale.ROOT so numbers stay consistent across devices. */ private fun buildReportText( parallel: Int, @@ -360,8 +363,8 @@ private fun buildReportText( ): String = buildString { appendLine("OCR Performance (p$parallel)") - appendLine("median: %.0f ms/image".format(result.medianPerImageMs)) - appendLine("throughput: %.1f it/s".format(result.throughputPerSecond)) + appendLine("median: %.0f ms/image".format(Locale.ROOT, result.medianPerImageMs)) + appendLine("throughput: %.1f it/s".format(Locale.ROOT, result.throughputPerSecond)) appendLine("batches: ${result.batchTimesMs.joinToString("/")} ms") append("consistent: ${result.resultsConsistent}") } @@ -396,6 +399,7 @@ private fun HistoryRow( ) { Text( "p%d, %.1f it/s".format( + Locale.ROOT, entry.parallel, entry.result.throughputPerSecond, ), diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt index 05561ffe..09f66be0 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/ocr/performance/OcrPerformanceScreenViewModel.kt @@ -8,6 +8,7 @@ import co.touchlab.kermit.Logger import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest @@ -72,9 +73,12 @@ class OcrPerformanceScreenViewModel( preferencesRepository.preferencesFlow.collectLatest { preferences -> _uiState.update { // Initial slider value follows the production config - it.copy(parallelCount = if (it.parallelCountInitialized) it.parallelCount else preferences.parallelCount) + if (it.parallelCountInitialized) { + it + } else { + it.copy(parallelCount = preferences.parallelCount, parallelCountInitialized = true) + } } - _uiState.update { it.copy(parallelCountInitialized = true) } } } } @@ -97,10 +101,11 @@ class OcrPerformanceScreenViewModel( benchmarkJob = viewModelScope.launch { + val parallel = state.parallelCount _uiState.update { it.copy( running = true, - runningParallel = it.parallelCount, + runningParallel = parallel, progress = 0, progressTotal = 0, result = null, @@ -112,7 +117,6 @@ class OcrPerformanceScreenViewModel( // no suspension point between decode and run, so ownership // of the ROI Mats transfers to runBenchmark without a // cancellation window - val parallel = _uiState.value.parallelCount val result = withContext(Dispatchers.Default) { OcrPerformanceBenchmark.runBenchmark( @@ -125,8 +129,6 @@ class OcrPerformanceScreenViewModel( } _uiState.update { it.copy( - running = false, - runningParallel = null, result = result, resultParallel = parallel, history = @@ -139,21 +141,20 @@ class OcrPerformanceScreenViewModel( ) } } catch (e: CancellationException) { - _uiState.update { it.copy(running = false, runningParallel = null) } throw e } catch (e: ImageLoadException) { logger.e(e) { "Failed to decode benchmark images" } _uiState.update { it.copy( - running = false, - runningParallel = null, selectedImageUris = emptyList(), imageLoadError = true, ) } } catch (e: Exception) { logger.e(e) { "Benchmark failed" } - _uiState.update { it.copy(running = false, runningParallel = null, errorMessage = e.message) } + _uiState.update { it.copy(errorMessage = e.message) } + } finally { + _uiState.update { it.copy(running = false, runningParallel = null) } } } } @@ -173,6 +174,9 @@ class OcrPerformanceScreenViewModel( val roiSets = mutableListOf>() try { uris.forEach { uri -> + // decoding below is all blocking calls, so cancellation + // must be polled manually between images + coroutineContext.ensureActive() val bytes = applicationContext.contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: throw ImageLoadException() diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt index 79b4e4a9..075bbf41 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/ocr/device/OcrPerformanceBenchmark.kt @@ -14,6 +14,7 @@ import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT1 import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT2 import xyz.sevive.arcaeaoffline.core.ocr.device.rois.extractor.DeviceRoisExtractor import xyz.sevive.arcaeaoffline.core.ocr.opencv.use +import java.util.Locale import kotlin.system.measureTimeMillis /** @@ -99,9 +100,15 @@ object OcrPerformanceBenchmark { // Median resists single-run spikes (DVFS/scheduling noise), used instead of the mean val sortedBatchTimes = batchTimes.sorted() - val medianBatchMs = sortedBatchTimes[sortedBatchTimes.size / 2].toDouble() + val medianBatchMs = + if (sortedBatchTimes.size % 2 == 1) { + sortedBatchTimes[sortedBatchTimes.size / 2].toDouble() + } else { + (sortedBatchTimes[sortedBatchTimes.size / 2 - 1] + sortedBatchTimes[sortedBatchTimes.size / 2]) / 2.0 + } logger.i { "Benchmark done: parallel %d, taskPerBatch %d, batchTimes(ms) %s, throughput %.1f it/s, consistent %s".format( + Locale.ROOT, parallel, tasksPerBatch, batchTimes.joinToString("/"),