Building Expo Panoramic Stitcher
expo-panoramic-stitcheris an Expo native module (SDK 56, RN 0.85) that stitches a set of photos into a 360°/wide panorama, using OpenCV 4.13 on both iOS and Android. Written in Swift + Kotlin against the Expo Modules API. It's MIT, open source, and published to npm and GitHub Packages as@notchip/expo-panoramic-stitcher— out in the open precisely because it needs real-world testing now.
Why I built it
Back in 2024 I shipped a 360° photosphere capture feature for a real-estate app — six phone photos in, a navigable panorama out. The image-processing half meant wiring OpenCV into React Native by hand: a vendored opencv2.framework on iOS, an OpenCV-android-sdk with jniLibs and CMake/JNI on Android, and a simulator arch hack so Apple Silicon would build at all. It worked, but the setup was the kind of thing you do once and dread touching again.
This module is that work done properly and pulled out as something reusable. Same OpenCV stitching, none of the manual SDK plumbing — and the same API surface on both platforms instead of two implementations that quietly drift apart.
What it does
It's on npm and GitHub Packages, so install is a normal dependency add:
npx expo install @notchip/expo-panoramic-stitcherThree entry points, one contract on both platforms:
import {
stitchImagePaths, // file paths in, JPEG file out — lowest memory
stitchBase64, // base64 in, base64 out — same payload both platforms
stitchIncrementalBase64, // build a panorama one frame at a time
isStitchingAvailable,
} from '@notchip/expo-panoramic-stitcher';
const res = await stitchImagePaths(photoPaths, {
warpMode: 'spherical',
outputWidth: 4096,
});
// res.path -> a displayable file:// JPEGwarpMode covers spherical (360°), cylindrical, and plane (flat document/scan stitching). autoResize forces a clean equirectangular 2:1 output, and there are knobs for blend strength, match confidence, output width, and JPEG quality — all with sensible defaults so the zero-config call just works.
Killing the OpenCV setup tax
The whole point was to make OpenCV a one-line dependency on each platform.
Android — pure Kotlin, zero native build. OpenCV comes from Maven Central:
implementation 'org.opencv:opencv:4.13.0'That package ships a Java/Kotlin API with the native libraries bundled, so the module is plain Kotlin against org.opencv.stitching.Stitcher — no JNI, no CMake, no NDK, no OpenCV-android-sdk checked into the repo.
iOS — a prebuilt XCFramework over Swift Package Manager. Instead of vendoring opencv2.framework, the podspec pulls a prebuilt OpenCV XCFramework:
s.spm_dependency(
url: 'https://github.com/yeatse/opencv-spm.git',
requirement: { kind: 'upToNextMajorVersion', minimumVersion: '4.13.0' },
products: ['OpenCV']
)It ships device and arm64-simulator slices, which is the detail that kills the old EXCLUDED_ARCHS simulator hack. pod install resolves and caches it once.
The one shim that has to exist
OpenCV is a C++ library with no Swift API. So iOS keeps exactly one thin Objective-C++ shim — PanoramaStitcherShim.mm, about 140 lines — that wraps cv::Stitcher, does file-IO with cv::imread/cv::imwrite, and returns a plain dictionary. No UIKit, no second bridge layer. Swift talks to it through a normal ObjC header (SWIFT_OBJC_INTEROP_MODE = objcxx), and the Expo module above it is ordinary Swift.
Android needs no shim at all — the Maven package already exposes Kotlin. (Worth saying: switching to Nitro Modules wouldn't remove the iOS shim either. OpenCV is still C++ underneath; something has to cross that boundary.)
Symmetric by design
The trick that keeps the two platforms honest: native code only ever works on image file paths. stitchImagePaths is the core; stitchBase64 and the incremental variant just decode base64 to a temp file, run the same path-based stitch, and re-encode — base64 handling lives in Swift/Kotlin, the C++/OpenCV surface stays tiny. Both platforms return the exact same StitchBase64Result shape, so the JS layer never branches on Platform.OS.
JS / TS (index.ts — defaults, validation, typed API)
│ requireNativeModule('ExpoPanoramicStitcher')
├── iOS: Swift module → PanoramaStitcherShim.mm → cv::Stitcher (OpenCV via SPM)
└── Android: pure Kotlin → org.opencv.stitching.Stitcher (OpenCV via Maven)
Honest caveats
- OpenCV stitching needs roughly 30–40% overlap between adjacent shots, or it returns a non-OK status — which the module surfaces as a thrown error with the status code rather than a silent black image.
spm_dependencyneeds CocoaPods ≥ 1.16, anduse_frameworks! :linkage => :staticapps should verify the XCFramework links cleanly.- Web is a deliberate stub —
isStitchingAvailable()returnsfalsethere.
Where it's at
It's public now — published to npm and GitHub Packages as @notchip/expo-panoramic-stitcher. It builds and stitches on both platforms, and the API is settled — but it's a fresh release that hasn't met enough hardware yet, so I'm putting it out in the open while I test across device classes and overlap edge cases. Expect rough edges until I've run it against more cameras and lighting. The code is on GitHub at notchip/expo-panoramic-stitcher — MIT, and the README has the full install and options reference. If you've fought OpenCV into React Native before, this is the setup I wish I'd had, and bug reports from real shoots are exactly what I'm after right now.