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 @@ -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를 입력으로 사용하지 않습니다.
Original file line number Diff line number Diff line change
@@ -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 높이를 제공한다.
5 changes: 5 additions & 0 deletions .omm/overall-architecture/camera-engines/app-bokeh/meta.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
kind: nested-class
title: app-bokeh
children: []
parentPath:
- camera-engines
6 changes: 4 additions & 2 deletions .omm/overall-architecture/camera-engines/description.md
Original file line number Diff line number Diff line change
@@ -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 요소에 설명합니다.
1 change: 1 addition & 0 deletions .omm/overall-architecture/camera-engines/meta.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ children:
- camera2-engine
- depth-engine
- portrait-engine
- app-bokeh
- camerax-engine
- endpoint-model
- endpoint-resolver
Expand Down
Original file line number Diff line number Diff line change
@@ -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를 종료합니다.
2 changes: 2 additions & 0 deletions .omm/state-transitions/description.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 요소에 있습니다.
8 changes: 4 additions & 4 deletions .omm/state-transitions/engine-lifecycle/description.md
Original file line number Diff line number Diff line change
@@ -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 요소에 설명합니다.
Original file line number Diff line number Diff line change
@@ -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 복귀와 다른 해상도 선택을 허용합니다.
2 changes: 2 additions & 0 deletions .omm/ui-tool-handoff/description.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 모드를 종료합니다.
35 changes: 35 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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")
Expand Down
87 changes: 87 additions & 0 deletions app/src/androidTest/java/dev/halcamera/camera/AppBokehChecks.kt
Original file line number Diff line number Diff line change
@@ -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 <T> main(block: () -> T): T {
val result = java.util.concurrent.atomic.AtomicReference<Result<T>>()
test.runOnMainSync { result.set(runCatching(block)) }
return result.get().getOrThrow()
}
fun nodes(view: android.view.View): Sequence<android.view.View> = 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<android.widget.Button>().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<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)
}
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 }}"
}
}
Loading
Loading