Skip to content

Polyvector.kt

src/jvmMain/kotlin/afield/geometry/vectorize/polyvector/Polyvector.kt · 312 lines

package afield.geometry.vectorize.polyvector

import afield.geometry.network.Channel
import afield.geometry.network.GeometryCurve
import afield.geometry.network.GeometryNetwork
import afield.geometry.network.GeometryNodes
import afield.geometry.path.FLine
import afield.math.linalg.Vec2
import afield.math.linalg.Vec4
import afield.media.image.Img
import afield.media.image.luma
import afield.runtime.lifecycle.ReleasePolicy
import afield.runtime.lifecycle.RunScope
import java.lang.foreign.MemorySegment
import java.lang.ref.Cleaner
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
import kotlin.math.hypot

Turning a raster line drawing into curves, by the polyvector-field method of Bessmeltsev and Solomon (2019).

The problem this solves is the junction: where two strokes cross, a tracer that follows ink alone has no way to tell which outgoing branch continues which incoming one. A PolyvectorField gives every ink pixel two unsigned directions rather than one, so a crossing is a pixel where both strokes are represented and each can be followed through it. Polyvector is the entry point: Polyvector.fitField solves for the field, PolyvectorField.trace reads curves off it as a PolyvectorDrawing, and Polyvector.vectorize does both for a dark-on-light scan.

Fitting the field is the expensive step and tracing is not, which is why the controls come in two parts: FrameFieldParameters for the field, TraceParameters for the trace. A drawing can be re-traced at several prune ratios without the field being solved again.

The input is evidence, 0 to 1, for how much each pixel is ink, not a picture: Polyvector.inkOf gets it from a dark-on-light drawing as 1 - luminance, and an edge or ridge detector's output can be passed to Polyvector.fitField instead. Coordinates throughout are pixels at pixel centres, x right and y down. The worked account, including which drawings the method suits, is public/docs/polyvector.md.

The solver is the optional native library field_polyvector (PolyvectorLibrary), so a field holds native memory and is closed by the box's next run.

data class FrameFieldParameters

Controls for building the frame field. inkThreshold is the evidence level (0..1) above which a pixel counts as ink. regularizer pushes the two directions towards a right angle (larger gives sharper junctions, smaller follows narrow junctions more closely). smoothness trades fidelity to the image's local direction for smoothness; raise it only for very noisy input. repairPasses fill single-pixel holes in the ink mask. The defaults are the authors'.

data class FrameFieldParameters(
    val inkThreshold: Double = 90.0 / 255.0,
    val regularizer: Double = 0.1,
    val smoothness: Double = 50.0,
    val repairPasses: Int = 3,
)

data class TraceParameters

Controls for tracing curves on a field. pruneRatio (0..1) is how readily short side branches are removed; maxLoopPixels is the largest hole, in background pixels, that is closed over rather than kept as a loop; simplifyTolerance simplifies the final polylines (pixels); smooth applies the authors' final smoothing. The defaults are the authors'.

data class TraceParameters(
    val pruneRatio: Double = 0.75,
    val maxLoopPixels: Int = 4,
    val simplifyTolerance: Double = 0.01,
    val smooth: Boolean = true,
)

class PolyvectorField internal constructor

The polyvector field of a line drawing: at every ink pixel, two unsigned directions that the lines through that pixel follow. Where one stroke runs, the two directions are the stroke's direction and one across it; where strokes cross, they are the two strokes' directions. Building the field is the expensive step; trace reads curves off it, and can be called again with different TraceParameters without rebuilding.

Holds native memory. One built while a box runs is closed when the box runs again.

class PolyvectorField internal constructor(
    val width: Int,
    val height: Int,
    private val evidence: FloatArray,
    val parameters: FrameFieldParameters,
    handle: MemorySegment,
) : AutoCloseable {
    private class Owner(var handle: MemorySegment?) : Runnable {
        override fun run() { synchronized(this) { handle?.let(PolyvectorNative::free); handle = null } }
    }

    private val owner = Owner(handle)
    private val cleanable = CLEANER.register(this, owner)
    private val maps = PolyvectorNative.maps(handle, width, height)

val mask: ByteArray get() = maps.mask

1 where the evidence counts as ink, 0 elsewhere; row-major.

val weight: FloatArray get() = maps.weight

Per pixel, the authors' confidence that one direction dominates there (0..1); row-major.

val directions: FloatArray get() = maps.directions

Per pixel, the two field directions as unit vectors (x0, y0, x1, y1); zero off the ink.

val singularities: List<Vec2>

Points where the field cannot be continued smoothly, in pixels.

    val singularities: List<Vec2> = List(maps.singularities.size / 2) { Vec2(maps.singularities[2 * it], maps.singularities[2 * it + 1]) }

val componentCount: Int get() = maps.components

Connected pieces of ink; each is vectorized on its own.

val fieldMs: Double get() = maps.fieldMs

Milliseconds spent building the field.

fun evidenceAt(x: Double, y: Double): Double

The evidence at (x, y) in pixels, bilinear, clamped.

    fun evidenceAt(x: Double, y: Double): Double {
        val cx = (x - 0.5).coerceIn(0.0, width - 1.0); val cy = (y - 0.5).coerceIn(0.0, height - 1.0)
        val x0 = cx.toInt().coerceAtMost(width - 2); val y0 = cy.toInt().coerceAtMost(height - 2)
        val fx = cx - x0; val fy = cy - y0
        fun e(i: Int, j: Int) = evidence[j * width + i].toDouble()
        return (e(x0, y0) * (1 - fx) + e(x0 + 1, y0) * fx) * (1 - fy) + (e(x0, y0 + 1) * (1 - fx) + e(x0 + 1, y0 + 1) * fx) * fy
    }

fun directionFLines(step: Int = 4, length: Double = 3.0, color0: Vec4 = Vec4(0.95f, 0.45f, 0.1f, 1f), color1: Vec4 = Vec4(0.15f, 0.5f, 0.95f, 1f), thickness: Do

The field drawn as small crosses, one every step pixels on the ink: each cross is the two directions there, length pixels long, the first in color0 and the second in color1, thickness wide.

    fun directionFLines(step: Int = 4, length: Double = 3.0, color0: Vec4 = Vec4(0.95f, 0.45f, 0.1f, 1f), color1: Vec4 = Vec4(0.15f, 0.5f, 0.95f, 1f), thickness: Double = 0.8): List<FLine> {
        val a = FLine(); val b = FLine()
        for (y in 0 until height step step) for (x in 0 until width step step) {
            val i = (y * width + x) * 4
            if (maps.mask[y * width + x].toInt() == 0) continue
            val cx = x + 0.5; val cy = y + 0.5
            for ((line, k) in listOf(a to 0, b to 2)) {
                val dx = maps.directions[i + k] * length / 2; val dy = maps.directions[i + k + 1] * length / 2
                line.moveTo(cx - dx, cy - dy).lineTo(cx + dx, cy + dy)
            }
        }
        a.properties["color"] = color0; a.properties["thickness"] = thickness
        b.properties["color"] = color1; b.properties["thickness"] = thickness
        return listOf(a, b)
    }

fun singularitiesFLine(pointSize: Double = 6.0, color: Vec4 = Vec4(1f, 0.1f, 0.6f, 1f)): FLine

The singular points as one FLine of dots.

    fun singularitiesFLine(pointSize: Double = 6.0, color: Vec4 = Vec4(1f, 0.1f, 0.6f, 1f)): FLine = FLine().also { f ->
        singularities.forEach { f.moveTo(it.x.toDouble(), it.y.toDouble()) }
        f.properties["pointed"] = true; f.properties["stroked"] = false; f.properties["pointSize"] = pointSize; f.properties["color"] = color
    }

fun trace(parameters: TraceParameters = TraceParameters()): PolyvectorDrawing

Traces curves on this field. Repeatable with different parameters; the field is not rebuilt.

    fun trace(parameters: TraceParameters = TraceParameters()): PolyvectorDrawing = synchronized(owner) {
        val h = owner.handle ?: throw IllegalStateException("this PolyvectorField has been closed")
        PolyvectorNative.trace(h, parameters)
        PolyvectorDrawing(this, parameters, PolyvectorNative.polylines(h, 0), PolyvectorNative.polylines(h, 1),
            PolyvectorNative.droppedPoints(h), PolyvectorNative.traceMs(h))
    }

val isClosed: Boolean get() = synchronized(owner) { owner.handle == null }

override fun close() = cleanable.clean()

override fun toString()

    override fun toString() = "PolyvectorField(${width}x$height, $componentCount components, ${singularities.size} singularities)"

companion object

    companion object {
        private val CLEANER: Cleaner = Cleaner.create()
    }
}

class PolyvectorDrawing internal constructor

A vectorized drawing: the curves traced on a field, and the streamlines they were built from. Coordinates are pixels at pixel centres, x right, y down.

class PolyvectorDrawing internal constructor(
    val field: PolyvectorField,
    val parameters: TraceParameters,
    streamlines: List<FloatArray>,
    curves: List<FloatArray>,
    /** Non-finite points the reference produced and that were removed by splitting curves there. */
    val droppedPoints: Int,
    val traceMs: Double,
) {

val curves: List<FloatArray> = curves

The final curves, each as x, y floats.

val streamlines: List<FloatArray> = streamlines

Streamlines traced along the field before any topology processing, each as x, y floats.

fun network(junctionTolerance: Double = 0.5): GeometryNetwork

The curves as a GeometryNetwork. A node is placed only where two or more curve ends coincide (within junctionTolerance pixels); other ends have none, and a curve that ends against another curve's side stays unjoined. Per-vertex sample evidence: the input's value under the curve. Curve attribute meanEvidence.

    fun network(junctionTolerance: Double = 0.5): GeometryNetwork {
        val ends = ArrayList<Pair<Double, Double>>()
        curves.forEach { c -> ends.add(c[0].toDouble() to c[1].toDouble()); ends.add(c[c.size - 2].toDouble() to c[c.size - 1].toDouble()) }
        val cluster = IntArray(ends.size) { -1 }
        val centres = ArrayList<Pair<Double, Double>>()
        val counts = ArrayList<Int>()
        for (i in ends.indices) {
            val found = centres.indexOfFirst { hypot(it.first - ends[i].first, it.second - ends[i].second) <= junctionTolerance }
            if (found >= 0) { cluster[i] = found; counts[found] = counts[found] + 1 } else { cluster[i] = centres.size; centres.add(ends[i]); counts.add(1) }
        }
        val shared = centres.indices.filter { counts[it] >= 2 }
        val idOf = shared.withIndex().associate { (k, c) -> c to k }
        val nodes = GeometryNodes(2, IntArray(shared.size) { it }, DoubleArray(shared.size * 2) { i -> centres[shared[i / 2]].let { if (i % 2 == 0) it.first else it.second } },
            mapOf("degree" to Channel.scalar(DoubleArray(shared.size) { counts[shared[it]].toDouble() })))
        val geometry = curves.mapIndexed { i, c ->
            val n = c.size / 2
            val ev = DoubleArray(n) { field.evidenceAt(c[2 * it].toDouble(), c[2 * it + 1].toDouble()) }
            GeometryCurve(i, 2, DoubleArray(c.size) { c[it].toDouble() }, idOf[cluster[2 * i]], idOf[cluster[2 * i + 1]],
                attributes = mapOf("meanEvidence" to ev.average()), samples = mapOf("evidence" to Channel.scalar(ev)))
        }
        return GeometryNetwork(2, nodes, geometry, mapOf(
            "source" to "polyvector", "units" to "pixels", "library" to PolyvectorNative.version(),
            "pruneRatio" to parameters.pruneRatio.toString(), "inkThreshold" to field.parameters.inkThreshold.toString(),
        ))
    }

val meanStrokeWidth: Double by lazy

Ink area divided by total curve length: the drawing's mean stroke width, in pixels.

    val meanStrokeWidth: Double by lazy {
        val ink = field.mask.count { it.toInt() != 0 }
        val length = curves.sumOf { c -> (1 until c.size / 2).sumOf { k -> hypot((c[2 * k] - c[2 * k - 2]).toDouble(), (c[2 * k + 1] - c[2 * k - 1]).toDouble()) } }
        if (length > 0) ink / length else 0.0
    }

fun coverage(radius: Double = meanStrokeWidth / 2 + 1.5): Double

How well the curves explain the ink: the share of ink pixels within radius pixels of a curve. The default radius is half the meanStrokeWidth plus a pixel and a half, so a stroke's full width counts as explained. 1 means every inked pixel is accounted for; a dropped stroke shows as a lower number.

    fun coverage(radius: Double = meanStrokeWidth / 2 + 1.5): Double {
        val w = field.width; val h = field.height
        val near = BooleanArray(w * h)
        val r = radius
        curves.forEach { c ->
            for (k in 0 until c.size / 2 - 1) {
                val ax = c[2 * k]; val ay = c[2 * k + 1]; val bx = c[2 * k + 2]; val by = c[2 * k + 3]
                val x0 = (minOf(ax, bx) - r).toInt().coerceAtLeast(0); val x1 = (maxOf(ax, bx) + r).toInt().coerceAtMost(w - 1)
                val y0 = (minOf(ay, by) - r).toInt().coerceAtLeast(0); val y1 = (maxOf(ay, by) + r).toInt().coerceAtMost(h - 1)
                val dx = bx - ax; val dy = by - ay; val l2 = dx * dx + dy * dy
                for (y in y0..y1) for (x in x0..x1) {
                    val px = x + 0.5f - ax; val py = y + 0.5f - ay
                    val t = if (l2 > 0) ((px * dx + py * dy) / l2).coerceIn(0f, 1f) else 0f
                    if (hypot((px - t * dx).toDouble(), (py - t * dy).toDouble()) <= r) near[y * w + x] = true
                }
            }
        }
        var ink = 0; var covered = 0
        for (i in near.indices) if (field.mask[i].toInt() != 0) { ink++; if (near[i]) covered++ }
        return if (ink == 0) 1.0 else covered.toDouble() / ink
    }

fun toFLines(smooth: Boolean = false, color: Vec4 = Vec4(0.85f, 0.15f, 0.1f, 1f), thickness: Double = 1.5): List<FLine>

The curves as FLines, each carrying curveId, startNode, endNode and geometryNetwork.

    fun toFLines(smooth: Boolean = false, color: Vec4 = Vec4(0.85f, 0.15f, 0.1f, 1f), thickness: Double = 1.5): List<FLine> {
        val net = network()
        return net.toFLines(smooth).onEach { it.properties["color"] = color; it.properties["thickness"] = thickness; it.properties["geometryNetwork"] = net }
    }

fun streamlinesFLine(color: Vec4 = Vec4(0.2f, 0.5f, 0.9f, 0.35f), thickness: Double = 0.6): FLine

The streamlines as one FLine, for seeing what the topology stage started from.

    fun streamlinesFLine(color: Vec4 = Vec4(0.2f, 0.5f, 0.9f, 0.35f), thickness: Double = 0.6): FLine = FLine().also { f ->
        streamlines.forEach { s -> if (s.size >= 4) { f.moveTo(s[0], s[1], 0); for (k in 1 until s.size / 2) f.lineTo(s[2 * k], s[2 * k + 1], 0) } }
        f.properties["color"] = color; f.properties["thickness"] = thickness
    }

override fun toString()

    override fun toString() = "PolyvectorDrawing(${curves.size} curves from ${streamlines.size} streamlines)"
}

object Polyvector

Vectorization of line drawings by polyvector fields (Bessmeltsev & Solomon 2019): turns a raster drawing into smooth curves that follow its strokes through junctions and crossings. Needs the optional native library field_polyvector (see PolyvectorLibrary).

val drawing = Polyvector.vectorize(imgFromFile("sketch.png"))
display("vectors", drawing.toFLines())

The input is evidence: how much each pixel is ink, 0 to 1. A dark-on-light drawing is 1 - luminance, which vectorize computes; fitField takes evidence directly, so an edge or ridge detector's output can be used instead.

object Polyvector {
    private val workers = Executors.newSingleThreadExecutor { r -> Thread(r, "Field-polyvector").apply { isDaemon = true } }

val isAvailable: Boolean get() = PolyvectorLibrary.isAvailable

fun fitField(evidence: FloatArray, width: Int, height: Int, parameters: FrameFieldParameters = FrameFieldParameters()): PolyvectorField

Builds the field of evidence (row-major, width × height, 1 = ink).

    fun fitField(evidence: FloatArray, width: Int, height: Int, parameters: FrameFieldParameters = FrameFieldParameters()): PolyvectorField {
        require(evidence.size == width * height) { "evidence has ${evidence.size} values for a ${width}x$height image" }
        val f = PolyvectorField(width, height, evidence.copyOf(), parameters, PolyvectorNative.field(evidence, width, height, parameters))
        RunScope.current()?.own(f, ReleasePolicy.ACCUMULATING)
        return f
    }

fun fitField(evidence: Img, parameters: FrameFieldParameters = FrameFieldParameters()): PolyvectorField

Builds the field of an evidence image (channel 0, 1 = ink).

    fun fitField(evidence: Img, parameters: FrameFieldParameters = FrameFieldParameters()): PolyvectorField =
        fitField(FloatArray(evidence.width * evidence.height) { evidence.data[it * evidence.channels] }, evidence.width, evidence.height, parameters)

fun inkOf(drawing: Img): Img

The evidence of a dark-on-light drawing: 1 - luminance.

    fun inkOf(drawing: Img): Img = drawing.luma().let { l -> Img(l.width, l.height, 1, FloatArray(l.data.size) { 1f - l.data[it] }) }

fun vectorize(drawing: Img, field: FrameFieldParameters = FrameFieldParameters(), trace: TraceParameters = TraceParameters()): PolyvectorDrawing

Field and trace in one call, for a dark-on-light drawing.

    fun vectorize(drawing: Img, field: FrameFieldParameters = FrameFieldParameters(), trace: TraceParameters = TraceParameters()): PolyvectorDrawing =
        fitField(inkOf(drawing), field).trace(trace)

fun vectorizeAsync(drawing: Img, field: FrameFieldParameters = FrameFieldParameters(), trace: TraceParameters = TraceParameters()): CompletableFuture<Polyvector

vectorize on a worker thread, for runAsync.

    fun vectorizeAsync(drawing: Img, field: FrameFieldParameters = FrameFieldParameters(), trace: TraceParameters = TraceParameters()): CompletableFuture<PolyvectorDrawing> =
        CompletableFuture.supplyAsync({ vectorize(drawing, field, trace) }, workers)
}