الوحدة 10 — مشروع: تصنيف الصور على الهاتف
كلّ ما بنيناه في الوحدات التسع السابقة يلتقي هنا. المزارع الذي فرضنا احتياجاته في الوحدة 1 (بلا شبكة، بطّاريّة محدودة، هاتف اقتصاديّ) يفتح الآن تطبيقًا فعليًّا اسمه « ورقتي »، يوجِّه الكاميرا إلى ورقة نبات، ويرى في أقلّ من نصف ثانية تشخيصًا مصنَّفًا في ثلاث فئات محتملة مع درجة ثقة. لا خادم، لا اتّصال، لا انتظار. سنعرض في هذه الوحدة معماريّة التطبيق النهائيّة، والقرارات التي اتّخذناها، والجدول الكامل للمفاضلات، وطريقة تحديث النموذج نفسه دون تحديث التطبيق كلّه.
معماريّة التطبيق
التطبيق طبقتان: طبقة عرض (واجهة المستخدم) وطبقة استدلال (النموذج والمنطق). بينهما، ViewModel يعزل الحالة عن التقنية.
┌───────────────────────────────────┐
│ واجهة المستخدم (Kotlin/Swift) │
│ - معاينة الكاميرا │
│ - نتائج التصنيف (3 قوائم) │
│ - سجلّ التشخيصات المحلّيّ │
└──────────────┬────────────────────┘
│
┌──────────────▼────────────────────┐
│ ViewModel (Kotlin/Swift) │
│ - حالة الشاشة │
│ - جدولة الاستدلال │
└───────── ─────┬────────────────────┘
│
┌──────────────▼────────────────────┐
│ Classifier (TFLite Interpreter) │
│ - plantvillage_int8.tflite │
│ - labels.txt │
│ - المعالجة المسبقة │
└───────────────────────────────────┘
القرار النهائيّ: النموذج المختار
بعد مقارنة الاستراتيجيّات، الاختيار المبرَّر لـ« ورقتي »:
- المعماريّة: MobileNetV2 بمُضاعف عرض 1.0، إعادة ضبط على PlantVillage (38 صنفًا)
- التحسين: PTQ int8 كامل مع جَمْع تمثيليّ من 200 صورة تحقّق (الوحدة 3)
- تقليم: 50 % بالمقدار مع جدولة تدريجيّة على 5 عصور (الوحدة 5)
- البيانات الوصفيّة:
labels.txtمُدمج، تطبيع [-1, 1] معلَن (الوحدة 2) - المفوَّض: NNAPI على Android، Core ML على iOS (الوحدتان 6 و8)
هذا يعطي الأرقام النهائيّة:
| القياس | القيمة | الميزانيّة (الوحدة 1) | ✓/✗ |
|---|---|---|---|
| حجم النموذج مضغوطًا | 2.1 Mo | 8 Mo | ✓ |
| زمن استدلال وسيط (Snapdragon 680) | 18 ms | 60 ms | ✓ |
| زمن استدلال المئينيّ 99 | 42 ms | — | ✓ |
| طاقة الاستدلال الواحد | 30 mJ | 40 mJ | ✓ |
| الدقّة على 38 صنفًا | 0.933 | 0.90 كحدّ أدنى | ✓ |
| ذاكرة حيّة قصوى للتطبيق | 165 Mo | 200 Mo | ✓ |
كلّ الأرقام ضمن الميزانيّة. هذا ليس صدفة: الميزانيّة أُعلنَت أوّلًا، والقرارات تلتها.
الشيفرة الرئيسيّة للتطبيق (Android)
class LeafViewModel(
private val classifier: PlantLeafClassifier,
private val history: DiagnosisHistory,
) : ViewModel() {
private val _state = MutableStateFlow(LeafState.Empty)
val state: StateFlow<LeafState> = _state
// جدولة : لا نستدلّ على كلّ إطار كاميرا (30 إطارًا/ثانية)،
// بل كلّ 100 مِلّي ثانية، لتوفير الطاقة
private var lastInference = 0L
private val minInterval = 100L
fun onCameraFrame(bitmap: Bitmap) {
val now = System.currentTimeMillis()
if (now - lastInference < minInterval) return
lastInference = now
viewModelScope.launch(Dispatchers.Default) {
val results = classifier.classify(bitmap)
_state.value = LeafState.Result(results, timestamp = now)
// حفظ في السجلّ المحلّيّ (SQLite أو Room)
if (results.isNotEmpty() && results[0].score > 0.6f) {
history.record(results[0], bitmap)
}
}
}
fun onCaptureButton(bitmap: Bitmap) {
viewModelScope.launch(Dispatchers.Default) {
val results = classifier.classify(bitmap)
_state.value = LeafState.Confirmed(results, bitmap)
history.recordManual(results, bitmap)
}
}
}
sealed class LeafState {
object Empty : LeafState()
data class Result(val results: List<Classification>, val timestamp: Long) : LeafState()
data class Confirmed(val results: List<Classification>, val image: Bitmap) : LeafState()
}
نقطة القرار: نستدلّ كلّ 100 مِلّي ثانية بدل كلّ إطار (33 مِلّي ثانية على 30 fps). الفارق للمستخدم غير محسوس، لكنّ الطاقة تُوفَّر بمقدار ثلاثة أضعاف. المزارع سيقضي دقيقة كاملة على ورقة واحدة، فلا حاجة لـ30 استدلالًا في الثانية.
تحديث النموذج عن بُعد
النموذج لا يبقى ثابتًا. مع مرور الأشهر، نجمع بيانات جديدة (أمراض نباتات ظهرت حديثًا في المنطقة)، ونُحسِّن الدقّة. لتحديث النموذج بلا إجبار المستخدم على تحديث التطبيق كلّه، نستعمل نمط Model OTA:
class ModelUpdater(
private val context: Context,
private val client: HttpClient,
private val currentVersion: String,
) {
private val localPath = File(context.filesDir, "plantvillage_current.tflite")
suspend fun checkAndUpdate(): UpdateResult = withContext(Dispatchers.IO) {
// 1) الاستفسار عن أحدث نسخة (JSON خفيف، بضع بايتات)
val manifest = client.get("$SERVER_URL/models/latest")
.body<ModelManifest>()
if (manifest.version <= currentVersion) {
return@withContext UpdateResult.UpToDate
}
// 2) التنزيل يجري على Wi-Fi فقط لتوفير بيانات المستخدم
if (!isOnWifi(context)) {
return@withContext UpdateResult.WaitingWifi
}
// 3) تنزيل الملفّ الجديد إلى ملفّ مؤقّت
val tempFile = File(context.cacheDir, "model_new.tflite")
client.download(manifest.url, tempFile)
// 4) التحقّق من التوقيع : الملفّ من الخادم الحقيقيّ لا من مهاجم
if (!verifySha256(tempFile, manifest.sha256)) {
tempFile.delete()
return@withContext UpdateResult.Corrupted
}
// 5) استبدال ذرّيّ : لا استبدال حيّ، بل إعادة تحميل عند إعادة التشغيل
tempFile.copyTo(localPath, overwrite = true)
tempFile.delete()
UpdateResult.UpdatedTo(manifest.version)
}
}
نقاط تصميم:
- Wi-Fi فقط: لا نُنزِّل 2 مِيغا على شبكة الجيل الرابع دون إذن المستخدم.
- التحقّق من SHA-256: بلاه، مهاجم يستطيع تسميم النموذج بنموذج خبيث يُخطئ عمدًا.
- استبدال ذرّيّ: نُنزِّل إلى ملفّ مؤقّت، نتحقّق، ثمّ نستبدل. لا نستبدل ملفًّا حيًّا يُستعمله المفسّر في اللحظة.
- إعادة التحميل عند إعادة التشغيل: المفس ّر مُهيَّأ في الذاكرة، فلا نُعيد تحميله في منتصف الجلسة. تجربة المستخدم الحاليّة لا تُقطع.
سجلّ التشخيصات المحلّيّ
المزارع لا يستدلّ مرّة واحدة ثمّ ينسى. سجلّ محلّيّ على SQLite (عبر Room على Android، Core Data على iOS) يحفظ كلّ تشخيص مع الصورة والوقت والموقع (بإذن). هذا يخدم غرضَين:
- متابعة الحقل: المزارع يرى تطوّر مرض على مدى أسابيع.
- بيانات لتحسين النموذج: بإذن صريح، نُصدِّر السجلّ عبر Wi-Fi إلى الخادم، فيصير مصدرًا لبيانات تدريب مستقبليّة.
الإذن الصريح مهمّ: بلا سؤال المستخدم، تصدير أيّ صورة انتهاك للخصوصيّة. الحقل في الإعدادات: « شارك تشخيصاتي لتحسين التطبيق » (معطَّل افتراضيًّا).
ما تنازلنا عنه في الطريق
كلّ قرار تحسين هو تنازل. لنكن أوضحاء عمّا خسرنا:
| القرار | ما ربحناه | ما خسرناه |
|---|---|---|
| MobileNetV2 α=1.0 (لا EfficientNet) | حجم أصغر، سرعة أعلى | 1.5 نقطة دقّة مقابل EfficientNetB0 |
| PTQ int8 (لا float32) | حجم إلى الربع، سرعة مضاعفة | 0.9 نقطة دقّة |
| تقليم 50 % | تحميل إلى النصف | 0.3 نقطة دقّة إضافيّة |
| استدلال كلّ 100 ms (لا كلّ إطار) | ثلث الطاقة | استجابة أبطأ قليلًا للحركة السريعة |
| لا استدلال سحابيّ ملحق | خصوصيّة، بلا اتّصال | لا نستفيد من نماذج أكبر عند وجود شبكة |
| Wi-Fi فقط للتحديث | لا استهلاك بيانات المستخدم | التحديث قد يتأخّر أيّامًا لمن لا يعود بيته |
المجموع: خسرنا نحو 2.7 نقاط دقّة (من 0.945 إلى 0.918 نظريًّا، ووقعنا في الممارسة على 0.933 بفضل QAT لبعض الأصناف الحسّاسة). في المقابل، ربحنا تطبيقًا يعمل في حقل نائٍ على هاتف اقتصاديّ في ثمن أقلّ من مئة دولار (نحو $80).
لولا الميزانيّة الصريحة في الوحدة 1، لكنّا حاولنا الحفاظ على الدقّة القصوى وأنتجنا تطبيقًا لا يعمل. كتابة الأهداف الرقميّة قبل الشروع في العمل هي القاعدة الوحيدة التي تحوِّل مشروعًا « نموذج ذكيّ » إلى منتج ينفع الناس.
قائمة تحقّق للنشر
قبل تسليم التطبيق إلى المتجر:
- النموذج الحاليّ اجتاز اختبار التطابق (خادم ↔ عميل، ± 5 %)
- البيانات الوصفيّة داخل الملفّ (labels.txt، تطبيع، حجم إدخال)
- القياس على ثلاث هواتف مختلفة، ضمن الميزانيّة
- أذون الكاميرا مُصرَّحة في Manifest / Info.plist بلغة واضحة
- سياسة الخصوصيّة مُحدَّثة على متجر Google Play وApp Store
- آليّة تحديث النموذج مُختبَرة مع نموذج « اختباريّ » (v0.0)
- السجلّ المحلّيّ مُشفَّر على القرص (SQLCipher أو Room مع مفتاح Keystore)
- رسائل خطأ باللغتَين على الأقلّ (العربيّة والإنكليزيّة)
هذه القائمة ليست مثاليّة، لكنّها الحدّ الأدنى الذي يجب الاّ يُتجاوز.
الخلاصة
- الميزانيّة أُعلنَت، الأرقام تحقّقت: كلّ قيد من قيود الوحدة 1 قِسناه في الوحدة 9، ونُلخِّصه هنا في جدول. المشروع نجح لأنّ الأهداف كانت واضحة قبل بدء العمل.
- قرار النموذج مبنيّ لا مُصادَف: PTQ int8 + تقليم 50 % + NNAPI/Core ML. كلّ خيار بُرِّر بالأرقام في الوحدات السابقة، ولا يوجد قرار « جرّبنا فنجحنا » في التصميم النهائيّ.
- التحديث عن بُعد نمط أساسيّ: النموذج يتحسّن مع الوقت.
ModelUpdaterمع تحقّق SHA-256 واستبدال ذرّيّ يسمح بتحديث ملفّ 2 مِيغا دون تحديث التطبيق كلّه، على Wi-Fi، بأمان. - التنازلات صريحة: ربحنا حجمًا وسرعة وطاقة، خسرنا نقطتَين إلى ثلاث نقاط من الدقّة. الشفافيّة في هذه المفاضلة تُمكِّن الفريق من إعادة النقاش لاحقًا إن تغيّرت الأولويّات.
الوحدة الأخيرة تراجع الدورة كاملة، وتقدِّم شجرة قرارات مضغوطة تلخِّص « متى أستعمل أيّ استراتيجيّة »، ثمّ تُعلن اختبار الأربعين سؤالًا والشهادة.