Skip to content

Imaging and computer vision

afield.media.image can look at a picture as well as make one. Its CPU imaging and computer-vision family has a single value type, Img, and around it a set of operations that turn a raster into structure, from convolution and gradients up through edges, corners, lines, blobs, pyramids, warps and motion. It is the raster counterpart to the time-domain timeseries toolkit and the frequency-domain spectral family, and it is built the same way: pure Kotlin, no external dependency, works headless in a box.

Every detector here reduces pixels to geometry, and geometry in Field is an FLine, so a Canny result, a set of corners, a Hough line or an optical-flow field lands in the same drawing, animation and export machinery as anything you drew by hand. Scalar responses colour-map through the same Colormaps tables RectSL uses. And Img implements ToHTML, so evaluating one in a box shows the picture, not Img@3f1a2b.

For learned estimates of depth and image content, see depth and CLIP, which runs neural models over the same RGBA bytes an Img can produce.

The value type

An image is a size, a channel count and a flat array of floats:

Img(width, height, channels, data)

width * height * channels floats, interleaved, row-major, origin top-left, the same layout BufferedImage, PNG and a GPU upload all want. Channels are 1 (scalar / gray / mask / gradient / distance), 3 (RGB) or 4 (RGBA); nothing else.

Three choices change what the numbers mean:

  • Float, not bytes. Every operation is float math. Operations keep floating-point values through the pipeline instead of quantising to bytes at every stage; clamping happens on output.
  • Signed where it should be. Gradients, Laplacians and differences legitimately go negative and are left that way. colormapDiverging(...) is the right way to look at them.
  • Gradients are in true units-per-pixel. The Sobel derivative factor is [-1, 0, 1]/2 and the smoothing factor is normalised, so sobel().dx on a ramp of slope s really is s, and a unit step edge integrates to a total gradient of exactly 1. A threshold like 0.05 therefore means the same thing on every gradient operator here.

Build one from a function, a file, a BufferedImage, PNG bytes or a plain FloatArray:

val ramp = Img.generate(64, 64) { x, _ -> x / 63f }
val photo = imgFromFile("cat.png") // javax.imageio under the hood
val fromGl = floatsToImg(heights, w, h) // a heightfield, a spectrogram, any scalar grid

Basics — resample, luma, HSV, threshold

resize area-averages when it shrinks (the anti-aliased answer) and interpolates bilinearly when it grows. luma() is BT.709. otsuThreshold() computes the best two-way split of the histogram rather than making you guess a number.

:::static
// An Img is width x height x channels floats, interleaved, origin top-left.
// Build one straight from a function of (x, y) — the usual way to make a test image.
val card =
    Img.generateRGB(240, 180) { x, y ->
        val u = x / 239f
        val v = y / 179f
        val disc = if (hypot(x - 60.0, y - 60.0) <= 34.0) 1f else 0f
        val bar = if (y in 130..142 && x in 20..219) 1f else 0f
        Triple(
            (0.15f + 0.7f * u * (1f - disc) + disc * 0.9f + bar).coerceIn(0f, 1f),
            (0.15f + 0.6f * v + disc * 0.25f + bar).coerceIn(0f, 1f),
            (0.55f - 0.4f * u + disc * 0.1f + bar).coerceIn(0f, 1f),
        )
    }

val gray = card.luma() // BT.709 perceptual grayscale
val hsv = card.rgbToHsv() // h, s, v each in 0..1
val small = card.resize(60, 45) // area-averaged (anti-aliased) downsample
val backUp = small.resize(240, 180) // bilinear upsample: the detail is really gone
val t = gray.otsuThreshold() // computed, not guessed

showImgGrid(
    "source RGB (${card.width}x${card.height})" to card,
    "luma()" to gray,
    "hue channel" to hsv.channel(0),
    "saturation channel" to hsv.channel(1),
    "resize(60,45) then back up" to backUp,
    "thresholdOtsu() @ ${t.f3}" to gray.threshold(t),
    columns = 3,
    cellSide = 260,
)
println(
    "Img ${card.width}x${card.height} x${card.channels} ch, mean ${card.mean().f3}, " +
        "otsu ${t.f3}, foreground ${gray.threshold(t).data.count { it > 0.5f }} px",
)

showImgGrid(...) is the fastest way to see a whole pipeline: it lays captioned panels out side by side, maps every panel through one shared display range, and pushes one PNG to the box output through Img.toPngBytes() → PngEncoder → display(...). Ordinary images use 0..1; out-of-range panels choose one common automatic range, or you can pass displayRange = -1f..1f (for example). No AWT is involved, so it works anywhere.

Choosing a filter

This box runs four smoothers over the same noisy image and prints each one's residual against the clean source. On impulse noise the median is two orders of magnitude better than the Gaussian. The bilateral filter, which is excellent at preserving edges, is bad at impulses: a salt pixel is far away in range and so keeps its own weight.

:::static
// A clean synthetic source, and the same source with 4% salt-and-pepper corruption.
val clean =
    Img.generate(240, 180) { x, y ->
        var v = 0.12f + 0.10f * (y / 180f)
        if (hypot(x - 60.0, y - 70.0) <= 34.0) v = 0.85f
        if (x in 120..185 && y in 30..95) v = 0.65f
        if (y in 135..142 && x in 20..219) v = 0.95f
        v
    }
val rng = kotlin.random.Random(11)
val noisy = clean.copy()
repeat((noisy.pixelCount * 0.04).toInt()) {
    noisy.data[rng.nextInt(noisy.pixelCount)] = if (rng.nextBoolean()) 0f else 1f
}

val gauss = noisy.gaussianBlur(2.0) // separable, normalised: preserves the image mean
val med = noisy.median(2) // the right tool for impulse noise
val bil = noisy.bilateral(sigmaSpace = 3.0, sigmaRange = 0.12)
val sharp = clean.gaussianBlur(2.0).unsharpMask(sigma = 2.0, amount = 1.4)

showImgGrid(
    "clean source" to clean,
    "+ 4% salt-and-pepper" to noisy,
    "gaussianBlur(2.0)" to gauss,
    "median(2)" to med,
    "bilateral(3.0, 0.12)" to bil,
    "blur then unsharpMask(2.0, 1.4)" to sharp,
    columns = 3,
    cellSide = 260,
)
println(
    "residual vs clean — gaussian ${gauss.meanAbsDiff(clean).f4}, " +
        "median ${med.meanAbsDiff(clean).f4}, " +
        "bilateral ${bil.meanAbsDiff(clean).f4}",
)

boxBlur(radius) is also there, computed with running sums so its cost is independent of the radius, and boxBlurApproxGaussian(sigma) chains three of them when sigma is large and you care more about speed than about the exact kernel shape.

Gradients, edges, and the FLines they become

sobel() returns a Gradient: dx, dy, and derived magnitude(), orientation() and a hue-encoded toColorImg(). canny() runs the four classic stages (smooth, gradient, non-maximum suppression, hysteresis) and returns a thin binary mask. toContourFLines(...) traces that mask and hands back drawable FLines, simplified with Ramer–Douglas–Peucker so a staircase pixel boundary becomes a handful of straight runs.

:::static
val src =
    Img.generate(240, 180) { x, y ->
        var v = 0.12f + 0.08f * (y / 180f)
        if (hypot(x - 62.0, y - 70.0) <= 34.0) v = 0.85f
        if (x in 120..185 && y in 30..95) v = 0.68f
        val tx = x - 210.0
        val ty = y - 40.0
        if (ty in 0.0..60.0 && kotlin.math.abs(tx) <= ty * 0.5) v = 0.95f
        v
    }

// Sobel here is scaled to true units-per-pixel: a unit step edge integrates to 1.
val grad = src.sobel()
val edges = src.canny(low = 0.05, high = 0.11, sigma = 1.2)
val auto = src.cannyAuto() // thresholds derived from this image's own gradients

// Edges are geometry, and geometry in Field is an FLine.
val flines = edges.toContourFLines(minPerimeter = 12, simplifyTolerance = 1.2)

showImgGrid(
    "source" to src,
    "sobel().dx (signed, coolwarm)" to grad.dx.colormapDiverging("coolwarm"),
    "sobel().dy (signed, coolwarm)" to grad.dy.colormapDiverging("coolwarm"),
    "magnitude (inferno)" to grad.magnitude().normalized().colormap("inferno"),
    "orientation as hue x magnitude" to grad.toColorImg(),
    "canny(0.05, 0.11)" to edges,
    columns = 3,
    cellSide = 260,
)
println(
    "|grad| max ${grad.magnitude().max().f3}; " +
        "canny ${edges.data.count { it > 0.5f }} px, cannyAuto ${auto.data.count { it > 0.5f }} px; " +
        "${flines.size} FLines, ${flines.sumOf { it.nodes.size }} nodes total",
)

dx fires on vertical edges and dy on horizontal ones; the orientation panel wheels its hue right around the disc, because that is what the gradient direction does.

Canny on low-contrast images

A (low, high) pair tuned on a bright synthetic card finds almost nothing in a dark, softly-lit photograph. The thresholds are in gradient-magnitude units, and a dark image does not reach them. cannyAuto(...) derives them from the image's own distribution: it takes a percentile of the non-maximum-suppressed magnitudes as the strong seed threshold and Canny's own recommended ratio for the weak one.

The figure below is a real repo PNG run through the whole pipeline. The fixed pair finds 252 edge pixels, barely the car at all. cannyAuto() finds 1952, and traces the whole silhouette; the last panel is those edges as 57 FLines drawn back over the source.

Corners

Three detectors, all on a square whose corners are at exactly known coordinates, so each detector's answer can be measured against the truth.

  • harrisResponse() — \( \det(M) - k\,\operatorname{trace}(M)^2 \) over the structure tensor. Large and positive at a corner, negative along an edge, near zero on flat ground; the diverging colour map in the figure is showing exactly that sign structure.
  • shiTomasiResponse() — the smaller eigenvalue of the same tensor. No magic k, and directly interpretable: it is the weakest intensity variation over any direction, so a large value means the point is localisable in both axes. A straight edge scores ~0, which is the aperture problem stated numerically. This is the response an optical-flow tracker wants, and goodFeaturesToTrack(...) is its friendlier name.
  • fastCorners() — no gradients and no eigenvalues, just a wrap-around run-length test on a 16-pixel circle. Much faster, and it scores each corner by the largest threshold at which it would still qualify.

Four panels. The Harris response is blue along the square's edges and red at its four corners; the Shi–Tomasi response is dark except for a bright spot at each corner; Harris and FAST corners are marked on the square with crosses and boxes

The two response maps are drawn from a 240 px copy of the square, shown 1:1, and raised to the 0.25 power so the weak values show.

Run headless, Harris and Shi–Tomasi each return exactly four corners, and the worst of the four sits 0.94 px from the true corner of the square (sub-pixel refinement is on by default, via a three-tap parabola fit in x and y). cornersToFLines(...) turns them into drawable crosses or boxes.

Hough lines

Every edge pixel votes for every line through it; a real line becomes a bright peak in (rho, theta) accumulator space, with no grouping step in between.

The figure has three planted lines and a decoy ring. All three lines come back at their exact polar coordinates: (rho=1500, θ=0.00), (rho=400, θ=1.57), (rho=283, θ=2.36). The ring is not fitted.

HoughLine.clipTo(w, h) clips the infinite line to the image rectangle, and linesToFLines(...) gives you the visible segments as FLines. houghCircles(...) is there too, for a known radius range.

Morphology, blobs and distance

erode / dilate / open / close with square, disc or cross structuring elements; connectedComponents(...) for labels plus per-blob area, bounding box, centroid and fill ratio; distanceTransform() for the exact Euclidean distance to the nearest background pixel (the Felzenszwalb–Huttenlocher two-pass algorithm, not a chamfer approximation).

:::static
// Three disjoint discs of known radius, then noise on top of them.
val discs = [Triple(55.0, 60.0, 28.0), Triple(150.0, 55.0, 20.0), Triple(110.0, 135.0, 14.0)]
val clean =
    Img.generate(220, 180) { x, y ->
        if (discs.any { (cx, cy, r) -> hypot(x - cx, y - cy) <= r }) 0.85f else 0.15f
    }
val rng = kotlin.random.Random(3)
val noisy = clean.copy()
repeat((noisy.pixelCount * 0.03).toInt()) {
    noisy.data[rng.nextInt(noisy.pixelCount)] = if (rng.nextBoolean()) 0f else 1f
}

val mask = noisy.thresholdOtsu()
val cleaned = mask.open(2, StructuringElement.DISC).close(2, StructuringElement.DISC)
val cc = cleaned.connectedComponents(8)
val dt = cleaned.distanceTransform()

showImgGrid(
    "noisy source" to noisy,
    "thresholdOtsu()" to mask,
    "open(2,DISC).close(2,DISC)" to cleaned,
    "erode(4)" to cleaned.erode(4, StructuringElement.DISC),
    "dilate(4)" to cleaned.dilate(4, StructuringElement.DISC),
    "distanceTransform (magma)" to dt.normalized().colormap("magma"),
    columns = 3,
    cellSide = 250,
)
println("${cc.count} components after cleaning (planted ${discs.size} discs):")
cc.components
    .sortedByDescending { it.area }
    .forEach {
        println(
            "  area=${it.area} centroid=(${it.centroidX.f1}, ${it.centroidY.f1}) " +
                "bbox=${it.boundingWidth}x${it.boundingHeight} fill=${it.fillRatio.f2}",
        )
    }
println("distance-transform peak ${dt.max().f1} (largest planted radius ${discs.maxOf { it.third }})")

Three discs in, three components out, with areas of 2419, 1226 and 584 against the analytic \( \pi r^2 \) of 2463, 1257 and 616 (the shortfall is the open(2) erosion), and centroids at (54.8, 60.1), (150.1, 55.0) and (110.2, 134.9) against planted centres of (55, 60), (150, 55) and (110, 135).

For sub-pixel-smooth contours rather than a pixel-staircase boundary, isoContourFLines(iso) adapts the image to the ScalarField2D that Field's existing marching-squares extractor (contours) already marches, so a distance transform or a blurred response becomes a smooth curve with no glue code.

Multiscale

gaussianPyramid(levels) halves each octave (blurring first, so it does not alias); laplacianPyramid(levels) splits the image into band-pass detail plus a coarse residual, and reconstruct() inverts it. The round-trip is exact to within float precision, at a maximum absolute error of 5.96e-08 on the figure below. That is small enough to build multiband blending and detail transfer on, rather than only to look at.

A 240 px test image with a finely checked disc, a striped square, a row of dots and a thin bar, taken down a four-level Gaussian pyramid: the check is gone at 120 px, the stripes at 60 px, and at 30 px only the shapes are left. The three Laplacian bands hold the check and the sharp edges, then the stripes, then the broad outlines

(Levels below the base are magnified by integer pixel replication for display; each caption states the real size and the zoom factor.)

Geometry — affine and projective warps

Affine2D uses the same six-number layout as java.awt.geom.AffineTransform and SVG's matrix(a, b, c, d, e, f). Homography is the 3×3 projective version. warpAffine and warpPerspective map source → destination and invert internally, doing the correct backwards resample, so there are no holes.

findHomography(src, dst) is the Direct Linear Transform: exact through four correspondences, least-squares through more. The points are Hartley-normalised first; without that, the conditioning on pixel-scale coordinates is bad enough to visibly distort the fit. warpQuadToRect(quad, w, h) wraps it up as the "flatten the photographed page" operation.

The rectified checkerboard matches the original to a mean absolute error of 0.0036 over the interior.

Motion

opticalFlowLK(a, b, points) is pyramidal Lucas–Kanade. Seed it with goodFeaturesToTrack(...), which selects exactly the points where the 2×2 tracking system is well conditioned, and it returns a FlowField of FlowVectors, each of which reports whether it converged. A track is marked ok = false when the system is ill-conditioned (a smooth region, or a straight edge) or the point leaves the frame.

The pyramid is there for large motions. Solve at the coarsest octave, where the displacement is small enough for the linearisation to hold, then propagate the estimate down. A 9-pixel shift that a single level cannot recover comes back to within half a pixel with four.

:::static
// Two frames related by a transform we choose, so the answer is known in advance.
val a =
    Img.generate(200, 200) { x, y ->
        (0.5 + 0.24 * kotlin.math.sin(x * 0.21) * kotlin.math.cos(y * 0.17) + 0.16 * kotlin.math.sin((x + y) * 0.11))
            .toFloat()
            .coerceIn(0f, 1f)
    }
val planted = Affine2D.rotationAbout(0.05, 100.0, 100.0).compose(Affine2D.translation(5.0, -3.0))
val b = a.warpAffine(planted, border = Border.CLAMP)

// Seed only where the 2x2 tracking system is well conditioned.
val seeds =
    a.goodFeaturesToTrack(maxCount = 200, minDistance = 9)
        .filter { it.x in 24.0..176.0 && it.y in 24.0..176.0 }
        .map { it.toVec2() }
val flow = opticalFlowLK(a, b, seeds, windowRadius = 8, levels = 3)
val fit = findHomographyRansac(
    flow.sourcePoints(),
    flow.targetPoints(),
    threshold = 1.5,
    iterations = 1200,
    seed = 3L,
)

// How far is the recovered model from the one we planted, in pixels, on a probe grid?
var worst = 0.0
for (gx in 40..160 step 40) for (gy in 40..160 step 40) {
    val (fx, fy) = fit.homography.apply(gx.toDouble(), gy.toDouble())
    val (tx, ty) = planted.apply(gx.toDouble(), gy.toDouble())
    worst = maxOf(worst, hypot(fx - tx, fy - ty))
}

showImgGrid(
    "frame A" to a,
    "frame B (rot 0.05 rad + shift 5,-3)" to b,
    "|A - B| (inferno)" to frameDifference(a, b).normalized().colormap("inferno"),
    "B warped back by the fitted model" to b.warpPerspective(fit.homography.inverse(), border = Border.CLAMP),
    columns = 2,
    cellSide = 280,
)
val (mdx, mdy) = flow.medianDisplacement()
println(
    "${flow.tracked().size}/${seeds.size} tracked; median displacement " +
        "(${mdx.f2}, ${mdy.f2}) px",
)
println("RANSAC ${fit.inlierCount} inliers of ${flow.tracked().size} after ${fit.iterations} iterations")
println("recovered model agrees with the planted transform to ${worst.f3} px over the probe grid")
println(fit.homography.toString())

With findHomographyRansac(...) half the correspondences can be garbage and the answer is still right, because a wrong model has no reason to be consistently wrong across many points. It samples four correspondences at a time, scores the consensus, keeps the best, and refits least-squares on the inliers. The seed is explicit, so a docs capture or a test gets the same answer every time.

opticalFlowBlockMatch(...) is the brute-force alternative for displacements large enough to break Lucas–Kanade's linearisation, and frameDifference(...) / updateBackground(...) are the crude, reliable motion cues; pair them with open(...) to drop sensor noise and connectedComponents(...) to count what moved.

FlowField.toFLines(scale, ...) draws the whole field as arrows, shaft plus arrowhead, so direction is visible and not just magnitude:

Colour

luma(), rgbToHsv() / hsvToRgb(), and toLinear() / toSrgb(). Blurring, downsampling and averaging are only correct in linear light, so img.toLinear().gaussianBlur(4.0).toSrgb() is measurably different from blurring the encoded values. The panel below shows both, and the amplified difference between them.

colormap(name, lo, hi) runs a 1-channel image through any of the Colormaps ramps (viridis, magma, inferno, plasma, turbo, coolwarm, grayscale); colormapDiverging(name) picks a symmetric range around zero, the right default for anything signed.

Trying it yourself

The imaging_cv_examples starting point builds the examples on this page as live boxes. Change a sigma, a threshold or the planted motion, re-run the box, and watch the picture update.

Agents: where the figures come from

The images under the code fences are those boxes' own output, captured by the docs build. The figures without a fence come from the imaging.gallery FIELD_TEST (python3 tools/run_field_tests.py --id imaging.gallery), which builds each one from real Img pipelines and fails if Canny stops being thinner than a raw threshold, the corner marks drift off the square's corners, Hough stops recovering the planted lines, the Laplacian pyramid stops round-tripping, or a flow arrow disagrees with the planted motion.

Speed, against OpenCV

These filters are pure Kotlin with no native dependency. The repository carries a benchmark that puts them beside OpenCV in the same JVM, on the same pixels: tools/imaging-bench, run with

./gradlew :tools:imaging-bench:jmh

The table below compares the two in milliseconds per call.

How the kernels are written

Underneath is a small layer of vectorized span kernels (Java Vector API, imaging_simd.kt). Three operations are fast mostly because they do less work: pyrDown filters only the rows and columns that survive the decimation, boxBlur accumulates a row at a time instead of a column, and bilateral reads a table instead of calling Math.exp per tap. dev_docs/IMAGING_VECTOR_KERNELS.md describes the layer and has the checklist for adding an operator.

operation before (scalar) now OpenCV 4.9, 1 thread now vs OpenCV
Gaussian blur, sigma 2, gray 91.59 ± 2.61 7.09 ± 0.07 7.13 ± 0.39 0.99x
Gaussian blur, sigma 2, RGB 263 ± 8.68 19.61 ± 0.15 21.81 ± 0.48 0.90x
Sobel 52.94 ± 0.13 4.49 ± 0.04 2.09 ± 0.03 2.14x
Scharr — 4.49 ± 0.04 2.21 ± 0.03 2.03x
Laplacian — 2.68 ± 0.02 1.61 ± 0.08 1.66x
Dense 11x11 convolution — 26.19 ± 0.21 27.22 ± 0.91 0.96x
Luma (RGB to gray) — 1.96 ± 0.01 1.14 ± 0.01 1.72x
Box blur, radius 2 — 2.60 ± 0.03 1.89 ± 0.10 1.38x
Median, radius 2 — 425 ± 5.30 120 ± 7.82 3.53x
Resize to half — 3.65 ± 0.06 0.73 ± 0.01 4.99x
Bilateral — 749 ± 95.18 628 ± 4.25 1.19x
pyrDown — 2.07 ± 0.02 0.50 ± 0.02 4.12x

Bold means we are faster. A dash in the "before" column means that operation has no preserved scalar original to compare against, not that its speedup is unknown.

Gaussian blur, box blur and the Laplacian run the same operation with the same parameters on both sides, and the benchmark fails if their outputs stop agreeing. The rest are matched in work but not in definition, so their ratios compare slightly different operations: our luma is BT.709 where cvtColor is BT.601; our Sobel and Scharr are scaled to true units-per-pixel, so ours is OpenCV's divided by a constant; our pyrDown takes a configurable sigma where OpenCV's is a fixed 5-tap kernel; and median/bilateral have matched parameters but different internal algorithms. Our API also returns a fresh Img each call, so part of our cost is an allocation OpenCV is not paying.

Gaussian blur, dense convolution and the RGB blur are at or slightly past OpenCV; box blur, the Laplacian, Scharr, Sobel, luma and bilateral are all inside about 2x, close enough that the pure-Kotlin path is the reasonable default. bilateral's interval is wide, so read its row as "about 1.2x". Three rows are meaningfully behind, and you should know which before choosing:

  • median is an algorithmic gap: it sorts 25 floats per pixel where OpenCV uses a sorting network.
  • resize to half has data-dependent inner loops that do not vectorize.
  • pyrDown is doing more arithmetic, not doing it worse: ours applies gaussianKernel1D(sigma), 9 taps at the default, where OpenCV's pyrDown is a fixed 5-tap [1,4,6,4,1]/16. That is close to twice the work, in exchange for a better filter and a sigma that is yours to choose.

The benchmark's conditions, the agreement tolerances, and what the absolute milliseconds depend on are in dev_docs/IMAGING_MEASUREMENTS.md.

Reference

Value type

Load, construct and save floating-point images.

Entry point Behavior
Img A CPU raster image: width * height * channels floats, interleaved, row-major, origin top-left (the image-processing convention, matching BufferedImage and PNG). API.
Border How a filter resolves coordinates that fall outside the image. API.
fun imgFromFile(path: String, channels: Int? = null): Img Loads an image from a file path, straight through ImageIO — the path is used verbatim and does not go through FileManager, so a workspace-relative spec will not resolve here. API.
fun imgFromBytes(bytes: ByteArray, channels: Int? = null): Img Decode an image from in-memory encoded bytes (PNG/JPEG/… — whatever ImageIO handles). API.
fun floatsToImg(values: FloatArray, width: Int, height: Int): Img Wrap a plain FloatArray of scalars as a 1-channel image — the bridge from the rest of the stdlib (a heightfield, a spectrogram frame stack, a DoubleArray reduction) into imaging. API.
fun Img.toBufferedImage(): BufferedImage Convert to a BufferedImage for AWT drawing / ImageIO. Samples are clamped to 0..1 and quantised to 8 bits at this point (and only at this point). A 1-channel image becomes gray RGB. API.
fun Img.toPngBytes(): ByteArray Encode straight to PNG bytes with no AWT (via PngEncoder) — the path a box uses to put a picture in its own output, and the path Img.toHTML uses for its thumbnail. API.
fun Img.savePng(path: String): File Write this image to path as a PNG (creating parent directories). Returns the file. API.

Sampling / shape

Change the image extent or sample positions. Use nearest sampling for discrete labels and bilinear sampling for interpolated image values.

Entry point Behavior
fun Img.sampleNearest(x: Double, y: Double, c: Int = 0, border: Border = Border.CLAMP): Float Nearest-neighbour sample at continuous coordinates. API.
fun Img.sampleBilinear(x: Double, y: Double, c: Int = 0, border: Border = Border.CLAMP): Float Bilinear sample at continuous coordinates — pixel centres sit at integer coordinates, so sampleBilinear(3.0, 4.0) == at(3, 4) exactly. API.
fun Img.crop(x: Int, y: Int, w: Int, h: Int): Img Rectangular sub-image. Coordinates are clamped into range; the result is at least 1x1. API.
fun Img.pad(pad: Int, border: Border = Border.CLAMP): Img Grow the image by pad pixels on every side, filling from border. API.
fun Img.resize(newWidth: Int, newHeight: Int, areaAverage: Boolean = true): Img Resize to newWidth x newHeight. API.
fun Img.scaleBy(factor: Double, areaAverage: Boolean = true): Img Resize by a scale factor (both axes). See resize. API.
fun Img.flipX(): Img Mirror left-right. API.
fun Img.flipY(): Img Mirror top-bottom. API.
fun Img.rotate90(): Img Rotate 90 degrees clockwise (width and height swap). API.
fun Img.transpose(): Img Transpose (x and y swap). API.

Channels / colour

Select channels or change colour representations.

Entry point Behavior
fun Img.channel(c: Int): Img Extract channel c as a 1-channel image. API.
fun Img.split(): List<Img> Split into one 1-channel image per channel. API.
fun mergeChannels(vararg parts: Img): Img Merge 1-channel images into a multi-channel image. All inputs must share a shape and the count must be 1, 3 or 4. API.
fun Img.luma(): Img Perceptual grayscale (BT.709 luma) as a 1-channel image. A 1-channel input is returned as a copy; an RGBA input ignores alpha. API.
fun Img.toRGB(): Img Replicate a 1-channel image into 3 channels (the inverse direction of luma). API.
fun Img.toRGBA(alpha: Float = 1f): Img Add an opaque (or alpha-valued) alpha channel. API.
fun Img.toLinear(): Img Decode sRGB-encoded samples to linear light. Blurring, downsampling and averaging are only correct in linear light; img.toLinear().gaussianBlur(2.0).toSrgb() is the physically right blur, and visibly different from blurring the encoded values. API.
fun Img.toSrgb(): Img Encode linear-light samples back to sRGB. Inverse of toLinear. API.
fun Img.rgbToHsv(): Img RGB → HSV, in place of the channels: h in 0..1 (hue angle / 360), s in 0..1, v in 0..1. Requires a 3- or 4-channel image; alpha is dropped. API.
fun Img.hsvToRgb(): Img HSV → RGB, the inverse of rgbToHsv. API.

Statistics

Measure or remap intensity distributions.

Entry point Behavior
fun normalized(): Kernel1D A copy scaled so the taps sum to 1. Throws for a zero-sum (derivative) kernel. API.
fun Img.normalizedPercentile(pLow: Double = 0.01, pHigh: Double = 0.99): Img Robust normalisation: map the pLow/pHigh percentiles of the sample distribution to 0..1 and clamp. Far more useful than normalized on noisy gradient/response images, where one hot pixel otherwise squashes everything else to black. API.
fun Img.histogram(bins: Int = 256, lo: Float = 0f, hi: Float = 1f): IntArray Histogram of a 1-channel image over [lo, hi] with bins equal-width buckets. Values outside the range land in the first/last bucket. API.
fun Img.equalize(bins: Int = 256): Img Histogram equalisation of a 1-channel image: remap intensities through the cumulative histogram so the output distribution is (approximately) flat. Classic contrast enhancement. API.
fun Img.otsuThreshold(bins: Int = 256): Float Otsu's method: the intensity threshold in [lo, hi] that maximises between-class variance — i.e. the best two-way split of a bimodal histogram, computed rather than guessed. API.
fun Img.threshold(threshold: Float): Img Binarise: samples > threshold become 1, everything else 0. Result is 1-channel. API.
fun Img.thresholdOtsu(bins: Int = 256): Img Binarise at the otsuThreshold. API.
fun Img.thresholdAdaptive(radius: Int = 8, bias: Float = 0.02f): Img Adaptive (local-mean) threshold: each pixel is compared to the mean of a (2*radius+1) square neighbourhood, minus bias. Handles uneven illumination that defeats a global threshold. API.
fun Img.meanAbsDiff(other: Img): Double Mean absolute difference per sample against other — a blunt but unbiased similarity measure. API.
fun Img.psnr(other: Img): Double Peak signal-to-noise ratio in dB against other, assuming a 0..1 signal range. API.

Filters

Smooth, sharpen or apply a custom kernel.

Entry point Behavior
Kernel1D A 1-D convolution kernel: taps centred on the middle element (so the length is normally odd). API.
Kernel2D A dense 2-D convolution kernel, width x height, centred (odd dimensions expected). API.
fun Img.convolveSeparable(kx: Kernel1D, ky: Kernel1D? = kx, border: Border = Border.CLAMP): Img Separable convolution: kx along x then ky along y (or kx both ways if ky is null). O(kx + ky) per pixel per channel. API.
fun Img.convolve2D(k: Kernel2D, border: Border = Border.CLAMP): Img Dense 2-D convolution with k. True convolution (the kernel is flipped); pass an already-flipped kernel or a symmetric one if you want correlation. O(kw * kh) per pixel per channel — this is the general fallback, and the reference the separable path is tested against. API.
fun Img.correlate2D(k: Kernel2D, border: Border = Border.CLAMP): Img Cross-correlation with k (kernel not flipped). This is what most "apply this stencil" intuitions actually mean; identical to convolve2D for symmetric kernels. API.
fun Img.gaussianBlur(sigma: Double, truncate: Double = 4.0, border: Border = Border.CLAMP): Img Gaussian blur with standard deviation sigma, run as two separable 1-D passes. API.
fun Img.boxBlur(radius: Int, border: Border = Border.CLAMP): Img Box (mean) blur over a (2*radius+1)² window, computed with running sums so the cost is independent of radius. API.
fun Img.boxBlurApproxGaussian(sigma: Double, border: Border = Border.CLAMP): Img Three successive boxBlur passes, which by the central limit theorem approximate a Gaussian of standard deviation sigma — O(1) per pixel at any sigma. Use when sigma is large and you care more about speed than about the exact kernel shape. API.
fun Img.median(radius: Int = 1, border: Border = Border.CLAMP): Img Median filter over a (2*radius+1)² window — the standard salt-and-pepper de-noiser, and unlike a mean it does not smear edges. API.
fun Img.bilateral(sigmaSpace: Double = 3.0, sigmaRange: Double = 0.1, border: Border = Border.CLAMP): Img Bilateral filter: a Gaussian blur whose weights are additionally attenuated by intensity distance, so it smooths flat regions while leaving edges sharp. API.
fun Img.unsharpMask(sigma: Double = 1.5, amount: Double = 1.0, threshold: Float = 0f): Img Unsharp mask: image + amount * (image - gaussianBlur(image, sigma)). The oldest sharpening trick there is; threshold suppresses amplification of low-contrast noise. API.

Gradients

Measure spatial changes in intensity.

Entry point Behavior
Gradient A gradient field: per-pixel partial derivatives of a scalar image. API.
fun Img.sobel(border: Border = Border.CLAMP): Gradient Sobel gradients. The 3x3 Sobel operator factors into a [-1, 0, 1] derivative across the gradient axis and a [1, 2, 1]/4 smoothing along it, so both components run separably. API.
fun Img.sobelMagnitude(border: Border = Border.CLAMP): Img Shorthand for sobel().magnitude(). API.
fun Img.scharr(border: Border = Border.CLAMP): Gradient Scharr gradients — same structure as sobel but with the [3, 10, 3]/16 smoothing factor, which has markedly better rotational symmetry (the direction estimate is more accurate on diagonal edges). API.
fun Img.gaussianGradient(sigma: Double, truncate: Double = 4.0, border: Border = Border.CLAMP): Gradient Gradients of a Gaussian-smoothed image at scale sigma, computed as the analytic derivative of the Gaussian (not a blur followed by a difference), so the scale is exact. API.
fun Img.laplacian(eightNeighbour: Boolean = false, border: Border = Border.CLAMP): Img Discrete Laplacian (second derivative). eightNeighbour picks LAPLACIAN_8 over LAPLACIAN_4. API.
fun Img.laplacianOfGaussian(sigma: Double = 1.5, border: Border = Border.CLAMP): Img Laplacian of Gaussian at scale sigma — computed as the difference of the image and its blur's Laplacian, i.e. laplacian(gaussianBlur(img, sigma)). Zero crossings are edges at that scale. API.
fun Img.differenceOfGaussians(sigma1: Double = 1.0, sigma2: Double = 2.0, border: Border = Border.CLAMP): Img Difference of Gaussians, blur(sigma1) - blur(sigma2) with sigma1 < sigma2 — the cheap band-pass approximation to laplacianOfGaussian and the basis of blob detection. API.

Multiscale

Build coarse-to-fine image representations.

Entry point Behavior
fun Img.pyrDown(sigma: Double = 1.0): Img One-octave downsample: blur by sigma then take every other pixel. The blur is what keeps this from aliasing — a bare decimation of a fine texture produces moiré, not a small picture. API.
fun Img.pyrUp(targetWidth: Int = width * 2, targetHeight: Int = height * 2, sigma: Double = 1.0): Img One-octave upsample to exactly targetWidth x targetHeight (default 2x), bilinear then smoothed by sigma to suppress the interpolation grid. The approximate inverse of pyrDown. API.
fun Img.gaussianPyramid(levels: Int = 4, sigma: Double = 1.0, minSize: Int = 8): GaussianPyramid Build a GaussianPyramid with at most levels octaves, stopping at minSize pixels. API.
fun Img.laplacianPyramid(levels: Int = 4, sigma: Double = 1.0, minSize: Int = 8): LaplacianPyramid Build a LaplacianPyramid: at each octave, band = level - pyrUp(pyrDown(level)). The reconstruction is exact up to the upsampling filter, so it is a genuine invertible band-splitting — the basis of multiband blending, texture synthesis and detail transfer. API.

Edges / features

Find edge pixels or repeatable corner locations. Use corner responses when you need a score image, and corner detectors when you need point coordinates.

Entry point Behavior
fun Img.canny(low: Double = 0.08, high: Double = 0.20, sigma: Double = 1.0): Img Canny edge detection. API.
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. API.
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. API.
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. API.
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. API.
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. API.
fun Img.harrisCorners(k: Double = 0.04, threshold: Double = 0.01, minDistance: Int = 5, maxCount: Int = 200, sigma: Double = 1.5): List<Corner> Harris corners: harrisResponse followed by peakCorners. API.
fun Img.shiTomasiCorners(threshold: Double = 0.01, minDistance: Int = 5, maxCount: Int = 200, sigma: Double = 1.5): List<Corner> Shi–Tomasi corners: shiTomasiResponse followed by peakCorners. The default tracker seed. API.
fun Img.goodFeaturesToTrack(maxCount: Int = 200, threshold: Double = 0.01, minDistance: Int = 5): List<Corner> Alias for shiTomasiCorners under the name the OpenCV-literate will reach for first. API.
fun Img.fastCorners(threshold: Double = 0.08, contiguous: Int = 9, minDistance: Int = 5, maxCount: Int = 300): List<Corner> 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. API.
fun peakCorners(response: Img, threshold: Double = 0.01, minDistance: Int = 5, maxCount: Int = 200, subPixel: Boolean = true): List<Corner> 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. API.
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. API.

Lines / blobs

Group local evidence into shapes. The morphology here runs on a mask before you label components or trace contours, not after.

Entry point Behavior
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> Standard Hough line transform over a binary edge mask. API.
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> 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. API.
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. API.
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. API.
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. API.
fun Img.close(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img Dilate then erode — fills pinholes and hairline cracks, keeps the overall size. API.
fun Img.morphologicalGradient(radius: Int = 1, shape: StructuringElement = StructuringElement.SQUARE): Img dilate - erode: a one-pixel-ish outline of every bright region. API.
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). API.
fun Img.blackHat(radius: Int = 3, shape: StructuringElement = StructuringElement.SQUARE): Img close(image) - image: dark detail smaller than the element. API.
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. API.
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. API.
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. API.

Geometry / motion

Align images or measure motion between frames. Use RANSAC when correspondences include outliers, and optical flow for local displacement.

Entry point Behavior
Affine2D A 2-D affine transform, [a c e; b d f; 0 0 1] — the same six-number layout as java.awt.geom.AffineTransform and SVG's matrix(a, b, c, d, e, f). API.
Homography A 2-D projective transform (homography), a 3x3 matrix in row-major order, mapping (x, y) -> ((m0 x + m1 y + m2)/(m6 x + m7 y + m8), (m3 x + m4 y + m5)/(m6 x + m7 y + m8)). API.
fun findHomography(src: List<Vec2>, dst: List<Vec2>): Homography Direct Linear Transform: the exact homography through 4 correspondences, or the least-squares homography through more. API.
fun findHomographyRansac(src: List<Vec2>, dst: List<Vec2>, threshold: Double = 3.0, iterations: Int = 500, seed: Long = 0L): RansacFit RANSAC homography estimation: repeatedly fit a homography to a random minimal sample of 4 correspondences, score it by how many of the rest it maps to within threshold pixels, and keep the best consensus set — then refit on that set with a full least-squares findHomography. API.
fun Img.warpAffine(t: Affine2D, outWidth: Int = width, outHeight: Int = height, bilinear: Boolean = true, border: Border = Border.ZERO): Img Resample through an affine transform. The transform maps source → destination; the implementation inverts it and does the correct backwards mapping (for every output pixel, find where it came from), so there are no holes. API.
fun Img.warpPerspective(h: Homography, outWidth: Int = width, outHeight: Int = height, bilinear: Boolean = true, border: Border = Border.ZERO): Img Resample through a projective transform, mapping source → destination (inverted internally, see warpAffine). Pixels whose source falls outside the image are resolved by border. API.
fun Img.warpQuadToRect(quad: List<Vec2>, outWidth: Int, outHeight: Int, bilinear: Boolean = true): Img Rectify a quadrilateral in this image to a outWidth x outHeight rectangle — the "flatten the photographed page" operation, and the most immediately convincing use of a homography. API.
fun Img.rotate(radians: Double, bilinear: Boolean = true, border: Border = Border.ZERO): Img Rotate about the image centre by radians, keeping the same canvas size. Positive angles rotate clockwise on screen (y points down). API.
fun opticalFlowLK(a: Img, b: Img, points: List<Vec2>, windowRadius: Int = 7, levels: Int = 3, iterations: Int = 20, minEigenvalue: Double = 1e-4, epsilon: Double = 0.01): FlowField Pyramidal Lucas–Kanade sparse optical flow. API.
fun opticalFlowBlockMatch(a: Img, b: Img, step: Int = 16, blockRadius: Int = 8, maxShift: Int = 12): FlowField Dense block-matching flow on a regular step-pixel grid: for each grid cell, search [-maxShift, maxShift]² for the integer displacement minimising the sum of absolute differences over a (2*blockRadius+1)² block. Brute force, but robust to the large displacements that break Lucas–Kanade's linearisation, and it needs no gradient. API.
fun frameDifference(a: Img, b: Img, threshold: Double = 0.0, blurSigma: Double = 0.0): Img Absolute frame difference, optionally thresholded to a binary motion mask. The crudest and most reliable motion cue there is; pair with Img.open to drop sensor noise and Img.connectedComponents to count what moved. API.
fun updateBackground(background: Img, frame: Img, alpha: Double = 0.05): Img Running background model: background = (1 - alpha) * background + alpha * frame. Returns the updated background; difference the next frame against it for a cheap foreground mask that adapts to slow lighting changes. API.

Field integration

Turn analysis results into Field geometry or output panels.

Entry point Behavior
fun Img.toContourFLines(minPerimeter: Int = 8, simplifyTolerance: Double = 1.0, z: Number = 0): List<FLine> Trace this binary mask's blob outlines and hand them back as FLines — the one-call path from "I thresholded an image" to "I have drawable geometry". API.
fun contoursToFLines(contours: List<List<Vec2>>, closed: Boolean = true, z: Number = 0): List<FLine> Convert many point lists (e.g. from Img.traceContours) to one FLine each. API.
fun cornersToFLines(corners: List<Corner>, size: Double = 4.0, box: Boolean = false, z: Number = 0): List<FLine> Draw each corner as a small cross (or box) FLine at its location, sized by size. API.
fun linesToFLines(lines: List<HoughLine>, w: Int, h: Int, z: Number = 0): List<FLine> Clip each HoughLine to a w x h image rectangle and return the visible segments as FLines. API.
fun circlesToFLines(circles: List<HoughCircle>, segments: Int = 48, z: Number = 0): List<FLine> Draw each HoughCircle as a closed polygon of segments sides. API.
fun FlowField.toFLines(scale: Double = 1.0, minMagnitude: Double = 0.25, headSize: Double = 3.0, z: Number = 0): List<FLine> Draw a sparse optical-flow field as one arrow FLine per tracked point — shaft plus a two-stroke arrowhead, so direction is visible and not just magnitude. API.
fun Img.isoContourFLines(iso: Double, cells: Int = max(width, height), z: Number = 0): List<FLine> Extract smooth iso-contours at iso through Field's existing marching-squares extractor and return them as FLines. Unlike toContourFLines (which traces the pixel boundary of a binary mask) this interpolates between samples, so the contour is sub-pixel smooth — the right tool for a distance transform, a blurred response or a height field. API.
fun Img.asScalarField2D(border: Border = Border.CLAMP): ScalarField2D Adapt this 1-channel image to the ScalarField2D that contours.kt marches — the image becomes a bilinearly-interpolated continuous field over [0, width-1] x [0, height-1]. API.
fun Img.colormap(name: String = "viridis", lo: Float? = null, hi: Float? = null): Img Colour-map a 1-channel image through one of Field's Colormaps ramps (viridis, magma, inferno, plasma, turbo, coolwarm, grayscale), returning a 3-channel RGB image. API.
fun Img.colormapDiverging(name: String = "coolwarm"): Img Colour-map with a symmetric range around zero — the correct default for a signed image (a Laplacian, a difference of Gaussians, a gradient component), where the meaningful thing is which side of zero a pixel is on. Pair with a diverging map like coolwarm. API.
fun Img.maskToRGBA(color: Color4 = Color4(1.0, 0.35, 0.15), alpha: Double = 1.0): Img Tint a binary mask a single colour over a transparent background — for overlaying a detection on the original picture with overlay. API.
fun Img.overlay(top: Img, opacity: Double = 1.0): Img Alpha-composite top (RGBA, or RGB treated as opaque) over this image, returning RGB. The standard way to show a mask/overlay on top of the source picture. API.
fun hstack(vararg images: Img, gap: Int = 8, background: Float = 0.08f): Img Lay images out left-to-right into one strip; heights are matched by scaling to the tallest. API.
fun vstack(vararg images: Img, gap: Int = 8, background: Float = 0.08f): Img Lay images out top-to-bottom into one column; widths are matched by scaling to the widest. API.
fun Box.showImg(img: Img, channel: String = "main", maxSide: Int = 900, caption: String? = null) Push an Img to one of this box's output channels as a real PNG, at up to maxSide pixels on the long edge. Signed / out-of-range images are normalised for display, and when a caption is asked for the caption says so. API.
fun Box.showImgGrid(vararg panels: Pair<String, Img>, channel: String = "main", columns: Int = 2, cellSide: Int = 320, displayRange: ClosedFloatingPointRange<Float>? = null) Show several images side by side with their captions burned in — the fastest way to see a whole pipeline (source / blurred / gradient / edges) at a glance. API.
fun simplifyPolyline(points: List<Vec2>, tolerance: Double): List<Vec2> Ramer–Douglas–Peucker polyline simplification: recursively keep the point furthest from the chord until every dropped point is within tolerance of the retained line. Exposed on its own because pixel-traced contours are unusable without it. API.

Source: imaging.kt, imaging_filters.kt, imaging_features.kt, imaging_geometry.kt, imaging_field.kt in src/jvmMain/kotlin/afield/media/image/.