Panoramic Stitcher
An open-source Expo native module that stitches overlapping phone photos into panoramas — OpenCV under the hood, one symmetric API across iOS and Android.

expo-panoramic-stitcher is an Expo native module we built and open-sourced. It takes a set of overlapping photos and stitches them into a single panorama — a 360° equirectangular image, a wide horizontal sweep, or a flat scan — using OpenCV's stitching pipeline behind a thin native bridge.
We released it because panorama stitching is a recurring need in camera and mapping apps, and getting OpenCV into a React Native project is normally a multi-day yak-shave. The module hides that work behind a handful of typed functions that behave identically on iOS and Android, so a panorama feature becomes an afternoon, not a sprint.
What we set out to solve
OpenCV is the obvious engine for image stitching, but getting it into a React Native app is painful. Teams end up vendoring multi-hundred-megabyte SDKs by hand, wiring CMake/NDK builds on Android and an XCFramework on iOS, and maintaining JNI glue that breaks on every toolchain bump.
Even once it compiles, the two platforms drift. iOS and Android grow separate native code paths with different option names, different return shapes, and different bugs — so the JavaScript layer has to special-case each one.
There was no drop-in, Expo-friendly way to do this. The few existing options assumed bare workflows, shipped divergent APIs, or had gone unmaintained against modern Expo SDKs.
How we built it
We treated the module as a thin, symmetric bridge over a proven engine. The goal was to expose OpenCV's stitcher with as little native surface as possible, keep both platforms behaving identically, and make installation a normal dependency add rather than a build project.
Lean on prebuilt OpenCV
Instead of vendoring OpenCV, iOS pulls a prebuilt XCFramework through Swift Package Manager (yeatse/opencv-spm) and Android pulls org.opencv:opencv:4.13.0 from Maven Central. No manual SDK download, no NDK/CMake project, no checked-in binaries — just dependencies the package managers already know how to resolve.
Keep the native surface thin
All the C++ that touches OpenCV lives in one ~140-line Objective-C++ shim on iOS; Android is pure Kotlin calling OpenCV's Java API with no JNI at all. Everything above that — option parsing, temp files, base64 — is ordinary Swift and Kotlin, so the part that can actually go wrong is small and auditable.
One symmetric API
Both platforms expose the same functions, the same option names, and the same result shapes. The TypeScript layer applies a single set of defaults and validation, so app code never branches on Platform.OS to stitch an image.
File paths at the core, base64 at the edge
The native core only ever reads and writes image files, which keeps memory predictable. Base64 encode/decode happens at the Swift/Kotlin boundary for the convenience APIs, so the heavy C++ path stays identical regardless of how the caller hands images in.
What it does
Three ways to stitch
stitchImagePaths (lowest memory, JPEG on disk), stitchBase64 (same payload on both platforms), and stitchIncrementalBase64 for building a panorama frame by frame from a live camera.
Spherical, cylindrical & plane
Pick a warp mode to match the shot — spherical for 360° panoramas, cylindrical for wide horizontal sweeps, and plane (OpenCV SCANS) for flat, near-planar scans.
Tunable blend & matching
Sensible defaults out of the box, with knobs for blend strength, feature-match confidence, output width, and JPEG quality when a shot needs a firmer hand.
Symmetric, predictable API
Identical functions, options, and result shapes on iOS and Android — plus a web stub and isStitchingAvailable() so callers can degrade gracefully where OpenCV isn't present.
Progress events
Long stitches emit onStitchProgress with a 0–1 value and a stage name, so the app can show real feedback instead of an indefinite spinner.
Fully on-device
All stitching runs natively on the device with no server round-trip — usable offline, with nothing leaving the phone.
Architecture
The module is three layers: a typed TypeScript API, and two thin native implementations that both delegate to OpenCV 4.13. Base64 handling sits at the language boundary so the C++ core stays minimal and identical across platforms.
index.ts applies defaults and validation over the native module; ExpoPanoramicStitcherModule.ts declares the typed surface and events; a .web.ts stub reports unavailable so universal code compiles everywhere.
A Swift module handles option records, temp files, and base64, then calls a single Objective-C++ shim that wraps cv::Stitcher. OpenCV is linked as a prebuilt XCFramework via Swift Package Manager.
Pure Kotlin calling OpenCV's Java Stitcher API — no JNI, CMake, or NDK. Every Mat is released explicitly and temp files are cleaned in finally blocks to keep memory flat across repeated stitches.
Built with
Engineering challenges
Ship the same behaviour on two platforms with completely different native toolchains, without maintaining two divergent APIs.
We defined one option set and one result shape, applied defaults once in TypeScript, and mapped them onto cv::Stitcher identically in the Objective-C++ shim and the Kotlin module. The warp mode, resize logic, and JPEG encoding are line-for-line equivalent, so a panorama looks the same and the JS layer never branches on platform.
OpenCV's C++ headers redefine Objective-C's YES and NO macros, which breaks compilation when imported into an Objective-C++ file.
The shim undefines and restores those macros around the OpenCV import, isolating the conflict to a few lines instead of leaking it into the rest of the iOS module.
Stitching is memory-heavy, and a leak compounds fast when frames are fed in continuously for incremental panoramas.
The native core operates only on image files, every OpenCV Mat is released explicitly, and temp images are cleaned in defer/finally blocks. Base64 is decoded to a temp JPEG, stitched, then encoded back — so the expensive path is identical to the file-path API and never accumulates buffers.
A look inside
What shipped
Want us to build something like this?
Tell us about your project. NOTchip scopes, designs, and ships on a milestone basis — you only pay for results.
Start a project