Skip to content

PolyvectorRequiredGraph.kt

src/jvmMain/kotlin/afield/geometry/vectorize/polyvector/PolyvectorRequiredGraph.kt · 231 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.media.image.Img
import kotlin.math.exp
import kotlin.math.hypot

Polyvector tracing under a graph the result has to reproduce: a Polyvector.vectorize overload that takes the curves the trace must keep, and a RequiredGraphDrawing saying which of them it kept.

The required graph is evidence rather than a hard constraint. Its curves are drawn into the field the tracer fits, so strokes follow them; traced ends landing within the snap tolerance of a required node are then moved onto that node and take its id, and a required node no traced end reached adopts a nearby traced junction instead. A connection can still be lost, which is what RequiredGraphDrawing.missingCurves and RequiredGraphDrawing.missingNodes report and RequiredGraphDrawing.satisfied answers in one call.

The graph usually comes from another reading of the same image, a Morse–Smale complex's network say, so that the traced curves share its nodes and their ids.

class RequiredGraphDrawing internal constructor

A PolyvectorDrawing traced under a required graph, and how far its connectivity keeps that graph's. network is the traced curves with every end within the snap tolerance of a required node moved onto that node and given its id; other shared ends become new nodes, numbered after the required ones. A required curve between nodes a and b is kept when some chain of traced curves joins a to b without passing through another required node.

The constraint is soft: the required curves are added to the evidence and junctions are snapped afterwards, so tracing can still lose a connection. missingCurves and missingNodes say where it did; satisfied is true when nothing is missing.

class RequiredGraphDrawing internal constructor(
    val drawing: PolyvectorDrawing,
    val required: GeometryNetwork,
    val network: GeometryNetwork,
    /** Ids of the required curves (between two nodes) that no chain of traced curves reproduces. */
    val missingCurves: List<Int>,
    /** Ids of required nodes (those a required curve ends at) that no traced curve end reached. */
    val missingNodes: List<Int>,
) {

val satisfied: Boolean get() = missingCurves.isEmpty() && missingNodes.isEmpty()

override fun toString()

    override fun toString() =
        "RequiredGraphDrawing(${network.curves.size} curves; ${missingCurves.size} of ${required.curves.count { it.startNode != null && it.endNode != null }} required curves missing, ${missingNodes.size} nodes missing)"
}

fun Polyvector.vectorize

Polyvector.vectorize for a drawing whose connectivity must agree with required, a 2-D GeometryNetwork in the drawing's pixel coordinates (a Morse–Smale complex's network, say).

val traced = Polyvector.vectorize(drawing, required = complex.network())
if (!traced.satisfied) println("lost ${traced.missingCurves}")
display("vectors", traced.network.toFLines())

The required curves are drawn into the evidence at seed (0..1, a soft line about three pixels wide) so the field follows them, then traced ends within snapTolerance pixels of a required node are joined there. Tracing pulls back from the end of a stroke, so a required node that no end reached takes the traced junction (the one with most curve ends, then the nearest) lying on one of its required curves within reach pixels, and the curves ending there are extended to it.

fun Polyvector.vectorize(
    drawing: Img,
    required: GeometryNetwork,
    seed: Double = 0.6,
    snapTolerance: Double = 3.0,
    reach: Double = 12.0,
    field: FrameFieldParameters = FrameFieldParameters(),
    trace: TraceParameters = TraceParameters(),
): RequiredGraphDrawing {
    require(required.dimension == 2) { "the required graph must be 2-D (pixel coordinates); it has dimension ${required.dimension}" }
    val ink = inkOf(drawing)
    val w = ink.width; val h = ink.height
    val evidence = FloatArray(w * h) { ink.data[it] }
    if (seed > 0) required.curves.forEach { c ->
        for (k in 0 until c.vertexCount - 1) {
            val a = c.vertex(k); val b = c.vertex(k + 1)
            val n = maxOf(1, (hypot((b.x - a.x).toDouble(), (b.y - a.y).toDouble()) * 2).toInt())
            for (s in 0..n) {
                val cx = a.x + (b.x - a.x) * s / n; val cy = a.y + (b.y - a.y) * s / n
                for (y in (cy - 3).toInt()..(cy + 3).toInt()) for (x in (cx - 3).toInt()..(cx + 3).toInt()) {
                    if (x !in 0 until w || y !in 0 until h) continue
                    val d = hypot(x + 0.5 - cx, y + 0.5 - cy) / 1.5
                    evidence[y * w + x] = maxOf(evidence[y * w + x], (seed * exp(-d * d)).toFloat())
                }
            }
        }
    }
    val traced = fitField(evidence, w, h, field).trace(trace)
    val (network, missingCurves, missingNodes) = constrainToRequired(traced.curves, required, snapTolerance, reach, traced.network().metadata)
    return RequiredGraphDrawing(traced, required, network, missingCurves, missingNodes)
}

internal fun constrainToRequired

The snapping and connectivity check of vectorize with a required graph, on bare x, y curves.

body — 129 lines, from line 102
internal fun constrainToRequired(
    tracedCurves: List<FloatArray>,
    required: GeometryNetwork,
    snapTolerance: Double,
    reach: Double = 0.0,
    metadata: Map<String, String> = emptyMap(),
): Triple<GeometryNetwork, List<Int>, List<Int>> {
    val rn = required.nodes
    // Only nodes a required curve ends at constrain anything; a network can carry others (minima, say).
    val requiredIds = required.curves.flatMap { listOfNotNull(it.startNode, it.endNode) }.toSet()
    val candidates = (0 until rn.size).filter { rn.ids[it] in requiredIds }

    // A stroke traced straight through a required node is cut there, so the node can join it.
    val tracedCurves = tracedCurves.flatMap { c ->
        val n = c.size / 2
        val cuts = ArrayList<Pair<Double, Int>>() // position along the polyline (segment + t), node index
        for (i in candidates) {
            val p = rn.position(i)
            var best = Double.MAX_VALUE; var at = -1.0
            for (k in 0 until n - 1) {
                val ax = c[2 * k].toDouble(); val ay = c[2 * k + 1].toDouble()
                val dx = c[2 * k + 2] - ax; val dy = c[2 * k + 3] - ay; val l2 = dx * dx + dy * dy
                val t = if (l2 > 0) (((p.x - ax) * dx + (p.y - ay) * dy) / l2).coerceIn(0.0, 1.0) else 0.0
                val d = hypot(p.x - ax - t * dx, p.y - ay - t * dy)
                if (d < best) { best = d; at = k + t }
            }
            val fromStart = hypot(p.x - c[0], p.y - c[1]); val fromEnd = hypot(p.x - c[2 * n - 2], p.y - c[2 * n - 1])
            if (best <= snapTolerance && fromStart > snapTolerance && fromEnd > snapTolerance) cuts.add(at to i)
        }
        if (cuts.isEmpty()) return@flatMap listOf(c)
        cuts.sortBy { it.first }
        val pieces = ArrayList<FloatArray>()
        var piece = arrayListOf(c[0], c[1]); var k = 0
        for ((at, i) in cuts) {
            while (k + 1 <= at.toInt() && k + 1 < n) { k++; piece.add(c[2 * k]); piece.add(c[2 * k + 1]) }
            val p = rn.position(i)
            piece.add(p.x); piece.add(p.y)
            pieces.add(piece.toFloatArray())
            piece = arrayListOf(p.x, p.y)
        }
        while (k + 1 < n) { k++; piece.add(c[2 * k]); piece.add(c[2 * k + 1]) }
        pieces.add(piece.toFloatArray())
        pieces.filter { it.size >= 4 }
    }
    var nextId = (rn.ids.maxOrNull() ?: -1) + 1
    val free = ArrayList<Triple<Double, Double, Int>>() // x, y, id of ends not on a required node
    val freeCount = HashMap<Int, Int>()

    fun nodeFor(x: Double, y: Double): Int {
        var best = -1; var bestD = snapTolerance
        for (i in candidates) {
            val p = rn.position(i)
            val d = hypot(p.x - x, p.y - y)
            if (d <= bestD) { best = i; bestD = d }
        }
        if (best >= 0) return rn.ids[best]
        free.firstOrNull { hypot(it.first - x, it.second - y) <= 0.5 }?.let { freeCount[it.third] = freeCount[it.third]!! + 1; return it.third }
        val id = nextId++
        free.add(Triple(x, y, id)); freeCount[id] = 1
        return id
    }

    var ends = tracedCurves.map { c -> nodeFor(c[0].toDouble(), c[1].toDouble()) to nodeFor(c[c.size - 2].toDouble(), c[c.size - 1].toDouble()) }
    val snapped = ends.flatMap { listOf(it.first, it.second) }.filter { it in requiredIds }.toSet()

    // A required node nothing snapped to takes the best traced junction on one of its curves within reach.
    fun distanceToCurve(c: GeometryCurve, x: Double, y: Double): Double = (0 until c.vertexCount - 1).minOfOrNull { k ->
        val a = c.vertex(k); val b = c.vertex(k + 1)
        val dx = (b.x - a.x).toDouble(); val dy = (b.y - a.y).toDouble(); val l2 = dx * dx + dy * dy
        val t = if (l2 > 0) (((x - a.x) * dx + (y - a.y) * dy) / l2).coerceIn(0.0, 1.0) else 0.0
        hypot(x - a.x - t * dx, y - a.y - t * dy)
    } ?: Double.MAX_VALUE
    val pulledIn = HashMap<Int, Int>()
    if (reach > 0) for (i in candidates) {
        val id = rn.ids[i]
        if (id in snapped) continue
        val p = rn.position(i)
        val along = required.curvesAt(id)
        free.filter { (x, y, f) -> f !in pulledIn && hypot(p.x - x, p.y - y) <= reach && along.any { distanceToCurve(it, x, y) <= snapTolerance } }
            .minWithOrNull(compareBy({ -(freeCount[it.third] ?: 0) }, { hypot(p.x - it.first, p.y - it.second) }))
            ?.let { pulledIn[it.third] = id }
    }
    if (pulledIn.isNotEmpty()) ends = ends.map { (s, e) -> (pulledIn[s] ?: s) to (pulledIn[e] ?: e) }
    val reached = ends.flatMap { listOf(it.first, it.second) }.filter { it in requiredIds }.toSet()
    // Unshared free ends carry no node, as in PolyvectorDrawing.network.
    fun keep(id: Int) = id in requiredIds || (freeCount[id] ?: 0) >= 2
    val usedRequired = (0 until rn.size).filter { rn.ids[it] in reached }
    val usedFree = free.filter { it.third !in pulledIn && keep(it.third) }
    val nodes = GeometryNodes(
        2,
        IntArray(usedRequired.size + usedFree.size) { if (it < usedRequired.size) rn.ids[usedRequired[it]] else usedFree[it - usedRequired.size].third },
        DoubleArray((usedRequired.size + usedFree.size) * 2) { i ->
            val k = i / 2
            if (k < usedRequired.size) rn.position(usedRequired[k]).let { if (i % 2 == 0) it.x.toDouble() else it.y.toDouble() }
            else usedFree[k - usedRequired.size].let { if (i % 2 == 0) it.first else it.second }
        },
        mapOf("required" to Channel.scalar(DoubleArray(usedRequired.size + usedFree.size) { if (it < usedRequired.size) 1.0 else 0.0 })),
    )
    val curves = tracedCurves.mapIndexed { i, c ->
        val (s, e) = ends[i]
        var xy = DoubleArray(c.size) { c[it].toDouble() }
        // Snapped ends move onto their node; pulled-in ends are extended to it.
        fun onto(id: Int, atStart: Boolean) {
            val p = rn.position(rn.indexOf(id)!!)
            val k = if (atStart) 0 else xy.size - 2
            if (hypot(xy[k] - p.x, xy[k + 1] - p.y) <= snapTolerance) { xy[k] = p.x.toDouble(); xy[k + 1] = p.y.toDouble() }
            else xy = if (atStart) doubleArrayOf(p.x.toDouble(), p.y.toDouble()) + xy else xy + doubleArrayOf(p.x.toDouble(), p.y.toDouble())
        }
        if (s in requiredIds) onto(s, true)
        if (e in requiredIds) onto(e, false)
        GeometryCurve(i, 2, xy, s.takeIf(::keep), e.takeIf(::keep))
    }
    val network = GeometryNetwork(2, nodes, curves, metadata + mapOf("requiredGraph" to (required.metadata["source"] ?: "given"), "snapTolerance" to snapTolerance.toString(), "reach" to reach.toString()))

    // Required a–b is kept when a walk over traced curves reaches b from a through free nodes only.
    val adjacency = HashMap<Int, MutableSet<Int>>()
    curves.forEach { c -> val s = c.startNode; val e = c.endNode; if (s != null && e != null) { adjacency.getOrPut(s) { HashSet() }.add(e); adjacency.getOrPut(e) { HashSet() }.add(s) } }
    fun joined(a: Int, b: Int): Boolean {
        val seen = hashSetOf(a); val queue = ArrayDeque(listOf(a))
        while (queue.isNotEmpty()) for (n in adjacency[queue.removeFirst()].orEmpty()) {
            if (n == b) return true
            if (n !in requiredIds && seen.add(n)) queue.add(n)
        }
        return false
    }
    val missingCurves = required.curves.filter { c -> val s = c.startNode; val e = c.endNode; s != null && e != null && !joined(s, e) }.map { it.id }
    val missingNodes = candidates.map { rn.ids[it] }.filter { it !in reached }
    return Triple(network, missingCurves, missingNodes)
}