From abbd73ac0092a0ee99c6172ed0212f748542776d Mon Sep 17 00:00:00 2001 From: Klaus Date: Thu, 21 Aug 2025 19:51:10 +0900 Subject: [PATCH] fix: ImageBlurUtil.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 블러 처리 방식 변경 --- .../image/AdminCharacterImageController.kt | 2 +- .../vividnext/sodalive/utils/ImageBlurUtil.kt | 205 +++++++++++------- 2 files changed, 122 insertions(+), 85 deletions(-) diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt index 2652163..fc16e25 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/admin/chat/character/image/AdminCharacterImageController.kt @@ -142,7 +142,7 @@ class AdminCharacterImageController( val bytes = image.bytes val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: throw SodaException("이미지 포맷을 인식할 수 없습니다.") - val blurred = ImageBlurUtil.blur(bimg) + val blurred = ImageBlurUtil.anonymizeStrong(bimg) // PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능 val baos = java.io.ByteArrayOutputStream() diff --git a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt index 31f030d..09e2fe4 100644 --- a/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt +++ b/src/main/kotlin/kr/co/vividnext/sodalive/utils/ImageBlurUtil.kt @@ -1,9 +1,12 @@ package kr.co.vividnext.sodalive.utils +import java.awt.RenderingHints import java.awt.image.BufferedImage +import java.awt.image.ConvolveOp +import java.awt.image.Kernel import kotlin.math.exp import kotlin.math.max -import kotlin.math.min +import kotlin.math.roundToInt /** * 가우시안 커널 기반 블러 유틸리티 @@ -12,91 +15,125 @@ import kotlin.math.min * - 수평/수직 분리 합성곱으로 품질과 성능 확보 */ object ImageBlurUtil { - fun blur(src: BufferedImage, radius: Int = 50): BufferedImage { - require(radius > 0) { "radius must be > 0" } - val w = src.width - val h = src.height - val dst = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) - - // 가우시안 1D 커널 생성 및 정규화 - val sigma = radius / 3.0 - val kernel = gaussianKernel(radius, sigma) - - // 중간 버퍼 - val temp = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB) - - // 수평 합성곱 - for (y in 0 until h) { - for (x in 0 until w) { - var aAcc = 0.0 - var rAcc = 0.0 - var gAcc = 0.0 - var bAcc = 0.0 - for (k in -radius..radius) { - val xx = clamp(x + k, 0, w - 1) - val rgb = src.getRGB(xx, y) - val a = (rgb ushr 24) and 0xFF - val r = (rgb ushr 16) and 0xFF - val g = (rgb ushr 8) and 0xFF - val b = rgb and 0xFF - val wgt = kernel[k + radius] - aAcc += a * wgt - rAcc += r * wgt - gAcc += g * wgt - bAcc += b * wgt - } - val a = aAcc.toInt().coerceIn(0, 255) - val r = rAcc.toInt().coerceIn(0, 255) - val g = gAcc.toInt().coerceIn(0, 255) - val b = bAcc.toInt().coerceIn(0, 255) - temp.setRGB(x, y, (a shl 24) or (r shl 16) or (g shl 8) or b) - } - } - - // 수직 합성곱 - for (x in 0 until w) { - for (y in 0 until h) { - var aAcc = 0.0 - var rAcc = 0.0 - var gAcc = 0.0 - var bAcc = 0.0 - for (k in -radius..radius) { - val yy = clamp(y + k, 0, h - 1) - val rgb = temp.getRGB(x, yy) - val a = (rgb ushr 24) and 0xFF - val r = (rgb ushr 16) and 0xFF - val g = (rgb ushr 8) and 0xFF - val b = rgb and 0xFF - val wgt = kernel[k + radius] - aAcc += a * wgt - rAcc += r * wgt - gAcc += g * wgt - bAcc += b * wgt - } - val a = aAcc.toInt().coerceIn(0, 255) - val r = rAcc.toInt().coerceIn(0, 255) - val g = gAcc.toInt().coerceIn(0, 255) - val b = bAcc.toInt().coerceIn(0, 255) - dst.setRGB(x, y, (a shl 24) or (r shl 16) or (g shl 8) or b) - } - } - return dst + /** + * 주어진 이미지를 목표 가로/세로 크기로 리사이즈합니다. + * + * - 원본 비율을 무시하고 강제로 맞춥니다. + * - 원본보다 크면 확대, 작으면 축소됩니다. + * + * @param src 원본 BufferedImage + * @param w 목표 가로 크기(px) + * @param h 목표 세로 크기(px) + * @return 리사이즈된 BufferedImage + */ + fun resizeTo(src: BufferedImage, w: Int, h: Int): BufferedImage { + val out = BufferedImage(w, h, src.type.takeIf { it != 0 } ?: BufferedImage.TYPE_INT_ARGB) + val g = out.createGraphics() + // 확대/축소 시 보간법: Bilinear → 부드러운 결과 + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + g.drawImage(src, 0, 0, w, h, null) + g.dispose() + return out } - private fun gaussianKernel(radius: Int, sigma: Double): DoubleArray { - val size = 2 * radius + 1 - val kernel = DoubleArray(size) - val sigma2 = 2.0 * sigma * sigma - var sum = 0.0 - for (i in -radius..radius) { - val v = exp(-(i * i) / sigma2) - kernel[i + radius] = v - sum += v - } - // 정규화 - for (i in kernel.indices) kernel[i] /= sum - return kernel + /** + * 가로 크기만 지정하고, 세로는 원본 비율에 맞춰 자동 계산합니다. + * + * @param src 원본 BufferedImage + * @param targetWidth 목표 가로 크기(px) + * @return 리사이즈된 BufferedImage (세로는 자동 비율) + */ + fun resizeToWidth(src: BufferedImage, targetWidth: Int): BufferedImage { + val ratio = targetWidth.toDouble() / src.width + val targetHeight = (src.height * ratio).roundToInt() + return resizeTo(src, targetWidth, targetHeight) } - private fun clamp(v: Int, lo: Int, hi: Int): Int = max(lo, min(hi, v)) + /** + * 세로 크기만 지정하고, 가로는 원본 비율에 맞춰 자동 계산합니다. + * + * @param src 원본 BufferedImage + * @param targetHeight 목표 세로 크기(px) + * @return 리사이즈된 BufferedImage (가로는 자동 비율) + */ + fun resizeToHeight(src: BufferedImage, targetHeight: Int): BufferedImage { + val ratio = targetHeight.toDouble() / src.height + val targetWidth = (src.width * ratio).roundToInt() + return resizeTo(src, targetWidth, targetHeight) + } + + /** + * 분리형 가우시안 블러(Separable Gaussian Blur). + * + * - 반경(radius)이 커질수록 더 강하게 흐려집니다. + * - 2D 전체 커널 대신 1D 커널을 두 번 적용하여 성능을 개선했습니다. + */ + private fun gaussianBlurSeparable(src: BufferedImage, radius: Int = 22, sigma: Float? = null): BufferedImage { + require(radius >= 1) + val s = sigma ?: (radius / 3f) + val size = radius * 2 + 1 + + // 1D 가우시안 커널 생성 + val kernel1D = FloatArray(size).also { k -> + var sum = 0f + var i = 0 + for (x in -radius..radius) { + val v = gaussian1D(x.toFloat(), s) + k[i++] = v + sum += v + } + for (j in k.indices) k[j] /= sum // 정규화 + } + + // 수평, 수직 두 번 적용 + val kx = Kernel(size, 1, kernel1D) + val ky = Kernel(1, size, kernel1D) + + val opX = ConvolveOp(kx, ConvolveOp.EDGE_ZERO_FILL, null) + val tmp = opX.filter(src, null) + val opY = ConvolveOp(ky, ConvolveOp.EDGE_ZERO_FILL, null) + return opY.filter(tmp, null) + } + + /** + * 강한 블러 처리(익명화 용도). + * + * 절차: + * 1) 원본 이미지를 축소 (긴 변이 longEdgeTarget 픽셀이 되도록) + * 2) 축소된 이미지에 큰 반경의 가우시안 블러 적용 + * 3) 다시 원본 해상도로 확대 (픽셀 정보가 손실되어 복구 불가능) + * + * @param src 원본 이미지 + * @param longEdgeTarget 축소 후 긴 변의 픽셀 수 (16~64 권장, 작을수록 강하게 흐려짐) + * @param blurRadius 가우시안 블러 반경 (20~32 권장, 클수록 강함) + * @return 원본 해상도의 블러 처리된 이미지 + */ + fun anonymizeStrong(src: BufferedImage, longEdgeTarget: Int = 32, blurRadius: Int = 22): BufferedImage { + val longEdge = max(src.width, src.height) + val scale = longEdgeTarget.toDouble() / longEdge + val smallW = max(1, (src.width * scale).toInt()) + val smallH = max(1, (src.height * scale).toInt()) + + // 1) 축소 + val small = resizeTo(src, smallW, smallH) + + // 2) 강한 블러 + val blurredSmall = gaussianBlurSeparable(small, radius = blurRadius) + + // 3) 다시 원본 해상도로 확대 + val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB) + val g = out.createGraphics() + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) + g.drawImage(blurredSmall, 0, 0, src.width, src.height, null) + g.dispose() + return out + } + + /** + * 1차원 가우시안 함수 값 계산 + */ + private fun gaussian1D(x: Float, sigma: Float): Float { + val s2 = 2 * sigma * sigma + return (1.0 / kotlin.math.sqrt((Math.PI * s2).toFloat())).toFloat() * exp(-(x * x) / s2) + } }