Skip to content

corpora.kt

src/jvmMain/kotlin/afield/ai/learning/pen/corpora.kt · 761 lines

Synthetic drawings to teach a learned pen: Hershey plotter fonts and four drawing generators, and a writer that turns a list of drawings into a corpus python/fieldpen can train on.

Every generator draws in the order a hand (or a plotter) would, which is the one thing a pen needs from its training data and the one thing a scanned drawing cannot supply. PenCorpus resamples each drawing exactly the way Pen.feed reads a line, so a pen trained on a corpus sees the same spacing when it is later shown a line. Tests: PenCorporaTest.

package afield.ai.learning.pen

import afield.geometry.path.FLine
import afield.math.linalg.Vec3
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.exp
import kotlin.math.floor
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.sin
import kotlin.random.Random

class HersheyFont private constructor(val name: String, private val glyphs: List<Glyph>)

One of the Hershey fonts: single-stroke vector lettering from 1967, drawn as a pen plotter draws it, stroke by stroke.

lines(HersheyFont.load("scripts").text("a learned pen", size = 24.0))

The fonts were created by Dr. A. V. Hershey at the U. S. National Bureau of Standards; the data format is James Hurt's (Cognition, Inc.). The acknowledgement travels with the data in afield/pen/hershey/HERSHEY-LICENSE.

class HersheyFont private constructor(val name: String, private val glyphs: List<Glyph>) {

    private class Glyph(val left: Int, val right: Int, val strokes: List<List<IntArray>>)

fun strokes(c: Char): List<List<IntArray>>? = glyphs.getOrNull(c.code - 32)?.strokes

The strokes of c in font units (y down, about 32 units from ascender to descender), or null.

fun text(text: String, size: Double = 24.0, at: Vec3 = Vec3(0f, 0f, 0f)): FLine

text as one FLine, a subpath per stroke, starting at at. size is the height of a capital in drawing units.

    fun text(text: String, size: Double = 24.0, at: Vec3 = Vec3(0f, 0f, 0f)): FLine {
        val k = size / 21.0
        val out = FLine()
        var pen = at.x.toDouble()
        for (c in text) {
            val g = glyphs.getOrNull(c.code - 32) ?: continue
            val ox = pen - g.left * k
            for (stroke in g.strokes) {
                stroke.forEachIndexed { i, p ->
                    val x = ox + p[0] * k
                    val y = at.y + p[1] * k
                    if (i == 0) out.moveTo(x, y) else out.lineTo(x, y)
                }
            }
            pen += (g.right - g.left) * k
        }
        return out
    }

companion object

    companion object {

val names

The fonts that ship with Field.

        val names = listOf("futural", "rowmans", "scripts", "scriptc", "cursive", "timesr", "timesi", "gothiceng", "greek")

        private val cache = ConcurrentHashMap<String, HersheyFont>()

fun load(name: String): HersheyFont

Load one of names.

        fun load(name: String): HersheyFont = cache.getOrPut(name) {
            val text = HersheyFont::class.java.getResourceAsStream("/afield/pen/hershey/$name.jhf")
                ?.bufferedReader()?.readText() ?: error("no Hershey font called \"$name\"; try one of $names")
            HersheyFont(name, text.lines().filter { it.length >= 10 }.map(::parseGlyph))
        }

A .jhf line: 5 characters of id, 3 of vertex count, then one pair of letters per vertex, each coordinate offset from 'R'. The first pair is the glyph's left and right; " R" lifts.

        private fun parseGlyph(line: String): Glyph {
            val pairs = (8 until line.length - 1 step 2).map { line[it] to line[it + 1] }
            val strokes = ArrayList<List<IntArray>>()
            var current = ArrayList<IntArray>()
            for ((a, b) in pairs.drop(1)) {
                if (a == ' ' && b == 'R') {
                    if (current.isNotEmpty()) strokes += current
                    current = ArrayList()
                } else current += intArrayOf(a - 'R', b - 'R')
            }
            if (current.isNotEmpty()) strokes += current
            return Glyph(pairs[0].first - 'R', pairs[0].second - 'R', strokes)
        }
    }
}

object PenDrawings

Generators of ordered drawings for training a pen. Each takes a seed and returns one drawing at a size that suits the default scale of 8 drawing units per model unit.

object PenDrawings {

    private val vowels = "aeiou"
    private val consonants = "bcdfghklmnprstvwy"

fun word(random: Random, length: Int = random.nextInt(2, 8)): String

A made-up word of length letters, alternating consonants and vowels often enough to look like one.

    fun word(random: Random, length: Int = random.nextInt(2, 8)): String = buildString {
        repeat(length) { i ->
            val vowel = if (i % 2 == 1) random.nextDouble() < 0.8 else random.nextDouble() < 0.25
            append(if (vowel) vowels.random(random) else consonants.random(random))
        }
    }

fun hershey(seed: Long, font: String = "scripts", words: Int = 5, size: Double = 22.0): FLine

A line of made-up words in a Hershey font: words words, capitals size units tall.

    fun hershey(seed: Long, font: String = "scripts", words: Int = 5, size: Double = 22.0): FLine {
        val r = Random(seed)
        val text = (0 until words).joinToString(" ") { word(r) }.replaceFirstChar { if (r.nextBoolean()) it.uppercaseChar() else it }
        return HersheyFont.load(font).text(text, size)
    }

fun harmonograph(seed: Long, radius: Double = 70.0, turns: Double = 14.0): FLine

A harmonograph: two damped pendulums per axis, summed, drawn as one long decaying stroke. The frequencies sit near small whole-number ratios, which is what makes the figure close up.

    fun harmonograph(seed: Long, radius: Double = 70.0, turns: Double = 14.0): FLine {
        val r = Random(seed)
        val ratios = listOf(1.0 to 2.0, 2.0 to 3.0, 3.0 to 4.0, 1.0 to 3.0, 3.0 to 5.0)
        val (fa, fb) = ratios[r.nextInt(ratios.size)]
        val detune = { 1 + (r.nextDouble() - 0.5) * 0.02 }
        val f = listOf(fa * detune(), fb * detune(), fb * detune(), fa * detune())
        val phase = DoubleArray(4) { r.nextDouble() * 2 * PI }
        val damp = 0.04 + r.nextDouble() * 0.05
        val out = FLine()
        val n = (turns * 60).toInt()
        for (i in 0..n) {
            val t = i * 2 * PI / 60
            val e = exp(-damp * t)
            val x = radius * 0.5 * e * (sin(f[0] * t + phase[0]) + sin(f[1] * t + phase[1]))
            val y = radius * 0.5 * e * (sin(f[2] * t + phase[2]) + sin(f[3] * t + phase[3]))
            if (i == 0) out.moveTo(x, y) else out.lineTo(x, y)
        }
        return out
    }

fun plant(seed: Long, generations: Int = 4, height: Double = 140.0): FLine

A branching plant from an L-system, drawn by a turtle in one unbroken stroke: each branch is drawn out to its tip and back down to its fork, the way a hand draws a twig.

Lifting the pen at a tip and jumping back to the fork is the obvious order, and a pen cannot learn it: it sees only displacements, so the jump means remembering exactly where the fork was, and a pen trained that way climbs forever and never branches.

    fun plant(seed: Long, generations: Int = 4, height: Double = 140.0): FLine {
        val r = Random(seed)
        var s = "X"
        repeat(generations) {
            s = buildString {
                for (c in s) append(when (c) {
                    'X' -> if (r.nextBoolean()) "F[+X]F[-X]+X" else "F[-X][+X]FX"
                    'F' -> "FF"
                    else -> c.toString()
                })
            }
        }
        val step = height / (1 shl generations) / 2.2
        val turn = (20.0 + r.nextDouble() * 12.0) * PI / 180
        val out = FLine().moveTo(0, 0)
        var x = 0.0
        var y = 0.0
        var a = -PI / 2
        // each open branch keeps the points drawn since its fork, to walk back down them at ']'
        val stack = ArrayDeque<Pair<Double, MutableList<Pair<Double, Double>>>>()
        val path = ArrayDeque<MutableList<Pair<Double, Double>>>().apply { addLast(ArrayList()) }
        for (c in s) when (c) {
            'F' -> {
                val jitter = 1 + (r.nextDouble() - 0.5) * 0.3
                path.last() += x to y
                x += cos(a) * step * jitter; y += sin(a) * step * jitter
                out.lineTo(x, y)
            }
            '+' -> a += turn * (0.8 + r.nextDouble() * 0.4)
            '-' -> a -= turn * (0.8 + r.nextDouble() * 0.4)
            '[' -> { stack.addLast(a to ArrayList()); path.addLast(ArrayList()) }
            ']' -> {
                val back = path.removeLast()
                for ((px, py) in back.asReversed()) out.lineTo(px, py)
                if (back.isNotEmpty()) { x = back[0].first; y = back[0].second }
                a = stack.removeLast().first
            }
        }
        return out
    }

fun truchet(seed: Long, columns: Int = 9, rows: Int = 5, cell: Double = 18.0): FLine

Truchet tiles: a grid of squares, each holding two quarter-circle arcs one way or the other, whose arcs join into long winding curves. Each curve is one stroke, traced end to end.

body — 52 lines, from line 202
    fun truchet(seed: Long, columns: Int = 9, rows: Int = 5, cell: Double = 18.0): FLine {
        val r = Random(seed)
        val tile = Array(rows) { BooleanArray(columns) { r.nextBoolean() } }
        // Edge midpoints: side 0 top, 1 right, 2 bottom, 3 left. A tile joins top-left and
        // bottom-right (false) or top-right and bottom-left (true).
        fun partner(flip: Boolean, side: Int) = if (!flip) intArrayOf(3, 2, 1, 0)[side] else intArrayOf(1, 0, 3, 2)[side]
        val used = HashSet<Triple<Int, Int, Int>>()
        val out = FLine()
        fun mid(cx: Int, cy: Int, side: Int): Pair<Double, Double> = when (side) {
            0 -> (cx + 0.5) * cell to cy * cell
            1 -> (cx + 1.0) * cell to (cy + 0.5) * cell
            2 -> (cx + 0.5) * cell to (cy + 1.0) * cell
            else -> cx * cell to (cy + 0.5) * cell
        }
        fun corner(side: Int, other: Int): Pair<Int, Int> {
            val s = setOf(side, other)
            return (if (1 in s) 1 else 0) to (if (2 in s) 1 else 0)
        }
        for (sy in 0 until rows) for (sx in 0 until columns) for (startSide in 0..3) {
            if (Triple(sx, sy, startSide) in used) continue
            var cx = sx
            var cy = sy
            var side = startSide
            var first = true
            while (cx in 0 until columns && cy in 0 until rows && Triple(cx, cy, side) !in used) {
                val other = partner(tile[cy][cx], side)
                used += Triple(cx, cy, side); used += Triple(cx, cy, other)
                val (ccx, ccy) = corner(side, other)
                val ox = (cx + ccx) * cell
                val oy = (cy + ccy) * cell
                val (ax, ay) = mid(cx, cy, side)
                val (bx, by) = mid(cx, cy, other)
                val a0 = atan2(ay - oy, ax - ox)
                var a1 = atan2(by - oy, bx - ox)
                while (a1 - a0 > PI) a1 -= 2 * PI
                while (a0 - a1 > PI) a1 += 2 * PI
                for (k in 0..6) {
                    val t = a0 + (a1 - a0) * k / 6
                    val x = ox + cos(t) * cell / 2
                    val y = oy + sin(t) * cell / 2
                    if (first) { out.moveTo(x, y); first = false } else if (k > 0) out.lineTo(x, y)
                }
                when (other) {
                    0 -> { cy -= 1; side = 2 }
                    1 -> { cx += 1; side = 3 }
                    2 -> { cy += 1; side = 0 }
                    else -> { cx -= 1; side = 1 }
                }
            }
        }
        return out
    }

fun boids(seed: Long, count: Int = 10, steps: Int = 160, size: Double = 160.0): FLine

Flocking trails: a dozen boids that steer towards each other, align and keep apart, wandering a box. Each boid's path is one stroke, drawn one boid after another.

    fun boids(seed: Long, count: Int = 10, steps: Int = 160, size: Double = 160.0): FLine {
        val r = Random(seed)
        val px = DoubleArray(count) { r.nextDouble() * size }
        val py = DoubleArray(count) { r.nextDouble() * size * 0.6 }
        val vx = DoubleArray(count) { r.nextDouble() - 0.5 }
        val vy = DoubleArray(count) { r.nextDouble() - 0.5 }
        val trails = Array(count) { ArrayList<Pair<Double, Double>>() }
        repeat(steps) {
            for (i in 0 until count) {
                var cx = 0.0; var cy = 0.0; var ax = 0.0; var ay = 0.0; var sx = 0.0; var sy = 0.0
                for (j in 0 until count) if (j != i) {
                    cx += px[j]; cy += py[j]; ax += vx[j]; ay += vy[j]
                    val d = hypot(px[j] - px[i], py[j] - py[i])
                    if (d < 22) { sx -= (px[j] - px[i]) / max(d, 1.0); sy -= (py[j] - py[i]) / max(d, 1.0) }
                }
                val n = count - 1.0
                vx[i] += (cx / n - px[i]) * 0.0008 + (ax / n - vx[i]) * 0.05 + sx * 0.08 + (r.nextDouble() - 0.5) * 0.15
                vy[i] += (cy / n - py[i]) * 0.0008 + (ay / n - vy[i]) * 0.05 + sy * 0.08 + (r.nextDouble() - 0.5) * 0.15
                if (px[i] < 0) vx[i] += 0.1
                if (px[i] > size) vx[i] -= 0.1
                if (py[i] < 0) vy[i] += 0.1
                if (py[i] > size * 0.6) vy[i] -= 0.1
                val speed = hypot(vx[i], vy[i])
                if (speed > 2.5) { vx[i] *= 2.5 / speed; vy[i] *= 2.5 / speed }
            }
            for (i in 0 until count) { px[i] += vx[i]; py[i] += vy[i]; trails[i] += px[i] to py[i] }
        }
        val out = FLine()
        for (t in trails) t.forEachIndexed { k, (x, y) -> if (k == 0) out.moveTo(x, y) else out.lineTo(x, y) }
        return out
    }

fun michaux(seed: Long, columns: Int = 2, lines: Int = 14, length: Double = 200.0, gap: Double = 5.0): FLine

After Henri Michaux's mescaline drawings: columns of fine, trembling lines, each laid down beside the last at a small gap and following its undulation, a little changed, so the field accumulates. A column is one unbroken stroke going down, turning, and coming back up beside itself, the way hatching is drawn; now and then a pass leans into its neighbour and touches it.

Following the line beside it is the thing a pen that sees only its own displacements cannot do: train a sensing pen (PenModel.sectors above 0) on these.

    fun michaux(seed: Long, columns: Int = 2, lines: Int = 14, length: Double = 200.0, gap: Double = 5.0): FLine {
        val r = Random(seed)
        val out = FLine()
        var x0 = 0.0
        repeat(columns) {
            val n = (lines * (0.6 + r.nextDouble() * 0.8)).toInt().coerceAtLeast(4)
            val len = length * (0.6 + r.nextDouble() * 0.6)
            // the shape every pass inherits: a few slow waves down the column
            val amp = DoubleArray(3) { 2.0 + r.nextDouble() * 6 }
            val freq = DoubleArray(3) { (it + 1) * (0.5 + r.nextDouble()) * 2 * PI / len }
            val phase = DoubleArray(3) { r.nextDouble() * 2 * PI }
            val tremor = 0.3 + r.nextDouble() * 0.6
            var offset = 0.0
            var first = true
            for (k in 0 until n) {
                for (i in 0..2) phase[i] += (r.nextDouble() - 0.5) * 0.15
                val down = k % 2 == 0
                val top = (r.nextDouble() - 0.5) * 12
                val bottom = len + (r.nextDouble() - 0.5) * 12
                val touch = k > 0 && r.nextDouble() < 0.12
                val steps = ((bottom - top) / 2.0).toInt()
                for (i in 0..steps) {
                    val t = if (down) i / steps.toDouble() else 1 - i / steps.toDouble()
                    val y = top + (bottom - top) * t
                    var x = x0 + offset
                    for (w in 0..2) x += amp[w] * sin(freq[w] * y + phase[w])
                    x += tremor * sin(y * 1.7 + k) + (r.nextDouble() - 0.5) * tremor
                    // a pass that touches leans back onto its neighbour through its middle third
                    if (touch && i > steps / 3 && i < steps * 2 / 3) x -= gap * 0.8 * sin(PI * (i - steps / 3.0) / (steps / 3.0))
                    if (first) { out.moveTo(x, y); first = false } else out.lineTo(x, y)
                }
                offset += gap * (0.8 + r.nextDouble() * 0.4)
            }
            x0 += offset + 25 + r.nextDouble() * 20
        }
        return out
    }

fun cracks(seed: Long, size: Double = 110.0, count: Int = 26): FLine

A web of cracks: a wobbling outline, then crack after crack, each starting on the ink near the last one and wandering until it meets a line, where it stops. Every crack ends in a join: the thing a sensing pen can learn and a blind one cannot.

body — 71 lines, from line 343
    fun cracks(seed: Long, size: Double = 110.0, count: Int = 26): FLine {
        val r = Random(seed)
        val out = FLine()
        val ink = ArrayList<Pair<Double, Double>>()
        val cell = 6.0
        val grid = HashMap<Pair<Int, Int>, MutableList<Int>>()
        fun put(x: Double, y: Double) {
            ink += x to y
            grid.getOrPut(floor(x / cell).toInt() to floor(y / cell).toInt()) { ArrayList() } += ink.size - 1
        }
        fun near(x: Double, y: Double, within: Double, skipFrom: Int): Boolean {
            val gx = floor(x / cell).toInt()
            val gy = floor(y / cell).toInt()
            for (cx in gx - 1..gx + 1) for (cy in gy - 1..gy + 1) for (j in grid[cx to cy] ?: emptyList<Int>()) {
                if (j >= skipFrom) continue
                if (hypot(ink[j].first - x, ink[j].second - y) < within) return true
            }
            return false
        }
        // the outline: a closed, wobbling loop
        val wob = DoubleArray(4) { r.nextDouble() * 0.12 }
        val ph = DoubleArray(4) { r.nextDouble() * 2 * PI }
        val n = (2 * PI * size / 2 / 2.5).toInt()
        for (i in 0..n) {
            val a = i * 2 * PI / n
            var rad = size / 2
            for (w in 0..3) rad *= 1 + wob[w] * sin((w + 2) * a + ph[w])
            val x = cos(a) * rad
            val y = sin(a) * rad
            if (i == 0) out.moveTo(x, y) else out.lineTo(x, y)
            put(x, y)
        }
        var lastEnd = ink.last()
        repeat(count) {
            // start on ink within reach of where the last crack ended, trying a few starts and
            // directions and keeping the one with the most open space ahead, so the web spreads
            val candidates = ink.indices.filter { hypot(ink[it].first - lastEnd.first, ink[it].second - lastEnd.second) < 30 }
                .ifEmpty { ink.indices.toList() }
            fun freeRun(sx: Double, sy: Double, angle: Double): Int {
                var k = 1
                while (k < 40 && !near(sx + cos(angle) * 2.5 * k, sy + sin(angle) * 2.5 * k, 2.0, ink.size) &&
                    hypot(sx + cos(angle) * 2.5 * k, sy + sin(angle) * 2.5 * k) < size * 0.75) k++
                return k
            }
            val (start, heading) = (0 until 8).map {
                val s0 = ink[candidates.random(r)]
                s0 to atan2(-s0.second, -s0.first) + (r.nextDouble() - 0.5) * 2.4
            }.maxBy { (s0, a0) -> freeRun(s0.first, s0.second, a0) }
            var x = start.first
            var y = start.second
            var a = heading
            var turn = (r.nextDouble() - 0.5) * 0.1
            out.moveTo(x, y)
            val first = ink.size
            put(x, y)
            for (step in 0 until 200) {
                turn += (r.nextDouble() - 0.5) * 0.04
                turn = turn.coerceIn(-0.12, 0.12)
                a += turn
                x += cos(a) * 2.5
                y += sin(a) * 2.5
                out.lineTo(x, y)
                // stop on meeting any line other than the last few points of this one
                val hit = step > 2 && near(x, y, 2.0, max(first, ink.size - 3)) || hypot(x, y) > size * 0.75
                put(x, y)
                if (hit) break
            }
            lastEnd = x to y
        }
        return out
    }

fun echoes(seed: Long, guides: Int = 3, passes: Int = 7, gap: Double = 5.0): FLine

Echo hatching: a few guide strokes (a stroke of a Hershey letter, a smooth curve, or a closed blob, each at a random angle), each followed by passes laid alongside it at a small gap, back and forth in one unbroken stroke with a tremor, outwards from the guide. A pass is cut short where it would run into other ink, so hatching stops at the lines around it.

What a sensing pen learns from these is to follow whatever line is beside it, in any direction and at any scale, and to stop at other lines: hatching around lettering and drawings rather than only in vertical columns (michaux). hatch puts it to work.

body — 105 lines, from line 425
    fun echoes(seed: Long, guides: Int = 3, passes: Int = 7, gap: Double = 5.0): FLine {
        val r = Random(seed)
        val out = FLine()
        val ink = ArrayList<Pair<Double, Double>>()
        val grid = HashMap<Pair<Int, Int>, MutableList<Int>>()
        fun put(x: Double, y: Double) {
            ink += x to y
            grid.getOrPut(floor(x / gap).toInt() to floor(y / gap).toInt()) { ArrayList() } += ink.size - 1
        }
        // is (x, y) within `within` of any ink outside index range [from, until)?
        fun blocked(x: Double, y: Double, within: Double, from: Int, until: Int): Boolean {
            val gx = floor(x / gap).toInt()
            val gy = floor(y / gap).toInt()
            for (cx in gx - 2..gx + 2) for (cy in gy - 2..gy + 2) for (j in grid[cx to cy] ?: emptyList<Int>()) {
                if (j in from until until) continue
                if (hypot(ink[j].first - x, ink[j].second - y) < within) return true
            }
            return false
        }
        fun resampled(points: List<Pair<Double, Double>>, spacing: Double): List<Pair<Double, Double>> {
            val f = FLine()
            points.forEachIndexed { i, (x, y) -> if (i == 0) f.moveTo(x, y) else f.lineTo(x, y) }
            return resampleForPen(f, spacing).map { it.x.toDouble() to it.y.toDouble() }
        }
        fun smoothed(points: List<Pair<Double, Double>>, closed: Boolean): List<Pair<Double, Double>> = points.indices.map { i ->
            val a = points[if (closed) (i - 1 + points.size) % points.size else max(0, i - 1)]
            val b = points[if (closed) (i + 1) % points.size else minOf(points.size - 1, i + 1)]
            (a.first + 2 * points[i].first + b.first) / 4 to (a.second + 2 * points[i].second + b.second) / 4
        }
        repeat(guides) { g ->
            val cx = (r.nextDouble() - 0.5) * 160
            val cy = (r.nextDouble() - 0.5) * 120
            val angle = r.nextDouble() * 2 * PI
            val kind = r.nextInt(3)
            val closed = kind == 2
            var raw: List<Pair<Double, Double>> = when (kind) {
                0 -> {
                    // a stroke of a Hershey letter, big
                    val font = HersheyFont.load(HersheyFont.names[r.nextInt(HersheyFont.names.size)])
                    val strokes = ('A'..'Z').flatMap { font.strokes(it) ?: emptyList() }.filter { it.size >= 4 }
                    val k = 2.5 + r.nextDouble() * 2.5
                    strokes[r.nextInt(strokes.size)].map { it[0] * k to it[1] * k }
                }
                1 -> {
                    var x = 0.0; var y = 0.0; var a = 0.0; var turn = (r.nextDouble() - 0.5) * 0.06
                    (0 until 40 + r.nextInt(40)).map {
                        turn = (turn + (r.nextDouble() - 0.5) * 0.02).coerceIn(-0.07, 0.07)
                        a += turn; x += cos(a) * 2.5; y += sin(a) * 2.5
                        x to y
                    }
                }
                else -> {
                    val rad = 15 + r.nextDouble() * 25
                    val wob = DoubleArray(3) { r.nextDouble() * 0.15 }
                    val ph = DoubleArray(3) { r.nextDouble() * 2 * PI }
                    (0 until 60).map { i ->
                        val t = i * 2 * PI / 60
                        var rr = rad
                        for (w in 0..2) rr *= 1 + wob[w] * sin((w + 2) * t + ph[w])
                        cos(t) * rr to sin(t) * rr
                    }
                }
            }
            raw = raw.map { (x, y) -> cx + cos(angle) * x - sin(angle) * y to cy + sin(angle) * x + cos(angle) * y }
            var base = resampled(if (closed) raw + raw[0] else raw, 2.8)
            if (base.size < 4) return@repeat
            // the guide, as its own stroke
            base.forEachIndexed { i, (x, y) -> if (i == 0) out.moveTo(x, y) else out.lineTo(x, y) }
            var previousFrom = ink.size
            base.forEach { (x, y) -> put(x, y) }
            val side = if (r.nextBoolean()) 1.0 else -1.0
            val tremor = 0.2 + r.nextDouble() * 0.4
            var first = true
            for (k in 1..passes) {
                // offset the last pass by about one gap along its normals, smooth, roughen
                val d = gap * (0.85 + r.nextDouble() * 0.3) * side
                val offset = base.indices.map { i ->
                    val a = base[max(0, i - 1)]
                    val b = base[minOf(base.size - 1, i + 1)]
                    val tx = b.first - a.first
                    val ty = b.second - a.second
                    val len = max(1e-9, hypot(tx, ty))
                    base[i].first - ty / len * d to base[i].second + tx / len * d
                }
                base = smoothed(resampled(smoothed(offset, closed), 2.8), closed)
                // closed shapes go round and round; open ones back and forth
                val drawn = if (closed || k % 2 == 0) base else base.reversed()
                val kept = ArrayList<Pair<Double, Double>>()
                for ((x, y) in drawn) {
                    if (blocked(x, y, gap * 0.6, previousFrom, ink.size)) break
                    kept += x + (r.nextDouble() - 0.5) * tremor to y + (r.nextDouble() - 0.5) * tremor
                }
                if (kept.size < 5) break
                val from = ink.size
                kept.forEachIndexed { i, (x, y) ->
                    // the first pass starts a new stroke beside the guide's end; later ones carry on
                    if (first && i == 0) out.moveTo(x, y) else out.lineTo(x, y)
                }
                first = false
                kept.forEach { (x, y) -> put(x, y) }
                previousFrom = from
            }
        }
        return out
    }

fun twoHands(seed: Long, words: Int = 6, hands: MutableList<Int>? = null): FLine

A line in two hands: words that are either chains of small loops (hand 0) or sharp zigzags (hand 1), the hand changing every word or two. hands gets the hand of each word, one entry per subpath of the result, which is what a probe needs to find the cell that tracks it.

    fun twoHands(seed: Long, words: Int = 6, hands: MutableList<Int>? = null): FLine {
        val r = Random(seed)
        val out = FLine()
        var x = 0.0
        var hand = r.nextInt(2)
        repeat(words) {
            if (r.nextDouble() < 0.6) hand = 1 - hand
            val strokes = r.nextInt(3, 8)
            val h = 10.0 + r.nextDouble() * 4
            out.moveTo(x, 0.0)
            if (hand == 0) {
                // loops: a prolate cycloid, travelling right
                val n = strokes * 16
                for (i in 1..n) {
                    val t = i * 2 * PI / 16
                    out.lineTo(x + t * 2.2 - sin(t) * 6, -h / 2 + cos(t) * h / 2)
                }
                x += strokes * 2 * PI * 2.2
            } else {
                for (i in 1..strokes * 2) {
                    val px = x + i * 4.5
                    out.lineTo(px, if (i % 2 == 1) -h else 0.0)
                }
                x += strokes * 2 * 4.5
            }
            hands?.add(hand)
            x += 14 + r.nextDouble() * 8
        }
        return out
    }
}

object PenCorpus

A corpus of drawings for python/fieldpen: one JSON file per drawing, split into train/ and valid/ under a folder.

PenCorpus.write("/tmp/plants", (0 until 400).map { PenDrawings.plant(it.toLong()) })

then python -m fieldpen.prepare /tmp/plants/train train.npz --scale 8 and so on (the Teaching a pen docs page has the whole run). Each subpath is resampled every step model units at scale, the spacing Pen.feed uses, so pass the same scale to prepare.

object PenCorpus {

fun write(folder: String, drawings: List<FLine>, scale: Double = 8.0, step: Double = 0.35, validEvery: Int = 10): Int

Write drawings under folder, every validEveryth one into valid/. Returns the number of points written.

    fun write(folder: String, drawings: List<FLine>, scale: Double = 8.0, step: Double = 0.35, validEvery: Int = 10): Int {
        val root = File(folder)
        File(root, "train").mkdirs(); File(root, "valid").mkdirs()
        var points = 0
        drawings.forEachIndexed { i, d ->
            val strokes = strokes(d, scale, step)
            points += strokes.sumOf { it.size }
            val json = strokes.joinToString(",", "{\"strokes\":[", "]}") { s ->
                s.joinToString(",", "[", "]") { "[${"%.3f".format(it.x)},${"%.3f".format(it.y)}]" }
            }
            File(root, if (i % validEvery == validEvery - 1) "valid" else "train").resolve("d%05d.json".format(i)).writeText(json)
        }
        return points
    }

fun strokes(line: FLine, scale: Double = 8.0, step: Double = 0.35): List<List<Vec3>>

line's subpaths resampled every step model units: what the pen is shown.

    fun strokes(line: FLine, scale: Double = 8.0, step: Double = 0.35): List<List<Vec3>> =
        line.pieces().map { resampleForPen(it, step * scale) }.filter { it.size >= 2 }
}

fun curliness(line: FLine, scale: Double = 8.0, step: Double = 0.35, shortest: Int = 6): Double

Mean absolute turn per unit length along line's marks, a rough measure of how curly it is, taken after resampling the way a pen reads a line (PenCorpus.strokes). Measured on the raw points instead, it mostly counts the small jitter between close points, and choosing by it picks the shakiest lines rather than the curliest. Marks of fewer than shortest resampled points are left out, so a line of dots does not count as curly either.

fun curliness(line: FLine, scale: Double = 8.0, step: Double = 0.35, shortest: Int = 6): Double {
    var turn = 0.0
    var length = 0.0
    PenCorpus.strokes(line, scale, step).forEach { p ->
        if (p.size < shortest) return@forEach
        for (i in 1 until p.size - 1) {
            val a = atan2((p[i].y - p[i - 1].y).toDouble(), (p[i].x - p[i - 1].x).toDouble())
            val b = atan2((p[i + 1].y - p[i].y).toDouble(), (p[i + 1].x - p[i].x).toDouble())
            var d = b - a
            while (d > PI) d -= 2 * PI
            while (d < -PI) d += 2 * PI
            turn += abs(d)
            length += p[i].distance(p[i - 1])
        }
    }
    return if (length == 0.0) 0.0 else turn / length
}

fun PenModel.probe(drawings: List<FLine>, labels: List<List<Int>>, scale: Double = 8.0, step: Double = 0.35): List<Pair<Int, Double>>

Which memory cells track a label: show model each of drawings (a fresh pen per drawing), and correlate every cell's output with the label of the subpath being drawn, labels[d](../../../../../../../reference/source/jvmMain/afield/ui/notation/StrokeSketch.md#L178) for subpath s of drawing d. Returns every cell with its correlation, strongest (positive or negative) first.

This is how to find the cell that knows which hand it is writing in, for drawings made by PenDrawings.twoHands: it does not look for meaning, only for agreement with a label you supply.

fun PenModel.probe(drawings: List<FLine>, labels: List<List<Int>>, scale: Double = 8.0, step: Double = 0.35): List<Pair<Int, Double>> {
    val n = hidden
    val sum = DoubleArray(n)
    val sumSq = DoubleArray(n)
    val sumXY = DoubleArray(n)
    var count = 0
    var sumY = 0.0
    var sumYY = 0.0
    drawings.forEachIndexed { d, drawing ->
        val pen = pen(d.toLong())
        var last: Vec3? = null
        PenCorpus.strokes(drawing, scale, step).forEachIndexed { s, points ->
            val y = labels[d][s].toDouble()
            points.forEachIndexed { i, p ->
                val l = last
                val lift = i == points.size - 1
                if (l == null) pen.feed(0.0, 0.0, lift) else pen.feed((p.x - l.x) / scale, (p.y - l.y) / scale, lift)
                last = p
                val h = pen.hidden()
                for (c in 0 until n) { sum[c] += h[c]; sumSq[c] += h[c] * h[c]; sumXY[c] += h[c] * y }
                sumY += y; sumYY += y * y; count++
            }
        }
    }
    val my = sumY / count
    val vy = sumYY / count - my * my
    return (0 until n).map { c ->
        val mx = sum[c] / count
        val vx = sumSq[c] / count - mx * mx
        val cov = sumXY[c] / count - mx * my
        c to if (vx > 1e-12 && vy > 1e-12) cov / kotlin.math.sqrt(vx * vy) else 0.0
    }.sortedByDescending { abs(it.second) }
}

object PenCorpora

Writes the corpora Field's bundled synthetic pens were trained on: run-jvm-main afield.ai.learning.pen.PenCorpora --arg <folder> makes one sub-folder per corpus, which tools/train_pen_corpora.py then trains and exports.

object PenCorpora {

fun all(): Map<String, () -> List<FLine>> = linkedMapOf

Every corpus by name: the drawings to write.

    fun all(): Map<String, () -> List<FLine>> = linkedMapOf(
        "hershey" to { (0 until 900).map { PenDrawings.hershey(it.toLong(), HersheyFont.names[it % HersheyFont.names.size]) } },
        "harmonograph" to { (0 until 300).map { PenDrawings.harmonograph(it.toLong()) } },
        "plants" to { (0 until 300).map { PenDrawings.plant(it.toLong()) } },
        "truchet" to { (0 until 300).map { PenDrawings.truchet(it.toLong()) } },
        "boids" to { (0 until 200).map { PenDrawings.boids(it.toLong()) } },
        "mixed" to { (0 until 600).map { PenDrawings.twoHands(it.toLong()) } },
        "rounder" to { rounder() },
        // trained twice each, seeing and blind (tools/train_pen_corpora.py)
        "michaux" to { (0 until 300).map { PenDrawings.michaux(it.toLong()) } },
        "cracks" to { (0 until 300).map { PenDrawings.cracks(it.toLong()) } },
        "echoes" to { (0 until 400).map { PenDrawings.echoes(it.toLong()) } },
    )

fun rounder(candidates: Int = 2400, keep: Int = 300): List<FLine>

The handwriting pen's own output, kept only where it is curliest: 2400 short unprimed scribbles of 120 points, the top eighth by curliness. Fine-tuning on it is a pen learning a taste from its own work.

    fun rounder(candidates: Int = 2400, keep: Int = 300): List<FLine> =
        (0 until candidates).map { seed ->
            PenLine(seed = seed.toLong()).also { it.grow(120) }.drawn
        }.sortedByDescending { curliness(it) }.take(keep)

fun main(args: Array<String>)

    @JvmStatic
    fun main(args: Array<String>) {
        val root = args.firstOrNull() ?: "build/pen-corpora"
        val only = args.drop(1).toSet()
        for ((name, make) in all()) {
            if (only.isNotEmpty() && name !in only) continue
            val drawings = make()
            val points = PenCorpus.write("$root/$name", drawings)
            println("$name: ${drawings.size} drawings, $points points -> $root/$name")
        }
    }
}

fun following(line: FLine, within: Double = 8.0, recent: Int = 8, scale: Double = 8.0, step: Double = 0.35): Double

How much of line runs alongside ink it drew earlier: the share of its points (resampled the way a pen reads a line) with an earlier point, more than recent points back, within within drawing units. Hatching scores high; a single wandering line scores near zero.

fun following(line: FLine, within: Double = 8.0, recent: Int = 8, scale: Double = 8.0, step: Double = 0.35): Double {
    val points = PenCorpus.strokes(line, scale, step).flatten()
    if (points.size <= recent) return 0.0
    val cell = within
    val grid = HashMap<Pair<Int, Int>, MutableList<Int>>()
    var near = 0
    for (i in points.indices) {
        if (i > recent) {
            val j = i - recent - 1
            grid.getOrPut(floor(points[j].x / cell).toInt() to floor(points[j].y / cell).toInt()) { ArrayList() } += j
        }
        val gx = floor(points[i].x / cell).toInt()
        val gy = floor(points[i].y / cell).toInt()
        var found = false
        for (cx in gx - 1..gx + 1) for (cy in gy - 1..gy + 1) {
            if (found) break
            for (j in grid[cx to cy] ?: emptyList<Int>()) if (points[j].distance(points[i]) < within) { found = true; break }
        }
        if (found) near++
    }
    return near.toDouble() / points.size
}

fun joins(line: FLine, within: Double = 4.0, recent: Int = 8, scale: Double = 8.0, step: Double = 0.35): Double

How many of line's strokes end on other ink: the share of its subpaths whose last point is within within drawing units of a point of any earlier subpath, or of this one more than recent points back. A web of cracks scores high; strokes that stop in mid-air score low.

fun joins(line: FLine, within: Double = 4.0, recent: Int = 8, scale: Double = 8.0, step: Double = 0.35): Double {
    val strokes = PenCorpus.strokes(line, scale, step)
    if (strokes.size < 2) return 0.0
    var joined = 0
    strokes.forEachIndexed { k, s ->
        val end = s.last()
        val earlier = strokes.take(k).flatten() + s.dropLast(recent + 1)
        if (earlier.any { it.distance(end) < within }) joined++
    }
    return joined.toDouble() / strokes.size
}