@trustchex/react-native-sdk 1.374.0 → 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.
- package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +0 -9
- package/android/src/main/java/com/trustchex/reactnativesdk/opencv/OpenCVModule.kt +636 -301
- package/ios/Camera/TrustchexCameraView.swift +8 -8
- package/ios/OpenCV/OpenCVHelper.h +0 -7
- package/ios/OpenCV/OpenCVHelper.mm +0 -60
- package/ios/OpenCV/OpenCVModule.h +0 -4
- package/ios/OpenCV/OpenCVModule.mm +440 -358
- package/lib/module/Shared/Components/DebugOverlay.js +541 -0
- package/lib/module/Shared/Components/IdentityDocumentCamera.constants.js +44 -0
- package/lib/module/Shared/Components/IdentityDocumentCamera.flows.js +270 -0
- package/lib/module/Shared/Components/IdentityDocumentCamera.js +679 -1701
- package/lib/module/Shared/Components/IdentityDocumentCamera.types.js +3 -0
- package/lib/module/Shared/Components/IdentityDocumentCamera.utils.js +273 -0
- package/lib/module/version.js +1 -1
- package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts +30 -0
- package/lib/typescript/src/Shared/Components/DebugOverlay.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts +35 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.constants.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts +3 -56
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts +88 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.flows.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts +116 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.types.d.ts.map +1 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts +93 -0
- package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.utils.d.ts.map +1 -0
- package/lib/typescript/src/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/Shared/Components/DebugOverlay.tsx +656 -0
- package/src/Shared/Components/IdentityDocumentCamera.constants.ts +44 -0
- package/src/Shared/Components/IdentityDocumentCamera.flows.ts +342 -0
- package/src/Shared/Components/IdentityDocumentCamera.tsx +1065 -2462
- package/src/Shared/Components/IdentityDocumentCamera.types.ts +136 -0
- package/src/Shared/Components/IdentityDocumentCamera.utils.ts +364 -0
- 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
|
|
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
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
val
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
|
86
|
-
Imgproc.cvtColor(
|
|
96
|
+
val hsv1 = Mat()
|
|
97
|
+
Imgproc.cvtColor(diff1, hsv1, Imgproc.COLOR_RGB2HSV)
|
|
87
98
|
|
|
88
|
-
|
|
99
|
+
val mask1a = Mat()
|
|
100
|
+
val mask1b = Mat()
|
|
89
101
|
val mask1 = Mat()
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Core.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
115
|
+
mask1.release()
|
|
113
116
|
}
|
|
114
117
|
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
308
|
-
val
|
|
309
|
-
val
|
|
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 -
|
|
313
|
-
val cropY = (y -
|
|
314
|
-
val cropRight = (x + w +
|
|
315
|
-
val cropBottom = (y + h +
|
|
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
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
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
|
|
816
|
-
* @param
|
|
817
|
-
* @param promise Returns
|
|
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
|
|
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
|
|
828
|
-
|
|
829
|
-
|
|
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
|
-
//
|
|
834
|
-
val
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
//
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
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
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
|
888
|
-
|
|
1163
|
+
val mat1 = base64ToMat(base64Image1)
|
|
1164
|
+
val mat2 = base64ToMat(base64Image2)
|
|
889
1165
|
|
|
890
|
-
if (
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
promise.reject("
|
|
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("
|
|
1231
|
+
promise.reject("FACE_SIMILARITY_ERROR", e.message)
|
|
897
1232
|
}
|
|
898
1233
|
}
|
|
899
1234
|
}
|