Environment setup
Open the Android Studio project from lesson 04. Use its configured JDK, an emulator or a USB-debuggable device, and the synced LiteRT dependency. Keep clothing.tflite at app/src/main/assets/clothing.tflite.
./gradlew :app:assembleDebug
adb devices
adb install -r app/build/outputs/apk/debug/app-debug.apk
# Run the app and inspect Logcat for the first prediction.This lesson uses a bundled Bitmap so the tensor path is easy to check. You can add a photo picker or camera later. Those pixels must go through the same 28×28 grayscale conversion.
Labels and model wrapper
package com.example.clothinglitert
import android.content.Context
import android.graphics.Bitmap
import com.google.ai.edge.litert.Accelerator
import com.google.ai.edge.litert.CompiledModel
import kotlin.math.exp
class ClothingClassifier(context: Context) {
private val compiledModel = CompiledModel.create(
copyModelFromAssets(context).absolutePath,
CompiledModel.Options(Accelerator.CPU),
)
private val inputs = compiledModel.createInputBuffers()
private val outputs = compiledModel.createOutputBuffers()
private val labels = listOf(
"T-shirt/top", "Trouser", "Pullover", "Dress", "Coat",
"Sandal", "Shirt", "Sneaker", "Bag", "Ankle boot",
)
fun classify(bitmap: Bitmap): Result {
val input = bitmapToNchw(bitmap)
inputs.get(0).writeFloat(input)
compiledModel.run(inputs, outputs)
val logits = outputs.get(0).readFloat()
require(logits.size == 10) { "expected 10 logits, got ${logits.size}" }
val probabilities = softmax(logits)
val best = probabilities.indices.maxBy { probabilities[it] }
return Result(labels[best], probabilities[best], best)
}
data class Result(val label: String, val confidence: Float, val index: Int)
fun close() = compiledModel.close()
}
CompiledModel uses buffers you create once. Fill input buffer zero, run, then read output buffer zero. If your installed Kotlin binding gives you a view of the buffer instead of a copy, read the ten float32 values before you reuse the buffers.
Preprocess Bitmap to the training contract
private fun bitmapToNchw(source: Bitmap): FloatArray {
val square = Bitmap.createBitmap(28, 28, Bitmap.Config.ARGB_8888)
android.graphics.Canvas(square).drawBitmap(
source, null, android.graphics.Rect(0, 0, 28, 28),
android.graphics.Paint(android.graphics.Paint.FILTER_BITMAP_FLAG),
)
val pixels = IntArray(28 * 28)
square.getPixels(pixels, 0, 28, 0, 0, 28, 28)
return FloatArray(28 * 28) { index ->
val red = (pixels[index] shr 16) and 0xff
val green = (pixels[index] shr 8) and 0xff
val blue = pixels[index] and 0xff
// Grayscale luminance scaled to [0, 1], row-major inside NCHW.
(0.299f * red + 0.587f * green + 0.114f * blue) / 255f
}.also { square.recycle() }
}
private fun softmax(logits: FloatArray): FloatArray {
val maxLogit = logits.maxOrNull() ?: error("empty output")
val exponentials = FloatArray(logits.size) { index ->
exp((logits[index] - maxLogit).toDouble()).toFloat()
}
val total = exponentials.sum()
return FloatArray(logits.size) { index -> exponentials[index] / total }
}
The grayscale formula turns ordinary RGB photos into the same kind of input. Fashion-MNIST images have a dark background and light clothing. A phone photo may need a crop, a contrast change, or inversion. What must stay the same is 28×28, one channel, float32, values from 0 to 1, and row-major pixels in the single NCHW channel.
Simple Compose screen
@Composable
fun ClothingScreen(classifier: ClothingClassifier, sample: Bitmap) {
var result by remember { mutableStateOf<ClothingClassifier.Result?>(null) }
Column(Modifier.padding(24.dp)) {
Image(sample.asImageBitmap(), contentDescription = "Fashion sample")
Button(onClick = { result = classifier.classify(sample) }) {
Text("Classify")
}
result?.let {
Text("${it.label} — ${(it.confidence * 100).formatPercent()}%")
}
}
}
private fun Float.formatPercent(): String = "%.1f".format(this)
In a finished app, run inference off the main thread, for example in a ViewModel coroutine. Keep the compiled model alive across button taps. This small screen is about the tensor path, not a full camera app.
Key concepts
ClothingClassifierwraps oneCompiledModeland buffers created once.- Resize to 28x28, compute grayscale, scale to 0-1, write row-major into the NCHW channel.
- Write input floats, call
compiledModel.run, then read ten output floats. - Softmax is for displayed confidence only. Labels stay in the fixed Python order.
Takeaways
- Do not feed 0-255 bytes. Do not treat logits as probabilities. Do not switch to NHWC.
- In a finished app, run inference off the main thread.
- Phone photos may need crop or contrast. The tensor contract stays 28x28, one channel, float32 0-1.