diff --git a/.omm/data-flow/description.md b/.omm/data-flow/description.md index 6dd75040..719b0331 100644 --- a/.omm/data-flow/description.md +++ b/.omm/data-flow/description.md @@ -15,3 +15,5 @@ CLI는 직접 인자 또는 base64url JSON을 받고 shell UID·허용 설정· Multi 모드의 ConcurrentSession은 독립 장치마다 출력과 저장을 관리합니다. 사진은 이미지·CaptureResult 센서 시각을 연결한 장치별 JPEG이며 다른 장치 영상을 합성하지 않습니다. ConcurrentPhotoStore는 JPEG를 DCIM/HALCamera에, 공통 촬영 ID·장치 ID·성공 또는 실패를 Download/HALCamera의 JSON에 기록합니다. 두 엔진은 MultiRecording을 공유하며, 하나의 마이크 AAC를 장치별 MP4에 결합한 뒤 공개합니다. 녹음이나 파일 하나라도 실패하면 해당 녹화 묶음의 공개 파일을 회수합니다. Split 배치는 프리뷰만 바꾸며 인코더 크기는 장치 스트림과 회전에서 정합니다. 이 경로는 Benchmark 계산과 분리됩니다. 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를 입력으로 사용하지 않습니다. diff --git a/.omm/overall-architecture/camera-engines/app-bokeh/description.md b/.omm/overall-architecture/camera-engines/app-bokeh/description.md new file mode 100644 index 00000000..8f352c62 --- /dev/null +++ b/.omm/overall-architecture/camera-engines/app-bokeh/description.md @@ -0,0 +1,7 @@ +App Bokeh는 일반 Camera2의 Preview와 JPEG 두 출력을 사용한다. MainAppBokehBridge가 JPEG 촬영·저장을 마친 뒤 AppBokehEditor에 URI를 전달한다. 제조사 확장과 분석용 YUV, 센서 Depth 스트림을 사용하지 않는다. + +AppBokehProcessor는 원본을 EXIF 방향에 맞추고 긴 변을 최대 1600픽셀로 제한한다. MiDaS v2.1 모델은 RGB 사진 한 장에서 256×256 Float 상대 역깊이를 추론한다. RelativeDepth와 DepthBokeh는 Android 의존성 없이 깊이 정규화·선택 위치 표본·배경 혼합과 blur를 계산한다. 결과는 JPEG로 재인코딩하며 별도 깊이 PNG와 모델·원본·선택 위치·강도 메타데이터를 저장한다. 원본은 수정하지 않는다. + +편집기의 worker가 추론·렌더·저장을 처리한다. 변경 세대가 지난 렌더는 표시하지 않고 화면 종료 후에는 UI를 갱신하지 않는다. 화면 복귀는 보관한 원본 URI로 편집을 다시 시작하며 이전 강도·선택 위치는 초기화한다. 상대 깊이는 실제 거리나 센서 DEPTH16이 아니다. Benchmark와 incident ZIP에는 이미지 픽셀을 넣지 않는다. + +편집기는 Live 선택창과 같은 제목·닫기 아이콘, 흰색 선택 테두리와 접근성 선택 상태를 사용한다. 이미지와 저장 버튼은 Gallery의 Look 토큰을 재사용한다. 강도 조절은 Bokeh 결과로 돌아가며 Save Bokeh는 비교 화면과 관계없이 보케 복사본을 저장한다. 슬라이더는 최소 48dp 높이를 제공한다. diff --git a/.omm/overall-architecture/camera-engines/app-bokeh/meta.yaml b/.omm/overall-architecture/camera-engines/app-bokeh/meta.yaml new file mode 100644 index 00000000..3a17edbc --- /dev/null +++ b/.omm/overall-architecture/camera-engines/app-bokeh/meta.yaml @@ -0,0 +1,5 @@ +kind: nested-class +title: app-bokeh +children: [] +parentPath: + - camera-engines diff --git a/.omm/overall-architecture/camera-engines/description.md b/.omm/overall-architecture/camera-engines/description.md index d3fab77b..26f285cc 100644 --- a/.omm/overall-architecture/camera-engines/description.md +++ b/.omm/overall-architecture/camera-engines/description.md @@ -1,2 +1,4 @@ -camera/는 CameraEngine 인터페이스와 Camera2Engine·CameraXEngine·DepthCameraEngine·PortraitCameraEngine을 제공합니다. Camera2Engine은 기본 크기 선택 경로와 벤치마크용 명시적 StreamSpec 경로를 구분합니다. Depth는 맵 보기, Portrait는 제조사 Bokeh JPEG 촬영이며 벤치마크 엔진과 구분합니다. 세션별 콜백과 close(done) 완료 통지가 러너에 연결됩니다. -StreamConfiguration은 실제 카메라 출력으로 Callback 목록을 만듭니다. 앱의 YUV JPEG 변환·PIP 합성·GPU 인코더 출력은 Callback에 추가하지 않습니다. +CameraEngine 구현은 Camera2·CameraX·Depth·Portrait로 나뉩니다. Camera2는 Live 기본 크기와 Benchmark의 명시적 StreamSpec을 구분합니다. Depth는 맵 보기, Portrait는 제조사 Bokeh JPEG 촬영입니다. 콜백과 close(done)는 러너에 연결됩니다. +StreamConfiguration의 Callback 목록은 실제 카메라 출력만 포함하며 앱 변환·합성·GPU 출력은 제외합니다. + +App Bokeh는 일반 JPEG 저장 후 앱에서 깊이를 추정하고 배경을 흐립니다. 구현은 app-bokeh 요소에 설명합니다. diff --git a/.omm/overall-architecture/camera-engines/meta.yaml b/.omm/overall-architecture/camera-engines/meta.yaml index 2777768f..a7e6f162 100644 --- a/.omm/overall-architecture/camera-engines/meta.yaml +++ b/.omm/overall-architecture/camera-engines/meta.yaml @@ -8,6 +8,7 @@ children: - camera2-engine - depth-engine - portrait-engine + - app-bokeh - camerax-engine - endpoint-model - endpoint-resolver diff --git a/.omm/overall-architecture/camera-engines/portrait-engine/description.md b/.omm/overall-architecture/camera-engines/portrait-engine/description.md index 9f27bc79..def254c7 100644 --- a/.omm/overall-architecture/camera-engines/portrait-engine/description.md +++ b/.omm/overall-architecture/camera-engines/portrait-engine/description.md @@ -1,5 +1,5 @@ PortraitCameraEngine은 API 31 이상에서 제조사 BOKEH의 CameraExtensionSession을 소유합니다. 공개 카메라의 확장 지원·SurfaceTexture 미리보기 크기·JPEG 크기를 각각 조회하고, 선택한 PortraitConfig의 두 크기로 세션을 구성합니다. 일반 StreamConfigurationMap 크기를 대용으로 쓰거나 실패 때 다른 크기로 재시도하지 않습니다. -Live의 Depth 메뉴는 Off·Portrait (Bokeh)·Depth Map을 구분합니다. Portrait는 컬러 미리보기와 JPEG 촬영이며 센서 Depth 맵을 반환하지 않습니다. 선택하면 Camera2로 전환하고 기존 close(done) 뒤 확장 세션을 엽니다. TextureView의 첫 표시 프레임 뒤 촬영을 허용합니다. 미리보기 15초·JPEG 60초 시간 제한, 실패 후 해제, 늦은 JPEG 차단과 단일 촬영의 한 번뿐인 완료 전달을 둡니다. close(done)는 열기 중인 장치도 닫고 CameraDevice.onClosed 뒤 자원을 회수합니다. 이미 받은 JPEG의 저장 작업은 완료할 수 있습니다. +Live의 Depth 메뉴는 Off·App Bokeh·Portrait (Bokeh)·Depth Map을 구분합니다. Portrait는 컬러 미리보기와 JPEG 촬영이며 센서 Depth 맵을 반환하지 않습니다. 선택하면 Camera2로 전환하고 기존 close(done) 뒤 확장 세션을 엽니다. TextureView의 첫 표시 프레임 뒤 촬영을 허용합니다. 미리보기 15초·JPEG 60초 시간 제한, 실패 후 해제, 늦은 JPEG 차단과 단일 촬영의 한 번뿐인 완료 전달을 둡니다. close(done)는 열기 중인 장치도 닫고 CameraDevice.onClosed 뒤 자원을 회수합니다. 이미 받은 JPEG의 저장 작업은 완료할 수 있습니다. 사진은 OEM JPEG를 그대로 MediaLibrary로 저장하고 JPEG 크기·미리보기 크기·manufacturer_bokeh 효과·시각을 촬영 JSON에 기록합니다. 수동 제어·줌·터치 측광·연사·AEB·RAW·PIP·동영상은 제공하지 않습니다. 일반 Camera2 콜백 지표로 확장 처리를 측정하지 않으며 Benchmark와 별도 경로입니다. Photo·Video·카메라·엔진 전환과 새 CLI 촬영 준비는 Portrait를 종료합니다. diff --git a/.omm/state-transitions/description.md b/.omm/state-transitions/description.md index b0a2ce00..a47c0d99 100644 --- a/.omm/state-transitions/description.md +++ b/.omm/state-transitions/description.md @@ -9,3 +9,5 @@ API 전환은 기존 close 완료 후 새 엔진을 엽니다. 목록 열기와 CLI는 accepted → preparing → running → saving → succeeded로 진행합니다. 실패·취소·중단, ID 중복과 충돌 처리는 하위 cli-request에서 설명합니다. 프로세스 재시작은 미완료 요청을 재실행하지 않습니다. 사진 저장 취소는 실제 저장과 경합할 수 있습니다. Live 스트림 적용은 사진·녹화·저장·권한 요청·CLI 작업 중에 거절합니다. 기존 close(done)가 끝난 뒤 새 엔진에 설정을 전달하고 구성 성공값을 카메라·엔진별로 보관합니다. 실패하면 크기를 자동 대체하지 않으며 사용자가 직전 정상 구성으로 복구할 수 있습니다. Activity 재생성에서도 요청값과 정상 구성을 복원합니다. + +App Bokeh는 JPEG 저장 후 편집하며 중복 촬영·CLI를 막습니다. worker는 오래된 결과를 버리고 실패해도 원본을 보존합니다. 중단 시 URI를 보관해 복귀 후 다시 편집합니다. 상세는 app-bokeh 요소에 있습니다. diff --git a/.omm/state-transitions/engine-lifecycle/description.md b/.omm/state-transitions/engine-lifecycle/description.md index b2e01ef7..9748e2e3 100644 --- a/.omm/state-transitions/engine-lifecycle/description.md +++ b/.omm/state-transitions/engine-lifecycle/description.md @@ -1,7 +1,7 @@ -Camera2Engine은 HandlerThread에서 카메라를 열고 세션과 반복 요청을 구성합니다. CameraXEngine은 ProcessCameraProvider를 사용하며, 자세한 수명 주기는 하위 요소 camerax-lifecycle에 있습니다. 두 엔진은 active 상태로 늦은 신호를 걸러 내며 close(done) 완료 후 다음 열기를 진행합니다. +Camera2Engine은 HandlerThread에서 열기·세션·반복 요청을 처리합니다. CameraXEngine의 ProcessCameraProvider 수명주기는 camerax-lifecycle에 있습니다. 두 엔진은 active로 늦은 신호를 차단하고 close(done) 뒤 다음 엔진을 엽니다. -Live 녹화는 선택한 설정, Benchmark 녹화는 profile을 사용합니다. 녹화 상태는 overall-architecture/camera-engines/camera2-engine/live-recorder, 측정 순서는 runner-sequence에 있습니다. PIP 종료 전에 메인 사진 대기를 취소하여 순환 대기를 막습니다. +Live 녹화는 선택 설정, Benchmark는 profile을 사용합니다. 녹화 상태는 overall-architecture/camera-engines/camera2-engine/live-recorder에 있습니다. PIP 종료 전 메인 사진 대기를 취소하여 순환 대기를 막습니다. -단계 전이의 시간 제한과 close 완료 처리는 runner-sequence에 있습니다. +측정 순서·시간 제한·close 처리는 runner-sequence에 있습니다. -DepthCameraEngine은 DEPTH16만 구성합니다. 열기 중 취소도 onOpened→onClosed 후 완료하며 active로 늦은 신호를 차단합니다. 첫 맵은 요청 후 8초까지 기다리고 해상도 대체·일반 Camera2 재시도는 하지 않습니다. +센서 Depth의 별도 세션과 시간 제한은 depth-engine 요소에 설명합니다. diff --git a/.omm/state-transitions/engine-lifecycle/portrait-lifecycle/description.md b/.omm/state-transitions/engine-lifecycle/portrait-lifecycle/description.md index cb948315..b6af1796 100644 --- a/.omm/state-transitions/engine-lifecycle/portrait-lifecycle/description.md +++ b/.omm/state-transitions/engine-lifecycle/portrait-lifecycle/description.md @@ -1 +1 @@ -Depth 메뉴의 Off·Portrait (Bokeh)·Depth Map을 구분합니다. Portrait 선택은 기존 close(done) 뒤 CameraExtensionSession을 열며, 첫 표시 프레임 이후 셔터를 허용합니다. JPEG를 받기 전 종료되면 촬영을 실패로 한 번 완료하고, 이미 받은 JPEG의 저장은 마칠 수 있습니다. Photo·Video·카메라·엔진 전환과 새 CLI 촬영은 Portrait를 종료합니다. 실패 뒤에도 Photo 복귀와 다른 해상도 선택을 허용합니다. +Depth 메뉴의 Off·App Bokeh·Portrait (Bokeh)·Depth Map을 구분합니다. Portrait 선택은 기존 close(done) 뒤 CameraExtensionSession을 열며, 첫 표시 프레임 이후 셔터를 허용합니다. JPEG를 받기 전 종료되면 촬영을 실패로 한 번 완료하고, 이미 받은 JPEG의 저장은 마칠 수 있습니다. Photo·Video·카메라·엔진 전환과 새 CLI 촬영은 Portrait를 종료합니다. 실패 뒤에도 Photo 복귀와 다른 해상도 선택을 허용합니다. diff --git a/.omm/ui-tool-handoff/description.md b/.omm/ui-tool-handoff/description.md index faefc451..9846a761 100644 --- a/.omm/ui-tool-handoff/description.md +++ b/.omm/ui-tool-handoff/description.md @@ -11,3 +11,5 @@ Multi도 Live의 close(done) 뒤 ConcurrentCameraActivity를 엽니다. Live에 Depth 보기에서도 Lab 진입 전에 close(done)를 기다립니다. 촬영 모드를 바꾸지 않고 Lab에서 돌아오면 선택한 Depth 해상도를 다시 엽니다. Depth 보기 중에는 크기 표시로 Live Streams를 여는 동작을 비활성화합니다. Portrait에서도 Lab 진입 전에 close(done)를 기다리고 복귀 때 선택 해상도를 다시 엽니다. Portrait의 크기 표시를 누르면 일반 Live Streams 대신 Depth 메뉴를 엽니다. Lab에서 일반 Live Streams 설정을 적용하면 Portrait를 종료하고 해당 설정을 사용합니다. + +App Bokeh도 Depth 메뉴에서 선택하지만 일반 Preview+JPEG 세션을 사용합니다. 셔터 완료 후 편집 Dialog가 원본 URI를 받아 열립니다. 편집 중에는 Lab·카메라·모드 전환을 막고 닫은 뒤 다시 허용합니다. 카메라·엔진 변경, 일반 Live Streams 적용이나 새 CLI 촬영 준비는 App Bokeh 모드를 종료합니다. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d66d0773..f253a118 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,8 @@ import java.util.Properties +import java.security.MessageDigest +import java.net.URI +import java.nio.file.Files +import java.nio.file.StandardCopyOption plugins { id("com.android.application") @@ -49,8 +53,39 @@ android { kotlinOptions { jvmTarget = "17" } lint { abortOnError = true } sourceSets.getByName("androidTest").assets.srcDir("../tools/halcam/fixtures") + sourceSets.getByName("main").assets.srcDir(layout.buildDirectory.dir("generated/depthAssets")) + androidResources { noCompress += "tflite" } } + +// Official, MIT-licensed MiDaS v2.1 mobile model. Never accept an unverified download. +val depthModel = layout.buildDirectory.file("generated/depthAssets/midas-v2_1.tflite") +val prepareDepthModel by tasks.registering { + outputs.file(depthModel) + val expected = "93d871071edff1218973ce25ee27ce95ccd20450c70a55e1b89efa3f5a772cdd" + fun hash(file: File): String = MessageDigest.getInstance("SHA-256").let { digest -> + file.inputStream().use { input -> + val bytes = ByteArray(65536) + while (true) { val n = input.read(bytes); if (n < 0) break; digest.update(bytes, 0, n) } + } + digest.digest().joinToString("") { "%02x".format(it) } + } + outputs.upToDateWhen { depthModel.get().asFile.let { it.exists() && hash(it) == expected } } + doLast { + val destination = depthModel.get().asFile + destination.parentFile.mkdirs() + val temporary = File(destination.parentFile, "model.download") + try { + val connection = URI("https://github.com/isl-org/MiDaS/releases/download/v2_1/model_opt.tflite").toURL().openConnection() + connection.connectTimeout = 30000; connection.readTimeout = 120000 + connection.getInputStream().use { input -> temporary.outputStream().use { input.copyTo(it) } } + check(hash(temporary) == expected) { "MiDaS model SHA-256 mismatch" } + Files.move(temporary.toPath(), destination.toPath(), StandardCopyOption.REPLACE_EXISTING) + } finally { temporary.delete() } + } +} +tasks.named("preBuild").configure { dependsOn(prepareDepthModel) } dependencies { + implementation("com.google.ai.edge.litert:litert:1.4.2") implementation("androidx.activity:activity-ktx:1.13.0") implementation("androidx.core:core-ktx:1.18.0") implementation("androidx.exifinterface:exifinterface:1.4.2") diff --git a/app/src/androidTest/java/dev/halcamera/camera/AppBokehChecks.kt b/app/src/androidTest/java/dev/halcamera/camera/AppBokehChecks.kt new file mode 100644 index 00000000..473ea2bb --- /dev/null +++ b/app/src/androidTest/java/dev/halcamera/camera/AppBokehChecks.kt @@ -0,0 +1,87 @@ +package dev.halcamera.camera + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import java.io.File + +object AppBokehChecks { + /** Reuse a local photo so editor checks never need another live capture. */ + fun ui(test: android.app.Instrumentation, path: String): String { + fun main(block: () -> T): T { + val result = java.util.concurrent.atomic.AtomicReference>() + test.runOnMainSync { result.set(runCatching(block)) } + return result.get().getOrThrow() + } + fun nodes(view: android.view.View): Sequence = sequence { + yield(view) + if (view is android.view.ViewGroup) for (i in 0 until view.childCount) yieldAll(nodes(view.getChildAt(i))) + } + fun root() = android.view.inspector.WindowInspector.getGlobalWindowViews().last { it.isShown } + fun button(label: String) = nodes(root()).filterIsInstance().first { it.text.toString() == label } + val activity = test.startActivitySync(android.content.Intent(test.targetContext, dev.halcamera.MainActivity::class.java) + .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) + } + 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().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) + } + main { + val view = root() + val bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888) + view.draw(android.graphics.Canvas(bitmap)) + File(test.targetContext.getExternalFilesDir(null), "app-bokeh-ux.png").outputStream().use { + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, it)) + } + 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" + } finally { main { editor.dismiss(); activity.finish() } } + } + + fun run(context: Context, path: String, focusX: Float = .5f, focusY: Float = .5f): String { + val processor = AppBokehProcessor(context) + val source = Uri.fromFile(File(path)) + val scene = processor.load(source) + check(scene.depth.width == 256 && scene.depth.height == 256) + check(scene.original.width <= 1600 && scene.original.height <= 1600) + 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) } + for (strength in listOf(50, 100)) { + val rendered = processor.render(scene, focusX, focusY, strength) + File(dir, "bokeh-$strength.jpg").outputStream().use { rendered.compress(Bitmap.CompressFormat.JPEG, 95, it) } + rendered.recycle() + } + val result = processor.render(scene, focusX, focusY, 80) + File(dir, "bokeh.jpg").outputStream().use { result.compress(Bitmap.CompressFormat.JPEG, 95, it) } + val zero = processor.render(scene, focusX, focusY, 0) + check(zero.sameAs(scene.original)) { "Zero strength must preserve source pixels" } + val artifacts = processor.save(scene, result, source, focusX, focusY, 80) + check(artifacts.size == 3 && artifacts.all { it.bytes > 0 }) + 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 }}" + } +} diff --git a/app/src/androidTest/java/dev/halcamera/cli/CliStoreInstrumentation.kt b/app/src/androidTest/java/dev/halcamera/cli/CliStoreInstrumentation.kt index a4a992fe..c710f42a 100644 --- a/app/src/androidTest/java/dev/halcamera/cli/CliStoreInstrumentation.kt +++ b/app/src/androidTest/java/dev/halcamera/cli/CliStoreInstrumentation.kt @@ -9,6 +9,10 @@ import java.util.UUID /** Device tests use real JSONObject and AtomicFile, with a deterministic clock and isolated files. */ class CliStoreInstrumentation : Instrumentation() { + private var appBokehSource: String? = null + private var appBokehUi = false + private var appBokehFocusX = .5f + private var appBokehFocusY = .5f private var exportReports = false private var probeLaunchDiagnostics = false private var snapshotFailures = false @@ -24,6 +28,10 @@ class CliStoreInstrumentation : Instrumentation() { private var portraitUi = false override fun onCreate(arguments: Bundle?) { super.onCreate(arguments) + appBokehSource = arguments?.getString("app_bokeh_source") + appBokehUi = arguments?.getString("app_bokeh_ui") == "true" + appBokehFocusX = arguments?.getString("app_bokeh_focus_x")?.toFloat() ?: .5f + appBokehFocusY = arguments?.getString("app_bokeh_focus_y")?.toFloat() ?: .5f exportReports = arguments?.getString("export_reports") == "true" probeLaunchDiagnostics = arguments?.getString("probe_launch_diagnostics") == "true" snapshotFailures = arguments?.getString("snapshot_failures") == "true" @@ -41,6 +49,16 @@ class CliStoreInstrumentation : Instrumentation() { } override fun onStart() { + appBokehSource?.let { path -> + try { + val result = if (appBokehUi) dev.halcamera.camera.AppBokehChecks.ui(this, path) + else dev.halcamera.camera.AppBokehChecks.run(targetContext, path, appBokehFocusX, appBokehFocusY) + finish(Activity.RESULT_OK, Bundle().apply { putString("stream", "$result\n") }) + } catch (error: Throwable) { + finish(Activity.RESULT_CANCELED, Bundle().apply { putString("stream", "APP_BOKEH_FAILED: ${error.stackTraceToString()}\n") }) + } + return + } if (portraitChecks || portraitMetadata || portraitUi) { try { val result = when { diff --git a/app/src/main/assets/licenses/MiDaS.txt b/app/src/main/assets/licenses/MiDaS.txt new file mode 100644 index 00000000..277b5c11 --- /dev/null +++ b/app/src/main/assets/licenses/MiDaS.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Intel ISL (Intel Intelligent Systems Lab) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/app/src/main/java/dev/halcamera/MainActivity.kt b/app/src/main/java/dev/halcamera/MainActivity.kt index 4e4d7b9b..b29ca903 100644 --- a/app/src/main/java/dev/halcamera/MainActivity.kt +++ b/app/src/main/java/dev/halcamera/MainActivity.kt @@ -69,7 +69,7 @@ class MainActivity : ComponentActivity() { private var engineCameraId: String? = null internal var closing = false internal var resumed = false - private var destroyed = false + internal var destroyed = false internal var paused = false internal var engineName = "CameraX" internal var cameraId = "" @@ -132,6 +132,10 @@ class MainActivity : ComponentActivity() { internal var videoMode = false internal var depthSize: LiveSize? = null internal var portraitConfig: PortraitConfig? = null + internal var appBokehMode = false + internal var appBokehCapturing = false + internal var appBokehEditor: AppBokehEditor? = null + internal var appBokehSource: String? = null internal lateinit var depthModeButton: Button internal var recordingVideo = false internal var stoppingRecording = false @@ -178,6 +182,7 @@ class MainActivity : ComponentActivity() { @Suppress("DEPRECATION") val settings = data.getSerializableExtra(LiveStreamsActivity.EXTRA_SETTINGS) as? LiveStreamSettings portraitConfig = null + appBokehMode = false if (settings == null) streamSettings.remove(streamKey()) else streamSettings[streamKey()] = settings telemetry.event(sessionId, "live_streams_changed", settings?.metadata() ?: mapOf("mode" to "default")) paused = false @@ -255,6 +260,8 @@ class MainActivity : ComponentActivity() { depthSize = savedInstanceState?.getSerializable("depthSize") as? LiveSize @Suppress("DEPRECATION") portraitConfig = savedInstanceState?.getSerializable("portraitConfig") as? PortraitConfig + appBokehMode = savedInstanceState?.getBoolean("appBokehMode") ?: false + appBokehSource = savedInstanceState?.getString("appBokehSource") @Suppress("DEPRECATION", "UNCHECKED_CAST") (savedInstanceState?.getSerializable("liveStreams") as? HashMap)?.let(streamSettings::putAll) @Suppress("DEPRECATION", "UNCHECKED_CAST") @@ -271,6 +278,8 @@ class MainActivity : ComponentActivity() { outState.putBoolean("videoMode", videoMode) outState.putSerializable("depthSize", depthSize) outState.putSerializable("portraitConfig", portraitConfig) + outState.putBoolean("appBokehMode", appBokehMode) + outState.putString("appBokehSource", appBokehSource) outState.putSerializable("liveStreams", HashMap(streamSettings)) outState.putSerializable("goodStreams", HashMap(goodStreams)) outState.putBoolean("returningFromLab", returningFromLab) @@ -290,6 +299,7 @@ class MainActivity : ComponentActivity() { } override fun onResume() { super.onResume() + appBokehSource?.takeIf { appBokehEditor == null }?.let { openAppBokehEditor(android.net.Uri.parse(it)) } when { returningFromLab -> { returningFromLab = false @@ -307,6 +317,9 @@ class MainActivity : ComponentActivity() { } } override fun onStop() { + val retainedBokehSource = appBokehSource + appBokehEditor?.dismiss() + appBokehSource = retainedBokehSource cliSequence.cancel() cli.detach(liveCli) zoomControl.collapse(animate = false) @@ -645,9 +658,12 @@ class MainActivity : ComponentActivity() { else if(recordingVideo) stopRecording() else if(videoMode) { withMediaPermissions(true) { (engine as? MediaCapture)?.startRecording() } - } else withMediaPermissions(false) { controlBar.controls.let { if (it.bracket) bursts.bracket(sessionId, it, controlBar.support) else engine?.capture() } } + } else withMediaPermissions(false) { + if (appBokehMode) captureAppBokeh() + else controlBar.controls.let { if (it.bracket) bursts.bracket(sessionId, it, controlBar.support) else engine?.capture() } + } } - bindBurstInput({ !videoMode && portraitConfig == null && !mediaBusy() && !controlBar.controls.bracket && cli.active == null }, + bindBurstInput({ !videoMode && !appBokehMode && portraitConfig == null && !mediaBusy() && !controlBar.controls.bracket && cli.active == null }, { withMediaPermissions(false,it) }, { bursts.hold(sessionId) }, bursts::release) } captureRow.addView(mediaButton,LinearLayout.LayoutParams(captureSize,captureSize).apply { marginStart=dp(12); marginEnd=dp(12) }) @@ -714,7 +730,7 @@ class MainActivity : ComponentActivity() { val idle=!recordingVideo && bursts.run==null cli.setUiBusy(liveCli, recordingVideo || stoppingRecording || pendingPermissionAction != null || mediaBusy()) listOf(photoModeButton,videoModeButton).forEachIndexed { index, button -> - val selected=depthSize == null && portraitConfig == null && (index==1)==videoMode + val selected=depthSize == null && portraitConfig == null && !appBokehMode && (index==1)==videoMode button.isSelected=selected button.isEnabled=(ready || depthSize != null || portraitConfig != null) && idle && !closing && !mediaBusy() && pendingPermissionAction == null button.setTextColor(if(selected) Look.onDark else Look.onDarkMuted) @@ -779,6 +795,12 @@ class MainActivity : ComponentActivity() { } updateDepthControls(callbackGraph.isVisible, topBar.height + dp(8), bottomBar.height + dp(64)) updatePortraitControls() + if (appBokehMode) { + controlBar.bind(false, false, false); manualPanel.close() + physicalPipButton.isEnabled = false; physicalPipButton.alpha = .4f + mediaButton.contentDescription = "Take photo for App Bokeh" + if (appBokehCapturing || appBokehEditor != null) mediaButton.isEnabled = false + } listOf(engineButton,cameraShortcut,photoModeButton,videoModeButton,mediaButton,galleryButton,labButton).forEach { it.alpha=if(it.isEnabled) 1f else 0.4f } @@ -789,8 +811,10 @@ class MainActivity : ComponentActivity() { engineName=name; resetModeSettings(); updateCameraChoices(); restartCamera() } internal fun resetModeSettings() { + if (appBokehMode) captureFeedback.clearResult() depthSize = null portraitConfig = null + appBokehMode = false if (::callbackGraph.isInitialized) showCallbacks(false) pendingPip = null pipUi.resetMode() @@ -864,7 +888,7 @@ class MainActivity : ComponentActivity() { } private fun pipSources(): List = (engine as? PipCamera)?.pipSources.orEmpty() - internal fun mediaBusy() = pipUi.busy || (engine as? MediaCapture)?.mediaBusy == true || bursts.run != null + internal fun mediaBusy() = appBokehCapturing || appBokehEditor != null || pipUi.busy || (engine as? MediaCapture)?.mediaBusy == true || bursts.run != null /** One snapshot at a time; failures leave recording active and unsupported cameras explain why. */ private fun takeSnapshot() { if (cli.active != null) return diff --git a/app/src/main/java/dev/halcamera/MainAppBokehBridge.kt b/app/src/main/java/dev/halcamera/MainAppBokehBridge.kt new file mode 100644 index 00000000..964638cb --- /dev/null +++ b/app/src/main/java/dev/halcamera/MainAppBokehBridge.kt @@ -0,0 +1,62 @@ +package dev.halcamera + +import android.net.Uri +import dev.halcamera.camera.MediaCapture +import dev.halcamera.camera.liveStreamSupport +import dev.halcamera.ui.AppBokehEditor +import java.util.UUID + +/** Uses the ordinary photo session; the saved JPEG is the only inference input. */ +internal fun MainActivity.chooseAppBokeh() { + if (!resumed || closing || mediaBusy() || cli.active != null || recordingVideo || stoppingRecording) return + val id = cameraId + io.execute { + val selected = runCatching { + liveStreamSupport(manager.getCameraCharacteristics(id)).defaults().copy(yuv = null).also { + require(it.jpeg != null) { "App Bokeh requires a JPEG output on this camera" } + } + } + main.post { + if (!resumed || closing || cameraId != id || mediaBusy() || cli.active != null || recordingVideo || stoppingRecording) return@post + selected.fold({ settings -> + recorder.finish("app_bokeh_selected")?.let(::exportCliIncident) + resetModeSettings() + engineName = "Camera2"; videoMode = false; appBokehMode = true; paused = false + streamSettings[streamKey()] = settings + captureFeedback.showResult("App Bokeh · Capture, then tap the subject") + updateCameraChoices(); restartCamera() + }, { captureFeedback.showResult("App Bokeh unavailable: ${it.message}") }) + } + } +} + +internal fun MainActivity.captureAppBokeh() { + if (!resumed || !ready || closing || mediaBusy() || cli.active != null) return + val camera = engine as? MediaCapture ?: return + appBokehCapturing = true + updateMediaControls() + camera.capturePhoto(UUID.randomUUID().toString()) { result -> + main.post { + appBokehCapturing = false + result.fold({ photo -> + val jpeg = photo.artifacts.firstOrNull { it.mime == "image/jpeg" } + if (jpeg == null) { if (resumed) captureFeedback.showResult("App Bokeh needs a JPEG photo. Enable JPEG in Live Streams.") } + else { + appBokehSource = jpeg.uri.toString() + if (resumed && !destroyed) openAppBokehEditor(jpeg.uri) + } + }, { if (resumed) captureFeedback.showResult("App Bokeh capture failed: ${it.message}") }) + if (!destroyed) updateMediaControls() + } + } +} + +internal fun MainActivity.openAppBokehEditor(uri: Uri) { + if (!resumed || destroyed || appBokehEditor != null) return + appBokehSource = uri.toString() + appBokehEditor = AppBokehEditor(this, uri) { + appBokehEditor = null; appBokehSource = null + if (!destroyed) updateMediaControls() + }.also { it.show() } + updateMediaControls() +} diff --git a/app/src/main/java/dev/halcamera/MainCameraPicker.kt b/app/src/main/java/dev/halcamera/MainCameraPicker.kt index 875268d3..1ea55a5d 100644 --- a/app/src/main/java/dev/halcamera/MainCameraPicker.kt +++ b/app/src/main/java/dev/halcamera/MainCameraPicker.kt @@ -22,7 +22,8 @@ internal fun MainActivity.selectCamera() { pendingPermissionAction = null recorder.finish("camera_changed")?.let(::exportCliIncident) cameraId = chosen; zoomRatio = 1f - depthSize = null; portraitConfig = null + if (appBokehMode) captureFeedback.clearResult() + depthSize = null; portraitConfig = null; appBokehMode = false resetControls(); updateCameraChoices(); restartCamera() } } diff --git a/app/src/main/java/dev/halcamera/MainCliBridge.kt b/app/src/main/java/dev/halcamera/MainCliBridge.kt index f50d0759..af771f76 100644 --- a/app/src/main/java/dev/halcamera/MainCliBridge.kt +++ b/app/src/main/java/dev/halcamera/MainCliBridge.kt @@ -34,6 +34,7 @@ internal fun MainActivity.createLiveCli(): LiveController = LiveController(cli, captureFeedback.clearResult() depthSize = null portraitConfig = null + appBokehMode = false showCallbacks(false) val modeChanged = engineName != (command.engine ?: "Camera2") || videoMode != command.videoMode cameraId = requireNotNull(command.camera); engineName = command.engine ?: "Camera2" diff --git a/app/src/main/java/dev/halcamera/MainDepthBridge.kt b/app/src/main/java/dev/halcamera/MainDepthBridge.kt index 1ce3bb1f..367e293b 100644 --- a/app/src/main/java/dev/halcamera/MainDepthBridge.kt +++ b/app/src/main/java/dev/halcamera/MainDepthBridge.kt @@ -20,18 +20,20 @@ internal fun MainActivity.chooseDepth() { if (!resumed || closing || cameraId != id || cli.active != null || mediaBusy() || recordingVideo || stoppingRecording || pendingPermissionAction != null) return@post val choices = listOf(LiveChoiceSheet.Choice("Off", "Return to Photo"), + LiveChoiceSheet.Choice("App Bokeh", if (appBokehMode) "Active · Tap shutter, then choose the subject" else "People and objects · Offline estimated depth · Edit after capture"), LiveChoiceSheet.Choice("Portrait (Bokeh)", portraitConfig?.let { "Active · JPEG ${it.jpeg} · Change resolution" } ?: if (portrait.available) "Manufacturer background blur · Photos" else portrait.unavailableReason.orEmpty(), portrait.available), LiveChoiceSheet.Choice("Depth Map", depthSize?.let { "Active · DEPTH16 $it · Change resolution" } ?: if (depth?.available == true) "View sensor DEPTH16 · No photo capture" else depth?.unavailableReason ?: "Cannot read Depth capabilities.", depth?.available == true)) LiveChoiceSheet.show(this, "Depth · Portrait", choices, - if (depthSize == null && portraitConfig == null && !videoMode) 0 else -1) { index -> + if (appBokehMode) 1 else if (depthSize == null && portraitConfig == null && !videoMode) 0 else -1) { index -> if (!resumed || closing || cameraId != id || cli.active != null || mediaBusy() || recordingVideo || stoppingRecording || pendingPermissionAction != null) return@show when (index) { 0 -> selectMode(false) - 1 -> choosePortrait(portrait) - 2 -> depth?.let { chooseDepthMap(it) } + 1 -> chooseAppBokeh() + 2 -> choosePortrait(portrait) + 3 -> depth?.let { chooseDepthMap(it) } } } } @@ -80,12 +82,12 @@ internal fun MainActivity.createDepthEngine(released: String?, status: (String, internal fun MainActivity.updateDepthControls(callbacks: Boolean, topInset: Int, bottomInset: Int) { depthModeButton.isEnabled = resumed && !closing && !recordingVideo && bursts.run == null && !stoppingRecording && !mediaBusy() && cli.active == null && pendingPermissionAction == null && cameraId.isNotEmpty() - val selected = depthSize != null || portraitConfig != null + val selected = depthSize != null || portraitConfig != null || appBokehMode depthModeButton.isSelected = selected depthModeButton.setTextColor(if (selected) Look.onDark else Look.onDarkMuted) depthModeButton.setTypeface(null, if (selected) Typeface.BOLD else Typeface.NORMAL) depthModeButton.alpha = if (depthModeButton.isEnabled) 1f else .4f - depthModeButton.contentDescription = "Depth and Portrait: choose manufacturer Bokeh photos or a sensor Depth map" + depthModeButton.contentDescription = "Depth and Portrait: choose App Bokeh, manufacturer Bokeh, or a sensor Depth map" ViewCompat.setStateDescription(depthModeButton, if (selected) "Selected" else "Not selected") if (depthSize != null) { mediaButton.isEnabled = false @@ -105,7 +107,7 @@ internal fun MainActivity.updateDepthControls(callbacks: Boolean, topInset: Int, internal fun MainActivity.selectMode(video: Boolean) { if (cli.active != null) return if (recordingVideo || stoppingRecording || closing || pendingPermissionAction != null || - mediaBusy() || (depthSize == null && portraitConfig == null && (!ready || videoMode==video))) return + mediaBusy() || (depthSize == null && portraitConfig == null && !appBokehMode && (!ready || videoMode==video))) return pendingPermissionAction=null videoMode=video resetModeSettings() diff --git a/app/src/main/java/dev/halcamera/camera/AppBokehProcessor.kt b/app/src/main/java/dev/halcamera/camera/AppBokehProcessor.kt new file mode 100644 index 00000000..ff7b9e46 --- /dev/null +++ b/app/src/main/java/dev/halcamera/camera/AppBokehProcessor.kt @@ -0,0 +1,108 @@ +package dev.halcamera.camera + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import androidx.exifinterface.media.ExifInterface +import org.tensorflow.lite.DataType +import org.tensorflow.lite.Interpreter +import java.io.FileInputStream +import java.nio.ByteBuffer +import java.nio.ByteOrder +import java.nio.channels.FileChannel + +/** Offline single-RGB relative-depth inference. No camera extension, person detector or network. */ +class AppBokehProcessor(context: Context) { + private val context = context.applicationContext + data class Scene(val original: Bitmap, val depth: RelativeDepth, val elapsedMs: Long) + + fun load(uri: Uri): Scene { + val start = android.os.SystemClock.elapsedRealtime() + val resolver = context.contentResolver + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + resolver.openInputStream(uri)!!.use { BitmapFactory.decodeStream(it, null, bounds) } + require(bounds.outWidth > 0 && bounds.outHeight > 0) { "Cannot read source photo" } + var sample = 1 + while (maxOf(bounds.outWidth, bounds.outHeight) / sample > 3200) sample *= 2 + val decoded = resolver.openInputStream(uri)!!.use { + BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample; inPreferredConfig = Bitmap.Config.ARGB_8888 }) + } ?: error("Cannot decode source photo") + val exif = resolver.openInputStream(uri)!!.use { ExifInterface(it) } + val matrix = Matrix().apply { + if (exif.isFlipped) postScale(-1f, 1f) + postRotate(exif.rotationDegrees.toFloat()) + } + val upright = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, matrix, true) + if (upright !== decoded) decoded.recycle() + val scale = minOf(1f, MAX_EDGE.toFloat() / maxOf(upright.width, upright.height)) + val original = Bitmap.createScaledBitmap(upright, (upright.width * scale).toInt().coerceAtLeast(1), + (upright.height * scale).toInt().coerceAtLeast(1), true) + if (original !== upright) upright.recycle() + val depth = infer(original) + return Scene(original, depth, android.os.SystemClock.elapsedRealtime() - start) + } + + fun infer(bitmap: Bitmap): RelativeDepth { + val model = context.assets.openFd("midas-v2_1.tflite").use { asset -> + FileInputStream(asset.fileDescriptor).use { it.channel.map(FileChannel.MapMode.READ_ONLY, asset.startOffset, asset.declaredLength) } + } + return Interpreter(model, Interpreter.Options().setNumThreads(4)).use { interpreter -> + val inputTensor = interpreter.getInputTensor(0) + val outputTensor = interpreter.getOutputTensor(0) + val shape = inputTensor.shape() + check(inputTensor.dataType() == DataType.FLOAT32 && shape.contentEquals(intArrayOf(1, 256, 256, 3))) { "Unexpected depth input tensor" } + check(outputTensor.dataType() == DataType.FLOAT32 && outputTensor.numElements() == 256 * 256) { "Unexpected depth output tensor" } + val resized = Bitmap.createScaledBitmap(bitmap, 256, 256, true) + val pixels = IntArray(256 * 256) + resized.getPixels(pixels, 0, 256, 0, 0, 256, 256) + if (resized !== bitmap) resized.recycle() + val input = ByteBuffer.allocateDirect(pixels.size * 3 * 4).order(ByteOrder.nativeOrder()) + // Matches the official MiDaS Android model_opt.tflite example (RGB, mean 115, std 58). + pixels.forEach { pixel -> for (shift in intArrayOf(16, 8, 0)) input.putFloat((((pixel ushr shift) and 255) - 115f) / 58f) } + input.rewind() + val output = ByteBuffer.allocateDirect(pixels.size * 4).order(ByteOrder.nativeOrder()) + interpreter.run(input, output) + output.rewind() + val raw = FloatArray(pixels.size) + output.asFloatBuffer().get(raw) + RelativeDepth.normalize(256, 256, raw) + } + } + + fun render(scene: Scene, x: Float, y: Float, strength: Int): Bitmap { + val bitmap = scene.original + if (strength == 0) return bitmap + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + return Bitmap.createBitmap(DepthBokeh.render(pixels, bitmap.width, bitmap.height, scene.depth, x, y, strength), + bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) + } + + fun save(scene: Scene, result: Bitmap, source: Uri, x: Float, y: Float, strength: Int): List { + val library = MediaLibrary(context) + val name = library.name() + "_APP_BOKEH" + return library.transaction { + val jpeg = save("$name.jpg", "image/jpeg") { check(result.compress(Bitmap.CompressFormat.JPEG, 95, it)) } + val depth = save("${name}_depth.png", "image/png") { check(depthBitmap(scene.depth).compress(Bitmap.CompressFormat.PNG, 100, it)) } + val metadata = mapOf("schema" to 1, "effect" to "app_depth_bokeh", "sourceUri" to source.toString(), + "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, + "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()) } + listOf(jpeg, depth, json) + } + } + + companion object { + const val MAX_EDGE = 1600 + const val MODEL_SHA256 = "93d871071edff1218973ce25ee27ce95ccd20450c70a55e1b89efa3f5a772cdd" + fun depthBitmap(depth: RelativeDepth): Bitmap = Bitmap.createBitmap(IntArray(depth.values.size) { + val value = (depth.values[it] * 255).toInt().coerceIn(0, 255) + -0x1000000 or (value shl 16) or (value shl 8) or value + }, depth.width, depth.height, Bitmap.Config.ARGB_8888) + } +} diff --git a/app/src/main/java/dev/halcamera/camera/RelativeDepth.kt b/app/src/main/java/dev/halcamera/camera/RelativeDepth.kt new file mode 100644 index 00000000..65570fd1 --- /dev/null +++ b/app/src/main/java/dev/halcamera/camera/RelativeDepth.kt @@ -0,0 +1,98 @@ +package dev.halcamera.camera + +import kotlin.math.roundToInt + +/** Relative inverse depth, not metres: 1 is nearer, 0 is farther. */ +class RelativeDepth(val width: Int, val height: Int, val values: FloatArray) { + init { require(width > 0 && height > 0 && values.size == width * height && values.all { it.isFinite() && it in 0f..1f }) } + + fun at(x: Float, y: Float): Float { + val px = x.coerceIn(0f, 1f) * (width - 1) + val py = y.coerceIn(0f, 1f) * (height - 1) + val x0 = px.toInt(); val y0 = py.toInt() + val x1 = (x0 + 1).coerceAtMost(width - 1); val y1 = (y0 + 1).coerceAtMost(height - 1) + val a = px - x0; val b = py - y0 + return (values[y0 * width + x0] * (1 - a) + values[y0 * width + x1] * a) * (1 - b) + + (values[y1 * width + x0] * (1 - a) + values[y1 * width + x1] * a) * b + } + + fun focus(x: Float, y: Float): Float { + val samples = FloatArray(9) { i -> at(x + (i % 3 - 1).toFloat() / width, y + (i / 3 - 1).toFloat() / height) } + samples.sort() + return samples[4] + } + + companion object { + fun normalize(width: Int, height: Int, raw: FloatArray): RelativeDepth { + require(raw.size == width * height && raw.all(Float::isFinite)) { "Depth model returned invalid values" } + val sorted = raw.sorted() + val low = sorted[(sorted.size * .02f).toInt()] + val high = sorted[((sorted.size - 1) * .98f).toInt()] + require(high - low > 1e-5f) { "Cannot distinguish depth in this scene. Try a textured subject and background." } + return RelativeDepth(width, height, FloatArray(raw.size) { ((raw[it] - low) / (high - low)).coerceIn(0f, 1f) }) + } + } +} + +/** CPU background-only blur. Foreground colour is excluded from the blur kernel to limit halos. */ +object DepthBokeh { + fun amount(depth: Float, focus: Float): Float { + val t = ((focus - depth - .04f) / .25f).coerceIn(0f, 1f) + return t * t * (3f - 2f * t) + } + + fun render(pixels: IntArray, width: Int, height: Int, depth: RelativeDepth, + focusX: Float, focusY: Float, strength: Int): IntArray { + require(width > 0 && height > 0 && pixels.size == width * height && strength in 0..100) + 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), + (i / width).toFloat() / (height - 1).coerceAtLeast(1)), focus) } + 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 -> + blur(FloatArray(pixels.size) { ((pixels[it] ushr shift) and 255) * weights[it] }, width, height, radius) + } + return IntArray(pixels.size) { i -> + val blend = weights[i] + var result = -0x1000000 + for (c in 0..2) { + val shift = (2 - c) * 8 + val original = (pixels[i] ushr shift) and 255 + val softened = if (blurredWeight[i] > .001f) channels[c][i] / blurredWeight[i] else original.toFloat() + result = result or ((original * (1 - blend) + softened * blend).roundToInt().coerceIn(0, 255) shl shift) + } + result + } + } + + // Three separable box passes approximate a Gaussian, with bounded O(width * height) work. + private fun blur(input: FloatArray, width: Int, height: Int, radius: Int): FloatArray { + var source = input + repeat(3) { + val horizontal = FloatArray(input.size) + for (y in 0 until height) { + var sum = 0f + for (x in 0..minOf(radius, width - 1)) sum += source[y * width + x] + for (x in 0 until width) { + horizontal[y * width + x] = sum / (minOf(width - 1, x + radius) - maxOf(0, x - radius) + 1) + if (x - radius >= 0) sum -= source[y * width + x - radius] + if (x + radius + 1 < width) sum += source[y * width + x + radius + 1] + } + } + val vertical = FloatArray(input.size) + for (x in 0 until width) { + var sum = 0f + for (y in 0..minOf(radius, height - 1)) sum += horizontal[y * width + x] + for (y in 0 until height) { + vertical[y * width + x] = sum / (minOf(height - 1, y + radius) - maxOf(0, y - radius) + 1) + if (y - radius >= 0) sum -= horizontal[(y - radius) * width + x] + if (y + radius + 1 < height) sum += horizontal[(y + radius + 1) * width + x] + } + } + source = vertical + } + return source + } +} diff --git a/app/src/main/java/dev/halcamera/ui/AppBokehEditor.kt b/app/src/main/java/dev/halcamera/ui/AppBokehEditor.kt new file mode 100644 index 00000000..3f8dc072 --- /dev/null +++ b/app/src/main/java/dev/halcamera/ui/AppBokehEditor.kt @@ -0,0 +1,196 @@ +package dev.halcamera.ui + +import android.app.Activity +import android.app.Dialog +import android.content.res.ColorStateList +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import android.graphics.drawable.RippleDrawable +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.view.MotionEvent +import android.view.View +import android.view.WindowManager +import android.widget.LinearLayout +import android.widget.Button +import android.widget.SeekBar +import androidx.core.view.ViewCompat +import dev.halcamera.R +import dev.halcamera.camera.AppBokehProcessor +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger + +/** One captured image, one estimated map. All inference/render/save work stays off the UI thread. */ +class AppBokehEditor(private val activity: Activity, private val source: Uri, private val closed: () -> Unit) { + private val processor = AppBokehProcessor(activity) + private val worker = Executors.newSingleThreadExecutor() + private val main = Handler(Looper.getMainLooper()) + private val revision = AtomicInteger() + @Volatile private var alive = true + private var saving = false + private var scene: AppBokehProcessor.Scene? = null + private var result: Bitmap? = null + private var depthImage: Bitmap? = null + private var focusX = .5f + private var focusY = .5f + private var strength = 65 + private var display = 0 + private val dialog = Dialog(activity) + private val info = Look.text(activity, "Estimating depth on this device…", 13, Look.onDarkMuted).apply { minLines = 2 } + private val picture = Picture(activity) { x, y -> focusX = x; focusY = y; render() } + private val save = Look.galleryButton(activity, "Save Bokeh", true) { saveCopy() } + private val close = IconButton(activity, R.drawable.ic_action_close, "Close App Bokeh", dark = true) { if (!saving) dismiss() } + private val viewButtons = mutableListOf