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/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..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,6 +47,7 @@ class CameraFrameAnalyzer( ) : ImageAnalysis.Analyzer, AutoCloseable { private val captureLock = Any() private val settings = AtomicReference(BroadcastVideoQualitySettings()) + private val onDeviceMosaic = AtomicReference(PrivacyMosaic()) private val previewExposure = PreviewExposureFrameGate() private val colorProcessor = VideoColorFrameProcessor() private val previewRenderer = VideoLookPreviewRenderer() @@ -157,6 +159,11 @@ class CameraFrameAnalyzer( settings.set(value.normalized()) } + /** 온디바이스 가림 처리의 블러 강도·영역 너비입니다. 다음 보호 프레임부터 송출·미리보기·가공본 녹화에 함께 적용합니다. */ + fun setOnDeviceMosaic(value: OnDeviceMosaicSettings) { + onDeviceMosaic.set(value.toPrivacyMosaic()) + } + internal fun beginPreviewExposure() = synchronized(captureLock) { previewExposure.begin() previewRenderer.invalidatePendingSample() @@ -479,6 +486,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/Live.kt b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/Live.kt index c9e876794..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,6 +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(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() } @@ -465,19 +475,33 @@ 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( - hasSession = webRtcSession.sessionSnapshot != null, - onDeviceProcessing = webRtcSession.selectedOnDeviceProcessing, - canChange = webRtcSession.canChangeMosaicConfig, - anonymizationEnabled = webRtcSession.anonymizationState == AnonymizationState.ENABLED, - ), - error = webRtcSession.mosaicConfigError, - onChange = { webRtcSession.updateMosaicConfig(it) }, - ), + mosaic = run { + // 온디바이스는 기기에 저장한 값을 바로 적용하고, 서버 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 { + 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 06cfc2091..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 @@ -29,10 +29,15 @@ 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, + /** 온디바이스 강도는 6 단위 다섯 단계입니다. */ + val blurStep: Int = 1, + val maskMarginRange: IntRange = MosaicConfig.MASK_MARGIN_RANGE, val enabled: Boolean, val isChanging: Boolean, /** 조절할 수 없거나 적용되지 않는 이유입니다. */ @@ -42,18 +47,21 @@ data class MosaicControlsState( ) /** - * 조절할 수 없거나 적용되지 않는 이유입니다. iOS와 같은 순서로 방송 준비 전, 온디바이스 처리, 비식별화 꺼짐을 봅니다. + * 조절할 수 없거나 적용되지 않는 이유입니다. 온디바이스는 기기에 저장해 방송 준비 전에도 조절하므로, 비식별화를 + * 실제로 끈 경우가 아니면 하한만 알립니다. 준비 전 비식별화 상태는 [AnonymizationState.UNKNOWN]입니다. + * 서버 AI는 iOS와 같은 순서로 방송 준비 전, 비식별화 꺼짐을 봅니다. */ @StringRes internal fun mosaicNoteRes( hasSession: Boolean, onDeviceProcessing: Boolean, canChange: Boolean, - anonymizationEnabled: Boolean, + anonymization: AnonymizationState, ): Int? = when { + onDeviceProcessing && anonymization == AnonymizationState.DISABLED -> 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 + canChange && anonymization != AnonymizationState.ENABLED -> R.string.mosaic_anonymization_off else -> null } @@ -95,11 +103,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) / state.blurStep - 1, onValueChangeFinished = ::commit, onValueChange = { isEditing = true @@ -110,11 +118,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/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..8c17d075a --- /dev/null +++ b/apps/android/InnoLive/app/src/main/java/com/framework/innolive/feature/live/OnDeviceMosaicSettings.kt @@ -0,0 +1,58 @@ +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)을 + * 쓰지만, 블러는 지금 온디바이스 강도보다 약해지지 않고 너비는 마스크 1칸 아래로 줄지 않습니다. 기기에 저장합니다. + */ +data class OnDeviceMosaicSettings( + val blurStrength: Int = PrivacyBroadcastBlur.DEFAULT_STRENGTH, + val maskMargin: Int = MosaicConfig.DEFAULT_MASK_MARGIN, +) { + fun normalized() = copy( + 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 + + /** 시트가 돌려준 값입니다. 기본값으로 되돌리기는 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..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 @@ -20,7 +20,7 @@ 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 { @@ -29,6 +29,8 @@ internal class PrivacyBitmapBlur : AutoCloseable { gpu = null gpu = Gpu(source.width, source.height) } + // 강도만 바뀌면 렌더러와 버퍼는 그대로 두고 블러 효과만 바꿉니다. + checkNotNull(gpu).sigma = sigma val output = checkNotNull(gpu).apply(source) lastUsedGpu = true return output @@ -38,7 +40,7 @@ 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 } @@ -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(width, height) - 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 fad9c4336..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 @@ -16,25 +16,39 @@ 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 + /** 강도는 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 - /** 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 * 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. */ - 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..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 @@ -28,6 +28,8 @@ internal class PrivacyFrameProcessor(context: Context, private val sharedContext PrivacyFaceCoordinator(context.applicationContext, optimized=nativePostprocessing)).also { coordinator -> coordinator.liveTrackIds = { model.liveTrackIds } } + /** 영상 조절의 온디바이스 블러 강도·가림 영역 너비입니다. 처리 스레드가 프레임마다 읽습니다. */ + @Volatile var mosaic = PrivacyMosaic() var lastTimings: PrivacyFrameTimings? = null private set val lastAnalysis: PrivacyFrameAnalysis? get() = model.lastAnalysis @@ -128,7 +130,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 +162,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 +170,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=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) @@ -191,7 +196,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..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 @@ -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) - } - draw("dilate-y", DILATE, alpha, listOf(temp.texture)) { p -> - glUniform2f(p.getUniformLocation("stepSize"), 0f, 1f / 160) - glUniform1i(p.getUniformLocation("radius"), PrivacyMaskRenderer.PROTECTED_EDGE_CELLS) + 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 } @@ -669,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=-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); } + 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 514971f13..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,50 +4,74 @@ 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 * 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 - /** The inner mask may only grow. */ + /** 송출 영상 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. 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++ + 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++ } } - 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++ - } - } - return expanded + return output } 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 +85,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/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 29d0f8e75..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 @@ -82,6 +82,7 @@ internal class PrivacyOnnxModel(private val context: Context, fun process( upright: Bitmap, timestampNs: Long = System.nanoTime(), + mosaic: PrivacyMosaic = PrivacyMosaic(), exemptFaces: (List, PrivacySegmentation.Letterbox) -> Set = { _, _ -> emptySet() }, ): Bitmap { val layout = PrivacySegmentation.Letterbox(upright.width, upright.height) @@ -96,7 +97,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 = mosaic.sigma(upright.width, upright.height) + PrivacyMaskRenderer.render(upright, mask, layout, { blur.apply(it, sigma) }, + mosaic.edgeCells(upright.width, upright.height)) } } 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는 기본 강도와 너비보다 약하게 할 수 없습니다. 블러 설정을 바꾸지 못했습니다. 다시 시도해 주세요. 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..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,11 +43,19 @@ 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, 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, anonymization = AnonymizationState.UNKNOWN)) + assertEquals(R.string.mosaic_on_device_floor, + mosaicNoteRes(hasSession = true, onDeviceProcessing = true, canChange = false, anonymization = AnonymizationState.ENABLED)) + assertEquals(R.string.mosaic_anonymization_off, + 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 new file mode 100644 index 000000000..b8f64ef57 --- /dev/null +++ b/apps/android/InnoLive/app/src/test/java/com/framework/innolive/feature/live/OnDeviceMosaicSettingsTest.kt @@ -0,0 +1,34 @@ +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()) + // 강도는 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(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))) + 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..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 @@ -15,9 +15,26 @@ 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 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(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 +45,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..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)) @@ -43,4 +44,29 @@ 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 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)) + 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)) + } }