Volver al inicio

Extracción de Perspectiva de Imágenes: Sobel + Hough

Algoritmo de Extracción de Puntos de Fuga de Imágenes Implementa Cadena: Escala de Grises Rec.709, Sobel para Bordes, Hough para Líneas. Optimizado para Kotlin/Compose con Empaquetado de Píxeles. Adecuado para Apps de Artistas y AR.

Kotlin: Auto-Perspectiva de Imágenes con Sobel y Hough
Advertisement 728x90

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.

Google AdInline article slot
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:

Google AdInline article slot
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:

Google AdInline article slot
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 Int reduce 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

Advertisement 728x90

Leer después