Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .omm/data-flow/description.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,5 @@ Multi 모드의 ConcurrentSession은 독립 장치마다 출력과 저장을 관
Portrait는 Depth 메뉴에서 선택하지만 별도 확장 세션의 컬러 미리보기와 OEM JPEG를 받습니다. PortraitCameraEngine은 JPEG를 MediaLibrary로 저장하고 효과·해상도·시각만 촬영 JSON에 남깁니다. 확장 콜백은 벤치마크나 일반 CaptureResult 지표에 섞지 않습니다.

App Bokeh는 일반 Camera2 Preview+JPEG 세션에서 원본 JPEG를 저장한 뒤 RGB로 디코딩합니다. MiDaS 모델의 Float 상대 깊이와 사용자 선택 지점을 DepthBokeh에 전달하여 배경을 흐리고, 보케 JPEG·깊이 PNG·처리 메타데이터를 MediaLibrary로 저장합니다. HAL Depth나 분석용 YUV를 입력으로 사용하지 않습니다.

배경 흐림 가중치는 원본 밝기 경계로 보정한 뒤 색 표본과 최종 합성에 동일하게 사용합니다. 깊이 PNG에는 보정 마스크가 아닌 모델 상대 깊이를 저장하며, 메타데이터의 renderer로 합성 방식을 구별합니다.
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ App Bokeh는 일반 Camera2의 Preview와 JPEG 두 출력을 사용한다. MainA

AppBokehProcessor는 원본을 EXIF 방향에 맞추고 긴 변을 최대 1600픽셀로 제한한다. MiDaS v2.1 모델은 RGB 사진 한 장에서 256×256 Float 상대 역깊이를 추론한다. RelativeDepth와 DepthBokeh는 Android 의존성 없이 깊이 정규화·선택 위치 표본·배경 혼합과 blur를 계산한다. 결과는 JPEG로 재인코딩하며 별도 깊이 PNG와 모델·원본·선택 위치·강도 메타데이터를 저장한다. 원본은 수정하지 않는다.

BokehMask는 원본 밝기를 guidance로 사용하는 국소 선형 필터로 깊이에서 얻은 흐림 가중치를 보정한다. 기존 가중치보다 흐림을 늘리지 않으며 보정한 가중치를 색 표본과 최종 합성에 함께 사용한다. 깊이 PNG는 원래 모델 출력을 유지하고 메타데이터는 renderer=guided_luma_mask_v1을 기록한다. 가는 경계의 손실을 줄이지만 피사체 분할이나 가려진 배경 복원은 아니다.

편집기의 worker가 추론·렌더·저장을 처리한다. 변경 세대가 지난 렌더는 표시하지 않고 화면 종료 후에는 UI를 갱신하지 않는다. 화면 복귀는 보관한 원본 URI로 편집을 다시 시작하며 이전 강도·선택 위치는 초기화한다. 상대 깊이는 실제 거리나 센서 DEPTH16이 아니다. Benchmark와 incident ZIP에는 이미지 픽셀을 넣지 않는다.

편집기는 Live 선택창과 같은 제목·닫기 아이콘, 흰색 선택 테두리와 접근성 선택 상태를 사용한다. 이미지와 저장 버튼은 Gallery의 Look 토큰을 재사용한다. 강도 조절은 Bokeh 결과로 돌아가며 Save Bokeh는 비교 화면과 관계없이 보케 복사본을 저장한다. 슬라이더는 최소 48dp 높이를 제공한다.
45 changes: 31 additions & 14 deletions app/src/androidTest/java/dev/halcamera/camera/AppBokehChecks.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,27 +24,37 @@ object AppBokehChecks {
.addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK))
val editor = main { dev.halcamera.ui.AppBokehEditor(activity, Uri.fromFile(File(path))) {}.also { it.show() } }
try {
val deadline = android.os.SystemClock.uptimeMillis() + 25_000
while (!main { button("Save Bokeh").isEnabled }) {
check(android.os.SystemClock.uptimeMillis() < deadline)
android.os.SystemClock.sleep(100)
fun awaitReady() {
val deadline = android.os.SystemClock.uptimeMillis() + 25_000
while (!main { button("Save Bokeh").isEnabled }) {
check(android.os.SystemClock.uptimeMillis() < deadline)
android.os.SystemClock.sleep(100)
}
}
awaitReady()
main {
check(button("Bokeh").isSelected)
button("Original").performClick()
check(button("Original").isSelected && !button("Bokeh").isSelected)
button("Depth").performClick()
check(button("Depth").isSelected)
val slider = nodes(root()).filterIsInstance<android.widget.SeekBar>().first()
check(slider.height >= (48 * activity.resources.displayMetrics.density).toInt())
check(slider.performAccessibilityAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS.id,
android.os.Bundle().apply { putFloat(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_PROGRESS_VALUE, 50f) }))
check(button("Bokeh").isSelected && !button("Depth").isSelected)
}
while (!main { button("Save Bokeh").isEnabled }) {
check(android.os.SystemClock.uptimeMillis() < deadline)
android.os.SystemClock.sleep(100)
for (strength in listOf(0, 50, 100)) {
main {
val slider = nodes(root()).filterIsInstance<android.widget.SeekBar>().first()
check(slider.height >= (48 * activity.resources.displayMetrics.density).toInt())
check(slider.performAccessibilityAction(android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction.ACTION_SET_PROGRESS.id,
android.os.Bundle().apply { putFloat(android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_PROGRESS_VALUE, strength.toFloat()) }))
check(button("Bokeh").isSelected && !button("Depth").isSelected)
}
awaitReady()
main {
check(nodes(root()).filterIsInstance<android.widget.TextView>().any { it.text.startsWith("Blur $strength%") })
}
}
main { check(button("Save Bokeh").performClick()) }
awaitReady()
main { check(nodes(root()).filterIsInstance<android.widget.TextView>().any { it.text.startsWith("Saved Bokeh JPEG + depth PNG.") }) }
main {
val view = root()
val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888)
Expand All @@ -55,7 +65,7 @@ object AppBokehChecks {
bitmap.recycle()
check(nodes(view).first { it.contentDescription == "Close App Bokeh" }.performClick())
}
return "APP_BOKEH_UI_OK selected comparison, accessible strength, result after adjustment, shared close control"
return "APP_BOKEH_UI_OK selected comparison, accessible strength 0/50/100, saved Bokeh, shared close control"
} finally { main { editor.dismiss(); activity.finish() } }
}

Expand All @@ -68,8 +78,15 @@ object AppBokehChecks {
val dir = File(context.getExternalFilesDir(null), "app-bokeh-check").apply { mkdirs() }
File(dir, "original.png").outputStream().use { scene.original.compress(Bitmap.CompressFormat.PNG, 100, it) }
File(dir, "depth.png").outputStream().use { AppBokehProcessor.depthBitmap(scene.depth).compress(Bitmap.CompressFormat.PNG, 100, it) }
// Exact model floats allow a before/after renderer comparison without re-inference.
java.io.DataOutputStream(File(dir, "depth.f32").outputStream()).use { output ->
scene.depth.values.forEach(output::writeFloat)
}
val renderTimes = mutableListOf<Long>()
for (strength in listOf(50, 100)) {
val start = android.os.SystemClock.elapsedRealtime()
val rendered = processor.render(scene, focusX, focusY, strength)
renderTimes += android.os.SystemClock.elapsedRealtime() - start
File(dir, "bokeh-$strength.jpg").outputStream().use { rendered.compress(Bitmap.CompressFormat.JPEG, 95, it) }
rendered.recycle()
}
Expand All @@ -82,6 +99,6 @@ object AppBokehChecks {
artifacts.filter { it.mime.startsWith("image/") }.forEach { artifact ->
context.contentResolver.openInputStream(artifact.uri)!!.use { check(BitmapFactory.decodeStream(it) != null) }
}
return "APP_BOKEH_OK source=${scene.original.width}x${scene.original.height} depth=256x256 focus=$focusX,$focusY loadAndInferenceMs=${scene.elapsedMs} saved=${artifacts.joinToString { it.name }}"
return "APP_BOKEH_OK source=${scene.original.width}x${scene.original.height} depth=256x256 focus=$focusX,$focusY loadAndInferenceMs=${scene.elapsedMs} render50And100Ms=$renderTimes saved=${artifacts.joinToString { it.name }}"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ class AppBokehProcessor(context: Context) {
"model" to "MiDaS v2.1 model_opt", "modelSha256" to MODEL_SHA256, "depthKind" to "relative_inverse_depth",
"depthNear" to "white", "metricDepth" to false, "focusX" to x, "focusY" to y,
"focusDepth" to scene.depth.focus(x, y), "strength" to strength,
"renderer" to "guided_luma_mask_v1",
"width" to result.width, "height" to result.height, "inferenceMs" to scene.elapsedMs,
"outputs" to listOf(jpeg.name, depth.name))
val json = save("${name}_metadata.json", "application/json") { it.write(org.json.JSONObject(metadata).toString(2).toByteArray()) }
Expand Down
58 changes: 58 additions & 0 deletions app/src/main/java/dev/halcamera/camera/BokehMask.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package dev.halcamera.camera

import kotlin.math.roundToInt

/** Refines coarse blur weights against the source image before either sampling or compositing blur. */
internal object BokehMask {
fun refine(pixels: IntArray, width: Int, height: Int, coarse: FloatArray): FloatArray {
// Local linear guided filter (He et al., ECCV 2010). Use full-resolution luminance so
// structures narrower than one model pixel can influence the compositing boundary.
val radius = (maxOf(width, height) * .035f).roundToInt().coerceAtLeast(1)
val guide = FloatArray(pixels.size) { i ->
val p = pixels[i]
(.299f * ((p ushr 16) and 255) + .587f * ((p ushr 8) and 255) + .114f * (p and 255)) / 255f
}
val meanGuide = mean(guide, width, height, radius)
val meanMask = mean(coarse, width, height, radius)
val variance = mean(FloatArray(pixels.size) { guide[it] * guide[it] }, width, height, radius)
val covariance = mean(FloatArray(pixels.size) { guide[it] * coarse[it] }, width, height, radius)
for (i in pixels.indices) {
val a = (covariance[i] - meanGuide[i] * meanMask[i]) /
((variance[i] - meanGuide[i] * meanGuide[i]).coerceAtLeast(0f) + .001f)
covariance[i] = a
variance[i] = meanMask[i] - a * meanGuide[i]
}
val meanA = mean(covariance, width, height, radius)
val meanB = mean(variance, width, height, radius)
return FloatArray(pixels.size) { i ->
// Never add blur to a plane the depth model already protects. Constant-background
// regions remain unchanged; refinement only recovers detail near a depth boundary.
minOf(coarse[i], (meanA[i] * guide[i] + meanB[i]).coerceIn(0f, 1f))
}
}

/** One box mean with clipped borders, O(pixels) work independent of radius. */
private fun mean(input: FloatArray, width: Int, height: Int, radius: Int): FloatArray {
val horizontal = FloatArray(input.size)
for (y in 0 until height) {
var sum = 0.0
for (x in 0..minOf(radius, width - 1)) sum += input[y * width + x]
for (x in 0 until width) {
horizontal[y * width + x] = (sum / (minOf(width - 1, x + radius) - maxOf(0, x - radius) + 1)).toFloat()
if (x - radius >= 0) sum -= input[y * width + x - radius]
if (x + radius + 1 < width) sum += input[y * width + x + radius + 1]
}
}
val output = FloatArray(input.size)
for (x in 0 until width) {
var sum = 0.0
for (y in 0..minOf(radius, height - 1)) sum += horizontal[y * width + x]
for (y in 0 until height) {
output[y * width + x] = (sum / (minOf(height - 1, y + radius) - maxOf(0, y - radius) + 1)).toFloat()
if (y - radius >= 0) sum -= horizontal[(y - radius) * width + x]
if (y + radius + 1 < height) sum += horizontal[(y + radius + 1) * width + x]
}
}
return output
}
}
3 changes: 2 additions & 1 deletion app/src/main/java/dev/halcamera/camera/RelativeDepth.kt
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ object DepthBokeh {
require(focusX.isFinite() && focusY.isFinite())
if (strength == 0) return pixels.copyOf()
val focus = depth.focus(focusX, focusY)
val weights = FloatArray(pixels.size) { i -> amount(depth.at((i % width).toFloat() / (width - 1).coerceAtLeast(1),
val coarseWeights = FloatArray(pixels.size) { i -> amount(depth.at((i % width).toFloat() / (width - 1).coerceAtLeast(1),
(i / width).toFloat() / (height - 1).coerceAtLeast(1)), focus) }
val weights = BokehMask.refine(pixels, width, height, coarseWeights)
val radius = (maxOf(width, height) * .018f * strength / 100).roundToInt().coerceAtLeast(1)
val blurredWeight = blur(weights, width, height, radius)
val channels = arrayOf(16, 8, 0).map { shift ->
Expand Down
48 changes: 48 additions & 0 deletions app/src/test/java/dev/halcamera/camera/BokehMaskTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
package dev.halcamera.camera

import org.junit.Assert.*
import org.junit.Test

class BokehMaskTest {
@Test fun `thin foreground extensions survive a missing coarse depth edge in both contrast directions`() {
val w = 256; val h = 128
for (brightSubject in listOf(false, true)) {
fun gray(value: Int) = -0x1000000 or (value shl 16) or (value shl 8) or value
val subject = if (brightSubject) 220 else 35
val pixels = IntArray(w * h) { i ->
val x = i % w; val y = i / w
gray(if (x < 72 || (x < 96 && y in 63..65)) subject
else if ((x + y) % 2 == 0) { if (brightSubject) 15 else 240 }
else { if (brightSubject) 47 else 208 })
}
// The model misses the narrow extension entirely; source pixels still show it.
val depth = RelativeDepth(w, h, FloatArray(w * h) { if (it % w < 72) 1f else 0f })
val result = DepthBokeh.render(pixels, w, h, depth, .1f, .5f, 100)
val recovered = result[64 * w + 77] and 255
val backgroundMean = if (brightSubject) 31 else 224
assertTrue("thin extension value=$recovered subject=$subject",
kotlin.math.abs(recovered - subject) < kotlin.math.abs(backgroundMean - subject) / 2)
// A disconnected high-contrast background texture must still be blurred.
val far = result[64 * w + 180] and 255
assertTrue(far in if (brightSubject) 25..37 else 218..230)
for (y in 0 until h) for (x in 0 until 72) assertEquals(pixels[y * w + x], result[y * w + x])
}
}

@Test fun `uniform background weight does not copy image texture into the blur mask`() {
val w = 80; val h = 50
val pixels = IntArray(w * h) { if (it % 3 == 0) -1 else 0xff123456.toInt() }
for (weight in listOf(0f, .4f, 1f)) {
val result = BokehMask.refine(pixels, w, h, FloatArray(pixels.size) { weight })
result.forEach { assertEquals(weight, it, .00001f) }
}
}

@Test fun `flat guidance and narrow images stay finite and cannot increase blur`() {
for ((w, h) in listOf(1 to 1, 1 to 64, 64 to 1, 20 to 30)) {
val coarse = FloatArray(w * h) { (it % 11) / 10f }
val result = BokehMask.refine(IntArray(w * h) { 0xff808080.toInt() }, w, h, coarse)
result.forEachIndexed { i, value -> assertTrue(value.isFinite() && value in 0f..coarse[i]) }
}
}
}
2 changes: 1 addition & 1 deletion docs/.site-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
".nojekyll": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"_inputs/decisions.html": "aadc3cad5fb3b8e37af148aa62ae79f9be0ba19a076fd2837f85c471b1629ded",
"_inputs/device-verification.yaml": "1852f48d52f75733b4db973418fc7fad1f0f9d340a8b4b555cf9d49aa7016215",
"architecture.html": "d5c30ef33736f4f609f3e39368a542fd6ec687796b894913dee4ce7be62e49a2",
"architecture.html": "b3de159e6d839d09d50c1c006d6d3ea43f69848c9ae23fb69ce6a7f10d61bd14",
"assets/docflow-design.css": "1cc4f34d8df94abdcf70ab2e9c51e456d8ef7f52283a2ef3adb986a6bd38d44c",
"assets/media/automation/bridge.png": "bdbd7cbdc28010caa9b8e320e2d311efd35bb2f66cbb9b5cb322af638b7ccbff",
"assets/media/automation/opening-sensor.mp4": "8e26f5eeeb0b92cd241b2235aab7ffd122cbf2ac94b66c37fafb440da4ae1178",
Expand Down
Loading
Loading