@glassly/crust 0.1.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -0
- package/android/build.gradle +146 -0
- package/android/src/internal/AndroidManifest.xml +9 -0
- package/android/src/internal/java/com/glassly/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
- package/android/src/main/AndroidManifest.xml +33 -0
- package/android/src/main/java/com/glassly/crust/CrustModule.kt +1074 -0
- package/android/src/main/java/com/glassly/crust/CrustView.kt +30 -0
- package/android/src/main/java/com/glassly/crust/heading/HeadingManager.kt +150 -0
- package/android/src/main/java/com/glassly/crust/jsc/JSCDispatcher.kt +189 -0
- package/android/src/main/java/com/glassly/crust/jsc/JSCPolyfillBridge.kt +287 -0
- package/android/src/main/java/com/glassly/crust/jsc/JSCRuntime.kt +593 -0
- package/android/src/main/java/com/glassly/crust/navigation/NavigationManager.kt +1466 -0
- package/android/src/main/java/com/glassly/crust/services/NotificationListener.kt +502 -0
- package/android/src/main/java/com/glassly/crust/services/NotificationProcessBridge.kt +222 -0
- package/android/src/main/java/com/glassly/crust/utils/ImageProcessor.java +452 -0
- package/android/src/main/java/com/glassly/crust/utils/VideoStabilizer.kt +556 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/app.plugin.js +3 -0
- package/build/Crust.types.d.ts +153 -0
- package/build/Crust.types.d.ts.map +1 -0
- package/build/Crust.types.js +2 -0
- package/build/Crust.types.js.map +1 -0
- package/build/CrustModule.d.ts +186 -0
- package/build/CrustModule.d.ts.map +1 -0
- package/build/CrustModule.js +4 -0
- package/build/CrustModule.js.map +1 -0
- package/build/CrustModule.web.d.ts +39 -0
- package/build/CrustModule.web.d.ts.map +1 -0
- package/build/CrustModule.web.js +69 -0
- package/build/CrustModule.web.js.map +1 -0
- package/build/CrustView.d.ts +4 -0
- package/build/CrustView.d.ts.map +1 -0
- package/build/CrustView.js +8 -0
- package/build/CrustView.js.map +1 -0
- package/build/CrustView.web.d.ts +3 -0
- package/build/CrustView.web.d.ts.map +1 -0
- package/build/CrustView.web.js +5 -0
- package/build/CrustView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Crust.podspec +65 -0
- package/ios/CrustModule.swift +784 -0
- package/ios/CrustView.swift +38 -0
- package/ios/Resources/startup.js +804 -0
- package/ios/Source/JSCDispatcher.swift +226 -0
- package/ios/Source/JSCPolyfillBridge.swift +378 -0
- package/ios/Source/JSCRuntime.swift +674 -0
- package/ios/Source/utils/ImageProcessor.swift +392 -0
- package/ios/Source/utils/SystemGestures.swift +53 -0
- package/ios/Source/utils/VideoStabilizer.swift +374 -0
- package/ios/heading/HeadingManager.swift +74 -0
- package/ios/navigation/NavPayloads.swift +62 -0
- package/ios/navigation/NavigationManager.swift +751 -0
- package/package.json +69 -0
- package/plugin/build/index.d.ts +21 -0
- package/plugin/build/index.js +25 -0
- package/plugin/build/withAndroid.d.ts +2 -0
- package/plugin/build/withAndroid.js +133 -0
- package/src/Crust.types.ts +162 -0
- package/src/CrustModule.ts +194 -0
- package/src/CrustModule.web.ts +72 -0
- package/src/CrustView.tsx +10 -0
- package/src/CrustView.web.tsx +9 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
package com.glassly.crust.services
|
|
2
|
+
|
|
3
|
+
import android.app.ActivityManager
|
|
4
|
+
import android.app.Application
|
|
5
|
+
import android.content.BroadcastReceiver
|
|
6
|
+
import android.content.ComponentName
|
|
7
|
+
import android.content.Context
|
|
8
|
+
import android.content.Intent
|
|
9
|
+
import android.content.IntentFilter
|
|
10
|
+
import android.os.Build
|
|
11
|
+
import android.os.Process
|
|
12
|
+
import android.util.Log
|
|
13
|
+
import java.io.File
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Identifies the lightweight process used by NotificationListenerService.
|
|
17
|
+
*
|
|
18
|
+
* The host app's generated MainApplication calls this before initializing
|
|
19
|
+
* React Native. Keep this object free of Expo/React dependencies so loading it
|
|
20
|
+
* cannot recreate the cold-start work that the process split avoids.
|
|
21
|
+
*/
|
|
22
|
+
object NotificationProcess {
|
|
23
|
+
const val SUFFIX = ":notif"
|
|
24
|
+
|
|
25
|
+
@JvmStatic
|
|
26
|
+
fun isCurrent(context: Context): Boolean = currentName(context).endsWith(SUFFIX)
|
|
27
|
+
|
|
28
|
+
private fun currentName(context: Context): String {
|
|
29
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
|
30
|
+
return Application.getProcessName()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager
|
|
34
|
+
activityManager
|
|
35
|
+
?.runningAppProcesses
|
|
36
|
+
?.firstOrNull { it.pid == Process.myPid() }
|
|
37
|
+
?.processName
|
|
38
|
+
?.let { return it }
|
|
39
|
+
|
|
40
|
+
return runCatching {
|
|
41
|
+
File("/proc/self/cmdline").inputStream().bufferedReader().use { it.readText().trimEnd('\u0000') }
|
|
42
|
+
}.getOrDefault(context.packageName)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Signature-protected, process-safe handoff from the notification process to
|
|
48
|
+
* the React Native process. The receiver is dynamic, so sending while the app
|
|
49
|
+
* runtime is dead does not start the expensive main process.
|
|
50
|
+
*/
|
|
51
|
+
object NotificationProcessBridge {
|
|
52
|
+
private const val TAG = "CrustNotificationBridge"
|
|
53
|
+
private const val POSTED_SUFFIX = ".crust.PHONE_NOTIFICATION"
|
|
54
|
+
private const val DISMISSED_SUFFIX = ".crust.PHONE_NOTIFICATION_DISMISSED"
|
|
55
|
+
private const val CONFIG_SUFFIX = ".crust.NOTIFICATION_CONFIG"
|
|
56
|
+
private const val PERMISSION_SUFFIX = ".permission.CRUST_NOTIFICATION_BRIDGE"
|
|
57
|
+
|
|
58
|
+
private const val EXTRA_NOTIFICATION_ID = "notificationId"
|
|
59
|
+
private const val EXTRA_NOTIFICATION_KEY = "notificationKey"
|
|
60
|
+
private const val EXTRA_PACKAGE_NAME = "packageName"
|
|
61
|
+
private const val EXTRA_APP = "app"
|
|
62
|
+
private const val EXTRA_TITLE = "title"
|
|
63
|
+
private const val EXTRA_CONTENT = "content"
|
|
64
|
+
private const val EXTRA_PRIORITY = "priority"
|
|
65
|
+
private const val EXTRA_TIMESTAMP = "timestamp"
|
|
66
|
+
private const val EXTRA_LISTENER_ENABLED = "listenerEnabled"
|
|
67
|
+
private const val EXTRA_BLOCKLIST = "blocklist"
|
|
68
|
+
private const val EXTRA_REQUEST_REBIND = "requestRebind"
|
|
69
|
+
|
|
70
|
+
fun register(
|
|
71
|
+
context: Context,
|
|
72
|
+
onEvent: (String, Map<String, Any>) -> Unit,
|
|
73
|
+
): BroadcastReceiver {
|
|
74
|
+
val applicationContext = context.applicationContext
|
|
75
|
+
val receiver =
|
|
76
|
+
object : BroadcastReceiver() {
|
|
77
|
+
override fun onReceive(receiveContext: Context, intent: Intent) {
|
|
78
|
+
when (intent.action) {
|
|
79
|
+
postedAction(receiveContext) -> {
|
|
80
|
+
onEvent(
|
|
81
|
+
"phone_notification",
|
|
82
|
+
mapOf(
|
|
83
|
+
EXTRA_NOTIFICATION_ID to intent.getStringExtra(EXTRA_NOTIFICATION_ID).orEmpty(),
|
|
84
|
+
EXTRA_APP to intent.getStringExtra(EXTRA_APP).orEmpty(),
|
|
85
|
+
EXTRA_TITLE to intent.getStringExtra(EXTRA_TITLE).orEmpty(),
|
|
86
|
+
EXTRA_CONTENT to intent.getStringExtra(EXTRA_CONTENT).orEmpty(),
|
|
87
|
+
EXTRA_PRIORITY to intent.getIntExtra(EXTRA_PRIORITY, 0),
|
|
88
|
+
EXTRA_TIMESTAMP to intent.getLongExtra(EXTRA_TIMESTAMP, 0L),
|
|
89
|
+
EXTRA_PACKAGE_NAME to intent.getStringExtra(EXTRA_PACKAGE_NAME).orEmpty(),
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
dismissedAction(receiveContext) -> {
|
|
94
|
+
onEvent(
|
|
95
|
+
"phone_notification_dismissed",
|
|
96
|
+
mapOf(
|
|
97
|
+
EXTRA_NOTIFICATION_ID to intent.getStringExtra(EXTRA_NOTIFICATION_ID).orEmpty(),
|
|
98
|
+
EXTRA_NOTIFICATION_KEY to intent.getStringExtra(EXTRA_NOTIFICATION_KEY).orEmpty(),
|
|
99
|
+
EXTRA_PACKAGE_NAME to intent.getStringExtra(EXTRA_PACKAGE_NAME).orEmpty(),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
val filter =
|
|
108
|
+
IntentFilter().apply {
|
|
109
|
+
addAction(postedAction(applicationContext))
|
|
110
|
+
addAction(dismissedAction(applicationContext))
|
|
111
|
+
}
|
|
112
|
+
val permission = bridgePermission(applicationContext)
|
|
113
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
|
114
|
+
applicationContext.registerReceiver(
|
|
115
|
+
receiver,
|
|
116
|
+
filter,
|
|
117
|
+
permission,
|
|
118
|
+
null,
|
|
119
|
+
Context.RECEIVER_NOT_EXPORTED,
|
|
120
|
+
)
|
|
121
|
+
} else {
|
|
122
|
+
@Suppress("UnspecifiedRegisterReceiverFlag")
|
|
123
|
+
applicationContext.registerReceiver(receiver, filter, permission, null)
|
|
124
|
+
}
|
|
125
|
+
return receiver
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fun unregister(context: Context, receiver: BroadcastReceiver) {
|
|
129
|
+
runCatching { context.applicationContext.unregisterReceiver(receiver) }
|
|
130
|
+
.onFailure { Log.w(TAG, "Could not unregister notification bridge", it) }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
fun emitPosted(
|
|
134
|
+
context: Context,
|
|
135
|
+
notificationKey: String,
|
|
136
|
+
packageName: String,
|
|
137
|
+
appName: String,
|
|
138
|
+
title: String,
|
|
139
|
+
text: String,
|
|
140
|
+
timestamp: Long,
|
|
141
|
+
priority: Int,
|
|
142
|
+
) {
|
|
143
|
+
val intent =
|
|
144
|
+
Intent(postedAction(context))
|
|
145
|
+
.setPackage(context.packageName)
|
|
146
|
+
.putExtra(EXTRA_NOTIFICATION_ID, "$packageName-$notificationKey")
|
|
147
|
+
.putExtra(EXTRA_APP, appName)
|
|
148
|
+
.putExtra(EXTRA_TITLE, title.ifEmpty { appName })
|
|
149
|
+
.putExtra(EXTRA_CONTENT, text)
|
|
150
|
+
.putExtra(EXTRA_PRIORITY, priority)
|
|
151
|
+
.putExtra(EXTRA_TIMESTAMP, timestamp)
|
|
152
|
+
.putExtra(EXTRA_PACKAGE_NAME, packageName)
|
|
153
|
+
context.sendBroadcast(intent, bridgePermission(context))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fun emitDismissed(context: Context, notificationKey: String, packageName: String) {
|
|
157
|
+
val intent =
|
|
158
|
+
Intent(dismissedAction(context))
|
|
159
|
+
.setPackage(context.packageName)
|
|
160
|
+
.putExtra(EXTRA_NOTIFICATION_ID, "$packageName-$notificationKey")
|
|
161
|
+
.putExtra(EXTRA_NOTIFICATION_KEY, notificationKey)
|
|
162
|
+
.putExtra(EXTRA_PACKAGE_NAME, packageName)
|
|
163
|
+
context.sendBroadcast(intent, bridgePermission(context))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
fun sendConfig(
|
|
167
|
+
context: Context,
|
|
168
|
+
listenerEnabled: Boolean,
|
|
169
|
+
blocklist: Set<String>,
|
|
170
|
+
requestRebind: Boolean,
|
|
171
|
+
) {
|
|
172
|
+
val intent =
|
|
173
|
+
Intent(configAction(context))
|
|
174
|
+
.setComponent(ComponentName(context, NotificationConfigReceiver::class.java))
|
|
175
|
+
.putExtra(EXTRA_LISTENER_ENABLED, listenerEnabled)
|
|
176
|
+
.putStringArrayListExtra(EXTRA_BLOCKLIST, ArrayList(blocklist))
|
|
177
|
+
.putExtra(EXTRA_REQUEST_REBIND, requestRebind)
|
|
178
|
+
context.sendBroadcast(intent, bridgePermission(context))
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
internal fun readConfig(intent: Intent): NotificationConfigUpdate {
|
|
182
|
+
return NotificationConfigUpdate(
|
|
183
|
+
listenerEnabled = intent.getBooleanExtra(EXTRA_LISTENER_ENABLED, false),
|
|
184
|
+
blocklist = intent.getStringArrayListExtra(EXTRA_BLOCKLIST)?.toSet() ?: emptySet(),
|
|
185
|
+
requestRebind = intent.getBooleanExtra(EXTRA_REQUEST_REBIND, false),
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
internal fun isConfigAction(context: Context, intent: Intent): Boolean = intent.action == configAction(context)
|
|
190
|
+
|
|
191
|
+
private fun postedAction(context: Context) = context.packageName + POSTED_SUFFIX
|
|
192
|
+
|
|
193
|
+
private fun dismissedAction(context: Context) = context.packageName + DISMISSED_SUFFIX
|
|
194
|
+
|
|
195
|
+
private fun configAction(context: Context) = context.packageName + CONFIG_SUFFIX
|
|
196
|
+
|
|
197
|
+
private fun bridgePermission(context: Context) = context.packageName + PERMISSION_SUFFIX
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
internal data class NotificationConfigUpdate(
|
|
201
|
+
val listenerEnabled: Boolean,
|
|
202
|
+
val blocklist: Set<String>,
|
|
203
|
+
val requestRebind: Boolean,
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
/** Persists and applies config inside the isolated notification process. */
|
|
207
|
+
class NotificationConfigReceiver : BroadcastReceiver() {
|
|
208
|
+
override fun onReceive(context: Context, intent: Intent) {
|
|
209
|
+
if (!NotificationProcessBridge.isConfigAction(context, intent)) return
|
|
210
|
+
val config = NotificationProcessBridge.readConfig(intent)
|
|
211
|
+
|
|
212
|
+
// SharedPreferences caches are per-process. Persist the payload here before
|
|
213
|
+
// touching the live instance so a listener recreated by requestRebind reads
|
|
214
|
+
// the same config rather than this process's stale cache.
|
|
215
|
+
NotificationListener.persistConfig(context, config.listenerEnabled, config.blocklist)
|
|
216
|
+
NotificationListener.applyConfigToExisting(config.listenerEnabled, config.blocklist)
|
|
217
|
+
|
|
218
|
+
if (config.listenerEnabled && config.requestRebind) {
|
|
219
|
+
NotificationListener.requestListenerRebind(context)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
package com.glassly.crust.utils;
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap;
|
|
4
|
+
import android.graphics.BitmapFactory;
|
|
5
|
+
import android.util.Log;
|
|
6
|
+
|
|
7
|
+
import java.io.File;
|
|
8
|
+
import java.io.FileOutputStream;
|
|
9
|
+
import java.io.IOException;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Image processor for gallery photos synced from Glassly glasses.
|
|
13
|
+
* Applies lens distortion correction and color correction to improve
|
|
14
|
+
* photo quality before saving to the user's camera roll.
|
|
15
|
+
*/
|
|
16
|
+
public class ImageProcessor {
|
|
17
|
+
private static final String TAG = "ImageProcessor";
|
|
18
|
+
|
|
19
|
+
// Brown-Conrady distortion coefficients for the Mentra Live camera
|
|
20
|
+
// (Sony sensor, 118-degree FOV fisheye lens)
|
|
21
|
+
// Calibrated from chessboard photos at 3264x2448 native sensor resolution.
|
|
22
|
+
// Brown-Conrady lens distortion coefficients
|
|
23
|
+
private static final double K1 = -0.10; // Radial distortion (barrel)
|
|
24
|
+
private static final double K2 = 0.02; // Radial distortion (higher order)
|
|
25
|
+
private static final double P1 = 0.0; // Tangential distortion
|
|
26
|
+
private static final double P2 = 0.0; // Tangential distortion
|
|
27
|
+
|
|
28
|
+
// --- Color pipeline tuning parameters ---
|
|
29
|
+
|
|
30
|
+
// Tone curve anchor points (X values fixed at 0.0, 0.25, 0.50, 0.75, 1.0)
|
|
31
|
+
// Y values control the S-curve shape in linear space.
|
|
32
|
+
// Slight shadow lift (0.02) for low-light camera, full whites, punchy midtone contrast.
|
|
33
|
+
private static final double[] TONE_CURVE_Y = {0.02, 0.20, 0.52, 0.82, 1.0};
|
|
34
|
+
|
|
35
|
+
// Vibrance: selective saturation boost for desaturated colors (0.0 = off, 1.0 = max)
|
|
36
|
+
private static final float VIBRANCE_AMOUNT = 0.45f;
|
|
37
|
+
|
|
38
|
+
// Color correction matrix (3x4: RGB coefficients + bias per channel)
|
|
39
|
+
// Adjusts warmth/white balance to compensate for the glasses camera's color cast.
|
|
40
|
+
private static final float CM_RR = 1.06f, CM_RG = 0.02f, CM_RB = -0.01f, CM_R_BIAS = 5.0f / 255.0f;
|
|
41
|
+
private static final float CM_GR = 0.01f, CM_GG = 1.04f, CM_GB = -0.01f, CM_G_BIAS = 3.0f / 255.0f;
|
|
42
|
+
private static final float CM_BR = -0.02f, CM_BG = 0.01f, CM_BB = 1.02f, CM_B_BIAS = 0.0f;
|
|
43
|
+
|
|
44
|
+
// sRGB gamma decode LUT: sRGB byte (0-255) -> linear float (0.0-1.0)
|
|
45
|
+
// iOS CIFilters operate in linear space internally; this matches that behavior.
|
|
46
|
+
private static final float[] LINEARIZE_LUT = buildLinearizeLut();
|
|
47
|
+
|
|
48
|
+
// sRGB gamma encode LUT: linear value (0-4095, Q12 fixed-point) -> sRGB byte (0-255)
|
|
49
|
+
private static final int[] DELINEARIZE_LUT = buildDelinearizeLut();
|
|
50
|
+
private static final int DELIN_LUT_SIZE = 4096;
|
|
51
|
+
|
|
52
|
+
private static float[] buildLinearizeLut() {
|
|
53
|
+
float[] lut = new float[256];
|
|
54
|
+
for (int i = 0; i < 256; i++) {
|
|
55
|
+
double v = i / 255.0;
|
|
56
|
+
if (v <= 0.04045) {
|
|
57
|
+
lut[i] = (float) (v / 12.92);
|
|
58
|
+
} else {
|
|
59
|
+
lut[i] = (float) Math.pow((v + 0.055) / 1.055, 2.4);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return lut;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private static int[] buildDelinearizeLut() {
|
|
66
|
+
int[] lut = new int[DELIN_LUT_SIZE];
|
|
67
|
+
for (int i = 0; i < DELIN_LUT_SIZE; i++) {
|
|
68
|
+
double v = (double) i / (DELIN_LUT_SIZE - 1);
|
|
69
|
+
double s;
|
|
70
|
+
if (v <= 0.0031308) {
|
|
71
|
+
s = v * 12.92;
|
|
72
|
+
} else {
|
|
73
|
+
s = 1.055 * Math.pow(v, 1.0 / 2.4) - 0.055;
|
|
74
|
+
}
|
|
75
|
+
lut[i] = Math.max(0, Math.min(255, (int) (s * 255 + 0.5)));
|
|
76
|
+
}
|
|
77
|
+
return lut;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Tone-curve LUT operating in linear space [0.0-1.0] -> [0.0-1.0]
|
|
81
|
+
// Piecewise-linear matching iOS CIToneCurve anchor points.
|
|
82
|
+
// Input/output are linear-light values, not gamma-encoded.
|
|
83
|
+
private static final float[] TONE_LUT_LINEAR = buildToneLutLinear();
|
|
84
|
+
|
|
85
|
+
private static float[] buildToneLutLinear() {
|
|
86
|
+
float[] lut = new float[DELIN_LUT_SIZE];
|
|
87
|
+
double[] ax = {0.0, 0.25, 0.50, 0.75, 1.0};
|
|
88
|
+
double[] ay = TONE_CURVE_Y;
|
|
89
|
+
for (int i = 0; i < DELIN_LUT_SIZE; i++) {
|
|
90
|
+
double x = (double) i / (DELIN_LUT_SIZE - 1);
|
|
91
|
+
double y;
|
|
92
|
+
if (x <= ax[1]) {
|
|
93
|
+
y = ay[0] + (ay[1] - ay[0]) * (x - ax[0]) / (ax[1] - ax[0]);
|
|
94
|
+
} else if (x <= ax[2]) {
|
|
95
|
+
y = ay[1] + (ay[2] - ay[1]) * (x - ax[1]) / (ax[2] - ax[1]);
|
|
96
|
+
} else if (x <= ax[3]) {
|
|
97
|
+
y = ay[2] + (ay[3] - ay[2]) * (x - ax[2]) / (ax[3] - ax[2]);
|
|
98
|
+
} else {
|
|
99
|
+
y = ay[3] + (ay[4] - ay[3]) * (x - ax[3]) / (ax[4] - ax[3]);
|
|
100
|
+
}
|
|
101
|
+
lut[i] = (float) Math.max(0.0, Math.min(1.0, y));
|
|
102
|
+
}
|
|
103
|
+
return lut;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Look up a tone-curve value for a linear float input. */
|
|
107
|
+
private static float toneCurveLookup(float linearVal) {
|
|
108
|
+
int idx = Math.max(0, Math.min(DELIN_LUT_SIZE - 1, (int) (linearVal * (DELIN_LUT_SIZE - 1) + 0.5f)));
|
|
109
|
+
return TONE_LUT_LINEAR[idx];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Convert a linear [0,1] float back to sRGB byte via LUT. */
|
|
113
|
+
private static int toSrgbByte(float linear) {
|
|
114
|
+
int idx = Math.max(0, Math.min(DELIN_LUT_SIZE - 1, (int) (linear * (DELIN_LUT_SIZE - 1) + 0.5f)));
|
|
115
|
+
return DELINEARIZE_LUT[idx];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Precomputed LUT for lens correction (lazily initialized).
|
|
119
|
+
// Stored as fixed-point Q8 (multiply by 256) for sub-pixel bilinear interpolation.
|
|
120
|
+
private static int[] sRemapXQ8;
|
|
121
|
+
private static int[] sRemapYQ8;
|
|
122
|
+
private static int sLutWidth;
|
|
123
|
+
private static int sLutHeight;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Process a gallery image with the specified corrections.
|
|
127
|
+
*
|
|
128
|
+
* @param inputPath Path to the input JPEG file
|
|
129
|
+
* @param outputPath Path to write the processed JPEG
|
|
130
|
+
* @param lensCorrection Whether to apply barrel distortion correction
|
|
131
|
+
* @param colorCorrection Whether to apply color/white balance correction
|
|
132
|
+
* @param jpegQuality JPEG output quality (1-100)
|
|
133
|
+
* @return processing time in milliseconds, or -1 on failure
|
|
134
|
+
*/
|
|
135
|
+
public static long process(String inputPath, String outputPath,
|
|
136
|
+
boolean lensCorrection, boolean colorCorrection,
|
|
137
|
+
int jpegQuality) {
|
|
138
|
+
long startTime = System.currentTimeMillis();
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
// Decode the input image
|
|
142
|
+
BitmapFactory.Options opts = new BitmapFactory.Options();
|
|
143
|
+
opts.inMutable = !lensCorrection; // Need mutable only for color correction without lens
|
|
144
|
+
Bitmap src = BitmapFactory.decodeFile(inputPath, opts);
|
|
145
|
+
if (src == null) {
|
|
146
|
+
Log.e(TAG, "Failed to decode image: " + inputPath);
|
|
147
|
+
return -1;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
int w = src.getWidth();
|
|
151
|
+
int h = src.getHeight();
|
|
152
|
+
Log.d(TAG, "Processing image: " + w + "x" + h
|
|
153
|
+
+ " lens=" + lensCorrection + " color=" + colorCorrection);
|
|
154
|
+
|
|
155
|
+
Bitmap result = src;
|
|
156
|
+
|
|
157
|
+
// Step 1: Lens distortion correction
|
|
158
|
+
if (lensCorrection) {
|
|
159
|
+
result = applyLensCorrection(src, w, h);
|
|
160
|
+
if (result != src) {
|
|
161
|
+
src.recycle();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Step 2: Tone mapping + vibrance + color correction in linear space
|
|
166
|
+
if (colorCorrection) {
|
|
167
|
+
Bitmap colorCorrected = applyColorPipeline(result);
|
|
168
|
+
if (colorCorrected != result) {
|
|
169
|
+
result.recycle();
|
|
170
|
+
result = colorCorrected;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Write output JPEG
|
|
175
|
+
File outFile = new File(outputPath);
|
|
176
|
+
try (FileOutputStream fos = new FileOutputStream(outFile)) {
|
|
177
|
+
result.compress(Bitmap.CompressFormat.JPEG, jpegQuality, fos);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
result.recycle();
|
|
181
|
+
|
|
182
|
+
long elapsed = System.currentTimeMillis() - startTime;
|
|
183
|
+
Log.d(TAG, "Image processing complete in " + elapsed + "ms -> " + outputPath);
|
|
184
|
+
return elapsed;
|
|
185
|
+
|
|
186
|
+
} catch (Exception e) {
|
|
187
|
+
Log.e(TAG, "Image processing failed", e);
|
|
188
|
+
return -1;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Apply Brown-Conrady lens distortion correction using a precomputed LUT
|
|
194
|
+
* with bilinear interpolation for sub-pixel accuracy.
|
|
195
|
+
*/
|
|
196
|
+
private static Bitmap applyLensCorrection(Bitmap src, int w, int h) {
|
|
197
|
+
// Build or reuse the remapping LUT
|
|
198
|
+
if (sRemapXQ8 == null || sLutWidth != w || sLutHeight != h) {
|
|
199
|
+
buildRemapLut(w, h);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Read source pixels
|
|
203
|
+
int[] srcPixels = new int[w * h];
|
|
204
|
+
src.getPixels(srcPixels, 0, w, 0, 0, w, h);
|
|
205
|
+
|
|
206
|
+
// Apply remapping with bilinear interpolation
|
|
207
|
+
int[] dstPixels = new int[w * h];
|
|
208
|
+
int maxX = w - 1;
|
|
209
|
+
int maxY = h - 1;
|
|
210
|
+
|
|
211
|
+
for (int i = 0; i < w * h; i++) {
|
|
212
|
+
int sxQ8 = sRemapXQ8[i];
|
|
213
|
+
int syQ8 = sRemapYQ8[i];
|
|
214
|
+
|
|
215
|
+
// Integer part (floor)
|
|
216
|
+
int x0 = sxQ8 >> 8;
|
|
217
|
+
int y0 = syQ8 >> 8;
|
|
218
|
+
|
|
219
|
+
if (x0 < 0 || x0 >= maxX || y0 < 0 || y0 >= maxY) {
|
|
220
|
+
// Out of bounds — black pixel
|
|
221
|
+
dstPixels[i] = 0xFF000000;
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Fractional part (0-255)
|
|
226
|
+
int fx = sxQ8 & 0xFF;
|
|
227
|
+
int fy = syQ8 & 0xFF;
|
|
228
|
+
int ifx = 256 - fx;
|
|
229
|
+
int ify = 256 - fy;
|
|
230
|
+
|
|
231
|
+
// Four source pixels
|
|
232
|
+
int idx00 = y0 * w + x0;
|
|
233
|
+
int p00 = srcPixels[idx00];
|
|
234
|
+
int p10 = srcPixels[idx00 + 1];
|
|
235
|
+
int p01 = srcPixels[idx00 + w];
|
|
236
|
+
int p11 = srcPixels[idx00 + w + 1];
|
|
237
|
+
|
|
238
|
+
// Bilinear blend per channel
|
|
239
|
+
int r = (ifx * ify * ((p00 >> 16) & 0xFF) + fx * ify * ((p10 >> 16) & 0xFF)
|
|
240
|
+
+ ifx * fy * ((p01 >> 16) & 0xFF) + fx * fy * ((p11 >> 16) & 0xFF)) >> 16;
|
|
241
|
+
int g = (ifx * ify * ((p00 >> 8) & 0xFF) + fx * ify * ((p10 >> 8) & 0xFF)
|
|
242
|
+
+ ifx * fy * ((p01 >> 8) & 0xFF) + fx * fy * ((p11 >> 8) & 0xFF)) >> 16;
|
|
243
|
+
int b = (ifx * ify * (p00 & 0xFF) + fx * ify * (p10 & 0xFF)
|
|
244
|
+
+ ifx * fy * (p01 & 0xFF) + fx * fy * (p11 & 0xFF)) >> 16;
|
|
245
|
+
|
|
246
|
+
dstPixels[i] = 0xFF000000 | (r << 16) | (g << 8) | b;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
Bitmap dst = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
|
|
250
|
+
dst.setPixels(dstPixels, 0, w, 0, 0, w, h);
|
|
251
|
+
return dst;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Build the remapping LUT for lens distortion correction.
|
|
256
|
+
* Coordinates are stored as Q8 fixed-point (value * 256) to support
|
|
257
|
+
* sub-pixel bilinear interpolation without floating point per-pixel.
|
|
258
|
+
*/
|
|
259
|
+
private static synchronized void buildRemapLut(int w, int h) {
|
|
260
|
+
Log.d(TAG, "Building lens correction LUT for " + w + "x" + h);
|
|
261
|
+
long t0 = System.currentTimeMillis();
|
|
262
|
+
|
|
263
|
+
int[] remapXQ8 = new int[w * h];
|
|
264
|
+
int[] remapYQ8 = new int[w * h];
|
|
265
|
+
|
|
266
|
+
double cx = w / 2.0;
|
|
267
|
+
double cy = h / 2.0;
|
|
268
|
+
double norm = Math.sqrt(cx * cx + cy * cy);
|
|
269
|
+
|
|
270
|
+
for (int y = 0; y < h; y++) {
|
|
271
|
+
for (int x = 0; x < w; x++) {
|
|
272
|
+
double xn = (x - cx) / norm;
|
|
273
|
+
double yn = (y - cy) / norm;
|
|
274
|
+
double r2 = xn * xn + yn * yn;
|
|
275
|
+
double r4 = r2 * r2;
|
|
276
|
+
|
|
277
|
+
double radial = 1.0 + K1 * r2 + K2 * r4;
|
|
278
|
+
double xd = xn * radial + 2 * P1 * xn * yn + P2 * (r2 + 2 * xn * xn);
|
|
279
|
+
double yd = yn * radial + P1 * (r2 + 2 * yn * yn) + 2 * P2 * xn * yn;
|
|
280
|
+
|
|
281
|
+
// Store as Q8 fixed-point for bilinear interpolation
|
|
282
|
+
int idx = y * w + x;
|
|
283
|
+
remapXQ8[idx] = (int) ((xd * norm + cx) * 256);
|
|
284
|
+
remapYQ8[idx] = (int) ((yd * norm + cy) * 256);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
sRemapXQ8 = remapXQ8;
|
|
289
|
+
sRemapYQ8 = remapYQ8;
|
|
290
|
+
sLutWidth = w;
|
|
291
|
+
sLutHeight = h;
|
|
292
|
+
|
|
293
|
+
Log.d(TAG, "LUT built in " + (System.currentTimeMillis() - t0) + "ms");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Apply tone mapping + vibrance + color correction in a single pass,
|
|
298
|
+
* all in linear light space to match iOS CIFilter behavior.
|
|
299
|
+
*
|
|
300
|
+
* Pipeline per pixel:
|
|
301
|
+
* 1. sRGB gamma decode (byte → linear float via LUT)
|
|
302
|
+
* 2. Tone curve (piecewise-linear S-curve in linear space)
|
|
303
|
+
* 3. Vibrance (luminance-based selective saturation boost)
|
|
304
|
+
* 4. Color correction matrix (warmth/white balance)
|
|
305
|
+
* 5. sRGB gamma encode (linear float → byte via LUT)
|
|
306
|
+
*/
|
|
307
|
+
private static Bitmap applyColorPipeline(Bitmap src) {
|
|
308
|
+
int w = src.getWidth(), h = src.getHeight();
|
|
309
|
+
int[] pixels = new int[w * h];
|
|
310
|
+
src.getPixels(pixels, 0, w, 0, 0, w, h);
|
|
311
|
+
|
|
312
|
+
for (int i = 0; i < pixels.length; i++) {
|
|
313
|
+
// 1. Linearize sRGB → linear
|
|
314
|
+
float lr = LINEARIZE_LUT[(pixels[i] >> 16) & 0xFF];
|
|
315
|
+
float lg = LINEARIZE_LUT[(pixels[i] >> 8) & 0xFF];
|
|
316
|
+
float lb = LINEARIZE_LUT[pixels[i] & 0xFF];
|
|
317
|
+
|
|
318
|
+
// 2a. Tone curve in linear space
|
|
319
|
+
lr = toneCurveLookup(lr);
|
|
320
|
+
lg = toneCurveLookup(lg);
|
|
321
|
+
lb = toneCurveLookup(lb);
|
|
322
|
+
|
|
323
|
+
// 2b. Vibrance — luminance-based, stays in linear space (no HSV round-trip)
|
|
324
|
+
// Matches CIVibrance behavior: selectively boosts desaturated colors
|
|
325
|
+
float lum = 0.2126f * lr + 0.7152f * lg + 0.0722f * lb;
|
|
326
|
+
float maxC = Math.max(lr, Math.max(lg, lb));
|
|
327
|
+
float minC = Math.min(lr, Math.min(lg, lb));
|
|
328
|
+
float sat = (maxC > 0.0001f) ? (maxC - minC) / maxC : 0f;
|
|
329
|
+
float amount = VIBRANCE_AMOUNT * (1.0f - sat); // boost desaturated more
|
|
330
|
+
lr = lr + (lr - lum) * amount;
|
|
331
|
+
lg = lg + (lg - lum) * amount;
|
|
332
|
+
lb = lb + (lb - lum) * amount;
|
|
333
|
+
|
|
334
|
+
// 2c. Color correction matrix in linear space
|
|
335
|
+
float cr = CM_RR * lr + CM_RG * lg + CM_RB * lb + CM_R_BIAS;
|
|
336
|
+
float cg = CM_GR * lr + CM_GG * lg + CM_GB * lb + CM_G_BIAS;
|
|
337
|
+
float cb = CM_BR * lr + CM_BG * lg + CM_BB * lb + CM_B_BIAS;
|
|
338
|
+
|
|
339
|
+
// Clamp to [0, 1]
|
|
340
|
+
cr = Math.max(0f, Math.min(1f, cr));
|
|
341
|
+
cg = Math.max(0f, Math.min(1f, cg));
|
|
342
|
+
cb = Math.max(0f, Math.min(1f, cb));
|
|
343
|
+
|
|
344
|
+
// 5. Encode linear → sRGB
|
|
345
|
+
int outR = toSrgbByte(cr);
|
|
346
|
+
int outG = toSrgbByte(cg);
|
|
347
|
+
int outB = toSrgbByte(cb);
|
|
348
|
+
|
|
349
|
+
pixels[i] = 0xFF000000 | (outR << 16) | (outG << 8) | outB;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
Bitmap dst = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
|
|
353
|
+
dst.setPixels(pixels, 0, w, 0, 0, w, h);
|
|
354
|
+
return dst;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Merge 3 exposure-bracketed images into a single HDR result using
|
|
359
|
+
* simple exposure fusion (Mertens' method approximation).
|
|
360
|
+
*
|
|
361
|
+
* @param underPath Path to the underexposed image (EV-2)
|
|
362
|
+
* @param normalPath Path to the normally exposed image (EV0)
|
|
363
|
+
* @param overPath Path to the overexposed image (EV+2)
|
|
364
|
+
* @param outputPath Path to write the merged HDR result
|
|
365
|
+
* @param jpegQuality JPEG output quality
|
|
366
|
+
* @return processing time in ms, or -1 on failure
|
|
367
|
+
*/
|
|
368
|
+
public static long mergeHdr(String underPath, String normalPath, String overPath,
|
|
369
|
+
String outputPath, int jpegQuality) {
|
|
370
|
+
long startTime = System.currentTimeMillis();
|
|
371
|
+
|
|
372
|
+
try {
|
|
373
|
+
Bitmap under = BitmapFactory.decodeFile(underPath);
|
|
374
|
+
Bitmap normal = BitmapFactory.decodeFile(normalPath);
|
|
375
|
+
Bitmap over = BitmapFactory.decodeFile(overPath);
|
|
376
|
+
|
|
377
|
+
if (under == null || normal == null || over == null) {
|
|
378
|
+
Log.e(TAG, "Failed to decode one or more HDR bracket images");
|
|
379
|
+
return -1;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
int w = normal.getWidth();
|
|
383
|
+
int h = normal.getHeight();
|
|
384
|
+
|
|
385
|
+
// Ensure all images are the same size
|
|
386
|
+
if (under.getWidth() != w || under.getHeight() != h
|
|
387
|
+
|| over.getWidth() != w || over.getHeight() != h) {
|
|
388
|
+
Log.w(TAG, "HDR bracket images have different sizes, resizing");
|
|
389
|
+
under = Bitmap.createScaledBitmap(under, w, h, true);
|
|
390
|
+
over = Bitmap.createScaledBitmap(over, w, h, true);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
int[] underPixels = new int[w * h];
|
|
394
|
+
int[] normalPixels = new int[w * h];
|
|
395
|
+
int[] overPixels = new int[w * h];
|
|
396
|
+
under.getPixels(underPixels, 0, w, 0, 0, w, h);
|
|
397
|
+
normal.getPixels(normalPixels, 0, w, 0, 0, w, h);
|
|
398
|
+
over.getPixels(overPixels, 0, w, 0, 0, w, h);
|
|
399
|
+
|
|
400
|
+
int[] resultPixels = new int[w * h];
|
|
401
|
+
|
|
402
|
+
// Simple exposure fusion: weight each pixel by how well-exposed it is
|
|
403
|
+
// Well-exposed = closer to mid-gray (128). Clip recovery from under/over.
|
|
404
|
+
for (int i = 0; i < w * h; i++) {
|
|
405
|
+
int uR = (underPixels[i] >> 16) & 0xFF;
|
|
406
|
+
int uG = (underPixels[i] >> 8) & 0xFF;
|
|
407
|
+
int uB = underPixels[i] & 0xFF;
|
|
408
|
+
float uLum = (uR + uG + uB) / 3.0f / 255.0f;
|
|
409
|
+
float uWeight = 4.0f * uLum * (1.0f - uLum) + 0.01f; // Gaussian-ish around 0.5
|
|
410
|
+
|
|
411
|
+
int nR = (normalPixels[i] >> 16) & 0xFF;
|
|
412
|
+
int nG = (normalPixels[i] >> 8) & 0xFF;
|
|
413
|
+
int nB = normalPixels[i] & 0xFF;
|
|
414
|
+
float nLum = (nR + nG + nB) / 3.0f / 255.0f;
|
|
415
|
+
float nWeight = 4.0f * nLum * (1.0f - nLum) + 0.01f;
|
|
416
|
+
|
|
417
|
+
int oR = (overPixels[i] >> 16) & 0xFF;
|
|
418
|
+
int oG = (overPixels[i] >> 8) & 0xFF;
|
|
419
|
+
int oB = overPixels[i] & 0xFF;
|
|
420
|
+
float oLum = (oR + oG + oB) / 3.0f / 255.0f;
|
|
421
|
+
float oWeight = 4.0f * oLum * (1.0f - oLum) + 0.01f;
|
|
422
|
+
|
|
423
|
+
float total = uWeight + nWeight + oWeight;
|
|
424
|
+
int rOut = Math.min(255, Math.round((uR * uWeight + nR * nWeight + oR * oWeight) / total));
|
|
425
|
+
int gOut = Math.min(255, Math.round((uG * uWeight + nG * nWeight + oG * oWeight) / total));
|
|
426
|
+
int bOut = Math.min(255, Math.round((uB * uWeight + nB * nWeight + oB * oWeight) / total));
|
|
427
|
+
|
|
428
|
+
resultPixels[i] = 0xFF000000 | (rOut << 16) | (gOut << 8) | bOut;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
under.recycle();
|
|
432
|
+
normal.recycle();
|
|
433
|
+
over.recycle();
|
|
434
|
+
|
|
435
|
+
Bitmap result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
|
|
436
|
+
result.setPixels(resultPixels, 0, w, 0, 0, w, h);
|
|
437
|
+
|
|
438
|
+
try (FileOutputStream fos = new FileOutputStream(new File(outputPath))) {
|
|
439
|
+
result.compress(Bitmap.CompressFormat.JPEG, jpegQuality, fos);
|
|
440
|
+
}
|
|
441
|
+
result.recycle();
|
|
442
|
+
|
|
443
|
+
long elapsed = System.currentTimeMillis() - startTime;
|
|
444
|
+
Log.d(TAG, "HDR merge complete in " + elapsed + "ms -> " + outputPath);
|
|
445
|
+
return elapsed;
|
|
446
|
+
|
|
447
|
+
} catch (Exception e) {
|
|
448
|
+
Log.e(TAG, "HDR merge failed", e);
|
|
449
|
+
return -1;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|