imaging_features.kt
src/jvmMain/kotlin/afield/media/image/imaging_features.kt · 970 lines
package and imports — 67 lines, from line 1
@file:afield.runtime.completion.CompletionCategory("Imaging")
package afield.media.image
import afield.data.collections.invoke
import afield.data.collections.times
import afield.document.context.why
import afield.document.graph.minusAssign
import afield.document.graph.plusAssign
import afield.document.properties.clear
import afield.document.properties.compareTo
import afield.document.workspace.file
import afield.geometry.mesh.centroid
import afield.graphics.render.circle
import afield.graphics.render.filled
import afield.graphics.render.rangeTo
import afield.math.autodiff.compareTo
import afield.math.autodiff.div
import afield.math.autodiff.minus
import afield.math.autodiff.plus
import afield.math.autodiff.sign
import afield.math.autodiff.times
import afield.math.linalg.invoke
import afield.math.linalg.remAssign
import afield.math.linalg.set
import afield.math.linalg.times
import afield.math.statistics.noise
import afield.math.units.div
import afield.math.units.minus
import afield.math.units.plus
import afield.math.units.times
import afield.media.video.image
import afield.runtime.execution.start
import afield.simulation.agents.morphology
import afield.simulation.agents.step
import afield.simulation.automata.step
import afield.animation.keyframes.h
import afield.audio.synth.*
import afield.data.collections.get
import afield.data.collections.m
import afield.data.collections.r
import afield.data.collections.s
import afield.geometry.mesh.gradient
import afield.graphics.render.label
import afield.graphics.vulkan.buffers.add
import afield.lang.stacksl.h
import afield.math.autodiff.grad
import afield.math.linalg.Vec2
import afield.math.linalg.bb
import afield.math.linalg.d
import afield.math.linalg.f
import afield.math.linalg.i
import afield.math.linalg.l
import afield.math.linalg.ra
import afield.math.linalg.rb
import afield.math.linalg.x
import afield.ui.output.h
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.atan2
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sin
import kotlin.math.sqrt
Edges, corners, lines, blobs and morphology for Img — the "find things in the picture" half of
the stdlib imaging family (see imaging.kt for the value type and imaging_filters.kt for
convolution and gradients).
Everything here reduces pixels to structure — a point set, a label map, a list of lines — and
that structure is what imaging_field.kt turns into FLines so it lands in Field's drawing
layer rather than staying trapped in a raster.
Every detector here is the textbook algorithm, implemented plainly and documented with the property a test can check:
| function | what it finds | test invariant |
|---|---|---|
canny |
thin, connected edges | a synthetic square yields a 1-pixel-wide closed outline |
harrisCorners / shiTomasiCorners |
corners via the structure tensor | 4 responses at a square's 4 corners |
fastCorners |
corners via a brightness circle test | same, and much faster |
| houghLines | straight lines in polar form | a drawn line is recovered at its (rho, theta) |
connectedComponents |
blobs in a binary mask | N drawn discs give N labels with the right areas |
distanceTransform |
distance to the nearest zero | a disc's centre has distance = its radius |
Canny edges
fun Img.canny(low: Double = 0.08, high: Double = 0.20, sigma: Double = 1.0): Img
Canny edge detection.
The four classic stages, in order: Gaussian smoothing at sigma; Sobel gradients; non-maximum
suppression (keep a pixel only if its gradient magnitude is a local maximum along the gradient
direction, which is what thins a fat gradient ridge to one pixel); hysteresis (seed from
pixels above high, then flood along connected pixels above low, which is what keeps a faint
but continuous edge while dropping isolated noise).
@param low weak-edge threshold, in gradient-magnitude units (the Img.sobel normalisation means
a unit step edge has magnitude ~1, so 0.05–0.15 is a sensible weak threshold)
@param high strong-edge seed threshold; must exceed low
@return a 1-channel binary mask, 1 on an edge and 0 elsewhere
fun Img.canny(low: Double = 0.08, high: Double = 0.20, sigma: Double = 1.0): Img {
require(high > low) { "canny needs high > low, got $low / $high" }
require(low >= 0.0) { "canny thresholds must be >= 0, got $low" }
val g = (if (channels == 1) this else luma()).let { if (sigma > 0.0) it.gaussianBlur(sigma) else it }
val grad = g.sobel()
val mag = grad.magnitude()
val nms = nonMaximumSuppression(mag, grad)
return hysteresis(nms, low.toFloat(), high.toFloat())
}
fun Img.cannyAuto(highPercentile: Double = 0.90, lowRatio: Double = 0.4, sigma: Double = 1.0): Img
canny with the thresholds chosen from the image instead of guessed.
A fixed (low, high) pair that works on a bright synthetic test card finds almost nothing in a
dark, softly-lit photograph — which is the single most common way Canny "doesn't work". This
variant computes the non-maximum-suppressed gradient magnitudes, takes the highPercentile of
the non-zero ones as the strong threshold, and sets the weak threshold to lowRatio times
it (Canny's own recommended 1:2–1:3 ratio).
@param highPercentile fraction of surviving ridge pixels to treat as strong seeds' cut-off (0.9 keeps roughly the top 10% as seeds)
fun Img.cannyAuto(highPercentile: Double = 0.90, lowRatio: Double = 0.4, sigma: Double = 1.0): Img {
require(highPercentile in 0.5..0.999) { "highPercentile must be in 0.5..0.999, got $highPercentile" }
require(lowRatio in 0.05..0.95) { "lowRatio must be in 0.05..0.95, got $lowRatio" }
val g = (if (channels == 1) this else luma()).let { if (sigma > 0.0) it.gaussianBlur(sigma) else it }
val grad = g.sobel()
val nms = nonMaximumSuppression(grad.magnitude(), grad)
val nonZero = nms.data.filter { it > 1e-6f }.sorted()
if (nonZero.isEmpty()) return zerosLike(1)
val high = nonZero[((nonZero.size - 1) * highPercentile).toInt().coerceIn(0, nonZero.size - 1)]
val low = (high * lowRatio).toFloat()
if (high <= low) return hysteresis(nms, low, low * 1.001f + 1e-9f)
return hysteresis(nms, low, high)
}
fun nonMaximumSuppression(magnitude: Img, gradient: Gradient): Img
Thin a gradient-magnitude image to ridge crests: a pixel survives only if its magnitude is >= both neighbours sampled along the gradient direction (quantised to the nearest of the four 45-degree axes). Exposed because it is useful on its own, on any gradient field.
fun nonMaximumSuppression(magnitude: Img, gradient: Gradient): Img {
require(magnitude.channels == 1) { "nonMaximumSuppression expects a 1-channel magnitude image" }
require(magnitude.width == gradient.width && magnitude.height == gradient.height) {
"magnitude and gradient shapes must match"
}
val w = magnitude.width
val h = magnitude.height
val out = FloatArray(w * h)
for (y in 0 until h) for (x in 0 until w) {
val p = y * w + x
val m = magnitude.data[p]
if (m <= 0f) continue
val ang = atan2(gradient.dy.data[p], gradient.dx.data[p]).toDouble()
// Quantise to 0 / 45 / 90 / 135 degrees.
var a = Math.toDegrees(ang)
if (a < 0) a += 180.0
val (dx, dy) = when {
a < 22.5 || a >= 157.5 -> 1 to 0
a < 67.5 -> 1 to 1
a < 112.5 -> 0 to 1
else -> -1 to 1
}
val m1 = magnitude.get(x + dx, y + dy, 0, Border.ZERO)
val m2 = magnitude.get(x - dx, y - dy, 0, Border.ZERO)
if (m >= m1 && m >= m2) out[p] = m
}
return Img(w, h, 1, out)
}
fun hysteresis(thinned: Img, low: Float, high: Float): Img
Double-threshold + connectivity: pixels above high are edges; pixels above low become edges
only if 8-connected (transitively) to one. Returns a binary mask.
fun hysteresis(thinned: Img, low: Float, high: Float): Img {
require(thinned.channels == 1) { "hysteresis expects a 1-channel image" }
require(high > low) { "hysteresis needs high > low, got $low / $high" }
val w = thinned.width
val h = thinned.height
val out = FloatArray(w * h)
val stack = ArrayDeque<Int>()
for (p in 0 until w * h) if (thinned.data[p] >= high) { out[p] = 1f; stack.addLast(p) }
while (stack.isNotEmpty()) {
val p = stack.removeLast()
val x = p % w
val y = p / w
for (dy in -1..1) for (dx in -1..1) {
if (dx == 0 && dy == 0) continue
val nx = x + dx
val ny = y + dy
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue
val q = ny * w + nx
if (out[q] == 0f && thinned.data[q] >= low) { out[q] = 1f; stack.addLast(q) }
}
}
return Img(w, h, 1, out)
}
Corners
data class Corner
A detected interest point.
data class Corner(
/** Column, refined to sub-pixel when the detector was asked for it. */
val x: Double,
/** Row, refined to sub-pixel when the detector was asked for it. */
val y: Double,
/** Detector strength: Harris response, Shi–Tomasi min-eigenvalue or FAST score. */
val response: Double
) {
fun toVec2(): Vec2 = Vec2(x.toFloat(), y.toFloat())
As a Vec2 so it drops straight into polylineFrom / the rest of the stdlib geometry.
fun Img.structureTensor(sigma: Double = 1.5, gradientSigma: Double = 1.0): Triple<Img, Img, Img>
The per-pixel structure tensor (second-moment matrix) of a scalar image, Gaussian-weighted over a neighbourhood — the shared engine behind Harris, Shi–Tomasi and optical flow.
@return Triple(Ixx, Ixy, Iyy), each 1-channel, where Ixx = G_sigma * (dx*dx) etc.
fun Img.structureTensor(sigma: Double = 1.5, gradientSigma: Double = 1.0): Triple<Img, Img, Img> {
val grad = if (gradientSigma > 0.0) gaussianGradient(gradientSigma) else sobel()
val ixx = (grad.dx * grad.dx).gaussianBlur(sigma)
val ixy = (grad.dx * grad.dy).gaussianBlur(sigma)
val iyy = (grad.dy * grad.dy).gaussianBlur(sigma)
return Triple(ixx, ixy, iyy)
}
fun Img.harrisResponse(k: Double = 0.04, sigma: Double = 1.5, gradientSigma: Double = 1.0): Img
Harris corner response image, det(M) - k * trace(M)² over the structureTensor M. Large and
positive at a corner, negative along an edge, near zero on flat ground.
fun Img.harrisResponse(k: Double = 0.04, sigma: Double = 1.5, gradientSigma: Double = 1.0): Img {
val (ixx, ixy, iyy) = structureTensor(sigma, gradientSigma)
val kf = k.toFloat()
val out = FloatArray(pixelCount)
for (p in out.indices) {
val a = ixx.data[p]; val b = ixy.data[p]; val c = iyy.data[p]
val det = a * c - b * b
val tr = a + c
out[p] = det - kf * tr * tr
}
return Img(width, height, 1, out)
}
fun Img.shiTomasiResponse(sigma: Double = 1.5, gradientSigma: Double = 1.0): Img
Shi–Tomasi ("good features to track") response: the smaller eigenvalue of the structure
tensor. Unlike Harris it needs no magic k, and the response is directly interpretable — it is
the weakest intensity variation over any direction, so a large value means the point is
localisable in both axes. This is the response optical-flow trackers want.
fun Img.shiTomasiResponse(sigma: Double = 1.5, gradientSigma: Double = 1.0): Img {
val (ixx, ixy, iyy) = structureTensor(sigma, gradientSigma)
val out = FloatArray(pixelCount)
for (p in out.indices) {
val a = ixx.data[p]; val b = ixy.data[p]; val c = iyy.data[p]
val tr = a + c
val disc = sqrt(max(0f, (a - c) * (a - c) + 4f * b * b))
out[p] = (tr - disc) / 2f
}
return Img(width, height, 1, out)
}
fun peakCorners
Peak extraction shared by the corner detectors: keep local maxima of response that clear
threshold * max(response), sort by strength, and greedily drop any peak within minDistance
pixels of a stronger one.
@param maxCount cap on returned corners (<= 0 for no cap)
@param subPixel refine each peak by fitting a parabola to its 3-tap neighbourhood in x and y
body — 42 lines, from line 283
fun peakCorners(
response: Img,
threshold: Double = 0.01,
minDistance: Int = 5,
maxCount: Int = 200,
subPixel: Boolean = true
): List<Corner> {
require(response.channels == 1) { "peakCorners expects a 1-channel response image" }
require(minDistance >= 1) { "minDistance must be >= 1, got $minDistance" }
val w = response.width
val h = response.height
val mx = response.max()
if (mx <= 0f) return emptyList()
val cut = (threshold * mx).toFloat()
val cand = ArrayList<Corner>()
for (y in 1 until h - 1) for (x in 1 until w - 1) {
val v = response.data[y * w + x]
if (v < cut) continue
var isMax = true
loop@ for (dy in -1..1) for (dx in -1..1) {
if (dx == 0 && dy == 0) continue
if (response.data[(y + dy) * w + (x + dx)] > v) { isMax = false; break@loop }
}
if (!isMax) continue
var fx = x.toDouble()
var fy = y.toDouble()
if (subPixel) {
fx += parabolicOffset(response.data[y * w + x - 1], v, response.data[y * w + x + 1])
fy += parabolicOffset(response.data[(y - 1) * w + x], v, response.data[(y + 1) * w + x])
}
cand.add(Corner(fx, fy, v.toDouble()))
}
cand.sortByDescending { it.response }
val kept = ArrayList<Corner>()
val md2 = (minDistance * minDistance).toDouble()
for (c in cand) {
if (maxCount > 0 && kept.size >= maxCount) break
if (kept.any { val dx = it.x - c.x; val dy = it.y - c.y; dx * dx + dy * dy < md2 }) continue
kept.add(c)
}
return kept
}
Sub-pixel peak offset from a 3-tap parabola fit; clamped to +-0.5 so a flat fit cannot run away.
private fun parabolicOffset(a: Float, b: Float, c: Float): Double {
val denom = (a - 2f * b + c).toDouble()
if (abs(denom) < 1e-12) return 0.0
return (0.5 * (a - c) / denom).coerceIn(-0.5, 0.5)
}
fun Img.harrisCorners
Harris corners: harrisResponse followed by peakCorners.
fun Img.harrisCorners(
k: Double = 0.04,
threshold: Double = 0.01,
minDistance: Int = 5,
maxCount: Int = 200,
sigma: Double = 1.5
): List<Corner> = peakCorners(harrisResponse(k, sigma), threshold, minDistance, maxCount)
fun Img.shiTomasiCorners
Shi–Tomasi corners: shiTomasiResponse followed by peakCorners. The default tracker seed.
fun Img.shiTomasiCorners(
threshold: Double = 0.01,
minDistance: Int = 5,
maxCount: Int = 200,
sigma: Double = 1.5
): List<Corner> = peakCorners(shiTomasiResponse(sigma), threshold, minDistance, maxCount)
fun Img.goodFeaturesToTrack
Alias for shiTomasiCorners under the name the OpenCV-literate will reach for first.
fun Img.goodFeaturesToTrack(
maxCount: Int = 200,
threshold: Double = 0.01,
minDistance: Int = 5
): List<Corner> = shiTomasiCorners(threshold, minDistance, maxCount)
The 16 offsets of the FAST Bresenham circle of radius 3, in order around the circle.
private val FAST_CIRCLE: Array<IntArray> = arrayOf(
intArrayOf(0, -3), intArrayOf(1, -3), intArrayOf(2, -2), intArrayOf(3, -1),
intArrayOf(3, 0), intArrayOf(3, 1), intArrayOf(2, 2), intArrayOf(1, 3),
intArrayOf(0, 3), intArrayOf(-1, 3), intArrayOf(-2, 2), intArrayOf(-3, 1),
intArrayOf(-3, 0), intArrayOf(-3, -1), intArrayOf(-2, -2), intArrayOf(-1, -3)
)
fun Img.fastCorners
FAST corner detection (Rosten & Drummond): a pixel is a corner if at least contiguous of the
16 pixels on a radius-3 circle around it are all brighter than centre + t or all darker than
centre - t. No gradients, no eigenvalues — just a wrap-around run-length test, which is why it
is the fastest usable corner detector.
The Corner.response is the classic FAST score: the largest threshold at which the pixel would still qualify, found by bisection, which gives non-maximum suppression something to rank by.
@param threshold brightness difference, in sample units (0..1 image data → 0.05–0.15 is typical) @param contiguous required arc length, 9 (FAST-9, the standard) through 16
fun Img.fastCorners(
threshold: Double = 0.08,
contiguous: Int = 9,
minDistance: Int = 5,
maxCount: Int = 300
): List<Corner> {
require(contiguous in 9..16) { "contiguous must be in 9..16, got $contiguous" }
require(threshold > 0.0) { "threshold must be > 0, got $threshold" }
val g = if (channels == 1) this else luma()
val w = g.width
val h = g.height
val ring = FloatArray(16)
val cand = ArrayList<Corner>()
for (y in 3 until h - 3) for (x in 3 until w - 3) {
val c = g.data[y * w + x]
for (i in 0 until 16) {
val o = FAST_CIRCLE[i]
ring[i] = g.data[(y + o[1]) * w + (x + o[0])]
}
if (!fastQualifies(ring, c, threshold.toFloat(), contiguous)) continue
// Score = largest t for which the test still passes (bisection, 12 steps is plenty for floats).
var lo = threshold.toFloat()
var hi = 1.5f
repeat(12) {
val mid = (lo + hi) / 2f
if (fastQualifies(ring, c, mid, contiguous)) lo = mid else hi = mid
}
cand.add(Corner(x.toDouble(), y.toDouble(), lo.toDouble()))
}
cand.sortByDescending { it.response }
val kept = ArrayList<Corner>()
val md2 = (minDistance * minDistance).toDouble()
for (cc in cand) {
if (maxCount > 0 && kept.size >= maxCount) break
if (kept.any { val dx = it.x - cc.x; val dy = it.y - cc.y; dx * dx + dy * dy < md2 }) continue
kept.add(cc)
}
return kept
}
Wrap-around contiguous-run test on the 16-pixel FAST ring.
private fun fastQualifies(ring: FloatArray, centre: Float, t: Float, need: Int): Boolean {
var runBright = 0
var runDark = 0
// Walk twice around so a run spanning index 15→0 is counted.
for (i in 0 until 32) {
val v = ring[i and 15]
runBright = if (v > centre + t) runBright + 1 else 0
runDark = if (v < centre - t) runDark + 1 else 0
if (runBright >= need || runDark >= need) return true
}
return false
}
Hough transform
data class HoughLine
A line in Hesse normal form: x*cos(theta) + y*sin(theta) = rho.
data class HoughLine(
/** Signed distance from the image origin (top-left) to the line, in pixels. */
val rho: Double,
/** Line normal angle in radians, in `[0, π)`. */
val theta: Double,
/** Accumulator strength: how many edge pixels voted for this line. */
val votes: Int
) {
fun clipTo(w: Int, h: Int): Pair<Vec2, Vec2>?
Clip the (infinite) line to the w x h image rectangle; null if it misses entirely.
fun clipTo(w: Int, h: Int): Pair<Vec2, Vec2>? {
val ct = cos(theta)
val st = sin(theta)
val pts = ArrayList<Vec2>(4)
fun add(x: Double, y: Double) {
if (x >= -1e-6 && x <= w - 1 + 1e-6 && y >= -1e-6 && y <= h - 1 + 1e-6) {
val v = Vec2(x.toFloat(), y.toFloat())
if (pts.none { abs(it.x - v.x) < 1e-3f && abs(it.y - v.y) < 1e-3f }) pts.add(v)
}
}
if (abs(st) > 1e-9) { add(0.0, rho / st); add((w - 1).toDouble(), (rho - (w - 1) * ct) / st) }
if (abs(ct) > 1e-9) { add(rho / ct, 0.0); add((rho - (h - 1) * st) / ct, (h - 1).toDouble()) }
return if (pts.size >= 2) pts[0] to pts[1] else null
}
}
fun houghLines
Standard Hough line transform over a binary edge mask.
Every non-zero pixel votes for every line through it; a real line in the image becomes a bright
peak in (rho, theta) accumulator space. The classic demonstration that a global structure
(a line) can be found from purely local evidence (edge pixels) with no grouping step.
@param edges 1-channel mask; any sample > 0.5 votes
@param thetaSteps angular resolution of the accumulator over [0, π)
@param rhoStep radial bin width in pixels
@param threshold minimum votes for a peak; <= 0 means 0.5 * max votes
@param maxLines cap on returned lines
@param minSeparationTheta / minSeparationRho non-maximum suppression in accumulator space
body — 59 lines, from line 478
fun houghLines(
edges: Img,
thetaSteps: Int = 360,
rhoStep: Double = 1.0,
threshold: Int = 0,
maxLines: Int = 20,
minSeparationTheta: Double = 0.15,
minSeparationRho: Double = 12.0
): List<HoughLine> {
require(edges.channels == 1) { "houghLines expects a 1-channel edge mask" }
require(thetaSteps >= 8) { "thetaSteps must be >= 8, got $thetaSteps" }
require(rhoStep > 0.0) { "rhoStep must be > 0, got $rhoStep" }
val w = edges.width
val h = edges.height
val rhoMax = sqrt((w * w + h * h).toDouble())
val rhoBins = (2 * rhoMax / rhoStep).toInt() + 1
val acc = IntArray(thetaSteps * rhoBins)
val cosT = DoubleArray(thetaSteps)
val sinT = DoubleArray(thetaSteps)
for (t in 0 until thetaSteps) {
val a = PI * t / thetaSteps
cosT[t] = cos(a); sinT[t] = sin(a)
}
for (y in 0 until h) for (x in 0 until w) {
if (edges.data[y * w + x] <= 0.5f) continue
for (t in 0 until thetaSteps) {
val rho = x * cosT[t] + y * sinT[t]
val b = ((rho + rhoMax) / rhoStep).roundToInt()
if (b in 0 until rhoBins) acc[t * rhoBins + b]++
}
}
val peak = acc.max()
if (peak == 0) return emptyList()
val cut = if (threshold > 0) threshold else max(2, (peak * 0.5).toInt())
val cand = ArrayList<HoughLine>()
for (t in 0 until thetaSteps) for (b in 0 until rhoBins) {
val v = acc[t * rhoBins + b]
if (v < cut) continue
// Local maximum in the accumulator (theta wraps, rho does not).
var isMax = true
loop@ for (dt in -1..1) for (db in -1..1) {
if (dt == 0 && db == 0) continue
val tt = Math.floorMod(t + dt, thetaSteps)
val bb = b + db
if (bb < 0 || bb >= rhoBins) continue
if (acc[tt * rhoBins + bb] > v) { isMax = false; break@loop }
}
if (!isMax) continue
cand.add(HoughLine(b * rhoStep - rhoMax, PI * t / thetaSteps, v))
}
cand.sortByDescending { it.votes }
val kept = ArrayList<HoughLine>()
for (c in cand) {
if (kept.size >= maxLines) break
if (kept.any { sameLine(it, c, minSeparationTheta, minSeparationRho) }) continue
kept.add(c)
}
return kept
}
Do two accumulator peaks describe the same line?
The subtlety that bites everyone: theta lives on [0, π), and the parametrisation is
antipodal — (theta, rho) and (theta + π, -rho) are the same line. So a near-vertical line
produces peaks at both theta ≈ 0, rho ≈ +d and theta ≈ π⁻, rho ≈ -d, and a naive
|rho_a - rho_b| comparison treats them as two different lines and reports the vertical twice.
This compares rho with the sign that matches how the thetas wrapped.
private fun sameLine(a: HoughLine, b: HoughLine, dTheta: Double, dRho: Double): Boolean {
val direct = abs(a.theta - b.theta)
val wrapped = PI - direct
return when {
direct < dTheta -> abs(a.rho - b.rho) < dRho
wrapped < dTheta -> abs(a.rho + b.rho) < dRho // theta wrapped by pi -> rho flips sign
else -> false
}
}
data class HoughCircle
A circle found by houghCircles.
data class HoughCircle(
/** Centre column, on the whole-pixel accumulator grid: these are not refined to sub-pixel. */
val x: Double,
/** Centre row, on the whole-pixel accumulator grid. */
val y: Double,
/** Radius in pixels, one of the radii that were tested — `minRadius + k * radiusStep`. */
val radius: Double,
/** Accumulator strength at the centre for this radius. */
val votes: Int
)
fun houghCircles
Hough circle transform over a binary edge mask, brute force over the radius range. Each edge pixel votes for the centres of all circles of each candidate radius passing through it.
Cost is O(edgePixels * radii * 360/angleStepDeg), so keep the radius range tight.
body — 62 lines, from line 577
fun houghCircles(
edges: Img,
minRadius: Int = 8,
maxRadius: Int = 40,
radiusStep: Int = 2,
threshold: Double = 0.5,
maxCircles: Int = 10,
minSeparation: Double = 10.0,
angleStepDeg: Double = 3.0
): List<HoughCircle> {
require(edges.channels == 1) { "houghCircles expects a 1-channel edge mask" }
require(minRadius >= 1 && maxRadius >= minRadius) { "need 1 <= minRadius <= maxRadius" }
require(radiusStep >= 1) { "radiusStep must be >= 1" }
val w = edges.width
val h = edges.height
val pts = ArrayList<Int>()
for (p in 0 until w * h) if (edges.data[p] > 0.5f) pts.add(p)
if (pts.isEmpty()) return emptyList()
val steps = max(8, (360.0 / angleStepDeg).toInt())
val cand = ArrayList<HoughCircle>()
var r = minRadius
while (r <= maxRadius) {
val acc = IntArray(w * h)
for (p in pts) {
val px = p % w
val py = p / w
for (s in 0 until steps) {
val a = 2.0 * PI * s / steps
val cx = (px - r * cos(a)).roundToInt()
val cy = (py - r * sin(a)).roundToInt()
if (cx in 0 until w && cy in 0 until h) acc[cy * w + cx]++
}
}
val expected = steps.toDouble()
val cut = max(4, (threshold * expected).toInt())
for (p in acc.indices) {
if (acc[p] < cut) continue
val x = p % w
val y = p / w
var isMax = true
loop@ for (dy in -1..1) for (dx in -1..1) {
if (dx == 0 && dy == 0) continue
val nx = x + dx; val ny = y + dy
if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue
if (acc[ny * w + nx] > acc[p]) { isMax = false; break@loop }
}
if (isMax) cand.add(HoughCircle(x.toDouble(), y.toDouble(), r.toDouble(), acc[p]))
}
r += radiusStep
}
cand.sortByDescending { it.votes }
val kept = ArrayList<HoughCircle>()
for (c in cand) {
if (kept.size >= maxCircles) break
if (kept.any {
val dx = it.x - c.x; val dy = it.y - c.y
sqrt(dx * dx + dy * dy) < minSeparation
}) continue
kept.add(c)
}
return kept
}
Morphology
enum class StructuringElement
Neighbourhood shape for the morphological operators.
enum class StructuringElement {
A (2r+1)² square (Chebyshev ball) — separable, and the cheapest.
SQUARE,
A discrete disc of radius r (Euclidean ball) — isotropic, no corner artefacts.
DISC,
The 4-neighbour plus (Manhattan ball).
CROSS
}
Offsets of a structuring element of radius radius.
private fun elementOffsets(shape: StructuringElement, radius: Int): Array<IntArray> {
val out = ArrayList<IntArray>()
for (dy in -radius..radius) for (dx in -radius..radius) {
val keep = when (shape) {
StructuringElement.SQUARE -> true
StructuringElement.DISC -> dx * dx + dy * dy <= radius * radius
StructuringElement.CROSS -> abs(dx) + abs(dy) <= radius
}
if (keep) out.add(intArrayOf(dx, dy))
}
return out.toTypedArray()
}
fun Img.erode(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img
Grayscale erosion: each pixel becomes the minimum over the structuring element. On a binary mask this shrinks bright regions and deletes anything thinner than the element.
fun Img.erode(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img =
morph(radius, shape, dilate = false)
fun Img.dilate(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img
Grayscale dilation: each pixel becomes the maximum over the structuring element. On a binary mask this grows bright regions and bridges gaps narrower than the element.
fun Img.dilate(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img =
morph(radius, shape, dilate = true)
private fun Img.morph(radius: Int, shape: StructuringElement, dilate: Boolean): Img {
require(radius >= 0) { "radius must be >= 0, got $radius" }
if (radius == 0) return copy()
val offs = elementOffsets(shape, radius)
val out = FloatArray(data.size)
for (y in 0 until height) for (x in 0 until width) for (c in 0 until channels) {
var best = if (dilate) Float.NEGATIVE_INFINITY else Float.POSITIVE_INFINITY
for (o in offs) {
val v = get(x + o[0], y + o[1], c, Border.CLAMP)
best = if (dilate) max(best, v) else min(best, v)
}
out[index(x, y, c)] = best
}
return Img(width, height, channels, out)
}
fun Img.open(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img
Erode then dilate — removes specks and thin protrusions, keeps the overall size of what remains.
fun Img.open(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img =
erode(radius, shape).dilate(radius, shape)
fun Img.close(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img
Dilate then erode — fills pinholes and hairline cracks, keeps the overall size.
fun Img.close(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img =
dilate(radius, shape).erode(radius, shape)
fun Img.morphologicalGradient(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img
dilate - erode: a one-pixel-ish outline of every bright region.
fun Img.morphologicalGradient(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img =
dilate(radius, shape) - erode(radius, shape)
fun Img.topHat(radius: Int = 3, shape: StructuringElement = StructuringElement.SQUARE): Img
image - open(image): bright detail smaller than the element (specks, text on a gradient).
fun Img.topHat(radius: Int = 3, shape: StructuringElement = StructuringElement.SQUARE): Img =
this - open(radius, shape)
fun Img.blackHat(radius: Int = 3, shape: StructuringElement = StructuringElement.SQUARE): Img
close(image) - image: dark detail smaller than the element.
fun Img.blackHat(radius: Int = 3, shape: StructuringElement = StructuringElement.SQUARE): Img =
close(radius, shape) - this
Connected components
data class Component
One labelled blob from connectedComponents.
data class Component(
/** The value this blob carries in the label map; 1-based, since 0 is background. */
val label: Int,
/** Pixel count. */
val area: Int,
/** Leftmost column of the bounding box, inclusive. */
val minX: Int,
/** Topmost row of the bounding box, inclusive. */
val minY: Int,
/** Rightmost column of the bounding box, inclusive. */
val maxX: Int,
/** Bottom row of the bounding box, inclusive. */
val maxY: Int,
/** Mean column over the blob's pixels. */
val centroidX: Double,
/** Mean row over the blob's pixels. */
val centroidY: Double
) {
val boundingWidth: Int get() = maxX - minX + 1
Bounding-box width in pixels; inclusive, so a single-pixel blob is 1 wide.
val boundingHeight: Int get() = maxY - minY + 1
Bounding-box height in pixels; inclusive, so a single-pixel blob is 1 high.
val fillRatio: Double get() = area.toDouble() / (boundingWidth * boundingHeight)
Area over bounding-box area — 1.0 for a filled rectangle, ~π/4 for a disc.
fun centroid(): Vec2 = Vec2(centroidX.toFloat(), centroidY.toFloat())
Centroid as a Vec2.
class ComponentLabelling
Result of a connected-component labelling.
class ComponentLabelling(
/** 1-channel image whose samples are the integer label of each pixel; 0 is background. */
val labels: Img,
/** Per-blob statistics, in label order. */
val components: List<Component>
) {
val count: Int get() = components.size
How many blobs were found (excluding background).
fun mask(label: Int): Img = labels.map { if (it.roundToInt() == label) 1f else 0f }
A binary mask selecting only the pixels of label.
fun largest(): Component? = components.maxByOrNull { it.area }
The largest blob by area, or null if there are none.
fun filterByArea(minArea: Int, maxArea: Int = Int.MAX_VALUE): List<Component>
Blobs with at least minArea pixels.
fun filterByArea(minArea: Int, maxArea: Int = Int.MAX_VALUE): List<Component> =
components.filter { it.area in minArea..maxArea }
override fun toString(): String
override fun toString(): String = "ComponentLabelling($count components in ${labels.width}x${labels.height})"
}
fun Img.connectedComponents(connectivity: Int = 8): ComponentLabelling
Two-pass connected-component labelling of a binary mask (samples > 0.5 are foreground), with per-blob area, bounding box and centroid.
@param connectivity 4 (edge-adjacent) or 8 (edge- or corner-adjacent)
body — 81 lines, from line 789
fun Img.connectedComponents(connectivity: Int = 8): ComponentLabelling {
require(channels == 1) { "connectedComponents expects a 1-channel binary mask" }
require(connectivity == 4 || connectivity == 8) { "connectivity must be 4 or 8, got $connectivity" }
val w = width
val h = height
val lab = IntArray(w * h)
val parent = ArrayList<Int>()
parent.add(0) // label 0 = background
fun find(a: Int): Int {
var r = a
while (parent[r] != r) r = parent[r]
var c = a
while (parent[c] != c) { val n = parent[c]; parent[c] = r; c = n }
return r
}
fun union(a: Int, b: Int) {
val ra = find(a); val rb = find(b)
if (ra != rb) parent[max(ra, rb)] = min(ra, rb)
}
// Pass 1: provisional labels + equivalences from already-visited neighbours.
val neigh = if (connectivity == 4) arrayOf(intArrayOf(-1, 0), intArrayOf(0, -1))
else arrayOf(intArrayOf(-1, 0), intArrayOf(0, -1), intArrayOf(-1, -1), intArrayOf(1, -1))
for (y in 0 until h) for (x in 0 until w) {
if (data[y * w + x] <= 0.5f) continue
var best = 0
for (n in neigh) {
val nx = x + n[0]; val ny = y + n[1]
if (nx < 0 || ny < 0 || nx >= w) continue
val l = lab[ny * w + nx]
if (l != 0) best = if (best == 0) l else min(best, l)
}
if (best == 0) { best = parent.size; parent.add(best) }
lab[y * w + x] = best
for (n in neigh) {
val nx = x + n[0]; val ny = y + n[1]
if (nx < 0 || ny < 0 || nx >= w) continue
val l = lab[ny * w + nx]
if (l != 0) union(best, l)
}
}
// Pass 2: resolve to compact 1..n labels and accumulate stats.
val remap = HashMap<Int, Int>()
val out = FloatArray(w * h)
val areas = ArrayList<Int>()
val minXs = ArrayList<Int>(); val minYs = ArrayList<Int>()
val maxXs = ArrayList<Int>(); val maxYs = ArrayList<Int>()
val sumXs = ArrayList<Double>(); val sumYs = ArrayList<Double>()
for (y in 0 until h) for (x in 0 until w) {
val l = lab[y * w + x]
if (l == 0) continue
val root = find(l)
val id = remap.getOrPut(root) {
areas.add(0); minXs.add(w); minYs.add(h); maxXs.add(-1); maxYs.add(-1)
sumXs.add(0.0); sumYs.add(0.0)
remap.size + 1
}
val i = id - 1
out[y * w + x] = id.toFloat()
areas[i] = areas[i] + 1
if (x < minXs[i]) minXs[i] = x
if (y < minYs[i]) minYs[i] = y
if (x > maxXs[i]) maxXs[i] = x
if (y > maxYs[i]) maxYs[i] = y
sumXs[i] = sumXs[i] + x
sumYs[i] = sumYs[i] + y
}
val comps = (0 until remap.size).map { i ->
Component(
label = i + 1,
area = areas[i],
minX = minXs[i], minY = minYs[i], maxX = maxXs[i], maxY = maxYs[i],
centroidX = sumXs[i] / areas[i],
centroidY = sumYs[i] / areas[i]
)
}
return ComponentLabelling(Img(w, h, 1, out), comps)
}
fun Img.distanceTransform(): Img
Euclidean distance transform of a binary mask: each foreground pixel's value becomes its distance (in pixels) to the nearest background pixel.
Uses Felzenszwalb & Huttenlocher's exact O(n) two-pass squared-distance algorithm (a lower
envelope of parabolas per row then per column) — exact Euclidean, not the chamfer approximation.
fun Img.distanceTransform(): Img {
require(channels == 1) { "distanceTransform expects a 1-channel binary mask" }
val w = width
val h = height
val inf = 1e20
val f = DoubleArray(w * h) { if (data[it] > 0.5f) inf else 0.0 }
// columns
val tmp = DoubleArray(max(w, h))
val v = IntArray(max(w, h))
val z = DoubleArray(max(w, h) + 1)
fun edt1d(src: DoubleArray, n: Int, read: (Int) -> Double, write: (Int, Double) -> Unit) {
for (i in 0 until n) src[i] = read(i)
var k = 0
v[0] = 0
z[0] = -inf
z[1] = inf
for (q in 1 until n) {
var s: Double
while (true) {
val p = v[k]
s = ((src[q] + q * q) - (src[p] + p * p)) / (2.0 * q - 2.0 * p)
if (s <= z[k]) k-- else break
}
k++
v[k] = q
z[k] = s
z[k + 1] = inf
}
k = 0
for (q in 0 until n) {
while (z[k + 1] < q) k++
val p = v[k]
write(q, (q - p).toDouble() * (q - p) + src[p])
}
}
val col = DoubleArray(max(w, h))
for (x in 0 until w) edt1d(col, h, { f[it * w + x] }, { i, d -> f[i * w + x] = d })
for (y in 0 until h) edt1d(col, w, { f[y * w + it] }, { i, d -> f[y * w + i] = d })
return Img(w, h, 1, FloatArray(w * h) { sqrt(f[it]).toFloat() })
}
fun Img.traceContours(minPerimeter: Int = 8): List<List<Vec2>>
Trace the outline of every blob in a binary mask as a closed pixel-boundary polygon
(Moore-neighbour tracing). Complements connectedComponents' statistics with actual geometry —
feed the result to contoursToFLines in imaging_field.kt to draw it.
@param minPerimeter drop contours shorter than this many boundary pixels (noise suppression) @return one closed point list per blob outer boundary, in image coordinates
body — 43 lines, from line 927
fun Img.traceContours(minPerimeter: Int = 8): List<List<Vec2>> {
require(channels == 1) { "traceContours expects a 1-channel binary mask" }
val w = width
val h = height
fun fg(x: Int, y: Int) = x in 0 until w && y in 0 until h && data[y * w + x] > 0.5f
val visited = BooleanArray(w * h)
val out = ArrayList<List<Vec2>>()
// Clockwise Moore neighbourhood, starting west.
val nx = intArrayOf(-1, -1, 0, 1, 1, 1, 0, -1)
val ny = intArrayOf(0, -1, -1, -1, 0, 1, 1, 1)
for (y in 0 until h) for (x in 0 until w) {
if (!fg(x, y) || visited[y * w + x]) continue
if (fg(x - 1, y)) continue // not a left-edge boundary start
val pts = ArrayList<Vec2>()
var cx = x
var cy = y
// The start pixel was reached from the west (guaranteed background by the test above), so
// pretend we arrived travelling east; the scan then begins just clockwise of the backtrack.
var dir = 4
val startX = x
val startY = y
var guard = 0
val maxSteps = 8 * w * h
while (guard++ < maxSteps) {
visited[cy * w + cx] = true
pts.add(Vec2(cx.toFloat(), cy.toFloat()))
var found = false
// Moore-neighbour sweep: clockwise, starting one step past the pixel we came from.
for (k in 0 until 8) {
val d = (dir + 5 + k) % 8
val tx = cx + nx[d]
val ty = cy + ny[d]
if (fg(tx, ty)) {
cx = tx; cy = ty; dir = d; found = true; break
}
}
if (!found) break // isolated pixel
if (cx == startX && cy == startY && pts.size > 2) break
}
if (pts.size >= minPerimeter) out.add(pts)
}
return out
}