parent
4bee95c8a6
commit
abbd73ac00
|
@ -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.blur(bimg)
|
val blurred = ImageBlurUtil.anonymizeStrong(bimg)
|
||||||
|
|
||||||
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
|
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
|
||||||
val baos = java.io.ByteArrayOutputStream()
|
val baos = java.io.ByteArrayOutputStream()
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
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.Kernel
|
||||||
import kotlin.math.exp
|
import kotlin.math.exp
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 가우시안 커널 기반 블러 유틸리티
|
* 가우시안 커널 기반 블러 유틸리티
|
||||||
|
@ -12,91 +15,125 @@ import kotlin.math.min
|
||||||
* - 수평/수직 분리 합성곱으로 품질과 성능 확보
|
* - 수평/수직 분리 합성곱으로 품질과 성능 확보
|
||||||
*/
|
*/
|
||||||
object ImageBlurUtil {
|
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 커널 생성 및 정규화
|
* @param src 원본 BufferedImage
|
||||||
val sigma = radius / 3.0
|
* @param w 목표 가로 크기(px)
|
||||||
val kernel = gaussianKernel(radius, sigma)
|
* @param h 목표 세로 크기(px)
|
||||||
|
* @return 리사이즈된 BufferedImage
|
||||||
// 중간 버퍼
|
*/
|
||||||
val temp = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB)
|
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()
|
||||||
for (y in 0 until h) {
|
// 확대/축소 시 보간법: Bilinear → 부드러운 결과
|
||||||
for (x in 0 until w) {
|
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
|
||||||
var aAcc = 0.0
|
g.drawImage(src, 0, 0, w, h, null)
|
||||||
var rAcc = 0.0
|
g.dispose()
|
||||||
var gAcc = 0.0
|
return out
|
||||||
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
|
* @param src 원본 BufferedImage
|
||||||
var rAcc = 0.0
|
* @param targetWidth 목표 가로 크기(px)
|
||||||
var gAcc = 0.0
|
* @return 리사이즈된 BufferedImage (세로는 자동 비율)
|
||||||
var bAcc = 0.0
|
*/
|
||||||
for (k in -radius..radius) {
|
fun resizeToWidth(src: BufferedImage, targetWidth: Int): BufferedImage {
|
||||||
val yy = clamp(y + k, 0, h - 1)
|
val ratio = targetWidth.toDouble() / src.width
|
||||||
val rgb = temp.getRGB(x, yy)
|
val targetHeight = (src.height * ratio).roundToInt()
|
||||||
val a = (rgb ushr 24) and 0xFF
|
return resizeTo(src, targetWidth, targetHeight)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun gaussianKernel(radius: Int, sigma: Double): DoubleArray {
|
/**
|
||||||
val size = 2 * radius + 1
|
* 세로 크기만 지정하고, 가로는 원본 비율에 맞춰 자동 계산합니다.
|
||||||
val kernel = DoubleArray(size)
|
*
|
||||||
val sigma2 = 2.0 * sigma * sigma
|
* @param src 원본 BufferedImage
|
||||||
var sum = 0.0
|
* @param targetHeight 목표 세로 크기(px)
|
||||||
for (i in -radius..radius) {
|
* @return 리사이즈된 BufferedImage (가로는 자동 비율)
|
||||||
val v = exp(-(i * i) / sigma2)
|
*/
|
||||||
kernel[i + radius] = v
|
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
|
sum += v
|
||||||
}
|
}
|
||||||
// 정규화
|
for (j in k.indices) k[j] /= sum // 정규화
|
||||||
for (i in kernel.indices) kernel[i] /= sum
|
|
||||||
return kernel
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clamp(v: Int, lo: Int, hi: Int): Int = max(lo, min(hi, v))
|
// 수평, 수직 두 번 적용
|
||||||
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue