V1.0.46 oss (#253)

* add a UI trigger for demo annotations

* Implement internal link navigation

* Simplify bottom padding logic in `EpubReaderScreen` for vertical scroll mode

* Add support for Calibre metadata

* Implement advanced library management with Room-backed collections, tags, and smart rules.

* Display book series information in book details and remove tags from home screen

* Implement zoom reset functionality in PDF viewer

* Add a "Pages" thumbnail view to the PDF navigation drawer

* Implement jump-back navigation in PDF viewer

* MainViewModel: disable FolderSyncWorker, add log export, and enhance PDF position logging

* Replace Gson with Kotlin Serialization in SmartCollectionEngine

* Implement ONNX-based speech bubble detection

* Implement speech bubble detection in PDF viewer

* Implement "Smart Comic Zoom" feature for PDF manga/comic reading

* Implement reader session persistence and restoration in `MainViewModel`

* Add tap-to-turn page feature to PDF viewer

* Improve book cache management and recovery

* Refactor top overlay padding logic in `PdfViewerScreen`

* Implement stylus eraser support in PDF reader

* Refactor ReaderTextFormatPanel to use ModalBottomSheet and add new formatting controls

* Introduce a customizable horizontal margin setting for the EPUB reader

* Refine folder sync and metadata handling for better conflict resolution and stability.

* Introduce user-adjustable image scaling for the EPUB reader

* Use maxWidthPx as default width fallback for blocks

* Improve cross-page text selection and header styling in the paginated reader

* Refactor speech bubble detection and UI to support segmentation masks and interactive scaling

* Improve speech bubble detection masking and rendering quality

* Update ONNX speech bubble detector to use `.ort` model and optimize inference

* Improve pagination accuracy

* Implement hierarchical folder navigation for the library.

* Refactor library item layout for improved space efficiency

* Add support for browsing and downloading Google Fonts

* Persist library landing state and add shelf search functionality

* Fix base tts sample.

* Move SpeechBubbleDetector to main source set and update ONNX dependency

* Implement a "locate" feature and improve synchronization for TTS (Text-to-Speech) playback across EPUB and PDF readers.

* Refine TTS synchronization and voice management in the EPUB reader

* Adjust OCR checks for OSS flavor and optimize external dictionary intent flags

* Add "Locate" button to PDF drawer's pages tab and move the page number to bottom right of thumbnail

* fix various crashes

* Refactor SpeechBubbleDetector into product flavors

* Implement speech bubble detection caching and background prefetching

* Implement on-demand download for Bubble Zoom ML model

* Add smooth animation for PDF speech bubble expansion

* fixes #252

* hide Google Fonts option, in FontsScreen, for offline variant

* Bump version to 1.0.46(46)
This commit is contained in:
Aryan 2026-04-29 16:37:53 +05:30 committed by GitHub
parent 579b6f25bf
commit d16bdd70d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 9724 additions and 1890 deletions

View file

@ -0,0 +1,259 @@
package com.aryan.reader.ml
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.RectF
import androidx.core.graphics.scale
import timber.log.Timber
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.util.Collections
import kotlin.math.min
class SpeechBubbleDetector(modelFile: File) : ISpeechBubbleDetector {
private var env: OrtEnvironment? = null
private var session: OrtSession? = null
private val inputSize = 504
private val byteBuffer = ByteBuffer.allocateDirect(3 * inputSize * inputSize * 4).order(
ByteOrder.nativeOrder())
private val floatBuffer = byteBuffer.asFloatBuffer()
private val pixels = IntArray(inputSize * inputSize)
init {
try {
env = OrtEnvironment.getEnvironment()
val options = OrtSession.SessionOptions().apply {
// Dynamically use available cores (cap at 4 to prevent thermal throttling)
val threadCount = Runtime.getRuntime().availableProcessors().coerceAtMost(4)
setIntraOpNumThreads(threadCount)
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT)
// 1. Thread spinning keeps CPU threads active between operations (reduces latency)
try {
addConfigEntry("session.intra_op.allow_spinning", "1")
} catch (_: Throwable) {
Timber.w("Could not set intra_op.allow_spinning config")
}
// 2. Enable XNNPACK (Highly optimized ARM CPU execution provider)
try {
// Safest cross-version way to request XNNPACK in Android ORT
addConfigEntry("session.disable_cpu_ep_fallback", "0")
addConfigEntry("optimization.enable_xnnpack", "1")
Timber.i("ONNX XNNPACK requested via config entry for optimized CPU inference.")
} catch (t: Throwable) {
Timber.w(t, "Could not set XNNPACK config entries")
}
}
session = env?.createSession(modelFile.absolutePath, options)
} catch (t: Throwable) {
Timber.e(t, "Fatal error initializing ONNX model")
}
}
// 3. Synchronized to safely share pre-allocated buffers across calls
@Synchronized
override fun detectBubbles(bitmap: Bitmap, confidenceThreshold: Float): List<SpeechBubble> {
Timber.tag("BubbleZoom").d("Detector: detectBubbles started. Bitmap: ${bitmap.width}x${bitmap.height}, threshold: $confidenceThreshold")
val currentEnv = env ?: run {
Timber.tag("BubbleZoom").w("Detector: OrtEnvironment is null")
return emptyList()
}
val currentSession = session ?: run {
Timber.tag("BubbleZoom").w("Detector: OrtSession is null")
return emptyList()
}
try {
// 4. Skip unnecessary scaling if already 504x504
val resized = if (bitmap.width == inputSize && bitmap.height == inputSize) {
bitmap
} else {
bitmap.scale(inputSize, inputSize, false)
}
floatBuffer.clear() // Reset buffer positions for reuse
resized.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
val imageArea = inputSize * inputSize
for (i in 0 until imageArea) {
val pixel = pixels[i]
val r = ((pixel shr 16) and 0xFF) / 255.0f
val g = ((pixel shr 8) and 0xFF) / 255.0f
val b = (pixel and 0xFF) / 255.0f
floatBuffer.put(i, r)
floatBuffer.put(i + imageArea, g)
floatBuffer.put(i + 2 * imageArea, b)
}
floatBuffer.rewind() // Ready for tensor creation
val inputTensor = OnnxTensor.createTensor(currentEnv, floatBuffer, longArrayOf(1, 3, inputSize.toLong(), inputSize.toLong()))
val inputName = currentSession.inputNames.iterator().next()
Timber.tag("BubbleZoom").d("Detector: Running ONNX inference...")
val results = currentSession.run(Collections.singletonMap(inputName, inputTensor))
Timber.tag("BubbleZoom").d("Detector: ONNX inference finished.")
val parsedResults = mutableListOf<SpeechBubble>()
var detsOutput: FloatArray? = null
var labelsOutput: FloatArray? = null
var masksOutput: FloatArray? = null
var numBoxes = 0
var numClasses = 0
var maskH = 0
var maskW = 0
Timber.tag("ONNX_SEG").d("--- ONNX OUTPUT TENSORS ---")
results.forEach { entry ->
val value = entry.value as OnnxTensor
val shape = value.info.shape
Timber.tag("ONNX_SEG").d("Name: ${entry.key}, Shape: ${shape.contentToString()}")
if (entry.key.contains("dets") || entry.key.contains("boxes") || (shape.size == 3 && shape[2] == 4L)) {
numBoxes = shape[1].toInt()
val flatOutput = FloatArray(shape.reduce { acc, l -> acc * l }.toInt())
value.floatBuffer.get(flatOutput)
detsOutput = flatOutput
} else if (entry.key.contains("labels") || entry.key.contains("scores") || (shape.size == 3 && shape[2] != 4L && shape[1] == numBoxes.toLong())) {
numClasses = shape[2].toInt()
val flatOutput = FloatArray(shape.reduce { acc, l -> acc * l }.toInt())
value.floatBuffer.get(flatOutput)
labelsOutput = flatOutput
} else if (entry.key.contains("masks") || shape.size == 4) {
maskH = shape[2].toInt()
maskW = shape[3].toInt()
val flatOutput = FloatArray(shape.reduce { acc, l -> acc * l }.toInt())
value.floatBuffer.get(flatOutput)
masksOutput = flatOutput
}
}
Timber.tag("BubbleZoom").d("Detector: Outputs mapped. Boxes: $numBoxes, Classes: $numClasses, Masks: ${maskW}x${maskH}")
if (detsOutput != null && labelsOutput != null) {
var maxCoord = 0f
for (i in 0 until min(100, detsOutput.size)) {
if (detsOutput[i] > maxCoord) maxCoord = detsOutput[i]
}
val isNormalized = maxCoord <= 1.5f
val scaleX = if (isNormalized) bitmap.width.toFloat() else bitmap.width.toFloat() / inputSize
val scaleY = if (isNormalized) bitmap.height.toFloat() else bitmap.height.toFloat() / inputSize
for (i in 0 until numBoxes) {
var maxConf = 0f
for (c in 0 until numClasses) {
val conf = labelsOutput[i * numClasses + c]
if (conf > maxConf) maxConf = conf
}
if (maxConf > confidenceThreshold) {
val val0 = detsOutput[i * 4 + 0]
val val1 = detsOutput[i * 4 + 1]
val val2 = detsOutput[i * 4 + 2]
val val3 = detsOutput[i * 4 + 3]
val w = val2 * 1.08f
val h = val3 * 1.08f
val rawLeft = val0 - w / 2
val rawTop = val1 - h / 2
val rawRight = val0 + w / 2
val rawBottom = val1 + h / 2
val left = rawLeft * scaleX
val top = rawTop * scaleY
val right = rawRight * scaleX
val bottom = rawBottom * scaleY
var maskBitmap: Bitmap? = null
if (masksOutput != null && maskH > 0 && maskW > 0) {
try {
val maskScaleX = if (isNormalized) maskW.toFloat() else maskW.toFloat() / inputSize
val maskScaleY = if (isNormalized) maskH.toFloat() else maskH.toFloat() / inputSize
val mLeft = (rawLeft * maskScaleX).toInt().coerceIn(0, maskW - 1)
val mTop = (rawTop * maskScaleY).toInt().coerceIn(0, maskH - 1)
val mRight = (rawRight * maskScaleX).toInt().coerceIn(0, maskW - 1)
val mBottom = (rawBottom * maskScaleY).toInt().coerceIn(0, maskH - 1)
val cropW = mRight - mLeft
val cropH = mBottom - mTop
if (cropW > 0 && cropH > 0) {
val maskBmp = Bitmap.createBitmap(cropW, cropH, Bitmap.Config.ALPHA_8)
val maskPixels = IntArray(cropW * cropH)
val offset = i * maskW * maskH
// Dilate radius: expands the mask slightly to include outline
val dilationRadius = 1
for (y in 0 until cropH) {
for (x in 0 until cropW) {
val maskX = mLeft + x
val maskY = mTop + y
var isWhite = false
// Morphological Dilation: Check neighbors to expand & smooth the mask
for (dy in -dilationRadius..dilationRadius) {
for (dx in -dilationRadius..dilationRadius) {
val nx = (maskX + dx).coerceIn(0, maskW - 1)
val ny = (maskY + dy).coerceIn(0, maskH - 1)
val p = ny * maskW + nx
// > -0.5f captures slightly softer edge bounds
if (offset + p < masksOutput.size && masksOutput[offset + p] > -0.5f) {
isWhite = true
break
}
}
if (isWhite) break
}
maskPixels[y * cropW + x] = if (isWhite) Color.WHITE else Color.TRANSPARENT
}
}
maskBmp.setPixels(maskPixels, 0, cropW, 0, 0, cropW, cropH)
maskBitmap = maskBmp
}
} catch (e: Exception) {
Timber.tag("ONNX_SEG").e(e, "Failed to parse mask for bubble $i")
}
}
// Prevent adding invalid boxes
if (right > left && bottom > top) {
parsedResults.add(SpeechBubble(RectF(left, top, right, bottom), maskBitmap))
}
}
}
}
Timber.tag("BubbleZoom").d("Detector: Parsed ${parsedResults.size} valid bubbles above threshold.")
inputTensor.close()
results.close()
if (resized != bitmap) resized.recycle()
return parsedResults
} catch (t: Throwable) {
Timber.tag("BubbleZoom").e(t, "Detector: ONNX Inference failed completely")
}
return emptyList()
}
override fun close() {
session?.close()
session = null
env?.close()
env = null
}
}