Skip to content

Lpips.kt

src/jvmMain/kotlin/afield/ai/inference/onnx/Lpips.kt · 307 lines

@file:afield.runtime.completion.CompletionCategory("Machine learning")

package afield.ai.inference.onnx

import afield.app.diagnostics.logging.lprintln
import afield.media.image.Img
import afield.media.image.resize
import afield.media.image.toRGB
import ai.onnxruntime.OnnxTensor
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import java.security.MessageDigest
import java.util.concurrent.TimeUnit

LPIPS: how different two pictures look, as a learned perceptual distance.

0 is identical, and larger is more different; for natural images most distances fall between 0 and about 0.8. It is Zhang et al.'s "Learned Perceptual Image Patch Similarity" (CVPR 2018, richzhang/PerceptualSimilarity, BSD-2), version 0.1 with the AlexNet backbone: both images go through AlexNet's five convolution stages, each stage's activations are normalised per position, and the squared differences are weighted by a linear layer trained on human judgements of which of two distortions looks closer to the original. So it compares edges, blobs and textures where they are, not pixel colours.

val d = Lpips.distance(imgFromFile("a.png"), imgFromFile("b.png"))
val ds = Lpips.distances(candidates, target)   // many against one, the target computed once

Both images must be the same size, at least 32 pixels on each side, RGB or grey in 0..1.

Size matters

AlexNet's first stage has stride 4 and the network halves twice more, so at 32x32 the three deepest stages are 1x1: they still see the image, but only as a whole. Measured on fixed pairs (see tools/export_lpips_onnx.py), a yellow disc against a colour ramp is 0.42 at 32 pixels, 0.68 at 64 and 0.83 at 128; the same pair, the same verdict, a compressed scale. Compare distances made at one size with each other, not across sizes.

Where the model comes from

Field exports its own AlexNet LPIPS ONNX file: tools/export_lpips_onnx.py builds it from the lpips package on PyPI (0.1.4, which ships the trained linear weights) and torchvision's ImageNet AlexNet, checks the ONNX graph against the PyTorch module (agreement 1.2e-7 on twelve pairs), and installs a 9.9MB file in the OnnxModel cache. Lpips.ensureModel runs that script through uv the first time it is needed — a one-off of about a minute that downloads PyTorch if uv has not cached it — and checks the result against Lpips.MODEL's pinned sha256, which two exports with the pinned package versions reproduced byte for byte.

Its gradient

Lpips.distanceAndGradientPlanar also returns d distance / d candidate pixel, from a second exported graph (Lpips.GRAD_MODEL) that the same script builds and checks against PyTorch's autograd. That is what lets a fit descend on LPIPS: afield.lang.stacksl.cpu.StackSLFit seeds its per-pixel tapes with it.

object Lpips

LPIPS perceptual distance between images, with an optional gradient, from an exported ONNX model.

object Lpips {

val MODEL

The exported model: candidate [N,3,H,W], reference [N or 1,3,H,W] in, distance [N](../../../../../../../reference/source/jvmMain/afield/audio/synth/DrawnMorphDocs.md#L60) out.

    val MODEL = OnnxModel(
        id = "lpips-alex",
        url = "",
        fileName = "lpips-alex/lpips_alex.onnx",
        sha256 = "6b99961b1b2b5a2c475d74fcaed30e26151833c06db4fdd48e81468b70ff35c3",
        note = "LPIPS v0.1, AlexNet backbone, fp32, exported by tools/export_lpips_onnx.py."
    )

val GRAD_MODEL

The gradient model: the same inputs, distance [N](../../../../../../../reference/source/jvmMain/afield/audio/synth/DrawnMorphDocs.md#L60) and gradient [N,3,H,W] out, the gradient being d distance[i](../../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) / d candidate[i](../../../../../../../reference/source/jvmMain/afield/audio/synth/Subharmonic.md#L240) on the 0..1 pixels.

Exported by the same script (--model grad) as a hand-written vector-Jacobian product, since an ONNX export traces only forward code. The script checks it against torch.autograd.grad on the PyTorch LPIPS module and refuses a disagreement beyond 1e-4 relative; measured, the worst of fourteen pairs was 5.4e-6. Max-pool ties are the one wrinkle: see distanceAndGradientPlanar.

    val GRAD_MODEL = OnnxModel(
        id = "lpips-alex-grad",
        url = "",
        fileName = "lpips-alex/lpips_alex_grad.onnx",
        sha256 = "8f9569d2fabe9f68cc5fc0e6d071d88347832ff2733589e725d840df53986e7c",
        note = "LPIPS v0.1 AlexNet distance and its gradient w.r.t. the candidate, fp32, exported by tools/export_lpips_onnx.py --model grad."
    )

const val MIN_SIDE: Int = 32

Smallest side the network accepts: AlexNet's deepest stage is 1x1 at 32.

val EXPORT_ARGS: List<String>

The command ensureModel runs, with the package versions the pinned digest was made with.

    val EXPORT_ARGS: List<String> = listOf(
        "run", "--no-project", "--python", "3.12",
        "--with", "torch==2.14.1", "--with", "torchvision==0.29.1", "--with", "lpips==0.1.4",
        "--with", "onnx==1.23.2", "--with", "onnxruntime==1.30.0", "--with", "numpy==2.5.3",
        "python", "tools/export_lpips_onnx.py", "--out"
    )

val GRAD_EXPORT_ARGS: List<String>

The command ensureGradModel runs: EXPORT_ARGS' environment, the gradient graph only.

    val GRAD_EXPORT_ARGS: List<String> = EXPORT_ARGS.dropLast(1) + listOf("--model", "grad", "--grad-out")

    @Volatile
    private var verified: Path? = null

    @Volatile
    private var verifiedGrad: Path? = null

fun ensureModel(): Path

The model file, exporting it first if it is not in the cache, and checked against MODEL's sha256 once per process.

@throws IllegalStateException if the export fails, or produces a file with another digest

    @Synchronized
    fun ensureModel(): Path {
        verified?.let { return it }
        return ensure(MODEL, EXPORT_ARGS).also { verified = it }
    }

fun ensureGradModel(): Path

The gradient model file, exported on first use and checked against GRAD_MODEL's sha256, exactly as ensureModel does for the forward model.

    @Synchronized
    fun ensureGradModel(): Path {
        verifiedGrad?.let { return it }
        return ensure(GRAD_MODEL, GRAD_EXPORT_ARGS).also { verifiedGrad = it }
    }

    private fun ensure(model: OnnxModel, args: List<String>): Path {
        val path = model.localPath
        if (!Files.isRegularFile(path)) export(path, args)
        val actual = sha256(path)
        check(actual.equals(model.sha256, ignoreCase = true)) {
            "LPIPS model at $path has sha256 $actual, expected ${model.sha256}. Delete it and let " +
                "Field export it again, or run tools/export_lpips_onnx.py by hand to see why it differs."
        }
        return path
    }

    private fun export(path: Path, args: List<String>) {
        val root = repoRoot()
        val uv = uvExecutable()
        val command = listOf(uv) + args + path.toString()
        lprintln("info,onnx", "exporting LPIPS to $path: ${command.joinToString(" ")}")
        val process = ProcessBuilder(command).directory(root).redirectErrorStream(true).start()
        val output = process.inputStream.bufferedReader().readText()
        check(process.waitFor(30, TimeUnit.MINUTES) && process.exitValue() == 0) {
            "LPIPS export failed (exit ${runCatching { process.exitValue() }.getOrNull()}):\n${output.takeLast(4000)}"
        }
        check(Files.isRegularFile(path)) { "LPIPS export reported success but wrote no $path:\n${output.takeLast(2000)}" }
    }

val session: OnnxRuntime.LoadedSession get() = OnnxRuntime.session(ensureModel())

The cached session.

val gradSession: OnnxRuntime.LoadedSession get() = OnnxRuntime.session(ensureGradModel())

The cached session of the gradient model.

fun distance(a: Img, b: Img): Double = distances(listOf(a), b)[0]

LPIPS distance between a and b, which must be the same size.

fun distances(candidates: List<Img>, reference: Img): DoubleArray

LPIPS distance from each of candidates to reference; all must be the same size.

    fun distances(candidates: List<Img>, reference: Img): DoubleArray {
        require(candidates.isNotEmpty()) { "need at least one candidate" }
        val w = reference.width
        val h = reference.height
        for (c in candidates) require(c.width == w && c.height == h) {
            "candidate is ${c.width}x${c.height} but the reference is ${w}x$h"
        }
        val n = candidates.size
        val batch = FloatArray(n * 3 * w * h)
        for ((i, c) in candidates.withIndex()) planar(c, batch, i * 3 * w * h)
        val ref = FloatArray(3 * w * h)
        planar(reference, ref, 0)
        return distancesPlanar(batch, n, w, h, ref)
    }

fun distancesPlanar(candidates: FloatArray, n: Int, width: Int, height: Int, reference: FloatArray): DoubleArray

The same, on planar data already laid out as the network wants it: candidates is n x 3 x height x width floats in 0..1, reference is one 3 x height x width image. For callers scoring thousands of renders, who should not pay for an Img per render.

    fun distancesPlanar(candidates: FloatArray, n: Int, width: Int, height: Int, reference: FloatArray): DoubleArray {
        require(width >= MIN_SIDE && height >= MIN_SIDE) { "LPIPS needs at least ${MIN_SIDE}x$MIN_SIDE, got ${width}x$height" }
        require(candidates.size == n * 3 * width * height) { "candidates hold ${candidates.size} floats, expected ${n * 3 * width * height}" }
        require(reference.size == 3 * width * height) { "reference holds ${reference.size} floats, expected ${3 * width * height}" }
        val loaded = session
        val env = OnnxRuntime.env
        return OnnxTensor.createTensor(env, buffer(candidates), longArrayOf(n.toLong(), 3, height.toLong(), width.toLong())).use { a ->
            OnnxTensor.createTensor(env, buffer(reference), longArrayOf(1, 3, height.toLong(), width.toLong())).use { b ->
                loaded.session.run(mapOf("candidate" to a, "reference" to b)).use { result ->
                    val out = (result.get("distance").orElse(null) ?: result.get(0)) as OnnxTensor
                    val fb = out.floatBuffer
                    DoubleArray(n) { fb.get(it).toDouble() }
                }
            }
        }
    }

fun distanceAndGradientPlanar(candidate: FloatArray, width: Int, height: Int, reference: FloatArray): Pair<Double, FloatArray>

The LPIPS distance from candidate to reference and its gradient with respect to every candidate value: planar 3 x height x width floats in 0..1, as distancesPlanar takes them. The gradient has the candidate's layout.

It is the derivative of exactly the distance distancesPlanar computes (the two models agree on the distance to 1e-7), through the network's 0..1 -> -1..1 input mapping, so a caller chains it straight onto whatever produced the pixels. It is the gradient of the function as the network sees it, so two things a caller may expect are not in it: a value outside 0..1 is not clamped here (clamp before, and carry the clamp's zero derivative yourself), and where two positions in an AlexNet max-pool window are exactly equal — common on flat-coloured pictures — the gradient goes to the one ONNX Runtime picks, which can differ from PyTorch's. Either is a valid subgradient; tools/export_lpips_onnx.py checks that every disagreement it sees is such a tie.

    fun distanceAndGradientPlanar(candidate: FloatArray, width: Int, height: Int, reference: FloatArray): Pair<Double, FloatArray> {
        val (d, g) = distancesAndGradientsPlanar(candidate, 1, width, height, reference, 1)
        return d[0] to g
    }

fun distancesAndGradientsPlanar

Batched distanceAndGradientPlanar: n candidates against references of batch m, which is 1 (one broadcast reference) or n (one each, as the regions of a tiled image are). The gradients come back concatenated, n x 3 x height x width.

    fun distancesAndGradientsPlanar(
        candidates: FloatArray, n: Int, width: Int, height: Int, references: FloatArray, m: Int = 1
    ): Pair<DoubleArray, FloatArray> {
        require(width >= MIN_SIDE && height >= MIN_SIDE) { "LPIPS needs at least ${MIN_SIDE}x$MIN_SIDE, got ${width}x$height" }
        require(m == 1 || m == n) { "references must be a batch of 1 or of $n, got $m" }
        val one = 3 * width * height
        require(candidates.size == n * one) { "candidates hold ${candidates.size} floats, expected ${n * one}" }
        require(references.size == m * one) { "references hold ${references.size} floats, expected ${m * one}" }
        val loaded = gradSession
        val env = OnnxRuntime.env
        return OnnxTensor.createTensor(env, buffer(candidates), longArrayOf(n.toLong(), 3, height.toLong(), width.toLong())).use { a ->
            OnnxTensor.createTensor(env, buffer(references), longArrayOf(m.toLong(), 3, height.toLong(), width.toLong())).use { b ->
                loaded.session.run(mapOf("candidate" to a, "reference" to b)).use { result ->
                    val dist = (result.get("distance").orElse(null) as OnnxTensor).floatBuffer
                    val grad = (result.get("gradient").orElse(null) as OnnxTensor).floatBuffer
                    val g = FloatArray(n * one)
                    grad.get(g)
                    DoubleArray(n) { dist.get(it).toDouble() } to g
                }
            }
        }
    }

fun conform(img: Img, width: Int, height: Int): Img

img resampled to width x height and expanded to RGB: the usual way to bring two pictures to one size before comparing them.

    fun conform(img: Img, width: Int, height: Int): Img {
        val rgb = if (img.channels == 3) img else img.toRGB()
        return if (rgb.width == width && rgb.height == height) rgb else rgb.resize(width, height)
    }

    private fun planar(img: Img, out: FloatArray, offset: Int) {
        val rgb = if (img.channels == 3) img else img.toRGB()
        val plane = rgb.width * rgb.height
        for (p in 0 until plane) for (c in 0 until 3) {
            out[offset + c * plane + p] = rgb.data[p * 3 + c].coerceIn(0f, 1f)
        }
    }

    private fun buffer(values: FloatArray) =
        ByteBuffer.allocateDirect(values.size * 4).order(ByteOrder.nativeOrder()).asFloatBuffer().also {
            it.put(values)
            it.rewind()
        }

    private fun sha256(path: Path): String {
        val digest = MessageDigest.getInstance("SHA-256")
        Files.newInputStream(path).use { s ->
            val buf = ByteArray(1 shl 16)
            while (true) {
                val r = s.read(buf)
                if (r <= 0) break
                digest.update(buf, 0, r)
            }
        }
        return digest.digest().joinToString("") { "%02x".format(it) }
    }

    private fun repoRoot(): File {
        var at: File? = File(System.getProperty("user.dir")).absoluteFile
        while (at != null) {
            if (File(at, "tools/export_lpips_onnx.py").isFile) return at
            at = at.parentFile
        }
        error("cannot export the LPIPS model: tools/export_lpips_onnx.py is not above ${System.getProperty("user.dir")}")
    }

uv, looked for by path as well as on PATH: a JVM launched from a GUI rarely has it on PATH.

    private fun uvExecutable(): String {
        System.getenv("AFIELD_UV")?.trim()?.takeIf { it.isNotEmpty() }?.let { return it }
        val searched = System.getenv("PATH").orEmpty().split(File.pathSeparator).filter { it.isNotBlank() }.map { Paths.get(it, "uv") } +
            listOf(Paths.get("/opt/homebrew/bin/uv"), Paths.get("/usr/local/bin/uv"), Paths.get(System.getProperty("user.home"), ".local", "bin", "uv"))
        return searched.firstOrNull { Files.isExecutable(it) }?.toString()
            ?: error("cannot export the LPIPS model: no `uv` found (set AFIELD_UV to its path)")
    }
}