PolyvectorNative.kt
src/jvmMain/kotlin/afield/geometry/vectorize/polyvector/PolyvectorNative.kt · 217 lines
Finding field_polyvector on this machine, and calling into it once found.
PolyvectorLibrary is the half that answers without loading anything: the library's file name on this OS, the paths it is looked for in, whether one of them exists, and the Gradle task and per-OS shell commands that build it. Its Windows list is empty, which is how a NativeGeometryCapability says the build does not cover that OS yet, so the welcome page's Geometry extensions panel offers no install route there. The path is resolved afresh on every read, so a library built during a live session is picked up by the next box run.
The other half, internal to the package, binds the C ABI declared in
native/field-polyvector/src/field_polyvector.h with java.lang.foreign: one lazy downcall handle
per fpv_ entry point, two parameter structs laid out by hand with their padding (one for the
field fit, one for the trace, following the split in FrameFieldParameters and
TraceParameters), and a 512-byte buffer that the field fit and the trace carry down and read a
message out of when either reports failure. The read-outs take no error buffer. The first symbol
lookup checks fpv_abi_version against the version this binding expects and refuses a library
that disagrees, naming the task that rebuilds it.
Reading a result copies it out of native memory into Kotlin arrays: the ink mask, the per-pixel weight, the two directions and the singularities as one snapshot of the field, the streamlines and the final curves as polylines, and the field and trace milliseconds the library times for itself.
Polyvector is the API over it, and its only caller.
package afield.geometry.vectorize.polyvector
import afield.geometry.network.NativeGeometryCapability
import java.lang.foreign.Arena
import java.lang.foreign.FunctionDescriptor
import java.lang.foreign.Linker
import java.lang.foreign.MemoryLayout
import java.lang.foreign.MemorySegment
import java.lang.foreign.SymbolLookup
import java.lang.foreign.ValueLayout.ADDRESS
import java.lang.foreign.ValueLayout.JAVA_BYTE
import java.lang.foreign.ValueLayout.JAVA_DOUBLE
import java.lang.foreign.ValueLayout.JAVA_FLOAT
import java.lang.foreign.ValueLayout.JAVA_INT
import java.lang.foreign.ValueLayout.JAVA_LONG
import java.lang.invoke.MethodHandle
import java.nio.file.Path
import kotlin.io.path.exists
object PolyvectorLibrary : NativeGeometryCapability
Where field_polyvector is, and whether it is there. Field runs without it; describe says how
to build it.
PolyvectorLibrary.describe()
override val name = "polyvector"
override val title = "Line-drawing vectorization"
override val docsPage = "polyvector.md"
override val buildTask = "buildPolyvectorNative"
override val install: Map<String, List<String>>
override val install: Map<String, List<String>> = mapOf(
"macos" to listOf("xcode-select --install", "brew install cmake opencv boost eigen", "./gradlew buildPolyvectorNative"),
"linux" to listOf(
"sudo apt install build-essential cmake libopencv-dev libboost-dev libeigen3-dev",
"# Fedora: sudo dnf install gcc-c++ cmake opencv-devel boost-devel eigen3-devel",
"# Arch: sudo pacman -S base-devel cmake opencv boost eigen",
"./gradlew buildPolyvectorNative",
),
"windows" to emptyList(),
)
private val os = System.getProperty("os.name").lowercase()
val fileName: String
val fileName: String
get() = when {
os.contains("win") -> "field_polyvector.dll"
os.contains("mac") || os.contains("darwin") -> "libfield_polyvector.dylib"
else -> "libfield_polyvector.so"
}
val searchedPaths: List<Path>
Every path resolvedPath tries, in order.
val searchedPaths: List<Path>
get() = listOfNotNull(
System.getProperty("afield.polyvector.library")?.takeIf { it.isNotBlank() },
System.getenv("AFIELD_POLYVECTOR_LIBRARY")?.takeIf { it.isNotBlank() },
).map(Path::of) + listOf(
Path.of("build", "field-polyvector", "build", fileName).toAbsolutePath(),
Path.of("build", "field-polyvector", "build", "Release", fileName).toAbsolutePath(),
)
val resolvedPath: Path? get() = searchedPaths.firstOrNull { it.exists() }
Not cached: a library built during a live session is picked up by the next box run.
override val isAvailable: Boolean get() = resolvedPath != null
val HOW_TO_BUILD: String
val HOW_TO_BUILD: String
get() = "Build it with `./gradlew buildPolyvectorNative` (needs cmake, a C++17 compiler, OpenCV, " +
"Boost and Eigen; macOS: `brew install cmake opencv boost eigen`; Debian/Ubuntu: " +
"`sudo apt install build-essential cmake libopencv-dev libboost-dev libeigen3-dev`), " +
"or point -Dafield.polyvector.library / AFIELD_POLYVECTOR_LIBRARY at a $fileName."
override fun describe(): String
override fun describe(): String = resolvedPath?.let {
runCatching { "polyvector: ${PolyvectorNative.version()} at $it" }
.getOrElse { e -> "polyvector: found $it but cannot use it: ${e.message}" }
} ?: "polyvector: not built. $HOW_TO_BUILD"
}
internal object PolyvectorNative
The C ABI in native/field-polyvector/src/field_polyvector.h, bound with java.lang.foreign.
const val ABI_VERSION = 1
private const val ERROR_CAPACITY = 512
private val linker: Linker = Linker.nativeLinker()
fpv_field_params: three doubles and an int; fpv_trace_params: double, int (padded), double, int
private val FIELD = MemoryLayout.structLayout(JAVA_DOUBLE, JAVA_DOUBLE, JAVA_DOUBLE, JAVA_INT, MemoryLayout.paddingLayout(4))
private val TRACE = MemoryLayout.structLayout(JAVA_DOUBLE, JAVA_INT, MemoryLayout.paddingLayout(4), JAVA_DOUBLE, JAVA_INT, MemoryLayout.paddingLayout(4))
private val lookup: SymbolLookup by lazy {
val path = PolyvectorLibrary.resolvedPath ?: throw UnsatisfiedLinkError(
"field_polyvector is not built. ${PolyvectorLibrary.HOW_TO_BUILD}\nLooked at: " +
PolyvectorLibrary.searchedPaths.joinToString(", "),
)
SymbolLookup.libraryLookup(path, Arena.global()).also { l ->
val abi = linker.downcallHandle(
l.find("fpv_abi_version").orElseThrow { UnsatisfiedLinkError("$path has no fpv_abi_version") },
FunctionDescriptor.of(JAVA_INT),
).invokeExact() as Int
if (abi != ABI_VERSION) throw UnsatisfiedLinkError(
"$path speaks ABI $abi, Field expects $ABI_VERSION. Rebuild it with ./gradlew buildPolyvectorNative.",
)
}
}
private fun handle(name: String, descriptor: FunctionDescriptor): MethodHandle = linker.downcallHandle(
lookup.find(name).orElseThrow {
UnsatisfiedLinkError("${PolyvectorLibrary.fileName} has no symbol $name; rebuild it with ./gradlew buildPolyvectorNative")
},
descriptor,
)
private val versionString by lazy { handle("fpv_version_string", FunctionDescriptor.of(ADDRESS)) }
private val field by lazy { handle("fpv_field", FunctionDescriptor.of(ADDRESS, ADDRESS, JAVA_INT, JAVA_INT, ADDRESS, ADDRESS, JAVA_INT)) }
private val free by lazy { handle("fpv_free", FunctionDescriptor.ofVoid(ADDRESS)) }
private val trace by lazy { handle("fpv_trace", FunctionDescriptor.of(JAVA_INT, ADDRESS, ADDRESS, ADDRESS, JAVA_INT)) }
private val maps by lazy { handle("fpv_field_maps", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS, ADDRESS, ADDRESS)) }
private val componentCount by lazy { handle("fpv_component_count", FunctionDescriptor.of(JAVA_INT, ADDRESS)) }
private val singularityCount by lazy { handle("fpv_singularity_count", FunctionDescriptor.of(JAVA_INT, ADDRESS)) }
private val singularities by lazy { handle("fpv_singularities", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS)) }
private val curveCount by lazy { handle("fpv_curve_count", FunctionDescriptor.of(JAVA_INT, ADDRESS, JAVA_INT)) }
private val pointTotal by lazy { handle("fpv_point_total", FunctionDescriptor.of(JAVA_LONG, ADDRESS, JAVA_INT)) }
private val curves by lazy { handle("fpv_curves", FunctionDescriptor.ofVoid(ADDRESS, JAVA_INT, ADDRESS, ADDRESS)) }
private val timings by lazy { handle("fpv_timings", FunctionDescriptor.ofVoid(ADDRESS, ADDRESS)) }
private val dropped by lazy { handle("fpv_dropped_points", FunctionDescriptor.of(JAVA_INT, ADDRESS)) }
fun version(): String
fun version(): String = (versionString.invokeExact() as MemorySegment).reinterpret(Long.MAX_VALUE).getString(0)
fun field(evidence: FloatArray, width: Int, height: Int, p: FrameFieldParameters): MemorySegment
fun field(evidence: FloatArray, width: Int, height: Int, p: FrameFieldParameters): MemorySegment = Arena.ofConfined().use { a ->
val params = a.allocate(FIELD).also { s ->
s.set(JAVA_DOUBLE, 0, p.inkThreshold); s.set(JAVA_DOUBLE, 8, p.regularizer)
s.set(JAVA_DOUBLE, 16, p.smoothness); s.set(JAVA_INT, 24, p.repairPasses)
}
val error = a.allocate(JAVA_BYTE, ERROR_CAPACITY.toLong())
val r = field.invokeExact(a.allocateFrom(JAVA_FLOAT, *evidence), width, height, params, error, ERROR_CAPACITY) as MemorySegment
if (r.address() == 0L) throw IllegalArgumentException("polyvector field: " + error.getString(0))
r
}
fun free(r: MemorySegment) { free.invokeExact(r) }
fun trace(r: MemorySegment, p: TraceParameters): Int
fun trace(r: MemorySegment, p: TraceParameters): Int = Arena.ofConfined().use { a ->
val params = a.allocate(TRACE).also { s ->
s.set(JAVA_DOUBLE, 0, p.pruneRatio); s.set(JAVA_INT, 8, p.maxLoopPixels)
s.set(JAVA_DOUBLE, 16, p.simplifyTolerance); s.set(JAVA_INT, 24, if (p.smooth) 1 else 0)
}
val error = a.allocate(JAVA_BYTE, ERROR_CAPACITY.toLong())
val n = trace.invokeExact(r, params, error, ERROR_CAPACITY) as Int
if (n < 0) throw IllegalArgumentException("polyvector trace: " + error.getString(0))
n
}
class Maps(val mask: ByteArray, val weight: FloatArray, val directions: FloatArray, val singularities: FloatArray, val components: Int, val fieldMs: Double)
class Maps(val mask: ByteArray, val weight: FloatArray, val directions: FloatArray, val singularities: FloatArray, val components: Int, val fieldMs: Double)
fun maps(r: MemorySegment, width: Int, height: Int): Maps
fun maps(r: MemorySegment, width: Int, height: Int): Maps = Arena.ofConfined().use { a ->
val n = width.toLong() * height
val m = a.allocate(JAVA_BYTE, n); val w = a.allocate(JAVA_FLOAT, n); val d = a.allocate(JAVA_FLOAT, 4 * n)
maps.invokeExact(r, m, w, d)
val s = singularityCount.invokeExact(r) as Int
val sxy = a.allocate(JAVA_FLOAT, (2L * s).coerceAtLeast(1))
singularities.invokeExact(r, sxy)
val t = a.allocate(JAVA_DOUBLE, 2)
timings.invokeExact(r, t)
Maps(m.toArray(JAVA_BYTE), w.toArray(JAVA_FLOAT), d.toArray(JAVA_FLOAT), sxy.asSlice(0, 8L * s).toArray(JAVA_FLOAT),
componentCount.invokeExact(r) as Int, t.getAtIndex(JAVA_DOUBLE, 0))
}
fun polylines(r: MemorySegment, kind: Int): List<FloatArray>
Polylines of kind (0 = streamlines, 1 = final curves), each as x, y floats.
fun polylines(r: MemorySegment, kind: Int): List<FloatArray> = Arena.ofConfined().use { a ->
val n = curveCount.invokeExact(r, kind) as Int
val total = pointTotal.invokeExact(r, kind) as Long
val lengths = a.allocate(JAVA_INT, n.toLong().coerceAtLeast(1))
val xy = a.allocate(JAVA_FLOAT, (2 * total).coerceAtLeast(1))
curves.invokeExact(r, kind, lengths, xy)
var o = 0L
List(n) { i ->
val k = lengths.getAtIndex(JAVA_INT, i.toLong())
xy.asSlice(4 * o, 8L * k).toArray(JAVA_FLOAT).also { o += 2L * k }
}
}
fun traceMs(r: MemorySegment): Double
fun traceMs(r: MemorySegment): Double = Arena.ofConfined().use { a ->
val t = a.allocate(JAVA_DOUBLE, 2); timings.invokeExact(r, t); t.getAtIndex(JAVA_DOUBLE, 1)
}