Skip to content

Commit 0f812c8

Browse files
committed
Add PlantNet-300K fine-grained plant identification sample (CompiledModel GPU)
PlantNet-300K ResNet18 (cpoisson/plantnet300k-resnet18, Apache-2.0; NeurIPS 2021 plantnet/PlantNet-300K) — fine-grained identification of 1081 plant species, running fully on the LiteRT CompiledModel GPU at ~16 ms/frame on a Pixel 8a. Plain torchvision ResNet18 (pure CNN) -> fully GPU-compatible (37/37 nodes on the delegate, 1 partition) with one patch: ZeroPadMaxPool (the ResNet stem maxpool's -inf PADV2 is rejected as 'PADV2: src has wrong size', replaced by an explicit 0-pad + unpadded maxpool, exact post-ReLU). CPU-exact vs PyTorch (corr 0.99999999999); device Mali GPU corr 0.99999, top-1 match. Sample at compiled_model_api/image_classification/plantnet_kotlin_gpu (android app + conversion scripts), with a deterministic bundled plant photo demo.
1 parent a38855b commit 0f812c8

18 files changed

Lines changed: 1877 additions & 0 deletions

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# PlantNet-300K — Fine-grained plant identification (LiteRT CompiledModel GPU)
2+
3+
Identify **1081 plant species** from a photo, running **fully on the LiteRT
4+
`CompiledModel` GPU** delegate. A [PlantNet-300K](https://github.com/plantnet/PlantNet-300K)
5+
(NeurIPS 2021) ResNet18. ~16 ms/frame on a Pixel 8a.
6+
7+
- **Model:** [litert-community/PlantNet-300K-ResNet18-LiteRT](https://huggingface.co/litert-community/PlantNet-300K-ResNet18-LiteRT) · 47 MB
8+
- **Weights:** [cpoisson/plantnet300k-resnet18](https://huggingface.co/cpoisson/plantnet300k-resnet18) · Apache-2.0
9+
- **Input:** `[1, 3, 224, 224]` NCHW, RGB, ImageNet-normalized
10+
- **Output:** `[1, 1081]` species logits (Latin names)
11+
12+
## How it works
13+
14+
Plain torchvision ResNet18 — a pure CNN. It converts to a fully GPU-compatible graph
15+
(**37/37 nodes on the delegate, 1 partition**; device corr 0.99999, top-1 match) with
16+
**one patch**: the ResNet stem `MaxPool2d(padding=1)` lowers to a PADV2 with `-inf`
17+
padding (`PADV2: src has wrong size` on the Mali delegate), replaced by an explicit
18+
0-pad + unpadded maxpool (exact post-ReLU). CPU-exact vs PyTorch (corr 0.99999999999).
19+
20+
Preprocess: RGB, center-crop → resize 224×224, ImageNet normalize, NCHW. Postprocess:
21+
softmax + top-k. Class index → species via sorted PlantNet species-id order
22+
(`PlantNetLabels.kt`).
23+
24+
## Run
25+
26+
```bash
27+
# 1. Get the model (build with ../conversion or download from Hugging Face)
28+
cd android
29+
./install_to_device.sh <dir-with-plantnet.tflite>
30+
31+
# 2. Build & run
32+
./gradlew :app:installDebug
33+
```
34+
35+
The sample classifies a bundled plant photo and shows the top-5 species. Adapt
36+
`MainActivity.kt` to feed live camera frames for a real-time plant-ID demo.
37+
38+
## Convert
39+
40+
See [`conversion/`](conversion/)`build_plantnet.py` loads the Apache-2.0 ResNet18
41+
weights and converts with litert-torch.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
*.iml
2+
.gradle
3+
/local.properties
4+
/.idea
5+
.DS_Store
6+
/build
7+
/captures
8+
.externalNativeBuild
9+
.cxx
10+
local.properties
11+
# model files are pushed to the device, never committed
12+
*.tflite
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/*
2+
* Copyright 2025 The Google AI Edge Authors. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
plugins {
18+
id("com.android.application")
19+
id("org.jetbrains.kotlin.android")
20+
}
21+
22+
android {
23+
namespace = "com.google.ai.edge.examples.plantnet"
24+
compileSdk = 35
25+
26+
defaultConfig {
27+
applicationId = "com.google.ai.edge.examples.plantnet"
28+
minSdk = 26
29+
targetSdk = 35
30+
versionCode = 1
31+
versionName = "1.0"
32+
ndk { abiFilters += setOf("arm64-v8a") }
33+
}
34+
35+
compileOptions {
36+
sourceCompatibility = JavaVersion.VERSION_17
37+
targetCompatibility = JavaVersion.VERSION_17
38+
}
39+
kotlinOptions { jvmTarget = "17" }
40+
41+
packaging {
42+
jniLibs {
43+
pickFirsts += setOf(
44+
"**/libc++_shared.so",
45+
"**/libtensorflowlite_jni.so",
46+
"**/libtensorflowlite_gpu_jni.so",
47+
)
48+
}
49+
}
50+
51+
androidResources { noCompress += listOf("tflite", "txt", "bin") }
52+
}
53+
54+
dependencies {
55+
implementation("com.google.ai.edge.litert:litert:2.1.5")
56+
implementation("androidx.core:core-ktx:1.15.0")
57+
implementation("androidx.appcompat:appcompat:1.7.0")
58+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?xml version="1.0" encoding="utf-8"?>
2+
<!--
3+
Copyright 2025 The Google AI Edge Authors. All Rights Reserved.
4+
5+
Licensed under the Apache License, Version 2.0 (the "License");
6+
you may not use this file except in compliance with the License.
7+
You may obtain a copy of the License at
8+
9+
http://www.apache.org/licenses/LICENSE-2.0
10+
11+
Unless required by applicable law or agreed to in writing, software
12+
distributed under the License is distributed on an "AS IS" BASIS,
13+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
See the License for the specific language governing permissions and
15+
limitations under the License.
16+
-->
17+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
18+
<application
19+
android:label="PlantNet"
20+
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
21+
android:supportsRtl="true">
22+
<activity
23+
android:name=".MainActivity"
24+
android:exported="true"
25+
android:screenOrientation="portrait">
26+
<intent-filter>
27+
<action android:name="android.intent.action.MAIN" />
28+
<category android:name="android.intent.category.LAUNCHER" />
29+
</intent-filter>
30+
</activity>
31+
</application>
32+
</manifest>
22.3 KB
Loading
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
/*
2+
* Copyright 2025 The Google AI Edge Authors. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.ai.edge.examples.plantnet
18+
19+
import android.graphics.BitmapFactory
20+
import android.os.Bundle
21+
import android.widget.ImageView
22+
import android.widget.LinearLayout
23+
import android.widget.TextView
24+
import androidx.appcompat.app.AppCompatActivity
25+
import java.io.File
26+
import java.util.concurrent.Executors
27+
28+
/**
29+
* Runs PlantNet-300K on a bundled plant photo and prints the top-5 species — a
30+
* deterministic, self-contained demo. The 47 MB model is loaded from the app's
31+
* filesDir; push it there first with install_to_device.sh (not bundled in the APK).
32+
*/
33+
class MainActivity : AppCompatActivity() {
34+
35+
private val executor = Executors.newSingleThreadExecutor()
36+
37+
override fun onCreate(savedInstanceState: Bundle?) {
38+
super.onCreate(savedInstanceState)
39+
val status = TextView(this).apply { textSize = 16f; setPadding(28, 40, 28, 20) }
40+
val imageView = ImageView(this).apply { adjustViewBounds = true }
41+
setContentView(LinearLayout(this).apply {
42+
orientation = LinearLayout.VERTICAL
43+
addView(status); addView(imageView)
44+
})
45+
46+
executor.execute {
47+
val modelFile = File(filesDir, "plantnet.tflite")
48+
if (!modelFile.exists()) {
49+
runOnUiThread {
50+
status.text = "Model not found at:\n${modelFile.absolutePath}\n\n" +
51+
"Push it first: ./install_to_device.sh <dir-with-plantnet.tflite>\n" +
52+
"(build with ../conversion or download from\n litert-community/PlantNet-300K-ResNet18-LiteRT)"
53+
}
54+
return@execute
55+
}
56+
val input = assets.open("plant.jpg").use { BitmapFactory.decodeStream(it) }
57+
PlantClassifier(modelFile.absolutePath).use { clf ->
58+
val (preds, ms) = clf.classify(input)
59+
val txt = "PlantNet-300K · CompiledModel GPU · ${ms} ms\n\n" +
60+
preds.joinToString("\n") { (n, p) -> "%s %d%%".format(n, (p * 100).toInt()) }
61+
runOnUiThread { status.text = txt; imageView.setImageBitmap(input) }
62+
}
63+
}
64+
}
65+
66+
override fun onDestroy() {
67+
super.onDestroy()
68+
executor.shutdown()
69+
}
70+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2025 The Google AI Edge Authors. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.google.ai.edge.examples.plantnet
18+
19+
import android.graphics.Bitmap
20+
import android.graphics.Canvas
21+
import android.graphics.Matrix
22+
import android.graphics.Paint
23+
import android.util.Log
24+
import com.google.ai.edge.litert.Accelerator
25+
import com.google.ai.edge.litert.CompiledModel
26+
import com.google.ai.edge.litert.TensorBuffer
27+
28+
/**
29+
* PlantNet-300K fine-grained plant species classifier on LiteRT CompiledModel (GPU).
30+
*
31+
* Input : [1, 3, 224, 224] NCHW, RGB, ImageNet-normalized.
32+
* Output: [1, 1081] species logits (PlantNet-300K, Latin names).
33+
*
34+
* A torchvision ResNet18 (pure CNN). One re-authoring patch (baked into the graph,
35+
* see conversion/): the ResNet stem MaxPool's -inf-pad PADV2 is replaced with a
36+
* 0-pad + unpadded maxpool (exact post-ReLU), which the Mali delegate accepts.
37+
*/
38+
class PlantClassifier(modelPath: String) : AutoCloseable {
39+
40+
companion object {
41+
private const val TAG = "PlantNet"
42+
const val SIZE = 224
43+
private val MEAN = floatArrayOf(0.485f, 0.456f, 0.406f)
44+
private val STD = floatArrayOf(0.229f, 0.224f, 0.225f)
45+
}
46+
47+
private val model = CompiledModel.create(modelPath, CompiledModel.Options(Accelerator.GPU), null)
48+
private val inBufs: List<TensorBuffer> = model.createInputBuffers()
49+
private val outBufs: List<TensorBuffer> = model.createOutputBuffers()
50+
51+
private val inputFloats = FloatArray(3 * SIZE * SIZE)
52+
private val pixels = IntArray(SIZE * SIZE)
53+
private val resized = Bitmap.createBitmap(SIZE, SIZE, Bitmap.Config.ARGB_8888)
54+
private val matrix = Matrix()
55+
private val paint = Paint(Paint.FILTER_BITMAP_FLAG)
56+
57+
init {
58+
Log.i(TAG, "GPU compiled OK — ${inBufs.size} in / ${outBufs.size} out")
59+
}
60+
61+
/** Classify. Returns top-[topK] (species name, probability) + time (ms). */
62+
fun classify(bitmap: Bitmap, topK: Int = 5): Pair<List<Pair<String, Float>>, Long> {
63+
val t = System.nanoTime()
64+
val side = minOf(bitmap.width, bitmap.height)
65+
matrix.reset()
66+
matrix.postTranslate(-(bitmap.width - side) / 2f, -(bitmap.height - side) / 2f)
67+
matrix.postScale(SIZE.toFloat() / side, SIZE.toFloat() / side)
68+
Canvas(resized).drawBitmap(bitmap, matrix, paint)
69+
resized.getPixels(pixels, 0, SIZE, 0, 0, SIZE, SIZE)
70+
val plane = SIZE * SIZE
71+
for (i in 0 until plane) {
72+
val p = pixels[i]
73+
inputFloats[i] = (((p shr 16) and 0xFF) / 255f - MEAN[0]) / STD[0]
74+
inputFloats[plane + i] = (((p shr 8) and 0xFF) / 255f - MEAN[1]) / STD[1]
75+
inputFloats[2 * plane + i] = ((p and 0xFF) / 255f - MEAN[2]) / STD[2]
76+
}
77+
inBufs[0].writeFloat(inputFloats)
78+
model.run(inBufs, outBufs)
79+
val logits = outBufs[0].readFloat() // [1081]
80+
81+
val idx = logits.indices.sortedByDescending { logits[it] }.take(topK)
82+
val mx = logits[idx.first()]
83+
var sum = 0.0
84+
for (v in logits) sum += Math.exp((v - mx).toDouble())
85+
val preds = idx.map { i ->
86+
PlantNetLabels.NAMES[i] to (Math.exp((logits[i] - mx).toDouble()) / sum).toFloat()
87+
}
88+
return preds to ((System.nanoTime() - t) / 1_000_000)
89+
}
90+
91+
override fun close() {
92+
model.close()
93+
if (!resized.isRecycled) resized.recycle()
94+
}
95+
}

0 commit comments

Comments
 (0)