انتقل إلى المحتوى الرئيسي

الوحدة 7 — التكامل مع Android

النموذج جاهز، حجمه 3.5 مِيغا، يعمل في 18 مِلّي ثانية على NNAPI. الوقت الآن لإدماجه في تطبيق يحمله المزارع في جيبه. سنغطّي في هذه الوحدة أربع نقاط جوهريّة: التبعيّات الصحيحة في Gradle، ومعالجة إطار الكاميرا الحقيقيّ (بلا خدع)، وإخراج الاستدلال من الخيط الرئيسيّ، والتفضيل بين واجهة المفسّر الخام ومكتبة Task Library التي تخفي جزءًا كبيرًا من التفاصيل.

أوّلًا: التبعيّات في Gradle

في ملفّ build.gradle الخاصّ بالوحدة، نُضيف:

dependencies {
// النواة الأساسيّة : المفسّر
implementation("org.tensorflow:tensorflow-lite:2.16.0")

// المفوَّض NNAPI (يستدعي مسرِّعات Android)
implementation("org.tensorflow:tensorflow-lite-nnapi:2.16.0")

// Task Library : واجهة عالية المستوى لتصنيف الصور
implementation("org.tensorflow:tensorflow-lite-task-vision:0.4.4")

// GPU Delegate (اختياريّ)
implementation("org.tensorflow:tensorflow-lite-gpu:2.16.0")
implementation("org.tensorflow:tensorflow-lite-gpu-delegate-plugin:0.4.4")

// CameraX لالتقاط الإطار من الكاميرا
implementation("androidx.camera:camera-core:1.3.1")
implementation("androidx.camera:camera-camera2:1.3.1")
implementation("androidx.camera:camera-lifecycle:1.3.1")
implementation("androidx.camera:camera-view:1.3.1")
}

نقطة مهمّة: لا تخلط بين نُسخ tensorflow-lite وtensorflow-lite-task-vision. مكتبة Task تعتمد على نسخة قياسيّة من النواة، وأيّ اختلاف يُنتج أخطاء اتّصال (UnsatisfiedLinkError) في وقت التنفيذ. إن استعملت واحدة فقط، الأسلم أن تستعمل Task Library وحدها لأنّها تجرّ النواة المطابقة.

إضافة النموذج إلى الأصول

النموذج plantvillage_int8.tflite يوضع في app/src/main/assets/. يُنصح بإيقاف ضغط ملفّات .tflite كي لا يُفكّها Android مرّة أخرى قبل التحميل:

android {
androidResources {
noCompress += "tflite"
}
}

المسار الأوّل: Task Library

Task Library تُخفي المفسّر والتنسورات والمعالجة المسبقة خلف واجهة عالية المستوى. لتصنيف صورة، الشيفرة كلّها ثلاثون سطرًا:

import org.tensorflow.lite.support.image.TensorImage
import org.tensorflow.lite.task.core.BaseOptions
import org.tensorflow.lite.task.vision.classifier.ImageClassifier

class PlantLeafClassifier(context: Context) {

private val classifier: ImageClassifier

init {
val baseOptions = BaseOptions.builder()
.useNnapi() // NNAPI أوّلًا، رجوع تلقائيّ إلى CPU
.setNumThreads(4) // للعمليّات التي تعود إلى CPU
.build()

val options = ImageClassifier.ImageClassifierOptions.builder()
.setBaseOptions(baseOptions)
.setMaxResults(3) // أعلى ثلاثة أصناف فقط
.setScoreThreshold(0.15f) // تجاهل الأصناف بأقلّ من 15 %
.build()

classifier = ImageClassifier.createFromFileAndOptions(
context, "plantvillage_int8.tflite", options,
)
}

fun classify(bitmap: Bitmap): List<Classification> {
val tensorImage = TensorImage.fromBitmap(bitmap)
return classifier.classify(tensorImage).firstOrNull()?.categories?.map {
Classification(it.label, it.score)
} ?: emptyList()
}
}

data class Classification(val label: String, val score: Float)

المفتاح هنا: ImageClassifier.createFromFileAndOptions يقرأ البيانات الوصفيّة التي أدرجناها في الوحدة 2 (التطبيع، حجم الإدخال، labels.txt)، ويطبِّق المعالجة المسبقة تلقائيًّا. بلا هذه البيانات، Task Library تفشل، ويجب استعمال المسار الخام.

المسار الثاني: المفسّر الخام

حين تحتاج إلى تحكّم دقيق (نموذج بلا بيانات وصفيّة، معالجة مسبقة غير قياسيّة، توقيعات متعدّدة)، المفسّر الخام هو الحلّ:

import org.tensorflow.lite.Interpreter
import org.tensorflow.lite.nnapi.NnApiDelegate
import org.tensorflow.lite.support.common.FileUtil
import java.nio.ByteBuffer
import java.nio.ByteOrder

class PlantLeafClassifierRaw(context: Context) {

private val interpreter: Interpreter
private val labels: List<String>
private val inputSize = 224

init {
val model = FileUtil.loadMappedFile(context, "plantvillage_int8.tflite")
val options = Interpreter.Options().apply {
addDelegate(NnApiDelegate())
setNumThreads(4)
}
interpreter = Interpreter(model, options)
labels = FileUtil.loadLabels(context, "labels.txt")
}

fun classify(bitmap: Bitmap): List<Classification> {
// 1) تصغير مربَّع 224x224 مع الحفاظ على النسبة (اقتصاص مركزيّ)
val scaled = Bitmap.createScaledBitmap(bitmap, inputSize, inputSize, true)

// 2) تحويل إلى ByteBuffer بـint8. النموذج ينتظر int8 مُكمَّم مباشرة
val input = ByteBuffer.allocateDirect(1 * inputSize * inputSize * 3)
.order(ByteOrder.nativeOrder())
val pixels = IntArray(inputSize * inputSize)
scaled.getPixels(pixels, 0, inputSize, 0, 0, inputSize, inputSize)
for (pixel in pixels) {
// من uint8 (0..255) إلى int8 (-128..127) : طرح 128
input.put((((pixel shr 16) and 0xFF) - 128).toByte()) // R
input.put((((pixel shr 8) and 0xFF) - 128).toByte()) // G
input.put(((pixel and 0xFF) - 128).toByte()) // B
}
input.rewind()

// 3) تنسور المخرَج : int8 بحجم [1, 38]
val output = Array(1) { ByteArray(labels.size) }
interpreter.run(input, output)

// 4) إعادة تحويل int8 إلى احتماليّات وترتيب
return output[0]
.mapIndexed { i, v -> Classification(labels[i], (v.toInt() + 128) / 255f) }
.sortedByDescending { it.score }
.take(3)
}
}

الفرق مع Task Library: أنت مسؤول عن كلّ خطوة. المعالجة المسبقة، التكميم، إعادة التحويل، ترتيب النتائج. مرن، لكنّه مطوَّل. اختر Task Library إن استطعت.

معالجة إطار الكاميرا: الحقيقة القاسية

الإطار من CameraX يأتي بصيغة YUV_420_888، لا RGB. تحويله إلى Bitmap مكلف (30-80 مِلّي ثانية على هاتف متوسّط)، وهو يُضاف إلى زمن الاستدلال. الحلّ الأمثل: تحويل مباشر إلى TensorImage بلا مرور بـBitmap.

import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import org.tensorflow.lite.support.image.TensorImage

class ClassificationAnalyzer(
private val classifier: PlantLeafClassifier,
private val onResult: (List<Classification>) -> Unit,
) : ImageAnalysis.Analyzer {

private var bitmapBuffer: Bitmap? = null

override fun analyze(image: ImageProxy) {
if (bitmapBuffer == null) {
bitmapBuffer = Bitmap.createBitmap(
image.width, image.height, Bitmap.Config.ARGB_8888,
)
}
image.use {
it.toBitmap(bitmapBuffer!!)
}

// تدوير حسب اتّجاه الجهاز
val matrix = android.graphics.Matrix().apply {
postRotate(image.imageInfo.rotationDegrees.toFloat())
}
val rotated = Bitmap.createBitmap(
bitmapBuffer!!, 0, 0, bitmapBuffer!!.width, bitmapBuffer!!.height,
matrix, true,
)

val results = classifier.classify(rotated)
onResult(results)
}
}

إخراج الاستدلال من الخيط الرئيسيّ

الاستدلال يستغرق 30-100 مِلّي ثانية. إن جرى على الخيط الرئيسيّ، الواجهة تتجمّد. CameraX يُشغِّل المُحلِّل على خيطه الخاصّ، لكن حين تستدلّ من زرّ يضغط عليه المستخدم مباشرة، استعمل كوروتين:

import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope

class LeafViewModel(private val classifier: PlantLeafClassifier) : ViewModel() {

fun classifyImage(bitmap: Bitmap) {
viewModelScope.launch {
val results = withContext(Dispatchers.Default) {
classifier.classify(bitmap)
}
// العودة إلى الخيط الرئيسيّ لتحديث الواجهة
updateUi(results)
}
}
}

التحقّق من التطابق بين الخادم والعميل

خطأ شائع: المعالجة المسبقة على الهاتف تختلف عن التي جرت أثناء التدريب. النموذج يعمل، لكنّ الدقّة تنخفض 10 نقاط بلا سبب واضح. الحلّ: اختبار تطابق قبل النشر. نُشغّل نفس الصورة على الهاتف وعلى بايثون ونقارن:

# على المكتب : نتوقّع 100 صورة اختبار
import json
import numpy as np
import tensorflow as tf

interpreter = tf.lite.Interpreter(model_path="plantvillage_int8.tflite")
interpreter.allocate_tensors()

resultats = {}
for chemin in test_images:
image = charger_et_preparer(chemin) # المعالجة المسبقة نفسها
interpreter.set_tensor(interpreter.get_input_details()[0]["index"], image)
interpreter.invoke()
y = interpreter.get_tensor(interpreter.get_output_details()[0]["index"])
resultats[chemin] = y[0].tolist()

with open("resultats_reference.json", "w") as f:
json.dump(resultats, f)

هذا الملفّ resultats_reference.json يُنقَل إلى Android داخل الأصول، والتطبيق يُشغِّل الصور نفسها ويقارن. أيّ فرق يتجاوز 5 % في أعلى صنف يُلقي تنبيهًا. هذه الحلقة تكشف الفوارق الصامتة قبل الإنتاج.

صلاحيّة الكاميرا وسياسة الخصوصيّة

AndroidManifest.xml يحتاج android.permission.CAMERA. على Android 6 وما بعده، الطلب في وقت التنفيذ. لا تنس تحديث سياسة الخصوصيّة في متجر Google Play: « التطبيق يستعمل الكاميرا لتحليل صور النباتات محلّيًّا. لا صورة تُرسَل إلى خادم ». الوضوح هنا يُطمئن المستخدم.

الخلاصة

  • Task Library هي المسار الافتراضيّ: تُخفي التنسورات والمعالجة المسبقة، تحتاج بيانات وصفيّة داخل النموذج، وتكفي لتسعين بالمئة من المسائل. المفسّر الخام للحالات الخاصّة.
  • NNAPI يُفعَّل بسطر واحد: .useNnapi() مع Task Library، أو addDelegate(NnApiDelegate()) مع المفسّر الخام. الرجوع الآمن إلى CPU تلقائيّ.
  • معالجة إطار الكاميرا هي عنق الزجاجة: تحويل YUV_420_888 إلى تنسور يستغرق أحيانًا أطول من الاستدلال. استعمل TensorImage مباشرة، وأعِد استعمال المصدّات.
  • الاستدلال خارج الخيط الرئيسيّ: كوروتين مع Dispatchers.Default، وواجهة تُحدَّث من الخيط الرئيسيّ. تجميد الواجهة أكثر شكوى لدى المستخدم من دقّة ناقصة بنقطة.

الوحدة التالية تنقل نفس النموذج إلى iOS، مع مقارنة صريحة مع Core ML.