Skip to content

Commit 77808af

Browse files
authored
Merge pull request #9 from wordingone/codex/install-and-test-packages-in-lumenio
Add Android mobile module with CameraX, MediaPipe, and Filament
2 parents 124af79 + ce3d8fe commit 77808af

13 files changed

Lines changed: 379 additions & 0 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
name: android-build
2+
on: [push, pull_request]
3+
jobs:
4+
build:
5+
runs-on: ubuntu-latest
6+
steps:
7+
- uses: actions/checkout@v4
8+
- uses: actions/setup-java@v4
9+
with: { distribution: 'temurin', java-version: '17' }
10+
- name: Gradle build
11+
uses: gradle/gradle-build-action@v3
12+
with:
13+
arguments: :apps:mobile:android:app:assembleDebug

LumenIO/README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# LumenIO – Mobile (Android) Slice
2+
3+
**What:** Device-responsive 3D platform (parent system). This slice delivers **mobile**: CameraX + MediaPipe Hand Landmarker → gesture FSM → Filament PBR renderer.
4+
5+
## Build
6+
- Android Studio Giraffe+ (JDK 17).
7+
- Put a hand landmark model into `apps/mobile/android/app/src/main/assets/hand_landmarker.task` (see AI Edge Hand Landmarker guide).
8+
- `./gradlew :apps:mobile:android:app:assembleDebug`
9+
10+
## Run
11+
Launch on a device (front camera). Pinch → rotate demo applies Y-rotation to the loaded glTF model.
12+
13+
## Deps (sources)
14+
- CameraX 1.4.2 (stable matrix & dependency snippet).
15+
- MediaPipe Tasks – Hand Landmarker (`com.google.mediapipe:tasks-vision`).
16+
- Filament 1.57.1 (`filament-android`, `gltfio-android`, `filament-utils-android`).
17+
Docs cited in code comments.
18+
19+
---
20+
21+
What’s next (tight)
22+
23+
1. Replace ImageProxy.toBitmap() with proper YUV→RGB, or feed MPImage from ImageProxy without intermediate Bitmap.
24+
25+
2. Add IBL/lighting in FilamentRenderer (KTX environment, skybox), and a proper frame loop / surface resize handling.
26+
27+
3. Emit gesture_event JSON to a bus; store deterministic replays; unit tests for FSM thresholds.
28+
29+
4. Add depth scaling (z from world landmarks) to improve rotate/scale stability.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
plugins {
2+
id("com.android.application")
3+
kotlin("android")
4+
}
5+
6+
android {
7+
namespace = "io.lumen.mobile"
8+
compileSdk = 34
9+
defaultConfig {
10+
applicationId = "io.lumen.mobile"
11+
minSdk = 23
12+
targetSdk = 34
13+
versionCode = 1
14+
versionName = "0.1.0"
15+
}
16+
buildTypes { release { isMinifyEnabled = false } }
17+
compileOptions { sourceCompatibility = JavaVersion.VERSION_17; targetCompatibility = JavaVersion.VERSION_17 }
18+
kotlinOptions { jvmTarget = "17" }
19+
}
20+
21+
dependencies {
22+
// CameraX (use stable; RC exists but keep prod-stable) — per official table
23+
val camerax = "1.4.2" // https://developer.android.com/jetpack/androidx/releases/camera
24+
implementation("androidx.camera:camera-core:$camerax")
25+
implementation("androidx.camera:camera-camera2:$camerax")
26+
implementation("androidx.camera:camera-lifecycle:$camerax")
27+
implementation("androidx.camera:camera-view:$camerax")
28+
29+
// MediaPipe Tasks – Hand Landmarker (Android)
30+
implementation("com.google.mediapipe:tasks-vision:latest.release") // https://ai.google.dev/edge/mediapipe/solutions/vision/hand_landmarker/android
31+
32+
// Filament (PBR renderer + glTF loader + utils) — latest release 1.57.1
33+
val filament = "1.57.1" // https://mvnrepository.com/artifact/com.google.android.filament/filament-android
34+
implementation("com.google.android.filament:filament-android:$filament")
35+
implementation("com.google.android.filament:gltfio-android:$filament")
36+
implementation("com.google.android.filament:filament-utils-android:$filament")
37+
38+
// KotlinX
39+
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1")
40+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
2+
package="io.lumen.mobile">
3+
<uses-permission android:name="android.permission.CAMERA"/>
4+
<uses-feature android:name="android.hardware.camera.front"/>
5+
<application android:allowBackup="true" android:label="LumenIO Mobile" android:supportsRtl="true">
6+
<activity android:name=".MainActivity" android:exported="true">
7+
<intent-filter><action android:name="android.intent.action.MAIN"/><category android:name="android.intent.category.LAUNCHER"/></intent-filter>
8+
</activity>
9+
</application>
10+
</manifest>

LumenIO/apps/mobile/android/app/src/main/assets/models/sample.bin

Whitespace-only changes.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"asset": { "version": "2.0" },
3+
"scenes": [ { "nodes": [] } ],
4+
"nodes": []
5+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package io.lumen.mobile
2+
3+
import android.content.Context
4+
import android.view.Surface
5+
import com.google.android.filament.*
6+
import com.google.android.filament.utils.*
7+
import com.google.android.filament.gltfio.*
8+
import java.nio.ByteBuffer
9+
10+
class FilamentRenderer(private val ctx: Context) {
11+
private lateinit var engine: Engine
12+
private lateinit var swapChain: SwapChain
13+
private lateinit var renderer: Renderer
14+
private lateinit var scene: Scene
15+
private lateinit var view: View
16+
private lateinit var camera: Camera
17+
private lateinit var assetLoader: AssetLoader
18+
private lateinit var resourceLoader: ResourceLoader
19+
private lateinit var asset: FilamentAsset
20+
21+
fun init(surface: Surface, width: Int, height: Int) {
22+
engine = Engine.create()
23+
swapChain = engine.createSwapChain(surface)
24+
renderer = engine.createRenderer()
25+
scene = engine.createScene()
26+
view = engine.createView().apply { this.scene = scene }
27+
camera = engine.createCamera(EntityManager.get().create()).apply {
28+
setProjection(45.0, width.toDouble()/height, 0.05, 100.0, Camera.Fov.VERTICAL)
29+
lookAt(0.0, 0.0, 3.0, 0.0, 0.0, 0.0)
30+
}
31+
view.camera = camera
32+
33+
assetLoader = AssetLoader(engine, UbershaderProvider(engine), EntityManager.get())
34+
resourceLoader = ResourceLoader(engine, true, true)
35+
}
36+
37+
fun loadGltfFromAssets(path: String) {
38+
val buf = ctx.assets.open(path).readBytes()
39+
asset = assetLoader.createAssetFromJson(ByteBuffer.wrap(buf))
40+
resourceLoader.loadResources(asset)
41+
scene.addEntities(asset.entities)
42+
// basic IBL/lighting skipped for brevity; add filament-utils helpers in production
43+
}
44+
45+
fun applyTransform(tx: FloatArray) {
46+
// tx = 4x4 column-major matrix
47+
val root = asset.root
48+
if (root != 0) TransformManager.getInstance().apply {
49+
val inst = getInstance(root)
50+
setTransform(inst, tx)
51+
}
52+
}
53+
54+
fun renderFrame() {
55+
if (renderer.beginFrame(swapChain)) {
56+
renderer.render(view)
57+
renderer.endFrame()
58+
}
59+
}
60+
61+
fun destroy() {
62+
// Destroy all Filament objects (omitted for brevity)
63+
}
64+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package io.lumen.mobile
2+
3+
enum class GState { IDLE, PINCH, DRAG, ROTATE, SCALE }
4+
5+
data class GestureParams(
6+
val pinch: Float, val x: Float, val y: Float, val angle: Float, val span: Float
7+
)
8+
9+
class GestureFSM(private val thresh: Float = 0.8f) {
10+
var state = GState.IDLE; private set
11+
private var startX=0f; private var startY=0f; private var startAngle=0f; private var startSpan=0f
12+
13+
fun step(p: GestureParams): String = when(state) {
14+
GState.IDLE -> if (p.pinch >= thresh) { state = GState.PINCH; startX=p.x; startY=p.y; startAngle=p.angle; startSpan=p.span; "pinch:start" } else "idle"
15+
GState.PINCH -> when {
16+
p.pinch < thresh -> { state = GState.IDLE; "pinch:cancel" }
17+
hypot(p.x-startX, p.y-startY) > 0.04f -> { state = GState.DRAG; "drag:start" }
18+
kotlin.math.abs(p.angle-startAngle) > 0.15f -> { state = GState.ROTATE; "rotate:start" }
19+
kotlin.math.abs(p.span-startSpan) > 0.05f -> { state = GState.SCALE; "scale:start" }
20+
else -> "pinch:hold"
21+
}
22+
GState.DRAG -> if (p.pinch < thresh) { state = GState.IDLE; "drag:end" } else "drag:move"
23+
GState.ROTATE -> if (p.pinch < thresh) { state = GState.IDLE; "rotate:end" } else "rotate:move"
24+
GState.SCALE -> if (p.pinch < thresh) { state = GState.IDLE; "scale:end" } else "scale:move"
25+
}
26+
27+
private fun hypot(dx: Float, dy: Float) = kotlin.math.sqrt(dx*dx + dy*dy)
28+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package io.lumen.mobile
2+
3+
import android.content.Context
4+
import android.os.SystemClock
5+
import com.google.mediapipe.tasks.vision.handlandmarker.HandLandmarker
6+
import com.google.mediapipe.tasks.vision.core.RunningMode
7+
import com.google.mediapipe.tasks.components.containers.Category
8+
import com.google.mediapipe.framework.image.BitmapImageBuilder
9+
10+
class HandLandmarkerHelper(
11+
private val context: Context,
12+
private val modelAssetPath: String = "hand_landmarker.task", // put model into assets
13+
private val maxHands: Int = 1,
14+
private val minDet: Float = 0.5f,
15+
private val minTrack: Float = 0.5f,
16+
private val minPresence: Float = 0.5f,
17+
private val onResult: (HandResult) -> Unit,
18+
private val onError: (Throwable) -> Unit
19+
) {
20+
private var landmarker: HandLandmarker? = null
21+
22+
fun setup() {
23+
val base = com.google.mediapipe.tasks.core.BaseOptions.builder().setModelAssetPath(modelAssetPath).build()
24+
val opts = HandLandmarker.HandLandmarkerOptions.builder()
25+
.setBaseOptions(base)
26+
.setRunningMode(RunningMode.LIVE_STREAM)
27+
.setNumHands(maxHands)
28+
.setMinHandDetectionConfidence(minDet)
29+
.setMinTrackingConfidence(minTrack)
30+
.setMinHandPresenceConfidence(minPresence)
31+
.setResultListener { res, inputImg ->
32+
val handed = res.handedness().firstOrNull()?.firstOrNull() ?: Category.create("Unknown", 0, 0f)
33+
val pts = res.landmarks().firstOrNull()?.map { floatArrayOf(it.x(), it.y(), it.z()) } ?: emptyList()
34+
onResult(HandResult(handed.categoryName(), pts))
35+
}
36+
.setErrorListener { e -> onError(e) }
37+
.build()
38+
landmarker = HandLandmarker.createFromOptions(context, opts)
39+
}
40+
41+
fun process(bitmap: android.graphics.Bitmap) {
42+
val mpImg = BitmapImageBuilder(bitmap).build()
43+
val ts = SystemClock.uptimeMillis()
44+
landmarker?.detectAsync(mpImg, ts)
45+
}
46+
47+
data class HandResult(val handedness: String, val points: List<FloatArray>)
48+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package io.lumen.mobile
2+
3+
import android.graphics.Bitmap
4+
import android.os.Bundle
5+
import android.util.Size
6+
import android.view.SurfaceView
7+
import android.widget.FrameLayout
8+
import androidx.activity.ComponentActivity
9+
import androidx.camera.core.*
10+
import androidx.camera.lifecycle.ProcessCameraProvider
11+
import androidx.camera.view.PreviewView
12+
import androidx.core.content.ContextCompat
13+
import java.util.concurrent.Executors
14+
15+
class MainActivity : ComponentActivity() {
16+
private lateinit var previewView: PreviewView
17+
private lateinit var overlay: OverlayView
18+
private lateinit var renderer: FilamentRenderer
19+
private val exec = Executors.newSingleThreadExecutor()
20+
private lateinit var hand: HandLandmarkerHelper
21+
private val fsm = GestureFSM()
22+
23+
override fun onCreate(savedInstanceState: Bundle?) {
24+
super.onCreate(savedInstanceState)
25+
previewView = PreviewView(this)
26+
overlay = OverlayView(this)
27+
val root = FrameLayout(this).apply { addView(previewView); addView(overlay) }
28+
setContentView(root)
29+
30+
hand = HandLandmarkerHelper(this, onResult = { r ->
31+
overlay.points = r.points; overlay.postInvalidate()
32+
// simple pinch heuristic: use landmark 4 (thumb tip) & 8 (index tip)
33+
val pinch = r.points.takeIf { it.size >= 9 }?.let {
34+
val d = hypot(it[4][0]-it[8][0], it[4][1]-it[8][1])
35+
(1.0f - d).coerceIn(0f,1f)
36+
} ?: 0f
37+
val ev = fsm.step(GestureParams(pinch, 0.5f, 0.5f, 0f, 0f))
38+
if (ev.startsWith("drag:") || ev.startsWith("rotate:") || ev.startsWith("scale:")) {
39+
// map to a transform; here we rotate around Y slightly as demo
40+
val angle = (System.nanoTime() % 1_000_000_000L) / 1e9f
41+
val mat = yRotation(angle)
42+
renderer.applyTransform(mat)
43+
}
44+
}, onError = { e -> e.printStackTrace() })
45+
hand.setup()
46+
47+
startCamera()
48+
setupRenderer()
49+
}
50+
51+
private fun setupRenderer() {
52+
val surfaceView = SurfaceView(this)
53+
(previewView.parent as FrameLayout).addView(surfaceView)
54+
surfaceView.holder.addCallback(object: android.view.SurfaceHolder.Callback {
55+
override fun surfaceCreated(holder: android.view.SurfaceHolder) {
56+
renderer = FilamentRenderer(this@MainActivity)
57+
renderer.init(holder.surface, surfaceView.width.coerceAtLeast(1), surfaceView.height.coerceAtLeast(1))
58+
renderer.loadGltfFromAssets("models/sample.gltf")
59+
surfaceView.post(object: Runnable { override fun run() { renderer.renderFrame(); surfaceView.post(this) }})
60+
}
61+
override fun surfaceChanged(h: android.view.SurfaceHolder, f: Int, w: Int, hgt: Int) {}
62+
override fun surfaceDestroyed(h: android.view.SurfaceHolder) { /* destroy renderer */ }
63+
})
64+
}
65+
66+
private fun startCamera() {
67+
val providerFuture = ProcessCameraProvider.getInstance(this)
68+
providerFuture.addListener({
69+
val provider = providerFuture.get()
70+
val preview = Preview.Builder().build().also { it.setSurfaceProvider(previewView.surfaceProvider) }
71+
val analysis = ImageAnalysis.Builder().setTargetResolution(Size(640,480)).setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build()
72+
analysis.setAnalyzer(exec) { img ->
73+
val bmp = img.toBitmap() ?: return@setAnalyzer
74+
hand.process(bmp)
75+
img.close()
76+
}
77+
provider.unbindAll()
78+
provider.bindToLifecycle(this, CameraSelector.DEFAULT_FRONT_CAMERA, preview, analysis)
79+
}, ContextCompat.getMainExecutor(this))
80+
}
81+
82+
private fun ImageProxy.toBitmap(): Bitmap? {
83+
val plane = planes.firstOrNull() ?: return null
84+
// Convert YUV→ARGB fast path for demo; production should use RenderScript/ScriptIntrinsicYuvToRGB or GPU path
85+
return Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888).also {
86+
// Simplified; replace with a correct YUV→RGB conversion
87+
}
88+
}
89+
90+
private fun hypot(dx: Float, dy: Float) = kotlin.math.sqrt(dx*dx + dy*dy)
91+
private fun yRotation(theta: Float): FloatArray {
92+
val c = kotlin.math.cos(theta); val s = kotlin.math.sin(theta)
93+
return floatArrayOf( c,0f, s,0f, 0f,1f,0f,0f, -s,0f,c,0f, 0f,0f,0f,1f )
94+
}
95+
}

0 commit comments

Comments
 (0)