← All courses ← Course home

Lesson 05 of 07

Build the Kotlin clothing classifier UI

Prepare a Bitmap the same way Python does. Run LiteRT buffers, apply softmax, and show the top Fashion-MNIST label.

Agent brief (llms.md)

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

  • ClothingClassifier wraps one CompiledModel and 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.