Skip to content

stippletransport.kt

src/jvmMain/kotlin/afield/geometry/spatial/stippletransport.kt · 280 lines

package and imports — 58 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Machine learning")

package afield.geometry.spatial

import afield.data.collections.get
import afield.data.collections.s
import afield.data.collections.times
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.compareTo
import afield.geometry.mesh.normalized
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.times
import afield.math.functions.parameterCount
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.image.normalized
import afield.media.video.image
import afield.runtime.lifecycle.once

import afield.ai.learning.FlowIntegrator
import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.invoke
import afield.document.workspace.file
import afield.document.workspace.path
import afield.graphics.render.lineTo
import afield.graphics.render.moveTo
import afield.graphics.shader.vertex
import afield.lang.stacksl.h
import afield.math.autodiff.max
import afield.math.autodiff.sum
import afield.math.linalg.Vec3
import afield.math.linalg.i
import afield.math.linalg.invoke
import afield.math.linalg.vec
import afield.math.linalg.x
import afield.math.statistics.random
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.ui.output.h
import afield.geometry.path.FLine
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import java.io.File
import kotlin.math.ceil
import kotlin.math.exp
import kotlin.math.sqrt
import kotlin.random.Random

Runs a learned stipple transport on the CPU: a small SiLU network, trained by the optional PyTorch tool in optional/transport-flow, that moves points from a uniform source rectangle toward an image's density.

loadStippleTransport reads the exported JSON model. StippleTransport.transport and StippleTransport.flowMap integrate points through the learned velocity field (Euler or midpoint steps), StippleTransport.pathsFrom returns each point's path as an FLine, and StippleTransport.sourceParticles draws the source points. A temporal model also takes a movie time, so you can draw source points once and re-transport the same list for each frame. StippleTransportTest covers loading and integration.

fun loadStippleTransport(file: File): StippleTransport

Loads the optional PyTorch stipple trainer's portable field.transport-flow.v1 model.

Coordinates and velocities are normalized image coordinates with y pointing down. Transport time is the ODE variable; movie time is held fixed through each solve. Neither time is clamped, so reverse integration and extrapolation remain available as experiments. This is CPU inference, intended for moderate geometry counts, not a claim of real-time GPU particle performance. See optional/transport-flow for training, density sampling, comparison figures and field baking.

fun loadStippleTransport(file: File): StippleTransport = StippleTransport.fromJson(file.readText())

fun loadStippleTransport(path: String): StippleTransport = loadStippleTransport(File(path))

Convenience spelling of loadStippleTransport for a filename.

class StippleTransport private constructor(private val data: StippleTransportData)

A reusable density-conditioned coordinate warp, independent of its training stipples.

transport preserves list order and each point's z coordinate. For movie playback, draw source points once and re-evaluate those same points at each movie time; repeatedly advancing the previous frame would integrate a different dynamical system. velocity is velocity in transport time, not motion with respect to movie time. Query scratch buffers belong to each call, so immutable models can be shared across threads.

class StippleTransport private constructor(private val data: StippleTransportData) {

val temporal: Boolean get() = data.temporal

Whether the network has a fourth input for movie time.

val frameTimes: List<Double> get() = data.frameTimes.toList()

Discrete movie times used for training, copied so callers cannot alter the model.

val parameterCount: Int get() = data.weights.size

Number of learned scalar weights and biases.

val sourceHalfExtent: List<Double> get() = data.sourceHalfExtent.toList()

Half-width and half-height of the uniform canonical source rectangle.

    private val widths = data.widths.toIntArray()
    private val weights = data.weights.toDoubleArray()
    private val offsets = IntArray(widths.size - 1)
    private val scratchWidth = widths.max()

init

    init {
        require(data.format == "field.transport-flow.v1") { "unsupported stipple transport format: ${data.format}" }
        require(data.activation == "silu" && data.coordinates == "normalized-y-down") {
            "stipple transports require SiLU and normalized-y-down coordinates"
        }
        require(widths.size >= 3 && widths.first() == (if (temporal) 4 else 3) && widths.last() == 2 && widths.all { it > 0 }) {
            "expected (x,y,transportTime[,movieTime]) -> hidden layers -> (vx,vy)"
        }
        require(data.sourceHalfExtent.size == 2 && data.sourceHalfExtent.all { it.isFinite() && it > 0 }) {
            "sourceHalfExtent must contain two finite positive values"
        }
        require(data.frameTimes.isNotEmpty() && data.frameTimes.all { it.isFinite() && it in 0.0..1.0 } &&
            data.frameTimes.zipWithNext().all { (a, b) -> a < b } &&
            (if (temporal) data.frameTimes.size >= 2 else data.frameTimes.size == 1)) {
            "frameTimes must be sorted training conditions in 0..1"
        }
        var count = 0L
        for (layer in offsets.indices) {
            require(count <= Int.MAX_VALUE) { "network is too large" }
            offsets[layer] = count.toInt()
            count += widths[layer].toLong() * widths[layer + 1] + widths[layer + 1]
        }
        require(count == weights.size.toLong() && weights.all { it.isFinite() }) { "incorrect weight count or nonfinite weights" }
    }

fun velocity(at: Vec3, transportTime: Double, movieTime: Double = 0.0): Vec3

Predicts (vx,vy,0) at any point, at two independent times.

    fun velocity(at: Vec3, transportTime: Double, movieTime: Double = 0.0): Vec3 {
        require(transportTime.isFinite() && movieTime.isFinite()) { "times must be finite" }
        val query = Query()
        val out = query.evaluate(at.x.toDouble(), at.y.toDouble(), transportTime, movieTime)
        return vec(out[0], out[1], 0.0)
    }

fun transport

Integrates arbitrary particles, curve vertices or lattice samples. No training-point lookup. fromTransportTime can exceed transportTime for inversion; both can overshoot 0..1.

    fun transport(
        points: List<Vec3>,
        transportTime: Double = 1.0,
        movieTime: Double = 0.0,
        steps: Int = 32,
        fromTransportTime: Double = 0.0,
        integrator: FlowIntegrator = FlowIntegrator.MIDPOINT,
    ): List<Vec3> {
        validateTimes(transportTime, movieTime, fromTransportTime, steps)
        val query = Query()
        return points.map { query.integrate(it, fromTransportTime, transportTime, movieTime, steps, integrator) }
    }

fun flowMap(at: Vec3, transportTime: Double = 1.0, movieTime: Double = 0.0, steps: Int = 32): Vec3

Single-point flow map, with the same semantics as transport.

    fun flowMap(at: Vec3, transportTime: Double = 1.0, movieTime: Double = 0.0, steps: Int = 32): Vec3 =
        transport(listOf(at), transportTime, movieTime, steps).single()

fun pathsFrom

Traces supplied geometry in transport time, one FLine per input point.

    fun pathsFrom(
        points: List<Vec3>,
        transportTime: Double = 1.0,
        movieTime: Double = 0.0,
        steps: Int = 32,
        fromTransportTime: Double = 0.0,
        integrator: FlowIntegrator = FlowIntegrator.MIDPOINT,
    ): List<FLine> {
        validateTimes(transportTime, movieTime, fromTransportTime, steps)
        val query = Query()
        return points.map { point ->
            val line = FLine().moveTo(point)
            query.integrate(point, fromTransportTime, transportTime, movieTime, steps, integrator) { line.lineTo(it) }
            line
        }
    }

fun sourceParticles(count: Int, seed: Long = 1L, grid: Boolean = false): List<Vec3>

Draw once and retain the returned list for persistent movie identity.

    fun sourceParticles(count: Int, seed: Long = 1L, grid: Boolean = false): List<Vec3> {
        require(count >= 0) { "particle count must be nonnegative" }
        val random = Random(seed)
        val side = ceil(sqrt(count.toDouble())).toInt().coerceAtLeast(1)
        return List(count) { i ->
            val x = if (grid) ((i % side) + .5) * 2 / side - 1 else random.nextDouble(-1.0, 1.0)
            val y = if (grid) ((i / side) + .5) * 2 / side - 1 else random.nextDouble(-1.0, 1.0)
            vec(x * data.sourceHalfExtent[0], y * data.sourceHalfExtent[1], 0.0)
        }
    }

fun unsafeBlendWeights(other: StippleTransport, amount: Double): StippleTransport

Deliberately unsafe weight interpolation; matching metadata does not align hidden neurons. Prefer conditioning one shared model on movie time. Sequential fine-tuning gives checkpoints shared ancestry but still supplies no guarantee. amount is not clamped.

body — 58 lines, from line 200
    fun unsafeBlendWeights(other: StippleTransport, amount: Double): StippleTransport {
        require(amount.isFinite()) { "blend amount must be finite" }
        require(data.widths == other.data.widths && data.temporal == other.data.temporal &&
            data.frameTimes == other.data.frameTimes && data.sourceHalfExtent == other.data.sourceHalfExtent) {
            "weight interpolation requires matching architecture, times and coordinate domain"
        }
        return StippleTransport(data.copy(weights = weights.indices.map { weights[it] * (1 - amount) + other.weights[it] * amount }))
    }

    private fun validateTimes(t: Double, tau: Double, from: Double, steps: Int) {
        require(steps > 0) { "integration steps must be positive" }
        require(t.isFinite() && tau.isFinite() && from.isFinite()) { "times must be finite" }
    }

    private inner class Query {
        private val a = DoubleArray(scratchWidth)
        private val b = DoubleArray(scratchWidth)

        fun evaluate(x: Double, y: Double, t: Double, tau: Double): DoubleArray {
            require(x.isFinite() && y.isFinite()) { "positions must be finite" }
            a[0] = x; a[1] = y; a[2] = t
            if (temporal) a[3] = tau
            var input = a
            var output = b
            for (layer in offsets.indices) {
                val n = widths[layer + 1]
                val p = widths[layer]
                val offset = offsets[layer]
                for (o in 0 until n) {
                    var sum = weights[offset + n * p + o]
                    for (i in 0 until p) sum += weights[offset + o * p + i] * input[i]
                    output[o] = if (layer == offsets.lastIndex) sum else sum / (1 + exp(-sum))
                }
                val swap = input; input = output; output = swap
            }
            return input
        }

        fun integrate(point: Vec3, from: Double, to: Double, tau: Double, steps: Int,
                      integrator: FlowIntegrator, vertex: ((Vec3) -> Unit)? = null): Vec3 {
            var x = point.x.toDouble()
            var y = point.y.toDouble()
            val h = (to - from) / steps
            for (step in 0 until steps) {
                val t = from + h * step
                val v = evaluate(x, y, t, tau)
                val vx = v[0]; val vy = v[1]
                if (integrator == FlowIntegrator.EULER) {
                    x += h * vx; y += h * vy
                } else {
                    val mid = evaluate(x + .5 * h * vx, y + .5 * h * vy, t + .5 * h, tau)
                    x += h * mid[0]; y += h * mid[1]
                }
                vertex?.invoke(vec(x, y, point.z.toDouble()))
            }
            return vec(x, y, point.z.toDouble())
        }
    }

companion object

    companion object {
        private val lenientJson = Json { ignoreUnknownKeys = true }

fun fromJson(json: String): StippleTransport

Parses a portable model without requiring a filesystem path.

        fun fromJson(json: String): StippleTransport =
            StippleTransport(lenientJson.decodeFromString<StippleTransportData>(json))
    }
}

Wire representation of the optional trainer's row-major SiLU network export.

@Serializable
private data class StippleTransportData(
    val format: String,
    val temporal: Boolean,
    val activation: String,
    val coordinates: String,
    val sourceHalfExtent: List<Double>,
    val frameTimes: List<Double>,
    val widths: List<Int>,
    val weights: List<Double>,
)