From 0f3d7df7087922d7d9d0ab0ef9ea27131d6c0740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=97=B0=ED=98=B8?= Date: Fri, 23 Oct 2026 11:32:35 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EC=98=A8=EB=94=94=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EA=B0=80=EB=A6=BC=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EC=97=90=20=EB=B8=94=EB=9F=AC=20=EA=B0=95=EB=8F=84=C2=B7?= =?UTF-8?q?=EA=B0=80=EB=A6=BC=20=EC=98=81=EC=97=AD=20=EB=84=88=EB=B9=84=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../feature/live/CameraFrameAnalyzer.kt | 7 +++ .../feature/live/OnDeviceMosaicSettings.kt | 53 +++++++++++++++++++ .../feature/live/privacy/PrivacyBitmapBlur.kt | 12 ++--- .../live/privacy/PrivacyBroadcastBlur.kt | 17 +++--- .../live/privacy/PrivacyFrameProcessor.kt | 12 +++-- .../live/privacy/PrivacyGpuFramePipeline.kt | 40 +++++++------- .../live/privacy/PrivacyMaskRenderer.kt | 21 ++++++-- .../feature/live/privacy/PrivacyOnnxModel.kt | 6 ++- 8 files changed, 129 insertions(+), 39 deletions(-) create mode 100644 apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/OnDeviceMosaicSettings.kt diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/CameraFrameAnalyzer.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/CameraFrameAnalyzer.kt index 98fb55124..bcfa5ff22 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/CameraFrameAnalyzer.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/CameraFrameAnalyzer.kt @@ -46,6 +46,7 @@ class CameraFrameAnalyzer( ) : ImageAnalysis.Analyzer, AutoCloseable { private val captureLock = Any() private val settings = AtomicReference(BroadcastVideoQualitySettings()) + private val onDeviceMosaic = AtomicReference(OnDeviceMosaicSettings()) private val previewExposure = PreviewExposureFrameGate() private val colorProcessor = VideoColorFrameProcessor() private val previewRenderer = VideoLookPreviewRenderer() @@ -157,6 +158,11 @@ class CameraFrameAnalyzer( settings.set(value.normalized()) } + /** 온디바이스 가림 처리의 블러 강도·영역 너비입니다. 다음 보호 프레임부터 송출·미리보기·가공본 녹화에 함께 적용합니다. */ + fun setOnDeviceMosaic(value: OnDeviceMosaicSettings) { + onDeviceMosaic.set(value.normalized()) + } + internal fun beginPreviewExposure() = synchronized(captureLock) { previewExposure.begin() previewRenderer.invalidatePendingSample() @@ -479,6 +485,7 @@ class CameraFrameAnalyzer( synchronized(processorLock) { val processor = processor() if (resetPending.getAndSet(false)) processor.resetFaceExceptions() + processor.mosaic = onDeviceMosaic.get() transform(processor) } }, diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/OnDeviceMosaicSettings.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/OnDeviceMosaicSettings.kt new file mode 100644 index 000000000..3ee8e5420 --- /dev/null +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/OnDeviceMosaicSettings.kt @@ -0,0 +1,53 @@ +package com.framework.innolive.feature.live + +import android.content.Context +import com.framework.innolive.feature.live.privacy.PrivacyBroadcastBlur + +/** + * 온디바이스 AI의 얼굴·번호판 블러 강도와 가림 영역 너비입니다. 서버 AI 슬라이더와 같은 눈금(블러 24, 너비 8px)을 + * 쓰지만, 블러는 지금 온디바이스 강도보다 약해지지 않고 너비는 마스크 1칸 아래로 줄지 않습니다. 기기에 저장합니다. + */ +data class OnDeviceMosaicSettings( + val blurStrength: Int = PrivacyBroadcastBlur.DEFAULT_STRENGTH, + val maskMargin: Int = MosaicConfig.DEFAULT_MASK_MARGIN, +) { + fun normalized() = copy( + blurStrength = blurStrength.coerceIn(BLUR_STRENGTH_RANGE), + maskMargin = maskMargin.coerceIn(MASK_MARGIN_RANGE), + ) + + /** 영상 조절 시트의 모자이크 구역이 보여 주는 값입니다. */ + fun toMosaicConfig() = MosaicConfig(blurRadius = blurStrength.toDouble(), maskMargin = maskMargin) + + companion object { + val BLUR_STRENGTH_RANGE = PrivacyBroadcastBlur.DEFAULT_STRENGTH..PrivacyBroadcastBlur.MAX_STRENGTH + /** 8px 아래는 어차피 마스크 1칸이라 슬라이더도 기본값부터 시작합니다. */ + val MASK_MARGIN_RANGE = MosaicConfig.DEFAULT_MASK_MARGIN..MosaicConfig.MASK_MARGIN_RANGE.last + + /** 시트가 돌려준 값입니다. 기본값으로 되돌리기는 null 필드로 오며, 서버 기본값 24·8px가 곧 지금 강도와 1칸입니다. */ + fun from(config: MosaicConfig) = + OnDeviceMosaicSettings(config.effectiveBlurRadius, config.effectiveMaskMargin).normalized() + } +} + +class OnDeviceMosaicPreferences(context: Context) { + private val preferences = context.applicationContext + .getSharedPreferences("live_on_device_mosaic", Context.MODE_PRIVATE) + + fun load() = OnDeviceMosaicSettings( + blurStrength = read("blur_strength", PrivacyBroadcastBlur.DEFAULT_STRENGTH), + maskMargin = read("mask_margin", MosaicConfig.DEFAULT_MASK_MARGIN), + ).normalized() + + fun save(settings: OnDeviceMosaicSettings) { + val value = settings.normalized() + preferences.edit() + .putInt("blur_strength", value.blurStrength) + .putInt("mask_margin", value.maskMargin) + .apply() + } + + // 저장 형식이 바뀌었거나 깨진 값은 기본값으로 읽어 카메라 시작을 막지 않습니다. + private fun read(key: String, fallback: Int) = + runCatching { preferences.getInt(key, fallback) }.getOrDefault(fallback) +} diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBitmapBlur.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBitmapBlur.kt index c69942c4b..e6a82a9be 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBitmapBlur.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBitmapBlur.kt @@ -20,14 +20,14 @@ internal class PrivacyBitmapBlur : AutoCloseable { var lastUsedGpu = false private set - fun apply(source: Bitmap): Bitmap { + fun apply(source: Bitmap, sigma: Int = PrivacyBroadcastBlur.sigma(source.width, source.height)): Bitmap { lastUsedGpu = false if (Build.VERSION.SDK_INT >= 31 && !gpuUnavailable) { try { - if (gpu?.width != source.width || gpu?.height != source.height) { + if (gpu?.width != source.width || gpu?.height != source.height || gpu?.sigma != sigma) { gpu?.close() gpu = null - gpu = Gpu(source.width, source.height) + gpu = Gpu(source.width, source.height, sigma) } val output = checkNotNull(gpu).apply(source) lastUsedGpu = true @@ -38,13 +38,13 @@ internal class PrivacyBitmapBlur : AutoCloseable { gpuUnavailable = true } } - return PrivacyMaskRenderer.broadcastBlur(source) + return PrivacyMaskRenderer.broadcastBlur(source, sigma) } override fun close() { if (Build.VERSION.SDK_INT >= 31) gpu?.close(); gpu = null } @RequiresApi(31) - private class Gpu(val width: Int, val height: Int) : AutoCloseable { + private class Gpu(val width: Int, val height: Int, val sigma: Int) : AutoCloseable { private val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2, HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT) private val node = RenderNode("PrivacyGaussianBlur") @@ -56,7 +56,7 @@ internal class PrivacyBitmapBlur : AutoCloseable { renderer.setContentRoot(node) node.setPosition(0, 0, width, height) // The same full-resolution sigma as the GPU graph and iOS, via HWUI's radius conversion. - val radius = PrivacyBroadcastBlur.renderEffectRadius(width, height) + val radius = PrivacyBroadcastBlur.renderEffectRadius(sigma) node.setRenderEffect(RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP)) } catch (error: Exception) { close(); throw error } } diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlur.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlur.kt index fad9c4336..8cb64b529 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlur.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlur.kt @@ -16,25 +16,30 @@ internal object PrivacyBroadcastBlur { const val REDUCTION = 4 const val MINIMUM_SIGMA = 48 const val SIGMA_PER_SHORT_SIDE = 1.0 / 15 + /** 영상 조절의 온디바이스 블러 강도 눈금입니다. 24가 위 기본 sigma이고, 그보다 약하게는 내리지 않습니다. */ + const val DEFAULT_STRENGTH = 24 + /** 기본의 두 배까지입니다. 1080p 축소 커널 반지름이 54에서 108탭으로 늘어납니다. */ + const val MAX_STRENGTH = 48 /** HWUI `Blur::convertRadiusToSigma`: sigma = 0.57735 * radius + 0.5 for a positive radius. */ private const val RENDER_EFFECT_RADIUS_SCALE = 0.57735f - /** Full-resolution Gaussian sigma for a frame, as on iOS. */ - fun sigma(width: Int, height: Int): Int { + /** Full-resolution Gaussian sigma for a frame, as on iOS, scaled by the on-device strength. + * The strength never goes below the default, so the blur is never weaker than iOS. */ + fun sigma(width: Int, height: Int, strength: Int = DEFAULT_STRENGTH): Int { require(width > 0 && height > 0) - return max(MINIMUM_SIGMA, (min(width, height) * SIGMA_PER_SHORT_SIDE).roundToInt()) + val base = max(MINIMUM_SIGMA, (min(width, height) * SIGMA_PER_SHORT_SIDE).roundToInt()) + return (base * strength.coerceIn(DEFAULT_STRENGTH, MAX_STRENGTH).toDouble() / DEFAULT_STRENGTH).roundToInt() } /** Sigma at the reduced resolution where the blur runs. */ - fun reducedSigma(width: Int, height: Int): Double = sigma(width, height).toDouble() / REDUCTION + fun reducedSigma(sigma: Int): Double = sigma.toDouble() / REDUCTION fun reducedSize(size: Int): Int = max(1, ceil(size / REDUCTION.toDouble()).toInt()) fun kernelRadius(sigma: Double): Int = ceil(3 * sigma).toInt() /** `RenderEffect.createBlurEffect` radius that HWUI converts to the full-resolution sigma. */ - fun renderEffectRadius(width: Int, height: Int): Float = - (sigma(width, height) - 0.5f) / RENDER_EFFECT_RADIUS_SCALE + fun renderEffectRadius(sigma: Int): Float = (sigma - 0.5f) / RENDER_EFFECT_RADIUS_SCALE /** Separable Gaussian fragment shader with every tap unrolled; GLSL ES 1.0 needs constant bounds. */ fun loopGaussianShader(radius: Int, sigma: Double): String { diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyFrameProcessor.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyFrameProcessor.kt index ce0bb41e7..ee7436b2e 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyFrameProcessor.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyFrameProcessor.kt @@ -7,6 +7,7 @@ import android.graphics.Color import android.graphics.Matrix import android.util.Log import com.framework.innolive.BuildConfig +import com.framework.innolive.feature.live.OnDeviceMosaicSettings import org.webrtc.VideoFrame import org.webrtc.JavaI420Buffer import org.webrtc.EglBase @@ -28,6 +29,8 @@ internal class PrivacyFrameProcessor(context: Context, private val sharedContext PrivacyFaceCoordinator(context.applicationContext, optimized=nativePostprocessing)).also { coordinator -> coordinator.liveTrackIds = { model.liveTrackIds } } + /** 영상 조절의 온디바이스 블러 강도·가림 영역 너비입니다. 처리 스레드가 프레임마다 읽습니다. */ + @Volatile var mosaic = OnDeviceMosaicSettings() var lastTimings: PrivacyFrameTimings? = null private set val lastAnalysis: PrivacyFrameAnalysis? get() = model.lastAnalysis @@ -128,7 +131,7 @@ internal class PrivacyFrameProcessor(context: Context, private val sharedContext private fun processCpuUpright(upright:Bitmap,width:Int,height:Int,rotation:Int,timestampNs:Long):VideoFrame { val started=System.nanoTime() if (!batchTwoOptimizations) faces.beginFrame(upright,timestampNs) - val protected=model.process(upright,timestampNs) {objects,layout -> + val protected=model.process(upright,timestampNs,mosaic) {objects,layout -> if (batchTwoOptimizations) faces.currentFrame(upright,objects,layout,timestampNs) else faces.exceptions(upright,objects,layout,timestampNs) } @@ -160,6 +163,7 @@ internal class PrivacyFrameProcessor(context: Context, private val sharedContext val nextGeometry=Triple(sensorWidth,sensorHeight,rotation) if(geometry!=nextGeometry) {resetFaceExceptions();geometry=nextGeometry} if (!batchTwoOptimizations) faces.beginFrame(layout.sourceWidth,layout.sourceHeight,timestampNs,graph::crop) + val mosaic=mosaic val buffer=model.processPrepared(graph.modelBitmap,layout,timestampNs, {objects,box -> if (batchTwoOptimizations) faces.currentFrame(box.sourceWidth,box.sourceHeight,objects,box,timestampNs,graph::crop, @@ -167,7 +171,9 @@ internal class PrivacyFrameProcessor(context: Context, private val sharedContext else faces.exceptions(box.sourceWidth,box.sourceHeight,objects,box,timestampNs) }, renderOnGpu=true,gpuGraph=graph) {mask -> graph.finish(mask,layout,sensorWidth,sensorHeight, - model.lastMaskPixels,outputUpright=true)} + model.lastMaskPixels,outputUpright=true, + blurSigma=PrivacyBroadcastBlur.sigma(layout.sourceWidth,layout.sourceHeight,mosaic.blurStrength), + edgeCells=PrivacyMaskRenderer.edgeCells(layout.sourceWidth,layout.sourceHeight,mosaic.maskMargin))} val completed = System.nanoTime() lastTimings = PrivacyFrameTimings((converted - started) / 1e6, checkNotNull(model.lastTimings), 0.0, (completed - started) / 1e6) @@ -191,7 +197,7 @@ internal class PrivacyFrameProcessor(context: Context, private val sharedContext val upright = rotate(sensor, rotation, uprightPixels) val converted = System.nanoTime() if (!batchTwoOptimizations) faces.beginFrame(upright, frame.timestampNs) - val protected = model.process(upright, frame.timestampNs) { objects, layout -> + val protected = model.process(upright, frame.timestampNs, mosaic) { objects, layout -> if (batchTwoOptimizations) faces.currentFrame(upright, objects, layout, frame.timestampNs) else faces.exceptions(upright, objects, layout, frame.timestampNs) } diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipeline.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipeline.kt index 656cad3f9..ce3ebfbef 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipeline.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipeline.kt @@ -413,7 +413,8 @@ internal class PrivacyGpuFramePipeline(sharedContext: EglBase.Context?, useGles3 fun finish(mask: ByteArray, layout: PrivacySegmentation.Letterbox, sensorWidth: Int, sensorHeight: Int, maskPixels: Int = -1, - outputUpright: Boolean = false): VideoFrame.TextureBuffer = onGl { + outputUpright: Boolean = false, blurSigma: Int? = null, + edgeCells: Int = PrivacyMaskRenderer.PROTECTED_EDGE_CELLS): VideoFrame.TextureBuffer = onGl { check(!closing) require(layout.sourceWidth == width && layout.sourceHeight == height && mask.size == 160 * 160) val outputWidth = if (outputUpright) width else sensorWidth @@ -428,18 +429,18 @@ internal class PrivacyGpuFramePipeline(sharedContext: EglBase.Context?, useGles3 } val original = targets.getValue("upright") if (if (maskPixels >= 0) maskPixels > 0 else mask.any { it.toInt() != 0 }) { - val alpha = uploadMask(mask) + val alpha = uploadMask(mask, edgeCells) val quarterW = PrivacyBroadcastBlur.reducedSize(width) val quarterH = PrivacyBroadcastBlur.reducedSize(height) val reduced = target("reduced", quarterW, quarterH) - val blurRegion=if(roiBlur) blurScissor(mask,layout,maskPixels,quarterW,quarterH) else null + val sigma = blurSigma ?: PrivacyBroadcastBlur.sigma(width, height) + val blurRegion=if(roiBlur) blurScissor(mask,layout,maskPixels,quarterW,quarterH,sigma,edgeCells) else null // iOS broadcastStyleBlur: area-average reduction, not a point-sampled 24px block grid. draw("reduce", REDUCE, reduced, listOf(original.texture),scissor=blurRegion) { p -> glUniform2f(p.getUniformLocation("texelSize"), 1f / width, 1f / height) } val horizontal = target("blur-x", quarterW, quarterH) val vertical = target("blur-y", quarterW, quarterH) - val sigma = PrivacyBroadcastBlur.sigma(width, height) val blurName = "${if(pairedKernels) "blur-fast" else "blur"}-$sigma" val blurShader = blurShader(blurName, sigma) draw(blurName, blurShader, horizontal, listOf(reduced.texture),scissor=blurRegion) { p -> @@ -486,7 +487,7 @@ internal class PrivacyGpuFramePipeline(sharedContext: EglBase.Context?, useGles3 /** Expand sparse mask bounds by dilation, feather, two Gaussian axes and the reduction footprint. */ private fun blurScissor(mask:ByteArray,layout:PrivacySegmentation.Letterbox,count:Int, - targetWidth:Int,targetHeight:Int):Rect? { + targetWidth:Int,targetHeight:Int,sigma:Int,edgeCells:Int):Rect? { if(count==mask.size) return null var left=160;var top=160;var right=-1;var bottom=-1 for(index in mask.indices) if(mask[index].toInt()!=0) { @@ -496,15 +497,16 @@ internal class PrivacyGpuFramePipeline(sharedContext: EglBase.Context?, useGles3 if(right glUniform2f(p.getUniformLocation("stepSize"), 1f / 160, 0f) - glUniform1i(p.getUniformLocation("radius"), PrivacyMaskRenderer.PROTECTED_EDGE_CELLS) + glUniform1i(p.getUniformLocation("radius"), edgeCells) } draw("dilate-y", DILATE, alpha, listOf(temp.texture)) { p -> glUniform2f(p.getUniformLocation("stepSize"), 0f, 1f / 160) - glUniform1i(p.getUniformLocation("radius"), PrivacyMaskRenderer.PROTECTED_EDGE_CELLS) + glUniform1i(p.getUniformLocation("radius"), edgeCells) } return alpha } @@ -670,7 +672,7 @@ internal class PrivacyGpuFramePipeline(sharedContext: EglBase.Context?, useGles3 """ private const val DILATE = """ uniform sampler2D tex0; uniform vec2 stepSize; uniform int radius; - void main(){ float a=0.0; for(int i=-4;i<=4;i++){ if(i>=-radius && i<=radius) a=max(a,texture2D(tex0,uv+float(i)*stepSize).r); } gl_FragColor=vec4(vec3(a),1.0); } + void main(){ float a=0.0; for(int i=-${PrivacyMaskRenderer.MAX_EDGE_CELLS};i<=${PrivacyMaskRenderer.MAX_EDGE_CELLS};i++){ if(i>=-radius && i<=radius) a=max(a,texture2D(tex0,uv+float(i)*stepSize).r); } gl_FragColor=vec4(vec3(a),1.0); } """ private const val COMPOSITE = "uniform sampler2D tex0,tex1,tex2; uniform vec4 box; uniform int rotation; " + FORWARD + "void main(){ vec2 p=upright(uv); float a=texture2D(tex2,box.xy+p*box.zw).r; gl_FragColor=mix(texture2D(tex0,p),texture2D(tex1,p),a); }" diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMaskRenderer.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMaskRenderer.kt index 514971f13..f77ca2420 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMaskRenderer.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMaskRenderer.kt @@ -6,12 +6,23 @@ import android.graphics.Paint import android.graphics.Rect import kotlin.math.max import kotlin.math.min +import kotlin.math.roundToInt internal object PrivacyMaskRenderer { /** Opaque cells added around the face contour, like the server's one-mask-pixel pad plus 8px * margin. One 160-grid cell is 8–12 output px at 720p–1080p; the 3x3 square equals a disk of * radius sqrt(2), so the margin follows the contour instead of growing into a box. */ const val PROTECTED_EDGE_CELLS = 1 + /** 영상 조절 가림 영역 너비의 상한입니다. 너비 64px가 854px 폭 영상에서 12칸입니다. */ + const val MAX_EDGE_CELLS = 12 + + /** 송출 영상 px로 받은 가림 영역 너비를 160 마스크 칸 수로 바꿉니다. 1칸은 영상 긴 변의 1/160이며, + * 서버 기본 8px는 720p·1080p 모두 지금과 같은 1칸입니다. 0px여도 1칸 아래로 줄이지 않습니다. */ + fun edgeCells(width: Int, height: Int, marginPx: Int): Int { + require(width > 0 && height > 0) + val cellPx = max(width, height).toDouble() / PrivacySegmentation.MASK_SIZE + return (marginPx / cellPx).roundToInt().coerceIn(PROTECTED_EDGE_CELLS, MAX_EDGE_CELLS) + } /** The inner mask may only grow. */ fun expandedMask(mask: ByteArray, radius: Int = PROTECTED_EDGE_CELLS): ByteArray { @@ -40,14 +51,15 @@ internal object PrivacyMaskRenderer { } fun render(upright: Bitmap, mask: ByteArray, layout: PrivacySegmentation.Letterbox, - filter: (Bitmap) -> Bitmap = ::broadcastBlur): Bitmap { + filter: (Bitmap) -> Bitmap = { broadcastBlur(it) }, + edgeCells: Int = PROTECTED_EDGE_CELLS): Bitmap { require(upright.width == layout.sourceWidth && upright.height == layout.sourceHeight) require(mask.size == PrivacySegmentation.MASK_SIZE * PrivacySegmentation.MASK_SIZE) val width = upright.width val height = upright.height // Never return/recycle the capture bitmap, even for a 1x1 input or an empty mask. if (mask.all { it.toInt() == 0 }) return checkNotNull(upright.copy(Bitmap.Config.ARGB_8888, false)) - val alpha = expandedMask(mask) + val alpha = expandedMask(mask, edgeCells) val mosaic = filter(upright) val output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) try { @@ -61,13 +73,14 @@ internal object PrivacyMaskRenderer { /** iOS broadcastStyleBlur on the CPU: area-average reduction to 1/4, a Gaussian with the * reduced sigma, then scaling back. No pixelation grid: a point-sampled block grid shimmers * between frames. The mask remains at its original resolution; this only reduces filter work. */ - internal fun broadcastBlur(upright: Bitmap): Bitmap { + internal fun broadcastBlur(upright: Bitmap, + sigma: Int = PrivacyBroadcastBlur.sigma(upright.width, upright.height)): Bitmap { val reduced = reduce(upright) try { val pixels = IntArray(reduced.width * reduced.height) reduced.getPixels(pixels, 0, reduced.width, 0, 0, reduced.width, reduced.height) val blurred = PrivacyGaussianBlur.apply(pixels, reduced.width, reduced.height, - PrivacyBroadcastBlur.reducedSigma(upright.width, upright.height)) + PrivacyBroadcastBlur.reducedSigma(sigma)) reduced.setPixels(blurred, 0, reduced.width, 0, 0, reduced.width, reduced.height) val output = Bitmap.createBitmap(upright.width, upright.height, Bitmap.Config.ARGB_8888) try { diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyOnnxModel.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyOnnxModel.kt index 29d0f8e75..ca82bf66c 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyOnnxModel.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyOnnxModel.kt @@ -13,6 +13,7 @@ import android.graphics.Rect import android.graphics.RectF import android.util.Log import com.framework.innolive.BuildConfig +import com.framework.innolive.feature.live.OnDeviceMosaicSettings import java.security.MessageDigest import java.nio.ByteBuffer import java.nio.ByteOrder @@ -82,6 +83,7 @@ internal class PrivacyOnnxModel(private val context: Context, fun process( upright: Bitmap, timestampNs: Long = System.nanoTime(), + mosaic: OnDeviceMosaicSettings = OnDeviceMosaicSettings(), exemptFaces: (List, PrivacySegmentation.Letterbox) -> Set = { _, _ -> emptySet() }, ): Bitmap { val layout = PrivacySegmentation.Letterbox(upright.width, upright.height) @@ -96,7 +98,9 @@ internal class PrivacyOnnxModel(private val context: Context, Paint(Paint.FILTER_BITMAP_FLAG), ) return processPrepared(inputBitmap, layout, timestampNs, exemptFaces) { mask -> - PrivacyMaskRenderer.render(upright, mask, layout, blur::apply) + val sigma = PrivacyBroadcastBlur.sigma(upright.width, upright.height, mosaic.blurStrength) + PrivacyMaskRenderer.render(upright, mask, layout, { blur.apply(it, sigma) }, + PrivacyMaskRenderer.edgeCells(upright.width, upright.height, mosaic.maskMargin)) } } From ee2fab161478c91ad440fda21aafcf5c1de1787a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=97=B0=ED=98=B8?= Date: Fri, 23 Oct 2026 11:32:36 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EC=98=81=EC=83=81=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=88=EC=97=90=EC=84=9C=20=EC=98=A8=EB=94=94=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=B8=94=EB=9F=AC=20=EA=B0=95=EB=8F=84?= =?UTF-8?q?=C2=B7=EA=B0=80=EB=A6=BC=20=EC=98=81=EC=97=AD=20=EB=84=88?= =?UTF-8?q?=EB=B9=84=20=EC=A1=B0=EC=A0=88=EA=B3=BC=20=EA=B8=B0=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../framework/innolive/feature/live/Live.kt | 41 +++++++++++++++---- .../innolive/feature/live/MosaicControls.kt | 19 +++++---- .../src/main/res/values-en/video_quality.xml | 1 + .../src/main/res/values-ja/video_quality.xml | 1 + .../app/src/main/res/values/video_quality.xml | 1 + 5 files changed, 47 insertions(+), 16 deletions(-) diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/Live.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/Live.kt index c9e876794..be23f7c13 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/Live.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/Live.kt @@ -351,6 +351,10 @@ private fun LiveScreenContent( } val idleFrameAnalyzer = remember { CameraFrameAnalyzer() } val frameAnalyzer = webRtcSession.frameAnalyzer ?: idleFrameAnalyzer + // 온디바이스 블러 강도·가림 영역 너비는 기기에 저장하고, 지금 송출 중인 분석기에 바로 넘깁니다. + val onDeviceMosaicPreferences = remember(context) { OnDeviceMosaicPreferences(context) } + var onDeviceMosaic by remember(onDeviceMosaicPreferences) { mutableStateOf(onDeviceMosaicPreferences.load()) } + SideEffect { frameAnalyzer.setOnDeviceMosaic(onDeviceMosaic) } val lookPreviews by frameAnalyzer.lookPreviews.collectAsState() DisposableEffect(idleFrameAnalyzer) { onDispose { idleFrameAnalyzer.close() } @@ -465,19 +469,38 @@ private fun LiveScreenContent( onSettingsChanged = props.onVideoQualitySettingsChanged, onDismiss = { setVideoControlsOpen(false) }, mirrorPreviews = props.cameraLensFacing.shouldMirrorPreview, - mosaic = MosaicControlsState( - config = webRtcSession.mosaicConfig, - enabled = webRtcSession.canChangeMosaicConfig, - isChanging = webRtcSession.isChangingMosaicConfig, - noteRes = mosaicNoteRes( + mosaic = run { + val noteRes = mosaicNoteRes( hasSession = webRtcSession.sessionSnapshot != null, onDeviceProcessing = webRtcSession.selectedOnDeviceProcessing, canChange = webRtcSession.canChangeMosaicConfig, anonymizationEnabled = webRtcSession.anonymizationState == AnonymizationState.ENABLED, - ), - error = webRtcSession.mosaicConfigError, - onChange = { webRtcSession.updateMosaicConfig(it) }, - ), + ) + if (webRtcSession.selectedOnDeviceProcessing) { + MosaicControlsState( + config = onDeviceMosaic.toMosaicConfig(), + blurRange = OnDeviceMosaicSettings.BLUR_STRENGTH_RANGE, + maskMarginRange = OnDeviceMosaicSettings.MASK_MARGIN_RANGE, + enabled = true, + isChanging = false, + noteRes = noteRes, + error = null, + onChange = { config -> + onDeviceMosaic = OnDeviceMosaicSettings.from(config) + onDeviceMosaicPreferences.save(onDeviceMosaic) + }, + ) + } else { + MosaicControlsState( + config = webRtcSession.mosaicConfig, + enabled = webRtcSession.canChangeMosaicConfig, + isChanging = webRtcSession.isChangingMosaicConfig, + noteRes = noteRes, + error = webRtcSession.mosaicConfigError, + onChange = { webRtcSession.updateMosaicConfig(it) }, + ) + } + }, ) } diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/MosaicControls.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/MosaicControls.kt index 06cfc2091..3926ecc03 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/MosaicControls.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/MosaicControls.kt @@ -29,10 +29,13 @@ import com.framework.innolive.ui.text.UiText import com.framework.innolive.ui.text.asString import kotlin.math.roundToInt -/** 영상 조절 시트의 모자이크 구역 상태입니다. [config]는 서버가 확정한 값입니다. */ +/** 영상 조절 시트의 모자이크 구역 상태입니다. [config]는 서버가 확정한 값이거나 기기에 저장한 온디바이스 값입니다. */ @Immutable data class MosaicControlsState( val config: MosaicConfig, + /** 슬라이더 범위입니다. 온디바이스는 지금 강도와 마스크 1칸보다 약하게 내리지 않습니다. */ + val blurRange: IntRange = MosaicConfig.BLUR_RADIUS_RANGE, + val maskMarginRange: IntRange = MosaicConfig.MASK_MARGIN_RANGE, val enabled: Boolean, val isChanging: Boolean, /** 조절할 수 없거나 적용되지 않는 이유입니다. */ @@ -42,7 +45,8 @@ data class MosaicControlsState( ) /** - * 조절할 수 없거나 적용되지 않는 이유입니다. iOS와 같은 순서로 방송 준비 전, 온디바이스 처리, 비식별화 꺼짐을 봅니다. + * 조절할 수 없거나 적용되지 않는 이유입니다. 온디바이스는 기기에 저장해 방송 준비 전에도 조절하므로 하한만 알립니다. + * 서버 AI는 iOS와 같은 순서로 방송 준비 전, 비식별화 꺼짐을 봅니다. */ @StringRes internal fun mosaicNoteRes( @@ -51,8 +55,9 @@ internal fun mosaicNoteRes( canChange: Boolean, anonymizationEnabled: Boolean, ): Int? = when { + onDeviceProcessing && !anonymizationEnabled -> R.string.mosaic_anonymization_off + onDeviceProcessing -> R.string.mosaic_on_device_floor !hasSession -> R.string.mosaic_prepare_required - onDeviceProcessing -> R.string.mosaic_server_only canChange && !anonymizationEnabled -> R.string.mosaic_anonymization_off else -> null } @@ -95,11 +100,11 @@ internal fun MosaicControls(state: MosaicControlsState) { title = stringResource(R.string.mosaic_blur_strength), value = shown.blurRadius.toFloat(), valueLabel = shown.blurRadius.toString(), - range = MosaicConfig.BLUR_RADIUS_RANGE.first.toFloat()..MosaicConfig.BLUR_RADIUS_RANGE.last.toFloat(), + range = state.blurRange.first.toFloat()..state.blurRange.last.toFloat(), leading = stringResource(R.string.mosaic_weaker), trailing = stringResource(R.string.mosaic_stronger), enabled = state.enabled, - steps = MosaicConfig.BLUR_RADIUS_RANGE.last - MosaicConfig.BLUR_RADIUS_RANGE.first - 1, + steps = state.blurRange.last - state.blurRange.first - 1, onValueChangeFinished = ::commit, onValueChange = { isEditing = true @@ -110,11 +115,11 @@ internal fun MosaicControls(state: MosaicControlsState) { title = stringResource(R.string.mosaic_mask_margin), value = shown.maskMargin.toFloat(), valueLabel = stringResource(R.string.mosaic_pixels_value, shown.maskMargin), - range = MosaicConfig.MASK_MARGIN_RANGE.first.toFloat()..MosaicConfig.MASK_MARGIN_RANGE.last.toFloat(), + range = state.maskMarginRange.first.toFloat()..state.maskMarginRange.last.toFloat(), leading = stringResource(R.string.mosaic_narrower), trailing = stringResource(R.string.mosaic_wider), enabled = state.enabled, - steps = MosaicConfig.MASK_MARGIN_RANGE.last - MosaicConfig.MASK_MARGIN_RANGE.first - 1, + steps = state.maskMarginRange.last - state.maskMarginRange.first - 1, onValueChangeFinished = ::commit, onValueChange = { isEditing = true diff --git a/apps/android/InnoLive/app/src/main/res/values-en/video_quality.xml b/apps/android/InnoLive/app/src/main/res/values-en/video_quality.xml index 95f1ac1dc..394975c2c 100644 --- a/apps/android/InnoLive/app/src/main/res/values-en/video_quality.xml +++ b/apps/android/InnoLive/app/src/main/res/values-en/video_quality.xml @@ -35,5 +35,6 @@ Available after you prepare a broadcast. Available only with server AI processing. Applies while anonymization is on. + On-device AI can\'t go below the default strength and width. Couldn’t change the blur settings. Please try again. diff --git a/apps/android/InnoLive/app/src/main/res/values-ja/video_quality.xml b/apps/android/InnoLive/app/src/main/res/values-ja/video_quality.xml index 21aa85d9c..1b93235d3 100644 --- a/apps/android/InnoLive/app/src/main/res/values-ja/video_quality.xml +++ b/apps/android/InnoLive/app/src/main/res/values-ja/video_quality.xml @@ -35,5 +35,6 @@ 配信を準備すると調整できます。 サーバーAI処理でのみ調整できます。 匿名化がオンのときに適用されます。 + オンデバイスAIは標準の強さと幅より弱くできません。 ぼかし設定を変更できませんでした。もう一度お試しください。 diff --git a/apps/android/InnoLive/app/src/main/res/values/video_quality.xml b/apps/android/InnoLive/app/src/main/res/values/video_quality.xml index afbdbbf20..fa86b464c 100644 --- a/apps/android/InnoLive/app/src/main/res/values/video_quality.xml +++ b/apps/android/InnoLive/app/src/main/res/values/video_quality.xml @@ -35,5 +35,6 @@ 방송을 준비하면 조절할 수 있습니다. 서버 AI 처리에서만 조절할 수 있습니다. 비식별화가 켜져 있을 때 적용됩니다. + 온디바이스 AI는 기본 강도와 너비보다 약하게 할 수 없습니다. 블러 설정을 바꾸지 못했습니다. 다시 시도해 주세요. From 8db1d84528f669c56b2abb01528cf244e4d01749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=97=B0=ED=98=B8?= Date: Fri, 23 Oct 2026 11:32:36 +0900 Subject: [PATCH 3/5] =?UTF-8?q?test:=20=EC=98=A8=EB=94=94=EB=B0=94?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EB=AA=A8=EC=9E=90=EC=9D=B4=ED=81=AC=20?= =?UTF-8?q?=EA=B0=95=EB=8F=84=C2=B7=EB=84=88=EB=B9=84=20=EB=B3=80=ED=99=98?= =?UTF-8?q?=EA=B3=BC=20GPU=20=EC=A0=81=EC=9A=A9=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../PrivacyGpuFramePipelineDeviceTest.kt | 51 +++++++++++++++++++ .../innolive/feature/live/MosaicConfigTest.kt | 13 +++-- .../live/OnDeviceMosaicSettingsTest.kt | 31 +++++++++++ .../live/privacy/PrivacyBroadcastBlurTest.kt | 21 ++++++-- .../live/privacy/PrivacyMaskExpansionTest.kt | 13 +++++ 5 files changed, 122 insertions(+), 7 deletions(-) create mode 100644 apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/OnDeviceMosaicSettingsTest.kt diff --git a/apps/android/InnoLive/app/src/androidTest/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipelineDeviceTest.kt b/apps/android/InnoLive/app/src/androidTest/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipelineDeviceTest.kt index deaa73318..386837629 100644 --- a/apps/android/InnoLive/app/src/androidTest/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipelineDeviceTest.kt +++ b/apps/android/InnoLive/app/src/androidTest/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipelineDeviceTest.kt @@ -214,6 +214,57 @@ class PrivacyGpuFramePipelineDeviceTest { } finally { source.release() } } + @Test fun strongerOnDeviceStrengthSpreadsTheEdgeFurther() { + val source = source(320,320) + try { + for (y in 0 until 320) for (x in 0 until 320) + source.dataY.put(y*source.strideY+x,(if(x<160)16 else 235).toByte()) + PrivacyGpuFramePipeline(null).use { graph -> + val layout = graph.prepare(source,0) + fun leftOfEdge(strength: Int): Int { + val texture = graph.finish(ByteArray(160*160) { -1 },layout,320,320, + blurSigma=PrivacyBroadcastBlur.sigma(320,320,strength)) + try { + val pixels = texture.toI420()!! + try { return pixels.dataY.get(160*pixels.strideY+100).toInt() and 255 } + finally { pixels.release() } + } finally { texture.release() } + } + val default = leftOfEdge(PrivacyBroadcastBlur.DEFAULT_STRENGTH) + val strongest = leftOfEdge(PrivacyBroadcastBlur.MAX_STRENGTH) + // 60px 떨어진 곳까지 밝은 쪽이 번지는 정도는 sigma가 두 배일 때 더 커야 합니다. + assertTrue("Stronger blur did not spread: default=$default strongest=$strongest", strongest > default + 5) + } + } finally { source.release() } + } + + @Test fun widerMarginProtectsPixelsOutsideTheDefaultEdge() { + val source = source(320, 320) + try { + for (y in 0 until 320) for (x in 0 until 320) + source.dataY.put(y * source.strideY + x, (if (x % 2 == 0) 16 else 235).toByte()) + PrivacyGpuFramePipeline(null).use { graph -> + val layout = graph.prepare(source, 0) + val mask = ByteArray(160 * 160) + for (y in 60..100) for (x in 60..100) mask[y * 160 + x] = -1 + fun pixel(mask: ByteArray, edgeCells: Int): Int { + val texture = graph.finish(mask, layout, 320, 320, edgeCells = edgeCells) + try { + val pixels = texture.toI420()!! + try { return pixels.dataY.get(160 * pixels.strideY + 212).toInt() and 255 } + finally { pixels.release() } + } finally { texture.release() } + } + val unprotected = pixel(ByteArray(160 * 160), PrivacyMaskRenderer.PROTECTED_EDGE_CELLS) + // 마스크 101칸(202px)까지가 기본 1칸 팽창입니다. 212px은 기본에서는 그대로이고 8칸이면 가려집니다. + assertEquals(unprotected.toDouble(), pixel(mask, PrivacyMaskRenderer.PROTECTED_EDGE_CELLS).toDouble(), 3.0) + val widened = pixel(mask, 8) + assertTrue("Wider margin left the pixel exposed: before=$unprotected after=$widened", + kotlin.math.abs(widened - unprotected) > 20 && widened in 40..220) + } + } finally { source.release() } + } + @Test fun heldTextureSurvivesNewFramesGeometryChangesAndProcessorClose() { val source = source() val different = source(48, 32) diff --git a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/MosaicConfigTest.kt b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/MosaicConfigTest.kt index b1dfb2191..e9dddcd87 100644 --- a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/MosaicConfigTest.kt +++ b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/MosaicConfigTest.kt @@ -43,11 +43,18 @@ class MosaicConfigTest { @Test fun noteFollowsIosOrder() { assertEquals(R.string.mosaic_prepare_required, - mosaicNoteRes(hasSession = false, onDeviceProcessing = true, canChange = false, anonymizationEnabled = true)) - assertEquals(R.string.mosaic_server_only, - mosaicNoteRes(hasSession = true, onDeviceProcessing = true, canChange = false, anonymizationEnabled = true)) + mosaicNoteRes(hasSession = false, onDeviceProcessing = false, canChange = false, anonymizationEnabled = true)) assertEquals(R.string.mosaic_anonymization_off, mosaicNoteRes(hasSession = true, onDeviceProcessing = false, canChange = true, anonymizationEnabled = false)) assertNull(mosaicNoteRes(hasSession = true, onDeviceProcessing = false, canChange = true, anonymizationEnabled = true)) } + + @Test fun onDeviceNoteExplainsTheFloorEvenBeforePreparation() { + assertEquals(R.string.mosaic_on_device_floor, + mosaicNoteRes(hasSession = false, onDeviceProcessing = true, canChange = false, anonymizationEnabled = true)) + assertEquals(R.string.mosaic_on_device_floor, + mosaicNoteRes(hasSession = true, onDeviceProcessing = true, canChange = false, anonymizationEnabled = true)) + assertEquals(R.string.mosaic_anonymization_off, + mosaicNoteRes(hasSession = true, onDeviceProcessing = true, canChange = false, anonymizationEnabled = false)) + } } diff --git a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/OnDeviceMosaicSettingsTest.kt b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/OnDeviceMosaicSettingsTest.kt new file mode 100644 index 000000000..4ad1ba569 --- /dev/null +++ b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/OnDeviceMosaicSettingsTest.kt @@ -0,0 +1,31 @@ +package com.framework.innolive.feature.live + +import com.framework.innolive.feature.live.privacy.PrivacyBroadcastBlur +import org.junit.Assert.assertEquals +import org.junit.Test + +class OnDeviceMosaicSettingsTest { + @Test fun defaultsMatchTheServerScaleAndTheCurrentOnDeviceStrength() { + val settings = OnDeviceMosaicSettings() + assertEquals(MosaicConfig.DEFAULT_BLUR_RADIUS, settings.blurStrength) + assertEquals(PrivacyBroadcastBlur.DEFAULT_STRENGTH, settings.blurStrength) + assertEquals(MosaicConfig.DEFAULT_MASK_MARGIN, settings.maskMargin) + assertEquals(MosaicConfig(blurRadius = 24.0, maskMargin = 8), settings.toMosaicConfig()) + } + + @Test fun neverGoesWeakerOrNarrowerThanTheDefault() { + assertEquals(OnDeviceMosaicSettings(24, 8), OnDeviceMosaicSettings(1, 0).normalized()) + assertEquals(OnDeviceMosaicSettings(48, 64), OnDeviceMosaicSettings(99, 99).normalized()) + assertEquals(OnDeviceMosaicSettings(36, 20), OnDeviceMosaicSettings(36, 20).normalized()) + } + + @Test fun sheetChangesAndResetMapBackToTheOnDeviceRange() { + assertEquals(OnDeviceMosaicSettings(40, 32), + OnDeviceMosaicSettings.from(MosaicConfig().adjusted(blurRadius = 40, maskMargin = 32))) + // 모자이크 기본값 버튼은 null 필드로 오며, 지금 강도와 1칸으로 돌아갑니다. + assertEquals(OnDeviceMosaicSettings(), + OnDeviceMosaicSettings.from(MosaicConfig(blurRadius = 40.0, maskMargin = 32).adjusted(null, null))) + assertEquals(OnDeviceMosaicSettings(48, 8), + OnDeviceMosaicSettings.from(MosaicConfig().adjusted(blurRadius = 64, maskMargin = 0))) + } +} diff --git a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlurTest.kt b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlurTest.kt index ff7b51787..40aea04c4 100644 --- a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlurTest.kt +++ b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlurTest.kt @@ -15,9 +15,19 @@ class PrivacyBroadcastBlurTest { assertEquals(96, PrivacyBroadcastBlur.sigma(2560, 1440)) } + @Test fun strengthScalesFromTheDefaultSigmaAndNeverGoesWeaker() { + assertEquals(72, PrivacyBroadcastBlur.sigma(1920, 1080, PrivacyBroadcastBlur.DEFAULT_STRENGTH)) + assertEquals(108, PrivacyBroadcastBlur.sigma(1920, 1080, 36)) + assertEquals(144, PrivacyBroadcastBlur.sigma(1920, 1080, PrivacyBroadcastBlur.MAX_STRENGTH)) + assertEquals(96, PrivacyBroadcastBlur.sigma(1280, 720, PrivacyBroadcastBlur.MAX_STRENGTH)) + // 영상 조절 하한보다 약한 값이나 상한을 넘는 값은 범위 안으로 맞춥니다. + assertEquals(72, PrivacyBroadcastBlur.sigma(1920, 1080, 1)) + assertEquals(144, PrivacyBroadcastBlur.sigma(1920, 1080, 64)) + } + @Test fun reducedSigmaAndSizeMatchTheQuarterResolutionGraph() { - assertEquals(18.0, PrivacyBroadcastBlur.reducedSigma(1920, 1080), 0.0) - assertEquals(12.0, PrivacyBroadcastBlur.reducedSigma(1280, 720), 0.0) + assertEquals(18.0, PrivacyBroadcastBlur.reducedSigma(PrivacyBroadcastBlur.sigma(1920, 1080)), 0.0) + assertEquals(12.0, PrivacyBroadcastBlur.reducedSigma(PrivacyBroadcastBlur.sigma(1280, 720)), 0.0) assertEquals(480, PrivacyBroadcastBlur.reducedSize(1920)) assertEquals(270, PrivacyBroadcastBlur.reducedSize(1080)) assertEquals(1, PrivacyBroadcastBlur.reducedSize(1)) @@ -28,8 +38,11 @@ class PrivacyBroadcastBlurTest { @Test fun renderEffectRadiusConvertsBackToTheFullResolutionSigma() { for ((width, height) in listOf(1920 to 1080, 1280 to 720)) { - val radius = PrivacyBroadcastBlur.renderEffectRadius(width, height) - assertEquals(PrivacyBroadcastBlur.sigma(width, height).toFloat(), 0.57735f * radius + 0.5f, 0.01f) + for (strength in listOf(PrivacyBroadcastBlur.DEFAULT_STRENGTH, PrivacyBroadcastBlur.MAX_STRENGTH)) { + val sigma = PrivacyBroadcastBlur.sigma(width, height, strength) + val radius = PrivacyBroadcastBlur.renderEffectRadius(sigma) + assertEquals(sigma.toFloat(), 0.57735f * radius + 0.5f, 0.01f) + } } } diff --git a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyMaskExpansionTest.kt b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyMaskExpansionTest.kt index 3468f3324..0a0659cd3 100644 --- a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyMaskExpansionTest.kt +++ b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyMaskExpansionTest.kt @@ -43,4 +43,17 @@ class PrivacyMaskExpansionTest { // The bounding-box corner of the oval stays unprotected, so the area is not a box. assertEquals(0, protected[(80 - 16) * size + (80 - 12)].toInt()) } + + @Test fun marginPixelsBecomeMaskCellsOfTheLongSide() { + // 서버 기본 8px는 720p·1080p 모두 지금과 같은 1칸입니다. + assertEquals(1, PrivacyMaskRenderer.edgeCells(1280, 720, 8)) + assertEquals(1, PrivacyMaskRenderer.edgeCells(1920, 1080, 8)) + assertEquals(1, PrivacyMaskRenderer.edgeCells(1080, 1920, 8)) + // 0px여도 1칸 아래로 줄이지 않습니다. + assertEquals(1, PrivacyMaskRenderer.edgeCells(1920, 1080, 0)) + assertEquals(3, PrivacyMaskRenderer.edgeCells(1280, 720, 24)) + assertEquals(8, PrivacyMaskRenderer.edgeCells(1280, 720, 64)) + assertEquals(5, PrivacyMaskRenderer.edgeCells(1920, 1080, 64)) + assertEquals(PrivacyMaskRenderer.MAX_EDGE_CELLS, PrivacyMaskRenderer.edgeCells(640, 360, 64)) + } } From ad22978ccfe7842b31a6c952fbbe3a9bbd571a86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=97=B0=ED=98=B8?= Date: Fri, 23 Oct 2026 11:52:00 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=EC=98=A8=EB=94=94=EB=B0=94=EC=9D=B4?= =?UTF-8?q?=EC=8A=A4=20=EB=AA=A8=EC=9E=90=EC=9D=B4=ED=81=AC=20=EA=B0=95?= =?UTF-8?q?=EB=8F=84=20=EB=8B=A8=EA=B3=84=C2=B7=EC=9B=90=ED=98=95=20?= =?UTF-8?q?=EB=84=88=EB=B9=84=C2=B7=EC=A4=80=EB=B9=84=20=EC=A0=84=20?= =?UTF-8?q?=EC=95=88=EB=82=B4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../feature/live/CameraFrameAnalyzer.kt | 5 +- .../framework/innolive/feature/live/Live.kt | 63 ++++++++++--------- .../innolive/feature/live/MosaicControls.kt | 13 ++-- .../feature/live/OnDeviceMosaicSettings.kt | 7 ++- .../feature/live/privacy/PrivacyBitmapBlur.kt | 20 ++++-- .../live/privacy/PrivacyBroadcastBlur.kt | 11 +++- .../live/privacy/PrivacyFrameProcessor.kt | 7 +-- .../live/privacy/PrivacyGpuFramePipeline.kt | 22 +++---- .../live/privacy/PrivacyMaskRenderer.kt | 42 ++++++++----- .../feature/live/privacy/PrivacyMosaic.kt | 19 ++++++ .../feature/live/privacy/PrivacyOnnxModel.kt | 7 +-- 11 files changed, 136 insertions(+), 80 deletions(-) create mode 100644 apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMosaic.kt diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/CameraFrameAnalyzer.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/CameraFrameAnalyzer.kt index bcfa5ff22..2bb3461e7 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/CameraFrameAnalyzer.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/CameraFrameAnalyzer.kt @@ -8,6 +8,7 @@ import com.framework.innolive.BuildConfig import com.framework.innolive.feature.live.privacy.PrivacyFrameMode import com.framework.innolive.feature.live.privacy.PrivacyFrameProcessor import com.framework.innolive.feature.live.privacy.PrivacyFrameRoute +import com.framework.innolive.feature.live.privacy.PrivacyMosaic import com.framework.innolive.feature.live.privacy.PrivacyNativePixels import com.framework.innolive.feature.live.privacy.PrivacyFrameAnalysis import com.framework.innolive.feature.live.privacy.PrivacyFrameTimings @@ -46,7 +47,7 @@ class CameraFrameAnalyzer( ) : ImageAnalysis.Analyzer, AutoCloseable { private val captureLock = Any() private val settings = AtomicReference(BroadcastVideoQualitySettings()) - private val onDeviceMosaic = AtomicReference(OnDeviceMosaicSettings()) + private val onDeviceMosaic = AtomicReference(PrivacyMosaic()) private val previewExposure = PreviewExposureFrameGate() private val colorProcessor = VideoColorFrameProcessor() private val previewRenderer = VideoLookPreviewRenderer() @@ -160,7 +161,7 @@ class CameraFrameAnalyzer( /** 온디바이스 가림 처리의 블러 강도·영역 너비입니다. 다음 보호 프레임부터 송출·미리보기·가공본 녹화에 함께 적용합니다. */ fun setOnDeviceMosaic(value: OnDeviceMosaicSettings) { - onDeviceMosaic.set(value.normalized()) + onDeviceMosaic.set(value.toPrivacyMosaic()) } internal fun beginPreviewExposure() = synchronized(captureLock) { diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/Live.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/Live.kt index be23f7c13..49b5e1cc2 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/Live.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/Live.kt @@ -154,8 +154,10 @@ import com.framework.innolive.feature.live.tutorial.LocalBroadcastTutorialAnchor import com.framework.innolive.feature.live.tutorial.broadcastTutorialAnchor import com.framework.innolive.feature.live.tutorial.guideFor import com.framework.innolive.ui.text.asString +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable fun LiveScreen( @@ -351,10 +353,14 @@ private fun LiveScreenContent( } val idleFrameAnalyzer = remember { CameraFrameAnalyzer() } val frameAnalyzer = webRtcSession.frameAnalyzer ?: idleFrameAnalyzer - // 온디바이스 블러 강도·가림 영역 너비는 기기에 저장하고, 지금 송출 중인 분석기에 바로 넘깁니다. + // 온디바이스 블러 강도·가림 영역 너비는 기기에 저장합니다. 읽는 동안은 하한인 기본값으로 처리하므로 약해지지 않습니다. val onDeviceMosaicPreferences = remember(context) { OnDeviceMosaicPreferences(context) } - var onDeviceMosaic by remember(onDeviceMosaicPreferences) { mutableStateOf(onDeviceMosaicPreferences.load()) } - SideEffect { frameAnalyzer.setOnDeviceMosaic(onDeviceMosaic) } + var onDeviceMosaic by remember(onDeviceMosaicPreferences) { mutableStateOf(OnDeviceMosaicSettings()) } + LaunchedEffect(onDeviceMosaicPreferences) { + onDeviceMosaic = withContext(Dispatchers.IO) { onDeviceMosaicPreferences.load() } + } + // 값이 바뀌거나 송출 분석기가 새로 생길 때만 넘깁니다. + LaunchedEffect(frameAnalyzer, onDeviceMosaic) { frameAnalyzer.setOnDeviceMosaic(onDeviceMosaic) } val lookPreviews by frameAnalyzer.lookPreviews.collectAsState() DisposableEffect(idleFrameAnalyzer) { onDispose { idleFrameAnalyzer.close() } @@ -470,36 +476,31 @@ private fun LiveScreenContent( onDismiss = { setVideoControlsOpen(false) }, mirrorPreviews = props.cameraLensFacing.shouldMirrorPreview, mosaic = run { - val noteRes = mosaicNoteRes( - hasSession = webRtcSession.sessionSnapshot != null, - onDeviceProcessing = webRtcSession.selectedOnDeviceProcessing, - canChange = webRtcSession.canChangeMosaicConfig, - anonymizationEnabled = webRtcSession.anonymizationState == AnonymizationState.ENABLED, - ) - if (webRtcSession.selectedOnDeviceProcessing) { - MosaicControlsState( - config = onDeviceMosaic.toMosaicConfig(), - blurRange = OnDeviceMosaicSettings.BLUR_STRENGTH_RANGE, - maskMarginRange = OnDeviceMosaicSettings.MASK_MARGIN_RANGE, - enabled = true, - isChanging = false, - noteRes = noteRes, - error = null, - onChange = { config -> + // 온디바이스는 기기에 저장한 값을 바로 적용하고, 서버 AI는 세션 설정으로 보냅니다. + val onDevice = webRtcSession.selectedOnDeviceProcessing + MosaicControlsState( + config = if (onDevice) onDeviceMosaic.toMosaicConfig() else webRtcSession.mosaicConfig, + blurRange = if (onDevice) OnDeviceMosaicSettings.BLUR_STRENGTH_RANGE else MosaicConfig.BLUR_RADIUS_RANGE, + blurStep = if (onDevice) OnDeviceMosaicSettings.BLUR_STRENGTH_STEP else 1, + maskMarginRange = if (onDevice) OnDeviceMosaicSettings.MASK_MARGIN_RANGE else MosaicConfig.MASK_MARGIN_RANGE, + enabled = onDevice || webRtcSession.canChangeMosaicConfig, + isChanging = !onDevice && webRtcSession.isChangingMosaicConfig, + noteRes = mosaicNoteRes( + hasSession = webRtcSession.sessionSnapshot != null, + onDeviceProcessing = onDevice, + canChange = webRtcSession.canChangeMosaicConfig, + anonymization = webRtcSession.anonymizationState, + ), + error = if (onDevice) null else webRtcSession.mosaicConfigError, + onChange = { config -> + if (onDevice) { onDeviceMosaic = OnDeviceMosaicSettings.from(config) onDeviceMosaicPreferences.save(onDeviceMosaic) - }, - ) - } else { - MosaicControlsState( - config = webRtcSession.mosaicConfig, - enabled = webRtcSession.canChangeMosaicConfig, - isChanging = webRtcSession.isChangingMosaicConfig, - noteRes = noteRes, - error = webRtcSession.mosaicConfigError, - onChange = { webRtcSession.updateMosaicConfig(it) }, - ) - } + } else { + webRtcSession.updateMosaicConfig(config) + } + }, + ) }, ) } diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/MosaicControls.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/MosaicControls.kt index 3926ecc03..09173d365 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/MosaicControls.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/MosaicControls.kt @@ -35,6 +35,8 @@ data class MosaicControlsState( val config: MosaicConfig, /** 슬라이더 범위입니다. 온디바이스는 지금 강도와 마스크 1칸보다 약하게 내리지 않습니다. */ val blurRange: IntRange = MosaicConfig.BLUR_RADIUS_RANGE, + /** 온디바이스 강도는 6 단위 다섯 단계입니다. */ + val blurStep: Int = 1, val maskMarginRange: IntRange = MosaicConfig.MASK_MARGIN_RANGE, val enabled: Boolean, val isChanging: Boolean, @@ -45,7 +47,8 @@ data class MosaicControlsState( ) /** - * 조절할 수 없거나 적용되지 않는 이유입니다. 온디바이스는 기기에 저장해 방송 준비 전에도 조절하므로 하한만 알립니다. + * 조절할 수 없거나 적용되지 않는 이유입니다. 온디바이스는 기기에 저장해 방송 준비 전에도 조절하므로, 비식별화를 + * 실제로 끈 경우가 아니면 하한만 알립니다. 준비 전 비식별화 상태는 [AnonymizationState.UNKNOWN]입니다. * 서버 AI는 iOS와 같은 순서로 방송 준비 전, 비식별화 꺼짐을 봅니다. */ @StringRes @@ -53,12 +56,12 @@ internal fun mosaicNoteRes( hasSession: Boolean, onDeviceProcessing: Boolean, canChange: Boolean, - anonymizationEnabled: Boolean, + anonymization: AnonymizationState, ): Int? = when { - onDeviceProcessing && !anonymizationEnabled -> R.string.mosaic_anonymization_off + onDeviceProcessing && anonymization == AnonymizationState.DISABLED -> R.string.mosaic_anonymization_off onDeviceProcessing -> R.string.mosaic_on_device_floor !hasSession -> R.string.mosaic_prepare_required - canChange && !anonymizationEnabled -> R.string.mosaic_anonymization_off + canChange && anonymization != AnonymizationState.ENABLED -> R.string.mosaic_anonymization_off else -> null } @@ -104,7 +107,7 @@ internal fun MosaicControls(state: MosaicControlsState) { leading = stringResource(R.string.mosaic_weaker), trailing = stringResource(R.string.mosaic_stronger), enabled = state.enabled, - steps = state.blurRange.last - state.blurRange.first - 1, + steps = (state.blurRange.last - state.blurRange.first) / state.blurStep - 1, onValueChangeFinished = ::commit, onValueChange = { isEditing = true diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/OnDeviceMosaicSettings.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/OnDeviceMosaicSettings.kt index 3ee8e5420..8c17d075a 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/OnDeviceMosaicSettings.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/OnDeviceMosaicSettings.kt @@ -2,6 +2,7 @@ package com.framework.innolive.feature.live import android.content.Context import com.framework.innolive.feature.live.privacy.PrivacyBroadcastBlur +import com.framework.innolive.feature.live.privacy.PrivacyMosaic /** * 온디바이스 AI의 얼굴·번호판 블러 강도와 가림 영역 너비입니다. 서버 AI 슬라이더와 같은 눈금(블러 24, 너비 8px)을 @@ -12,15 +13,19 @@ data class OnDeviceMosaicSettings( val maskMargin: Int = MosaicConfig.DEFAULT_MASK_MARGIN, ) { fun normalized() = copy( - blurStrength = blurStrength.coerceIn(BLUR_STRENGTH_RANGE), + blurStrength = PrivacyBroadcastBlur.strengthLevel(blurStrength), maskMargin = maskMargin.coerceIn(MASK_MARGIN_RANGE), ) /** 영상 조절 시트의 모자이크 구역이 보여 주는 값입니다. */ fun toMosaicConfig() = MosaicConfig(blurRadius = blurStrength.toDouble(), maskMargin = maskMargin) + /** 가림 처리 경로가 프레임마다 읽는 값입니다. */ + internal fun toPrivacyMosaic() = normalized().let { PrivacyMosaic(it.blurStrength, it.maskMargin) } + companion object { val BLUR_STRENGTH_RANGE = PrivacyBroadcastBlur.DEFAULT_STRENGTH..PrivacyBroadcastBlur.MAX_STRENGTH + const val BLUR_STRENGTH_STEP = PrivacyBroadcastBlur.STRENGTH_STEP /** 8px 아래는 어차피 마스크 1칸이라 슬라이더도 기본값부터 시작합니다. */ val MASK_MARGIN_RANGE = MosaicConfig.DEFAULT_MASK_MARGIN..MosaicConfig.MASK_MARGIN_RANGE.last diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBitmapBlur.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBitmapBlur.kt index e6a82a9be..795836409 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBitmapBlur.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBitmapBlur.kt @@ -24,11 +24,13 @@ internal class PrivacyBitmapBlur : AutoCloseable { lastUsedGpu = false if (Build.VERSION.SDK_INT >= 31 && !gpuUnavailable) { try { - if (gpu?.width != source.width || gpu?.height != source.height || gpu?.sigma != sigma) { + if (gpu?.width != source.width || gpu?.height != source.height) { gpu?.close() gpu = null - gpu = Gpu(source.width, source.height, sigma) + gpu = Gpu(source.width, source.height) } + // 강도만 바뀌면 렌더러와 버퍼는 그대로 두고 블러 효과만 바꿉니다. + checkNotNull(gpu).sigma = sigma val output = checkNotNull(gpu).apply(source) lastUsedGpu = true return output @@ -44,7 +46,7 @@ internal class PrivacyBitmapBlur : AutoCloseable { override fun close() { if (Build.VERSION.SDK_INT >= 31) gpu?.close(); gpu = null } @RequiresApi(31) - private class Gpu(val width: Int, val height: Int, val sigma: Int) : AutoCloseable { + private class Gpu(val width: Int, val height: Int) : AutoCloseable { private val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2, HardwareBuffer.USAGE_GPU_SAMPLED_IMAGE or HardwareBuffer.USAGE_GPU_COLOR_OUTPUT) private val node = RenderNode("PrivacyGaussianBlur") @@ -55,12 +57,18 @@ internal class PrivacyBitmapBlur : AutoCloseable { renderer.setSurface(reader.surface) renderer.setContentRoot(node) node.setPosition(0, 0, width, height) - // The same full-resolution sigma as the GPU graph and iOS, via HWUI's radius conversion. - val radius = PrivacyBroadcastBlur.renderEffectRadius(sigma) - node.setRenderEffect(RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP)) } catch (error: Exception) { close(); throw error } } + /** The same full-resolution sigma as the GPU graph and iOS, via HWUI's radius conversion. */ + var sigma = 0 + set(value) { + if (field == value) return + val radius = PrivacyBroadcastBlur.renderEffectRadius(value) + node.setRenderEffect(RenderEffect.createBlurEffect(radius, radius, Shader.TileMode.CLAMP)) + field = value + } + fun apply(source: Bitmap): Bitmap { // iOS broadcastStyleBlur: area-average reduction, then one wide Gaussian; no block grid. val reduced = PrivacyMaskRenderer.reduce(source) diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlur.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlur.kt index 8cb64b529..a9a5f5b66 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlur.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlur.kt @@ -20,6 +20,9 @@ internal object PrivacyBroadcastBlur { const val DEFAULT_STRENGTH = 24 /** 기본의 두 배까지입니다. 1080p 축소 커널 반지름이 54에서 108탭으로 늘어납니다. */ const val MAX_STRENGTH = 48 + /** 강도는 24·30·36·42·48 다섯 단계입니다. GPU 블러 셰이더는 sigma마다 하나씩 컴파일되므로 + * 해상도당 셰이더를 다섯 개로 묶고, 송출 중 새 셰이더 컴파일도 단계가 바뀔 때만 일어나게 합니다. */ + const val STRENGTH_STEP = 6 /** HWUI `Blur::convertRadiusToSigma`: sigma = 0.57735 * radius + 0.5 for a positive radius. */ private const val RENDER_EFFECT_RADIUS_SCALE = 0.57735f @@ -28,7 +31,13 @@ internal object PrivacyBroadcastBlur { fun sigma(width: Int, height: Int, strength: Int = DEFAULT_STRENGTH): Int { require(width > 0 && height > 0) val base = max(MINIMUM_SIGMA, (min(width, height) * SIGMA_PER_SHORT_SIDE).roundToInt()) - return (base * strength.coerceIn(DEFAULT_STRENGTH, MAX_STRENGTH).toDouble() / DEFAULT_STRENGTH).roundToInt() + return (base * strengthLevel(strength).toDouble() / DEFAULT_STRENGTH).roundToInt() + } + + /** 강도를 기본~최대 범위의 가장 가까운 단계로 맞춥니다. */ + fun strengthLevel(strength: Int): Int { + val steps = ((strength.coerceIn(DEFAULT_STRENGTH, MAX_STRENGTH) - DEFAULT_STRENGTH).toDouble() / STRENGTH_STEP).roundToInt() + return DEFAULT_STRENGTH + steps * STRENGTH_STEP } /** Sigma at the reduced resolution where the blur runs. */ diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyFrameProcessor.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyFrameProcessor.kt index ee7436b2e..ef75af07b 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyFrameProcessor.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyFrameProcessor.kt @@ -7,7 +7,6 @@ import android.graphics.Color import android.graphics.Matrix import android.util.Log import com.framework.innolive.BuildConfig -import com.framework.innolive.feature.live.OnDeviceMosaicSettings import org.webrtc.VideoFrame import org.webrtc.JavaI420Buffer import org.webrtc.EglBase @@ -30,7 +29,7 @@ internal class PrivacyFrameProcessor(context: Context, private val sharedContext coordinator.liveTrackIds = { model.liveTrackIds } } /** 영상 조절의 온디바이스 블러 강도·가림 영역 너비입니다. 처리 스레드가 프레임마다 읽습니다. */ - @Volatile var mosaic = OnDeviceMosaicSettings() + @Volatile var mosaic = PrivacyMosaic() var lastTimings: PrivacyFrameTimings? = null private set val lastAnalysis: PrivacyFrameAnalysis? get() = model.lastAnalysis @@ -172,8 +171,8 @@ internal class PrivacyFrameProcessor(context: Context, private val sharedContext }, renderOnGpu=true,gpuGraph=graph) {mask -> graph.finish(mask,layout,sensorWidth,sensorHeight, model.lastMaskPixels,outputUpright=true, - blurSigma=PrivacyBroadcastBlur.sigma(layout.sourceWidth,layout.sourceHeight,mosaic.blurStrength), - edgeCells=PrivacyMaskRenderer.edgeCells(layout.sourceWidth,layout.sourceHeight,mosaic.maskMargin))} + blurSigma=mosaic.sigma(layout.sourceWidth,layout.sourceHeight), + edgeCells=mosaic.edgeCells(layout.sourceWidth,layout.sourceHeight))} val completed = System.nanoTime() lastTimings = PrivacyFrameTimings((converted - started) / 1e6, checkNotNull(model.lastTimings), 0.0, (completed - started) / 1e6) diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipeline.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipeline.kt index ce3ebfbef..bf7904551 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipeline.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyGpuFramePipeline.kt @@ -535,16 +535,12 @@ internal class PrivacyGpuFramePipeline(sharedContext: EglBase.Context?, useGles3 raw.texture } // Server mosaic: the contour plus the chosen mask cells (one by default) stays opaque, and the - // bilinear composite fades it over the next cell. - val temp = target("mask-temp", 160, 160, maskTargetsCompact) + // bilinear composite fades it over the next cell. The margin is the same disk as the CPU path, + // so a wide margin follows the contour instead of becoming a box. val alpha = target("mask-alpha",160,160,maskTargetsCompact) - draw("dilate-x", DILATE, temp, listOf(rawTexture)) { p -> - glUniform2f(p.getUniformLocation("stepSize"), 1f / 160, 0f) - glUniform1i(p.getUniformLocation("radius"), edgeCells) - } - draw("dilate-y", DILATE, alpha, listOf(temp.texture)) { p -> - glUniform2f(p.getUniformLocation("stepSize"), 0f, 1f / 160) - glUniform1i(p.getUniformLocation("radius"), edgeCells) + draw("dilate", DILATE, alpha, listOf(rawTexture)) { p -> + glUniform2f(p.getUniformLocation("texel"), 1f / 160, 1f / 160) + glUniform1f(p.getUniformLocation("radiusSq"), (edgeCells * edgeCells + edgeCells).toFloat()) } return alpha } @@ -671,8 +667,12 @@ internal class PrivacyGpuFramePipeline(sharedContext: EglBase.Context?, useGles3 +texture2D(tex0,uv+vec2(-1.0,1.0)*texelSize)+texture2D(tex0,uv+vec2(1.0,1.0)*texelSize)); } """ private const val DILATE = """ - uniform sampler2D tex0; uniform vec2 stepSize; uniform int radius; - void main(){ float a=0.0; for(int i=-${PrivacyMaskRenderer.MAX_EDGE_CELLS};i<=${PrivacyMaskRenderer.MAX_EDGE_CELLS};i++){ if(i>=-radius && i<=radius) a=max(a,texture2D(tex0,uv+float(i)*stepSize).r); } gl_FragColor=vec4(vec3(a),1.0); } + uniform sampler2D tex0; uniform vec2 texel; uniform float radiusSq; + void main(){ float a=0.0; + for(int j=-${PrivacyMaskRenderer.MAX_EDGE_CELLS};j<=${PrivacyMaskRenderer.MAX_EDGE_CELLS};j++) + for(int i=-${PrivacyMaskRenderer.MAX_EDGE_CELLS};i<=${PrivacyMaskRenderer.MAX_EDGE_CELLS};i++) + if(float(i*i+j*j)<=radiusSq) a=max(a,texture2D(tex0,uv+vec2(float(i),float(j))*texel).r); + gl_FragColor=vec4(vec3(a),1.0); } """ private const val COMPOSITE = "uniform sampler2D tex0,tex1,tex2; uniform vec4 box; uniform int rotation; " + FORWARD + "void main(){ vec2 p=upright(uv); float a=texture2D(tex2,box.xy+p*box.zw).r; gl_FragColor=mix(texture2D(tex0,p),texture2D(tex1,p),a); }" diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMaskRenderer.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMaskRenderer.kt index f77ca2420..dec50e18b 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMaskRenderer.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMaskRenderer.kt @@ -4,9 +4,12 @@ import android.graphics.Bitmap import android.graphics.Canvas import android.graphics.Paint import android.graphics.Rect +import kotlin.math.abs +import kotlin.math.floor import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt +import kotlin.math.sqrt internal object PrivacyMaskRenderer { /** Opaque cells added around the face contour, like the server's one-mask-pixel pad plus 8px @@ -24,30 +27,39 @@ internal object PrivacyMaskRenderer { return (marginPx / cellPx).roundToInt().coerceIn(PROTECTED_EDGE_CELLS, MAX_EDGE_CELLS) } - /** The inner mask may only grow. */ + /** The inner mask may only grow. The margin is a disk of radius r+0.5 cells, so a wide margin + * follows the face contour instead of turning into a box; one cell is the same 3x3 square. */ fun expandedMask(mask: ByteArray, radius: Int = PROTECTED_EDGE_CELLS): ByteArray { val size = PrivacySegmentation.MASK_SIZE require(mask.size == size * size && radius >= 0) val margin = min(radius, size - 1) - val horizontal = ByteArray(mask.size) + val halfWidths = diskHalfWidths(margin) + val rows = halfWidths.distinct().associateWith { horizontalDilation(mask, size, it) } val expanded = ByteArray(mask.size) + for (y in 0 until size) for (dy in -margin..margin) { + val source = y + dy + if (source !in 0 until size) continue + val row = rows.getValue(halfWidths[abs(dy)]) + for (x in 0 until size) if (row[source * size + x].toInt() != 0) expanded[y * size + x] = -1 + } + return expanded + } + + /** Horizontal reach of the disk on each row offset: dx² + dy² <= r² + r, as in the GPU shader. */ + private fun diskHalfWidths(radius: Int): IntArray = + IntArray(radius + 1) { dy -> floor(sqrt((radius * radius + radius - dy * dy).toDouble())).toInt() } + + private fun horizontalDilation(mask: ByteArray, size: Int, reach: Int): ByteArray { + val output = ByteArray(mask.size) for (y in 0 until size) { - var count = (0..margin).count { mask[y * size + it].toInt() != 0 } + var count = (0..reach).count { mask[y * size + it].toInt() != 0 } for (x in 0 until size) { - if (count > 0) horizontal[y * size + x] = -1 - if (x - margin >= 0 && mask[y * size + x - margin].toInt() != 0) count-- - if (x + margin + 1 < size && mask[y * size + x + margin + 1].toInt() != 0) count++ - } - } - for (x in 0 until size) { - var count = (0..margin).count { horizontal[it * size + x].toInt() != 0 } - for (y in 0 until size) { - if (count > 0) expanded[y * size + x] = -1 - if (y - margin >= 0 && horizontal[(y - margin) * size + x].toInt() != 0) count-- - if (y + margin + 1 < size && horizontal[(y + margin + 1) * size + x].toInt() != 0) count++ + if (count > 0) output[y * size + x] = -1 + if (x - reach >= 0 && mask[y * size + x - reach].toInt() != 0) count-- + if (x + reach + 1 < size && mask[y * size + x + reach + 1].toInt() != 0) count++ } } - return expanded + return output } fun render(upright: Bitmap, mask: ByteArray, layout: PrivacySegmentation.Letterbox, diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMosaic.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMosaic.kt new file mode 100644 index 000000000..810d55f5b --- /dev/null +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyMosaic.kt @@ -0,0 +1,19 @@ +package com.framework.innolive.feature.live.privacy + +/** + * 온디바이스 가림 처리의 블러 강도·가림 영역 너비입니다. 영상 조절 화면과 같은 눈금(강도 24, 너비 송출 px)을 쓰고, + * 프레임 해상도에 맞는 블러 sigma와 160 마스크 팽창 칸 수로 바꾸는 계산을 GPU·CPU 경로가 함께 씁니다. + */ +internal data class PrivacyMosaic( + val blurStrength: Int = PrivacyBroadcastBlur.DEFAULT_STRENGTH, + val maskMargin: Int = DEFAULT_MASK_MARGIN, +) { + fun sigma(width: Int, height: Int): Int = PrivacyBroadcastBlur.sigma(width, height, blurStrength) + + fun edgeCells(width: Int, height: Int): Int = PrivacyMaskRenderer.edgeCells(width, height, maskMargin) + + companion object { + /** 서버 AI 기본값과 같은 8px이며, 720p·1080p 모두 마스크 1칸입니다. */ + const val DEFAULT_MASK_MARGIN = 8 + } +} diff --git a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyOnnxModel.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyOnnxModel.kt index ca82bf66c..cb07d21a6 100644 --- a/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyOnnxModel.kt +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/privacy/PrivacyOnnxModel.kt @@ -13,7 +13,6 @@ import android.graphics.Rect import android.graphics.RectF import android.util.Log import com.framework.innolive.BuildConfig -import com.framework.innolive.feature.live.OnDeviceMosaicSettings import java.security.MessageDigest import java.nio.ByteBuffer import java.nio.ByteOrder @@ -83,7 +82,7 @@ internal class PrivacyOnnxModel(private val context: Context, fun process( upright: Bitmap, timestampNs: Long = System.nanoTime(), - mosaic: OnDeviceMosaicSettings = OnDeviceMosaicSettings(), + mosaic: PrivacyMosaic = PrivacyMosaic(), exemptFaces: (List, PrivacySegmentation.Letterbox) -> Set = { _, _ -> emptySet() }, ): Bitmap { val layout = PrivacySegmentation.Letterbox(upright.width, upright.height) @@ -98,9 +97,9 @@ internal class PrivacyOnnxModel(private val context: Context, Paint(Paint.FILTER_BITMAP_FLAG), ) return processPrepared(inputBitmap, layout, timestampNs, exemptFaces) { mask -> - val sigma = PrivacyBroadcastBlur.sigma(upright.width, upright.height, mosaic.blurStrength) + val sigma = mosaic.sigma(upright.width, upright.height) PrivacyMaskRenderer.render(upright, mask, layout, { blur.apply(it, sigma) }, - PrivacyMaskRenderer.edgeCells(upright.width, upright.height, mosaic.maskMargin)) + mosaic.edgeCells(upright.width, upright.height)) } } From d8b5081987ef306660fb9032d5b7d80bf40e4a16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=97=B0=ED=98=B8?= Date: Fri, 23 Oct 2026 11:52:00 +0900 Subject: [PATCH 5/5] =?UTF-8?q?test:=20=EA=B0=95=EB=8F=84=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=C2=B7=EC=9B=90=ED=98=95=20=ED=8C=BD=EC=B0=BD=C2=B7?= =?UTF-8?q?=EC=A4=80=EB=B9=84=20=EC=A0=84=20=EC=95=88=EB=82=B4=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../innolive/feature/live/MosaicConfigTest.kt | 13 +++++++------ .../live/OnDeviceMosaicSettingsTest.kt | 7 +++++-- .../live/privacy/PrivacyBroadcastBlurTest.kt | 7 +++++++ .../live/privacy/PrivacyMaskExpansionTest.kt | 19 ++++++++++++++++--- 4 files changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/MosaicConfigTest.kt b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/MosaicConfigTest.kt index e9dddcd87..6d2686480 100644 --- a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/MosaicConfigTest.kt +++ b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/MosaicConfigTest.kt @@ -43,18 +43,19 @@ class MosaicConfigTest { @Test fun noteFollowsIosOrder() { assertEquals(R.string.mosaic_prepare_required, - mosaicNoteRes(hasSession = false, onDeviceProcessing = false, canChange = false, anonymizationEnabled = true)) + mosaicNoteRes(hasSession = false, onDeviceProcessing = false, canChange = false, anonymization = AnonymizationState.ENABLED)) assertEquals(R.string.mosaic_anonymization_off, - mosaicNoteRes(hasSession = true, onDeviceProcessing = false, canChange = true, anonymizationEnabled = false)) - assertNull(mosaicNoteRes(hasSession = true, onDeviceProcessing = false, canChange = true, anonymizationEnabled = true)) + mosaicNoteRes(hasSession = true, onDeviceProcessing = false, canChange = true, anonymization = AnonymizationState.DISABLED)) + assertNull(mosaicNoteRes(hasSession = true, onDeviceProcessing = false, canChange = true, anonymization = AnonymizationState.ENABLED)) } @Test fun onDeviceNoteExplainsTheFloorEvenBeforePreparation() { + // 방송 준비 전에는 세션이 없고 비식별화 상태도 아직 모릅니다. 꺼짐 안내를 띄우지 않습니다. assertEquals(R.string.mosaic_on_device_floor, - mosaicNoteRes(hasSession = false, onDeviceProcessing = true, canChange = false, anonymizationEnabled = true)) + mosaicNoteRes(hasSession = false, onDeviceProcessing = true, canChange = false, anonymization = AnonymizationState.UNKNOWN)) assertEquals(R.string.mosaic_on_device_floor, - mosaicNoteRes(hasSession = true, onDeviceProcessing = true, canChange = false, anonymizationEnabled = true)) + mosaicNoteRes(hasSession = true, onDeviceProcessing = true, canChange = false, anonymization = AnonymizationState.ENABLED)) assertEquals(R.string.mosaic_anonymization_off, - mosaicNoteRes(hasSession = true, onDeviceProcessing = true, canChange = false, anonymizationEnabled = false)) + mosaicNoteRes(hasSession = true, onDeviceProcessing = true, canChange = false, anonymization = AnonymizationState.DISABLED)) } } diff --git a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/OnDeviceMosaicSettingsTest.kt b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/OnDeviceMosaicSettingsTest.kt index 4ad1ba569..b8f64ef57 100644 --- a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/OnDeviceMosaicSettingsTest.kt +++ b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/OnDeviceMosaicSettingsTest.kt @@ -17,11 +17,14 @@ class OnDeviceMosaicSettingsTest { assertEquals(OnDeviceMosaicSettings(24, 8), OnDeviceMosaicSettings(1, 0).normalized()) assertEquals(OnDeviceMosaicSettings(48, 64), OnDeviceMosaicSettings(99, 99).normalized()) assertEquals(OnDeviceMosaicSettings(36, 20), OnDeviceMosaicSettings(36, 20).normalized()) + // 강도는 24·30·36·42·48 다섯 단계 중 가까운 값으로 맞춥니다. + assertEquals(OnDeviceMosaicSettings(42, 20), OnDeviceMosaicSettings(40, 20).normalized()) + assertEquals(OnDeviceMosaicSettings(30, 20), OnDeviceMosaicSettings(28, 20).normalized()) } @Test fun sheetChangesAndResetMapBackToTheOnDeviceRange() { - assertEquals(OnDeviceMosaicSettings(40, 32), - OnDeviceMosaicSettings.from(MosaicConfig().adjusted(blurRadius = 40, maskMargin = 32))) + assertEquals(OnDeviceMosaicSettings(42, 32), + OnDeviceMosaicSettings.from(MosaicConfig().adjusted(blurRadius = 42, maskMargin = 32))) // 모자이크 기본값 버튼은 null 필드로 오며, 지금 강도와 1칸으로 돌아갑니다. assertEquals(OnDeviceMosaicSettings(), OnDeviceMosaicSettings.from(MosaicConfig(blurRadius = 40.0, maskMargin = 32).adjusted(null, null))) diff --git a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlurTest.kt b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlurTest.kt index 40aea04c4..3602bf33d 100644 --- a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlurTest.kt +++ b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyBroadcastBlurTest.kt @@ -25,6 +25,13 @@ class PrivacyBroadcastBlurTest { assertEquals(144, PrivacyBroadcastBlur.sigma(1920, 1080, 64)) } + @Test fun strengthSnapsToFiveLevelsSoOnlyFiveBlurShadersExist() { + assertEquals(listOf(24, 24, 30, 30, 36, 42, 48, 48), + listOf(1, 26, 27, 32, 36, 40, 46, 99).map(PrivacyBroadcastBlur::strengthLevel)) + assertEquals(setOf(72, 90, 108, 126, 144), + (0..80).map { PrivacyBroadcastBlur.sigma(1920, 1080, it) }.toSet()) + } + @Test fun reducedSigmaAndSizeMatchTheQuarterResolutionGraph() { assertEquals(18.0, PrivacyBroadcastBlur.reducedSigma(PrivacyBroadcastBlur.sigma(1920, 1080)), 0.0) assertEquals(12.0, PrivacyBroadcastBlur.reducedSigma(PrivacyBroadcastBlur.sigma(1280, 720)), 0.0) diff --git a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyMaskExpansionTest.kt b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyMaskExpansionTest.kt index 0a0659cd3..8e653f377 100644 --- a/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyMaskExpansionTest.kt +++ b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/privacy/PrivacyMaskExpansionTest.kt @@ -5,19 +5,20 @@ import org.junit.Assert.assertEquals import org.junit.Test class PrivacyMaskExpansionTest { - @Test fun slidingWindowDilationKeepsEveryProtectedPixelAndMatchesDenseReference() { + @Test fun diskDilationKeepsEveryProtectedPixelAndMatchesDenseReference() { val size = 160 val masks = listOf(ByteArray(size * size) { -1 }, ByteArray(size * size).apply { this[0] = -1; this[size * size - 1] = -1; this[80 * size + 80] = -1 for (y in 50..100) for (x in 20..80) if ((x + y) % 7 == 0) this[y * size + x] = -1 }) - for (mask in masks) for (radius in listOf(0, 1, 2, 4)) { + for (mask in masks) for (radius in listOf(0, 1, 2, 4, PrivacyMaskRenderer.MAX_EDGE_CELLS)) { val expected = ByteArray(mask.size) for (y in 0 until size) for (x in 0 until size) { if (mask[y * size + x].toInt() != 0) { for (dy in (y - radius).coerceAtLeast(0)..(y + radius).coerceAtMost(size - 1)) for (dx in (x - radius).coerceAtLeast(0)..(x + radius).coerceAtMost(size - 1)) - expected[dy * size + dx] = -1 + if ((dx - x) * (dx - x) + (dy - y) * (dy - y) <= radius * radius + radius) + expected[dy * size + dx] = -1 } } assertArrayEquals(expected, PrivacyMaskRenderer.expandedMask(mask, radius)) @@ -44,6 +45,18 @@ class PrivacyMaskExpansionTest { assertEquals(0, protected[(80 - 16) * size + (80 - 12)].toInt()) } + @Test fun wideMarginIsRoundNotABox() { + val size = 160 + val point = ByteArray(size * size).apply { this[80 * size + 80] = -1 } + val protected = PrivacyMaskRenderer.expandedMask(point, PrivacyMaskRenderer.MAX_EDGE_CELLS) + val reach = PrivacyMaskRenderer.MAX_EDGE_CELLS + // 축 방향으로는 끝까지 닿지만, 대각선 모서리는 비워 얼굴 주변이 사각형으로 가려지지 않습니다. + assertEquals(-1, protected[80 * size + 80 + reach].toInt()) + assertEquals(-1, protected[(80 + reach) * size + 80].toInt()) + assertEquals(0, protected[(80 + reach) * size + 80 + reach].toInt()) + assertEquals(0, protected[(80 + 10) * size + 80 + 10].toInt()) + } + @Test fun marginPixelsBecomeMaskCellsOfTheLongSide() { // 서버 기본 8px는 720p·1080p 모두 지금과 같은 1칸입니다. assertEquals(1, PrivacyMaskRenderer.edgeCells(1280, 720, 8))