@@ -530,6 +530,140 @@ describe('VisionCamera - Coordinates', () => {
530530 }
531531 } )
532532
533+ // Analyzer coordinates may be reported in the Frame's intended/oriented
534+ // image space, while Frame.convertFramePointToCameraPoint consumes raw
535+ // buffer-space points. The center-only test above cannot catch an
536+ // off-center rectangle drifting after orientation is applied.
537+ // See https://github.com/mrousavy/react-native-vision-camera/pull/3878.
538+ it ( 'maps oriented Frame rectangles into the same Camera bounds' , async ( ) => {
539+ const session = await VisionCamera . createCameraSession ( false )
540+ const frameOutput = VisionCamera . createFrameOutput ( {
541+ targetResolution : CommonResolutions . HD_16_9 ,
542+ pixelFormat : 'yuv' ,
543+ enablePreviewSizedOutputBuffers : false ,
544+ enablePhysicalBufferRotation : false ,
545+ enableCameraMatrixDelivery : false ,
546+ allowDeferredStart : false ,
547+ dropFramesWhileBusy : true ,
548+ } )
549+ await session . configure ( [
550+ {
551+ input : backDevice ,
552+ outputs : [ { output : frameOutput , mirrorMode : 'auto' } ] ,
553+ constraints : [ ] ,
554+ } ,
555+ ] )
556+
557+ type Bounds = {
558+ left : number
559+ top : number
560+ right : number
561+ bottom : number
562+ }
563+ type ProjectionReport = {
564+ orientation : string
565+ expected : Bounds
566+ reported : Bounds
567+ }
568+ let report : ProjectionReport | undefined
569+ const onReport = ( r : ProjectionReport ) => {
570+ report = r
571+ }
572+
573+ let sessionError : Error | undefined
574+ const errorSub = session . addOnErrorListener ( ( error ) => {
575+ sessionError = error
576+ } )
577+
578+ const runtime = workletsProvider . createRuntimeForThread ( frameOutput . thread )
579+ runtime . setOnFrameCallback ( frameOutput , ( frame ) => {
580+ 'worklet'
581+ const w = frame . width
582+ const h = frame . height
583+
584+ const orientedWidth =
585+ frame . orientation === 'left' || frame . orientation === 'right' ? h : w
586+ const orientedHeight =
587+ frame . orientation === 'left' || frame . orientation === 'right' ? w : h
588+ const box = {
589+ left : orientedWidth * 0.34 ,
590+ top : orientedHeight * 0.29 ,
591+ right : orientedWidth * 0.62 ,
592+ bottom : orientedHeight * 0.57 ,
593+ }
594+ const orientedCorners : Point [ ] = [
595+ { x : box . left , y : box . top } ,
596+ { x : box . right , y : box . top } ,
597+ { x : box . right , y : box . bottom } ,
598+ { x : box . left , y : box . bottom } ,
599+ ]
600+
601+ const orientedPointToFramePoint = ( point : Point ) : Point => {
602+ switch ( frame . orientation ) {
603+ case 'right' :
604+ return { x : w - point . y , y : point . x }
605+ case 'left' :
606+ return { x : point . y , y : h - point . x }
607+ case 'down' :
608+ return { x : w - point . x , y : h - point . y }
609+ default :
610+ return point
611+ }
612+ }
613+ const getCameraBounds = ( points : Point [ ] ) : Bounds => {
614+ const cameraPoints = points . map ( ( point ) =>
615+ frame . convertFramePointToCameraPoint ( point ) ,
616+ )
617+ const xs = cameraPoints . map ( ( point ) => point . x )
618+ const ys = cameraPoints . map ( ( point ) => point . y )
619+ return {
620+ left : Math . min ( ...xs ) ,
621+ top : Math . min ( ...ys ) ,
622+ right : Math . max ( ...xs ) ,
623+ bottom : Math . max ( ...ys ) ,
624+ }
625+ }
626+
627+ scheduleOnRN ( onReport , {
628+ orientation : frame . orientation ,
629+ expected : getCameraBounds (
630+ orientedCorners . map ( orientedPointToFramePoint ) ,
631+ ) ,
632+ reported : getCameraBounds ( orientedCorners ) ,
633+ } )
634+ frame . dispose ( )
635+ } )
636+
637+ await session . start ( )
638+ try {
639+ await waitUntil ( ( ) => report != null || sessionError != null , {
640+ timeout : 15_000 ,
641+ } )
642+ expect ( sessionError ) . toBe ( undefined )
643+ const r = report
644+ if ( r == null ) throw new Error ( 'no rectangle projection report' )
645+
646+ if ( r . orientation === 'up' ) {
647+ console . log (
648+ '[SKIP] oriented rectangle projection: frame orientation is up' ,
649+ )
650+ return
651+ }
652+
653+ for ( const edge of [ 'left' , 'top' , 'right' , 'bottom' ] as const ) {
654+ expect ( r . reported [ edge ] ) . toBeCloseTo ( r . expected [ edge ] , 0 )
655+ }
656+
657+ console . log (
658+ `oriented rectangle projection orientation=${ r . orientation } expected=${ JSON . stringify ( r . expected ) } reported=${ JSON . stringify ( r . reported ) } ` ,
659+ )
660+ } finally {
661+ runtime . setOnFrameCallback ( frameOutput , undefined )
662+ errorSub . remove ( )
663+ await session . stop ( )
664+ }
665+ } )
666+
533667 // TODO: Re-enable once we have a way to produce a ScannedObject without a
534668 // real on-device scan (e.g. a `createMockScannedObject` factory or
535669 // a CI-friendly QR fixture). On iOS, the only way to obtain a
0 commit comments