Extracción automática de puntos de fuga en imágenes con Kotlin
La clase PerspectiveSceneExtractor analiza una imagen, la convierte a escala de grises, detecta bordes mediante el operador Sobel y aplica la Transformada de Hough para encontrar líneas. El resultado es una lista de objetos PerspectivePoint con coordenadas de puntos de fuga o direcciones de líneas paralelas.
Representación de imágenes y píxeles
Para mantener la independencia de la interfaz de usuario, usamos una clase de valor Image con píxeles empaquetados en RGBPixel. Esto minimiza el uso de memoria.
data class Image(
val path: String,
val width: Int,
val height: Int,
val pixels: List<RGBPixel>
)
@JvmInline
value class RGBPixel(private val packed: Int) {
constructor(r: Int, g: Int, b: Int, a: Int = 255) : this(
((a and 0xFF) shl 24) or
((r and 0xFF) shl 16) or
((g and 0xFF) shl 8) or
(b and 0xFF)
)
val a: Int get() = (packed ushr 24) and 0xFF
val r: Int get() = (packed shr 16) and 0xFF
val g: Int get() = (packed shr 8) and 0xFF
val b: Int get() = packed and 0xFF
}
Los puntos de fuga se almacenan en PerspectivePoint. Las coordenadas establecidas en Float.MAX_VALUE indican un punto infinito con una dirección en grados.
data class PerspectivePoint(
val x: Float,
val y: Float,
val direction: Float? = null
) {
val isFinite: Boolean get() =
x != Float.MAX_VALUE && y != Float.MAX_VALUE
companion object {
fun infinite(directionDegrees: Float) =
PerspectivePoint(Float.MAX_VALUE, Float.MAX_VALUE, directionDegrees)
}
}
Conversión a escala de grises
La conversión utiliza el estándar Rec. 709 con pesos 0.2126 (R), 0.7152 (G) y 0.0722 (B) para coincidir con la percepción de brillo humano. El resultado es una GrayImage con un Int por píxel.
data class GrayImage(
val image: IntArray,
val height: Int,
val width: Int,
)
private fun convertToGrayscale(image: Image): GrayImage {
val gray = IntArray(image.width * image.height) { i ->
val pixel = image.pixels[i]
(0.2126 * pixel.r + 0.7152 * pixel.g + 0.0722 * pixel.b).toInt()
}
return GrayImage(gray, image.height, image.width)
}
Detección de bordes con el operador Sobel
El operador Sobel calcula gradientes en X e Y para cada píxel. Si la magnitud del gradiente supera el umbral (thresholdSq = 16384), el píxel se marca como borde.
data class BlackWhiteImage(
val image: BooleanArray,
val height: Int,
val width: Int,
)
private fun detectEdges(grayImage: GrayImage): BlackWhiteImage {
val edges = BooleanArray(grayImage.height * grayImage.width) { false }
val thresholdSq = 16384
for (y in 1 until grayImage.height - 1) {
for (x in 1 until grayImage.width - 1) {
val gx = computeGradientX(grayImage, x, y)
val gy = computeGradientY(grayImage, x, y)
if (gx * gx + gy * gy > thresholdSq) {
edges[y * grayImage.width + x] = true
}
}
}
return BlackWhiteImage(edges, grayImage.height, grayImage.width)
}
Los gradientes se calculan considerando píxeles vecinos:
private fun computeGradientX(grayImage: GrayImage, x: Int, y: Int): Int {
with(grayImage) {
val idx = y * width + x
return (
-image[idx - width - 1]
+ image[idx - width + 1]
-2 * image[idx - 1]
+ 2 * image[idx + 1]
-image[idx + width - 1]
+ image[idx + width + 1]
)
}
}
private fun computeGradientY(grayImage: GrayImage, x: Int, y: Int): Int {
// implementación similar para Y
}
Transformada de Hough para detección de líneas
Las líneas se parametrizan como ρ = x·cos(θ) + y·sin(θ). El acumulador es un array 2D sobre ρ y θ (thetaSteps pasos). Cada punto de borde vota por las líneas que lo atraviesan.
La clase LineVote rastrea votos, proyecciones para longitud y densidad:
private class LineVote(
var votes: Int = 0,
var minProj: Float = Float.MAX_VALUE,
var maxProj: Float = -Float.MAX_VALUE
)
Proceso de votación:
private fun vote(edges: BlackWhiteImage, accumulator: Array<Array<LineVote>>, maxRho: Int) {
for (y in 0 until height) {
for (x in 0 until width) {
if (edges.image[y * width + x]) {
for (thetaIdx in 0 until thetaSteps) {
val theta = Math.toRadians(thetaIdx.toDouble())
val rho = x * cos(theta) + y * sin(theta)
// actualizar votos y proyecciones
}
}
}
}
}
Las líneas se seleccionan según máximos locales de votos, longitud mínima y densidad.
Parámetros clave del algoritmo
- thetaSteps: Número de ángulos discretos (típicamente 180 para pasos de 1°).
- threshold: Votos mínimos requeridos para una línea.
- localMaxWindow: Tamaño de ventana para comprobaciones de máximo local.
- thresholdSq: Umbral del operador Sobel (16384).
Lecciones clave
- Empaquetar píxeles en
Intreduce el uso de memoria un 75% frente a canales separados. - Rec. 709 asegura una escala de grises precisa que coincide con la percepción humana.
- Sobel es más rápido que Canny para imágenes grandes.
- El filtrado de Hough por longitud y densidad elimina líneas ruidosas.
- Los puntos de fuga infinitos se modelan con direcciones para horizontes.
— Editorial Team
Aún no hay comentarios.