Shipping OpenCV in an Expo Module Without the Setup Tax
The first time I put OpenCV into a React Native app — a 360° photosphere feature for a real-estate product back in 2024 — the actual computer vision was the easy part. The hard part was getting OpenCV to exist in the build at all.
On iOS that meant downloading opencv2.framework, vendoring it into the repo, and then fighting Xcode because the framework didn't ship an arm64 simulator slice, so every build on an Apple Silicon Mac needed an EXCLUDED_ARCHS hack to even compile. On Android it meant downloading the OpenCV-android-sdk, wiring jniLibs.srcDirs, and writing CMake + JNI glue to reach the C++ from Java. Two completely different setups, both of them the kind of thing you get working once and then never want to touch again.
I rebuilt that work as an open-source Expo module — expo-panoramic-stitcher — with one goal for the plumbing: nobody installing it should ever download OpenCV by hand. I got there. The first version of this post claimed it was "one dependency line per platform." That version was wrong on both platforms, and the reasons are more useful than the original claim.
Android: the Maven package can't stitch
The obvious answer looked great. OpenCV publishes to Maven Central with a Java/Kotlin API and the native libraries bundled inside the package:
implementation 'org.opencv:opencv:4.13.0'One line, plain Kotlin against org.opencv.stitching.Stitcher, no NDK. Except org.opencv.stitching doesn't exist. OpenCV wraps the stitching module for Python only, and the AAR's libopencv_java4.so doesn't even compile the stitching module in. It links, it runs, and there is nothing to call.
So the module now does what the old manual setup did, but automated. A Gradle task downloads the official opencv-4.13.0-android-sdk.zip once — about 300 MB, SHA-256 verified — into the Gradle user-home cache. CMake statically links libopencv_stitching.a and its dependency closure into one JNI shim:
cv::Ptr<cv::Stitcher> stitcher = cv::Stitcher::create(mode);
stitcher->setFeaturesMatcher(
cv::makePtr<cv::detail::BestOf2NearestMatcher>(false, matchConf));
stitcher->setPanoConfidenceThresh((double)panoConfidence);
cv::Mat pano;
cv::Stitcher::Status status = stitcher->stitch(images, pano);
if (status != cv::Stitcher::OK) {
return "err|" + messageForStatus(status);
}
std::vector<int> component = stitcher->component(); // which inputs survivedKotlin calls System.loadLibrary("panostitcher") and that's it. Nothing is checked into the repo, there's no jniLibs folder, and the cached SDK is shared by every project on the machine. It isn't one line. It is zero manual steps, which is the thing that actually matters.
iOS: SPM compiles, then doesn't link
iOS is where the old setup hurt most, and where I was most sure I'd fixed it. The podspec declared an SPM dependency on 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 built. Then I dropped it into an app that used CocoaPods static frameworks — which is the Expo default via expo-build-properties, and mandatory for anything using Firebase — and every cv:: symbol came up undefined at the final app link. An SPM product attaches to the pod target and never reaches the app's link line when the pod is a static framework. A vendored framework, on the other hand, gets propagated by CocoaPods straight into the app's xcconfig, which is exactly what static linkage needs.
So the module vendors it, just not by hand. An npm postinstall script fetches the same prebuilt opencv2.xcframework (about 190 MB, SHA-256 verified) into the package's ios/ directory, and the podspec has one line:
s.vendored_frameworks = 'opencv2.xcframework'The XCFramework ships both device and arm64-simulator slices. That one detail retires the entire EXCLUDED_ARCHS simulator dance — the simulator just builds, because the architecture it needs is actually in the binary. The framework never lands in git or the npm tarball, and offline builds can point an environment variable at a pre-downloaded zip.
You still need a C++ shim — one per platform, as it turns out
Here's the part people hope a new module system will erase, and it won't: OpenCV is a C++ library with no Swift API, and no Kotlin API for stitching either. Something has to cross into C++ on both sides. So each platform keeps exactly one thin file, about 180 lines — PanoramaStitcherShim.mm on iOS, panorama_stitcher_jni.cpp on Android — that wraps cv::Stitcher, reads and writes images with cv::imread/cv::imwrite, and hands back a plain result:
cv::Stitcher::Mode mode = [self modeForWarp:warpMode];
cv::Ptr<cv::Stitcher> stitcher = cv::Stitcher::create(mode);
cv::Mat pano;
cv::Stitcher::Status status = stitcher->stitch(images, pano);Swift reaches it through a normal Objective-C header, no bridging header and no interop flag. I originally set SWIFT_OBJC_INTEROP_MODE = objcxx in the pod config; it broke import ExpoModulesCore against Expo SDK 57's precompiled binaries, and removing it changed nothing else. The .mm extension already makes the file compile as ObjC++. The two shims share the same stitch core and get edited together, which is a discipline, not a build rule.
I want to be clear about this because it's a common misconception: switching to Nitro Modules wouldn't delete these shims. Nitro changes how JS talks to native; it doesn't change that OpenCV is C++. The shim isn't a bridge tax you can remove with a better module system — it's the actual Swift↔C++ and Kotlin↔C++ seam.
The design lesson: make the platforms symmetric
The setup was half the battle. The other half was not ending up with two implementations that drift apart — which is exactly what happened in my 2024 version, where iOS returned base64 and Android returned raw RGBA bytes, and the JS had to branch on Platform.OS.
The fix this time was a rule: native code only ever operates on image file paths. The core function takes paths in and writes a JPEG out. Everything else is built on top of it in Swift/Kotlin — the base64 variant just decodes inputs to temp files, runs the same path-based stitch, and re-encodes the result:
export async function stitchBase64(images: string[], options?: StitchOptions) {
if (!images || images.length < 2) {
throw new Error("At least 2 images are required for stitching");
}
return ExpoPanoramicStitcher.stitchBase64(images, { ...DEFAULTS, ...options });
}Because both platforms share that contract, they return the identical StitchBase64Result shape and the JS layer never has to know which OS it's on. The C++/OpenCV surface stays tiny, the two platforms stay honest, and the incremental "add one frame at a time" API falls out of the same core for free. So does the sweep-aware stitchSweep that arrived later: wrap closure for full 360°s, salvaging partial arcs, reporting gaps — all plain TypeScript over the same path-based core, with zero new native code.
What it actually costs you now
For comparison, the old way vs this module:
- Android OpenCV — was: download the SDK, wire
jniLibs, write CMake + JNI. Now: Gradle fetches the official SDK once into a shared cache; one JNI shim, statically linked. - iOS OpenCV — was: vendor
opencv2.frameworkby hand, fight simulator arches. Now: one vendored XCFramework fetched atnpm install, no arch hack, links under static frameworks. - iOS bridge — was: Swift → ObjC++ → C++, two layers. Now: Swift → one ~180-line shim.
- Result payload — was: asymmetric per platform. Now: identical on both.
None of this makes the computer vision easier — stitching still needs ~30–40% overlap between shots or OpenCV refuses, and that's a real constraint you design the capture UX around. But the plumbing that used to eat a day and rot in the repo now runs itself, verified by checksum, and never touches your git history. That's the difference the Expo Modules API and OpenCV's own prebuilt Android SDK and iOS XCFramework releases have made between 2024 and now. The lesson I'd pass on: "it compiles" is not the bar. Link it into an app with static frameworks and try to call the function before you write the blog post.
The module is open source under MIT — published to npm as @notchip/expo-panoramic-stitcher, installable with npx expo install @notchip/expo-panoramic-stitcher. Six releases in, it's the stitching engine inside FieldDojo's Walkthrough feature, and bug reports from real shoots are still exactly what I'm after.