All case studies
Open-Source Native Module · Open Source

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.

iOSAndroid
expo-panoramic-stitcher — OpenCV panorama stitching for Expo
Role
R&D · open source
Type
Expo native module
License
MIT
Platforms
iOS · Android
Overview

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.

The problem

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.

Our approach

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.

01

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.

02

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.

03

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.

04

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.

Capabilities

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.

Under the hood

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.

TypeScript API layer

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.

Expo Modules APITypeScriptZod-free typed options
iOS — Swift + Objective-C++

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.

SwiftObjective-C++ shimOpenCV via SPM (yeatse/opencv-spm)iOS 16.4+
Android — Kotlin

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.

Kotlinorg.opencv:opencv:4.13.0 (Maven)Android API 24+
Tech stack

Built with

Module & API
Expo Modules API
Native module + events
TypeScript
Typed, validated surface
OpenCV 4.13
Stitching engine
expo-module-scripts
Build & lint tooling
iOS
Swift
Async module functions
Objective-C++
~140-line cv::Stitcher shim
Swift Package Manager
Prebuilt OpenCV XCFramework
CocoaPods
spm_dependency wiring
Android
Kotlin
No-JNI OpenCV bridge
Maven Central
org.opencv:opencv:4.13.0
Gradle
Bundles native .so libs
Imgcodecs / Imgproc
Decode, resize, encode
Hard parts

Engineering challenges

Challenge

Ship the same behaviour on two platforms with completely different native toolchains, without maintaining two divergent APIs.

Solution

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.

Challenge

OpenCV's C++ headers redefine Objective-C's YES and NO macros, which breaks compilation when imported into an Objective-C++ file.

Solution

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.

Challenge

Stitching is memory-heavy, and a leak compounds fast when frames are fed in continuously for incremental panoramas.

Solution

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.

Screens

A look inside

Outcome

What shipped

Published to npm and GitHub Packages as @notchip/expo-panoramic-stitcher (MIT), for Expo SDK 56+ / React Native 0.85+.
Identical API, options, and results across iOS and Android, with a web stub for universal builds.
OpenCV linked through prebuilt packages (SPM XCFramework, Maven) — no manual vendoring or NDK/CMake setup for consumers.
A deliberately small native surface: one ~140-line Objective-C++ shim on iOS and pure, JNI-free Kotlin on Android.
Three stitching modes — file paths, base64, and incremental — covering post-processing and live-capture use cases.

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
Next case study
Wageasy