캐릭터 챗봇 #338
| @@ -142,7 +142,7 @@ class AdminCharacterImageController( | |||||||
|             val bytes = image.bytes |             val bytes = image.bytes | ||||||
|             val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) |             val bimg = javax.imageio.ImageIO.read(java.io.ByteArrayInputStream(bytes)) | ||||||
|                 ?: throw SodaException("이미지 포맷을 인식할 수 없습니다.") |                 ?: throw SodaException("이미지 포맷을 인식할 수 없습니다.") | ||||||
|             val blurred = ImageBlurUtil.anonymizeStrong(bimg) |             val blurred = ImageBlurUtil.blurFast(bimg) | ||||||
|  |  | ||||||
|             // PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능 |             // PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능 | ||||||
|             val baos = java.io.ByteArrayOutputStream() |             val baos = java.io.ByteArrayOutputStream() | ||||||
|   | |||||||
| @@ -1,11 +1,11 @@ | |||||||
| package kr.co.vividnext.sodalive.utils | package kr.co.vividnext.sodalive.utils | ||||||
|  |  | ||||||
| import java.awt.RenderingHints |  | ||||||
| import java.awt.image.BufferedImage | import java.awt.image.BufferedImage | ||||||
| import java.awt.image.ConvolveOp | import java.awt.image.DataBufferInt | ||||||
| import java.awt.image.Kernel | import java.util.stream.IntStream | ||||||
| import kotlin.math.exp | import kotlin.math.exp | ||||||
| import kotlin.math.max | import kotlin.math.max | ||||||
|  | import kotlin.math.min | ||||||
| import kotlin.math.roundToInt | import kotlin.math.roundToInt | ||||||
|  |  | ||||||
| /** | /** | ||||||
| @@ -14,126 +14,160 @@ import kotlin.math.roundToInt | |||||||
|  * - 시그마는 관례적으로 radius/3.0 적용 |  * - 시그마는 관례적으로 radius/3.0 적용 | ||||||
|  * - 수평/수직 분리 합성곱으로 품질과 성능 확보 |  * - 수평/수직 분리 합성곱으로 품질과 성능 확보 | ||||||
|  */ |  */ | ||||||
|  | /** | ||||||
|  |  * 고속 가우시안 블러 유틸 | ||||||
|  |  * | ||||||
|  |  * - 원본 비율/해상도 그대로 두고 "큰 반경 블러"만 빠르게 적용하고 싶을 때 사용합니다. | ||||||
|  |  * - 강한 익명화를 원하면(식별 불가 수준) 이 함수 대신 | ||||||
|  |  *   "다운스케일 → 큰 반경 블러 → 원본 해상도로 업스케일"을 조합하세요. | ||||||
|  |  *   (예: ImageUtils.anonymizeStrongFast 처럼) | ||||||
|  |  */ | ||||||
| object ImageBlurUtil { | object ImageBlurUtil { | ||||||
|     /** |     /** | ||||||
|      * 주어진 이미지를 목표 가로/세로 크기로 리사이즈합니다. |      * 분리형(1D) 가우시안 블러(수평 → 수직 2패스), 배열 접근 기반 고속 구현. | ||||||
|      * |  | ||||||
|      * - 원본 비율을 무시하고 강제로 맞춥니다. |  | ||||||
|      * - 원본보다 크면 확대, 작으면 축소됩니다. |  | ||||||
|      * |  | ||||||
|      * @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 |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * 가로 크기만 지정하고, 세로는 원본 비율에 맞춰 자동 계산합니다. |  | ||||||
|      * |  | ||||||
|      * @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) |  | ||||||
|     } |  | ||||||
|  |  | ||||||
|     /** |  | ||||||
|      * 세로 크기만 지정하고, 가로는 원본 비율에 맞춰 자동 계산합니다. |  | ||||||
|      * |  | ||||||
|      * @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 src      원본 이미지 | ||||||
|      * @param longEdgeTarget 축소 후 긴 변의 픽셀 수 (16~64 권장, 작을수록 강하게 흐려짐) |      * @param radius   가우시안 반경(>=1). 클수록 강하게 흐려짐. (권장 5~64) | ||||||
|      * @param blurRadius 가우시안 블러 반경 (20~32 권장, 클수록 강함) |      * @param parallel true면 행/열 패스를 병렬 실행(ForkJoinPool). 멀티코어에서만 유효. | ||||||
|      * @return 원본 해상도의 블러 처리된 이미지 |      * @return 블러된 새 이미지 (TYPE_INT_ARGB) | ||||||
|      */ |      */ | ||||||
|     fun anonymizeStrong(src: BufferedImage, longEdgeTarget: Int = 32, blurRadius: Int = 22): BufferedImage { |     fun blurFast(src: BufferedImage, radius: Int = 100, parallel: Boolean = true): BufferedImage { | ||||||
|         val longEdge = max(src.width, src.height) |         require(radius > 0) { "radius must be > 0" } | ||||||
|         val scale = longEdgeTarget.toDouble() / longEdge |  | ||||||
|         val smallW = max(1, (src.width * scale).toInt()) |  | ||||||
|         val smallH = max(1, (src.height * scale).toInt()) |  | ||||||
|  |  | ||||||
|         // 1) 축소 |         // 1) 프리멀티 알파로 변환 (경계 품질↑) | ||||||
|         val small = resizeTo(src, smallW, smallH) |         val s = toPremultiplied(src) // TYPE_INT_ARGB_PRE | ||||||
|  |         val w = s.width | ||||||
|  |         val h = s.height | ||||||
|  |  | ||||||
|         // 2) 강한 블러 |         // 2) 중간/최종 버퍼(프리멀티 유지) | ||||||
|         val blurredSmall = gaussianBlurSeparable(small, radius = blurRadius) |         val tmp = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB_PRE) | ||||||
|  |         val dst = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB_PRE) | ||||||
|  |  | ||||||
|         // 3) 다시 원본 해상도로 확대 |         val srcArr = (s.raster.dataBuffer as DataBufferInt).data | ||||||
|         val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB) |         val tmpArr = (tmp.raster.dataBuffer as DataBufferInt).data | ||||||
|  |         val dstArr = (dst.raster.dataBuffer as DataBufferInt).data | ||||||
|  |  | ||||||
|  |         // 3) 1D 가우시안 커널(정규화) | ||||||
|  |         //    sigma는 일반적으로 radius/3.0이 자연스러운 값 | ||||||
|  |         val sigma = radius / 3.0 | ||||||
|  |         val kernel = buildGaussian1D(radius, sigma) | ||||||
|  |  | ||||||
|  |         // 4) 수평 패스 (y 라인별) | ||||||
|  |         if (parallel) { | ||||||
|  |             IntStream.range(0, h).parallel().forEach { y -> | ||||||
|  |                 convolveRow(srcArr, tmpArr, w, h, y, kernel, radius) | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             for (y in 0 until h) convolveRow(srcArr, tmpArr, w, h, y, kernel, radius) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 5) 수직 패스 (x 컬럼별) | ||||||
|  |         if (parallel) { | ||||||
|  |             IntStream.range(0, w).parallel().forEach { x -> | ||||||
|  |                 convolveCol(tmpArr, dstArr, w, h, x, kernel, radius) | ||||||
|  |             } | ||||||
|  |         } else { | ||||||
|  |             for (x in 0 until w) convolveCol(tmpArr, dstArr, w, h, x, kernel, radius) | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         // 6) 비프리멀티(일반 ARGB)로 변환해서 반환 (파일 저장/그리기 호환성↑) | ||||||
|  |         return toNonPremultiplied(dst) | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // ───────────────────────────────────────────────────────────────────────────── | ||||||
|  |     // 내부 구현 | ||||||
|  |     // ───────────────────────────────────────────────────────────────────────────── | ||||||
|  |  | ||||||
|  |     // 수평 합성곱: 경계는 replicate(클램프) | ||||||
|  |     private fun convolveRow(src: IntArray, dst: IntArray, w: Int, h: Int, y: Int, k: DoubleArray, r: Int) { | ||||||
|  |         val base = y * w | ||||||
|  |         for (x in 0 until w) { | ||||||
|  |             var aAcc = 0.0 | ||||||
|  |             var rAcc = 0.0 | ||||||
|  |             var gAcc = 0.0 | ||||||
|  |             var bAcc = 0.0 | ||||||
|  |             var i = -r | ||||||
|  |             while (i <= r) { | ||||||
|  |                 val xx = clamp(x + i, 0, w - 1) | ||||||
|  |                 val argb = src[base + xx] | ||||||
|  |                 val a = (argb ushr 24) and 0xFF | ||||||
|  |                 val rr = (argb ushr 16) and 0xFF | ||||||
|  |                 val gg = (argb ushr 8) and 0xFF | ||||||
|  |                 val bb = argb and 0xFF | ||||||
|  |                 val wgt = k[i + r] | ||||||
|  |                 aAcc += a * wgt; rAcc += rr * wgt; gAcc += gg * wgt; bAcc += bb * wgt | ||||||
|  |                 i++ | ||||||
|  |             } | ||||||
|  |             val a = aAcc.roundToInt().coerceIn(0, 255) | ||||||
|  |             val rr = rAcc.roundToInt().coerceIn(0, 255) | ||||||
|  |             val gg = gAcc.roundToInt().coerceIn(0, 255) | ||||||
|  |             val bb = bAcc.roundToInt().coerceIn(0, 255) | ||||||
|  |             dst[base + x] = (a shl 24) or (rr shl 16) or (gg shl 8) or bb | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 수직 합성곱: 경계 replicate(클램프) | ||||||
|  |     private fun convolveCol(src: IntArray, dst: IntArray, w: Int, h: Int, x: Int, k: DoubleArray, r: Int) { | ||||||
|  |         var idx = x | ||||||
|  |         for (y in 0 until h) { | ||||||
|  |             var aAcc = 0.0 | ||||||
|  |             var rAcc = 0.0 | ||||||
|  |             var gAcc = 0.0 | ||||||
|  |             var bAcc = 0.0 | ||||||
|  |             var i = -r | ||||||
|  |             while (i <= r) { | ||||||
|  |                 val yy = clamp(y + i, 0, h - 1) | ||||||
|  |                 val argb = src[yy * w + x] | ||||||
|  |                 val a = (argb ushr 24) and 0xFF | ||||||
|  |                 val rr = (argb ushr 16) and 0xFF | ||||||
|  |                 val gg = (argb ushr 8) and 0xFF | ||||||
|  |                 val bb = argb and 0xFF | ||||||
|  |                 val wgt = k[i + r] | ||||||
|  |                 aAcc += a * wgt; rAcc += rr * wgt; gAcc += gg * wgt; bAcc += bb * wgt | ||||||
|  |                 i++ | ||||||
|  |             } | ||||||
|  |             val a = aAcc.roundToInt().coerceIn(0, 255) | ||||||
|  |             val rr = rAcc.roundToInt().coerceIn(0, 255) | ||||||
|  |             val gg = gAcc.roundToInt().coerceIn(0, 255) | ||||||
|  |             val bb = bAcc.roundToInt().coerceIn(0, 255) | ||||||
|  |             dst[idx] = (a shl 24) or (rr shl 16) or (gg shl 8) or bb | ||||||
|  |             idx += w | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     // 1D 가우시안 커널 (정규화) | ||||||
|  |     private fun buildGaussian1D(radius: Int, sigma: Double): DoubleArray { | ||||||
|  |         val size = radius * 2 + 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 0 until size) kernel[i] /= sum | ||||||
|  |         return kernel | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     private fun clamp(v: Int, lo: Int, hi: Int): Int = max(lo, min(hi, v)) | ||||||
|  |  | ||||||
|  |     // 프리멀티/비프리멀티 변환(빠른 방법: Graphics로 그리기) | ||||||
|  |     private fun toPremultiplied(src: BufferedImage): BufferedImage { | ||||||
|  |         if (src.type == BufferedImage.TYPE_INT_ARGB_PRE) return src | ||||||
|  |         val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB_PRE) | ||||||
|         val g = out.createGraphics() |         val g = out.createGraphics() | ||||||
|         g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR) |         g.drawImage(src, 0, 0, null) | ||||||
|         g.drawImage(blurredSmall, 0, 0, src.width, src.height, null) |  | ||||||
|         g.dispose() |         g.dispose() | ||||||
|         return out |         return out | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     /** |     private fun toNonPremultiplied(src: BufferedImage): BufferedImage { | ||||||
|      * 1차원 가우시안 함수 값 계산 |         if (src.type == BufferedImage.TYPE_INT_ARGB) return src | ||||||
|      */ |         val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB) | ||||||
|     private fun gaussian1D(x: Float, sigma: Float): Float { |         val g = out.createGraphics() | ||||||
|         val s2 = 2 * sigma * sigma |         g.drawImage(src, 0, 0, null) | ||||||
|         return (1.0 / kotlin.math.sqrt((Math.PI * s2).toFloat())).toFloat() * exp(-(x * x) / s2) |         g.dispose() | ||||||
|  |         return out | ||||||
|     } |     } | ||||||
| } | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user