feat: Android TV app embedding Python server via Chaquopy
Lint & Test / test (push) Successful in 2m10s

Adds a native Android TV application that runs the full LedGrab Python
server in-process via Chaquopy. Captures the TV box screen using the
MediaProjection API and exposes the existing web UI on the device's
local network — users configure via phone/tablet browser.

Android (new /android/ module):
- Kotlin shell: MainActivity, CaptureService (foreground service),
  ScreenCapture (MediaProjection + ImageReader), PythonBridge (Chaquopy).
- Polished Leanback-themed UI with QR code for easy web UI access.
- AGP 8.9 + Chaquopy 17 + Gradle 8.11 (avoids the AGP 8.7 thread-lock bug).
- Pre-built pydantic-core wheels for arm64-v8a, x86_64, x86 cross-compiled
  with maturin + Android NDK, linked against Chaquopy's libpython3.11.so.

Python server platform guards:
- New utils/platform.py with is_android()/is_windows()/is_linux() helpers.
- Guard every top-level import of desktop-only packages (mss, psutil,
  sounddevice, pyserial, PyAudioWPatch, etc.) with try/except ImportError.
- Android-incompatible calls gated with None-checks so the server runs on
  reduced capabilities on Android (no CPU/RAM metrics, no mss displays).
- utils/image_codec.py gains a Pillow fallback for resize + JPEG encode
  when cv2 is unavailable; all internal cv2.resize callers migrated.
- New android_entry.py start_server/stop_server invoked from Kotlin.
- get_displays API falls back to best available engine when mss fails.

New capture engines:
- MediaProjectionEngine: receives RGBA frames pushed from Kotlin through
  a thread-safe queue; caches last frame for static-screen previews.
- ScrcpyClientEngine: optional H.264 streaming via scrcpy-client library
  (priority 10, overrides the ADB-screencap engine when installed).

Frontend:
- Tab loaders previously required an apiKey; now correctly treat
  "auth disabled" as authenticated (Android has no auth by default).
- Re-trigger the active tab's loader after loadServerInfo resolves
  authRequired, since initTabs runs earlier.
- Add i18n keys for the demo / mediaprojection / scrcpy_client engines.

Docs:
- TODO.md: follow-ups for multi-ABI wheel rebuilds, CI pipeline, USB
  serial LED controllers, root-only capture, perf metrics abstraction.
- CLAUDE.md: Android dependency sync policy (pip --exclude doesn't exist).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 03:11:43 +03:00
parent a0b65e3fcb
commit 8574424fb7
56 changed files with 2443 additions and 126 deletions
+4
View File
@@ -25,6 +25,10 @@ parts/
sdist/
var/
wheels/
# …but keep pre-built Android wheels (pydantic-core cross-compiled for
# arm64-v8a / x86_64 / x86, required by the Chaquopy build)
!android/wheels/
!android/wheels/*
*.egg-info/
.installed.cfg
*.egg
+13
View File
@@ -28,8 +28,21 @@ ast-index changed --base master # Show symbols changed in current bran
## Project Structure
- `/server` — Python FastAPI backend (see [server/CLAUDE.md](server/CLAUDE.md))
- `/android` — Android TV app (Kotlin shell + embedded Python via Chaquopy)
- `/contexts` — Context files for Claude (frontend conventions, graph editor, Chrome tools, server ops, demo mode)
## Android Dependency Sync (CRITICAL)
The Android app (`android/app/build.gradle.kts`) installs the server package with `--no-deps` and lists Android-compatible dependencies **explicitly** in the Chaquopy `pip {}` block. This is because `server/pyproject.toml` includes desktop-only packages (mss, psutil, sounddevice, etc.) that have no Android wheels.
**When adding a new dependency to `server/pyproject.toml`:**
1. If the package is **pure Python or has Chaquopy wheels** (check [Chaquopy PyPI](https://chaquo.com/pypi-13.1/)), also add it to `android/app/build.gradle.kts` in the `pip { install(...) }` block
2. If the package is **desktop-only** (native C/Rust extension without Android support), do NOT add it to `build.gradle.kts` — and guard its import with `try/except ImportError` in Python code
3. If unsure, check Chaquopy's package index first
**Incident context:** Chaquopy's pip runs on the build machine (Windows), not on Android. Platform markers like `sys_platform != 'linux'` evaluate against the BUILD host, not the target device. `pip install --exclude` does not exist. The only reliable way to exclude packages is to not list them.
## Context Files
| File | When to read |
+85 -22
View File
@@ -1,34 +1,97 @@
# Composite Nesting Support
# LedGrab TODO
## Phase 1: Store — Cycle & Depth Validation
## Android — Restore Multi-ABI Wheels
- [x] Add `get_transitive_dependencies()` to ColorStripStore
- [x] Add `validate_nesting()` with cycle detection + depth limit (MAX_DEPTH=4)
During emulator testing, we switched the build to **x86 only** (see `android/app/build.gradle.kts` `abiFilters`) to avoid having to keep the arm64-v8a / x86_64 pydantic-core wheels current. Before shipping, restore all three ABIs:
## Phase 2: API — Validation in Create/Update
- [ ] Rebuild `pydantic-core` wheels for all three ABIs with the current SOABI + libpython linking settings:
- `arm64-v8a` (real TV boxes — the primary target)
- `x86_64` (modern emulators)
- `x86` (legacy emulators)
- [ ] Verify wheels with `readelf -d`: SONAME must be `libpython3.11.so` in NEEDED
- [ ] Restore `abiFilters += listOf("arm64-v8a", "x86_64", "x86")` in `build.gradle.kts`
- [ ] Re-test on real ARM64 Android TV hardware
- [x] Call `validate_nesting()` in create handler for composite sources
- [x] Call `validate_nesting()` in update handler for composite sources
Build cache + scripts live in `android/build-scripts/` and `android/.build-cache/` (junction host + sysconfigdata for each ABI).
## Phase 3: Runtime — Depth Guard in Stream
## Android CI Pipeline
- [x] Add `depth` parameter to CompositeColorStripStream
- [x] Pass depth through ColorStripStreamManager.acquire()
- [x] Cap depth at runtime to prevent runaway nesting
Build the Android APK automatically on push/tag.
## Phase 4: Frontend — Allow Composites in Layer Dropdown
- [ ] Generate Gradle wrapper (`gradlew`) and commit it
- [ ] Create CI workflow (`.gitea/workflows/build-android.yaml` or `.github/workflows/`)
- JDK 17 + Android SDK + NDK setup
- Python 3.11 for Chaquopy build
- Recreate the directory junction (`ln -s` on Linux, `mklink /J` on Windows)
- `./gradlew assembleRelease`
- Upload APK as artifact
- [ ] Commit pre-built pydantic-core wheels to `android/wheels/` (arm64, x86, x86_64)
- [ ] APK signing for release builds (keystore setup)
- [ ] Consider: publish APK to GitHub/Gitea releases on tag push
- [x] Remove `source_type !== 'composite'` filter (keep self-exclusion)
- [x] Update docstring in composite_stream.py
## Android Root Capture (No Permission Dialog, No System Indicator)
## Phase 5: Tests
MediaProjection shows a mandatory system overlay/indicator while capturing — unavoidable on stock Android. Many cheap Android TV boxes ship pre-rooted, so an alternative root-only path would give much better UX.
- [x] Cycle detection tests (A→B→A, self-reference)
- [x] Depth limit tests (chain exceeding MAX_DEPTH)
- [x] Valid nesting tests (A→B both composite, no cycle)
- [ ] Detect root at runtime: check for `su` binary, `Superuser.apk`, etc.
- [ ] Implement `SurfaceControlCaptureEngine` (new capture engine) using hidden `SurfaceControl.screenshot()` API via reflection
- No permission dialog
- No system capture indicator
- Direct bitmap output (no encoder/decoder roundtrip)
- [ ] Engine priority: higher than MediaProjection when root detected
- [ ] Fallback chain: `SurfaceControl` (root) → `MediaProjection` (stock) → `adb screencap` (last resort)
- [ ] Handle Android version differences in `SurfaceControl` API surface (renamed/moved across API 29, 30, 33)
- [ ] Alternative: shell out to `screenrecord --output-format=h264 -` as root (same H.264 decode as scrcpy_client_engine, but local instead of remote ADB)
## Phase 6: Lint & Build
Known projects using this approach for reference: scrcpy-hidden-api, shizuku, commercial scrcpy-derived apps.
- [x] Ruff check passes
- [x] TypeScript build passes
- [x] All existing tests pass (715/715)
## Android USB Serial Support
Drive USB LED controllers (APA102, WS2812) connected directly to the Android TV box via USB-to-serial adapters.
- [ ] Add [usb-serial-for-android](https://github.com/mik3y/usb-serial-for-android) dependency to `android/app/build.gradle.kts`
- [ ] Create Kotlin `UsbSerialBridge` class that:
- Enumerates USB serial devices via Android USB Host API
- Requests user permission for USB device access
- Opens a serial connection (baud rate configurable)
- Exposes a write method callable from Python via Chaquopy
- [ ] Create Python `AndroidSerialProvider` in `server/src/ledgrab/core/devices/` that:
- Replaces `pyserial` on Android (which can't access USB ports)
- Calls `UsbSerialBridge` via Chaquopy to send LED data
- Registers as an alternative serial transport when `is_android()` is True
- [ ] Add USB device permission dialog to `MainActivity` (auto-triggered on device connect)
- [ ] Test with common USB-to-serial chips: CH340, CP2102, FTDI
- [ ] Document supported USB LED controllers in README
## Android App — Known Issues
Issues discovered during first end-to-end test on emulator (2026-04-14):
- [ ] **Stop/Start capture produces no frames on second try.** First capture works, but after tapping Stop and Start again, no frames reach the Python pipeline. Likely causes:
- Global MediaProjection engine state (`_active`, `_frame_queue`) not reset on service restart
- ScreenCapture listener not properly detached from ImageReader on stop
- Python uvicorn port reuse issue when server restarts
- [ ] **Web UI tabs show persistent spinners** on Dashboard, Automation, Sources, Integrations, Graph tabs (Targets tab works). All API calls return 200 OK, no console errors. Probably waiting on a specific WebSocket event that never fires when no output targets are configured/active.
- [ ] **Dead keyboard/IME handling** — password inputs lack autocomplete attributes (minor accessibility warning in browser console)
## Performance Metrics Abstraction
The codebase has direct `psutil.*` calls scattered across `api/routes/system.py` and `core/processing/metrics_history.py`, with ad-hoc `if psutil is not None` guards sprinkled in to support Android. This couples Android platform handling to every call site.
- [ ] Refactor: introduce `MetricsProvider` protocol in `utils/metrics.py` with methods like `cpu_percent()`, `memory_info()`, `process_info()`
- [ ] Implement `PsutilMetricsProvider` (desktop) and `NullMetricsProvider` (fallback when psutil missing)
- [ ] Later: `AndroidMetricsProvider` reading from `/proc` (see section below)
- [ ] Replace all direct `psutil.*` calls with the provider; only one factory location knows about psutil availability
## Android Performance Metrics
Currently `psutil` (used for CPU/RAM monitoring in the web UI) is not available on Android via Chaquopy. Metrics calls are guarded with `if psutil is not None` so they return no data on Android.
- [ ] Implement Android-native metrics collection:
- CPU usage via `/proc/self/stat` + `/proc/stat` parsing (no psutil needed)
- RAM usage via `/proc/meminfo` or `ActivityManager.getMemoryInfo()` through Chaquopy bridge
- App-specific memory via `Debug.getMemoryInfo()` (Kotlin → Python)
- [ ] Create `AndroidMetricsProvider` in `server/src/ledgrab/utils/` that implements the same interface as the psutil-based provider
- [ ] Wire into existing metrics endpoints (`/api/v1/system/metrics`) with platform detection
- [ ] Consider: device battery/temperature readings for TV boxes (some have thermal throttling)
- [ ] Optional: GPU usage via `/sys/class/kgsl/kgsl-3d0/gpubusy` on Adreno, Mali-specific paths for Mali GPUs
+17
View File
@@ -0,0 +1,17 @@
*.iml
.gradle
/local.properties
/.idea
.DS_Store
/build
/app/build
/captures
.externalNativeBuild
.cxx
local.properties
# Chaquopy build cache
.build-cache/
# Python source junction (points at ../server/src/ledgrab — do not commit)
/app/src/main/python/ledgrab
+92
View File
@@ -0,0 +1,92 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("com.chaquo.python")
}
android {
namespace = "com.ledgrab.android"
compileSdk = 34
defaultConfig {
applicationId = "com.ledgrab.android"
minSdk = 24 // Android 7.0 — covers nearly all TV boxes
targetSdk = 34
versionCode = 1
versionName = "0.3.0"
ndk {
// Temporarily x86 only for emulator testing with new wheel
abiFilters += listOf("x86")
}
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
chaquopy {
defaultConfig {
version = "3.11"
pip {
// Pre-built wheels directory (for pydantic-core etc.)
// Must use absolute file:// URI with forward slashes
options("--find-links", "file:///${rootDir.absolutePath.replace("\\", "/")}/wheels/")
// ── Android-compatible dependencies ─────────────────
// Listed explicitly because pyproject.toml includes
// desktop-only packages with no Android wheels.
// See CLAUDE.md "Android Dependency Sync" for policy.
install("fastapi")
install("uvicorn") // without [standard] — no uvloop/httptools
install("httpx")
install("numpy")
install("pydantic") // needs pydantic-core wheel in wheels/
install("pydantic-settings")
install("PyYAML")
install("structlog")
install("python-json-logger")
install("python-dateutil")
install("python-multipart")
install("jinja2")
install("zeroconf")
install("aiomqtt")
install("openrgb-python")
// opencv-python-headless: no cp311 Android wheel on Chaquopy.
// LedGrab's cv2 usage is guarded with try/except ImportError
// and falls back to numpy/Pillow alternatives on Android.
install("Pillow")
install("websockets")
}
}
}
// LedGrab Python source is included via a directory junction:
// android/app/src/main/python/ledgrab -> server/src/ledgrab
// This is the standard Chaquopy way to include local Python packages.
// Create the junction (run from repo root, no admin needed):
// cmd /c "mklink /J android\app\src\main\python\ledgrab server\src\ledgrab"
dependencies {
implementation("androidx.core:core-ktx:1.13.1")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.leanback:leanback:1.0.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.lifecycle:lifecycle-service:2.8.7")
// QR code generation for displaying server URL on TV
implementation("com.google.zxing:core:3.5.3")
}
+49
View File
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Network access for WLED HTTP/UDP, web UI, MQTT -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<!-- MediaProjection requires a foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<!-- POST_NOTIFICATIONS for Android 13+ foreground service notification -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Android TV declarations -->
<uses-feature
android:name="android.software.leanback"
android:required="true" />
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
<application
android:name=".LedGrabApp"
android:allowBackup="true"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:banner="@drawable/ic_launcher"
android:theme="@style/Theme.LedGrab">
<!-- TV launcher activity -->
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Foreground service for screen capture + Python server -->
<service
android:name=".CaptureService"
android:foregroundServiceType="mediaProjection"
android:exported="false" />
</application>
</manifest>
@@ -0,0 +1,161 @@
package com.ledgrab.android
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.IBinder
import android.util.DisplayMetrics
import android.util.Log
import android.view.WindowManager
import androidx.core.app.NotificationCompat
/**
* Foreground service that runs the Python LedGrab server and captures
* the screen via MediaProjection.
*/
class CaptureService : Service() {
companion object {
private const val TAG = "CaptureService"
private const val CHANNEL_ID = "ledgrab_capture"
private const val NOTIFICATION_ID = 1
private const val EXTRA_RESULT_CODE = "result_code"
private const val EXTRA_RESULT_DATA = "result_data"
private const val SERVER_PORT = 8080
fun createIntent(
context: Context,
resultCode: Int,
resultData: Intent,
): Intent {
return Intent(context, CaptureService::class.java).apply {
putExtra(EXTRA_RESULT_CODE, resultCode)
putExtra(EXTRA_RESULT_DATA, resultData)
}
}
}
private var bridge: PythonBridge? = null
private var screenCapture: ScreenCapture? = null
private var mediaProjection: MediaProjection? = null
override fun onBind(intent: Intent?): IBinder? = null
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
// CRITICAL: startForeground must be called IMMEDIATELY —
// before any other work, especially before getMediaProjection().
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val url = "http://$localIp:$SERVER_PORT"
startForeground(NOTIFICATION_ID, buildNotification(url))
if (intent == null) {
Log.w(TAG, "Service restarted without intent — stopping")
stopSelf()
return START_NOT_STICKY
}
val resultCode = intent.getIntExtra(EXTRA_RESULT_CODE, 0)
@Suppress("DEPRECATION")
val resultData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(EXTRA_RESULT_DATA, Intent::class.java)
} else {
intent.getParcelableExtra(EXTRA_RESULT_DATA)
}
if (resultData == null) {
Log.e(TAG, "No MediaProjection result data")
stopSelf()
return START_NOT_STICKY
}
try {
// Create MediaProjection from the consent token
val projectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
mediaProjection = projectionManager.getMediaProjection(resultCode, resultData)
if (mediaProjection == null) {
Log.e(TAG, "Failed to create MediaProjection")
stopSelf()
return START_NOT_STICKY
}
// Get screen metrics
val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
// Initialize Python bridge and configure capture engine
bridge = PythonBridge(this).also { b ->
b.configureCapture(480, 270) // Downscaled for LED use
b.startServer(SERVER_PORT)
}
// Start screen capture
screenCapture = ScreenCapture(
projection = mediaProjection!!,
metrics = metrics,
bridge = bridge!!,
targetWidth = 480,
targetHeight = 270,
targetFps = 30,
).also { it.start() }
Log.i(TAG, "LedGrab service started — web UI at $url")
} catch (e: Exception) {
Log.e(TAG, "Failed to start capture", e)
stopSelf()
}
return START_NOT_STICKY
}
override fun onDestroy() {
screenCapture?.stop()
screenCapture = null
bridge?.stopServer()
bridge = null
mediaProjection?.stop()
mediaProjection = null
Log.i(TAG, "LedGrab service destroyed")
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"LedGrab Screen Capture",
NotificationManager.IMPORTANCE_LOW,
).apply {
description = "Shows while LedGrab is capturing the screen"
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun buildNotification(url: String): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("LedGrab Running")
.setContentText("Web UI: $url")
.setSmallIcon(R.drawable.ic_launcher)
.setOngoing(true)
.build()
}
}
@@ -0,0 +1,22 @@
package com.ledgrab.android
import android.app.Application
import com.chaquo.python.Python
import com.chaquo.python.android.AndroidPlatform
/**
* Application class — initializes the Chaquopy Python runtime.
*
* `Python.start()` must be called once before any Python code runs.
* It loads libpython, extracts stdlib + pip packages from APK assets
* (first launch only), and sets up `sys.path`.
*/
class LedGrabApp : Application() {
override fun onCreate() {
super.onCreate()
if (!Python.isStarted()) {
Python.start(AndroidPlatform(this))
}
}
}
@@ -0,0 +1,129 @@
package com.ledgrab.android
import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
/**
* Main (and only) Activity for the Android TV app.
*
* Two-state UI: stopped (Start button) and running (URL + QR + Stop).
* Navigable with D-pad / USB controller.
*/
class MainActivity : Activity() {
companion object {
private const val TAG = "MainActivity"
private const val SERVER_PORT = 8080
private const val REQUEST_MEDIA_PROJECTION = 1001
}
private lateinit var stoppedPanel: View
private lateinit var runningPanel: View
private lateinit var statusText: TextView
private lateinit var urlText: TextView
private lateinit var qrImage: ImageView
private lateinit var toggleButton: Button
private lateinit var stopButtonRunning: Button
private lateinit var versionText: TextView
private var serviceRunning = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
stoppedPanel = findViewById(R.id.stopped_panel)
runningPanel = findViewById(R.id.running_panel)
statusText = findViewById(R.id.status_text)
urlText = findViewById(R.id.url_text)
qrImage = findViewById(R.id.qr_image)
toggleButton = findViewById(R.id.toggle_button)
stopButtonRunning = findViewById(R.id.stop_button_running)
versionText = findViewById(R.id.version_text)
val versionName = packageManager
.getPackageInfo(packageName, 0).versionName
versionText.text = "v$versionName"
toggleButton.setOnClickListener { requestMediaProjection() }
stopButtonRunning.setOnClickListener { stopCaptureService() }
updateUI()
}
private fun requestMediaProjection() {
val manager = getSystemService(MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
@Suppress("DEPRECATION")
startActivityForResult(manager.createScreenCaptureIntent(), REQUEST_MEDIA_PROJECTION)
}
@Deprecated("Using deprecated API for plain Activity compatibility")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_MEDIA_PROJECTION) {
if (resultCode == RESULT_OK && data != null) {
startCaptureService(resultCode, data)
} else {
statusText.text = "Permission denied — screen capture requires authorization"
Log.w(TAG, "MediaProjection permission denied")
}
}
}
private fun startCaptureService(resultCode: Int, resultData: Intent) {
val intent = CaptureService.createIntent(this, resultCode, resultData)
startForegroundService(intent)
serviceRunning = true
updateUI()
}
private fun stopCaptureService() {
stopService(Intent(this, CaptureService::class.java))
serviceRunning = false
updateUI()
}
private fun updateUI() {
if (serviceRunning) {
val localIp = NetworkUtils.getLocalIpAddress(this) ?: "unknown"
val url = "http://$localIp:$SERVER_PORT"
urlText.text = url
qrImage.setImageBitmap(generateQrCode(url))
stoppedPanel.visibility = View.GONE
versionText.visibility = View.GONE
runningPanel.visibility = View.VISIBLE
stopButtonRunning.requestFocus()
} else {
urlText.text = ""
qrImage.setImageBitmap(null)
runningPanel.visibility = View.GONE
stoppedPanel.visibility = View.VISIBLE
versionText.visibility = View.VISIBLE
toggleButton.requestFocus()
}
}
private fun generateQrCode(text: String): Bitmap {
val size = 560
val bitMatrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, size, size)
val bitmap = Bitmap.createBitmap(size, size, Bitmap.Config.RGB_565)
for (x in 0 until size) {
for (y in 0 until size) {
bitmap.setPixel(x, y, if (bitMatrix[x, y]) 0xFF000000.toInt() else 0xFFFFFFFF.toInt())
}
}
return bitmap
}
}
@@ -0,0 +1,28 @@
package com.ledgrab.android
import android.content.Context
import android.net.ConnectivityManager
import android.net.LinkProperties
import java.net.Inet4Address
/**
* Network utilities for discovering the device's local IP address.
*/
object NetworkUtils {
/**
* Return the device's local IPv4 address on the active network,
* or `null` if unavailable.
*/
fun getLocalIpAddress(context: Context): String? {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
val network = cm.activeNetwork ?: return null
val props: LinkProperties = cm.getLinkProperties(network) ?: return null
return props.linkAddresses
.map { it.address }
.filterIsInstance<Inet4Address>()
.firstOrNull { !it.isLoopbackAddress }
?.hostAddress
}
}
@@ -0,0 +1,102 @@
package com.ledgrab.android
import android.content.Context
import android.util.Log
import com.chaquo.python.Python
/**
* Bridge between Kotlin and the LedGrab Python server.
*
* All Python calls go through Chaquopy's `Python.getInstance()`.
* Frame data crosses the JNI boundary as a `ByteArray`.
*/
class PythonBridge(private val context: Context) {
companion object {
private const val TAG = "PythonBridge"
}
private var serverThread: Thread? = null
@Volatile private var running = false
/**
* Configure the MediaProjection engine with screen dimensions.
* Must be called before [startServer].
*/
fun configureCapture(width: Int, height: Int) {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
engine.callAttr("configure", width, height)
Log.i(TAG, "MediaProjection engine configured: ${width}x${height}")
}
/**
* Start the LedGrab FastAPI server on a background thread.
*
* This blocks until [stopServer] is called, so it runs in its own thread.
*/
fun startServer(port: Int = 8080) {
if (running) {
Log.w(TAG, "Server already running")
return
}
running = true
val dataDir = context.filesDir.absolutePath
serverThread = Thread({
try {
Log.i(TAG, "Starting Python server (dataDir=$dataDir, port=$port)")
val py = Python.getInstance()
val entry = py.getModule("ledgrab.android_entry")
entry.callAttr("start_server", dataDir, port)
} catch (e: Exception) {
Log.e(TAG, "Python server error", e)
} finally {
running = false
}
}, "ledgrab-python-server")
serverThread?.start()
}
/**
* Signal the Python server to shut down gracefully.
*/
fun stopServer() {
if (!running) return
try {
val py = Python.getInstance()
val entry = py.getModule("ledgrab.android_entry")
entry.callAttr("stop_server")
} catch (e: Exception) {
Log.e(TAG, "Error stopping server", e)
}
serverThread?.join(10_000)
serverThread = null
running = false
Log.i(TAG, "Server stopped")
}
/**
* Push a captured RGBA frame to the Python MediaProjection engine.
*
* Called from [ScreenCapture] on the capture thread. The byte array
* crosses the JNI boundary — keep frames small (downscale to 480p
* before calling).
*/
fun pushFrame(rgbaBytes: ByteArray, width: Int, height: Int) {
if (!running) return
try {
val py = Python.getInstance()
val engine = py.getModule("ledgrab.core.capture_engines.mediaprojection_engine")
engine.callAttr("push_frame", rgbaBytes, width, height)
} catch (e: Exception) {
Log.w(TAG, "Failed to push frame: ${e.message}")
}
}
val isRunning: Boolean get() = running
}
@@ -0,0 +1,140 @@
package com.ledgrab.android
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.os.Handler
import android.os.HandlerThread
import android.util.DisplayMetrics
import android.util.Log
import java.nio.ByteBuffer
/**
* Captures the Android screen via MediaProjection and feeds frames
* to [PythonBridge].
*
* Frames are downscaled to [targetWidth] x [targetHeight] before
* crossing the JNI boundary to minimize overhead. For LED ambient
* lighting, even 480x270 contains far more data than needed.
*/
class ScreenCapture(
private val projection: MediaProjection,
private val metrics: DisplayMetrics,
private val bridge: PythonBridge,
private val targetWidth: Int = 480,
private val targetHeight: Int = 270,
private val targetFps: Int = 30,
) {
companion object {
private const val TAG = "ScreenCapture"
private const val VIRTUAL_DISPLAY_NAME = "LedGrabCapture"
}
private var virtualDisplay: VirtualDisplay? = null
private var imageReader: ImageReader? = null
private var captureThread: HandlerThread? = null
private var captureHandler: Handler? = null
@Volatile private var running = false
private var lastFrameTimeMs = 0L
private val frameIntervalMs = 1000L / targetFps
/**
* Start capturing the screen.
*/
fun start() {
if (running) return
running = true
captureThread = HandlerThread("LedGrab-Capture").also { it.start() }
captureHandler = Handler(captureThread!!.looper)
// Android 14+ requires registering a callback before createVirtualDisplay
projection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Log.i(TAG, "MediaProjection stopped")
stop()
}
}, captureHandler)
imageReader = ImageReader.newInstance(
targetWidth,
targetHeight,
PixelFormat.RGBA_8888,
2, // maxImages — double buffer
)
imageReader?.setOnImageAvailableListener({ reader ->
if (!running) return@setOnImageAvailableListener
val now = System.currentTimeMillis()
if (now - lastFrameTimeMs < frameIntervalMs) {
// Skip frame to maintain target FPS
reader.acquireLatestImage()?.close()
return@setOnImageAvailableListener
}
val image = reader.acquireLatestImage() ?: return@setOnImageAvailableListener
try {
val plane = image.planes[0]
val buffer = plane.buffer
val rowStride = plane.rowStride
val pixelStride = plane.pixelStride
// Handle row padding: rowStride may be > width * pixelStride
val rgbaBytes = if (rowStride == targetWidth * pixelStride) {
// No padding — direct copy
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
bytes
} else {
// Strip row padding
val rowBytes = targetWidth * pixelStride
val bytes = ByteArray(targetWidth * targetHeight * 4)
for (row in 0 until targetHeight) {
buffer.position(row * rowStride)
buffer.get(bytes, row * rowBytes, rowBytes)
}
bytes
}
bridge.pushFrame(rgbaBytes, targetWidth, targetHeight)
lastFrameTimeMs = now
} catch (e: Exception) {
Log.w(TAG, "Frame processing error: ${e.message}")
} finally {
image.close()
}
}, captureHandler)
virtualDisplay = projection.createVirtualDisplay(
VIRTUAL_DISPLAY_NAME,
targetWidth,
targetHeight,
metrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader?.surface,
null,
captureHandler,
)
Log.i(TAG, "Screen capture started (${targetWidth}x${targetHeight} @ ${targetFps}fps)")
}
/**
* Stop capturing and release all resources.
*/
fun stop() {
running = false
virtualDisplay?.release()
virtualDisplay = null
imageReader?.close()
imageReader = null
captureThread?.quitSafely()
captureThread = null
captureHandler = null
Log.i(TAG, "Screen capture stopped")
}
}
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true" android:color="#ffffff" />
<item android:color="@color/purple_accent" />
</selector>
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<layer-list>
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
<shape android:shape="rectangle">
<solid android:color="#4064ffda" />
<corners android:radius="44dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#64ffda" />
<corners android:radius="36dp" />
</shape>
</item>
</layer-list>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#3dccb0" />
<corners android:radius="36dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="@color/teal_accent" />
<corners android:radius="36dp" />
</shape>
</item>
</selector>
@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_focused="true">
<layer-list>
<item android:left="-6dp" android:top="-6dp" android:right="-6dp" android:bottom="-6dp">
<shape android:shape="rectangle">
<solid android:color="#40bb86fc" />
<corners android:radius="44dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#7c4dff" />
<corners android:radius="36dp" />
</shape>
</item>
</layer-list>
</item>
<item android:state_pressed="true">
<shape android:shape="rectangle">
<solid android:color="#9966d4" />
<corners android:radius="36dp" />
<stroke android:width="2dp" android:color="@color/purple_accent" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#1Abb86fc" />
<corners android:radius="36dp" />
<stroke android:width="2dp" android:color="@color/purple_accent" />
</shape>
</item>
</selector>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<color android:color="@color/bg_navy" />
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:gradientRadius="900dp"
android:centerX="0.5"
android:centerY="-0.2"
android:startColor="#1A64ffda"
android:endColor="#000d1117" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<gradient
android:type="radial"
android:gradientRadius="600dp"
android:centerX="1.1"
android:centerY="1.2"
android:startColor="#12bb86fc"
android:endColor="#000d1117" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:left="-8dp" android:top="-8dp" android:right="-8dp" android:bottom="-8dp">
<shape android:shape="rectangle">
<solid android:color="#2064ffda" />
<corners android:radius="24dp" />
</shape>
</item>
<item>
<shape android:shape="rectangle">
<solid android:color="#ffffff" />
<corners android:radius="16dp" />
<stroke android:width="3dp" android:color="@color/teal_accent" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/green_status" />
</shape>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/bg_surface_elevated" />
<corners android:radius="12dp" />
<stroke android:width="1dp" android:color="#2264ffda" />
</shape>
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- Background circle -->
<path
android:fillColor="#0d1117"
android:pathData="M54,54m-50,0a50,50 0,1 1,100 0a50,50 0,1 1,-100 0" />
<!-- Border ring -->
<path
android:strokeColor="#2264ffda"
android:strokeWidth="1.5"
android:fillColor="#00000000"
android:pathData="M54,54m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0" />
<!-- TV body -->
<path
android:fillColor="#1c2333"
android:pathData="M26,32 L82,32 Q86,32 86,36 L86,68 Q86,72 82,72 L26,72 Q22,72 22,68 L22,36 Q22,32 26,32 Z" />
<!-- TV screen -->
<path
android:fillColor="#161b22"
android:pathData="M28,35 L80,35 Q82,35 82,37 L82,66 Q82,68 80,68 L28,68 Q26,68 26,66 L26,37 Q26,35 28,35 Z" />
<!-- LED glow - top (teal) -->
<path
android:fillColor="#64ffda"
android:fillAlpha="0.7"
android:pathData="M30,28 L78,28 L78,30 L30,30 Z" />
<!-- LED glow - left (purple) -->
<path
android:fillColor="#bb86fc"
android:fillAlpha="0.6"
android:pathData="M18,34 L20,34 L20,70 L18,70 Z" />
<!-- LED glow - right (red) -->
<path
android:fillColor="#ff6b6b"
android:fillAlpha="0.6"
android:pathData="M88,34 L90,34 L90,70 L88,70 Z" />
<!-- LED glow - bottom (yellow) -->
<path
android:fillColor="#ffd93d"
android:fillAlpha="0.6"
android:pathData="M30,74 L78,74 L78,76 L30,76 Z" />
<!-- TV stand -->
<path
android:fillColor="#1c2333"
android:pathData="M44,72 L44,78 L64,78 L64,72" />
<path
android:fillColor="#1c2333"
android:pathData="M38,78 L70,78 L70,80 L38,80 Z" />
</vector>
@@ -0,0 +1,179 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_main">
<!-- STOPPED STATE -->
<LinearLayout
android:id="@+id/stopped_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:gravity="center"
android:paddingStart="160dp"
android:paddingEnd="160dp">
<ImageView
android:layout_width="72dp"
android:layout_height="72dp"
android:src="@drawable/ic_launcher"
android:contentDescription="@null"
android:layout_marginBottom="24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_name"
android:textColor="@color/teal_accent"
android:textSize="64sp"
android:textStyle="bold"
android:letterSpacing="0.08"
android:layout_marginBottom="12dp"
android:fontFamily="sans-serif-light" />
<TextView
android:id="@+id/status_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/tagline"
android:textColor="@color/text_secondary"
android:textSize="28sp"
android:layout_marginBottom="64dp" />
<Button
android:id="@+id/toggle_button"
style="@style/Widget.LedGrab.Button.Primary"
android:layout_width="320dp"
android:layout_height="72dp"
android:text="@string/btn_start"
android:textSize="22sp"
android:focusable="true"
android:focusableInTouchMode="true" />
</LinearLayout>
<!-- Version at bottom -->
<TextView
android:id="@+id/version_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|center_horizontal"
android:layout_marginBottom="32dp"
android:textColor="@color/text_hint"
android:textSize="18sp"
tools:text="v0.1.0" />
<!-- RUNNING STATE -->
<LinearLayout
android:id="@+id/running_panel"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="120dp"
android:paddingEnd="120dp"
android:paddingTop="80dp"
android:paddingBottom="80dp"
android:visibility="gone">
<!-- Left: status + URL + stop -->
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:gravity="start|center_vertical"
android:paddingEnd="64dp">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="32dp">
<View
android:layout_width="18dp"
android:layout_height="18dp"
android:background="@drawable/bg_status_dot"
android:layout_marginEnd="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/status_running"
android:textColor="@color/green_status"
android:textSize="28sp"
android:textStyle="bold"
android:letterSpacing="0.05" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/label_web_ui"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:layout_marginBottom="8dp" />
<TextView
android:id="@+id/url_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/teal_accent"
android:textSize="30sp"
android:maxLines="1"
android:textStyle="bold"
android:background="@drawable/bg_url_chip"
android:paddingStart="24dp"
android:paddingEnd="24dp"
android:paddingTop="12dp"
android:paddingBottom="12dp"
android:layout_marginBottom="56dp"
tools:text="http://192.168.1.5:8080" />
<Button
android:id="@+id/stop_button_running"
style="@style/Widget.LedGrab.Button.Secondary"
android:layout_width="240dp"
android:layout_height="64dp"
android:text="@string/btn_stop"
android:textSize="20sp"
android:focusable="true"
android:focusableInTouchMode="true" />
</LinearLayout>
<!-- Right: QR code -->
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="center">
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_qr_container"
android:padding="20dp"
android:layout_marginBottom="20dp">
<ImageView
android:id="@+id/qr_image"
android:layout_width="280dp"
android:layout_height="280dp"
android:contentDescription="@string/qr_description"
android:scaleType="fitXY" />
</FrameLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/scan_to_configure"
android:textColor="@color/text_secondary"
android:textSize="22sp"
android:gravity="center" />
</LinearLayout>
</LinearLayout>
</FrameLayout>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="bg_navy">#0d1117</color>
<color name="bg_navy_mid">#111827</color>
<color name="bg_surface">#161b22</color>
<color name="bg_surface_elevated">#1c2333</color>
<color name="teal_accent">#64ffda</color>
<color name="teal_accent_dim">#3399aa</color>
<color name="teal_accent_alpha30">#4D64ffda</color>
<color name="teal_accent_alpha15">#2664ffda</color>
<color name="purple_accent">#bb86fc</color>
<color name="purple_accent_dim">#7c4dff</color>
<color name="purple_accent_alpha30">#4Dbb86fc</color>
<color name="purple_accent_alpha15">#26bb86fc</color>
<color name="green_status">#4caf50</color>
<color name="green_status_dim">#1b5e20</color>
<color name="text_primary">#e6edf3</color>
<color name="text_secondary">#8b949e</color>
<color name="text_hint">#484f58</color>
</resources>
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">LedGrab</string>
<string name="tagline">Ambient lighting for your TV</string>
<string name="btn_start">Start Capture</string>
<string name="btn_stop">Stop</string>
<string name="status_running">Running</string>
<string name="label_web_ui">Web UI address</string>
<string name="scan_to_configure">Scan to configure</string>
<string name="qr_description">QR code for web UI</string>
</resources>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.LedGrab" parent="@style/Theme.Leanback">
<item name="android:windowBackground">@color/bg_navy</item>
<item name="android:colorBackground">@color/bg_navy</item>
<item name="android:windowNoTitle">true</item>
<item name="android:textColorPrimary">@color/text_primary</item>
<item name="android:textColorSecondary">@color/text_secondary</item>
<item name="android:textColorHint">@color/text_hint</item>
<item name="android:colorAccent">@color/teal_accent</item>
<item name="android:colorControlHighlight">@color/teal_accent_dim</item>
<item name="android:colorControlActivated">@color/teal_accent</item>
</style>
<style name="Widget.LedGrab.Button.Primary" parent="@android:style/Widget.Button">
<item name="android:background">@drawable/bg_button_primary</item>
<item name="android:textColor">@color/bg_navy</item>
<item name="android:textStyle">bold</item>
<item name="android:focusable">true</item>
<item name="android:stateListAnimator">@null</item>
</style>
<style name="Widget.LedGrab.Button.Secondary" parent="@android:style/Widget.Button">
<item name="android:background">@drawable/bg_button_secondary</item>
<item name="android:textColor">@color/btn_secondary_text</item>
<item name="android:textStyle">bold</item>
<item name="android:focusable">true</item>
<item name="android:stateListAnimator">@null</item>
</style>
</resources>
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
#
# Cross-compile pydantic-core for Android ARM64.
#
# Prerequisites:
# - Rust toolchain (rustup)
# - Android NDK (set ANDROID_NDK_HOME or let this script find it)
# - maturin (pip install maturin)
# - Python 3.11 (matching Chaquopy's embedded version)
#
# Output: ../wheels/pydantic_core-*.whl
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
WHEELS_DIR="$SCRIPT_DIR/../wheels"
BUILD_DIR="$SCRIPT_DIR/../.build-cache"
# ── Pydantic-core version (must match pydantic>=2.9.2 requirement) ──
PYDANTIC_CORE_VERSION="2.27.2"
# ── Find Android NDK ────────────────────────────────────────────────
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
# Try common locations
for candidate in \
"$HOME/Library/Android/sdk/ndk"/* \
"$HOME/Android/Sdk/ndk"/* \
"$LOCALAPPDATA/Android/Sdk/ndk"/* \
"/usr/local/lib/android/sdk/ndk"/*; do
if [ -d "$candidate" ]; then
ANDROID_NDK_HOME="$candidate"
break
fi
done
fi
if [ -z "${ANDROID_NDK_HOME:-}" ]; then
echo "ERROR: Android NDK not found. Set ANDROID_NDK_HOME or install via Android Studio."
exit 1
fi
echo "Using Android NDK: $ANDROID_NDK_HOME"
# ── Determine NDK API level and toolchain ───────────────────────────
API_LEVEL=24
HOST_TAG=""
case "$(uname -s)" in
Linux*) HOST_TAG="linux-x86_64" ;;
Darwin*) HOST_TAG="darwin-x86_64" ;;
MINGW*|MSYS*|CYGWIN*) HOST_TAG="windows-x86_64" ;;
*) echo "Unsupported host OS"; exit 1 ;;
esac
TOOLCHAIN="$ANDROID_NDK_HOME/toolchains/llvm/prebuilt/$HOST_TAG"
CC="$TOOLCHAIN/bin/aarch64-linux-android${API_LEVEL}-clang"
AR="$TOOLCHAIN/bin/llvm-ar"
if [ ! -f "$CC" ] && [ ! -f "${CC}.cmd" ]; then
echo "ERROR: NDK compiler not found at $CC"
echo "Check your NDK installation."
exit 1
fi
# ── Install Rust Android target ─────────────────────────────────────
echo "Adding Rust target aarch64-linux-android..."
rustup target add aarch64-linux-android
# ── Configure Cargo for cross-compilation ───────────────────────────
mkdir -p "$BUILD_DIR"
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$CC"
export CC_aarch64_linux_android="$CC"
export AR_aarch64_linux_android="$AR"
# ── Clone pydantic-core ─────────────────────────────────────────────
REPO_DIR="$BUILD_DIR/pydantic-core"
if [ -d "$REPO_DIR" ]; then
echo "Updating existing pydantic-core checkout..."
cd "$REPO_DIR"
git fetch --tags
git checkout "v$PYDANTIC_CORE_VERSION"
else
echo "Cloning pydantic-core v$PYDANTIC_CORE_VERSION..."
git clone --depth 1 --branch "v$PYDANTIC_CORE_VERSION" \
https://github.com/pydantic/pydantic-core.git "$REPO_DIR"
cd "$REPO_DIR"
fi
# ── Build with maturin ──────────────────────────────────────────────
echo "Building pydantic-core for aarch64-linux-android..."
maturin build \
--release \
--target aarch64-linux-android \
--interpreter python3.11 \
--out "$WHEELS_DIR"
echo ""
echo "=== Build complete ==="
echo "Wheels:"
ls -la "$WHEELS_DIR"/pydantic_core-*.whl 2>/dev/null || echo "WARNING: No wheel found!"
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
#
# Set up the cross-compilation environment for building Python native
# extensions targeting Android ARM64.
#
# This script:
# 1. Verifies Android NDK is installed
# 2. Installs the Rust aarch64-linux-android target
# 3. Installs maturin (Python wheel builder for Rust extensions)
# 4. Runs a quick test compile to verify the toolchain works
#
set -euo pipefail
echo "=== LedGrab Android NDK Setup ==="
# ── Check prerequisites ─────────────────────────────────────────────
if ! command -v rustc &>/dev/null; then
echo "ERROR: Rust is not installed."
echo "Install from: https://rustup.rs/"
exit 1
fi
echo "Rust: $(rustc --version)"
if ! command -v cargo &>/dev/null; then
echo "ERROR: Cargo is not installed."
exit 1
fi
echo "Cargo: $(cargo --version)"
if ! command -v python3.11 &>/dev/null && ! command -v python3 &>/dev/null; then
echo "WARNING: Python 3.11 not found. Needed for maturin builds."
fi
# ── Install Rust target ─────────────────────────────────────────────
echo ""
echo "Installing Rust Android target..."
rustup target add aarch64-linux-android
echo "Installed targets:"
rustup target list --installed | grep android
# ── Install maturin ─────────────────────────────────────────────────
echo ""
echo "Installing maturin..."
pip install maturin 2>/dev/null || pip3 install maturin 2>/dev/null || {
echo "WARNING: Could not install maturin. Install manually: pip install maturin"
}
if command -v maturin &>/dev/null; then
echo "maturin: $(maturin --version)"
fi
# ── Verify NDK ──────────────────────────────────────────────────────
echo ""
if [ -n "${ANDROID_NDK_HOME:-}" ]; then
echo "ANDROID_NDK_HOME: $ANDROID_NDK_HOME"
else
echo "ANDROID_NDK_HOME is not set."
echo "Set it to your NDK installation path, e.g.:"
echo " export ANDROID_NDK_HOME=\$HOME/Android/Sdk/ndk/26.1.10909125"
echo ""
echo "Or install NDK via Android Studio:"
echo " SDK Manager → SDK Tools → NDK (Side by side)"
fi
echo ""
echo "=== Setup complete ==="
echo ""
echo "Next steps:"
echo " 1. Ensure ANDROID_NDK_HOME is set"
echo " 2. Run: ./build-pydantic-core.sh"
+5
View File
@@ -0,0 +1,5 @@
plugins {
id("com.android.application") version "8.9.0" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.chaquo.python") version "17.0.0" apply false
}
+7
View File
@@ -0,0 +1,7 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true
org.gradle.parallel=false
org.gradle.configuration-cache=false
org.gradle.unsafe.isolated-projects=false
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "LedGrab"
include(":app")
+4
View File
@@ -66,6 +66,10 @@ camera = [
# opencv-python-headless is now a core dependency (used for image encoding)
# camera extra kept for backwards compatibility
]
# High-performance Android capture via scrcpy H.264 streaming
scrcpy = [
"scrcpy-client>=0.5.0",
]
# OS notification capture (winrt packages are ~2.5MB total vs winsdk's ~35MB)
notifications = [
"winrt-Windows.UI.Notifications>=3.0.0; sys_platform == 'win32'",
+6 -2
View File
@@ -2,11 +2,15 @@
from importlib.metadata import version, PackageNotFoundError
# Fallback version — kept in sync with pyproject.toml.
# Used when the package isn't pip-installed (e.g. embedded via Chaquopy
# on Android, where the source is included directly via source sets).
_FALLBACK_VERSION = "0.3.0"
try:
__version__ = version("ledgrab")
except PackageNotFoundError:
# Running from source without pip install (e.g. dev, embedded Python)
__version__ = "0.0.0-dev"
__version__ = _FALLBACK_VERSION
__author__ = "Alexei Dolgolyov"
__email__ = "dolgolyov.alexei@gmail.com"
+84
View File
@@ -0,0 +1,84 @@
"""Android entry point for LedGrab.
Called from Kotlin via Chaquopy to start/stop the FastAPI server
inside an Android application. Sets up Android-specific paths
(app-private storage) before importing the main application.
"""
import asyncio
import os
import threading
from typing import Optional
_server_thread: Optional[threading.Thread] = None
_shutdown_event: Optional[asyncio.Event] = None
_loop: Optional[asyncio.AbstractEventLoop] = None
def start_server(data_dir: str, port: int = 8080) -> None:
"""Start the LedGrab uvicorn server.
Called from Kotlin's ``PythonBridge.startServer()``. This function
blocks until ``stop_server()`` is called, so Kotlin should invoke it
on a background thread.
Args:
data_dir: Android app-private files directory
(e.g. ``/data/data/com.ledgrab.android/files``).
port: HTTP port for the web UI / API.
"""
# ── Configure paths before any LedGrab imports ──────────────
os.makedirs(os.path.join(data_dir, "data"), exist_ok=True)
os.makedirs(os.path.join(data_dir, "data", "assets"), exist_ok=True)
os.makedirs(os.path.join(data_dir, "config"), exist_ok=True)
# Change working directory to app-private storage so relative
# paths (e.g. "config/default_config.yaml") don't hit system dirs
os.chdir(data_dir)
os.environ["LEDGRAB_STORAGE__DATABASE_FILE"] = os.path.join(data_dir, "data", "ledgrab.db")
os.environ["LEDGRAB_ASSETS__ASSETS_DIR"] = os.path.join(data_dir, "data", "assets")
os.environ["LEDGRAB_SERVER__HOST"] = "0.0.0.0"
os.environ["LEDGRAB_SERVER__PORT"] = str(port)
# ── Now safe to import LedGrab ──────────────────────────────
import uvicorn # noqa: E402
from ledgrab.utils import setup_logging, get_logger # noqa: E402
setup_logging()
logger = get_logger(__name__)
logger.info("LedGrab Android: starting server on port %d", port)
logger.info("Data directory: %s", data_dir)
from ledgrab.config import get_config # noqa: E402
config = get_config()
uv_config = uvicorn.Config(
"ledgrab.main:app",
host=config.server.host,
port=port,
log_level=config.server.log_level.lower(),
# No uvloop/httptools on Android — use pure-Python asyncio
loop="asyncio",
)
server = uvicorn.Server(uv_config)
global _shutdown_event, _loop
_loop = asyncio.new_event_loop()
asyncio.set_event_loop(_loop)
_shutdown_event = asyncio.Event()
logger.info("LedGrab Android: server starting")
_loop.run_until_complete(server.serve())
logger.info("LedGrab Android: server stopped")
def stop_server() -> None:
"""Signal the uvicorn server to shut down gracefully.
Called from Kotlin's ``PythonBridge.stopServer()``.
"""
if _shutdown_event is not None and _loop is not None:
_loop.call_soon_threadsafe(_shutdown_event.set)
@@ -1643,8 +1643,7 @@ async def test_color_strip_ws(
try:
frame = _frame_live.get_latest_frame()
if frame is not None and frame.image is not None:
from ledgrab.utils.image_codec import encode_jpeg
import cv2 as _cv2
from ledgrab.utils.image_codec import encode_jpeg, resize_image
img = frame.image
# Ensure 3-channel RGB (some engines may produce BGRA)
@@ -1668,9 +1667,7 @@ async def test_color_strip_ws(
if scale < 1.0:
new_w = max(1, int(w * scale))
new_h = max(1, int(h * scale))
img = _cv2.resize(
img, (new_w, new_h), interpolation=_cv2.INTER_AREA
)
img = resize_image(img, new_w, new_h)
# Wire format: [0xFD] [jpeg_bytes]
await websocket.send_bytes(b"\xfd" + encode_jpeg(img, quality=70))
except Exception as e:
+38 -5
View File
@@ -12,7 +12,11 @@ from typing import Optional
import os
import psutil
try:
import psutil
except ImportError:
psutil = None # type: ignore[assignment]
from fastapi import APIRouter, Depends, HTTPException, Query
from ledgrab import __version__, REPO_URL, DONATE_URL
@@ -54,9 +58,12 @@ from ledgrab.api.routes.system_settings import load_external_url # noqa: F401
logger = get_logger(__name__)
# Prime psutil CPU counters (first call always returns 0.0)
psutil.cpu_percent(interval=None)
_process = psutil.Process(os.getpid())
_process.cpu_percent(interval=None) # prime process-level counter
if psutil is not None:
psutil.cpu_percent(interval=None)
_process = psutil.Process(os.getpid())
_process.cpu_percent(interval=None) # prime process-level counter
else:
_process = None # type: ignore[assignment]
# GPU monitoring (initialized once in utils.gpu, shared with metrics_history)
from ledgrab.utils.gpu import ( # noqa: E402
@@ -200,7 +207,18 @@ async def get_displays(
else:
display_dataclasses = await asyncio.to_thread(get_available_displays)
else:
display_dataclasses = await asyncio.to_thread(get_available_displays)
# Try the mss-based detection first (desktop default).
# If mss is unavailable (e.g. on Android), fall back to the
# best registered engine that has its own display list.
try:
display_dataclasses = await asyncio.to_thread(get_available_displays)
except RuntimeError:
display_dataclasses = []
if not display_dataclasses:
best = EngineRegistry.get_best_available_engine()
if best:
engine_cls = EngineRegistry.get_engine(best)
display_dataclasses = await asyncio.to_thread(engine_cls.get_available_displays)
# Convert dataclass DisplayInfo to Pydantic DisplayInfo
displays = [
@@ -264,6 +282,21 @@ def get_system_performance(_: AuthRequired):
and NVML calls are blocking and would stall the event loop if run
in an ``async def`` handler.
"""
if psutil is None or _process is None:
# psutil unavailable on this platform (e.g. Android)
from datetime import datetime, timezone
return PerformanceResponse(
timestamp=datetime.now(timezone.utc),
cpu_name=_cpu_name,
cpu_percent=0.0,
ram_used_mb=0.0,
ram_total_mb=0.0,
ram_percent=0.0,
app_cpu_percent=0.0,
app_ram_mb=0.0,
gpu=None,
)
mem = psutil.virtual_memory()
# App-level metrics
+7 -4
View File
@@ -169,10 +169,13 @@ class Config(BaseSettings):
if demo_path.exists():
return cls.from_yaml(demo_path)
# Try default location
default_path = Path("config/default_config.yaml")
if default_path.exists():
return cls.from_yaml(default_path)
# Try default location (guard against permission errors on Android)
try:
default_path = Path("config/default_config.yaml")
if default_path.exists():
return cls.from_yaml(default_path)
except (PermissionError, OSError):
pass
# Use defaults
return cls()
+35 -9
View File
@@ -1,4 +1,9 @@
"""Audio capture engine abstraction layer."""
"""Audio capture engine abstraction layer.
Audio engines with native dependencies (WASAPI, PortAudio) are imported
inside try/except blocks so the package loads cleanly on platforms where
those libraries are unavailable (e.g. Android via Chaquopy).
"""
from ledgrab.core.audio.base import (
AudioCaptureEngine,
@@ -13,13 +18,33 @@ from ledgrab.core.audio.analysis import (
DEFAULT_SAMPLE_RATE,
DEFAULT_CHUNK_SIZE,
)
from ledgrab.core.audio.wasapi_engine import WasapiEngine, WasapiCaptureStream
from ledgrab.core.audio.sounddevice_engine import SounddeviceEngine, SounddeviceCaptureStream
# ── Platform-specific audio engines ─────────────────────────────────
try:
from ledgrab.core.audio.wasapi_engine import WasapiEngine, WasapiCaptureStream
_has_wasapi = True
except ImportError:
_has_wasapi = False
try:
from ledgrab.core.audio.sounddevice_engine import (
SounddeviceEngine,
SounddeviceCaptureStream,
)
_has_sounddevice = True
except ImportError:
_has_sounddevice = False
from ledgrab.core.audio.demo_engine import DemoAudioEngine, DemoAudioCaptureStream
# Auto-register available engines
AudioEngineRegistry.register(WasapiEngine)
AudioEngineRegistry.register(SounddeviceEngine)
if _has_wasapi:
AudioEngineRegistry.register(WasapiEngine)
if _has_sounddevice:
AudioEngineRegistry.register(SounddeviceEngine)
AudioEngineRegistry.register(DemoAudioEngine)
__all__ = [
@@ -32,10 +57,11 @@ __all__ = [
"NUM_BANDS",
"DEFAULT_SAMPLE_RATE",
"DEFAULT_CHUNK_SIZE",
"WasapiEngine",
"WasapiCaptureStream",
"SounddeviceEngine",
"SounddeviceCaptureStream",
"DemoAudioEngine",
"DemoAudioCaptureStream",
]
if _has_wasapi:
__all__ += ["WasapiEngine", "WasapiCaptureStream"]
if _has_sounddevice:
__all__ += ["SounddeviceEngine", "SounddeviceCaptureStream"]
@@ -6,7 +6,6 @@ Non-Windows: graceful degradation (returns empty results).
import asyncio
import ctypes
import ctypes.wintypes
import os
import sys
import threading
@@ -18,6 +17,9 @@ logger = get_logger(__name__)
_IS_WINDOWS = sys.platform == "win32"
if _IS_WINDOWS:
import ctypes.wintypes
class PlatformDetector:
"""Detect running processes and the foreground window's process."""
@@ -3,9 +3,13 @@
from dataclasses import dataclass
from typing import List
import mss
import numpy as np
try:
import mss
except ImportError:
mss = None # type: ignore[assignment]
from ledgrab.utils import get_logger, get_monitor_names, get_monitor_refresh_rates
logger = get_logger(__name__)
@@ -54,6 +58,8 @@ def get_available_displays() -> List[DisplayInfo]:
Raises:
RuntimeError: If unable to detect displays
"""
if mss is None:
return []
try:
# Get friendly monitor names (Windows only, falls back to generic names)
monitor_names = get_monitor_names()
@@ -105,6 +111,8 @@ def capture_display(display_index: int = 0) -> ScreenCapture:
ValueError: If display_index is invalid
RuntimeError: If screen capture fails
"""
if mss is None:
raise RuntimeError("mss library not available on this platform")
try:
with mss.mss() as sct:
# mss monitors[0] is the combined screen, monitors[1+] are individual displays
@@ -1,4 +1,9 @@
"""Screen capture engine abstraction layer."""
"""Screen capture engine abstraction layer.
Engines with native/platform-specific dependencies are imported inside
try/except blocks so the package loads cleanly on any platform (including
Android via Chaquopy where desktop capture libraries are unavailable).
"""
from ledgrab.core.capture_engines.base import (
CaptureEngine,
@@ -7,14 +12,61 @@ from ledgrab.core.capture_engines.base import (
ScreenCapture,
)
from ledgrab.core.capture_engines.factory import EngineRegistry
from ledgrab.core.capture_engines.mss_engine import MSSEngine, MSSCaptureStream
from ledgrab.core.capture_engines.dxcam_engine import DXcamEngine, DXcamCaptureStream
from ledgrab.core.capture_engines.bettercam_engine import BetterCamEngine, BetterCamCaptureStream
from ledgrab.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream
from ledgrab.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
from ledgrab.core.capture_engines.demo_engine import DemoCaptureEngine, DemoCaptureStream
# Camera engine requires OpenCV — optional dependency
# ── Desktop capture engines (platform-specific native deps) ──────────
try:
from ledgrab.core.capture_engines.mss_engine import MSSEngine, MSSCaptureStream
_has_mss = True
except ImportError:
_has_mss = False
try:
from ledgrab.core.capture_engines.dxcam_engine import DXcamEngine, DXcamCaptureStream
_has_dxcam = True
except ImportError:
_has_dxcam = False
try:
from ledgrab.core.capture_engines.bettercam_engine import (
BetterCamEngine,
BetterCamCaptureStream,
)
_has_bettercam = True
except ImportError:
_has_bettercam = False
try:
from ledgrab.core.capture_engines.wgc_engine import WGCEngine, WGCCaptureStream
_has_wgc = True
except ImportError:
_has_wgc = False
# ── ADB-based Android capture ───────────────────────────────────────
try:
from ledgrab.core.capture_engines.scrcpy_engine import ScrcpyEngine, ScrcpyCaptureStream
_has_scrcpy = True
except ImportError:
_has_scrcpy = False
try:
from ledgrab.core.capture_engines.scrcpy_client_engine import (
ScrcpyClientEngine,
ScrcpyClientCaptureStream,
)
_has_scrcpy_client = True
except ImportError:
_has_scrcpy_client = False
# ── Camera (OpenCV) ─────────────────────────────────────────────────
try:
from ledgrab.core.capture_engines.camera_engine import CameraEngine, CameraCaptureStream
@@ -22,35 +74,67 @@ try:
except ImportError:
_has_camera = False
# Auto-register available engines
EngineRegistry.register(MSSEngine)
EngineRegistry.register(DXcamEngine)
EngineRegistry.register(BetterCamEngine)
EngineRegistry.register(WGCEngine)
EngineRegistry.register(ScrcpyEngine)
# ── Android MediaProjection (Chaquopy bridge) ───────────────────────
try:
from ledgrab.core.capture_engines.mediaprojection_engine import (
MediaProjectionEngine,
MediaProjectionCaptureStream,
)
_has_mediaprojection = True
except ImportError:
_has_mediaprojection = False
# ── Demo / always available ─────────────────────────────────────────
from ledgrab.core.capture_engines.demo_engine import DemoCaptureEngine, DemoCaptureStream
# ── Auto-register available engines ─────────────────────────────────
if _has_mss:
EngineRegistry.register(MSSEngine)
if _has_dxcam:
EngineRegistry.register(DXcamEngine)
if _has_bettercam:
EngineRegistry.register(BetterCamEngine)
if _has_wgc:
EngineRegistry.register(WGCEngine)
if _has_scrcpy:
EngineRegistry.register(ScrcpyEngine)
if _has_scrcpy_client:
EngineRegistry.register(ScrcpyClientEngine)
if _has_camera:
EngineRegistry.register(CameraEngine)
if _has_mediaprojection:
EngineRegistry.register(MediaProjectionEngine)
EngineRegistry.register(DemoCaptureEngine)
# ── Public API ──────────────────────────────────────────────────────
__all__ = [
"CaptureEngine",
"CaptureStream",
"DisplayInfo",
"ScreenCapture",
"EngineRegistry",
"MSSEngine",
"MSSCaptureStream",
"DXcamEngine",
"DXcamCaptureStream",
"BetterCamEngine",
"BetterCamCaptureStream",
"WGCEngine",
"WGCCaptureStream",
"ScrcpyEngine",
"ScrcpyCaptureStream",
"DemoCaptureEngine",
"DemoCaptureStream",
]
if _has_mss:
__all__ += ["MSSEngine", "MSSCaptureStream"]
if _has_dxcam:
__all__ += ["DXcamEngine", "DXcamCaptureStream"]
if _has_bettercam:
__all__ += ["BetterCamEngine", "BetterCamCaptureStream"]
if _has_wgc:
__all__ += ["WGCEngine", "WGCCaptureStream"]
if _has_scrcpy:
__all__ += ["ScrcpyEngine", "ScrcpyCaptureStream"]
if _has_scrcpy_client:
__all__ += ["ScrcpyClientEngine", "ScrcpyClientCaptureStream"]
if _has_camera:
__all__ += ["CameraEngine", "CameraCaptureStream"]
if _has_mediaprojection:
__all__ += ["MediaProjectionEngine", "MediaProjectionCaptureStream"]
@@ -0,0 +1,201 @@
"""Android MediaProjection capture engine.
Receives screen frames pushed from Kotlin (via Chaquopy) through a
module-level frame queue. The Kotlin layer captures the screen using
the ``MediaProjection`` API and calls :func:`push_frame` with raw RGBA
bytes for each frame.
This engine is only available when running inside an Android app that
has set up the frame queue.
"""
import queue
from typing import Any, Dict, List, Optional
import numpy as np
from ledgrab.core.capture_engines.base import (
CaptureEngine,
CaptureStream,
DisplayInfo,
ScreenCapture,
)
from ledgrab.utils import get_logger
logger = get_logger(__name__)
# ---------------------------------------------------------------------------
# Frame queue — the bridge between Kotlin and Python
# ---------------------------------------------------------------------------
_frame_queue: queue.Queue["ScreenCapture"] = queue.Queue(maxsize=2)
_display_info: Optional[DisplayInfo] = None
_active = False
_frames_received = 0
_frames_consumed = 0
# MediaProjection only fires onImageAvailable when the screen changes.
# Cache the last frame so preview stays usable on static screens.
_last_frame: Optional["ScreenCapture"] = None
def configure(width: int, height: int) -> None:
"""Set display dimensions. Called from Kotlin before server start.
Drains any stale frames from a previous capture session so the
first frame after restart is actually current.
"""
global _display_info, _active, _last_frame, _frames_received
# Drain the queue — frames from a previous capture session are stale
while not _frame_queue.empty():
try:
_frame_queue.get_nowait()
except queue.Empty:
break
_last_frame = None
_frames_received = 0
_display_info = DisplayInfo(
index=0,
name="Android TV Screen",
width=width,
height=height,
x=0,
y=0,
is_primary=True,
refresh_rate=60,
)
_active = True
logger.info("MediaProjection engine configured: %dx%d", width, height)
def push_frame(rgba_bytes: bytes, width: int, height: int) -> None:
"""Push a captured frame from Kotlin into the Python pipeline.
Called from Kotlin's ``PythonBridge.pushFrame()`` on the capture
thread. The RGBA byte buffer is converted to an RGB NumPy array
and placed on the queue. If the queue is full (Python consumer is
slow), the oldest frame is dropped.
Args:
rgba_bytes: Raw RGBA pixel data (width * height * 4 bytes).
width: Frame width in pixels.
height: Frame height in pixels.
"""
global _frames_received
_frames_received += 1
if _frames_received == 1 or _frames_received % 100 == 0:
logger.info("MediaProjection: received %d frames", _frames_received)
# RGBA → RGB (drop alpha channel)
rgba = np.frombuffer(rgba_bytes, dtype=np.uint8).reshape((height, width, 4))
rgb = rgba[:, :, :3].copy()
frame = ScreenCapture(
image=rgb,
width=width,
height=height,
display_index=0,
)
global _last_frame
_last_frame = frame
# Drop oldest frame if queue is full (non-blocking)
try:
_frame_queue.put_nowait(frame)
except queue.Full:
try:
_frame_queue.get_nowait()
except queue.Empty:
pass
try:
_frame_queue.put_nowait(frame)
except queue.Full:
pass
def shutdown() -> None:
"""Deactivate the engine. Called when the Android app stops."""
global _active
_active = False
# ---------------------------------------------------------------------------
# CaptureStream
# ---------------------------------------------------------------------------
class MediaProjectionCaptureStream(CaptureStream):
"""Reads frames pushed by Kotlin from the module-level queue."""
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
def initialize(self) -> None:
if self._initialized:
return
if not _active:
raise RuntimeError(
"MediaProjection engine not configured. "
"This engine is only available inside the Android app."
)
self._initialized = True
logger.info("MediaProjection capture stream initialized")
def capture_frame(self) -> Optional[ScreenCapture]:
if not self._initialized:
self.initialize()
# Prefer fresh frames from the queue; fall back to the last
# received frame when the screen is static (MediaProjection
# only emits frames on actual content changes).
try:
return _frame_queue.get(timeout=0.1)
except queue.Empty:
return _last_frame
def cleanup(self) -> None:
# Drain the queue
while not _frame_queue.empty():
try:
_frame_queue.get_nowait()
except queue.Empty:
break
self._initialized = False
logger.info("MediaProjection capture stream cleaned up")
# ---------------------------------------------------------------------------
# CaptureEngine
# ---------------------------------------------------------------------------
class MediaProjectionEngine(CaptureEngine):
"""Android MediaProjection capture engine.
Only available when running inside the LedGrab Android app.
The Kotlin layer calls :func:`configure` at startup and
:func:`push_frame` for each captured frame.
"""
ENGINE_TYPE = "mediaprojection"
ENGINE_PRIORITY = 100 # Highest priority on Android
HAS_OWN_DISPLAYS = True
@classmethod
def is_available(cls) -> bool:
return _active and _display_info is not None
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
return {}
@classmethod
def get_available_displays(cls) -> List[DisplayInfo]:
if _display_info is not None:
return [_display_info]
return []
@classmethod
def create_stream(
cls, display_index: int, config: Dict[str, Any]
) -> MediaProjectionCaptureStream:
return MediaProjectionCaptureStream(display_index, config)
@@ -0,0 +1,279 @@
"""High-performance Android screen capture via the scrcpy protocol.
Uses the ``scrcpy-client`` library to stream H.264/H.265 video from an
Android device over ADB. Frames are decoded by PyAV and delivered as
NumPy arrays at up to 60 FPS — a major upgrade over the ``adb screencap``
polling approach in :mod:`scrcpy_engine` (~1-2 FPS).
Prerequisites (pip packages):
- scrcpy-client>=0.5.0 (pulls in ``av``, ``adbutils``, ``numpy``)
The library pushes a small (~35 KB) ``scrcpy-server.jar`` to the device,
launches it via ``app_process``, and opens an ADB socket for the encoded
video stream. No APK installation, no root.
"""
import threading
from typing import Any, Dict, List, Optional
import numpy as np
from ledgrab.core.capture_engines.base import (
CaptureEngine,
CaptureStream,
DisplayInfo,
ScreenCapture,
)
from ledgrab.utils import get_logger
logger = get_logger(__name__)
try:
import scrcpy
from adbutils import adb as adb_client
_HAS_SCRCPY_CLIENT = True
except ImportError:
_HAS_SCRCPY_CLIENT = False
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _list_devices() -> List[Dict[str, Any]]:
"""Enumerate connected ADB devices via adbutils."""
if not _HAS_SCRCPY_CLIENT:
return []
devices = []
try:
for dev in adb_client.device_list():
serial = dev.serial
# Query model name
try:
model = dev.prop.model or serial
except Exception:
model = serial
# Query screen resolution
width, height = 1920, 1080
try:
wm_output = dev.shell("wm size")
for line in wm_output.strip().splitlines():
if "x" in line:
parts = line.split()[-1].split("x")
width, height = int(parts[0]), int(parts[1])
break
except Exception:
pass
devices.append(
{
"serial": serial,
"model": model,
"width": width,
"height": height,
}
)
except Exception as e:
logger.debug("Failed to enumerate ADB devices: %s", e)
return devices
# ---------------------------------------------------------------------------
# CaptureStream
# ---------------------------------------------------------------------------
class ScrcpyClientCaptureStream(CaptureStream):
"""H.264/H.265 video stream from an Android device via scrcpy protocol.
The ``scrcpy.Client`` runs in a background thread. On each decoded
frame it invokes our callback, which stores the latest frame.
``capture_frame()`` returns it without blocking.
"""
def __init__(self, display_index: int, config: Dict[str, Any]):
super().__init__(display_index, config)
self._client: Optional["scrcpy.Client"] = None
self._latest_frame: Optional[ScreenCapture] = None
self._frame_lock = threading.Lock()
self._frame_event = threading.Event()
self._client_thread: Optional[threading.Thread] = None
self._device_serial: Optional[str] = None
def initialize(self) -> None:
if self._initialized:
return
devices = _list_devices()
if not devices:
raise RuntimeError(
"No ADB devices found. Connect a device via USB or " "'adb connect <ip>' for WiFi."
)
if self.display_index >= len(devices):
raise RuntimeError(
f"Device index {self.display_index} out of range "
f"(found {len(devices)} device(s))"
)
device = devices[self.display_index]
self._device_serial = device["serial"]
max_fps = self.config.get("max_fps", 30)
max_size = self.config.get("max_size", 480)
bitrate = self.config.get("bitrate", 2_000_000)
logger.info(
"scrcpy-client: connecting to %s (%s, %dx%d) — " "max_fps=%d, max_size=%d, bitrate=%d",
self._device_serial,
device["model"],
device["width"],
device["height"],
max_fps,
max_size,
bitrate,
)
self._client = scrcpy.Client(
device=self._device_serial,
max_fps=max_fps,
max_size=max_size,
bitrate=bitrate,
)
self._client.add_listener(scrcpy.EVENT_FRAME, self._on_frame)
# scrcpy.Client.start() blocks, so run in a thread
self._client_thread = threading.Thread(
target=self._run_client, daemon=True, name="scrcpy-client"
)
self._client_thread.start()
# Wait for first frame (with timeout)
if not self._frame_event.wait(timeout=10.0):
logger.warning(
"scrcpy-client: no frame received within 10s from %s",
self._device_serial,
)
self._initialized = True
logger.info("scrcpy-client: stream initialized for %s", self._device_serial)
def _run_client(self) -> None:
"""Start the scrcpy client (blocking)."""
try:
self._client.start(threaded=False)
except Exception as e:
logger.error("scrcpy-client: error for %s: %s", self._device_serial, e)
def _on_frame(self, frame: np.ndarray) -> None:
"""Callback invoked by scrcpy-client for each decoded frame.
The frame arrives as a BGR numpy array; we convert to RGB to
match the ``ScreenCapture`` convention.
"""
if frame is None:
return
# BGR → RGB
rgb = frame[:, :, ::-1].copy()
h, w = rgb.shape[:2]
with self._frame_lock:
self._latest_frame = ScreenCapture(
image=rgb,
width=w,
height=h,
display_index=self.display_index,
)
self._frame_event.set()
def capture_frame(self) -> Optional[ScreenCapture]:
if not self._initialized:
self.initialize()
if not self._frame_event.is_set():
return None
with self._frame_lock:
frame = self._latest_frame
self._frame_event.clear()
return frame
def cleanup(self) -> None:
if self._client is not None:
try:
self._client.stop()
except Exception as e:
logger.debug("scrcpy-client: stop error: %s", e)
if self._client_thread is not None and self._client_thread.is_alive():
self._client_thread.join(timeout=5)
self._client = None
self._client_thread = None
self._latest_frame = None
self._frame_event.clear()
self._initialized = False
logger.info("scrcpy-client: stream cleaned up (%s)", self._device_serial)
# ---------------------------------------------------------------------------
# CaptureEngine
# ---------------------------------------------------------------------------
class ScrcpyClientEngine(CaptureEngine):
"""High-performance Android capture via scrcpy H.264 streaming.
Requires the ``scrcpy-client`` pip package (optional dependency).
When available, this engine is preferred over :class:`ScrcpyEngine`
(which falls back to ``adb screencap`` at ~1-2 FPS).
Prerequisites:
- ``pip install scrcpy-client`` (pulls in PyAV, adbutils)
- adb on PATH (for device connection)
- USB debugging enabled on Android device
"""
ENGINE_TYPE = "scrcpy_client"
ENGINE_PRIORITY = 10 # Higher than ScrcpyEngine (5)
HAS_OWN_DISPLAYS = True
@classmethod
def is_available(cls) -> bool:
return _HAS_SCRCPY_CLIENT
@classmethod
def get_default_config(cls) -> Dict[str, Any]:
return {
"max_fps": 30,
"max_size": 480,
"bitrate": 2_000_000,
}
@classmethod
def get_available_displays(cls) -> List[DisplayInfo]:
devices = _list_devices()
displays = []
for idx, device in enumerate(devices):
displays.append(
DisplayInfo(
index=idx,
name=f"{device['model']} ({device['serial']})",
width=device["width"],
height=device["height"],
x=idx * 500,
y=0,
is_primary=(idx == 0),
refresh_rate=60,
)
)
logger.debug("scrcpy-client: detected %d Android device(s)", len(displays))
return displays
@classmethod
def create_stream(cls, display_index: int, config: Dict[str, Any]) -> ScrcpyClientCaptureStream:
return ScrcpyClientCaptureStream(display_index, config)
@@ -2,10 +2,10 @@
from typing import List, Optional
import cv2
import numpy as np
from ledgrab.core.filters.base import FilterOptionDef, PostprocessingFilter
from ledgrab.utils.image_codec import resize_image
from ledgrab.core.filters.image_pool import ImagePool
from ledgrab.core.filters.registry import FilterRegistry
@@ -44,7 +44,7 @@ class DownscalerFilter(PostprocessingFilter):
if new_h == h and new_w == w:
return None
downscaled = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
downscaled = resize_image(image, new_w, new_h)
result = image_pool.acquire(new_h, new_w, image.shape[2] if image.ndim == 3 else 3)
np.copyto(result, downscaled)
+3 -3
View File
@@ -2,10 +2,10 @@
from typing import List, Optional
import cv2
import numpy as np
from ledgrab.core.filters.base import FilterOptionDef, PostprocessingFilter
from ledgrab.utils.image_codec import resize_image
from ledgrab.core.filters.image_pool import ImagePool
from ledgrab.core.filters.registry import FilterRegistry
@@ -42,8 +42,8 @@ class PixelateFilter(PostprocessingFilter):
# vectorized C++ instead of per-block Python loop
small_w = max(1, w // block_size)
small_h = max(1, h // block_size)
small = cv2.resize(image, (small_w, small_h), interpolation=cv2.INTER_AREA)
pixelated = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST)
small = resize_image(image, small_w, small_h)
pixelated = resize_image(small, w, h)
np.copyto(image, pixelated)
return None
@@ -10,9 +10,10 @@ import threading
import time
from typing import TYPE_CHECKING, List, Optional
import cv2
import numpy as np
from ledgrab.utils.image_codec import resize_image
from ledgrab.core.capture.screen_capture import (
calculate_average_color,
calculate_dominant_color,
@@ -150,7 +151,7 @@ class KeyColorsColorStripStream(ColorStripStream):
calc_fn = _CALC_FNS.get(src.interpolation_mode, calculate_average_color)
# Downsample
small = cv2.resize(capture.image, KC_WORK_SIZE, interpolation=cv2.INTER_AREA)
small = resize_image(capture.image, KC_WORK_SIZE[0], KC_WORK_SIZE[1])
# Extract colors per rectangle
n = len(self._rect_names)
@@ -6,7 +6,10 @@ from collections import deque
from datetime import datetime, timezone
from typing import Dict, Optional
import psutil
try:
import psutil
except ImportError:
psutil = None # type: ignore[assignment]
from ledgrab.utils import get_logger
from ledgrab.utils.gpu import (
@@ -21,8 +24,11 @@ MAX_SAMPLES = 120 # ~2 minutes at 1-second interval
SAMPLE_INTERVAL = 1.0 # seconds
_process = psutil.Process(os.getpid())
_process.cpu_percent(interval=None) # prime process-level counter
if psutil is not None:
_process = psutil.Process(os.getpid())
_process.cpu_percent(interval=None) # prime process-level counter
else:
_process = None # type: ignore[assignment]
def _collect_system_snapshot() -> dict:
@@ -30,6 +36,21 @@ def _collect_system_snapshot() -> dict:
Returns a dict suitable for direct JSON serialization.
"""
if psutil is None or _process is None:
# psutil unavailable (e.g. Android) — return zeroed snapshot
return {
"t": datetime.now(timezone.utc).isoformat(),
"cpu": 0.0,
"ram_pct": 0.0,
"ram_used": 0.0,
"ram_total": 0.0,
"app_cpu": 0.0,
"app_ram": 0.0,
"gpu_util": None,
"gpu_temp": None,
"app_gpu_mem": None,
}
mem = psutil.virtual_memory()
proc_mem = _process.memory_info()
snapshot = {
+8
View File
@@ -198,6 +198,7 @@ import {
// Layer 6: tabs, navigation, command palette, settings
import { switchTab, initTabs, startAutoRefresh, handlePopState } from './features/tabs.ts';
import { callTabLoader } from './core/tab-registry.ts';
import { navigateToCard } from './core/navigation.ts';
import { openCommandPalette, closeCommandPalette, initCommandPalette } from './core/command-palette.ts';
import {
@@ -761,6 +762,13 @@ document.addEventListener('DOMContentLoaded', async () => {
loadDisplays();
loadTargetsTab();
// Trigger the active tab's loader — initTabs() ran before authRequired
// was known, so its conditional loader call may have been skipped.
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
if (activeTab !== 'targets') {
callTabLoader(activeTab);
}
// Start global events WebSocket and auto-refresh
startEventsWS();
startEntityEventListeners();
@@ -2,7 +2,7 @@
* Tab switching — switchTab, initTabs, startAutoRefresh, hash routing.
*/
import { apiKey, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.ts';
import { apiKey, authRequired, refreshInterval, setRefreshInterval, dashboardPollInterval } from '../core/state.ts';
import { getActiveSubTab, setActiveSubTab, callTabLoader, callSubTabSwitcher, getSubTabConfig, getTabConfig } from '../core/tab-registry.ts';
/** Parse location.hash into {tab, subTab}. */
@@ -48,9 +48,12 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
_setHash(name, getActiveSubTab(name));
}
// Authenticated when either auth is disabled or we have an apiKey
const isAuthed = !authRequired || !!apiKey;
if (name === 'dashboard') {
// Use window.* to avoid circular imports with feature modules
if (!skipLoad && apiKey) callTabLoader(name);
if (!skipLoad && isAuthed) callTabLoader(name);
} else {
if (typeof window.stopPerfPolling === 'function') window.stopPerfPolling();
if (typeof window.stopUptimeTimer === 'function') window.stopUptimeTimer();
@@ -59,7 +62,7 @@ export function switchTab(name: string, { updateHash = true, skipLoad = false }:
if (typeof window.disconnectAllKCWebSockets === 'function') window.disconnectAllKCWebSockets();
if (typeof window.disconnectAllLedPreviewWS === 'function') window.disconnectAllLedPreviewWS();
}
if (!apiKey || skipLoad) return;
if (!isAuthed || skipLoad) return;
callTabLoader(name);
}
}
@@ -97,7 +100,8 @@ export function startAutoRefresh(): void {
}
setRefreshInterval(setInterval(() => {
if (!apiKey || document.hidden) return;
const isAuthed = !authRequired || !!apiKey;
if (!isAuthed || document.hidden) return;
const activeTab = localStorage.getItem('activeTab') || 'dashboard';
const cfg = getTabConfig(activeTab);
if (!cfg?.autoRefresh) return;
+4 -1
View File
@@ -80,8 +80,11 @@
"templates.engine.dxcam.desc": "DirectX, low latency",
"templates.engine.bettercam.desc": "DirectX, high performance",
"templates.engine.camera.desc": "USB/IP camera capture",
"templates.engine.scrcpy.desc": "Android screen mirror",
"templates.engine.scrcpy.desc": "Android screen mirror (adb screencap)",
"templates.engine.scrcpy_client.desc": "Android H.264 stream, high FPS",
"templates.engine.wgc.desc": "Windows Graphics Capture",
"templates.engine.demo.desc": "Animated test pattern (demo mode)",
"templates.engine.mediaprojection.desc": "Native Android screen capture",
"templates.config": "Configuration",
"templates.config.show": "Show configuration",
"templates.config.none": "No additional configuration",
+4 -1
View File
@@ -84,8 +84,11 @@
"templates.engine.dxcam.desc": "DirectX, низкая задержка",
"templates.engine.bettercam.desc": "DirectX, высокая производительность",
"templates.engine.camera.desc": "Захват USB/IP камеры",
"templates.engine.scrcpy.desc": "Зеркалирование экрана Android",
"templates.engine.scrcpy.desc": "Зеркалирование экрана Android (adb screencap)",
"templates.engine.scrcpy_client.desc": "Android H.264 поток, высокая частота",
"templates.engine.wgc.desc": "Windows Graphics Capture",
"templates.engine.demo.desc": "Тестовый анимированный шаблон (демо)",
"templates.engine.mediaprojection.desc": "Нативный захват экрана Android",
"templates.config": "Конфигурация",
"templates.config.show": "Показать конфигурацию",
"templates.config.none": "Нет дополнительных настроек",
+4 -1
View File
@@ -84,8 +84,11 @@
"templates.engine.dxcam.desc": "DirectX,低延迟",
"templates.engine.bettercam.desc": "DirectX,高性能",
"templates.engine.camera.desc": "USB/IP摄像头捕获",
"templates.engine.scrcpy.desc": "Android屏幕镜像",
"templates.engine.scrcpy.desc": "Android屏幕镜像 (adb screencap)",
"templates.engine.scrcpy_client.desc": "Android H.264流,高帧率",
"templates.engine.wgc.desc": "Windows图形捕获",
"templates.engine.demo.desc": "动画测试图案(演示模式)",
"templates.engine.mediaprojection.desc": "原生Android屏幕捕获",
"templates.config": "配置",
"templates.config.show": "显示配置",
"templates.config.none": "无额外配置",
+102 -36
View File
@@ -1,24 +1,62 @@
"""Image encoding/decoding/resizing utilities using OpenCV.
"""Image encoding/decoding/resizing utilities.
Uses OpenCV (cv2) when available for best performance, falls back to
Pillow on platforms where cv2 is unavailable (e.g. Android via Chaquopy).
Replaces PIL/Pillow for JPEG encoding, image loading, and resizing operations.
All functions work with numpy RGB arrays (H, W, 3) uint8.
"""
import base64
import io
from pathlib import Path
from typing import Tuple, Union
import cv2
import numpy as np
try:
import cv2
_HAS_CV2 = True
except ImportError:
_HAS_CV2 = False
try:
from PIL import Image
_HAS_PIL = True
except ImportError:
_HAS_PIL = False
def _require_backend() -> None:
if not _HAS_CV2 and not _HAS_PIL:
raise ImportError(
"Neither opencv-python-headless nor Pillow is installed. "
"At least one is required for image operations."
)
# ---------------------------------------------------------------------------
# Encode
# ---------------------------------------------------------------------------
def encode_jpeg(image: np.ndarray, quality: int = 85) -> bytes:
"""Encode an RGB numpy array as JPEG bytes."""
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
if not ok:
raise RuntimeError("JPEG encoding failed")
return buf.tobytes()
if _HAS_CV2:
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
ok, buf = cv2.imencode(".jpg", bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
if not ok:
raise RuntimeError("JPEG encoding failed")
return buf.tobytes()
if _HAS_PIL:
img = Image.fromarray(image, "RGB")
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality)
return buf.getvalue()
_require_backend()
def encode_jpeg_data_uri(image: np.ndarray, quality: int = 85) -> str:
@@ -28,62 +66,90 @@ def encode_jpeg_data_uri(image: np.ndarray, quality: int = 85) -> str:
return f"data:image/jpeg;base64,{b64}"
def resize_image(image: np.ndarray, width: int, height: int) -> np.ndarray:
"""Resize an image to exact dimensions.
# ---------------------------------------------------------------------------
# Resize
# ---------------------------------------------------------------------------
Uses INTER_AREA for downscaling (better quality, faster) and
INTER_LANCZOS4 for upscaling.
"""
h, w = image.shape[:2]
shrinking = (width * height) < (w * h)
interp = cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4
return cv2.resize(image, (width, height), interpolation=interp)
def resize_image(image: np.ndarray, width: int, height: int) -> np.ndarray:
"""Resize an image to exact dimensions."""
if _HAS_CV2:
h, w = image.shape[:2]
shrinking = (width * height) < (w * h)
interp = cv2.INTER_AREA if shrinking else cv2.INTER_LANCZOS4
return cv2.resize(image, (width, height), interpolation=interp)
if _HAS_PIL:
img = Image.fromarray(image, "RGB")
resized = img.resize((width, height), Image.LANCZOS)
return np.asarray(resized)
_require_backend()
def thumbnail(image: np.ndarray, max_width: int) -> np.ndarray:
"""Create a thumbnail that fits within max_width, preserving aspect ratio.
Uses INTER_AREA (optimal for downscaling).
"""
"""Create a thumbnail that fits within max_width, preserving aspect ratio."""
h, w = image.shape[:2]
if w <= max_width:
return image.copy()
scale = max_width / w
new_w = max_width
new_h = max(1, int(h * scale))
return cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
return resize_image(image, new_w, new_h)
def resize_down(image: np.ndarray, max_width: int) -> np.ndarray:
"""Downscale if wider than max_width; return as-is otherwise.
Uses INTER_AREA (optimal for downscaling).
"""
"""Downscale if wider than max_width; return as-is otherwise."""
h, w = image.shape[:2]
if w <= max_width:
return image
scale = max_width / w
new_w = max_width
new_h = max(1, int(h * scale))
return cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
return resize_image(image, new_w, new_h)
# ---------------------------------------------------------------------------
# Load
# ---------------------------------------------------------------------------
def load_image_file(path: Union[str, Path]) -> np.ndarray:
"""Load an image file and return as RGB numpy array."""
path = str(path)
bgr = cv2.imread(path, cv2.IMREAD_COLOR)
if bgr is None:
raise FileNotFoundError(f"Cannot load image: {path}")
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
if _HAS_CV2:
bgr = cv2.imread(path, cv2.IMREAD_COLOR)
if bgr is None:
raise FileNotFoundError(f"Cannot load image: {path}")
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
if _HAS_PIL:
img = Image.open(path).convert("RGB")
return np.asarray(img)
_require_backend()
def load_image_bytes(data: bytes) -> np.ndarray:
"""Decode image bytes (JPEG, PNG, etc.) and return as RGB numpy array."""
arr = np.frombuffer(data, dtype=np.uint8)
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if bgr is None:
raise ValueError("Cannot decode image data")
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
if _HAS_CV2:
arr = np.frombuffer(data, dtype=np.uint8)
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if bgr is None:
raise ValueError("Cannot decode image data")
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
if _HAS_PIL:
img = Image.open(io.BytesIO(data)).convert("RGB")
return np.asarray(img)
_require_backend()
# ---------------------------------------------------------------------------
# Info
# ---------------------------------------------------------------------------
def image_size(image: np.ndarray) -> Tuple[int, int]:
+37
View File
@@ -0,0 +1,37 @@
"""Platform detection utilities.
Centralizes platform checks so the rest of the codebase can use
``is_android()``, ``is_windows()``, etc. instead of ad-hoc
``sys.platform`` comparisons.
Android reports ``sys.platform == "linux"``, so a dedicated check is
needed to distinguish it from desktop Linux.
"""
import os
import sys
_is_android: bool | None = None
def is_android() -> bool:
"""Return True when running inside an Android environment (Chaquopy)."""
global _is_android
if _is_android is None:
_is_android = hasattr(sys, "getandroidapilevel") or bool(os.environ.get("ANDROID_ROOT"))
return _is_android
def is_windows() -> bool:
"""Return True on Windows."""
return sys.platform == "win32"
def is_linux() -> bool:
"""Return True on desktop Linux (excludes Android)."""
return sys.platform == "linux" and not is_android()
def is_macos() -> bool:
"""Return True on macOS."""
return sys.platform == "darwin"