From cb03eb49b6fe8db1674c6a93a9f4b0e897ce52cb Mon Sep 17 00:00:00 2001 From: Mike Roberts Date: Fri, 24 Apr 2026 17:04:43 -0500 Subject: [PATCH 1/6] Add face detection --- README.md | 23 ++ android/build.gradle | 1 + .../src/main/java/com/rncamerakit/CKCamera.kt | 56 +++++ .../main/java/com/rncamerakit/FaceAnalyzer.kt | 97 ++++++++ .../rncamerakit/events/FaceDetectedEvent.kt | 36 +++ .../java/com/rncamerakit/CKCameraManager.kt | 28 ++- .../java/com/rncamerakit/CKCameraManager.kt | 28 ++- example/images/faceDetection.png | Bin 0 -> 14216 bytes example/ios/Podfile.lock | 4 +- example/src/CameraExample.tsx | 216 +++++++++++++++--- ios/ReactNativeCameraKit/CKCameraManager.mm | 4 + .../CKCameraViewComponentView.mm | 36 +++ ios/ReactNativeCameraKit/CameraProtocol.swift | 6 + ios/ReactNativeCameraKit/CameraView.swift | 17 ++ ios/ReactNativeCameraKit/FaceDetector.swift | 103 +++++++++ ios/ReactNativeCameraKit/RealCamera.swift | 87 ++++++- .../SimulatorCamera.swift | 7 + src/Camera.android.tsx | 1 + src/Camera.ios.tsx | 1 + src/CameraProps.ts | 25 +- src/specs/CameraNativeComponent.ts | 16 ++ 21 files changed, 733 insertions(+), 59 deletions(-) create mode 100644 android/src/main/java/com/rncamerakit/FaceAnalyzer.kt create mode 100644 android/src/main/java/com/rncamerakit/events/FaceDetectedEvent.kt create mode 100644 example/images/faceDetection.png create mode 100644 ios/ReactNativeCameraKit/FaceDetector.swift diff --git a/README.md b/README.md index 8f5d9b249..989f4a7ba 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@
  • Cross Platform (iOS and Android)

  • Optimized for performance and high photo capture rate

  • QR / Barcode scanning support

  • +
  • Face detection support

  • Camera preview support in iOS simulator

  • @@ -170,6 +171,24 @@ Additionally, the Camera can be used for barcode scanning /> ``` +#### Face Detection + +Detect faces in real time. iOS uses Apple Vision; Android uses Google ML Kit. + +```tsx + { + // event.nativeEvent.faces: ReadonlyArray + // each: { id, yaw, pitch, roll, boundsX, boundsY, boundsWidth, boundsHeight } + }} +/> +``` + +> **Android note:** Uses the unbundled `play-services-mlkit-face-detection` variant — the ML model is downloaded by Google Play Services on first use rather than bundled in the APK. Requires Play Services on the device. + ### Camera Props (Optional) | Props | Type | Description | @@ -207,6 +226,10 @@ Additionally, the Camera can be used for barcode scanning | `laserColor` | Color | Color of barcode scanner laser visualization. Default: `red` | | `frameColor` | Color | Color of barcode scanner frame visualization. Default: `yellow` | | `onReadCode` | Function | Callback when scanner successfully reads barcode. Returned event contains `codeStringValue`. Default: `null`. Ex: `onReadCode={(event) => console.log(event.nativeEvent.codeStringValue)}` | +| **Face detection** | +| `faceDetectionEnabled` | `boolean` | Enable real-time face detection. Default: `false` | +| `faceDetectionThrottleMs` | `number` | Minimum milliseconds between `onFaceDetected` emits. Default: `100` (~10 events/sec) | +| `onFaceDetected` | Function | Callback while face detection is active, with one entry per detected face (empty array if none). Each face has `id`, `yaw`, `pitch`, `roll` (degrees), and `boundsX/Y/Width/Height` (normalized 0–1, top-left, preview-space). | ### Imperative API diff --git a/android/build.gradle b/android/build.gradle index 6cc073301..145733b84 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -67,6 +67,7 @@ dependencies { // implementation "androidx.camera:camera-extensions:${camerax_version}" implementation 'com.google.mlkit:barcode-scanning:17.3.0' + implementation 'com.google.android.gms:play-services-mlkit-face-detection:17.1.0' } repositories { mavenCentral() diff --git a/android/src/main/java/com/rncamerakit/CKCamera.kt b/android/src/main/java/com/rncamerakit/CKCamera.kt index 928c5e361..b3bd12cae 100644 --- a/android/src/main/java/com/rncamerakit/CKCamera.kt +++ b/android/src/main/java/com/rncamerakit/CKCamera.kt @@ -83,6 +83,7 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs private var preview: Preview? = null private var imageCapture: ImageCapture? = null private var imageAnalyzer: ImageAnalysis? = null + private var faceAnalyzer: FaceAnalyzer? = null private var orientationListener: OrientationEventListener? = null private var viewFinder: PreviewView = PreviewView(context) private var rectOverlay: RectOverlay = RectOverlay(context) @@ -112,6 +113,10 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs private var barcodeFrameSize: Size? = null private var allowedBarcodeTypes: Array? = null + // Face detection props + private var faceDetectionEnabled: Boolean = false + private var faceDetectionThrottleMs: Long = FaceAnalyzer.DEFAULT_THROTTLE_MS + private fun getActivity() : Activity { return currentContext.currentActivity!! } @@ -142,11 +147,21 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs override fun onDetachedFromWindow() { super.onDetachedFromWindow() + faceAnalyzer?.close() + faceAnalyzer = null cameraExecutor.shutdown() orientationListener?.disable() cameraProvider?.unbindAll() } + override fun onWindowFocusChanged(hasWindowFocus: Boolean) { + super.onWindowFocusChanged(hasWindowFocus) + if (hasWindowFocus && cameraProvider == null && + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + viewFinder.post { setupCamera() } + } + } + override fun dispatchKeyEvent(event: KeyEvent?): Boolean { val keyCode = event?.getKeyCode() val action = event?.getAction() @@ -394,6 +409,20 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs useCases.add(imageAnalyzer) } + faceAnalyzer?.close() + faceAnalyzer = null + if (faceDetectionEnabled) { + val analyzer = FaceAnalyzer(faceDetectionThrottleMs) { payloads -> onFaceDetected(payloads) } + faceAnalyzer = analyzer + useCases.add( + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setTargetAspectRatio(previewAspectRatio) + .build() + .also { it.setAnalyzer(cameraExecutor, analyzer) } + ) + } + // Must unbind the use-cases before rebinding them cameraProvider.unbindAll() @@ -547,6 +576,20 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs ?.dispatchEvent(ReadCodeEvent(surfaceId, id, barcodes.first().rawValue, codeFormat.code)) } + private fun onFaceDetected(payloads: List) { + // CameraX auto-mirrors the front-camera Preview but not ImageAnalysis, + // so flip X here to keep bounds in preview-space for JS consumers. + val mirrored = if (lensType == CameraSelector.LENS_FACING_FRONT) { + payloads.map { it.copy(boundsX = 1.0 - it.boundsX - it.boundsWidth) } + } else { + payloads + } + val surfaceId = UIManagerHelper.getSurfaceId(currentContext) + UIManagerHelper + .getEventDispatcherForReactTag(currentContext, id) + ?.dispatchEvent(FaceDetectedEvent(surfaceId, id, mirrored)) + } + private fun onOrientationChange(orientation: Int) { val remappedOrientation = when (orientation) { Surface.ROTATION_0 -> RNCameraKitModule.PORTRAIT @@ -674,6 +717,19 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs if (restartCamera) bindCameraUseCases() } + fun setFaceDetectionEnabled(enabled: Boolean) { + val restartCamera = enabled != faceDetectionEnabled + faceDetectionEnabled = enabled + if (restartCamera) bindCameraUseCases() + } + + fun setFaceDetectionThrottleMs(throttleMs: Int) { + val newThrottle = if (throttleMs < 0) FaceAnalyzer.DEFAULT_THROTTLE_MS else throttleMs.toLong() + if (faceDetectionThrottleMs == newThrottle) return + faceDetectionThrottleMs = newThrottle + faceAnalyzer?.throttleMs = newThrottle + } + fun setScanThrottleDelay(delayMs: Int) { val newDelay = if (delayMs < 0) 2000L else delayMs.toLong() val restartCamera = scanThrottleDelay != newDelay && scanBarcode diff --git a/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt b/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt new file mode 100644 index 000000000..ae3a27449 --- /dev/null +++ b/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt @@ -0,0 +1,97 @@ +package com.rncamerakit + +import android.annotation.SuppressLint +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import com.google.mlkit.vision.common.InputImage +import com.google.mlkit.vision.face.Face +import com.google.mlkit.vision.face.FaceDetection +import com.google.mlkit.vision.face.FaceDetectorOptions + +data class FacePayload( + val id: Int, + val yaw: Double, + val pitch: Double, + val roll: Double, + val boundsX: Double, + val boundsY: Double, + val boundsWidth: Double, + val boundsHeight: Double, +) + +class FaceAnalyzer( + @Volatile var throttleMs: Long, + private val onFaceDetected: (payloads: List) -> Unit +) : ImageAnalysis.Analyzer { + + private val detector = FaceDetection.getClient( + FaceDetectorOptions.Builder() + .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) + .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE) + .setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE) + .setMinFaceSize(MIN_FACE_SIZE) + .enableTracking() + .build() + ) + + private var lastEmitMs = 0L + private var nextLocalId: Int = -1 + + @SuppressLint("UnsafeExperimentalUsageError") + @ExperimentalGetImage + override fun analyze(image: ImageProxy) { + val mediaImage = image.image + if (mediaImage == null) { + image.close() + return + } + + val now = System.currentTimeMillis() + if (now - lastEmitMs < throttleMs) { + image.close() + return + } + lastEmitMs = now + + val rotation = image.imageInfo.rotationDegrees + val inputImage = InputImage.fromMediaImage(mediaImage, rotation) + val width = if (rotation == 90 || rotation == 270) image.height else image.width + val height = if (rotation == 90 || rotation == 270) image.width else image.height + + detector.process(inputImage) + .addOnSuccessListener { faces -> dispatch(faces, width, height) } + .addOnCompleteListener { image.close() } + } + + private fun dispatch(faces: List, imgWidth: Int, imgHeight: Int) { + val w = imgWidth.toDouble().coerceAtLeast(1.0) + val h = imgHeight.toDouble().coerceAtLeast(1.0) + val payloads = faces.map { face -> build(face, w, h) } + onFaceDetected(payloads) + } + + fun close() { + detector.close() + } + + private fun build(face: Face, w: Double, h: Double): FacePayload { + val box = face.boundingBox + val id = face.trackingId ?: nextLocalId.also { nextLocalId-- } + return FacePayload( + id = id, + yaw = face.headEulerAngleY.toDouble(), + pitch = face.headEulerAngleX.toDouble(), + roll = face.headEulerAngleZ.toDouble(), + boundsX = box.left / w, + boundsY = box.top / h, + boundsWidth = box.width() / w, + boundsHeight = box.height() / h, + ) + } + + companion object { + private const val MIN_FACE_SIZE = 0.15f + const val DEFAULT_THROTTLE_MS = 100L + } +} diff --git a/android/src/main/java/com/rncamerakit/events/FaceDetectedEvent.kt b/android/src/main/java/com/rncamerakit/events/FaceDetectedEvent.kt new file mode 100644 index 000000000..4e760254c --- /dev/null +++ b/android/src/main/java/com/rncamerakit/events/FaceDetectedEvent.kt @@ -0,0 +1,36 @@ +package com.rncamerakit.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event +import com.rncamerakit.FacePayload + +class FaceDetectedEvent( + surfaceId: Int, + viewId: Int, + private val faces: List, +) : Event(surfaceId, viewId) { + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap { + val array = Arguments.createArray() + for (face in faces) { + val map = Arguments.createMap().apply { + putInt("id", face.id) + putDouble("yaw", face.yaw) + putDouble("pitch", face.pitch) + putDouble("roll", face.roll) + putDouble("boundsX", face.boundsX) + putDouble("boundsY", face.boundsY) + putDouble("boundsWidth", face.boundsWidth) + putDouble("boundsHeight", face.boundsHeight) + } + array.pushMap(map) + } + return Arguments.createMap().apply { putArray("faces", array) } + } + + companion object { + const val EVENT_NAME = "topFaceDetected" + } +} diff --git a/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt b/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt index cbca7f5e5..4d2855792 100644 --- a/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt +++ b/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt @@ -8,7 +8,6 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReadableArray import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.ReadableType -import com.facebook.react.common.MapBuilder import com.facebook.react.common.ReactConstants.TAG import com.facebook.react.uimanager.SimpleViewManager import com.facebook.react.uimanager.ThemedReactContext @@ -53,14 +52,15 @@ class CKCameraManager(context: ReactApplicationContext) : SimpleViewManager { - return MapBuilder.of( - OrientationChangeEvent.EVENT_NAME, MapBuilder.of("registrationName", "onOrientationChange"), - ReadCodeEvent.EVENT_NAME, MapBuilder.of("registrationName", "onReadCode"), - PictureTakenEvent.EVENT_NAME, MapBuilder.of("registrationName", "onPictureTaken"), - ZoomEvent.EVENT_NAME, MapBuilder.of("registrationName", "onZoom"), - ErrorEvent.EVENT_NAME, MapBuilder.of("registrationName", "onError"), - CaptureButtonPressInEvent.EVENT_NAME, MapBuilder.of("registrationName", "onCaptureButtonPressIn"), - CaptureButtonPressOutEvent.EVENT_NAME, MapBuilder.of("registrationName", "onCaptureButtonPressOut") + return mapOf( + OrientationChangeEvent.EVENT_NAME to mapOf("registrationName" to "onOrientationChange"), + ReadCodeEvent.EVENT_NAME to mapOf("registrationName" to "onReadCode"), + PictureTakenEvent.EVENT_NAME to mapOf("registrationName" to "onPictureTaken"), + ZoomEvent.EVENT_NAME to mapOf("registrationName" to "onZoom"), + ErrorEvent.EVENT_NAME to mapOf("registrationName" to "onError"), + CaptureButtonPressInEvent.EVENT_NAME to mapOf("registrationName" to "onCaptureButtonPressIn"), + CaptureButtonPressOutEvent.EVENT_NAME to mapOf("registrationName" to "onCaptureButtonPressOut"), + FaceDetectedEvent.EVENT_NAME to mapOf("registrationName" to "onFaceDetected"), ) } @@ -104,6 +104,16 @@ class CKCameraManager(context: ReactApplicationContext) : SimpleViewManager { - return MapBuilder.of( - OrientationChangeEvent.EVENT_NAME, MapBuilder.of("registrationName", "onOrientationChange"), - ReadCodeEvent.EVENT_NAME, MapBuilder.of("registrationName", "onReadCode"), - PictureTakenEvent.EVENT_NAME, MapBuilder.of("registrationName", "onPictureTaken"), - ZoomEvent.EVENT_NAME, MapBuilder.of("registrationName", "onZoom"), - ErrorEvent.EVENT_NAME, MapBuilder.of("registrationName", "onError"), - CaptureButtonPressInEvent.EVENT_NAME, MapBuilder.of("registrationName", "onCaptureButtonPressIn"), - CaptureButtonPressOutEvent.EVENT_NAME, MapBuilder.of("registrationName", "onCaptureButtonPressOut") + return mapOf( + OrientationChangeEvent.EVENT_NAME to mapOf("registrationName" to "onOrientationChange"), + ReadCodeEvent.EVENT_NAME to mapOf("registrationName" to "onReadCode"), + PictureTakenEvent.EVENT_NAME to mapOf("registrationName" to "onPictureTaken"), + ZoomEvent.EVENT_NAME to mapOf("registrationName" to "onZoom"), + ErrorEvent.EVENT_NAME to mapOf("registrationName" to "onError"), + CaptureButtonPressInEvent.EVENT_NAME to mapOf("registrationName" to "onCaptureButtonPressIn"), + CaptureButtonPressOutEvent.EVENT_NAME to mapOf("registrationName" to "onCaptureButtonPressOut"), + FaceDetectedEvent.EVENT_NAME to mapOf("registrationName" to "onFaceDetected"), ) } @@ -98,6 +98,16 @@ class CKCameraManager(var context: ReactApplicationContext) : SimpleViewManager< view.setScanBarcode(enabled) } + @ReactProp(name = "faceDetectionEnabled") + fun setFaceDetectionEnabled(view: CKCamera, enabled: Boolean) { + view.setFaceDetectionEnabled(enabled) + } + + @ReactProp(name = "faceDetectionThrottleMs") + fun setFaceDetectionThrottleMs(view: CKCamera?, value: Int) { + view?.setFaceDetectionThrottleMs(value) + } + @ReactProp(name = "showFrame") fun setShowFrame(view: CKCamera, enabled: Boolean) { view.setShowFrame(enabled) diff --git a/example/images/faceDetection.png b/example/images/faceDetection.png new file mode 100644 index 0000000000000000000000000000000000000000..c7b7466eb0787c9b060ffe5a303552ac6bef5af2 GIT binary patch literal 14216 zcmb8Wc|6q7_dos`V@X0KWLL%>%DxVzV(byhT1jOmJA;`Pl5As8b{W~1vhP!h5Ne3B z&1B6!cEd2gm)@Vx=lgkl|M)!~-^YW;%$(PGoqO)R=bn4dInNtyW}?S>ocA~Y0IUf8 ztL6Ye2Y#gkjxmBCn?Zwn;0Lp}{%v0X2;e{ZhZKAYS^$YWe%EgKS$IA03%vW-83+sv zly&iR^L4uG?JVo{_+k2rCNBU81Bk1aZw6&7j|bO_TZXOf2L|f0zv>-(B5VOYFW+%W z{3QKVj+SVP$8R586n`2WtrD#=H{DSp-dXtZs;`NWVxM4?YNy(bA30A>(7k*q&+_Ek zcSQ@8&551(8Ws1n@$t}AN6Rp)gN1{TtW{lFdwET3P4Up4Wtj~N41xK7`6G02<+GUO zUc-mub1i7&+`pK9vHo(FN`V@pJru5$c8B4Y@s{}K_<1}xs^+6SV(I}C=_ViDuSaX6LJqsyPEq0#kDBO(QB=_R9eg_4x}b&uXQ}~5w8>= zR4(h>?vf4D;?4V#zv=y!H0cMKy~{vuL(zb3_S9>6qbchxWs3I;CXYu~o z&#e<1dSQlXv4_C$81BUqre?@ zk~aEbKFeE^QUtfo@g@;_z<*Me;q%+j55%AUuU(mejSg>@zN!AiI&4CidM^$bJ z6|+0d$&E837445*<7aBlR?qqzxU$;iMY?nUo`5VJ%-GiWx20^DJ@1K#O_Z`%s-KsbSD+z!|Vkr z7rU4uVlruF_Rx%1nQhgQ!_a<6c#7{-hA2Gn-@m}{x{{0bUOzv+A8gqIy+bmd0sH2g0ivMXI1HgPB>o=<4j+<@Le3mjbpj=(&DSO!EOBvD3#g!Eal)d46#x7;xT)K zlD8}+-1*$vw~i`8;x+8gSORl~TWK!x-R@sy%sIqMb?oE#wx`Glg<;jaWFtQkWjaYE zwVq*Dx_e3lK?oXU4w7+Zp7ou)L&GMR&I+EcuM{UvW8Rw2d7{V;TiF%B3 z=wh)pzg;|0`g5>hdXB*j-%)8^P=C68;QM8+S4Gb|I-9>{c$Liy{tp~$oDX`vG%%uFXp<5Wm zIEO1?6;Kn;nhudYfI{DUf=DsMa4nd2?Kmk+wJci&{z^GDTi$20;T4-s0mtlyO zI>sQie6OO*GGWWQc2~mhD>73JM>)aw`u4x>cqL)W=1V?sQ*cJ+%G*935eV~vfP&_y zraQh#_+l}s!9i(Mf#PQ1^(`yXtlv2p!fEb0Nr>?L^<(!&K%pg;`r;9*=e#t+wZ9K9 zFumW@=Jr{_?`q%hGF33HKzUE== zC(8!Qe&uUZkq<+Bp-k_$=*8MZi#b3v8_{i-isNxC!U*11>;)`3!KW`_#2A5nC9=x( zEuncjeGKDN$vhpfAGgPpb{2R=J$vt}3Z3@x)XNw}exeAii}F>wHT^8GG=L|b{lbIY zIfsGy-^CDiV3wDd?(8rgc1RT4a^1ivZWF@#J@XFHpztO|Q25#uc}*12zL7*;(_0BW zk}!58A^*>mAtrJTLtZO63%DN%y%usl66O*FzWz1LivxoD5&lzGE3x*FIEt(((7@VLFAdDaYYCrk*I#18^fx~JwX>Vt?Es~4q7*rM7%jk>o z$8~*=MSt2Hpi|s)oOG2$PYUXmqqf$rAxKf+dBeJ3GV4(^?!i_g%@+`!S-v_?&Sue! zXCn5-r-z~{AlXp3?i*X-t>2{mwk~h|uxkFGOJvdR(2Y(1LH|;w)?>|&q`1w6@+=-BJ=3+$di0Li$u~CVc2ll=NwrB+i=o+ z8T>Iu;t=-uw1Rg-Whb_o>~X08Ui8};fkXK#xQe8IFMiF!YnzF+f)7cj7$l7zsGM3ozd|=bzjU!c zL8L}HOzSf%9Ku`r1#gI6>~dt%jY&saLkw$!QgGY2il;}-J@wNhYppLw{Plr27u}aU zH-0z;HpAL&(B1TxdEJ8;bP<02_mw=xO>$BDkt}hrg_Wq-X z#shG})-pcd7*lBIw-B-X2l=BRU}tmQ2b?GSt}lwKKLVU=4T1*}=E-zHYs|G9_dx{Q#zgv<9FEy#$-d|?sn~D(Vw`-1 zmqXk_dHMZ@kdU4QBc$y$xS4T)R`LGD2cP~qbmk}L8yf)t{F+)PTnWodSPT?CUCBC= z7||%hH~v}AyXmfy$asq0BtO79-iI}%#u;;8%~VhSP~L7l|D`zL56bE?UiqknV1{iT)wX7FV zWmXjj*+FHsvw=sCvQAJn0uDpI8<4ampt>#>`GH6JSw_KgZMu!Z#eH#jYji;Y%X$AV z5LD#kt!}3XjR=qKTCCiaEUfH{K?bnFV}iPG$a8esX)#JzaR||n`|8jCSoqXRBEAkC zMqs#6VP_cZIxYTWB*S4Rf(=mY3vdp~-ZKWQfz%tyZ4IwJWUjGduX%Yg4ncRIZUu+g z%jhyxJ~m^SaS5VHr|Nxusk737&*6k7XT7NXNz0PqgKQ`eTQY;6e?{9L<3o7?#9a3v zUkH1^lJO||1A=e~&w+Yq#&~>c=>kdhjy>2D0=QxvDxR0V--4`rRK?b_?;a;d;d5i1 z-R^STn6;*!a%MaTo8B0>-dvH3bfMx#9~@c60+ojn@caIlDdowZd~6GgYmEXKG>I*~ zuXANxLH8 zazvm$5vh@3S_*YAyj^nVuN6>HaKQu%m)nS5l#CH``9)a1=t*Wa8|>_=`$+lx2J1Dq zWfpaVOTi#JoANozw(Hp*Y@G3Ed48eFQ?^xxZ?RLgPq;~rQ*7v{q6ao+e}l{JUkOX} zj5yNkd_b;dq;Xwa!)Hq@4>>Kp6Pk+A_VSI+w%D08a%)8)d74+nTTTZ0-e=)c+ zJ9jquHI3Shu{7tD(VzHkDB);=>tCFm)Z|}cz0o*+)Y8J>YND5KdW;5&!?xtWTR`ui zN3-p1%}9yWyfXLENGoVSRgyrCmy76j+me>@b#@>G-z7a8AUgnfT)E z;_`0XsO{0@n>1%4ZPR6j0KJ-9UcD>cB<1zFh}t0Am@O-Nf``n}BrI;{_uVEfdVg{x zD4;^W);;|_@+ExP(v}lZS94^NWuYpRfLb3TK+e62ke0-4F_(6dHufOjGS*Kc26V@R z#acxh!?eYb((2inimtk-*eQn$mW%ZB0bHrgYQ)jY2iUFa>)gLP))3uN{>AP0NW`yo zF|kt-)wf+N?~)IQ#?z<0b(yv-`nCsEek{u0iBgs>WB7fl{t0sbHBTKdT65dw3%HZH z-J9>0=G*0C@xMm?BIL7JU1%=y_?b{puQ5_ z_TG%hB51p8Lr)EFJjoZ#>K)K{as=m#ZZqP(CsOfw_}Y{^s6tGa7M)Lf^4gZsdc60; zjyj*JRQRnE$RuRw8=jRjs;!Jfnup%)gW|<*V)W{8$8WtZVtv7s>>dB6pO#PA-j!R5 z5Q(1tocC!=%`qz+C7R_1rB0!m7_s4DBr57bxQl{Z{)9Ri)mPi&Wp zy=AySWrVnr#i#6qHW$;*cy#`fRP3TqEYZ9CM63}M7Z5I`1)Sq}K zjnXK2*I+6wu~xdc4~apBrs1d|13^_ZXaMvu>46--%Fx+}__uM6ZhRzkI9wDb?h zgETe_KWE}K3dt5sH46jNUf+}O@4=^;8ga;`G<|DHqBkyiy6 zn!hqL)oPT}@*B8B9h2}JqveH)+@79qZ8BcV0+n35!+z%m)7Wop`|FqFmKm}EH)F-Y zQ}1uoS3R$(MK;pZ{1p;E2OGQgtF4*ik=m^=W{H@GIInIMI@=?% z$2GO#`R(n$wbD`^(RpS+b0Tl=WYe#ha$hNH656fvJLi2qM~VdKC>7oXNFCbkYYl#o z!n|HX^ReIeg*wagkOd`oe`R^@cxB6s3Em=*WZr)3GnshRMhKCyOjr0?+b}v(@wPg+ zmCkHr0_%7mQ4kehu@WYOq$cAzO{;~l4aHqY~y6@Vb zDQ)@xCUU|Lzx{qL%R+1cf2Ag|8eFatxlomO~gu{UcdDi(dtF z=9MdrcX$6uvN;1k)B=^38)y749vA{T4d`HSkjFp{u8X|pK{MFkMBQtKNJ+K`HE~p8 zJ)!e%I?!1_2N4(*zpC=fAR;o*`oF{(L_A4urTi~xn4xF!ZN9s^-J_C7Bopw`2Nb#~ zqV#y`M+oX3KOi9)wqeV-#3lRm@iveW*^|)9sCb7B!20o3!tEkkj)qI5 zz0)xY3NV0^NT8=I-`WA=8MXaq@ewkVnv4IK1Hex0gHxUX{nTk%7xr(a4lW?6e&RV9 zn+^X>$&X;j*MQ96`VVUUOu3Q3wq>*c)r&~v42}taL(H(?{Mw&_A5KhbGg$1TU}$vH z>*O^hZ%~_*R%Tgmj+J{vfh9m`TOnJc>umwQDsrpCBs(A|*M#shX)7lNTQ=29>w>!=sABKiySwv!I^%}f8-D4j{6OLH zc}Lyw2+x!2fR9x{F4=W$=u%`nR6P!=K*JxoDxXmu4Jw7CghoBY`9e6xMD-A)tMHF?qA8@qk#(*)cP=L&zke+g+YjV}YoQns;)C^@ z%h}HFe;-e?WIdwn{dm;qYqn3AQyuTfX96`dCa84&hsUQe){xyB$VtO_#mzI^8*JTF zGpX~Lhky<#X|vadv(f|9qhA>>_(vW$TE7YW6E|Cb?3bJ!W6d1Ut!85I-|QvmEs#m* zREgHSZuJP#IWK`!R2In+9H%Nw=HGBms$*Vrv{;Y-Z=2ItLfg@yV2UslVE%4 zn^z>~O|C2>vPb@OS^n+wuR;17^Gxe(jI^Bfox_Hsqq#>*NPVM!9#l z5x{rN(>=N`p3i~;lM897_7Z%BMvPP4LvBUNGIZr)qmg2$WN;6}boA!m_nJt;%T+rV z*32NLM@{!EQ>qc5AVXjXmTl&B zmGym8%Lqu`kmy!3tV@pv_cWI?aVC~cU{*Ni0&GKX{>r1i!`IYFZNSlxTL-hZ1gy7B zi=mZvwE~5f=u1V34q~V<@Km69IvXeqNQG+abJP8T1UZApueaK7^lI#^5xmc? z;W?+h7LxE<*xR$kJrI1$^k^r{8`0yR%=?EQ*!l<~M}P^k5Bx!HjVD81*U|NWzswD7 zlX34W)2J&bg{cPcptWJvQyh_mRc?#`b9lHvYDrYqaP@M!TCbYgw3uYP()4J%AF^b8 z!GLh_z4c#=~KlA{D%R0z-e_rQZ`%;Zo^TLH`R)(^($N*~p9U=;6o ziALmQ^Qh3pyVrE2^|s>6ru%8%f(+9+Q*gN&FB-+5CUg%WhNGp`g7+jv=jJ!y3Str- z7MQ`ea+tP1Ui`FQuCYo3O*5oh;@RJo0X-tyxUHUnI39-@e?1t@5A6?Gp-zVXxomlYUZ`LvJb5dRt>Ip_&ko; zN`ZOYKjqCvNvFBL=L#|pwG_$_hXf{QS5!S`q3N6B*QPa4#$T3Ik3ITiQB=2)jr*pT z2xCCLL5f!v)pMx6Zz+y%;gD1_%I-A&k~&(_2y-!v!x^<21~R6<3`KL-SnaQEE#lSJ z=e#U@w7qY-)uh6DyF$MP9!rm5Ce>hrSw@<#;c13pFlsQ)n(iM+nY6l7m-1K`kyc=hSv@;@nj0GLA;}aLaF#5xKKD>|ElOra2U86g zqFBBLviU|gp^5F*bY_pq1|-4tIcHfPw)`>#M(VL*WfoHW((tYnxzI}f2fH*nLL@?P zmUnS&J{D%V7?H3lG0PDM$a#oz_-oMDzd|0)aa4GTa$s=RFEF&4wpP9NYG%JRgj6r~ z6ZV#&Wb})-c6#^4!?uQQ3^sXEXD3-+MTl34c&kzD`o&jrjm)vIEquw>Kz^2JQ~8M` zsI~#Tb0gNe7Q>7%m*#?Gix7h^zzz$aBL{Pa1o&0BvmZ7y-Nz`V1Td7JBSwEySz)lM zONhr^!&9bxHL~mJel^72*T;+mo|$Rd_j@Q$+%@CtI;I|nG@0Yr0XnOX#6EPm-m3e? z%RL7vr6N0F2rt-(CO#5p#o)?Yz+sgqo-J7I^AgVPZ|JbK=|;mO??`t)EBBj|&))Ts ze)D(YaN1XGc=t?YeWs#5#=Q0@XRvaAmZPZ}6YZ%Lg%q4SJqL_dW1&Uraehj$KuABM zEQ0&KAm}r5AYs4McjQUjk{s9bFN1DBx8aTav3h0u1ho+jdlaL4x-6c{j#ZPR9&UfK z2s~9QUQM-t8}0`;3j0=Sf(&34>yCmaoypr1bhyi)kIij76T~$Wt<&3aca>s0!0_uC zA8LvxyDNpztP19=4p0QhAoXt@yQyUDbiq8`w@Gyp4^|n>Q5XKiTTVaB?dNLy?ogvq zC3EpIJaxcF$=UE80uw~G-e^;;%Fhd{c6ymnD;N8Kt4r^D^*qT zazDiM&)t62*?8PsBm*j}_1CKV6&cKZ%g<2#AiRFpc6aA)v{LIu?bwQV*#KOW4HK1{ zv`S#O+sp`9gB!)_P5m@*QwpiK5p2s&mDPDlfT9jQb*3KeN@?~Xa=Xl=Pu@If*l>nQ z@yhddj73tQl-~m2DO(gja`ZqDxInkfntHkTB@~$dlLACV^;|Srr9r~4zi~{gj@aEb zw*(W^?12&#kGpMr@bCe#uN8Hxnt%%<6S&Xi6KUcR4}6k z2vEQgqOYI|BnZkL(39jBCawhL60+&kfrJO%fUwr!Y0?XCo{qIO?Ol4Ef6eEzpYZOe z87VCQ4CBRrzuU9`vH^XIBTk!F8~}Kd0Khhm21-g0LglOstLLBEJB+Xi0lZJl*nz2e zEc*)(Ou)^2gavTFmaK@oVz3w2A5KrLmib@Iga`b(l$ih~N}SWVwbEG&^ZW`-{}yseu>1ZG4%9U;k%m!p zl$LD!E(;?fUhy_5Wu)7*Bq7W7qGKeJ^rIfP!isof&;@4U%S7W=-ACukUF9z)@3X8^ zJkJl^$T|+@+G239BGFLu@L+qB!&#yqviPIs&y_FJ8{>XYFR?SS$)oeEF$eOjNB&4Qi<~#iaxu; zih8gCSr0Y^tFX1| z8^Eo$KIQCW#YYcR&9twhZvEE;-1{00asig%CP8I%WU}skG?LiAzYd4G=VZC22VV#Y=&PB8@`r6TzrafGVx})aJNZA;eyQNF{F!?Na)&V2%^DxOhS1&nGkEFVC@Vfd#>t5X4iJmRtSy(_Z(dP7@ioU-u)<7J8uj z6SN(tPVY>f*TvM{Q6p>A-!dI|57}kPsL>KKl+d``7=E>CO@zdWb+gC(%6weqFBY+} z7Dht&!Z}8M{lPa(_fOS-Va297nX7X3pnC*0R7HZlhS9b_1 zN$}1se7fYmV4)D(yQLa$Ez0)5T>WWP_pFB0o9HRjXczJ7-dR40HrTj-88O9-3r{)g zEkp_U*;x+_8bWf&NzSFBL%$L=_qeq4noTDHCwQ!vx9J^pZ^x;P_w8{lq8u|Lx<&n$ zEqB@cT2<=eZ-mcWaL{S}DHkI-x92E12OmG&JIyB%%A#L9*i*TY?M8jQ9^<{k;A}Gm z*6z&<((QX?#85;Bvv;C33&FctzJ@;Rp0D2iHugfz6nsdJ-}-mb(cHNr|Xva z2+sWc+Q(vC$>yJl(6dV|-yeUd=l^o=%GY|Nqf&5eS6EDEl%v%2( z8CotgL@Gv`4?ZT*t*bmuyBG34%j5dj62$>|yZeu7cj2BeoZ1K^h?y*hSHs6*4_b#! z5BB*D%|#xg+Es$PQI)y=cJBx?N(Im9NgCZY8{O6uOi;%)n~^|&5~Cd(6n$!#T)ZQDXhkGb+6l)eRPFIehU;tM70^OVh;%R3Pz=$J?4=u8I9 zw*$O`%OcZqd$LmwRHW&_#RIiJ$ndpTU1VkSbZj)ps&`|;MR6Qo{*h-KL}I9NB!*w5 zIM=v|yh&=T5^8=QK z;pcC^2p6X^&%{LM^7_&0GCWfTHRJz;hpbDja|he5d5~1l%e*})H&Z>(#GLA_&8;D; z6aHRdrO@;0>AEh*G2_phbD?tigv8Rp+pYikZ0bv4hcxA#agPjVm{7^=(AM!##a@cM;=0rp<(ubRItjeF!kBWEhD+l;~z(VLjSTKGG2a7-~7OePFpyF z_&Tn?+IGCq&l9XVp}3ZGwR)bEGqAaUrwiU%-dnndhnElysmr6T9=?PIfv(9LG?dL+ z(_q!(iEOC03HKF3v)yqj7~5K;02TV&SC;>79V@)jQ}{5dlBMXO@OhW;6o5OiE#Am{ z#ieJY%=4;xg~%GSPM8C*lqLHa+m^dDAzl=r40!2t-|Jq%SKwH3XSYJ=Z{}8go%1_g z&l_=I7*em9Is$$c9EdmVFah_o>sA0~h z>-l~)%rpi_X7(rU9?ubMV|Gsh6^tZFpx5$Qg29>756=cUq}zKjJbNa$|i2 zDf>MzZ=R`*DTU0Q#8*9tzfDP~r$;e&j~oM{H$<-yuwc8>yu)-Bd!*Nz+InyXy(hHR zqoEulUz_Vbm)Y8HM3)Wzv`5VOz~GRtqfDxz_Aew?^Np@z6~jUFV*;p!1^R&S{H!_? z5Zq=ez_Maw-g=O#*m`*CwLbR~!VcA>@g%Q(g%JAL&*~LgDwsgPsZ%v5`@Jx4j>_hl z##))o_QYL!;l*o%RV!^tG~C_785?5qhn?t<(qWOD%Brq8uhYAd{>_mIgRNii$6NqSG77%o)VT_M$(R?bdg4!8NU|qwXa#@APSZ<;)!r zvSx7@*a%?Wy56QP0^L~>3va+trh}WpA0}bJ27_78NjfoXEwPguz{Vo|`mRrMpggsX ztEwH1@H?Z{L`AzDVAT*by(*swl!+SfcSy*r^NB{%;D$gYuC&gg{}5A?zW1d0n)SeJ3A5dazdm>g0H2)PW9kODW*c)D^G@7fIEOaRlTpCBDr% zS`oT)3H?7|-ke8cd^7$Fn8aB%1xLty?|fznI5jzZdeHbIHAa2LBM|ILkY*83!}x-% z{ruV-a4h1QQ;}I>k_zF0M~1Uk^zHyMG5Xj(9n)t4uIfsLQ98G-ITuU_d?)c{!A??} zsWDw3-Dr*7uo!Mb@w6kPdX>AV;2g~Y9IIlhp8I$Y$wj)tYiz(spdDzSeR+;aZZQ8< z_wJUrj5T8#oheY%wm`Bh9%~bDh`Cv3lSRj$D2uPR9mEs$+oHVQvUxNz{Aze2zhwM| zB!Wnn3MJ_1{EEMtp@{o=r~EeHHC^*#8acB;jS0HQ7`UVED#s9x~q{iDm;rTee3S|!_s63vL9%!fS6j*F?cxk^T17} zQcqlo&;H;0ep4YN4*|i|)vqQKiHdMp=)}mMwZp6r$8$oCW|U2aNfvj2L}q`$7QmF8 z)x06g#5UQtjyo0LR{PWxugR~&^Sd@VW^wID8!n)f(ev~Eu+h_18tA-&)n5iMLWZYS zZevDf4ivv>>lwzAcu4r4RG;Xq@pV~r=;*#1^C_JZ3LSbV?m{WvN1m2y*n z3^`+SuWDDGBl?dqv*j&Zj)fy z`L=cFFFuo{!e%NHI!f-MGzH2%NnWoe zi|R!yVMQ7-l3}I<%l}~aM7Zx=ZAub~MqnbRj;$vF6}(&QieMrB!x4@U1nVZiSUZ^K z>CE(qJ%{-Fs(l_UDHs?hcf#t7lD&X!GcC@>oVoku>8;H?=;sKf2oAxPCJi;cqKiKgHD>=>7R z{lkb>p03Y8wkZqryVo_cRJ{tBh&Q4W@;b^=Kz3o2Lw9vgXiBZ^`14S05OMm-y3W=f zK{&hbyMFNNTaanfPl4W|X%0mNF;r^Z?YjuVtv$;-P)fM_1I?13yz!6RNORLkiV8!} ze$Ofb*x_}417hMz+k=i8yp@MlBeNGQyD)YNPErBSMlhEPRPKdEsyMn0WHwey2!W@* z=Hb9+*WU6%m`BLGZQiJ;p^db)6ss(<`~%@CuJ8yWLcX)%+c2xy%ADCA6$`x@S;$Yy z+^JAgkFsqHt~H3)n}HJU;=5KoVoXP7?}32!%230tRM<~9_U?j3CHQ(a579*$ z;;|>X9-VGD#@HP9p*Iia(!5O9oYlxxK5DnQE?s*Kj7O6|WDiR-Ly!?)R|4#s03%gk z#vyyfYnJz64JJBnuv#0BoJ4kWL-l|dzW$f6B99of2wEE|D@?4o0E_d|lvcRlx)E$m+{{=6KFT-}~1)8^VC&T=`Br64++p{v!Ptm#(rjN6aFQrM3e1$VM z7xqH^!1@UNF5Ed;CpLRu;%ROPiUmC&`Ly-FE^Bm~FLqjF8Lb%3Z#GxN71ZGKBui*p z6X_tN`ch*|6AFaECn~;tiVCzSFWU6?@Z)x>NgO{;GG9k1HO7JwDtbmgEuItaf)#h4 zU;On~xz&_?h?-q7RYVIl%*#||38Kf$-ctgzl8Zxs;mQ{~Vg_51kxycc?4bN{xL&p6 z^@-Fi*ZY&KeuXgzl!6Sj@14=TA7ncw&8$mrew|lxS0a1dsq?#hX3e-eX??#*{mw8H zkjQ?R!RpSZTD#r~)r8*SmW{fZG=p_(7t(9@llE3556H4N;(DGMH$;En>93%VD{Iy> zpM8Q90kfH6Ui4Ynvd_+oNgVtMo`2#uueiQy`^u=4!Rj1ycR_gHwveaa-bBQ^&m1Th z$NUR^q>=shD(w34zbx}l^f`kRyp0-9zT7=VHt5#L@r_mk6g*gwaUUL{B4=c~3()0f z3(t*0cGW748-*9f=CRJ}P8+!dzVR`pW?3xKOwYP+%b zLF%U_@Os;l@L?el+bV>pRP z#8cy$!ntI7eub!$q3e9AXY)YBSAc$dt(NocrOmBJuegXzSqA-^Gt77+8X~xPeePx@ z2E(<~9(0JS!Lr^RG<8GM_*B!@|2hhUz(7EIp0-o?d$wN@DI1ckKU}+0P3IR0uN|q< zmBSb2ECfQuz~mL<-ET6754Hy+Scf*LVtI$EEIZzX1Ph5#7SjYTSjUo&Sf#s#xEXKZ z3^qy%{Q&3jm1~ z??25)_&()IdjFs6MJALbwXha8t#XD#@jgpl7F&oobd|wVgjgTR_0T+gTz}+)<*vH$ zsXDq^)AhHHaSeXm_4(Qo@7o315fuQXq#t z;0L~WI+A|i!!2kNOuq!cD_*s^n&(I07R|+_Or8bqu8$#P|8Cx2vg;yB4>h_|K6FC# z%vX%1c}NvQw@dSlc3b)Couxc;zU9|HmXc)*Pm{{|f4OD`-bq`CrwQp+c|aP6uEN1v PbO7R-$<^{Jcb@!zgdCCy literal 0 HcmV?d00001 diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index ba3677e72..d62140197 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2223,7 +2223,7 @@ PODS: - React-perflogger (= 0.81.0) - React-utils (= 0.81.0) - SocketRocket - - ReactNativeCameraKit (16.2.0): + - ReactNativeCameraKit (17.0.1): - boost - DoubleConversion - fast_float @@ -2552,7 +2552,7 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: c91900fa724baee992f01c05eeb4c9e01a807f78 ReactCodegen: a55799cae416c387aeaae3aabc1bc0289ac19cee ReactCommon: 116d6ee71679243698620d8cd9a9042541e44aa6 - ReactNativeCameraKit: 9a5c627808ea152bc4c7e5574a89ced9ca196e40 + ReactNativeCameraKit: 84894fc476300e5d3b9f4e34f55ff75050ec5050 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 Yoga: 00013dd9cde63a2d98e8002fcc4f5ddb66c10782 diff --git a/example/src/CameraExample.tsx b/example/src/CameraExample.tsx index cb9cd6aca..c0724f0d5 100644 --- a/example/src/CameraExample.tsx +++ b/example/src/CameraExample.tsx @@ -1,9 +1,10 @@ import type React from 'react'; -import { useState, useRef, useEffect } from 'react'; +import { useCallback, useState, useRef, useEffect } from 'react'; import { StyleSheet, Text, View, TouchableOpacity, Image, Animated, ScrollView } from 'react-native'; import Camera from '../../src/Camera'; import { type CameraApi, CameraType, type CaptureData } from '../../src/types'; import { Orientation } from '../../src'; +import { type FaceData, type OnFaceDetectedData } from '../../src/CameraProps'; import SafeAreaView from './SafeAreaView'; const flashImages = { @@ -33,6 +34,103 @@ function median(values: number[]): number { return sortedValues.length % 2 ? sortedValues[half] : (sortedValues[half - 1] + sortedValues[half]) / 2; } +const FACING_THRESHOLD_DEG = 15; +const CENTERING_TOLERANCE = 0.2; +function isFacingCamera(face: FaceData): boolean { + const orientationOk = Math.abs(face.yaw) < FACING_THRESHOLD_DEG && Math.abs(face.pitch) < FACING_THRESHOLD_DEG; + const centerX = face.boundsX + face.boundsWidth / 2; + const centerY = face.boundsY + face.boundsHeight / 2; + const centeredOk = Math.abs(centerX - 0.5) < CENTERING_TOLERANCE && Math.abs(centerY - 0.5) < CENTERING_TOLERANCE; + return orientationOk && centeredOk; +} + +function CaptureButton({ onPress, children }: { onPress: () => void; children?: React.ReactNode }) { + const w = 80; + const brdW = 4; + const spc = 6; + const cInner = 'white'; + const cOuter = 'white'; + return ( + + + + {children} + + ); +} + +function FaceFrame({ face, layout }: { face: FaceData; layout: { width: number; height: number } }) { + if (!layout.width || !layout.height) return null; + const facing = isFacingCamera(face); + const color = facing ? '#22c55e' : '#facc15'; + const left = face.boundsX * layout.width; + const top = face.boundsY * layout.height; + const height = face.boundsHeight * layout.height; + return ( + <> + + + ID {face.id} + + + ); +} + +function FaceStats({ faces }: { faces: readonly FaceData[] }) { + const face = faces[0]; + return ( + + Faces: {faces.length} + {face && ( + <> + id: {face.id} + Yaw: {face.yaw.toFixed(1)}° + Pitch: {face.pitch.toFixed(1)}° + Roll: {face.roll.toFixed(1)}° + Facing: {isFacingCamera(face) ? 'yes' : 'no'} + + Box: {face.boundsX.toFixed(2)},{face.boundsY.toFixed(2)} {face.boundsWidth.toFixed(2)}× + {face.boundsHeight.toFixed(2)} + + + )} + + ); +} + const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolean }) => { const cameraRef = useRef(null); const [currentFlashArrayPosition, setCurrentFlashArrayPosition] = useState(0); @@ -45,6 +143,13 @@ const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolea const [zoom, setZoom] = useState(); const [orientationAnim] = useState(new Animated.Value(3)); const [resize, setResize] = useState<'contain' | 'cover'>('contain'); + const [faceDetection, setFaceDetection] = useState(false); + const [faces, setFaces] = useState([]); + const [cameraLayout, setCameraLayout] = useState({ width: 0, height: 0 }); + + useEffect(() => { + if (!faceDetection) setFaces([]); + }, [faceDetection]); // zoom to random positions every 10ms: useEffect(() => { @@ -95,6 +200,20 @@ const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolea setTorchMode(!torchMode); }; + const onSetFaceDetection = () => { + setFaceDetection(!faceDetection); + }; + + const onFaceDetected = useCallback((e: OnFaceDetectedData) => { + const next = e.nativeEvent.faces; + setFaces((prev) => (prev.length === 0 && next.length === 0 ? prev : next)); + }, []); + + const onCameraLayout = useCallback((e: { nativeEvent: { layout: { width: number; height: number } } }) => { + const { width, height } = e.nativeEvent.layout; + setCameraLayout((prev) => (prev.width === width && prev.height === height ? prev : { width, height })); + }, []); + const onCaptureImagePressed = async () => { const times: number[] = []; for (let i = 1; i <= 5; i++) { @@ -123,42 +242,6 @@ const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolea console.log(`median capture time: ${median(times)}ms`); }; - function CaptureButton({ onPress, children }: { onPress: () => void; children?: React.ReactNode }) { - const w = 80; - const brdW = 4; - const spc = 6; - const cInner = 'white'; - const cOuter = 'white'; - return ( - - - - {children} - - ); - } - // Counter-rotate the icons to indicate the actual orientation of the captured photo. // For this example, it'll behave incorrectly since UI orientation is allowed (and already-counter rotates the entire screen) // For real phone apps, lock your UI orientation using a library like 'react-native-orientation-locker' @@ -213,6 +296,16 @@ const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolea /> + + + + void; stress?: boolea torchMode={torchMode ? 'on' : 'off'} shutterPhotoSound maxPhotoQualityPrioritization="speed" + faceDetectionEnabled={faceDetection} + faceDetectionThrottleMs={100} + onLayout={onCameraLayout} + onFaceDetected={onFaceDetected} onCaptureButtonPressIn={() => { console.log('capture button pressed in'); }} @@ -279,6 +376,15 @@ const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolea }} /> )} + + {faceDetection && !showImageUri && faces.length > 0 && ( + <> + {faces.map((face) => ( + + ))} + + + )} @@ -336,10 +442,14 @@ const styles = StyleSheet.create({ justifyContent: 'center', alignItems: 'center', }, + topButtonActive: { + backgroundColor: '#1e7eff', + }, topButtonImg: { margin: 10, width: 24, height: 24, + tintColor: 'white', }, cameraContainer: { justifyContent: 'center', @@ -391,4 +501,36 @@ const styles = StyleSheet.create({ borderRadius: 4, marginEnd: 10, }, + faceFrame: { + position: 'absolute', + borderWidth: 3, + borderRadius: 8, + }, + faceIdBadge: { + position: 'absolute', + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + borderWidth: 1, + backgroundColor: 'rgba(0,0,0,0.6)', + }, + faceIdText: { + fontSize: 11, + fontWeight: '600', + fontVariant: ['tabular-nums'], + }, + statsBox: { + position: 'absolute', + top: 10, + right: 25, + backgroundColor: 'rgba(0,0,0,0.55)', + paddingHorizontal: 8, + paddingVertical: 6, + borderRadius: 6, + }, + statsText: { + color: 'white', + fontSize: 11, + fontVariant: ['tabular-nums'], + }, }); diff --git a/ios/ReactNativeCameraKit/CKCameraManager.mm b/ios/ReactNativeCameraKit/CKCameraManager.mm index 6ef4da3fc..3d6a2ce77 100644 --- a/ios/ReactNativeCameraKit/CKCameraManager.mm +++ b/ios/ReactNativeCameraKit/CKCameraManager.mm @@ -33,6 +33,10 @@ @interface RCT_EXTERN_MODULE (CKCameraManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(barcodeFrameSize, NSDictionary) RCT_EXPORT_VIEW_PROPERTY(allowedBarcodeTypes, NSArray) +RCT_EXPORT_VIEW_PROPERTY(faceDetectionEnabled, BOOL) +RCT_EXPORT_VIEW_PROPERTY(faceDetectionThrottleMs, NSInteger) +RCT_EXPORT_VIEW_PROPERTY(onFaceDetected, RCTDirectEventBlock) + RCT_EXPORT_VIEW_PROPERTY(onOrientationChange, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onCaptureButtonPressIn, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onCaptureButtonPressOut, RCTDirectEventBlock) diff --git a/ios/ReactNativeCameraKit/CKCameraViewComponentView.mm b/ios/ReactNativeCameraKit/CKCameraViewComponentView.mm index a91fb2fc0..b7fc51133 100644 --- a/ios/ReactNativeCameraKit/CKCameraViewComponentView.mm +++ b/ios/ReactNativeCameraKit/CKCameraViewComponentView.mm @@ -151,6 +151,33 @@ - (void)prepareView { ->onCaptureButtonPressOut({}); } }]; + [_view setOnFaceDetected:^(NSDictionary *event) { + __typeof__(self) strongSelf = weakSelf; + + if (strongSelf != nullptr && strongSelf->_eventEmitter != nullptr) { + NSArray *facesArray = [event valueForKey:@"faces"] ?: @[]; + std::vector faces; + faces.reserve(facesArray.count); + for (NSDictionary *face in facesArray) { + faces.push_back({ + .id = [face[@"id"] intValue], + .yaw = [face[@"yaw"] doubleValue], + .pitch = [face[@"pitch"] doubleValue], + .roll = [face[@"roll"] doubleValue], + .boundsX = [face[@"boundsX"] doubleValue], + .boundsY = [face[@"boundsY"] doubleValue], + .boundsWidth = [face[@"boundsWidth"] doubleValue], + .boundsHeight = [face[@"boundsHeight"] doubleValue], + }); + } + facebook::react::CKCameraEventEmitter::OnFaceDetected payload = { + .faces = std::move(faces), + }; + std::dynamic_pointer_cast( + strongSelf->_eventEmitter) + ->onFaceDetected(payload); + } + }]; self.contentView = _view; } @@ -291,6 +318,15 @@ - (void)updateProps:(const Props::Shared &)props @{@"width" : @(barcodeWidth), @"height" : @(barcodeHeight)}; [changedProps addObject:@"barcodeFrameSize"]; } + if (_view.faceDetectionEnabled != newProps.faceDetectionEnabled) { + _view.faceDetectionEnabled = newProps.faceDetectionEnabled; + [changedProps addObject:@"faceDetectionEnabled"]; + } + if (newProps.faceDetectionThrottleMs > -1 && + _view.faceDetectionThrottleMs != newProps.faceDetectionThrottleMs) { + _view.faceDetectionThrottleMs = newProps.faceDetectionThrottleMs; + [changedProps addObject:@"faceDetectionThrottleMs"]; + } // Since viewprops optional props isn't supported in all RN versions, // we assume empty arrays mean it's not defined / ignore changes to it. // if the user/dev wants to NOT define the prop, they can simply use diff --git a/ios/ReactNativeCameraKit/CameraProtocol.swift b/ios/ReactNativeCameraKit/CameraProtocol.swift index a0c2353c2..1cf943a42 100644 --- a/ios/ReactNativeCameraKit/CameraProtocol.swift +++ b/ios/ReactNativeCameraKit/CameraProtocol.swift @@ -34,6 +34,12 @@ protocol CameraProtocol: AnyObject, FocusInterfaceViewDelegate { func update(scannerFrameSize: CGRect?) + func isFaceDetectionEnabled( + _ isEnabled: Bool, + onFaceDetected: ((_ payloads: [FaceDetectionPayload]) -> Void)?) + + func update(faceDetectionThrottleMs: Int) + func capturePicture( onWillCapture: @escaping () -> Void, onSuccess: diff --git a/ios/ReactNativeCameraKit/CameraView.swift b/ios/ReactNativeCameraKit/CameraView.swift index 1e5cc62d2..9ea1d96d2 100644 --- a/ios/ReactNativeCameraKit/CameraView.swift +++ b/ios/ReactNativeCameraKit/CameraView.swift @@ -49,6 +49,11 @@ public class CameraView: UIView { @objc public var barcodeFrameSize: NSDictionary? @objc public var allowedBarcodeTypes: NSArray? + // face detection + @objc public var faceDetectionEnabled = false + @objc public var faceDetectionThrottleMs: Int = FaceDetector.defaultThrottleMs + @objc public var onFaceDetected: RCTDirectEventBlock? + // other @objc public var onOrientationChange: RCTDirectEventBlock? @objc public var onZoom: RCTDirectEventBlock? @@ -275,6 +280,18 @@ public class CameraView: UIView { }) } + // Face detection + if changedProps.contains("faceDetectionEnabled") { + camera.isFaceDetectionEnabled( + faceDetectionEnabled, + onFaceDetected: { [weak self] payloads in + self?.onFaceDetected?(["faces": payloads.map { $0.asDictionary }]) + }) + } + if changedProps.contains("faceDetectionThrottleMs") { + camera.update(faceDetectionThrottleMs: faceDetectionThrottleMs) + } + if changedProps.contains("showFrame") || changedProps.contains("scanBarcode") { DispatchQueue.main.async { self.scannerInterfaceView.isHidden = !self.showFrame diff --git a/ios/ReactNativeCameraKit/FaceDetector.swift b/ios/ReactNativeCameraKit/FaceDetector.swift new file mode 100644 index 000000000..3b16a507f --- /dev/null +++ b/ios/ReactNativeCameraKit/FaceDetector.swift @@ -0,0 +1,103 @@ +// +// FaceDetector.swift +// ReactNativeCameraKit +// + +import AVFoundation +import CoreVideo +import Foundation +import Vision + +struct FaceDetectionPayload { + let id: Int + let yaw: Double + let pitch: Double + let roll: Double + let boundsX: Double + let boundsY: Double + let boundsWidth: Double + let boundsHeight: Double + + var asDictionary: [String: Any] { + return [ + "id": id, + "yaw": yaw, + "pitch": pitch, + "roll": roll, + "boundsX": boundsX, + "boundsY": boundsY, + "boundsWidth": boundsWidth, + "boundsHeight": boundsHeight, + ] + } +} + +final class FaceDetector { + static let defaultThrottleMs: Int = 100 + + private var throttleSeconds: TimeInterval = TimeInterval(FaceDetector.defaultThrottleMs) / 1000.0 + + private let request: VNDetectFaceRectanglesRequest = { + let r = VNDetectFaceRectanglesRequest() + r.revision = VNDetectFaceRectanglesRequestRevision3 + return r + }() + + private var lastEmit: TimeInterval = 0 + private var lastErrorDescription: String? + + func update(throttleMs: Int) { + let validated = throttleMs < 0 ? FaceDetector.defaultThrottleMs : throttleMs + throttleSeconds = TimeInterval(validated) / 1000.0 + } + + func process(pixelBuffer: CVPixelBuffer, orientation: CGImagePropertyOrientation) -> [FaceDetectionPayload]? { + let now = Date.timeIntervalSinceReferenceDate + guard now - lastEmit >= throttleSeconds else { return nil } + lastEmit = now + + let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: orientation, options: [:]) + do { + try handler.perform([request]) + lastErrorDescription = nil + } catch { + let description = "\(error)" + if description != lastErrorDescription { + print("CKCameraKit: face detection error: \(description)") + lastErrorDescription = description + } + return [] + } + + let observations = request.results ?? [] + return observations.enumerated().map { build(from: $1, id: $0) } + } + + private func build(from face: VNFaceObservation, id: Int) -> FaceDetectionPayload { + let bounds = previewBounds(face.boundingBox) + return FaceDetectionPayload( + id: id, + yaw: -degrees(from: face.yaw), + pitch: degrees(from: face.pitch), + roll: degrees(from: face.roll), + boundsX: Double(bounds.origin.x), + boundsY: Double(bounds.origin.y), + boundsWidth: Double(bounds.size.width), + boundsHeight: Double(bounds.size.height) + ) + } + + private func degrees(from radians: NSNumber?) -> Double { + guard let radians = radians?.doubleValue else { return 0 } + return radians * 180.0 / .pi + } + + private func previewBounds(_ rect: CGRect) -> CGRect { + return CGRect( + x: rect.origin.x, + y: 1.0 - rect.origin.y - rect.size.height, + width: rect.size.width, + height: rect.size.height + ) + } +} diff --git a/ios/ReactNativeCameraKit/RealCamera.swift b/ios/ReactNativeCameraKit/RealCamera.swift index 135a383ce..618c804d0 100644 --- a/ios/ReactNativeCameraKit/RealCamera.swift +++ b/ios/ReactNativeCameraKit/RealCamera.swift @@ -7,6 +7,7 @@ import AVFoundation import CoreMotion +import ImageIO import React import UIKit @@ -14,13 +15,14 @@ import UIKit * Real camera implementation that uses AVFoundation */ // swiftlint:disable:next type_body_length -class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelegate { +class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelegate, AVCaptureVideoDataOutputSampleBufferDelegate { var previewView: UIView { cameraPreview } private let cameraPreview = RealPreviewView(frame: .zero) private let session = AVCaptureSession() // Communicate with the session and other session objects on this queue. private let sessionQueue = DispatchQueue(label: "com.tesla.react-native-camera-kit") + private let visionQueue = DispatchQueue(label: "com.tesla.react-native-camera-kit.vision") // utilities private var setupResult: SetupResult = .notStarted @@ -30,6 +32,11 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega private var videoDeviceInput: AVCaptureDeviceInput? private let photoOutput = AVCapturePhotoOutput() private let metadataOutput = AVCaptureMetadataOutput() + private let videoDataOutput = AVCaptureVideoDataOutput() + private var isVideoDataOutputAttached = false + private let faceDetector = FaceDetector() + private var onFaceDetected: (([FaceDetectionPayload]) -> Void)? + private var currentVisionOrientation: CGImagePropertyOrientation = .right private var resizeMode: ResizeMode = .contain private var flashMode: FlashMode = .auto @@ -343,6 +350,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega } self.addObservers() + self.refreshVisionOrientation() } } @@ -467,6 +475,43 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega } } + func update(faceDetectionThrottleMs: Int) { + sessionQueue.async { + self.faceDetector.update(throttleMs: faceDetectionThrottleMs) + } + } + + func isFaceDetectionEnabled( + _ isEnabled: Bool, + onFaceDetected: ((_ payloads: [FaceDetectionPayload]) -> Void)? + ) { + sessionQueue.async { + self.onFaceDetected = onFaceDetected + + let shouldAttach = isEnabled && onFaceDetected != nil + if shouldAttach == self.isVideoDataOutputAttached { return } + + self.session.beginConfiguration() + defer { self.session.commitConfiguration() } + + if shouldAttach { + self.videoDataOutput.alwaysDiscardsLateVideoFrames = true + self.videoDataOutput.videoSettings = [ + kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA, + ] + self.videoDataOutput.setSampleBufferDelegate(self, queue: self.visionQueue) + if self.session.canAddOutput(self.videoDataOutput) { + self.session.addOutput(self.videoDataOutput) + self.isVideoDataOutputAttached = true + } + } else { + self.videoDataOutput.setSampleBufferDelegate(nil, queue: nil) + self.session.removeOutput(self.videoDataOutput) + self.isVideoDataOutputAttached = false + } + } + } + // MARK: - AVCaptureMetadataOutputObjectsDelegate func metadataOutput( @@ -487,6 +532,44 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega onBarcodeRead?(codeStringValue, barcodeType) } + // MARK: - Vision orientation + + private func visionOrientation(for cameraPosition: AVCaptureDevice.Position) -> CGImagePropertyOrientation { + let isFront = cameraPosition == .front + switch deviceOrientation { + case .landscapeLeft: return isFront ? .downMirrored : .up + case .landscapeRight: return isFront ? .upMirrored : .down + case .portraitUpsideDown: return isFront ? .rightMirrored : .left + default: return isFront ? .leftMirrored : .right + } + } + + private func refreshVisionOrientation() { + guard let position = videoDeviceInput?.device.position else { return } + currentVisionOrientation = visionOrientation(for: position) + } + + // MARK: - AVCaptureVideoDataOutputSampleBufferDelegate + + func captureOutput( + _ output: AVCaptureOutput, + didOutput sampleBuffer: CMSampleBuffer, + from connection: AVCaptureConnection + ) { + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + + guard let payloads = faceDetector.process( + pixelBuffer: pixelBuffer, + orientation: currentVisionOrientation + ) else { + return + } + + DispatchQueue.main.async { + self.onFaceDetected?(payloads) + } + } + // MARK: - Private private func videoOrientation(from deviceOrientation: UIDeviceOrientation) @@ -579,6 +662,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega if session.canAddInput(videoDeviceInput) { session.addInput(videoDeviceInput) self.videoDeviceInput = videoDeviceInput + self.refreshVisionOrientation() } else { return .sessionConfigurationFailed } @@ -719,6 +803,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega } self.deviceOrientation = newOrientation + self.refreshVisionOrientation() self.onOrientationChange?([ "orientation": Orientation.init(from: newOrientation)!.rawValue ]) diff --git a/ios/ReactNativeCameraKit/SimulatorCamera.swift b/ios/ReactNativeCameraKit/SimulatorCamera.swift index d72a06017..41fe9629e 100644 --- a/ios/ReactNativeCameraKit/SimulatorCamera.swift +++ b/ios/ReactNativeCameraKit/SimulatorCamera.swift @@ -191,6 +191,13 @@ class SimulatorCamera: CameraProtocol { ) {} func update(scannerFrameSize: CGRect?) {} + func isFaceDetectionEnabled( + _ isEnabled: Bool, + onFaceDetected: ((_ payloads: [FaceDetectionPayload]) -> Void)? + ) {} + + func update(faceDetectionThrottleMs: Int) {} + func capturePicture( onWillCapture: @escaping () -> Void, onSuccess: diff --git a/src/Camera.android.tsx b/src/Camera.android.tsx index 56cddbccd..0a47aff6d 100644 --- a/src/Camera.android.tsx +++ b/src/Camera.android.tsx @@ -14,6 +14,7 @@ const Camera = React.forwardRef((props, ref) => { props.zoom = props.zoom ?? -1; props.maxZoom = props.maxZoom ?? -1; props.scanThrottleDelay = props.scanThrottleDelay ?? -1; + props.faceDetectionThrottleMs = props.faceDetectionThrottleMs ?? -1; props.allowedBarcodeTypes = props.allowedBarcodeTypes ?? supportedCodeFormats; diff --git a/src/Camera.ios.tsx b/src/Camera.ios.tsx index 0845d6d91..63d3c87b6 100644 --- a/src/Camera.ios.tsx +++ b/src/Camera.ios.tsx @@ -14,6 +14,7 @@ const Camera = React.forwardRef((props, ref) => { props.zoom = props.zoom ?? -1; props.maxZoom = props.maxZoom ?? -1; props.scanThrottleDelay = props.scanThrottleDelay ?? -1; + props.faceDetectionThrottleMs = props.faceDetectionThrottleMs ?? -1; props.iOsDeferredStart = props.iOsDeferredStart ?? true; props.allowedBarcodeTypes = props.allowedBarcodeTypes ?? supportedCodeFormats; diff --git a/src/CameraProps.ts b/src/CameraProps.ts index 2eec24af1..1957677ec 100644 --- a/src/CameraProps.ts +++ b/src/CameraProps.ts @@ -27,7 +27,24 @@ export type OnZoom = { nativeEvent: { zoom: number; }; -} +}; + +export type FaceData = { + id: number; + yaw: number; + pitch: number; + roll: number; + boundsX: number; + boundsY: number; + boundsWidth: number; + boundsHeight: number; +}; + +export type OnFaceDetectedData = { + nativeEvent: { + faces: ReadonlyArray; + }; +}; export interface CameraProps extends ViewProps { // Behavior @@ -120,4 +137,10 @@ export interface CameraProps extends ViewProps { onCaptureButtonPressIn?: ({ nativeEvent: {} }) => void; onCaptureButtonPressOut?: ({ nativeEvent: {} }) => void; allowedBarcodeTypes?: CodeFormat[]; + /** Enable real-time face detection. iOS uses Apple Vision; Android uses MLKit */ + faceDetectionEnabled?: boolean; + /** Throttle how often `onFaceDetected` emits. Defaults to 100 (~10 events/sec) */ + faceDetectionThrottleMs?: number; + /** Fires per frame while face detection is active; bounds are normalized 0–1 in preview space */ + onFaceDetected?: (event: OnFaceDetectedData) => void; } diff --git a/src/specs/CameraNativeComponent.ts b/src/specs/CameraNativeComponent.ts index e9d5d7dc1..0cf43b525 100644 --- a/src/specs/CameraNativeComponent.ts +++ b/src/specs/CameraNativeComponent.ts @@ -28,6 +28,19 @@ type OnZoom = { zoom: Double; } +type OnFaceDetectedData = { + faces: { + id: Int32; + yaw: Double; + pitch: Double; + roll: Double; + boundsX: Double; + boundsY: Double; + boundsWidth: Double; + boundsHeight: Double; + }[]; +}; + // We have to use -1 until RN Fabric (New Arch for view components) supports optional values: // https://github.com/facebook/react-native/issues/49920#issuecomment-3237917813 export interface NativeProps extends ViewProps { @@ -59,6 +72,9 @@ export interface NativeProps extends ViewProps { onCaptureButtonPressIn?: DirectEventHandler<{}>; onCaptureButtonPressOut?: DirectEventHandler<{}>; allowedBarcodeTypes?: string[]; + faceDetectionEnabled?: boolean; + faceDetectionThrottleMs?: WithDefault; + onFaceDetected?: DirectEventHandler; // not mentioned in props but available on the native side shutterAnimationDuration?: WithDefault; From debc37a32118cd9b1469b1f0e8b2672e5e004037 Mon Sep 17 00:00:00 2001 From: Mike Roberts Date: Wed, 29 Apr 2026 16:15:22 -0500 Subject: [PATCH 2/6] Add onFaceDetectionInstallStatus event for MLKit module download --- README.md | 34 ++++-- .../src/main/java/com/rncamerakit/CKCamera.kt | 14 ++- .../main/java/com/rncamerakit/FaceAnalyzer.kt | 111 +++++++++++++++++- .../events/FaceDetectionInstallStatusEvent.kt | 21 ++++ .../java/com/rncamerakit/CKCameraManager.kt | 1 + .../java/com/rncamerakit/CKCameraManager.kt | 1 + example/src/CameraExample.tsx | 60 ++++++++-- src/CameraProps.ts | 12 +- src/specs/CameraNativeComponent.ts | 1 + 9 files changed, 231 insertions(+), 24 deletions(-) create mode 100644 android/src/main/java/com/rncamerakit/events/FaceDetectionInstallStatusEvent.kt diff --git a/README.md b/README.md index 989f4a7ba..ab9198b79 100644 --- a/README.md +++ b/README.md @@ -179,15 +179,34 @@ Detect faces in real time. iOS uses Apple Vision; Android uses Google ML Kit. { - // event.nativeEvent.faces: ReadonlyArray - // each: { id, yaw, pitch, roll, boundsX, boundsY, boundsWidth, boundsHeight } + // event.nativeEvent.faces: FaceData[] + // each face: { id, yaw, pitch, roll, boundsX, boundsY, boundsWidth, boundsHeight } + }} + // Android only — track MLKit face module download progress + onFaceDetectionInstallStatus={(event) => { + // event.nativeEvent.state: FaceDetectionInstallState + // 'pending' | 'downloading' | 'installing' | 'ready' | 'failed' }} /> ``` -> **Android note:** Uses the unbundled `play-services-mlkit-face-detection` variant — the ML model is downloaded by Google Play Services on first use rather than bundled in the APK. Requires Play Services on the device. +##### Android: pre-download the ML Kit model + +This library depends on the unbundled `play-services-mlkit-face-detection` variant — the model is downloaded by Google Play Services rather than bundled in the APK. By default the download happens on first use, and `onFaceDetectionInstallStatus` reports the progress. + +To have Play Services [pre-download the model in the background after the app is installed](https://developers.google.com/ml-kit/tips/installation-paths#how_to_download_models), add this to your app's `AndroidManifest.xml`: + +```xml + + + +``` + +> **Note:** Requires Google Play Services on the device. ### Camera Props (Optional) @@ -216,7 +235,7 @@ Detect faces in real time. iOS uses Apple Vision; Android uses Google ML Kit. | `resizeMode` | `'cover' / 'contain'` | Determines the scaling and cropping behavior of content within the view. `cover` (resizeAspectFill on iOS) scales the content to fill the view completely, potentially cropping content if its aspect ratio differs from the view. `contain` (resizeAspect on iOS) scales the content to fit within the view's bounds without cropping, ensuring all content is visible but may introduce letterboxing. Default behavior depends on the specific use case. | | `scanThrottleDelay` | `number` | Duration between scan detection in milliseconds. Default 2000 (2s) | | `maxPhotoQualityPrioritization` | `'balanced'` / `'quality'` / `'speed'` | [iOS 13 and newer](https://developer.apple.com/documentation/avfoundation/avcapturephotooutput/3182995-maxphotoqualityprioritization). `'speed'` provides a 60-80% median capture time reduction vs 'quality' setting. Tested on iPhone 6S Max (66% faster) and iPhone 15 Pro Max (76% faster!). Default `balanced` | -| `iOsDeferredStart` | `boolean` | iOS 26+ only. Enables `AVCaptureOutput.deferredStartEnabled` when supported to get the preview visible faster. Default `true`. When enabled, the first capture can be delayed by a few hundred milliseconds. Ignored on Android and on older iOS versions. | +| `iOsDeferredStart` | `boolean` | iOS 26+ only. Enables `AVCaptureOutput.deferredStartEnabled` when supported to get the preview visible faster. Default `true`. When enabled, the first capture can be delayed by a few hundred milliseconds. Ignored on Android and on older iOS versions. | | `onCaptureButtonPressIn` | Function | Callback when iPhone capture button is pressed in or Android volume or camera button is pressed in. Ex: `onCaptureButtonPressIn={() => console.log("volume button pressed in")}` | | `onCaptureButtonPressOut` | Function | Callback when iPhone capture button is released or Android volume or camera button is released. Ex: `onCaptureButtonPressOut={() => console.log("volume button released")}` | | **Barcode only** | @@ -228,8 +247,9 @@ Detect faces in real time. iOS uses Apple Vision; Android uses Google ML Kit. | `onReadCode` | Function | Callback when scanner successfully reads barcode. Returned event contains `codeStringValue`. Default: `null`. Ex: `onReadCode={(event) => console.log(event.nativeEvent.codeStringValue)}` | | **Face detection** | | `faceDetectionEnabled` | `boolean` | Enable real-time face detection. Default: `false` | -| `faceDetectionThrottleMs` | `number` | Minimum milliseconds between `onFaceDetected` emits. Default: `100` (~10 events/sec) | -| `onFaceDetected` | Function | Callback while face detection is active, with one entry per detected face (empty array if none). Each face has `id`, `yaw`, `pitch`, `roll` (degrees), and `boundsX/Y/Width/Height` (normalized 0–1, top-left, preview-space). | +| `faceDetectionThrottleMs` | `number` | Minimum milliseconds between `onFaceDetected` emits. Default: `100` | +| `onFaceDetected` | Function | Callback while face detection is active, with one entry per detected face (empty array if none). | +| `onFaceDetectionInstallStatus` | Function | **Android only.** Callback while the MLKit face detection module is being downloaded by Google Play Services. See [Android: pre-download the ML Kit model](#android-pre-download-the-ml-kit-model) above to skip the first-run download. | ### Imperative API diff --git a/android/src/main/java/com/rncamerakit/CKCamera.kt b/android/src/main/java/com/rncamerakit/CKCamera.kt index b3bd12cae..a74c95ec5 100644 --- a/android/src/main/java/com/rncamerakit/CKCamera.kt +++ b/android/src/main/java/com/rncamerakit/CKCamera.kt @@ -412,7 +412,12 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs faceAnalyzer?.close() faceAnalyzer = null if (faceDetectionEnabled) { - val analyzer = FaceAnalyzer(faceDetectionThrottleMs) { payloads -> onFaceDetected(payloads) } + val analyzer = FaceAnalyzer( + faceDetectionThrottleMs, + context, + { state -> onFaceDetectionInstallStatus(state) }, + { payloads -> onFaceDetected(payloads) } + ) faceAnalyzer = analyzer useCases.add( ImageAnalysis.Builder() @@ -590,6 +595,13 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs ?.dispatchEvent(FaceDetectedEvent(surfaceId, id, mirrored)) } + private fun onFaceDetectionInstallStatus(state: String) { + val surfaceId = UIManagerHelper.getSurfaceId(currentContext) + UIManagerHelper + .getEventDispatcherForReactTag(currentContext, id) + ?.dispatchEvent(FaceDetectionInstallStatusEvent(surfaceId, id, state)) + } + private fun onOrientationChange(orientation: Int) { val remappedOrientation = when (orientation) { Surface.ROTATION_0 -> RNCameraKitModule.PORTRAIT diff --git a/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt b/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt index ae3a27449..60d65cf59 100644 --- a/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt +++ b/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt @@ -1,12 +1,20 @@ package com.rncamerakit import android.annotation.SuppressLint +import android.content.Context +import android.util.Log import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy +import com.google.android.gms.common.moduleinstall.InstallStatusListener +import com.google.android.gms.common.moduleinstall.ModuleInstall +import com.google.android.gms.common.moduleinstall.ModuleInstallClient +import com.google.android.gms.common.moduleinstall.ModuleInstallRequest +import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.face.Face import com.google.mlkit.vision.face.FaceDetection +import com.google.mlkit.vision.face.FaceDetector import com.google.mlkit.vision.face.FaceDetectorOptions data class FacePayload( @@ -22,10 +30,24 @@ data class FacePayload( class FaceAnalyzer( @Volatile var throttleMs: Long, + context: Context, + private val onInstallStatus: (state: String) -> Unit, private val onFaceDetected: (payloads: List) -> Unit ) : ImageAnalysis.Analyzer { - private val detector = FaceDetection.getClient( + @Volatile private var detector: FaceDetector? = null + @Volatile private var closed = false + @Volatile private var moduleClient: ModuleInstallClient? = null + @Volatile private var installListener: InstallStatusListener? = null + + private var lastEmitMs = 0L + private var nextLocalId: Int = -1 + + init { + ensureModuleAndCreateDetector(context.applicationContext) + } + + private fun createDetector() = FaceDetection.getClient( FaceDetectorOptions.Builder() .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE) @@ -35,14 +57,87 @@ class FaceAnalyzer( .build() ) - private var lastEmitMs = 0L - private var nextLocalId: Int = -1 + private fun setDetectorIfAlive(d: FaceDetector) { + if (closed) d.close() else detector = d + } + + private fun ensureModuleAndCreateDetector(context: Context) { + val newDetector = createDetector() + val client = ModuleInstall.getClient(context).also { moduleClient = it } + + client.areModulesAvailable(newDetector) + .addOnSuccessListener { response -> + if (response.areModulesAvailable()) { + setDetectorIfAlive(newDetector) + onInstallStatus("ready") + } else { + onInstallStatus("pending") + requestInstall(client, newDetector) + } + } + .addOnFailureListener { + onInstallStatus("pending") + requestInstall(client, newDetector) + } + } + + private fun requestInstall(client: ModuleInstallClient, newDetector: FaceDetector) { + val listener = InstallStatusListener { update -> + when (update.installState) { + ModuleInstallStatusUpdate.InstallState.STATE_DOWNLOADING -> + onInstallStatus("downloading") + ModuleInstallStatusUpdate.InstallState.STATE_INSTALLING -> + onInstallStatus("installing") + ModuleInstallStatusUpdate.InstallState.STATE_COMPLETED -> { + setDetectorIfAlive(newDetector) + onInstallStatus("ready") + unregisterInstallListener() + } + ModuleInstallStatusUpdate.InstallState.STATE_FAILED, + ModuleInstallStatusUpdate.InstallState.STATE_CANCELED -> { + newDetector.close() + Log.w(TAG, "MLKit face module install ended in state=${update.installState}") + onInstallStatus("failed") + unregisterInstallListener() + } + else -> {} + } + } + installListener = listener + + val request = ModuleInstallRequest.newBuilder() + .addApi(newDetector) + .setListener(listener) + .build() + + client.installModules(request) + .addOnSuccessListener { response -> + if (response.areModulesAlreadyInstalled()) { + setDetectorIfAlive(newDetector) + onInstallStatus("ready") + unregisterInstallListener() + } + } + .addOnFailureListener { e -> + newDetector.close() + Log.w(TAG, "MLKit face module install request failed: ${e.message}") + onInstallStatus("failed") + unregisterInstallListener() + } + } + + private fun unregisterInstallListener() { + val l = installListener ?: return + installListener = null + moduleClient?.unregisterListener(l) + } @SuppressLint("UnsafeExperimentalUsageError") @ExperimentalGetImage override fun analyze(image: ImageProxy) { + val det = detector val mediaImage = image.image - if (mediaImage == null) { + if (det == null || mediaImage == null) { image.close() return } @@ -59,7 +154,7 @@ class FaceAnalyzer( val width = if (rotation == 90 || rotation == 270) image.height else image.width val height = if (rotation == 90 || rotation == 270) image.width else image.height - detector.process(inputImage) + det.process(inputImage) .addOnSuccessListener { faces -> dispatch(faces, width, height) } .addOnCompleteListener { image.close() } } @@ -72,7 +167,10 @@ class FaceAnalyzer( } fun close() { - detector.close() + closed = true + unregisterInstallListener() + detector?.close() + detector = null } private fun build(face: Face, w: Double, h: Double): FacePayload { @@ -91,6 +189,7 @@ class FaceAnalyzer( } companion object { + private const val TAG = "FaceAnalyzer" private const val MIN_FACE_SIZE = 0.15f const val DEFAULT_THROTTLE_MS = 100L } diff --git a/android/src/main/java/com/rncamerakit/events/FaceDetectionInstallStatusEvent.kt b/android/src/main/java/com/rncamerakit/events/FaceDetectionInstallStatusEvent.kt new file mode 100644 index 000000000..25803af3b --- /dev/null +++ b/android/src/main/java/com/rncamerakit/events/FaceDetectionInstallStatusEvent.kt @@ -0,0 +1,21 @@ +package com.rncamerakit.events + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.events.Event + +class FaceDetectionInstallStatusEvent( + surfaceId: Int, + viewId: Int, + private val state: String, +) : Event(surfaceId, viewId) { + override fun getEventName(): String = EVENT_NAME + + override fun getEventData(): WritableMap = Arguments.createMap().apply { + putString("state", state) + } + + companion object { + const val EVENT_NAME = "topFaceDetectionInstallStatus" + } +} diff --git a/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt b/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt index 4d2855792..d97c3f606 100644 --- a/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt +++ b/android/src/newarch/java/com/rncamerakit/CKCameraManager.kt @@ -61,6 +61,7 @@ class CKCameraManager(context: ReactApplicationContext) : SimpleViewManager Faces: {faces.length} {face && ( <> - id: {face.id} Yaw: {face.yaw.toFixed(1)}° Pitch: {face.pitch.toFixed(1)}° Roll: {face.roll.toFixed(1)}° @@ -144,8 +151,9 @@ const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolea const [orientationAnim] = useState(new Animated.Value(3)); const [resize, setResize] = useState<'contain' | 'cover'>('contain'); const [faceDetection, setFaceDetection] = useState(false); - const [faces, setFaces] = useState([]); + const [faces, setFaces] = useState([]); const [cameraLayout, setCameraLayout] = useState({ width: 0, height: 0 }); + const [faceInstallState, setFaceInstallState] = useState(null); useEffect(() => { if (!faceDetection) setFaces([]); @@ -209,9 +217,14 @@ const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolea setFaces((prev) => (prev.length === 0 && next.length === 0 ? prev : next)); }, []); - const onCameraLayout = useCallback((e: { nativeEvent: { layout: { width: number; height: number } } }) => { - const { width, height } = e.nativeEvent.layout; - setCameraLayout((prev) => (prev.width === width && prev.height === height ? prev : { width, height })); + const onCameraLayout = useCallback((e: LayoutChangeEvent) => { + const width = e.nativeEvent.layout.width; + const height = e.nativeEvent.layout.height; + setCameraLayout({ width, height }); + }, []); + + const onFaceDetectionInstallStatus = useCallback((e: OnFaceDetectionInstallStatusData) => { + setFaceInstallState(e.nativeEvent.state); }, []); const onCaptureImagePressed = async () => { @@ -338,9 +351,10 @@ const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolea shutterPhotoSound maxPhotoQualityPrioritization="speed" faceDetectionEnabled={faceDetection} - faceDetectionThrottleMs={100} + faceDetectionThrottleMs={50} onLayout={onCameraLayout} onFaceDetected={onFaceDetected} + onFaceDetectionInstallStatus={onFaceDetectionInstallStatus} onCaptureButtonPressIn={() => { console.log('capture button pressed in'); }} @@ -385,6 +399,20 @@ const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolea )} + + {faceDetection && faceInstallState && faceInstallState !== 'ready' && ( + + + {faceInstallState === 'pending' + ? 'Preparing face detection…' + : faceInstallState === 'downloading' + ? 'Downloading face detection…' + : faceInstallState === 'installing' + ? 'Installing face detection…' + : 'Face detection unavailable'} + + + )} @@ -533,4 +561,18 @@ const styles = StyleSheet.create({ fontSize: 11, fontVariant: ['tabular-nums'], }, + installBanner: { + position: 'absolute', + top: 30, + left: 20, + right: 20, + backgroundColor: 'rgba(0,0,0,0.7)', + padding: 12, + borderRadius: 6, + }, + installText: { + color: 'white', + fontSize: 13, + textAlign: 'center', + }, }); diff --git a/src/CameraProps.ts b/src/CameraProps.ts index 1957677ec..50cf5269a 100644 --- a/src/CameraProps.ts +++ b/src/CameraProps.ts @@ -42,7 +42,15 @@ export type FaceData = { export type OnFaceDetectedData = { nativeEvent: { - faces: ReadonlyArray; + faces: FaceData[]; + }; +}; + +export type FaceDetectionInstallState = 'pending' | 'downloading' | 'installing' | 'ready' | 'failed'; + +export type OnFaceDetectionInstallStatusData = { + nativeEvent: { + state: FaceDetectionInstallState; }; }; @@ -143,4 +151,6 @@ export interface CameraProps extends ViewProps { faceDetectionThrottleMs?: number; /** Fires per frame while face detection is active; bounds are normalized 0–1 in preview space */ onFaceDetected?: (event: OnFaceDetectedData) => void; + /** **Android only**. Fires while the MLKit face detection module is being downloaded by Play Services on first use */ + onFaceDetectionInstallStatus?: (event: OnFaceDetectionInstallStatusData) => void; } diff --git a/src/specs/CameraNativeComponent.ts b/src/specs/CameraNativeComponent.ts index 0cf43b525..ab5a71522 100644 --- a/src/specs/CameraNativeComponent.ts +++ b/src/specs/CameraNativeComponent.ts @@ -75,6 +75,7 @@ export interface NativeProps extends ViewProps { faceDetectionEnabled?: boolean; faceDetectionThrottleMs?: WithDefault; onFaceDetected?: DirectEventHandler; + onFaceDetectionInstallStatus?: DirectEventHandler<{ state: string }>; // not mentioned in props but available on the native side shutterAnimationDuration?: WithDefault; From 752472ed408aca18228e47941715cf706ea4a59e Mon Sep 17 00:00:00 2001 From: Mike Roberts Date: Wed, 29 Apr 2026 18:25:11 -0500 Subject: [PATCH 3/6] Skip MLKit face detection install on non-Google Play devices --- README.md | 6 ++++-- .../main/java/com/rncamerakit/FaceAnalyzer.kt | 9 +++++++++ example/images/faceDetection.png | Bin 14216 -> 1215 bytes src/CameraProps.ts | 8 +++++++- 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ab9198b79..ce44e5236 100644 --- a/README.md +++ b/README.md @@ -175,6 +175,8 @@ Additionally, the Camera can be used for barcode scanning Detect faces in real time. iOS uses Apple Vision; Android uses Google ML Kit. +> **Android requires a Google Play Store device** + ```tsx { // event.nativeEvent.state: FaceDetectionInstallState - // 'pending' | 'downloading' | 'installing' | 'ready' | 'failed' + // 'pending' | 'downloading' | 'installing' | 'ready' | 'failed' | 'unavailable' }} /> ``` @@ -206,7 +208,7 @@ To have Play Services [pre-download the model in the background after the app is ``` -> **Note:** Requires Google Play Services on the device. +> **Note:** Requires Google Play Services on the device. On devices without Google Play Services, `onFaceDetectionInstallStatus` fires once with `'unavailable'`. ### Camera Props (Optional) diff --git a/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt b/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt index 60d65cf59..2611a83fb 100644 --- a/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt +++ b/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt @@ -6,6 +6,8 @@ import android.util.Log import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.moduleinstall.InstallStatusListener import com.google.android.gms.common.moduleinstall.ModuleInstall import com.google.android.gms.common.moduleinstall.ModuleInstallClient @@ -62,6 +64,13 @@ class FaceAnalyzer( } private fun ensureModuleAndCreateDetector(context: Context) { + val status = GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) + if (status != ConnectionResult.SUCCESS) { + Log.w(TAG, "Google Play Services unavailable (status=$status); face detection disabled.") + onInstallStatus("unavailable") + return + } + val newDetector = createDetector() val client = ModuleInstall.getClient(context).also { moduleClient = it } diff --git a/example/images/faceDetection.png b/example/images/faceDetection.png index c7b7466eb0787c9b060ffe5a303552ac6bef5af2..0eefffefd71fbf7d56b9199928c089e4b9f21ada 100644 GIT binary patch literal 1215 zcmV;w1VH7WMf2m&Z4Jl zPMv%H-F3Upy>%}rilQirqA2m10}KP_fV;pbFqzSw1KUCeZoxK8tXbRl7I*<%2TlQ- zeFQfX*blt42(Xon_z2Q5Be-enBu1P;SAnepZaNIEih@i6Z-Ak^LH!K8avWIMFIk;L zSz{Z}Fz~L0(^23wuo3vq)_IAk0e!#@;5^OwyJqXS)mQc$ury!1m18f>dE8{+h%i(TVW=R&m>oL+OL{TI&mqk4z(D=)BftaT67UD65Lz0h78j+A zU-SS+fY)>`b&mmu>;EHx+&9{d@c=2~7k$8OvX4RhjVWEkrc{-W0rHLDJp=lKM?Ia= z2+{-GA^&LIpO~6bEJ`EDQSyt?utR}kpNhmjIWxU<*MWCyW5A-IE%=JGKG}*rxD%sc zyF)f0E7ETaw|nrJdn%4kqzhs_WhRBy9(-1Yaok_B#)GyXBhtSiKKL9vfHb5F@}eL< zogrP2`vvjo4C#Vg^5FBL2cMOp7jrY`szVRU9fwh=1@$Z2pb(>v(AK*XW6K0W|m%wAp_{Tlq zYW;o1l78S9;07kid5eo5F{|<%KvbX;Lw%0mp&&_!A;& z?}1|!tlkxZKHRU#mBsF$72*F`20SF57!7-Z`LR%G76ShgUyO$R!`EMImE?Egi;%S6 zytY^{$t8;%f;fc!aYP8lYC()C+1h0DZGcqgmO49EGH1-xP>ypv4; z^E~CCEitZ>KT(VcYdx5PW>8`XvN6IrZP^g|gfRqJ8{wR`tP6d@7=o;ba86r-*SBH_ zQlznn;LCBO3F7NP_XQ4p`Nn0ex%263DF~kfPp<8Q!*+L-PhGb+99`wJQuUf5pn?cP z1rde{A`BHo7%GS`R1jgPAi_{VgrR~6Lj@5=njr5IuZuA3DwVm3*AwjyOcNxzv%q5O zaFk^3+Ae62CdlK&>-DzLC{4M0)b7AEL6W;s?y?R?*`0W8wbSPong?w5<_fEt?4vo4 zKiWFG-on`h%tYw{a>T!=go5jNZov_v1KNS$hcg zq7-4fhCCa{v=hVG2XPS75vd?{b_Gvs&Tj%vV#Illxxvv()^wYw|KBxCuB%DxVzV(byhT1jOmJA;`Pl5As8b{W~1vhP!h5Ne3B z&1B6!cEd2gm)@Vx=lgkl|M)!~-^YW;%$(PGoqO)R=bn4dInNtyW}?S>ocA~Y0IUf8 ztL6Ye2Y#gkjxmBCn?Zwn;0Lp}{%v0X2;e{ZhZKAYS^$YWe%EgKS$IA03%vW-83+sv zly&iR^L4uG?JVo{_+k2rCNBU81Bk1aZw6&7j|bO_TZXOf2L|f0zv>-(B5VOYFW+%W z{3QKVj+SVP$8R586n`2WtrD#=H{DSp-dXtZs;`NWVxM4?YNy(bA30A>(7k*q&+_Ek zcSQ@8&551(8Ws1n@$t}AN6Rp)gN1{TtW{lFdwET3P4Up4Wtj~N41xK7`6G02<+GUO zUc-mub1i7&+`pK9vHo(FN`V@pJru5$c8B4Y@s{}K_<1}xs^+6SV(I}C=_ViDuSaX6LJqsyPEq0#kDBO(QB=_R9eg_4x}b&uXQ}~5w8>= zR4(h>?vf4D;?4V#zv=y!H0cMKy~{vuL(zb3_S9>6qbchxWs3I;CXYu~o z&#e<1dSQlXv4_C$81BUqre?@ zk~aEbKFeE^QUtfo@g@;_z<*Me;q%+j55%AUuU(mejSg>@zN!AiI&4CidM^$bJ z6|+0d$&E837445*<7aBlR?qqzxU$;iMY?nUo`5VJ%-GiWx20^DJ@1K#O_Z`%s-KsbSD+z!|Vkr z7rU4uVlruF_Rx%1nQhgQ!_a<6c#7{-hA2Gn-@m}{x{{0bUOzv+A8gqIy+bmd0sH2g0ivMXI1HgPB>o=<4j+<@Le3mjbpj=(&DSO!EOBvD3#g!Eal)d46#x7;xT)K zlD8}+-1*$vw~i`8;x+8gSORl~TWK!x-R@sy%sIqMb?oE#wx`Glg<;jaWFtQkWjaYE zwVq*Dx_e3lK?oXU4w7+Zp7ou)L&GMR&I+EcuM{UvW8Rw2d7{V;TiF%B3 z=wh)pzg;|0`g5>hdXB*j-%)8^P=C68;QM8+S4Gb|I-9>{c$Liy{tp~$oDX`vG%%uFXp<5Wm zIEO1?6;Kn;nhudYfI{DUf=DsMa4nd2?Kmk+wJci&{z^GDTi$20;T4-s0mtlyO zI>sQie6OO*GGWWQc2~mhD>73JM>)aw`u4x>cqL)W=1V?sQ*cJ+%G*935eV~vfP&_y zraQh#_+l}s!9i(Mf#PQ1^(`yXtlv2p!fEb0Nr>?L^<(!&K%pg;`r;9*=e#t+wZ9K9 zFumW@=Jr{_?`q%hGF33HKzUE== zC(8!Qe&uUZkq<+Bp-k_$=*8MZi#b3v8_{i-isNxC!U*11>;)`3!KW`_#2A5nC9=x( zEuncjeGKDN$vhpfAGgPpb{2R=J$vt}3Z3@x)XNw}exeAii}F>wHT^8GG=L|b{lbIY zIfsGy-^CDiV3wDd?(8rgc1RT4a^1ivZWF@#J@XFHpztO|Q25#uc}*12zL7*;(_0BW zk}!58A^*>mAtrJTLtZO63%DN%y%usl66O*FzWz1LivxoD5&lzGE3x*FIEt(((7@VLFAdDaYYCrk*I#18^fx~JwX>Vt?Es~4q7*rM7%jk>o z$8~*=MSt2Hpi|s)oOG2$PYUXmqqf$rAxKf+dBeJ3GV4(^?!i_g%@+`!S-v_?&Sue! zXCn5-r-z~{AlXp3?i*X-t>2{mwk~h|uxkFGOJvdR(2Y(1LH|;w)?>|&q`1w6@+=-BJ=3+$di0Li$u~CVc2ll=NwrB+i=o+ z8T>Iu;t=-uw1Rg-Whb_o>~X08Ui8};fkXK#xQe8IFMiF!YnzF+f)7cj7$l7zsGM3ozd|=bzjU!c zL8L}HOzSf%9Ku`r1#gI6>~dt%jY&saLkw$!QgGY2il;}-J@wNhYppLw{Plr27u}aU zH-0z;HpAL&(B1TxdEJ8;bP<02_mw=xO>$BDkt}hrg_Wq-X z#shG})-pcd7*lBIw-B-X2l=BRU}tmQ2b?GSt}lwKKLVU=4T1*}=E-zHYs|G9_dx{Q#zgv<9FEy#$-d|?sn~D(Vw`-1 zmqXk_dHMZ@kdU4QBc$y$xS4T)R`LGD2cP~qbmk}L8yf)t{F+)PTnWodSPT?CUCBC= z7||%hH~v}AyXmfy$asq0BtO79-iI}%#u;;8%~VhSP~L7l|D`zL56bE?UiqknV1{iT)wX7FV zWmXjj*+FHsvw=sCvQAJn0uDpI8<4ampt>#>`GH6JSw_KgZMu!Z#eH#jYji;Y%X$AV z5LD#kt!}3XjR=qKTCCiaEUfH{K?bnFV}iPG$a8esX)#JzaR||n`|8jCSoqXRBEAkC zMqs#6VP_cZIxYTWB*S4Rf(=mY3vdp~-ZKWQfz%tyZ4IwJWUjGduX%Yg4ncRIZUu+g z%jhyxJ~m^SaS5VHr|Nxusk737&*6k7XT7NXNz0PqgKQ`eTQY;6e?{9L<3o7?#9a3v zUkH1^lJO||1A=e~&w+Yq#&~>c=>kdhjy>2D0=QxvDxR0V--4`rRK?b_?;a;d;d5i1 z-R^STn6;*!a%MaTo8B0>-dvH3bfMx#9~@c60+ojn@caIlDdowZd~6GgYmEXKG>I*~ zuXANxLH8 zazvm$5vh@3S_*YAyj^nVuN6>HaKQu%m)nS5l#CH``9)a1=t*Wa8|>_=`$+lx2J1Dq zWfpaVOTi#JoANozw(Hp*Y@G3Ed48eFQ?^xxZ?RLgPq;~rQ*7v{q6ao+e}l{JUkOX} zj5yNkd_b;dq;Xwa!)Hq@4>>Kp6Pk+A_VSI+w%D08a%)8)d74+nTTTZ0-e=)c+ zJ9jquHI3Shu{7tD(VzHkDB);=>tCFm)Z|}cz0o*+)Y8J>YND5KdW;5&!?xtWTR`ui zN3-p1%}9yWyfXLENGoVSRgyrCmy76j+me>@b#@>G-z7a8AUgnfT)E z;_`0XsO{0@n>1%4ZPR6j0KJ-9UcD>cB<1zFh}t0Am@O-Nf``n}BrI;{_uVEfdVg{x zD4;^W);;|_@+ExP(v}lZS94^NWuYpRfLb3TK+e62ke0-4F_(6dHufOjGS*Kc26V@R z#acxh!?eYb((2inimtk-*eQn$mW%ZB0bHrgYQ)jY2iUFa>)gLP))3uN{>AP0NW`yo zF|kt-)wf+N?~)IQ#?z<0b(yv-`nCsEek{u0iBgs>WB7fl{t0sbHBTKdT65dw3%HZH z-J9>0=G*0C@xMm?BIL7JU1%=y_?b{puQ5_ z_TG%hB51p8Lr)EFJjoZ#>K)K{as=m#ZZqP(CsOfw_}Y{^s6tGa7M)Lf^4gZsdc60; zjyj*JRQRnE$RuRw8=jRjs;!Jfnup%)gW|<*V)W{8$8WtZVtv7s>>dB6pO#PA-j!R5 z5Q(1tocC!=%`qz+C7R_1rB0!m7_s4DBr57bxQl{Z{)9Ri)mPi&Wp zy=AySWrVnr#i#6qHW$;*cy#`fRP3TqEYZ9CM63}M7Z5I`1)Sq}K zjnXK2*I+6wu~xdc4~apBrs1d|13^_ZXaMvu>46--%Fx+}__uM6ZhRzkI9wDb?h zgETe_KWE}K3dt5sH46jNUf+}O@4=^;8ga;`G<|DHqBkyiy6 zn!hqL)oPT}@*B8B9h2}JqveH)+@79qZ8BcV0+n35!+z%m)7Wop`|FqFmKm}EH)F-Y zQ}1uoS3R$(MK;pZ{1p;E2OGQgtF4*ik=m^=W{H@GIInIMI@=?% z$2GO#`R(n$wbD`^(RpS+b0Tl=WYe#ha$hNH656fvJLi2qM~VdKC>7oXNFCbkYYl#o z!n|HX^ReIeg*wagkOd`oe`R^@cxB6s3Em=*WZr)3GnshRMhKCyOjr0?+b}v(@wPg+ zmCkHr0_%7mQ4kehu@WYOq$cAzO{;~l4aHqY~y6@Vb zDQ)@xCUU|Lzx{qL%R+1cf2Ag|8eFatxlomO~gu{UcdDi(dtF z=9MdrcX$6uvN;1k)B=^38)y749vA{T4d`HSkjFp{u8X|pK{MFkMBQtKNJ+K`HE~p8 zJ)!e%I?!1_2N4(*zpC=fAR;o*`oF{(L_A4urTi~xn4xF!ZN9s^-J_C7Bopw`2Nb#~ zqV#y`M+oX3KOi9)wqeV-#3lRm@iveW*^|)9sCb7B!20o3!tEkkj)qI5 zz0)xY3NV0^NT8=I-`WA=8MXaq@ewkVnv4IK1Hex0gHxUX{nTk%7xr(a4lW?6e&RV9 zn+^X>$&X;j*MQ96`VVUUOu3Q3wq>*c)r&~v42}taL(H(?{Mw&_A5KhbGg$1TU}$vH z>*O^hZ%~_*R%Tgmj+J{vfh9m`TOnJc>umwQDsrpCBs(A|*M#shX)7lNTQ=29>w>!=sABKiySwv!I^%}f8-D4j{6OLH zc}Lyw2+x!2fR9x{F4=W$=u%`nR6P!=K*JxoDxXmu4Jw7CghoBY`9e6xMD-A)tMHF?qA8@qk#(*)cP=L&zke+g+YjV}YoQns;)C^@ z%h}HFe;-e?WIdwn{dm;qYqn3AQyuTfX96`dCa84&hsUQe){xyB$VtO_#mzI^8*JTF zGpX~Lhky<#X|vadv(f|9qhA>>_(vW$TE7YW6E|Cb?3bJ!W6d1Ut!85I-|QvmEs#m* zREgHSZuJP#IWK`!R2In+9H%Nw=HGBms$*Vrv{;Y-Z=2ItLfg@yV2UslVE%4 zn^z>~O|C2>vPb@OS^n+wuR;17^Gxe(jI^Bfox_Hsqq#>*NPVM!9#l z5x{rN(>=N`p3i~;lM897_7Z%BMvPP4LvBUNGIZr)qmg2$WN;6}boA!m_nJt;%T+rV z*32NLM@{!EQ>qc5AVXjXmTl&B zmGym8%Lqu`kmy!3tV@pv_cWI?aVC~cU{*Ni0&GKX{>r1i!`IYFZNSlxTL-hZ1gy7B zi=mZvwE~5f=u1V34q~V<@Km69IvXeqNQG+abJP8T1UZApueaK7^lI#^5xmc? z;W?+h7LxE<*xR$kJrI1$^k^r{8`0yR%=?EQ*!l<~M}P^k5Bx!HjVD81*U|NWzswD7 zlX34W)2J&bg{cPcptWJvQyh_mRc?#`b9lHvYDrYqaP@M!TCbYgw3uYP()4J%AF^b8 z!GLh_z4c#=~KlA{D%R0z-e_rQZ`%;Zo^TLH`R)(^($N*~p9U=;6o ziALmQ^Qh3pyVrE2^|s>6ru%8%f(+9+Q*gN&FB-+5CUg%WhNGp`g7+jv=jJ!y3Str- z7MQ`ea+tP1Ui`FQuCYo3O*5oh;@RJo0X-tyxUHUnI39-@e?1t@5A6?Gp-zVXxomlYUZ`LvJb5dRt>Ip_&ko; zN`ZOYKjqCvNvFBL=L#|pwG_$_hXf{QS5!S`q3N6B*QPa4#$T3Ik3ITiQB=2)jr*pT z2xCCLL5f!v)pMx6Zz+y%;gD1_%I-A&k~&(_2y-!v!x^<21~R6<3`KL-SnaQEE#lSJ z=e#U@w7qY-)uh6DyF$MP9!rm5Ce>hrSw@<#;c13pFlsQ)n(iM+nY6l7m-1K`kyc=hSv@;@nj0GLA;}aLaF#5xKKD>|ElOra2U86g zqFBBLviU|gp^5F*bY_pq1|-4tIcHfPw)`>#M(VL*WfoHW((tYnxzI}f2fH*nLL@?P zmUnS&J{D%V7?H3lG0PDM$a#oz_-oMDzd|0)aa4GTa$s=RFEF&4wpP9NYG%JRgj6r~ z6ZV#&Wb})-c6#^4!?uQQ3^sXEXD3-+MTl34c&kzD`o&jrjm)vIEquw>Kz^2JQ~8M` zsI~#Tb0gNe7Q>7%m*#?Gix7h^zzz$aBL{Pa1o&0BvmZ7y-Nz`V1Td7JBSwEySz)lM zONhr^!&9bxHL~mJel^72*T;+mo|$Rd_j@Q$+%@CtI;I|nG@0Yr0XnOX#6EPm-m3e? z%RL7vr6N0F2rt-(CO#5p#o)?Yz+sgqo-J7I^AgVPZ|JbK=|;mO??`t)EBBj|&))Ts ze)D(YaN1XGc=t?YeWs#5#=Q0@XRvaAmZPZ}6YZ%Lg%q4SJqL_dW1&Uraehj$KuABM zEQ0&KAm}r5AYs4McjQUjk{s9bFN1DBx8aTav3h0u1ho+jdlaL4x-6c{j#ZPR9&UfK z2s~9QUQM-t8}0`;3j0=Sf(&34>yCmaoypr1bhyi)kIij76T~$Wt<&3aca>s0!0_uC zA8LvxyDNpztP19=4p0QhAoXt@yQyUDbiq8`w@Gyp4^|n>Q5XKiTTVaB?dNLy?ogvq zC3EpIJaxcF$=UE80uw~G-e^;;%Fhd{c6ymnD;N8Kt4r^D^*qT zazDiM&)t62*?8PsBm*j}_1CKV6&cKZ%g<2#AiRFpc6aA)v{LIu?bwQV*#KOW4HK1{ zv`S#O+sp`9gB!)_P5m@*QwpiK5p2s&mDPDlfT9jQb*3KeN@?~Xa=Xl=Pu@If*l>nQ z@yhddj73tQl-~m2DO(gja`ZqDxInkfntHkTB@~$dlLACV^;|Srr9r~4zi~{gj@aEb zw*(W^?12&#kGpMr@bCe#uN8Hxnt%%<6S&Xi6KUcR4}6k z2vEQgqOYI|BnZkL(39jBCawhL60+&kfrJO%fUwr!Y0?XCo{qIO?Ol4Ef6eEzpYZOe z87VCQ4CBRrzuU9`vH^XIBTk!F8~}Kd0Khhm21-g0LglOstLLBEJB+Xi0lZJl*nz2e zEc*)(Ou)^2gavTFmaK@oVz3w2A5KrLmib@Iga`b(l$ih~N}SWVwbEG&^ZW`-{}yseu>1ZG4%9U;k%m!p zl$LD!E(;?fUhy_5Wu)7*Bq7W7qGKeJ^rIfP!isof&;@4U%S7W=-ACukUF9z)@3X8^ zJkJl^$T|+@+G239BGFLu@L+qB!&#yqviPIs&y_FJ8{>XYFR?SS$)oeEF$eOjNB&4Qi<~#iaxu; zih8gCSr0Y^tFX1| z8^Eo$KIQCW#YYcR&9twhZvEE;-1{00asig%CP8I%WU}skG?LiAzYd4G=VZC22VV#Y=&PB8@`r6TzrafGVx})aJNZA;eyQNF{F!?Na)&V2%^DxOhS1&nGkEFVC@Vfd#>t5X4iJmRtSy(_Z(dP7@ioU-u)<7J8uj z6SN(tPVY>f*TvM{Q6p>A-!dI|57}kPsL>KKl+d``7=E>CO@zdWb+gC(%6weqFBY+} z7Dht&!Z}8M{lPa(_fOS-Va297nX7X3pnC*0R7HZlhS9b_1 zN$}1se7fYmV4)D(yQLa$Ez0)5T>WWP_pFB0o9HRjXczJ7-dR40HrTj-88O9-3r{)g zEkp_U*;x+_8bWf&NzSFBL%$L=_qeq4noTDHCwQ!vx9J^pZ^x;P_w8{lq8u|Lx<&n$ zEqB@cT2<=eZ-mcWaL{S}DHkI-x92E12OmG&JIyB%%A#L9*i*TY?M8jQ9^<{k;A}Gm z*6z&<((QX?#85;Bvv;C33&FctzJ@;Rp0D2iHugfz6nsdJ-}-mb(cHNr|Xva z2+sWc+Q(vC$>yJl(6dV|-yeUd=l^o=%GY|Nqf&5eS6EDEl%v%2( z8CotgL@Gv`4?ZT*t*bmuyBG34%j5dj62$>|yZeu7cj2BeoZ1K^h?y*hSHs6*4_b#! z5BB*D%|#xg+Es$PQI)y=cJBx?N(Im9NgCZY8{O6uOi;%)n~^|&5~Cd(6n$!#T)ZQDXhkGb+6l)eRPFIehU;tM70^OVh;%R3Pz=$J?4=u8I9 zw*$O`%OcZqd$LmwRHW&_#RIiJ$ndpTU1VkSbZj)ps&`|;MR6Qo{*h-KL}I9NB!*w5 zIM=v|yh&=T5^8=QK z;pcC^2p6X^&%{LM^7_&0GCWfTHRJz;hpbDja|he5d5~1l%e*})H&Z>(#GLA_&8;D; z6aHRdrO@;0>AEh*G2_phbD?tigv8Rp+pYikZ0bv4hcxA#agPjVm{7^=(AM!##a@cM;=0rp<(ubRItjeF!kBWEhD+l;~z(VLjSTKGG2a7-~7OePFpyF z_&Tn?+IGCq&l9XVp}3ZGwR)bEGqAaUrwiU%-dnndhnElysmr6T9=?PIfv(9LG?dL+ z(_q!(iEOC03HKF3v)yqj7~5K;02TV&SC;>79V@)jQ}{5dlBMXO@OhW;6o5OiE#Am{ z#ieJY%=4;xg~%GSPM8C*lqLHa+m^dDAzl=r40!2t-|Jq%SKwH3XSYJ=Z{}8go%1_g z&l_=I7*em9Is$$c9EdmVFah_o>sA0~h z>-l~)%rpi_X7(rU9?ubMV|Gsh6^tZFpx5$Qg29>756=cUq}zKjJbNa$|i2 zDf>MzZ=R`*DTU0Q#8*9tzfDP~r$;e&j~oM{H$<-yuwc8>yu)-Bd!*Nz+InyXy(hHR zqoEulUz_Vbm)Y8HM3)Wzv`5VOz~GRtqfDxz_Aew?^Np@z6~jUFV*;p!1^R&S{H!_? z5Zq=ez_Maw-g=O#*m`*CwLbR~!VcA>@g%Q(g%JAL&*~LgDwsgPsZ%v5`@Jx4j>_hl z##))o_QYL!;l*o%RV!^tG~C_785?5qhn?t<(qWOD%Brq8uhYAd{>_mIgRNii$6NqSG77%o)VT_M$(R?bdg4!8NU|qwXa#@APSZ<;)!r zvSx7@*a%?Wy56QP0^L~>3va+trh}WpA0}bJ27_78NjfoXEwPguz{Vo|`mRrMpggsX ztEwH1@H?Z{L`AzDVAT*by(*swl!+SfcSy*r^NB{%;D$gYuC&gg{}5A?zW1d0n)SeJ3A5dazdm>g0H2)PW9kODW*c)D^G@7fIEOaRlTpCBDr% zS`oT)3H?7|-ke8cd^7$Fn8aB%1xLty?|fznI5jzZdeHbIHAa2LBM|ILkY*83!}x-% z{ruV-a4h1QQ;}I>k_zF0M~1Uk^zHyMG5Xj(9n)t4uIfsLQ98G-ITuU_d?)c{!A??} zsWDw3-Dr*7uo!Mb@w6kPdX>AV;2g~Y9IIlhp8I$Y$wj)tYiz(spdDzSeR+;aZZQ8< z_wJUrj5T8#oheY%wm`Bh9%~bDh`Cv3lSRj$D2uPR9mEs$+oHVQvUxNz{Aze2zhwM| zB!Wnn3MJ_1{EEMtp@{o=r~EeHHC^*#8acB;jS0HQ7`UVED#s9x~q{iDm;rTee3S|!_s63vL9%!fS6j*F?cxk^T17} zQcqlo&;H;0ep4YN4*|i|)vqQKiHdMp=)}mMwZp6r$8$oCW|U2aNfvj2L}q`$7QmF8 z)x06g#5UQtjyo0LR{PWxugR~&^Sd@VW^wID8!n)f(ev~Eu+h_18tA-&)n5iMLWZYS zZevDf4ivv>>lwzAcu4r4RG;Xq@pV~r=;*#1^C_JZ3LSbV?m{WvN1m2y*n z3^`+SuWDDGBl?dqv*j&Zj)fy z`L=cFFFuo{!e%NHI!f-MGzH2%NnWoe zi|R!yVMQ7-l3}I<%l}~aM7Zx=ZAub~MqnbRj;$vF6}(&QieMrB!x4@U1nVZiSUZ^K z>CE(qJ%{-Fs(l_UDHs?hcf#t7lD&X!GcC@>oVoku>8;H?=;sKf2oAxPCJi;cqKiKgHD>=>7R z{lkb>p03Y8wkZqryVo_cRJ{tBh&Q4W@;b^=Kz3o2Lw9vgXiBZ^`14S05OMm-y3W=f zK{&hbyMFNNTaanfPl4W|X%0mNF;r^Z?YjuVtv$;-P)fM_1I?13yz!6RNORLkiV8!} ze$Ofb*x_}417hMz+k=i8yp@MlBeNGQyD)YNPErBSMlhEPRPKdEsyMn0WHwey2!W@* z=Hb9+*WU6%m`BLGZQiJ;p^db)6ss(<`~%@CuJ8yWLcX)%+c2xy%ADCA6$`x@S;$Yy z+^JAgkFsqHt~H3)n}HJU;=5KoVoXP7?}32!%230tRM<~9_U?j3CHQ(a579*$ z;;|>X9-VGD#@HP9p*Iia(!5O9oYlxxK5DnQE?s*Kj7O6|WDiR-Ly!?)R|4#s03%gk z#vyyfYnJz64JJBnuv#0BoJ4kWL-l|dzW$f6B99of2wEE|D@?4o0E_d|lvcRlx)E$m+{{=6KFT-}~1)8^VC&T=`Br64++p{v!Ptm#(rjN6aFQrM3e1$VM z7xqH^!1@UNF5Ed;CpLRu;%ROPiUmC&`Ly-FE^Bm~FLqjF8Lb%3Z#GxN71ZGKBui*p z6X_tN`ch*|6AFaECn~;tiVCzSFWU6?@Z)x>NgO{;GG9k1HO7JwDtbmgEuItaf)#h4 zU;On~xz&_?h?-q7RYVIl%*#||38Kf$-ctgzl8Zxs;mQ{~Vg_51kxycc?4bN{xL&p6 z^@-Fi*ZY&KeuXgzl!6Sj@14=TA7ncw&8$mrew|lxS0a1dsq?#hX3e-eX??#*{mw8H zkjQ?R!RpSZTD#r~)r8*SmW{fZG=p_(7t(9@llE3556H4N;(DGMH$;En>93%VD{Iy> zpM8Q90kfH6Ui4Ynvd_+oNgVtMo`2#uueiQy`^u=4!Rj1ycR_gHwveaa-bBQ^&m1Th z$NUR^q>=shD(w34zbx}l^f`kRyp0-9zT7=VHt5#L@r_mk6g*gwaUUL{B4=c~3()0f z3(t*0cGW748-*9f=CRJ}P8+!dzVR`pW?3xKOwYP+%b zLF%U_@Os;l@L?el+bV>pRP z#8cy$!ntI7eub!$q3e9AXY)YBSAc$dt(NocrOmBJuegXzSqA-^Gt77+8X~xPeePx@ z2E(<~9(0JS!Lr^RG<8GM_*B!@|2hhUz(7EIp0-o?d$wN@DI1ckKU}+0P3IR0uN|q< zmBSb2ECfQuz~mL<-ET6754Hy+Scf*LVtI$EEIZzX1Ph5#7SjYTSjUo&Sf#s#xEXKZ z3^qy%{Q&3jm1~ z??25)_&()IdjFs6MJALbwXha8t#XD#@jgpl7F&oobd|wVgjgTR_0T+gTz}+)<*vH$ zsXDq^)AhHHaSeXm_4(Qo@7o315fuQXq#t z;0L~WI+A|i!!2kNOuq!cD_*s^n&(I07R|+_Or8bqu8$#P|8Cx2vg;yB4>h_|K6FC# z%vX%1c}NvQw@dSlc3b)Couxc;zU9|HmXc)*Pm{{|f4OD`-bq`CrwQp+c|aP6uEN1v PbO7R-$<^{Jcb@!zgdCCy diff --git a/src/CameraProps.ts b/src/CameraProps.ts index 50cf5269a..aa33dc254 100644 --- a/src/CameraProps.ts +++ b/src/CameraProps.ts @@ -46,7 +46,13 @@ export type OnFaceDetectedData = { }; }; -export type FaceDetectionInstallState = 'pending' | 'downloading' | 'installing' | 'ready' | 'failed'; +export type FaceDetectionInstallState = + | 'pending' + | 'downloading' + | 'installing' + | 'ready' + | 'failed' + | 'unavailable'; export type OnFaceDetectionInstallStatusData = { nativeEvent: { From 0eda355e7cb20d2609896dddb499e21be9d02ba8 Mon Sep 17 00:00:00 2001 From: Mike Roberts Date: Thu, 7 May 2026 16:36:38 -0500 Subject: [PATCH 4/6] Allow simultaneous barcode scanning and face detection on Android --- .../src/main/java/com/rncamerakit/CKCamera.kt | 39 ++++++++++-------- .../main/java/com/rncamerakit/FaceAnalyzer.kt | 28 ++++++++----- .../java/com/rncamerakit/QRCodeAnalyzer.kt | 31 ++++++++------ example/images/faceDetection.png | Bin 1215 -> 1358 bytes 4 files changed, 57 insertions(+), 41 deletions(-) diff --git a/android/src/main/java/com/rncamerakit/CKCamera.kt b/android/src/main/java/com/rncamerakit/CKCamera.kt index a74c95ec5..64d017d40 100644 --- a/android/src/main/java/com/rncamerakit/CKCamera.kt +++ b/android/src/main/java/com/rncamerakit/CKCamera.kt @@ -45,6 +45,8 @@ import android.graphics.Rect import android.graphics.RectF import android.util.Size import com.facebook.react.uimanager.UIManagerHelper +import com.google.android.gms.tasks.Task +import com.google.android.gms.tasks.Tasks import com.google.mlkit.vision.barcode.common.Barcode import com.rncamerakit.events.* @@ -356,8 +358,11 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs val useCases = mutableListOf(preview, imageCapture) - if (scanBarcode) { - val analyzer = QRCodeAnalyzer({ barcodes, imageSize -> + faceAnalyzer?.close() + faceAnalyzer = null + + val barcodeAnalyzer: QRCodeAnalyzer? = if (scanBarcode) { + QRCodeAnalyzer({ barcodes, imageSize -> if (barcodes.isEmpty()) return@QRCodeAnalyzer // 1. Filter by allowed barcode formats @@ -405,27 +410,27 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs onBarcodeRead(filteredBarcodes) } }, scanThrottleDelay) - imageAnalyzer!!.setAnalyzer(cameraExecutor, analyzer) - useCases.add(imageAnalyzer) - } + } else null - faceAnalyzer?.close() - faceAnalyzer = null - if (faceDetectionEnabled) { - val analyzer = FaceAnalyzer( + faceAnalyzer = if (faceDetectionEnabled) { + FaceAnalyzer( faceDetectionThrottleMs, context, { state -> onFaceDetectionInstallStatus(state) }, { payloads -> onFaceDetected(payloads) } ) - faceAnalyzer = analyzer - useCases.add( - ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .setTargetAspectRatio(previewAspectRatio) - .build() - .also { it.setAnalyzer(cameraExecutor, analyzer) } - ) + } else null + + val activeFaceAnalyzer = faceAnalyzer + if (barcodeAnalyzer != null || activeFaceAnalyzer != null) { + imageAnalyzer!!.setAnalyzer(cameraExecutor) { image -> + val tasks = mutableListOf>() + barcodeAnalyzer?.analyzeWithoutClosing(image)?.let { tasks.add(it) } + activeFaceAnalyzer?.analyzeWithoutClosing(image)?.let { tasks.add(it) } + if (tasks.isEmpty()) image.close() + else Tasks.whenAllComplete(tasks).addOnCompleteListener { image.close() } + } + useCases.add(imageAnalyzer) } // Must unbind the use-cases before rebinding them diff --git a/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt b/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt index 2611a83fb..dd68e8b4c 100644 --- a/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt +++ b/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt @@ -13,6 +13,7 @@ import com.google.android.gms.common.moduleinstall.ModuleInstall import com.google.android.gms.common.moduleinstall.ModuleInstallClient import com.google.android.gms.common.moduleinstall.ModuleInstallRequest import com.google.android.gms.common.moduleinstall.ModuleInstallStatusUpdate +import com.google.android.gms.tasks.Task import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.face.Face import com.google.mlkit.vision.face.FaceDetection @@ -143,18 +144,13 @@ class FaceAnalyzer( @SuppressLint("UnsafeExperimentalUsageError") @ExperimentalGetImage - override fun analyze(image: ImageProxy) { - val det = detector - val mediaImage = image.image - if (det == null || mediaImage == null) { - image.close() - return - } + fun analyzeWithoutClosing(image: ImageProxy): Task<*>? { + val det = detector ?: return null + val mediaImage = image.image ?: return null val now = System.currentTimeMillis() if (now - lastEmitMs < throttleMs) { - image.close() - return + return null } lastEmitMs = now @@ -163,9 +159,19 @@ class FaceAnalyzer( val width = if (rotation == 90 || rotation == 270) image.height else image.width val height = if (rotation == 90 || rotation == 270) image.width else image.height - det.process(inputImage) + return det.process(inputImage) .addOnSuccessListener { faces -> dispatch(faces, width, height) } - .addOnCompleteListener { image.close() } + } + + @SuppressLint("UnsafeExperimentalUsageError") + @ExperimentalGetImage + override fun analyze(image: ImageProxy) { + val task = analyzeWithoutClosing(image) + if (task == null) { + image.close() + return + } + task.addOnCompleteListener { image.close() } } private fun dispatch(faces: List, imgWidth: Int, imgHeight: Int) { diff --git a/android/src/main/java/com/rncamerakit/QRCodeAnalyzer.kt b/android/src/main/java/com/rncamerakit/QRCodeAnalyzer.kt index dd3bfc665..9faf67bd7 100644 --- a/android/src/main/java/com/rncamerakit/QRCodeAnalyzer.kt +++ b/android/src/main/java/com/rncamerakit/QRCodeAnalyzer.kt @@ -5,6 +5,7 @@ import android.util.Size import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy +import com.google.android.gms.tasks.Task import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode import com.google.mlkit.vision.common.InputImage @@ -15,15 +16,16 @@ class QRCodeAnalyzer ( ) : ImageAnalysis.Analyzer { // Time in milliseconds of the last time we dispatched detected barcodes private var lastBarcodeDetectedTime: Long = 0L + @SuppressLint("UnsafeExperimentalUsageError") @ExperimentalGetImage - override fun analyze(image: ImageProxy) { - val mediaImage = image.image ?: return + fun analyzeWithoutClosing(image: ImageProxy): Task<*>? { + val mediaImage = image.image ?: return null val inputImage = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees) val scanner = BarcodeScanning.getClient() - scanner.process(inputImage) + return scanner.process(inputImage) .addOnSuccessListener { barcodes -> // Throttle callback invocations based on scanThrottleDelay (ms) val now = System.currentTimeMillis() @@ -31,18 +33,21 @@ class QRCodeAnalyzer ( return@addOnSuccessListener } - val strBarcodes = mutableListOf() - barcodes.forEach { barcode -> - strBarcodes.add(barcode ?: return@forEach) - } - - if (strBarcodes.isNotEmpty()) { + if (barcodes.isNotEmpty()) { lastBarcodeDetectedTime = now - onQRCodesDetected(strBarcodes, Size(image.width, image.height)) + onQRCodesDetected(barcodes, Size(image.width, image.height)) } } - .addOnCompleteListener { - image.close() - } + } + + @SuppressLint("UnsafeExperimentalUsageError") + @ExperimentalGetImage + override fun analyze(image: ImageProxy) { + val task = analyzeWithoutClosing(image) + if (task == null) { + image.close() + return + } + task.addOnCompleteListener { image.close() } } } diff --git a/example/images/faceDetection.png b/example/images/faceDetection.png index 0eefffefd71fbf7d56b9199928c089e4b9f21ada..1d9b6e9ac11673759b55ce76fa6879f55bbe0666 100644 GIT binary patch delta 1322 zcmV+_1=ae$3C;?THGc&INklU27dh7{~upOKn7}1{G;jZ9!?VErKtxRk8Qd zrtuS4X!XJ?l_H{-B4QCgfr8%k%9`L85SwTXQ9;mpqoF3fh|t8oq^&9byqHrOx4Y+M z&+N{ebDkd@$mYy>=9%Yzc6QG*von%m7=~dOh7lQH127Jp0Dpc0mgt-X;6UuQv;sKL z@U>H}0Ox>d;3MGKDx6yfyap_ma4?Vg;7$exm=Dxe@Z#(a<=9T(yMWHY;0Xe72^j0v zsr|tDz>f8Mm(`3*z^<|mjRRM6j4lBm1Eav*f#cBg02_hLz#-s^ocK+ZHJo?Ko&X*U z+=jjeIGPj3eSc*PWjR)XzXJhqG84-W%NY3|75F(Y057%MZLO=*u>oc_bG)snpb1hB zi<#r$qJpmIjchmNoGUDAw5Hv7+8L4tF>o zyPJwrF@LEGQf|sja~`X}W-wA0q>33XQEz`+k-8vN@NaAfnvuF7=X%6uFj5!fmmaYh zjMN30tia|(1vZ0`^h^?bn*Dc4q2>@TOWN+7TMlF|I7y!&&bjlFjt4aPLCRwE1o+wH zGXZ{~pC)2*v9AEO;8W;jISXu#-HyHjKR|W5gnt-6kiY;{UxM$x-o|HbTd@SZ1>7Fn z?@mbiE~^N93jI*yl{d*@Lehj?zzxGN48t%CBT0JVcL=aa(({t`$d8F{lk`C2wO-Pu zM$rqBuFF4UxhQE)e*EKSNvE817wYu8ufXT8-UPk{u9rxeWLd?p%JT-6*J>YN2k?0# zfPYo-5AYfAXzcbT3qDWvK5(rVL8hDqS%oKMMI`jEZ9kawJl{o$yMGN^9@3 zfIB7qB`NheQdcD1@0`0_XQeUDkQbD`7?Lz3pU|w87=nyOsIx3j*Ii)@LH0zbvn-F- zU11DC9*$6FS!&O3#So;I#v*Ee9>};=*!30SR6C^QAki;-S62k;Z3=+BEbEPN zOYHtAD%ebr>~54Vmv+ZEoH_1%gMVV+xtuw#M}xRGKmf1i#Bpy~W79dtPU0s@kC4b_ zDJg;9&0%XJMiV*l`=%S?yYZW{homPKqoixeK^q g7=~dOh7pke0KQ=3Z=bHLlK=n!07*qoM6N<$f*)yrF8}}l delta 1178 zcmV;L1ZDfq3cm@EHGc$jNklON$g&6vux)Fg-pJ9Ykbi)NCZ8BQ8XV50WpS zVZg-ri?yl~-bswjy z&JWI_r)y4~d;Z;ZyUx9JFDQzlD2k#e@tFe*1LuIdz$h@8(SM!;+d>C!!8S~+S=;v( zcmZ4oP63;J1UD1d54^Mpu$7JY2+}blxM}MoMw~%cfvo~=It;Fgf=mK$fT6rW{S3Tv z99Y>eS)D{#V;j&g@UDf^QQ$PN5%|v5d5Nh3eZUUjJk9yLX6v}sSN0sRG+(@xV=v8l z++^b@>Eu^O0e@DJ3rx@D2^%L#K@@v-;^4BSg1BQj%|C21qk_1j#`IjiZFk^|6emd$ z(vDR+(^Woa1_(sSfC?fE6+{>+h%i(TVW=R&m>oL+OL{TI&mqk4z(D=)BftaT67UD6 z5Lz0h78j+AU-SS+fY)>`b&mmu>;EHx+&9{d@c=2~7k_=gZL*I+{EaDH#HLi0j{)+H z;5`HSgGW7`(g@N6+#&yH-Jh76QY=a%$Wii((Xc~-W1ot|J~=bJbk~7*Yh%Eope^`{ zv_9F2J-8F2VY@>%AS=>u47YpmnR_aZPoxWCJ!K|^)gF9ShH>0qvc`k9AS2ShAwKvV zJAgE#3xD#WAU>TTU6A_)@#zfdf?V?8^P&fzm7y1NGv}&99|aZ$Z9xe+j5|RN29CXy z)G&p?ykguUZgH_x^kYiswqiWRjHf6L>BD@=vx@N-G6twhNexp%@1~<4#q=59M2Xar zqR!V{gn2?~2dyVLT&&wzl;{*iQ4~c{6h%p+D1Y}M-vV2KwU{yS?|~(Cn_Yh&;6LCK zW|5qiz+=q#$35U`{e8ufe&8421}4dQi;EvItMVK`>sp!k0XU0Epz8?^4f_C`0ak`- zQZcCk$AS0w6C!EvfnyY`-W7sA+^@-%#qOXL;s03%JS3hN4SRz5u~2Ci0{;?UjE4Qg z*MDDZmE?Egi;%S6ytY^{$t8;%f;fc!aYP8lYC()C+1h0DZGcqgmO z49EGH1-xP>ypv4;^E~CCEitZ>KT(VcYdx5PW>8`XvN6IrZP^g|gfRqJ8{wR`tP6d@ z7=o;ba86r-*SBH_Qlznn;LCBO3F7NP_kRTrefh>^thw{)Ybgky15d8)gTr=rl}}x_ zHymB%vr_e%BA|i@Lj@6r3L*>@L>MZFFjNp>s35{nL4=`#2tx%CMw%e+60eIe>?)PH ziPsbD4onjyxwF7x>u{80?%FPBkS56E#Ow98(I`#1d(`g0G(nQPQSP!1N7g Date: Fri, 8 May 2026 14:01:17 -0500 Subject: [PATCH 5/6] Address Copilot comments, face detection threading and prop reactivity --- ios/ReactNativeCameraKit/CKCameraManager.mm | 2 ++ ios/ReactNativeCameraKit/CameraView.swift | 6 ++++-- ios/ReactNativeCameraKit/RealCamera.swift | 7 +++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/ios/ReactNativeCameraKit/CKCameraManager.mm b/ios/ReactNativeCameraKit/CKCameraManager.mm index 3d6a2ce77..ef8a78249 100644 --- a/ios/ReactNativeCameraKit/CKCameraManager.mm +++ b/ios/ReactNativeCameraKit/CKCameraManager.mm @@ -36,6 +36,8 @@ @interface RCT_EXTERN_MODULE (CKCameraManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(faceDetectionEnabled, BOOL) RCT_EXPORT_VIEW_PROPERTY(faceDetectionThrottleMs, NSInteger) RCT_EXPORT_VIEW_PROPERTY(onFaceDetected, RCTDirectEventBlock) +// Android-only; Never fired. +RCT_EXPORT_VIEW_PROPERTY(onFaceDetectionInstallStatus, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onOrientationChange, RCTDirectEventBlock) RCT_EXPORT_VIEW_PROPERTY(onCaptureButtonPressIn, RCTDirectEventBlock) diff --git a/ios/ReactNativeCameraKit/CameraView.swift b/ios/ReactNativeCameraKit/CameraView.swift index 9ea1d96d2..28720e734 100644 --- a/ios/ReactNativeCameraKit/CameraView.swift +++ b/ios/ReactNativeCameraKit/CameraView.swift @@ -53,6 +53,8 @@ public class CameraView: UIView { @objc public var faceDetectionEnabled = false @objc public var faceDetectionThrottleMs: Int = FaceDetector.defaultThrottleMs @objc public var onFaceDetected: RCTDirectEventBlock? + // Android-only; Never fired. + @objc public var onFaceDetectionInstallStatus: RCTDirectEventBlock? // other @objc public var onOrientationChange: RCTDirectEventBlock? @@ -281,10 +283,10 @@ public class CameraView: UIView { } // Face detection - if changedProps.contains("faceDetectionEnabled") { + if changedProps.contains("faceDetectionEnabled") || changedProps.contains("onFaceDetected") { camera.isFaceDetectionEnabled( faceDetectionEnabled, - onFaceDetected: { [weak self] payloads in + onFaceDetected: onFaceDetected == nil ? nil : { [weak self] payloads in self?.onFaceDetected?(["faces": payloads.map { $0.asDictionary }]) }) } diff --git a/ios/ReactNativeCameraKit/RealCamera.swift b/ios/ReactNativeCameraKit/RealCamera.swift index 618c804d0..a3fc3eca3 100644 --- a/ios/ReactNativeCameraKit/RealCamera.swift +++ b/ios/ReactNativeCameraKit/RealCamera.swift @@ -476,7 +476,7 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega } func update(faceDetectionThrottleMs: Int) { - sessionQueue.async { + visionQueue.async { self.faceDetector.update(throttleMs: faceDetectionThrottleMs) } } @@ -546,7 +546,10 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega private func refreshVisionOrientation() { guard let position = videoDeviceInput?.device.position else { return } - currentVisionOrientation = visionOrientation(for: position) + let newOrientation = visionOrientation(for: position) + visionQueue.async { + self.currentVisionOrientation = newOrientation + } } // MARK: - AVCaptureVideoDataOutputSampleBufferDelegate From f85984d56e664be7ec5e32097c4940a859767f61 Mon Sep 17 00:00:00 2001 From: Mike Roberts Date: Fri, 8 May 2026 15:20:34 -0500 Subject: [PATCH 6/6] Map face detection bounds to preview-space coordinates --- .../src/main/java/com/rncamerakit/CKCamera.kt | 1 + .../main/java/com/rncamerakit/FaceAnalyzer.kt | 43 +++++++++++-------- example/src/CameraExample.tsx | 18 +++++--- ios/ReactNativeCameraKit/RealCamera.swift | 22 +++++++++- 4 files changed, 60 insertions(+), 24 deletions(-) diff --git a/android/src/main/java/com/rncamerakit/CKCamera.kt b/android/src/main/java/com/rncamerakit/CKCamera.kt index 64d017d40..bf8d9e7f4 100644 --- a/android/src/main/java/com/rncamerakit/CKCamera.kt +++ b/android/src/main/java/com/rncamerakit/CKCamera.kt @@ -416,6 +416,7 @@ class CKCamera(context: ThemedReactContext) : FrameLayout(context), LifecycleObs FaceAnalyzer( faceDetectionThrottleMs, context, + viewFinder, { state -> onFaceDetectionInstallStatus(state) }, { payloads -> onFaceDetected(payloads) } ) diff --git a/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt b/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt index dd68e8b4c..058ef88d6 100644 --- a/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt +++ b/android/src/main/java/com/rncamerakit/FaceAnalyzer.kt @@ -6,6 +6,7 @@ import android.util.Log import androidx.camera.core.ExperimentalGetImage import androidx.camera.core.ImageAnalysis import androidx.camera.core.ImageProxy +import androidx.camera.view.PreviewView import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.moduleinstall.InstallStatusListener @@ -19,6 +20,7 @@ import com.google.mlkit.vision.face.Face import com.google.mlkit.vision.face.FaceDetection import com.google.mlkit.vision.face.FaceDetector import com.google.mlkit.vision.face.FaceDetectorOptions +import kotlin.math.max data class FacePayload( val id: Int, @@ -34,6 +36,7 @@ data class FacePayload( class FaceAnalyzer( @Volatile var throttleMs: Long, context: Context, + private val previewView: PreviewView, private val onInstallStatus: (state: String) -> Unit, private val onFaceDetected: (payloads: List) -> Unit ) : ImageAnalysis.Analyzer { @@ -175,9 +178,28 @@ class FaceAnalyzer( } private fun dispatch(faces: List, imgWidth: Int, imgHeight: Int) { - val w = imgWidth.toDouble().coerceAtLeast(1.0) - val h = imgHeight.toDouble().coerceAtLeast(1.0) - val payloads = faces.map { face -> build(face, w, h) } + val viewW = previewView.width.toFloat() + val viewH = previewView.height.toFloat() + if (viewW <= 0f || viewH <= 0f) return + val srcW = imgWidth.toFloat().coerceAtLeast(1f) + val srcH = imgHeight.toFloat().coerceAtLeast(1f) + val scale = max(viewW / srcW, viewH / srcH) + val offsetX = (viewW - srcW * scale) / 2f + val offsetY = (viewH - srcH * scale) / 2f + + val payloads = faces.map { face -> + val box = face.boundingBox + FacePayload( + id = face.trackingId ?: nextLocalId.also { nextLocalId-- }, + yaw = face.headEulerAngleY.toDouble(), + pitch = face.headEulerAngleX.toDouble(), + roll = face.headEulerAngleZ.toDouble(), + boundsX = ((offsetX + box.left * scale) / viewW).toDouble(), + boundsY = ((offsetY + box.top * scale) / viewH).toDouble(), + boundsWidth = (box.width() * scale / viewW).toDouble(), + boundsHeight = (box.height() * scale / viewH).toDouble(), + ) + } onFaceDetected(payloads) } @@ -188,21 +210,6 @@ class FaceAnalyzer( detector = null } - private fun build(face: Face, w: Double, h: Double): FacePayload { - val box = face.boundingBox - val id = face.trackingId ?: nextLocalId.also { nextLocalId-- } - return FacePayload( - id = id, - yaw = face.headEulerAngleY.toDouble(), - pitch = face.headEulerAngleX.toDouble(), - roll = face.headEulerAngleZ.toDouble(), - boundsX = box.left / w, - boundsY = box.top / h, - boundsWidth = box.width() / w, - boundsHeight = box.height() / h, - ) - } - companion object { private const val TAG = "FaceAnalyzer" private const val MIN_FACE_SIZE = 0.15f diff --git a/example/src/CameraExample.tsx b/example/src/CameraExample.tsx index 9d70dbb79..5326fd142 100644 --- a/example/src/CameraExample.tsx +++ b/example/src/CameraExample.tsx @@ -88,12 +88,18 @@ function CaptureButton({ onPress, children }: { onPress: () => void; children?: ); } -function FaceFrame({ face, layout }: { face: FaceData; layout: { width: number; height: number } }) { +function FaceFrame({ + face, + layout, +}: { + face: FaceData; + layout: { x: number; y: number; width: number; height: number }; +}) { if (!layout.width || !layout.height) return null; const facing = isFacingCamera(face); const color = facing ? '#22c55e' : '#facc15'; - const left = face.boundsX * layout.width; - const top = face.boundsY * layout.height; + const left = layout.x + face.boundsX * layout.width; + const top = layout.y + face.boundsY * layout.height; const height = face.boundsHeight * layout.height; return ( <> @@ -152,7 +158,7 @@ const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolea const [resize, setResize] = useState<'contain' | 'cover'>('contain'); const [faceDetection, setFaceDetection] = useState(false); const [faces, setFaces] = useState([]); - const [cameraLayout, setCameraLayout] = useState({ width: 0, height: 0 }); + const [cameraLayout, setCameraLayout] = useState({ x: 0, y: 0, width: 0, height: 0 }); const [faceInstallState, setFaceInstallState] = useState(null); useEffect(() => { @@ -218,9 +224,11 @@ const CameraExample = ({ onBack, stress }: { onBack: () => void; stress?: boolea }, []); const onCameraLayout = useCallback((e: LayoutChangeEvent) => { + const x = e.nativeEvent.layout.x; + const y = e.nativeEvent.layout.y; const width = e.nativeEvent.layout.width; const height = e.nativeEvent.layout.height; - setCameraLayout({ width, height }); + setCameraLayout({ x, y, width, height }); }, []); const onFaceDetectionInstallStatus = useCallback((e: OnFaceDetectionInstallStatusData) => { diff --git a/ios/ReactNativeCameraKit/RealCamera.swift b/ios/ReactNativeCameraKit/RealCamera.swift index a3fc3eca3..cc722c67d 100644 --- a/ios/ReactNativeCameraKit/RealCamera.swift +++ b/ios/ReactNativeCameraKit/RealCamera.swift @@ -569,7 +569,27 @@ class RealCamera: NSObject, CameraProtocol, AVCaptureMetadataOutputObjectsDelega } DispatchQueue.main.async { - self.onFaceDetected?(payloads) + let layer = self.cameraPreview.previewLayer + let layerSize = layer.bounds.size + guard layerSize.width > 0, layerSize.height > 0 else { return } + let frameRect = layer.layerRectConverted( + fromMetadataOutputRect: CGRect(x: 0, y: 0, width: 1, height: 1)) + guard frameRect.width > 0, frameRect.height > 0 else { return } + + let converted = payloads.map { p -> FaceDetectionPayload in + let layerX = frameRect.origin.x + p.boundsX * frameRect.width + let layerY = frameRect.origin.y + p.boundsY * frameRect.height + let layerW = p.boundsWidth * frameRect.width + let layerH = p.boundsHeight * frameRect.height + return FaceDetectionPayload( + id: p.id, yaw: p.yaw, pitch: p.pitch, roll: p.roll, + boundsX: Double(layerX / layerSize.width), + boundsY: Double(layerY / layerSize.height), + boundsWidth: Double(layerW / layerSize.width), + boundsHeight: Double(layerH / layerSize.height) + ) + } + self.onFaceDetected?(converted) } }