fix: ImageBlurUtil.kt

- 블러 처리 방식 변경
This commit is contained in:
Klaus 2025-08-21 20:14:06 +09:00
parent abbd73ac00
commit 99386c6d53
2 changed files with 137 additions and 103 deletions

View File

@ -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()

View File

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