@trustchex/react-native-sdk 1.362.6 → 1.381.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 (58) hide show
  1. package/TrustchexSDK.podspec +3 -3
  2. package/android/build.gradle +3 -3
  3. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +64 -19
  4. package/android/src/main/java/com/trustchex/reactnativesdk/opencv/OpenCVModule.kt +636 -301
  5. package/ios/Camera/TrustchexCameraView.swift +166 -119
  6. package/ios/OpenCV/OpenCVHelper.h +0 -7
  7. package/ios/OpenCV/OpenCVHelper.mm +0 -60
  8. package/ios/OpenCV/OpenCVModule.h +0 -4
  9. package/ios/OpenCV/OpenCVModule.mm +440 -358
  10. package/lib/module/Shared/Components/DebugOverlay.js +541 -0
  11. package/lib/module/Shared/Components/FaceCamera.js +1 -0
  12. package/lib/module/Shared/Components/IdentityDocumentCamera.constants.js +44 -0
  13. package/lib/module/Shared/Components/IdentityDocumentCamera.flows.js +270 -0
  14. package/lib/module/Shared/Components/IdentityDocumentCamera.js +708 -1593
  15. package/lib/module/Shared/Components/IdentityDocumentCamera.types.js +3 -0
  16. package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +273 -0
  17. package/lib/module/Shared/Components/QrCodeScannerCamera.js +1 -8
  18. package/lib/module/Shared/Libs/mrz.utils.js +202 -9
  19. package/lib/module/Translation/Resources/en.js +0 -4
  20. package/lib/module/Translation/Resources/tr.js +0 -4
  21. package/lib/module/version.js +1 -1
  22. package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts +30 -0
  23. package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts.map +1 -0
  24. package/lib/typescript/src/Shared/Components/FaceCamera.d.ts.map +1 -1
  25. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts +35 -0
  26. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts.map +1 -0
  27. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts +3 -56
  28. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  29. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts +88 -0
  30. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts.map +1 -0
  31. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +116 -0
  32. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -0
  33. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +93 -0
  34. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -0
  35. package/lib/typescript/src/Shared/Components/QrCodeScannerCamera.d.ts.map +1 -1
  36. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts +1 -0
  37. package/lib/typescript/src/Shared/Components/TrustchexCamera.d.ts.map +1 -1
  38. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +8 -0
  39. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  40. package/lib/typescript/src/Translation/Resources/en.d.ts +0 -4
  41. package/lib/typescript/src/Translation/Resources/en.d.ts.map +1 -1
  42. package/lib/typescript/src/Translation/Resources/tr.d.ts +0 -4
  43. package/lib/typescript/src/Translation/Resources/tr.d.ts.map +1 -1
  44. package/lib/typescript/src/version.d.ts +1 -1
  45. package/package.json +1 -1
  46. package/src/Shared/Components/DebugOverlay.tsx +656 -0
  47. package/src/Shared/Components/FaceCamera.tsx +1 -0
  48. package/src/Shared/Components/IdentityDocumentCamera.constants.ts +44 -0
  49. package/src/Shared/Components/IdentityDocumentCamera.flows.ts +342 -0
  50. package/src/Shared/Components/IdentityDocumentCamera.tsx +1105 -2324
  51. package/src/Shared/Components/IdentityDocumentCamera.types.ts +136 -0
  52. package/src/Shared/Components/IdentityDocumentCamera.utils.ts +364 -0
  53. package/src/Shared/Components/QrCodeScannerCamera.tsx +1 -9
  54. package/src/Shared/Components/TrustchexCamera.tsx +1 -0
  55. package/src/Shared/Libs/mrz.utils.ts +238 -26
  56. package/src/Translation/Resources/en.ts +0 -4
  57. package/src/Translation/Resources/tr.ts +0 -4
  58. package/src/version.ts +1 -1
@@ -16,7 +16,7 @@ class OpenCVModule(reactContext: ReactApplicationContext) : ReactContextBaseJava
16
16
 
17
17
  companion object {
18
18
  private var opencvInitialized = false
19
- private const val HOLOGRAM_NON_ZERO_THRESHOLD = 600
19
+ private const val HOLOGRAM_NON_ZERO_THRESHOLD_PERCENT = 0.01 // 1% of total pixels - balanced detection
20
20
  }
21
21
 
22
22
  init {
@@ -68,56 +68,89 @@ class OpenCVModule(reactContext: ReactApplicationContext) : ReactContextBaseJava
68
68
  return@Thread
69
69
  }
70
70
 
71
- // Multi-range HSV filtering for holographic colors
72
- // Range 1: Cyan-green holographic reflections
73
- val lowerBound1 = Scalar(35.0, 80.0, 80.0)
74
- val upperBound1 = Scalar(85.0, 255.0, 255.0)
75
- // Range 2: Blue-violet holographic reflections
76
- val lowerBound2 = Scalar(100.0, 80.0, 80.0)
77
- val upperBound2 = Scalar(160.0, 255.0, 255.0)
71
+ // Use original images directly (no alignment needed - HSV detects color changes regardless)
72
+ val imagesToProcess = mats
73
+
74
+ // Calculate adaptive threshold based on image size (0.5% of total pixels - strict hologram detection)
75
+ val imagePixels = imagesToProcess[0].rows() * imagesToProcess[0].cols()
76
+ val adaptiveThreshold = maxOf(1, (imagePixels * HOLOGRAM_NON_ZERO_THRESHOLD_PERCENT).toInt())
77
+
78
+ // HSV filtering for holographic rainbow spectrum (balanced - detect holograms, exclude white glares)
79
+ // Saturation 60+ = colorful holographic shifts, not desaturated white glares
80
+ // Value capped at 220 = exclude very bright white glares
81
+ // Range 1: Cyan-green holographic spectrum
82
+ val lowerBound1 = Scalar(40.0, 60.0, 60.0)
83
+ val upperBound1 = Scalar(75.0, 255.0, 220.0)
84
+ // Range 2: Blue-violet holographic spectrum
85
+ val lowerBound2 = Scalar(110.0, 60.0, 60.0)
86
+ val upperBound2 = Scalar(150.0, 255.0, 220.0)
78
87
  val diffs = mutableListOf<Mat>()
79
88
  val brightestImages = mutableListOf<Mat>()
80
89
 
81
- for (i in 0 until mats.size - 1) {
82
- val diff = Mat()
83
- Core.absdiff(mats[i], mats[i + 1], diff)
90
+ // Compare consecutive frames AND frames with gaps to catch both fast and slow color shifts
91
+ for (i in 0 until imagesToProcess.size - 1) {
92
+ // Gap 1: Consecutive frames (fast changes)
93
+ val diff1 = Mat()
94
+ Core.absdiff(imagesToProcess[i], imagesToProcess[i + 1], diff1)
84
95
 
85
- val hsv = Mat()
86
- Imgproc.cvtColor(diff, hsv, Imgproc.COLOR_RGB2HSV)
96
+ val hsv1 = Mat()
97
+ Imgproc.cvtColor(diff1, hsv1, Imgproc.COLOR_RGB2HSV)
87
98
 
88
- // Apply multi-range HSV filtering
99
+ val mask1a = Mat()
100
+ val mask1b = Mat()
89
101
  val mask1 = Mat()
90
- val mask2 = Mat()
91
- val mask = Mat()
92
- Core.inRange(hsv, lowerBound1, upperBound1, mask1)
93
- Core.inRange(hsv, lowerBound2, upperBound2, mask2)
94
- Core.bitwise_or(mask1, mask2, mask)
95
- mask1.release()
96
- mask2.release()
97
-
98
- val maskNonZero = Core.countNonZero(mask)
99
-
100
- if (maskNonZero > HOLOGRAM_NON_ZERO_THRESHOLD) {
101
- diffs.add(mask)
102
- brightestImages.add(mats[i].clone())
103
- brightestImages.add(mats[i + 1].clone())
104
-
105
- // Early termination: if first pair has very strong signal, skip rest
106
- if (i == 0 && maskNonZero > HOLOGRAM_NON_ZERO_THRESHOLD * 4) {
107
- diff.release()
108
- hsv.release()
109
- break
110
- }
102
+ Core.inRange(hsv1, lowerBound1, upperBound1, mask1a)
103
+ Core.inRange(hsv1, lowerBound2, upperBound2, mask1b)
104
+ Core.bitwise_or(mask1a, mask1b, mask1)
105
+ mask1a.release()
106
+ mask1b.release()
107
+
108
+ val maskNonZero1 = Core.countNonZero(mask1)
109
+
110
+ if (maskNonZero1 > adaptiveThreshold) {
111
+ diffs.add(mask1)
112
+ brightestImages.add(imagesToProcess[i].clone())
113
+ brightestImages.add(imagesToProcess[i + 1].clone())
111
114
  } else {
112
- mask.release()
115
+ mask1.release()
113
116
  }
114
117
 
115
- diff.release()
116
- hsv.release()
118
+ diff1.release()
119
+ hsv1.release()
120
+
121
+ // Gap 3: Every third frame (slower color shifts) - only for frames that allow it
122
+ if (i + 3 < imagesToProcess.size) {
123
+ val diff3 = Mat()
124
+ Core.absdiff(imagesToProcess[i], imagesToProcess[i + 3], diff3)
125
+
126
+ val hsv3 = Mat()
127
+ Imgproc.cvtColor(diff3, hsv3, Imgproc.COLOR_RGB2HSV)
128
+
129
+ val mask3a = Mat()
130
+ val mask3b = Mat()
131
+ val mask3 = Mat()
132
+ Core.inRange(hsv3, lowerBound1, upperBound1, mask3a)
133
+ Core.inRange(hsv3, lowerBound2, upperBound2, mask3b)
134
+ Core.bitwise_or(mask3a, mask3b, mask3)
135
+ mask3a.release()
136
+ mask3b.release()
137
+
138
+ val maskNonZero3 = Core.countNonZero(mask3)
139
+
140
+ if (maskNonZero3 > adaptiveThreshold) {
141
+ diffs.add(mask3)
142
+ brightestImages.add(imagesToProcess[i].clone())
143
+ brightestImages.add(imagesToProcess[i + 3].clone())
144
+ } else {
145
+ mask3.release()
146
+ }
147
+
148
+ diff3.release()
149
+ hsv3.release()
150
+ }
117
151
  }
118
152
 
119
- mats.forEach { it.release() }
120
- mats.clear()
153
+ imagesToProcess.forEach { it.release() }
121
154
 
122
155
  if (diffs.isEmpty()) {
123
156
  brightestImages.forEach { it.release() }
@@ -208,6 +241,413 @@ class OpenCVModule(reactContext: ReactApplicationContext) : ReactContextBaseJava
208
241
  }.start()
209
242
  }
210
243
 
244
+ /**
245
+ * Find common region across all frames and crop to it.
246
+ * Uses feature matching to find translation offsets, then calculates overlapping region.
247
+ * Returns cropped images without warping artifacts.
248
+ */
249
+ private fun findAndCropToCommonRegion(images: List<Mat>): MutableList<Mat> {
250
+ if (images.size < 2) return mutableListOf()
251
+
252
+ try {
253
+ val reference = images[0]
254
+ val refGray = Mat()
255
+ Imgproc.cvtColor(reference, refGray, Imgproc.COLOR_RGB2GRAY)
256
+
257
+ // Track translation offsets for each image relative to reference
258
+ val offsets = mutableListOf<Point>()
259
+ offsets.add(Point(0.0, 0.0)) // Reference has no offset
260
+
261
+ // Find translation offset for each image
262
+ for (i in 1 until images.size) {
263
+ val offset = findTranslationOffset(images[i], reference, refGray)
264
+ offsets.add(offset)
265
+ }
266
+
267
+ refGray.release()
268
+
269
+ // Calculate common region (intersection of all translated frames)
270
+ var commonX = 0.0
271
+ var commonY = 0.0
272
+ var commonWidth = reference.cols().toDouble()
273
+ var commonHeight = reference.rows().toDouble()
274
+
275
+ for (offset in offsets) {
276
+ val dx = offset.x
277
+ val dy = offset.y
278
+
279
+ // Update common region boundaries
280
+ commonX = maxOf(commonX, -dx)
281
+ commonY = maxOf(commonY, -dy)
282
+ commonWidth = minOf(commonWidth, reference.cols() - dx)
283
+ commonHeight = minOf(commonHeight, reference.rows() - dy)
284
+ }
285
+
286
+ // Ensure valid region
287
+ if (commonWidth <= commonX || commonHeight <= commonY ||
288
+ commonWidth - commonX < 50 || commonHeight - commonY < 50) {
289
+ // Region too small or invalid, return empty
290
+ return mutableListOf()
291
+ }
292
+
293
+ val cropRect = Rect(
294
+ commonX.toInt(),
295
+ commonY.toInt(),
296
+ (commonWidth - commonX).toInt(),
297
+ (commonHeight - commonY).toInt()
298
+ )
299
+
300
+ // Crop all images to common region
301
+ val croppedImages = mutableListOf<Mat>()
302
+ for (i in images.indices) {
303
+ val offset = offsets[i]
304
+ val adjustedRect = Rect(
305
+ (cropRect.x + offset.x).toInt(),
306
+ (cropRect.y + offset.y).toInt(),
307
+ cropRect.width,
308
+ cropRect.height
309
+ )
310
+
311
+ // Ensure rect is within image bounds
312
+ if (adjustedRect.x >= 0 && adjustedRect.y >= 0 &&
313
+ adjustedRect.x + adjustedRect.width <= images[i].cols() &&
314
+ adjustedRect.y + adjustedRect.height <= images[i].rows()) {
315
+ val cropped = Mat(images[i], adjustedRect).clone()
316
+ croppedImages.add(cropped)
317
+ } else {
318
+ // Fallback: use original
319
+ croppedImages.add(images[i].clone())
320
+ }
321
+ }
322
+
323
+ return croppedImages
324
+ } catch (e: Exception) {
325
+ return mutableListOf()
326
+ }
327
+ }
328
+
329
+ /**
330
+ * Find translation offset between image and reference using feature matching.
331
+ * Returns Point(dx, dy) representing pixel shift.
332
+ */
333
+ private fun findTranslationOffset(image: Mat, reference: Mat, refGray: Mat): Point {
334
+ try {
335
+ val imgGray = Mat()
336
+ Imgproc.cvtColor(image, imgGray, Imgproc.COLOR_RGB2GRAY)
337
+
338
+ // Detect ORB keypoints
339
+ val orb = org.opencv.features2d.ORB.create(300)
340
+ val kpRef = MatOfKeyPoint()
341
+ val descRef = Mat()
342
+ val kpImg = MatOfKeyPoint()
343
+ val descImg = Mat()
344
+
345
+ orb.detectAndCompute(refGray, Mat(), kpRef, descRef)
346
+ orb.detectAndCompute(imgGray, Mat(), kpImg, descImg)
347
+
348
+ imgGray.release()
349
+
350
+ if (descRef.empty() || descImg.empty()) {
351
+ kpRef.release()
352
+ descRef.release()
353
+ kpImg.release()
354
+ descImg.release()
355
+ return Point(0.0, 0.0)
356
+ }
357
+
358
+ // Match features
359
+ val matcher = org.opencv.features2d.DescriptorMatcher.create(
360
+ org.opencv.features2d.DescriptorMatcher.BRUTEFORCE_HAMMING
361
+ )
362
+ val matchesMat = MatOfDMatch()
363
+ matcher.match(descImg, descRef, matchesMat)
364
+ val matches = matchesMat.toList()
365
+
366
+ if (matches.isEmpty()) {
367
+ kpRef.release()
368
+ descRef.release()
369
+ kpImg.release()
370
+ descImg.release()
371
+ matchesMat.release()
372
+ return Point(0.0, 0.0)
373
+ }
374
+
375
+ // Filter good matches
376
+ val minDistance = matches.minOf { it.distance }
377
+ val goodMatches = matches.filter { it.distance < 3 * minDistance && it.distance < 40.0 }
378
+
379
+ if (goodMatches.size < 5) {
380
+ kpRef.release()
381
+ descRef.release()
382
+ kpImg.release()
383
+ descImg.release()
384
+ matchesMat.release()
385
+ return Point(0.0, 0.0)
386
+ }
387
+
388
+ // Calculate median translation offset
389
+ val imgKeypoints = kpImg.toArray()
390
+ val refKeypoints = kpRef.toArray()
391
+ val dxList = mutableListOf<Double>()
392
+ val dyList = mutableListOf<Double>()
393
+
394
+ for (match in goodMatches) {
395
+ val imgPt = imgKeypoints[match.queryIdx].pt
396
+ val refPt = refKeypoints[match.trainIdx].pt
397
+ dxList.add(refPt.x - imgPt.x)
398
+ dyList.add(refPt.y - imgPt.y)
399
+ }
400
+
401
+ kpRef.release()
402
+ descRef.release()
403
+ kpImg.release()
404
+ descImg.release()
405
+ matchesMat.release()
406
+
407
+ // Use median to be robust against outliers
408
+ dxList.sort()
409
+ dyList.sort()
410
+ val medianDx = dxList[dxList.size / 2]
411
+ val medianDy = dyList[dyList.size / 2]
412
+
413
+ return Point(medianDx, medianDy)
414
+ } catch (e: Exception) {
415
+ return Point(0.0, 0.0)
416
+ }
417
+ }
418
+
419
+ /**
420
+ * Align faces in multiple images to improve hologram detection accuracy.
421
+ * Uses feature matching (ORB) to find homography and warp images to reference frame.
422
+ *
423
+ * @param base64Images Array of base64 JPEG face images
424
+ * @param promise Returns array of aligned base64 images or original if alignment fails
425
+ */
426
+ @ReactMethod
427
+ fun alignFaces(base64Images: ReadableArray, promise: Promise) {
428
+ Thread {
429
+ try {
430
+ if (!opencvInitialized || base64Images.size() < 2) {
431
+ promise.resolve(Arguments.fromList(base64Images.toArrayList()))
432
+ return@Thread
433
+ }
434
+
435
+ val mats = mutableListOf<Mat>()
436
+ for (i in 0 until base64Images.size()) {
437
+ val b64 = base64Images.getString(i) ?: continue
438
+ val mat = base64ToMat(b64) ?: continue
439
+ mats.add(mat)
440
+ }
441
+
442
+ if (mats.size < 2) {
443
+ mats.forEach { it.release() }
444
+ promise.resolve(Arguments.fromList(base64Images.toArrayList()))
445
+ return@Thread
446
+ }
447
+
448
+ // Use first image as reference
449
+ val reference = mats[0]
450
+ val alignedImages = WritableNativeArray()
451
+
452
+ // Add reference image as-is
453
+ val refBase64 = matToBase64(reference)
454
+ if (refBase64 != null) {
455
+ alignedImages.pushString(refBase64)
456
+ }
457
+
458
+ // Convert reference to grayscale for feature detection
459
+ val refGray = Mat()
460
+ Imgproc.cvtColor(reference, refGray, Imgproc.COLOR_RGB2GRAY)
461
+
462
+ // Align remaining images to reference
463
+ for (i in 1 until mats.size) {
464
+ val current = mats[i]
465
+ val aligned = alignImageToReference(current, reference, refGray)
466
+
467
+ if (aligned != null) {
468
+ val alignedBase64 = matToBase64(aligned)
469
+ if (alignedBase64 != null) {
470
+ alignedImages.pushString(alignedBase64)
471
+ }
472
+ aligned.release()
473
+ } else {
474
+ // Fallback to original if alignment fails
475
+ val originalBase64 = matToBase64(current)
476
+ if (originalBase64 != null) {
477
+ alignedImages.pushString(originalBase64)
478
+ }
479
+ }
480
+ }
481
+
482
+ refGray.release()
483
+ mats.forEach { it.release() }
484
+
485
+ promise.resolve(alignedImages)
486
+ } catch (e: Exception) {
487
+ promise.reject("ALIGN_FACES_ERROR", e.message)
488
+ }
489
+ }.start()
490
+ }
491
+
492
+ /**
493
+ * Align a single image to reference using feature matching.
494
+ * Returns aligned Mat or null if alignment fails.
495
+ */
496
+ private fun alignImageToReference(image: Mat, reference: Mat, refGray: Mat): Mat? {
497
+ try {
498
+ val imgGray = Mat()
499
+ Imgproc.cvtColor(image, imgGray, Imgproc.COLOR_RGB2GRAY)
500
+
501
+ // Detect ORB keypoints and descriptors
502
+ val orb = org.opencv.features2d.ORB.create(500)
503
+ val kpRef = MatOfKeyPoint()
504
+ val descRef = Mat()
505
+ val kpImg = MatOfKeyPoint()
506
+ val descImg = Mat()
507
+
508
+ orb.detectAndCompute(refGray, Mat(), kpRef, descRef)
509
+ orb.detectAndCompute(imgGray, Mat(), kpImg, descImg)
510
+
511
+ if (descRef.empty() || descImg.empty()) {
512
+ imgGray.release()
513
+ kpRef.release()
514
+ descRef.release()
515
+ kpImg.release()
516
+ descImg.release()
517
+ return null
518
+ }
519
+
520
+ // Match features using BFMatcher
521
+ val matcher = org.opencv.features2d.DescriptorMatcher.create(
522
+ org.opencv.features2d.DescriptorMatcher.BRUTEFORCE_HAMMING
523
+ )
524
+ val matchesMat = MatOfDMatch()
525
+ matcher.match(descImg, descRef, matchesMat)
526
+ val matches = matchesMat.toList()
527
+
528
+ // Filter good matches (distance < 3 * min_distance)
529
+ if (matches.isEmpty()) {
530
+ imgGray.release()
531
+ kpRef.release()
532
+ descRef.release()
533
+ kpImg.release()
534
+ descImg.release()
535
+ matchesMat.release()
536
+ return null
537
+ }
538
+
539
+ val minDistance = matches.minOf { it.distance }
540
+ val goodMatches = matches.filter { it.distance < 3 * minDistance && it.distance < 50.0 }
541
+
542
+ if (goodMatches.size < 10) {
543
+ imgGray.release()
544
+ kpRef.release()
545
+ descRef.release()
546
+ kpImg.release()
547
+ descImg.release()
548
+ matchesMat.release()
549
+ return null
550
+ }
551
+
552
+ // Extract matched points
553
+ val srcPoints = mutableListOf<Point>()
554
+ val dstPoints = mutableListOf<Point>()
555
+ val imgKeypoints = kpImg.toArray()
556
+ val refKeypoints = kpRef.toArray()
557
+
558
+ for (match in goodMatches) {
559
+ srcPoints.add(imgKeypoints[match.queryIdx].pt)
560
+ dstPoints.add(refKeypoints[match.trainIdx].pt)
561
+ }
562
+
563
+ // Find homography
564
+ val srcMat = MatOfPoint2f()
565
+ val dstMat = MatOfPoint2f()
566
+ srcMat.fromList(srcPoints)
567
+ dstMat.fromList(dstPoints)
568
+
569
+ val homography = org.opencv.calib3d.Calib3d.findHomography(
570
+ srcMat, dstMat, org.opencv.calib3d.Calib3d.RANSAC, 5.0
571
+ )
572
+
573
+ if (homography.empty()) {
574
+ imgGray.release()
575
+ kpRef.release()
576
+ descRef.release()
577
+ kpImg.release()
578
+ descImg.release()
579
+ matchesMat.release()
580
+ srcMat.release()
581
+ dstMat.release()
582
+ homography.release()
583
+ return null
584
+ }
585
+
586
+ // Warp image to align with reference
587
+ // Use BORDER_REPLICATE to avoid black/white spaces in aligned images
588
+ val aligned = Mat()
589
+ Imgproc.warpPerspective(
590
+ image, aligned, homography,
591
+ reference.size(),
592
+ Imgproc.INTER_LINEAR,
593
+ Core.BORDER_REPLICATE
594
+ )
595
+
596
+ imgGray.release()
597
+ kpRef.release()
598
+ descRef.release()
599
+ kpImg.release()
600
+ descImg.release()
601
+ matchesMat.release()
602
+ srcMat.release()
603
+ dstMat.release()
604
+ homography.release()
605
+
606
+ return aligned
607
+ } catch (e: Exception) {
608
+ return null
609
+ }
610
+ }
611
+
612
+ /**
613
+ * Align all images to first image as reference.
614
+ * Returns list of aligned Mats or empty list if alignment fails.
615
+ */
616
+ private fun alignFacesToReference(images: List<Mat>): MutableList<Mat> {
617
+ val alignedImages = mutableListOf<Mat>()
618
+
619
+ if (images.size < 2) return alignedImages
620
+
621
+ try {
622
+ val reference = images[0]
623
+
624
+ // Add reference image as-is
625
+ alignedImages.add(reference.clone())
626
+
627
+ // Convert reference to grayscale for feature detection
628
+ val refGray = Mat()
629
+ Imgproc.cvtColor(reference, refGray, Imgproc.COLOR_RGB2GRAY)
630
+
631
+ // Align remaining images to reference
632
+ for (i in 1 until images.size) {
633
+ val aligned = alignImageToReference(images[i], reference, refGray)
634
+ if (aligned != null) {
635
+ alignedImages.add(aligned)
636
+ } else {
637
+ // Fallback to original if alignment fails
638
+ alignedImages.add(images[i].clone())
639
+ }
640
+ }
641
+
642
+ refGray.release()
643
+
644
+ return if (alignedImages.size >= 2) alignedImages else mutableListOf()
645
+ } catch (e: Exception) {
646
+ alignedImages.forEach { it.release() }
647
+ return mutableListOf()
648
+ }
649
+ }
650
+
211
651
  /**
212
652
  * Compare two images for similarity using thresholded absdiff.
213
653
  * Mirrors the JS areImagesSimilar logic:
@@ -273,7 +713,7 @@ class OpenCVModule(reactContext: ReactApplicationContext) : ReactContextBaseJava
273
713
  * @param promise Returns array of base64 cropped face images (240x320)
274
714
  */
275
715
  @ReactMethod
276
- fun cropFaceImages(base64Image: String, faceBounds: ReadableArray, imageWidth: Int, imageHeight: Int, promise: Promise) {
716
+ fun cropFaceImages(base64Image: String, faceBounds: ReadableArray, imageWidth: Int, imageHeight: Int, widerRightPadding: Boolean, promise: Promise) {
277
717
  try {
278
718
  if (!opencvInitialized) {
279
719
  promise.resolve(Arguments.createArray())
@@ -304,15 +744,17 @@ class OpenCVModule(reactContext: ReactApplicationContext) : ReactContextBaseJava
304
744
 
305
745
  if (w <= 0 || h <= 0) continue
306
746
 
307
- // Add 25% padding around the face bounding box
308
- val padX = (w * 0.25).toInt()
309
- val padY = (h * 0.25).toInt()
747
+ // Padding based on use case: hologram needs wider area on right/top/bottom, regular face is symmetric
748
+ val padLeft = (w * 0.25).toInt()
749
+ val padRight = if (widerRightPadding) (w * 0.60).toInt() else (w * 0.25).toInt()
750
+ val padTop = if (widerRightPadding) (h * 0.50).toInt() else (h * 0.25).toInt()
751
+ val padBottom = if (widerRightPadding) (h * 0.50).toInt() else (h * 0.25).toInt()
310
752
 
311
753
  // Face bounds from ML Kit are in portrait coordinates (matching the rotated JPEG)
312
- val cropX = (x - padX).coerceAtLeast(0)
313
- val cropY = (y - padY).coerceAtLeast(0)
314
- val cropRight = (x + w + padX).coerceAtMost(mat.cols())
315
- val cropBottom = (y + h + padY).coerceAtMost(mat.rows())
754
+ val cropX = (x - padLeft).coerceAtLeast(0)
755
+ val cropY = (y - padTop).coerceAtLeast(0)
756
+ val cropRight = (x + w + padRight).coerceAtMost(mat.cols())
757
+ val cropBottom = (y + h + padBottom).coerceAtMost(mat.rows())
316
758
  val cropW = cropRight - cropX
317
759
  val cropH = cropBottom - cropY
318
760
 
@@ -593,189 +1035,6 @@ class OpenCVModule(reactContext: ReactApplicationContext) : ReactContextBaseJava
593
1035
  }
594
1036
  }
595
1037
 
596
- /**
597
- * Detect ID card boundaries based on text blocks and face positions.
598
- * Creates a bounding rectangle that encompasses all detected elements.
599
- *
600
- * @param base64Image Base64 JPEG image
601
- * @param textBlocks Array of detected text blocks with bounding boxes
602
- * @param faces Array of detected faces with bounding boxes
603
- * @param imageWidth Width of the image
604
- * @param imageHeight Height of the image
605
- * @param promise Returns card bounds as { x, y, width, height, corners: [{x, y}, ...] } or null
606
- */
607
- @ReactMethod
608
- fun detectCardBounds(
609
- base64Image: String,
610
- textBlocks: ReadableArray,
611
- faces: ReadableArray,
612
- imageWidth: Int,
613
- imageHeight: Int,
614
- promise: Promise
615
- ) {
616
- try {
617
- // Collect all element bounds for clustering
618
- val allElements = mutableListOf<Rect>()
619
-
620
- // Process text blocks
621
- for (i in 0 until textBlocks.size()) {
622
- val block = textBlocks.getMap(i) ?: continue
623
- val frame = block.getMap("blockFrame") ?: continue
624
-
625
- val x = frame.getInt("x")
626
- val y = frame.getInt("y")
627
- val width = frame.getInt("width")
628
- val height = frame.getInt("height")
629
-
630
- allElements.add(Rect(x, y, width, height))
631
- }
632
-
633
- // Process faces
634
- for (i in 0 until faces.size()) {
635
- val face = faces.getMap(i) ?: continue
636
- val bounds = face.getMap("bounds") ?: continue
637
-
638
- val x = bounds.getInt("x")
639
- val y = bounds.getInt("y")
640
- val width = bounds.getInt("width")
641
- val height = bounds.getInt("height")
642
-
643
- allElements.add(Rect(x, y, width, height))
644
- }
645
-
646
- if (allElements.isEmpty()) {
647
- promise.resolve(null)
648
- return
649
- }
650
-
651
- // Calculate the centroid of all elements
652
- var centerX = 0
653
- var centerY = 0
654
- for (rect in allElements) {
655
- val rectRight = rect.x + rect.width
656
- val rectBottom = rect.y + rect.height
657
- centerX += (rect.x + rectRight) / 2
658
- centerY += (rect.y + rectBottom) / 2
659
- }
660
- centerX /= allElements.size
661
- centerY /= allElements.size
662
-
663
- // Calculate distances from centroid and filter outliers
664
- val distances = allElements.map { rect ->
665
- val rectRight = rect.x + rect.width
666
- val rectBottom = rect.y + rect.height
667
- val elemCenterX = (rect.x + rectRight) / 2
668
- val elemCenterY = (rect.y + rectBottom) / 2
669
- val dx = elemCenterX - centerX
670
- val dy = elemCenterY - centerY
671
- Math.sqrt((dx * dx + dy * dy).toDouble())
672
- }
673
-
674
- // Calculate median distance
675
- val sortedDistances = distances.sorted()
676
- val medianDistance = sortedDistances[sortedDistances.size / 2]
677
-
678
- // Filter out elements that are more than 2x the median distance from center
679
- // This removes outliers that are far from the main cluster
680
- val threshold = medianDistance * 2.0
681
- val filteredElements = allElements.filterIndexed { index, _ ->
682
- distances[index] <= threshold
683
- }
684
-
685
- if (filteredElements.isEmpty()) {
686
- promise.resolve(null)
687
- return
688
- }
689
-
690
- // Now calculate bounds from filtered elements
691
- var minX = imageWidth
692
- var minY = imageHeight
693
- var maxX = 0
694
- var maxY = 0
695
-
696
- for (rect in filteredElements) {
697
- val rectRight = rect.x + rect.width
698
- val rectBottom = rect.y + rect.height
699
- minX = Math.min(minX, rect.x)
700
- minY = Math.min(minY, rect.y)
701
- maxX = Math.max(maxX, rectRight)
702
- maxY = Math.max(maxY, rectBottom)
703
- }
704
-
705
- val elementCount = filteredElements.size
706
-
707
- // Calculate raw bounding box from elements
708
- val elementsWidth = maxX - minX
709
- val elementsHeight = maxY - minY
710
-
711
- // Validate minimum size (elements should occupy at least 5% of frame)
712
- val minArea = (imageWidth * imageHeight * 0.05).toInt()
713
- if (elementsWidth * elementsHeight < minArea) {
714
- promise.resolve(null)
715
- return
716
- }
717
-
718
- // Add minimal padding (5% on all sides) to create tight frame
719
- val paddingX = (elementsWidth * 0.05).toInt()
720
- val paddingY = (elementsHeight * 0.05).toInt()
721
-
722
- // Calculate card bounds with padding, clamped to image boundaries
723
- val cardX = Math.max(0, minX - paddingX)
724
- val cardY = Math.max(0, minY - paddingY)
725
- val cardRight = Math.min(imageWidth, maxX + paddingX)
726
- val cardBottom = Math.min(imageHeight, maxY + paddingY)
727
- val cardWidth = cardRight - cardX
728
- val cardHeight = cardBottom - cardY
729
-
730
- // Validate aspect ratio is reasonable for a document (very lenient: 1.0 - 2.5)
731
- val aspectRatio = cardWidth.toDouble() / cardHeight.toDouble().coerceAtLeast(1.0)
732
-
733
- if (aspectRatio < 1.0 || aspectRatio > 2.5) {
734
- promise.resolve(null)
735
- return
736
- }
737
-
738
- // Create corner points (rectangular bounds)
739
- val corners = WritableNativeArray()
740
-
741
- // Top-left
742
- val tl = WritableNativeMap()
743
- tl.putDouble("x", cardX.toDouble())
744
- tl.putDouble("y", cardY.toDouble())
745
- corners.pushMap(tl)
746
-
747
- // Top-right
748
- val tr = WritableNativeMap()
749
- tr.putDouble("x", (cardX + cardWidth).toDouble())
750
- tr.putDouble("y", cardY.toDouble())
751
- corners.pushMap(tr)
752
-
753
- // Bottom-right
754
- val br = WritableNativeMap()
755
- br.putDouble("x", (cardX + cardWidth).toDouble())
756
- br.putDouble("y", (cardY + cardHeight).toDouble())
757
- corners.pushMap(br)
758
-
759
- // Bottom-left
760
- val bl = WritableNativeMap()
761
- bl.putDouble("x", cardX.toDouble())
762
- bl.putDouble("y", (cardY + cardHeight).toDouble())
763
- corners.pushMap(bl)
764
-
765
- val result = WritableNativeMap()
766
- result.putInt("x", cardX)
767
- result.putInt("y", cardY)
768
- result.putInt("width", cardWidth)
769
- result.putInt("height", cardHeight)
770
- result.putArray("corners", corners)
771
- result.putDouble("angle", 0.0) // Rectangular alignment
772
-
773
- promise.resolve(result)
774
- } catch (e: Exception) {
775
- promise.resolve(null)
776
- }
777
- }
778
-
779
1038
  private fun matToBase64(mat: Mat): String? {
780
1039
  return try {
781
1040
  val bitmap = Bitmap.createBitmap(mat.cols(), mat.rows(), Bitmap.Config.ARGB_8888)
@@ -804,96 +1063,172 @@ class OpenCVModule(reactContext: ReactApplicationContext) : ReactContextBaseJava
804
1063
  }
805
1064
 
806
1065
  /**
807
- * Preprocess image for better OCR text recognition using OpenCV.
808
- * Steps:
809
- * 1. Convert to grayscale
810
- * 2. Bilateral filter for noise reduction while preserving edges
811
- * 3. CLAHE for adaptive contrast enhancement
812
- * 4. Sharpen the image to enhance text edges
813
- * 5. Optional: Adaptive threshold for high-contrast text extraction
1066
+ * Compare face shape/geometry between two face images.
1067
+ * Analyzes aspect ratio, edges, and structural similarity.
1068
+ * Device-side validation before backend FaceNet.
814
1069
  *
815
- * @param base64Image Base64 encoded input image
816
- * @param applyThresholding Whether to apply adaptive thresholding (default: false)
817
- * @param promise Returns preprocessed image as base64
1070
+ * @param base64Image1 Primary face image
1071
+ * @param base64Image2 Secondary face image
1072
+ * @param promise Returns { shapeScore: 0-1, aspectRatioMatch: boolean }
818
1073
  */
819
1074
  @ReactMethod
820
- fun preprocessImageForOCR(base64Image: String, applyThresholding: Boolean, promise: Promise) {
1075
+ fun compareFaceShape(base64Image1: String, base64Image2: String, promise: Promise) {
821
1076
  try {
822
1077
  if (!opencvInitialized) {
823
1078
  promise.reject("OPENCV_ERROR", "OpenCV not initialized")
824
1079
  return
825
1080
  }
826
1081
 
827
- val mat = base64ToMat(base64Image)
828
- if (mat == null) {
829
- promise.reject("DECODE_ERROR", "Failed to decode image")
1082
+ val mat1 = base64ToMat(base64Image1)
1083
+ val mat2 = base64ToMat(base64Image2)
1084
+
1085
+ if (mat1 == null || mat2 == null) {
1086
+ mat1?.release()
1087
+ mat2?.release()
1088
+ promise.reject("DECODE_ERROR", "Failed to decode face images")
830
1089
  return
831
1090
  }
832
1091
 
833
- // 1. Convert to grayscale
834
- val gray = Mat()
835
- Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGB2GRAY)
836
- mat.release()
837
-
838
- // 2. Apply bilateral filter for noise reduction while preserving edges
839
- // This is better than Gaussian blur for text as it keeps edges sharp
840
- val filtered = Mat()
841
- Imgproc.bilateralFilter(gray, filtered, 9, 75.0, 75.0)
842
- gray.release()
1092
+ // Normalize sizes for comparison
1093
+ val size = Size(200.0, 200.0)
1094
+ val resized1 = Mat()
1095
+ val resized2 = Mat()
1096
+ Imgproc.resize(mat1, resized1, size)
1097
+ Imgproc.resize(mat2, resized2, size)
1098
+
1099
+ // Calculate aspect ratios
1100
+ val aspectRatio1 = mat1.width().toDouble() / mat1.height().toDouble()
1101
+ val aspectRatio2 = mat2.width().toDouble() / mat2.height().toDouble()
1102
+ val aspectRatioDiff = kotlin.math.abs(aspectRatio1 - aspectRatio2)
1103
+ val aspectRatioMatch = aspectRatioDiff < 0.15 // 15% tolerance
1104
+
1105
+ // Edge detection for structural comparison
1106
+ val gray1 = Mat()
1107
+ val gray2 = Mat()
1108
+ Imgproc.cvtColor(resized1, gray1, Imgproc.COLOR_BGR2GRAY)
1109
+ Imgproc.cvtColor(resized2, gray2, Imgproc.COLOR_BGR2GRAY)
1110
+
1111
+ val edges1 = Mat()
1112
+ val edges2 = Mat()
1113
+ // Canny edge detection
1114
+ Imgproc.Canny(gray1, edges1, 50.0, 150.0)
1115
+ Imgproc.Canny(gray2, edges2, 50.0, 150.0)
1116
+
1117
+ // Compare edge structures
1118
+ val diff = Mat()
1119
+ Core.absdiff(edges1, edges2, diff)
1120
+ val diffCount = Core.countNonZero(diff)
1121
+ val totalPixels = edges1.rows() * edges1.cols()
1122
+ val matchPixels = totalPixels - diffCount
1123
+ val shapeScore = matchPixels.toDouble() / totalPixels.toDouble()
843
1124
 
844
- // 3. Apply CLAHE (Contrast Limited Adaptive Histogram Equalization)
845
- // This enhances local contrast, making text stand out better
846
- val clahe = Imgproc.createCLAHE(2.0, Size(8.0, 8.0))
847
- val enhanced = Mat()
848
- clahe.apply(filtered, enhanced)
849
- filtered.release()
850
-
851
- // 4. Sharpen the image to enhance text edges
852
- // Use unsharp masking: original + (original - blurred) * amount
853
- val blurred = Mat()
854
- Imgproc.GaussianBlur(enhanced, blurred, Size(0.0, 0.0), 3.0)
855
- val sharpened = Mat()
856
- Core.addWeighted(enhanced, 1.5, blurred, -0.5, 0.0, sharpened)
857
- blurred.release()
858
- enhanced.release()
859
-
860
- // 5. Optional: Apply adaptive thresholding for binary text extraction
861
- val result = if (applyThresholding) {
862
- val thresholded = Mat()
863
- // Use Gaussian adaptive threshold - better for varying illumination
864
- Imgproc.adaptiveThreshold(
865
- sharpened,
866
- thresholded,
867
- 255.0,
868
- Imgproc.ADAPTIVE_THRESH_GAUSSIAN_C,
869
- Imgproc.THRESH_BINARY,
870
- 11,
871
- 2.0
872
- )
873
- sharpened.release()
1125
+ // Cleanup
1126
+ mat1.release()
1127
+ mat2.release()
1128
+ resized1.release()
1129
+ resized2.release()
1130
+ gray1.release()
1131
+ gray2.release()
1132
+ edges1.release()
1133
+ edges2.release()
1134
+ diff.release()
874
1135
 
875
- // Apply morphological operations to clean up noise
876
- val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(2.0, 2.0))
877
- val cleaned = Mat()
878
- Imgproc.morphologyEx(thresholded, cleaned, Imgproc.MORPH_CLOSE, kernel)
879
- thresholded.release()
880
- kernel.release()
1136
+ val result = Arguments.createMap()
1137
+ result.putDouble("shapeScore", shapeScore)
1138
+ result.putBoolean("aspectRatioMatch", aspectRatioMatch)
1139
+ result.putDouble("aspectRatioDiff", aspectRatioDiff)
1140
+ promise.resolve(result)
1141
+ } catch (e: Exception) {
1142
+ promise.reject("FACE_SHAPE_ERROR", e.message)
1143
+ }
1144
+ }
881
1145
 
882
- cleaned
883
- } else {
884
- sharpened
1146
+ /**
1147
+ * Compare visual similarity between two face images using SSIM-like approach.
1148
+ * Better than simple pixel difference for different lighting/angles.
1149
+ * Device-side validation before backend FaceNet.
1150
+ *
1151
+ * @param base64Image1 Primary face image
1152
+ * @param base64Image2 Secondary face image
1153
+ * @param promise Returns { similarity: 0-1 }
1154
+ */
1155
+ @ReactMethod
1156
+ fun compareFaceVisualSimilarity(base64Image1: String, base64Image2: String, promise: Promise) {
1157
+ try {
1158
+ if (!opencvInitialized) {
1159
+ promise.reject("OPENCV_ERROR", "OpenCV not initialized")
1160
+ return
885
1161
  }
886
1162
 
887
- val resultBase64 = matToBase64(result)
888
- result.release()
1163
+ val mat1 = base64ToMat(base64Image1)
1164
+ val mat2 = base64ToMat(base64Image2)
889
1165
 
890
- if (resultBase64 != null) {
891
- promise.resolve(resultBase64)
892
- } else {
893
- promise.reject("ENCODE_ERROR", "Failed to encode result")
1166
+ if (mat1 == null || mat2 == null) {
1167
+ mat1?.release()
1168
+ mat2?.release()
1169
+ promise.reject("DECODE_ERROR", "Failed to decode face images")
1170
+ return
894
1171
  }
1172
+
1173
+ // Normalize sizes
1174
+ val size = Size(200.0, 200.0)
1175
+ val resized1 = Mat()
1176
+ val resized2 = Mat()
1177
+ Imgproc.resize(mat1, resized1, size)
1178
+ Imgproc.resize(mat2, resized2, size)
1179
+
1180
+ // Convert to grayscale and equalize histogram for better comparison
1181
+ val gray1 = Mat()
1182
+ val gray2 = Mat()
1183
+ Imgproc.cvtColor(resized1, gray1, Imgproc.COLOR_BGR2GRAY)
1184
+ Imgproc.cvtColor(resized2, gray2, Imgproc.COLOR_BGR2GRAY)
1185
+ Imgproc.equalizeHist(gray1, gray1)
1186
+ Imgproc.equalizeHist(gray2, gray2)
1187
+
1188
+ // Compute mean and standard deviation for both images
1189
+ val mean1 = MatOfDouble()
1190
+ val stdDev1 = MatOfDouble()
1191
+ val mean2 = MatOfDouble()
1192
+ val stdDev2 = MatOfDouble()
1193
+ Core.meanStdDev(gray1, mean1, stdDev1)
1194
+ Core.meanStdDev(gray2, mean2, stdDev2)
1195
+
1196
+ // Compute correlation coefficient (simplified SSIM approach)
1197
+ val normalizedGray1 = Mat()
1198
+ val normalizedGray2 = Mat()
1199
+ gray1.convertTo(normalizedGray1, CvType.CV_64F)
1200
+ gray2.convertTo(normalizedGray2, CvType.CV_64F)
1201
+
1202
+ // Calculate correlation
1203
+ val product = Mat()
1204
+ Core.multiply(normalizedGray1, normalizedGray2, product)
1205
+ val sumScalar = Core.sumElems(product)
1206
+ val totalPixels = gray1.rows().toDouble() * gray1.cols().toDouble()
1207
+ val correlation = sumScalar.`val`[0] / totalPixels
1208
+
1209
+ // Normalize to 0-1 range (simplified similarity score)
1210
+ val similarity = kotlin.math.max(0.0, kotlin.math.min(1.0, correlation / 65536.0))
1211
+
1212
+ // Cleanup
1213
+ mat1.release()
1214
+ mat2.release()
1215
+ resized1.release()
1216
+ resized2.release()
1217
+ gray1.release()
1218
+ gray2.release()
1219
+ mean1.release()
1220
+ stdDev1.release()
1221
+ mean2.release()
1222
+ stdDev2.release()
1223
+ normalizedGray1.release()
1224
+ normalizedGray2.release()
1225
+ product.release()
1226
+
1227
+ val result = Arguments.createMap()
1228
+ result.putDouble("similarity", similarity)
1229
+ promise.resolve(result)
895
1230
  } catch (e: Exception) {
896
- promise.reject("OCR_PREPROCESS_ERROR", e.message)
1231
+ promise.reject("FACE_SIMILARITY_ERROR", e.message)
897
1232
  }
898
1233
  }
899
1234
  }