@trustchex/react-native-sdk 1.472.0 → 1.475.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/android/build.gradle +3 -3
  2. package/android/src/main/java/com/trustchex/reactnativesdk/camera/TrustchexCameraView.kt +147 -9
  3. package/ios/Camera/TrustchexCameraView.swift +92 -19
  4. package/lib/module/Screens/Debug/MRZTestScreen.js +121 -147
  5. package/lib/module/Screens/Static/ResultScreen.js +5 -0
  6. package/lib/module/Shared/Components/IdentityDocumentCamera.js +38 -4
  7. package/lib/module/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
  8. package/lib/module/Shared/Libs/mrz.utils.js +639 -16
  9. package/lib/module/Shared/Libs/mrzFrameAggregator.js +301 -0
  10. package/lib/module/Shared/Libs/mrzOcrIntegration.js +109 -0
  11. package/lib/module/version.js +1 -1
  12. package/lib/typescript/src/Screens/Debug/MRZTestScreen.d.ts.map +1 -1
  13. package/lib/typescript/src/Screens/Static/ResultScreen.d.ts.map +1 -1
  14. package/lib/typescript/src/Shared/Components/IdentityDocumentCamera.d.ts.map +1 -1
  15. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts +8 -0
  16. package/lib/typescript/src/Shared/Libs/mrz.utils.d.ts.map +1 -1
  17. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts +76 -0
  18. package/lib/typescript/src/Shared/Libs/mrzFrameAggregator.d.ts.map +1 -0
  19. package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts +73 -0
  20. package/lib/typescript/src/Shared/Libs/mrzOcrIntegration.d.ts.map +1 -0
  21. package/lib/typescript/src/version.d.ts +1 -1
  22. package/package.json +15 -10
  23. package/src/Screens/Debug/MRZTestScreen.tsx +137 -166
  24. package/src/Screens/Static/ResultScreen.tsx +5 -0
  25. package/src/Shared/Components/IdentityDocumentCamera.tsx +46 -6
  26. package/src/Shared/Libs/MRZ_KNOWN_ISSUES.md +112 -0
  27. package/src/Shared/Libs/mrz.utils.ts +704 -11
  28. package/src/Shared/Libs/mrzFrameAggregator.ts +370 -0
  29. package/src/Shared/Libs/mrzOcrIntegration.ts +175 -0
  30. package/src/version.ts +1 -1
@@ -83,9 +83,9 @@ dependencies {
83
83
  implementation "androidx.camera:camera-video:1.4.0"
84
84
 
85
85
  // Google ML Kit dependencies
86
- implementation 'com.google.mlkit:text-recognition:16.0.0'
87
- implementation 'com.google.mlkit:face-detection:16.1.5'
88
- implementation 'com.google.mlkit:barcode-scanning:17.1.0'
86
+ implementation 'com.google.mlkit:text-recognition:16.0.1'
87
+ implementation 'com.google.mlkit:face-detection:16.1.7'
88
+ implementation 'com.google.mlkit:barcode-scanning:17.3.0'
89
89
 
90
90
  // Google Play Services dependency for Tasks
91
91
  implementation 'com.google.android.gms:play-services-tasks:18.2.0'
@@ -452,9 +452,20 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
452
452
  // Brightness calculation restricted to scanning frame area (between 36% from top and 36% from bottom, 5% margins on sides)
453
453
  val averageBrightness = computeYPlaneBrightness(imageProxy, reportedWidth, reportedHeight)
454
454
 
455
- // Use original inputImage directly for text recognition
456
- // ML Kit works best with native camera frames, not processed bitmaps
457
- val textInputImage = inputImage
455
+ // Text recognition: process ONLY the data inside the on-screen scan
456
+ // frame. We capture the full image but crop to the scan-frame ROI
457
+ // (top 36% / bottom 36% / 5% side margins — the same rectangle the UI
458
+ // guide and brightness sampling use) and UPSCALE it before OCR. This
459
+ // (a) excludes front-of-card text and surrounding noise (screen glare,
460
+ // IDE/browser chrome) that pollute the MRZ read, and (b) gives ML Kit
461
+ // more pixels per character — ML Kit needs ≥16px/char, and a full-frame
462
+ // capture leaves a 30–44-char MRZ line below that floor. Block
463
+ // coordinates from the ROI pass are mapped back to the full portrait
464
+ // frame so the JS overlay/filters stay correct.
465
+ val roi = if (textRecognitionEnabled) {
466
+ buildScanFrameRoiImage(imageProxy, rotationDegrees, reportedWidth, reportedHeight)
467
+ } else null
468
+ val textInputImage = roi?.image ?: inputImage
458
469
 
459
470
  // Generate JPEG base64 only when JS side explicitly needs the image
460
471
  // NOTE: Do NOT auto-generate for face detection - too expensive, causes frame drops
@@ -539,13 +550,23 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
539
550
  blockMap.putString("text", block.text)
540
551
  val bb = block.boundingBox
541
552
  if (bb != null) {
553
+ // When OCR ran on the upscaled scan-frame ROI, map
554
+ // the box back to full portrait-frame coordinates:
555
+ // divide by the ROI upscale, then add the ROI origin.
556
+ val s = roi?.scale ?: 1f
557
+ val ox = roi?.offsetX ?: 0
558
+ val oy = roi?.offsetY ?: 0
559
+ val left = (bb.left / s).toInt() + ox
560
+ val top = (bb.top / s).toInt() + oy
561
+ val w = (bb.width() / s).toInt()
562
+ val h = (bb.height() / s).toInt()
542
563
  val boxMap = Arguments.createMap()
543
- boxMap.putInt("x", bb.left)
544
- boxMap.putInt("y", bb.top)
545
- boxMap.putInt("width", bb.width())
546
- boxMap.putInt("height", bb.height())
547
- boxMap.putInt("boundingCenterX", bb.centerX())
548
- boxMap.putInt("boundingCenterY", bb.centerY())
564
+ boxMap.putInt("x", left)
565
+ boxMap.putInt("y", top)
566
+ boxMap.putInt("width", w)
567
+ boxMap.putInt("height", h)
568
+ boxMap.putInt("boundingCenterX", left + w / 2)
569
+ boxMap.putInt("boundingCenterY", top + h / 2)
549
570
  blockMap.putMap("blockFrame", boxMap)
550
571
  }
551
572
  blocksArray.pushMap(blockMap)
@@ -1017,6 +1038,123 @@ class TrustchexCameraView(context: ThemedReactContext) : FrameLayout(context) {
1017
1038
  }
1018
1039
  }
1019
1040
 
1041
+ /** A scan-frame ROI prepared for OCR, with the transform back to full-frame coords. */
1042
+ private data class ScanFrameRoi(
1043
+ val image: InputImage,
1044
+ val offsetX: Int,
1045
+ val offsetY: Int,
1046
+ val scale: Float
1047
+ )
1048
+
1049
+ /**
1050
+ * Builds an [InputImage] cropped to the on-screen scan frame (top 36% /
1051
+ * bottom 36% / 5% side margins — the same rectangle the UI guide draws) and
1052
+ * UPSCALED so ML Kit gets more pixels per character. We capture the full frame
1053
+ * but OCR only this region, which removes front-of-card text and surrounding
1054
+ * noise and lifts a dense MRZ line above ML Kit's ≥16px/char floor. Returns
1055
+ * null on any failure so the caller falls back to full-frame OCR.
1056
+ */
1057
+ private fun buildScanFrameRoiImage(
1058
+ imageProxy: ImageProxy,
1059
+ rotationDegrees: Int,
1060
+ portraitWidth: Int,
1061
+ portraitHeight: Int
1062
+ ): ScanFrameRoi? {
1063
+ return try {
1064
+ val full = yuvImageToBitmap(imageProxy, rotationDegrees) ?: return null
1065
+
1066
+ // Scan-frame rectangle in the rotated (portrait) bitmap. Covers the
1067
+ // lower-middle band where the MRZ lands, with generous vertical
1068
+ // tolerance (top 25% → bottom 12%) so the band doesn't have to be
1069
+ // perfectly centered to be OCR'd. Keep it large enough for tolerance
1070
+ // but not the whole frame (front text/glare would creep back in).
1071
+ val scanTopPercent = 0.25
1072
+ val scanBottomPercent = 0.12
1073
+ val scanLeftPercent = 0.04
1074
+ val scanRightPercent = 0.04
1075
+ val left = (full.width * scanLeftPercent).toInt().coerceIn(0, full.width - 1)
1076
+ val right = (full.width * (1.0 - scanRightPercent)).toInt().coerceIn(left + 1, full.width)
1077
+ val top = (full.height * scanTopPercent).toInt().coerceIn(0, full.height - 1)
1078
+ val bottom = (full.height * (1.0 - scanBottomPercent)).toInt().coerceIn(top + 1, full.height)
1079
+ val cropW = right - left
1080
+ val cropH = bottom - top
1081
+ if (cropW < 8 || cropH < 8) {
1082
+ full.recycle()
1083
+ return null
1084
+ }
1085
+
1086
+ val cropped = Bitmap.createBitmap(full, left, top, cropW, cropH)
1087
+ full.recycle()
1088
+
1089
+ // Upscale so a ~30–44-char MRZ line clears the per-character pixel
1090
+ // minimum. Cap the long edge to keep OCR latency bounded.
1091
+ val maxLongEdge = 2200
1092
+ val longEdge = max(cropW, cropH)
1093
+ val rawScale = if (longEdge > 0) (maxLongEdge.toFloat() / longEdge) else 1f
1094
+ val scale = rawScale.coerceIn(1f, 2.5f)
1095
+ val outW = (cropW * scale).toInt()
1096
+ val outH = (cropH * scale).toInt()
1097
+ val scaled = if (scale != 1f) {
1098
+ val s = Bitmap.createScaledBitmap(cropped, outW, outH, true)
1099
+ cropped.recycle()
1100
+ s
1101
+ } else {
1102
+ cropped
1103
+ }
1104
+
1105
+ // Grayscale + contrast boost: the MRZ is black OCR-B on a light
1106
+ // background, so desaturating and stretching contrast makes glyphs
1107
+ // crisp and suppresses the dominant filler confusion (the "<" angle
1108
+ // bracket misread as C/E/K under glare). Done with a ColorMatrix on a
1109
+ // Canvas — no OpenCV dependency.
1110
+ val enhanced = enhanceForOcr(scaled)
1111
+ if (enhanced !== scaled) scaled.recycle()
1112
+
1113
+ // The bitmap is already rotated to portrait, so rotation = 0 here.
1114
+ ScanFrameRoi(
1115
+ image = InputImage.fromBitmap(enhanced, 0),
1116
+ offsetX = left,
1117
+ offsetY = top,
1118
+ scale = scale
1119
+ )
1120
+ } catch (e: Exception) {
1121
+ null
1122
+ }
1123
+ }
1124
+
1125
+ /**
1126
+ * Desaturate + boost contrast to make dark OCR-B glyphs pop against the light
1127
+ * MRZ background, which suppresses the "<" filler being misread as C/E/K under
1128
+ * glare. Uses a single ColorMatrix (saturation 0, then a contrast scale about
1129
+ * mid-grey). Returns the original bitmap unchanged on failure.
1130
+ */
1131
+ private fun enhanceForOcr(src: Bitmap): Bitmap {
1132
+ return try {
1133
+ val out = Bitmap.createBitmap(src.width, src.height, Bitmap.Config.ARGB_8888)
1134
+ val canvas = android.graphics.Canvas(out)
1135
+ val paint = android.graphics.Paint()
1136
+
1137
+ val gray = android.graphics.ColorMatrix().apply { setSaturation(0f) }
1138
+ // Contrast about mid-grey (128): out = c*in + (1-c)*128.
1139
+ val c = 1.6f
1140
+ val t = (1f - c) * 128f
1141
+ val contrast = android.graphics.ColorMatrix(
1142
+ floatArrayOf(
1143
+ c, 0f, 0f, 0f, t,
1144
+ 0f, c, 0f, 0f, t,
1145
+ 0f, 0f, c, 0f, t,
1146
+ 0f, 0f, 0f, 1f, 0f
1147
+ )
1148
+ )
1149
+ gray.postConcat(contrast)
1150
+ paint.colorFilter = android.graphics.ColorMatrixColorFilter(gray)
1151
+ canvas.drawBitmap(src, 0f, 0f, paint)
1152
+ out
1153
+ } catch (e: Exception) {
1154
+ src
1155
+ }
1156
+ }
1157
+
1020
1158
  fun cleanup() {
1021
1159
  // Cancel any active recording and delete file
1022
1160
  if (activeRecording != null) {
@@ -864,28 +864,30 @@ extension TrustchexCameraView: AVCaptureVideoDataOutputSampleBufferDelegate {
864
864
  // Compute brightness early for OCR-B enhancement decision
865
865
  let brightness = computeBrightness(from: pixelBuffer, width: portraitWidth, height: portraitHeight)
866
866
 
867
- // For text recognition, use original image directly
868
- // ML Kit works best with native camera frames
869
- let textEnhancedImage = orientedImage
870
-
871
- // Create VisionImage from the image (enhanced or original) for better text recognition
872
- // This ensures MLKit processes the image in the correct orientation
873
- guard let cgImage = self.ciContext.createCGImage(textEnhancedImage, from: textEnhancedImage.extent) else {
867
+ // Face detection / base64 use the full portrait frame.
868
+ guard let cgImage = self.ciContext.createCGImage(orientedImage, from: orientedImage.extent) else {
874
869
  resetProcessingState()
875
870
  return
876
871
  }
877
872
  let visionImage = VisionImage(image: UIImage(cgImage: cgImage))
878
873
  visionImage.orientation = .up // Already oriented correctly
879
-
880
- // Use image for text recognition
881
- // ML Kit text recognition best practices (iOS):
882
- // - Requires minimum 16x16 pixels per character (ideal 16-24px per character)
883
- // - Input image: 1080x1920 (portrait Full HD) provides excellent accuracy at real-time speed
884
- // - Each character at ~30px = 36 characters per line @ 1080px width
885
- // - Use synchronous results(in:) API from captureOutput(_:didOutput:from:)
886
- // - Set AVCaptureVideoDataOutput.alwaysDiscardsLateVideoFrames = true (throttle)
887
- // - Get results then render overlay in single step for optimal performance
888
- let textVisionImage = visionImage
874
+
875
+ // Text recognition: process ONLY the data inside the on-screen scan frame
876
+ // (matching Android). We crop the portrait frame to the scan-frame ROI
877
+ // (top 25% / bottom 12% / 4% side margins), UPSCALE it, and grayscale +
878
+ // contrast-enhance before OCR. This removes front-of-card text and glare
879
+ // noise and lifts a dense MRZ line above ML Kit's ~16px/char floor.
880
+ // `mrzRoi` carries the transform back to full portrait-frame coordinates.
881
+ let mrzRoi = enableTextRecognition
882
+ ? buildScanFrameRoiImage(orientedImage, portraitWidth: portraitWidth, portraitHeight: portraitHeight)
883
+ : nil
884
+ var textVisionImage = visionImage
885
+ if let roi = mrzRoi,
886
+ let roiCg = self.ciContext.createCGImage(roi.image, from: roi.image.extent) {
887
+ let img = VisionImage(image: UIImage(cgImage: roiCg))
888
+ img.orientation = .up
889
+ textVisionImage = img
890
+ }
889
891
 
890
892
  // Generate JPEG base64 only when JS side explicitly needs the image
891
893
  // NOTE: Do NOT auto-generate for face detection - too expensive, causes frame drops
@@ -961,8 +963,16 @@ extension TrustchexCameraView: AVCaptureVideoDataOutputSampleBufferDelegate {
961
963
  let blockY: Int
962
964
  let blockWidth: Int
963
965
  let blockHeight: Int
964
-
965
- if isBufferLandscape {
966
+
967
+ if let roi = mrzRoi {
968
+ // OCR ran on the upscaled, already-portrait scan-frame ROI.
969
+ // Map the box back to full portrait-frame coordinates:
970
+ // divide out the ROI upscale, then add the ROI origin.
971
+ blockX = Int(bb.origin.x / roi.scale) + roi.offsetX
972
+ blockY = Int(bb.origin.y / roi.scale) + roi.offsetY
973
+ blockWidth = Int(bb.width / roi.scale)
974
+ blockHeight = Int(bb.height / roi.scale)
975
+ } else if isBufferLandscape {
966
976
  // Rotate from landscape (1920x1080) to portrait (1080x1920)
967
977
  // When rotating 90° clockwise (.right):
968
978
  // new_x = old_y
@@ -1041,6 +1051,69 @@ extension TrustchexCameraView: AVCaptureVideoDataOutputSampleBufferDelegate {
1041
1051
  }
1042
1052
  }
1043
1053
 
1054
+ /// A scan-frame ROI prepared for OCR, with the transform back to full-frame coords.
1055
+ private struct ScanFrameRoi {
1056
+ let image: CIImage
1057
+ let offsetX: Int
1058
+ let offsetY: Int
1059
+ let scale: CGFloat
1060
+ }
1061
+
1062
+ /// Crops the portrait frame to the on-screen scan frame (top 25% / bottom 12%
1063
+ /// / 4% side margins — the same rectangle the UI guide draws), UPSCALES it so a
1064
+ /// dense MRZ line clears ML Kit's ~16px/char floor, and grayscale + contrast
1065
+ /// enhances it (the MRZ is dark OCR-B on a light background, so this makes
1066
+ /// glyphs pop and suppresses the "<" filler being misread as a letter under
1067
+ /// glare). Returns nil on failure so the caller falls back to full-frame OCR.
1068
+ /// Mirrors the Android `buildScanFrameRoiImage`.
1069
+ private func buildScanFrameRoiImage(_ portrait: CIImage, portraitWidth: Int, portraitHeight: Int) -> ScanFrameRoi? {
1070
+ let w = portrait.extent.width
1071
+ let h = portrait.extent.height
1072
+ if w < 8 || h < 8 { return nil }
1073
+
1074
+ // Scan-frame rectangle. CIImage origin is BOTTOM-left, so "top 25%" maps to
1075
+ // a y measured from the bottom: cropY = h*0.12 (bottom margin), height =
1076
+ // h*(1 - 0.25 - 0.12).
1077
+ let leftPct: CGFloat = 0.04
1078
+ let rightPct: CGFloat = 0.04
1079
+ let topPct: CGFloat = 0.25
1080
+ let bottomPct: CGFloat = 0.12
1081
+ let cropX = (w * leftPct).rounded()
1082
+ let cropW = (w * (1 - leftPct - rightPct)).rounded()
1083
+ let cropY = (h * bottomPct).rounded()
1084
+ let cropH = (h * (1 - topPct - bottomPct)).rounded()
1085
+ if cropW < 8 || cropH < 8 { return nil }
1086
+
1087
+ let cropRect = CGRect(x: portrait.extent.origin.x + cropX,
1088
+ y: portrait.extent.origin.y + cropY,
1089
+ width: cropW, height: cropH)
1090
+ var img = portrait.cropped(to: cropRect)
1091
+ // Move the crop origin to (0,0) so downstream extents are simple.
1092
+ img = img.transformed(by: CGAffineTransform(translationX: -cropRect.origin.x, y: -cropRect.origin.y))
1093
+
1094
+ // Upscale (≤2.5×, long edge capped at 2200) for more pixels per character.
1095
+ let longEdge = max(cropW, cropH)
1096
+ let rawScale = longEdge > 0 ? (2200.0 / longEdge) : 1.0
1097
+ let scale = min(max(rawScale, 1.0), 2.5)
1098
+ if scale != 1.0 {
1099
+ img = img.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
1100
+ }
1101
+
1102
+ // Grayscale (saturation 0) + contrast boost.
1103
+ img = img.applyingFilter("CIColorControls", parameters: [
1104
+ kCIInputSaturationKey: 0.0,
1105
+ kCIInputContrastKey: 1.6
1106
+ ])
1107
+
1108
+ // The crop is taken from the TOP of the portrait frame (UIKit coords), but
1109
+ // CIImage y is bottom-up. The block coords ML Kit returns are top-left in
1110
+ // the OCR image, so the offset back to the portrait frame is the UIKit top
1111
+ // edge (= portraitHeight * topPct) and left edge.
1112
+ let offsetX = Int(cropX)
1113
+ let offsetY = Int((CGFloat(portraitHeight) * topPct).rounded())
1114
+ return ScanFrameRoi(image: img, offsetX: offsetX, offsetY: offsetY, scale: scale)
1115
+ }
1116
+
1044
1117
  private func computeBrightness(from pixelBuffer: CVPixelBuffer, width: Int, height: Int) -> Double {
1045
1118
  guard let yPlane = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) else { return 128.0 }
1046
1119
  let pixelWidth = CVPixelBufferGetWidthOfPlane(pixelBuffer, 0)