@mentra/crust 0.1.0-dev.0

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.
Files changed (66) hide show
  1. package/README.md +35 -0
  2. package/android/build.gradle +146 -0
  3. package/android/src/internal/AndroidManifest.xml +9 -0
  4. package/android/src/internal/java/com/mentra/crust/receivers/CaptionsTesterIncidentReceiver.kt +46 -0
  5. package/android/src/main/AndroidManifest.xml +19 -0
  6. package/android/src/main/java/com/mentra/crust/CrustModule.kt +882 -0
  7. package/android/src/main/java/com/mentra/crust/CrustView.kt +30 -0
  8. package/android/src/main/java/com/mentra/crust/heading/HeadingManager.kt +150 -0
  9. package/android/src/main/java/com/mentra/crust/jsc/JSCDispatcher.kt +189 -0
  10. package/android/src/main/java/com/mentra/crust/jsc/JSCPolyfillBridge.kt +246 -0
  11. package/android/src/main/java/com/mentra/crust/jsc/JSCRuntime.kt +593 -0
  12. package/android/src/main/java/com/mentra/crust/navigation/NavigationManager.kt +1445 -0
  13. package/android/src/main/java/com/mentra/crust/services/NotificationListener.kt +319 -0
  14. package/android/src/main/java/com/mentra/crust/utils/ImageProcessor.java +452 -0
  15. package/android/src/main/java/com/mentra/crust/utils/VideoStabilizer.kt +556 -0
  16. package/android/src/main/res/values/strings.xml +3 -0
  17. package/app.plugin.js +3 -0
  18. package/build/Crust.types.d.ts +148 -0
  19. package/build/Crust.types.d.ts.map +1 -0
  20. package/build/Crust.types.js +2 -0
  21. package/build/Crust.types.js.map +1 -0
  22. package/build/CrustModule.d.ts +175 -0
  23. package/build/CrustModule.d.ts.map +1 -0
  24. package/build/CrustModule.js +4 -0
  25. package/build/CrustModule.js.map +1 -0
  26. package/build/CrustModule.web.d.ts +26 -0
  27. package/build/CrustModule.web.d.ts.map +1 -0
  28. package/build/CrustModule.web.js +54 -0
  29. package/build/CrustModule.web.js.map +1 -0
  30. package/build/CrustView.d.ts +4 -0
  31. package/build/CrustView.d.ts.map +1 -0
  32. package/build/CrustView.js +7 -0
  33. package/build/CrustView.js.map +1 -0
  34. package/build/CrustView.web.d.ts +4 -0
  35. package/build/CrustView.web.d.ts.map +1 -0
  36. package/build/CrustView.web.js +7 -0
  37. package/build/CrustView.web.js.map +1 -0
  38. package/build/index.d.ts +4 -0
  39. package/build/index.d.ts.map +1 -0
  40. package/build/index.js +6 -0
  41. package/build/index.js.map +1 -0
  42. package/expo-module.config.json +9 -0
  43. package/ios/Crust.podspec +65 -0
  44. package/ios/CrustModule.swift +544 -0
  45. package/ios/CrustView.swift +38 -0
  46. package/ios/Resources/startup.js +814 -0
  47. package/ios/Source/JSCDispatcher.swift +226 -0
  48. package/ios/Source/JSCPolyfillBridge.swift +378 -0
  49. package/ios/Source/JSCRuntime.swift +673 -0
  50. package/ios/Source/utils/ImageProcessor.swift +392 -0
  51. package/ios/Source/utils/SystemGestures.swift +53 -0
  52. package/ios/Source/utils/VideoStabilizer.swift +374 -0
  53. package/ios/heading/HeadingManager.swift +74 -0
  54. package/ios/navigation/NavPayloads.swift +62 -0
  55. package/ios/navigation/NavigationManager.swift +720 -0
  56. package/package.json +69 -0
  57. package/plugin/build/index.d.ts +19 -0
  58. package/plugin/build/index.js +23 -0
  59. package/plugin/build/withAndroid.d.ts +2 -0
  60. package/plugin/build/withAndroid.js +78 -0
  61. package/src/Crust.types.ts +157 -0
  62. package/src/CrustModule.ts +186 -0
  63. package/src/CrustModule.web.ts +57 -0
  64. package/src/CrustView.tsx +10 -0
  65. package/src/CrustView.web.tsx +11 -0
  66. package/src/index.ts +5 -0
@@ -0,0 +1,452 @@
1
+ package com.mentra.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 Mentra 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
+ }