캐릭터 챗봇 #338

Merged
klaus merged 119 commits from test into main 2025-09-10 06:08:47 +00:00
2 changed files with 137 additions and 103 deletions
Showing only changes of commit 99386c6d53 - Show all commits

View File

@ -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.anonymizeStrong(bimg)
val blurred = ImageBlurUtil.blurFast(bimg)
// PNG로 저장(알파 유지), JPEG 업로드가 필요하면 포맷 변경 가능
val baos = java.io.ByteArrayOutputStream()

View File

@ -1,11 +1,11 @@
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 java.awt.image.DataBufferInt
import java.util.stream.IntStream
import kotlin.math.exp
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
/**
@ -14,126 +14,160 @@ import kotlin.math.roundToInt
* - 시그마는 관례적으로 radius/3.0 적용
* - 수평/수직 분리 합성곱으로 품질과 성능 확보
*/
/**
* 고속 가우시안 블러 유틸
*
* - 원본 비율/해상도 그대로 두고 "큰 반경 블러" 빠르게 적용하고 싶을 사용합니다.
* - 강한 익명화를 원하면(식별 불가 수준) 함수 대신
* "다운스케일 → 큰 반경 블러 → 원본 해상도로 업스케일" 조합하세요.
* (: ImageUtils.anonymizeStrongFast 처럼)
*/
object ImageBlurUtil {
/**
* 주어진 이미지를 목표 가로/세로 크기로 리사이즈합니다.
*
* - 원본 비율을 무시하고 강제로 맞춥니다.
* - 원본보다 크면 확대, 작으면 축소됩니다.
*
* @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) 다시 원본 해상도로 확대 (픽셀 정보가 손실되어 복구 불가능)
* 분리형(1D) 가우시안 블러(수평 수직 2패스), 배열 접근 기반 고속 구현.
*
* @param src 원본 이미지
* @param longEdgeTarget 축소 변의 픽셀 (16~64 권장, 작을수록 강하게 흐려짐)
* @param blurRadius 가우시안 블러 반경 (20~32 권장, 클수록 강함)
* @return 원본 해상도의 블러 처리된 이미지
* @param radius 가우시안 반경(>=1). 클수록 강하게 흐려짐. (권장 5~64)
* @param parallel true면 / 패스를 병렬 실행(ForkJoinPool). 멀티코어에서만 유효.
* @return 블러된 이미지 (TYPE_INT_ARGB)
*/
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())
fun blurFast(src: BufferedImage, radius: Int = 100, parallel: Boolean = true): BufferedImage {
require(radius > 0) { "radius must be > 0" }
// 1) 축소
val small = resizeTo(src, smallW, smallH)
// 1) 프리멀티 알파로 변환 (경계 품질↑)
val s = toPremultiplied(src) // TYPE_INT_ARGB_PRE
val w = s.width
val h = s.height
// 2) 강한 블러
val blurredSmall = gaussianBlurSeparable(small, radius = blurRadius)
// 2) 중간/최종 버퍼(프리멀티 유지)
val tmp = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB_PRE)
val dst = BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB_PRE)
// 3) 다시 원본 해상도로 확대
val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB)
val srcArr = (s.raster.dataBuffer as DataBufferInt).data
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()
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR)
g.drawImage(blurredSmall, 0, 0, src.width, src.height, null)
g.drawImage(src, 0, 0, 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)
private fun toNonPremultiplied(src: BufferedImage): BufferedImage {
if (src.type == BufferedImage.TYPE_INT_ARGB) return src
val out = BufferedImage(src.width, src.height, BufferedImage.TYPE_INT_ARGB)
val g = out.createGraphics()
g.drawImage(src, 0, 0, null)
g.dispose()
return out
}
}