홈으로 돌아가기

이미지에서 원근 추출: Sobel + Hough

이미지 소실점 추출 알고리즘 체인 구현: Rec.709 Grayscale, 에지용 Sobel, 선용 Hough. Pixel Packing으로 Kotlin/Compose 최적화. 아티스트 앱 및 AR에 적합.

Kotlin: Sobel과 Hough를 사용한 이미지 자동 원근
Advertisement 728x90

Kotlin으로 이미지에서 자동 소실점 추출하기

PerspectiveSceneExtractor 클래스는 이미지를 분석하여 그레이스케일로 변환하고, Sobel 연산자를 사용해 에지를 감지한 후 Hough 변환을 적용하여 선을 찾습니다. 결과는 소실점 좌표나 평행선 방향을 가진 PerspectivePoint 객체 리스트입니다.

이미지와 픽셀 표현

UI에 독립적으로 동작하도록 Image 값 클래스에 RGBPixel로 압축된 픽셀을 사용합니다. 이를 통해 메모리 사용량을 최소화합니다.

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
}

소실점은 PerspectivePoint에 저장됩니다. 좌표가 Float.MAX_VALUE로 설정되면 무한점으로 간주하며 도 단위 방향을 나타냅니다.

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)
    }
}

그레이스케일 변환

Rec. 709 표준(가중치 R: 0.2126, G: 0.7152, B: 0.0722)을 사용해 인간의 밝기 인식에 맞춘 변환을 수행합니다. 결과는 픽셀당 하나의 Int를 가진 GrayImage입니다.

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)
}

Sobel 연산자로 에지 감지

Sobel 연산자는 각 픽셀에 대해 X, Y 방향 그라디언트를 계산합니다. 그라디언트 크기가 임계값(thresholdSq = 16384)을 초과하면 에지로 표시합니다.

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)
}

이웃 픽셀을 고려해 그라디언트를 계산합니다:

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 {
    // Y 방향에 대한 유사한 구현
}

선 감지를 위한 Hough 변환

선을 ρ = x·cos(θ) + y·sin(θ)로 매개변수화합니다. 누적기는 ρ와 θ(thetaSteps 단계)에 대한 2D 배열입니다. 각 에지 점은 자신을 통과하는 선에 투표합니다.

LineVote 클래스는 투표 수, 길이 투영, 밀도를 추적합니다:

private class LineVote(
    var votes: Int = 0,
    var minProj: Float = Float.MAX_VALUE,
    var maxProj: Float = -Float.MAX_VALUE
)

투표 과정:

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)
                    // 투표 및 투영 업데이트
                }
            }
        }
    }
}

선은 로컬 투표 최대값, 최소 길이, 밀도를 기준으로 선택됩니다.

주요 알고리즘 매개변수

  • thetaSteps: 이산 각도 수(보통 1° 간격으로 180).
  • threshold: 선으로 인정되는 최소 투표 수.
  • localMaxWindow: 로컬 최대값 확인 창 크기.
  • thresholdSq: Sobel 연산자 임계값(16384).

주요 요약

  • 픽셀을 Int로 압축해 별도 채널 대비 메모리 75% 절감.
  • Rec. 709로 인간 인식에 맞춘 정확한 그레이스케일 변환.
  • 대형 이미지에서 Sobel이 Canny보다 빠름.
  • 길이와 밀도 필터링으로 노이즈 선 제거.
  • 수평선을 위한 무한 소실점은 방향으로 모델링.

— Editorial Team

Advertisement 728x90

다음 읽기