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
}