How To

Menggunakan Android (Kotlin)

Penggunaan RustO! pada aplikasi Android Kotlin dan Jetpack Compose via JitPack.

SDK Android didistribusikan via JitPack sebagai AAR yang menyertakan pustaka biner native JNI untuk arm64-v8a, armeabi-v7a, x86, dan x86_64.

Instalasi

Tambahkan JitPack ke dalam settings.gradle root proyek Anda:

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
        maven { url 'https://jitpack.io' }
    }
}

Pada build.gradle modul aplikasi:

dependencies {
    // Runtime OCR inti dengan biner native JNI
    implementation 'com.github.byrizki.rusto-rs:rusto-android:0.3.2'

    // Paket model bawaan PP-OCRv6 Tiny (~6 MB)
    implementation 'com.github.byrizki.rusto-rs:rusto-models-ppocrv6-tiny:0.3.2'
}

1. Siklus Hidup Mesin & Resolusi Aset

Menyertakan Context Android ke RustO.initialize() akan mengekstrak dan menyimpan cache model serta kamus secara otomatis saat aplikasi pertama kali dijalankan:

package com.example.ocr

import android.content.Context
import com.byrizki.rusto.*

class OcrManager(private val context: Context) : AutoCloseable {
    // 1. Inisialisasi dengan model bawaan default PP-OCRv6
    private val ocr: RustO = RustO.initialize(context)

    // 2. Atau inisialisasi dengan konfigurasi kustom:
    fun initCustom(): RustO {
        val config = InitializeConfig(
            template = "ppv6",
            detection = DetectionConfig(modelPath = "custom_det.rten"),
            recognition = RecognitionConfig(
                modelPath = "custom_rec.rten",
                dictPath = "custom_dict.txt"
            )
        )
        return RustO.initialize(context, config)
    }

    override fun close() {
        ocr.close()
    }
}

2. Sumber Masukan Gambar yang Didukung

ImageSource mampu mengurai URI content:// dari Photo Picker atau Kamera, path berkas file://, serta larik byte dalam memori ByteArray secara native:

// 1. Content URI dari Photo Picker / Intent Kamera (resolusi stream otomatis)
val uriSource = ImageSource.Uri("content://media/external/images/media/1002")

// 2. Path berkas filesystem lokal
val pathSource = ImageSource.Uri("/data/user/0/com.example/cache/doc.jpg")

// 3. Larik byte mentah dalam memori
val rawBytes: ByteArray = file.readBytes()
val byteSource = ImageSource.Bytes(rawBytes)

3. Opsi Permintaan Lokal (OcrRunOptions)

Atur konfigurasi pemindaian secara dinamis:

val options = OcrRunOptions(
    output = OutputGranularity.LINES, // LINES, WORDS, atau SPATIAL
    lineYThreshold = 0.5f,            // Toleransi dinamis pengelompokan baris
    wordXThreshold = 0.4f,            // Toleransi pemisahan kata
    textScore = 0.55f,                // Batas skor kepercayaan pengenalan
    maxSideLen = 1600f,               // Batas panjang sisi maksimum gambar
    detection = DetectionRunOptions(
        limitSideLen = 960,
        limitType = "max"
    ),
    postprocess = PostprocessRunOptions(
        useDilation = true            // Dilatasi morfologi untuk struk redup
    )
)

4. Contoh Lengkap Pemindai Dokumen Jetpack Compose

Berikut implementasi lengkap menggunakan Kotlin Coroutines dan Photo Picker modern di Jetpack Compose:

package com.example.ocr

import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import com.byrizki.rusto.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

@Composable
fun DocumentScannerScreen() {
    val context = LocalContext.current
    var selectedUri by remember { mutableStateOf<Uri?>(null) }
    var results by remember { mutableStateOf<List<TextResult>>(emptyList()) }
    var spatialText by remember { mutableStateOf("") }
    var isSpatial by remember { mutableStateOf(false) }
    var isLoading by remember { mutableStateOf(false) }

    val photoPicker = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.PickVisualMedia()
    ) { uri ->
        if (uri != null) {
            selectedUri = uri
        }
    }

    LaunchedEffect(selectedUri, isSpatial) {
        val uri = selectedUri ?: return@LaunchedEffect
        isLoading = true

        withContext(Dispatchers.Default) {
            RustO.initialize(context).use { ocr ->
                val source = ImageSource.Uri(uri.toString())
                if (isSpatial) {
                    val spatialRes = ocr.detectText(
                        source,
                        OcrRunOptions(output = OutputGranularity.SPATIAL, lineYThreshold = 0.5f)
                    )
                    if (spatialRes is DetectTextResult.Spatial) {
                        spatialText = spatialRes.text
                        results = emptyList()
                    }
                } else {
                    val structRes = ocr.detectText(
                        source,
                        OcrRunOptions(output = OutputGranularity.LINES, textScore = 0.5f)
                    )
                    if (structRes is DetectTextResult.Structured) {
                        results = structRes.items
                        spatialText = ""
                    }
                }
            }
        }
        isLoading = false
    }

    Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            Button(
                onClick = { photoPicker.launch(androidx.activity.result.PickVisualMediaRequest()) },
                modifier = Modifier.weight(1f)
            ) {
                Text("Pilih Dokumen")
            }

            FilterChip(
                selected = isSpatial,
                onClick = { isSpatial = !isSpatial },
                label = { Text(if (isSpatial) "Tata Letak Spasial" else "Baris Terstruktur") }
            )
        }

        if (isLoading) {
            CircularProgressIndicator(modifier = Modifier.padding(vertical = 16.dp))
        }

        Spacer(modifier = Modifier.height(16.dp))

        if (isSpatial && spatialText.isNotEmpty()) {
            Text(
                text = spatialText,
                fontFamily = FontFamily.Monospace,
                style = MaterialTheme.typography.bodySmall
            )
        } else {
            LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
                items(results) { item ->
                    Card(modifier = Modifier.fillMaxWidth()) {
                        Row(
                            modifier = Modifier.padding(12.dp),
                            horizontalArrangement = Arrangement.SpaceBetween
                        ) {
                            Text(text = item.text, modifier = Modifier.weight(1f))
                            Text(
                                text = "${(item.score * 100).toInt()}%",
                                style = MaterialTheme.typography.bodySmall,
                                color = MaterialTheme.colorScheme.primary
                            )
                        }
                    }
                }
            }
        }
    }
}