react-native-image-stitcher 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/LICENSE +201 -0
- package/NOTICE +21 -0
- package/README.md +189 -0
- package/RNImageStitcher.podspec +76 -0
- package/android/build.gradle +224 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/cpp/CMakeLists.txt +124 -0
- package/android/src/main/cpp/image_stitcher_jni.cpp +145 -0
- package/android/src/main/cpp/keyframe_gate_jni.cpp +204 -0
- package/android/src/main/java/io/imagestitcher/rn/BatchStitcher.kt +426 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalFirstwinsEngine.kt +960 -0
- package/android/src/main/java/io/imagestitcher/rn/IncrementalStitcher.kt +2371 -0
- package/android/src/main/java/io/imagestitcher/rn/KeyframeGate.kt +256 -0
- package/android/src/main/java/io/imagestitcher/rn/QualityChecker.kt +167 -0
- package/android/src/main/java/io/imagestitcher/rn/RNImageStitcherPackage.kt +39 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraView.kt +558 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARCameraViewManager.kt +35 -0
- package/android/src/main/java/io/imagestitcher/rn/RNSARSession.kt +784 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/BackgroundRenderer.kt +176 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/ShaderUtil.kt +67 -0
- package/android/src/main/java/io/imagestitcher/rn/ar/YuvImageConverter.kt +201 -0
- package/cpp/ar_frame_pose.h +63 -0
- package/cpp/keyframe_gate.cpp +927 -0
- package/cpp/keyframe_gate.hpp +240 -0
- package/cpp/stitcher.cpp +2207 -0
- package/cpp/stitcher.hpp +275 -0
- package/dist/ar/useARSession.d.ts +102 -0
- package/dist/ar/useARSession.js +133 -0
- package/dist/camera/ARCameraView.d.ts +93 -0
- package/dist/camera/ARCameraView.js +170 -0
- package/dist/camera/Camera.d.ts +134 -0
- package/dist/camera/Camera.js +688 -0
- package/dist/camera/CameraShutter.d.ts +80 -0
- package/dist/camera/CameraShutter.js +237 -0
- package/dist/camera/CameraView.d.ts +65 -0
- package/dist/camera/CameraView.js +117 -0
- package/dist/camera/CaptureControlsBar.d.ts +87 -0
- package/dist/camera/CaptureControlsBar.js +82 -0
- package/dist/camera/CaptureHeader.d.ts +62 -0
- package/dist/camera/CaptureHeader.js +81 -0
- package/dist/camera/CapturePreview.d.ts +70 -0
- package/dist/camera/CapturePreview.js +188 -0
- package/dist/camera/CaptureStatusOverlay.d.ts +75 -0
- package/dist/camera/CaptureStatusOverlay.js +326 -0
- package/dist/camera/CaptureThumbnailStrip.d.ts +87 -0
- package/dist/camera/CaptureThumbnailStrip.js +177 -0
- package/dist/camera/IncrementalPanGuide.d.ts +83 -0
- package/dist/camera/IncrementalPanGuide.js +267 -0
- package/dist/camera/PanoramaBandOverlay.d.ts +107 -0
- package/dist/camera/PanoramaBandOverlay.js +399 -0
- package/dist/camera/PanoramaConfirmModal.d.ts +57 -0
- package/dist/camera/PanoramaConfirmModal.js +128 -0
- package/dist/camera/PanoramaGuidance.d.ts +79 -0
- package/dist/camera/PanoramaGuidance.js +246 -0
- package/dist/camera/PanoramaSettingsModal.d.ts +311 -0
- package/dist/camera/PanoramaSettingsModal.js +611 -0
- package/dist/camera/ViewportCropOverlay.d.ts +46 -0
- package/dist/camera/ViewportCropOverlay.js +67 -0
- package/dist/camera/useCapture.d.ts +111 -0
- package/dist/camera/useCapture.js +160 -0
- package/dist/camera/useDeviceOrientation.d.ts +48 -0
- package/dist/camera/useDeviceOrientation.js +131 -0
- package/dist/camera/useVideoCapture.d.ts +79 -0
- package/dist/camera/useVideoCapture.js +151 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +39 -0
- package/dist/quality/normaliseOrientation.d.ts +36 -0
- package/dist/quality/normaliseOrientation.js +62 -0
- package/dist/quality/runQualityCheck.d.ts +41 -0
- package/dist/quality/runQualityCheck.js +98 -0
- package/dist/sensors/useIMUTranslationGate.d.ts +70 -0
- package/dist/sensors/useIMUTranslationGate.js +235 -0
- package/dist/stitching/IncrementalStitcherView.d.ts +41 -0
- package/dist/stitching/IncrementalStitcherView.js +157 -0
- package/dist/stitching/incremental.d.ts +930 -0
- package/dist/stitching/incremental.js +133 -0
- package/dist/stitching/stitchFrames.d.ts +55 -0
- package/dist/stitching/stitchFrames.js +56 -0
- package/dist/stitching/stitchVideo.d.ts +119 -0
- package/dist/stitching/stitchVideo.js +57 -0
- package/dist/stitching/useIncrementalJSDriver.d.ts +74 -0
- package/dist/stitching/useIncrementalJSDriver.js +199 -0
- package/dist/stitching/useIncrementalStitcher.d.ts +58 -0
- package/dist/stitching/useIncrementalStitcher.js +172 -0
- package/dist/types.d.ts +58 -0
- package/dist/types.js +15 -0
- package/ios/Package.swift +72 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.m +33 -0
- package/ios/Sources/RNImageStitcher/ARCameraViewManager.swift +40 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.m +55 -0
- package/ios/Sources/RNImageStitcher/ARSessionBridge.swift +149 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcher.swift +2727 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.m +85 -0
- package/ios/Sources/RNImageStitcher/IncrementalStitcherBridge.swift +625 -0
- package/ios/Sources/RNImageStitcher/KeyframeGate.swift +328 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.h +141 -0
- package/ios/Sources/RNImageStitcher/KeyframeGateBridge.mm +278 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.h +473 -0
- package/ios/Sources/RNImageStitcher/OpenCVIncrementalStitcher.mm +1326 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.h +97 -0
- package/ios/Sources/RNImageStitcher/OpenCVKeyframeCollector.mm +296 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.h +103 -0
- package/ios/Sources/RNImageStitcher/OpenCVSlitScanStitcher.mm +3285 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.h +238 -0
- package/ios/Sources/RNImageStitcher/OpenCVStitcher.mm +1880 -0
- package/ios/Sources/RNImageStitcher/QualityChecker.swift +252 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.m +26 -0
- package/ios/Sources/RNImageStitcher/QualityCheckerBridge.swift +72 -0
- package/ios/Sources/RNImageStitcher/RNSARCameraView.swift +114 -0
- package/ios/Sources/RNImageStitcher/RNSARSession.swift +1111 -0
- package/ios/Sources/RNImageStitcher/Stitcher.swift +243 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.m +28 -0
- package/ios/Sources/RNImageStitcher/StitcherBridge.swift +246 -0
- package/package.json +73 -0
- package/react-native.config.js +34 -0
- package/scripts/opencv-version.txt +1 -0
- package/scripts/postinstall-fetch-binaries.js +286 -0
- package/src/ar/useARSession.ts +210 -0
- package/src/camera/.gitkeep +0 -0
- package/src/camera/ARCameraView.tsx +256 -0
- package/src/camera/Camera.tsx +1053 -0
- package/src/camera/CameraShutter.tsx +292 -0
- package/src/camera/CameraView.tsx +157 -0
- package/src/camera/CaptureControlsBar.tsx +204 -0
- package/src/camera/CaptureHeader.tsx +184 -0
- package/src/camera/CapturePreview.tsx +318 -0
- package/src/camera/CaptureStatusOverlay.tsx +391 -0
- package/src/camera/CaptureThumbnailStrip.tsx +277 -0
- package/src/camera/IncrementalPanGuide.tsx +328 -0
- package/src/camera/PanoramaBandOverlay.tsx +498 -0
- package/src/camera/PanoramaConfirmModal.tsx +206 -0
- package/src/camera/PanoramaGuidance.tsx +327 -0
- package/src/camera/PanoramaSettingsModal.tsx +1357 -0
- package/src/camera/ViewportCropOverlay.tsx +81 -0
- package/src/camera/useCapture.ts +279 -0
- package/src/camera/useDeviceOrientation.ts +140 -0
- package/src/camera/useVideoCapture.ts +236 -0
- package/src/index.ts +53 -0
- package/src/quality/.gitkeep +0 -0
- package/src/quality/normaliseOrientation.ts +79 -0
- package/src/quality/runQualityCheck.ts +131 -0
- package/src/sensors/useIMUTranslationGate.ts +347 -0
- package/src/stitching/.gitkeep +0 -0
- package/src/stitching/IncrementalStitcherView.tsx +198 -0
- package/src/stitching/incremental.ts +1021 -0
- package/src/stitching/stitchFrames.ts +88 -0
- package/src/stitching/stitchVideo.ts +153 -0
- package/src/stitching/useIncrementalJSDriver.ts +273 -0
- package/src/stitching/useIncrementalStitcher.ts +252 -0
- package/src/types.ts +78 -0
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
package io.imagestitcher.rn.ar
|
|
3
|
+
|
|
4
|
+
import android.opengl.GLES11Ext
|
|
5
|
+
import android.opengl.GLES20
|
|
6
|
+
import com.google.ar.core.Coordinates2d
|
|
7
|
+
import com.google.ar.core.Frame
|
|
8
|
+
import com.google.ar.core.Session
|
|
9
|
+
import java.nio.ByteBuffer
|
|
10
|
+
import java.nio.ByteOrder
|
|
11
|
+
import java.nio.FloatBuffer
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Renders the ARCore camera feed as a fullscreen background quad.
|
|
15
|
+
*
|
|
16
|
+
* ARCore delivers camera frames via a GL_TEXTURE_EXTERNAL_OES texture
|
|
17
|
+
* (the OpenGL ES extension that lets a SurfaceTexture be sampled
|
|
18
|
+
* directly). We bind that texture to the session via
|
|
19
|
+
* `Session.setCameraTextureName()` and render it through the
|
|
20
|
+
* fullscreen quad below; ARCore handles the camera pixel updates
|
|
21
|
+
* inside `Session.update()`.
|
|
22
|
+
*
|
|
23
|
+
* Why hand-rolled vs adopting the Google hello_ar sample:
|
|
24
|
+
* The sample is ~250 lines per renderer plus shader files in
|
|
25
|
+
* `assets/shaders/`. We only need a fullscreen quad — vertex
|
|
26
|
+
* coords (-1..1) plus texture coords supplied by ARCore via
|
|
27
|
+
* `Frame.transformCoordinates2d()`. That fits in ~150 lines
|
|
28
|
+
* inline, no asset dependency, easier to debug.
|
|
29
|
+
*/
|
|
30
|
+
internal class BackgroundRenderer {
|
|
31
|
+
|
|
32
|
+
/// OES texture id ARCore writes camera pixels into.
|
|
33
|
+
var textureId: Int = -1
|
|
34
|
+
private set
|
|
35
|
+
|
|
36
|
+
/// Compiled shader program.
|
|
37
|
+
private var program: Int = 0
|
|
38
|
+
/// Attribute / uniform locations cached at link time.
|
|
39
|
+
private var positionAttrib: Int = 0
|
|
40
|
+
private var texCoordAttrib: Int = 0
|
|
41
|
+
private var textureUniform: Int = 0
|
|
42
|
+
|
|
43
|
+
/// Static fullscreen quad coords.
|
|
44
|
+
private val quadCoords: FloatBuffer = ByteBuffer
|
|
45
|
+
.allocateDirect(QUAD_COORDS.size * 4)
|
|
46
|
+
.order(ByteOrder.nativeOrder())
|
|
47
|
+
.asFloatBuffer()
|
|
48
|
+
.apply {
|
|
49
|
+
put(QUAD_COORDS)
|
|
50
|
+
position(0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// Texture coords ARCore overwrites each frame via
|
|
54
|
+
/// `Frame.transformCoordinates2d()`. The buffer is allocated
|
|
55
|
+
/// once and refilled in-place to avoid GC pressure.
|
|
56
|
+
private val quadTexCoords: FloatBuffer = ByteBuffer
|
|
57
|
+
.allocateDirect(QUAD_COORDS.size * 4)
|
|
58
|
+
.order(ByteOrder.nativeOrder())
|
|
59
|
+
.asFloatBuffer()
|
|
60
|
+
|
|
61
|
+
/// Has `Session.setCameraTextureName()` been called for the
|
|
62
|
+
/// current GL context? `BackgroundRenderer` is created on the
|
|
63
|
+
/// GL render thread but the Session may already exist; we
|
|
64
|
+
/// connect them on the first draw call.
|
|
65
|
+
private var sessionTextureBound: Boolean = false
|
|
66
|
+
|
|
67
|
+
fun createOnGlThread() {
|
|
68
|
+
// Create OES texture for ARCore's camera output.
|
|
69
|
+
val textures = IntArray(1)
|
|
70
|
+
GLES20.glGenTextures(1, textures, 0)
|
|
71
|
+
textureId = textures[0]
|
|
72
|
+
val target = GLES11Ext.GL_TEXTURE_EXTERNAL_OES
|
|
73
|
+
GLES20.glBindTexture(target, textureId)
|
|
74
|
+
GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
|
|
75
|
+
GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
|
|
76
|
+
GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
|
|
77
|
+
GLES20.glTexParameteri(target, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
|
|
78
|
+
|
|
79
|
+
program = ShaderUtil.loadGLProgram(VERTEX_SHADER, FRAGMENT_SHADER)
|
|
80
|
+
positionAttrib = GLES20.glGetAttribLocation(program, "a_Position")
|
|
81
|
+
texCoordAttrib = GLES20.glGetAttribLocation(program, "a_TexCoord")
|
|
82
|
+
textureUniform = GLES20.glGetUniformLocation(program, "sTexture")
|
|
83
|
+
ShaderUtil.checkGLError("BackgroundRenderer.createOnGlThread")
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
fun bindToSession(session: Session) {
|
|
87
|
+
if (textureId == -1) return
|
|
88
|
+
session.setCameraTextureName(textureId)
|
|
89
|
+
sessionTextureBound = true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/// Draw the camera feed for the given ARFrame. Must be called
|
|
93
|
+
/// from `Renderer.onDrawFrame` after `Session.update()`. No-op
|
|
94
|
+
/// if the GL program isn't ready yet (very first frame).
|
|
95
|
+
fun draw(frame: Frame) {
|
|
96
|
+
if (program == 0) return
|
|
97
|
+
// Update the tex coords from the ARFrame whenever the display
|
|
98
|
+
// geometry has changed (rotation, surface resize). Cheap
|
|
99
|
+
// (constant 8 floats) so we do it every frame.
|
|
100
|
+
if (frame.hasDisplayGeometryChanged()) {
|
|
101
|
+
frame.transformCoordinates2d(
|
|
102
|
+
Coordinates2d.OPENGL_NORMALIZED_DEVICE_COORDINATES,
|
|
103
|
+
quadCoords,
|
|
104
|
+
Coordinates2d.TEXTURE_NORMALIZED,
|
|
105
|
+
quadTexCoords,
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
// First frame after onSurfaceCreated: do an unconditional fill
|
|
109
|
+
// so we have valid tex coords before geometryChanged ever flips.
|
|
110
|
+
if (quadTexCoords.position() == 0 && !frame.hasDisplayGeometryChanged()) {
|
|
111
|
+
frame.transformCoordinates2d(
|
|
112
|
+
Coordinates2d.OPENGL_NORMALIZED_DEVICE_COORDINATES,
|
|
113
|
+
quadCoords,
|
|
114
|
+
Coordinates2d.TEXTURE_NORMALIZED,
|
|
115
|
+
quadTexCoords,
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
quadCoords.position(0)
|
|
119
|
+
quadTexCoords.position(0)
|
|
120
|
+
|
|
121
|
+
GLES20.glDisable(GLES20.GL_DEPTH_TEST)
|
|
122
|
+
GLES20.glDepthMask(false)
|
|
123
|
+
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
|
124
|
+
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
|
|
125
|
+
GLES20.glUseProgram(program)
|
|
126
|
+
GLES20.glUniform1i(textureUniform, 0)
|
|
127
|
+
|
|
128
|
+
GLES20.glVertexAttribPointer(positionAttrib, 2, GLES20.GL_FLOAT, false, 0, quadCoords)
|
|
129
|
+
GLES20.glEnableVertexAttribArray(positionAttrib)
|
|
130
|
+
GLES20.glVertexAttribPointer(texCoordAttrib, 2, GLES20.GL_FLOAT, false, 0, quadTexCoords)
|
|
131
|
+
GLES20.glEnableVertexAttribArray(texCoordAttrib)
|
|
132
|
+
|
|
133
|
+
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
|
134
|
+
|
|
135
|
+
GLES20.glDisableVertexAttribArray(positionAttrib)
|
|
136
|
+
GLES20.glDisableVertexAttribArray(texCoordAttrib)
|
|
137
|
+
GLES20.glDepthMask(true)
|
|
138
|
+
GLES20.glEnable(GLES20.GL_DEPTH_TEST)
|
|
139
|
+
ShaderUtil.checkGLError("BackgroundRenderer.draw")
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
companion object {
|
|
143
|
+
// Fullscreen quad in NDC space. 4 vertices, GL_TRIANGLE_STRIP
|
|
144
|
+
// order: bottom-left, bottom-right, top-left, top-right.
|
|
145
|
+
private val QUAD_COORDS = floatArrayOf(
|
|
146
|
+
-1f, -1f,
|
|
147
|
+
1f, -1f,
|
|
148
|
+
-1f, 1f,
|
|
149
|
+
1f, 1f,
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
// Vertex shader: pass-through. Tex coords are pre-transformed
|
|
153
|
+
// into TEXTURE_NORMALIZED space by ARCore so no extra matrix
|
|
154
|
+
// math is needed.
|
|
155
|
+
private val VERTEX_SHADER = """
|
|
156
|
+
attribute vec4 a_Position;
|
|
157
|
+
attribute vec2 a_TexCoord;
|
|
158
|
+
varying vec2 v_TexCoord;
|
|
159
|
+
void main() {
|
|
160
|
+
gl_Position = a_Position;
|
|
161
|
+
v_TexCoord = a_TexCoord;
|
|
162
|
+
}
|
|
163
|
+
""".trimIndent()
|
|
164
|
+
|
|
165
|
+
// Fragment shader: sample the OES external texture and write.
|
|
166
|
+
private val FRAGMENT_SHADER = """
|
|
167
|
+
#extension GL_OES_EGL_image_external : require
|
|
168
|
+
precision mediump float;
|
|
169
|
+
uniform samplerExternalOES sTexture;
|
|
170
|
+
varying vec2 v_TexCoord;
|
|
171
|
+
void main() {
|
|
172
|
+
gl_FragColor = texture2D(sTexture, v_TexCoord);
|
|
173
|
+
}
|
|
174
|
+
""".trimIndent()
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
package io.imagestitcher.rn.ar
|
|
3
|
+
|
|
4
|
+
import android.opengl.GLES20
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Minimal GLSL compile + link helper. Shared by the BackgroundRenderer
|
|
8
|
+
* (and any future GL stages we add). Keeps the per-renderer code
|
|
9
|
+
* focused on what's drawn rather than on GLSL boilerplate.
|
|
10
|
+
*
|
|
11
|
+
* No Android Studio or hello_ar dependency — small enough to inline.
|
|
12
|
+
*/
|
|
13
|
+
internal object ShaderUtil {
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Compile a vertex + fragment shader pair and link into a GL
|
|
17
|
+
* program. Throws on any compile/link error with the GL log
|
|
18
|
+
* attached to the message — much more useful than the bare
|
|
19
|
+
* GL_FALSE return that GLES20 hands you.
|
|
20
|
+
*/
|
|
21
|
+
fun loadGLProgram(vertexSrc: String, fragmentSrc: String): Int {
|
|
22
|
+
val vsh = compileShader(GLES20.GL_VERTEX_SHADER, vertexSrc)
|
|
23
|
+
val fsh = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSrc)
|
|
24
|
+
val program = GLES20.glCreateProgram()
|
|
25
|
+
GLES20.glAttachShader(program, vsh)
|
|
26
|
+
GLES20.glAttachShader(program, fsh)
|
|
27
|
+
GLES20.glLinkProgram(program)
|
|
28
|
+
val linked = IntArray(1)
|
|
29
|
+
GLES20.glGetProgramiv(program, GLES20.GL_LINK_STATUS, linked, 0)
|
|
30
|
+
if (linked[0] == 0) {
|
|
31
|
+
val log = GLES20.glGetProgramInfoLog(program)
|
|
32
|
+
GLES20.glDeleteProgram(program)
|
|
33
|
+
throw RuntimeException("Shader link failed: $log")
|
|
34
|
+
}
|
|
35
|
+
// Shaders can be detached + deleted once linked; the program
|
|
36
|
+
// holds its own reference to the compiled object code.
|
|
37
|
+
GLES20.glDetachShader(program, vsh)
|
|
38
|
+
GLES20.glDetachShader(program, fsh)
|
|
39
|
+
GLES20.glDeleteShader(vsh)
|
|
40
|
+
GLES20.glDeleteShader(fsh)
|
|
41
|
+
return program
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private fun compileShader(type: Int, src: String): Int {
|
|
45
|
+
val shader = GLES20.glCreateShader(type)
|
|
46
|
+
GLES20.glShaderSource(shader, src)
|
|
47
|
+
GLES20.glCompileShader(shader)
|
|
48
|
+
val compiled = IntArray(1)
|
|
49
|
+
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compiled, 0)
|
|
50
|
+
if (compiled[0] == 0) {
|
|
51
|
+
val log = GLES20.glGetShaderInfoLog(shader)
|
|
52
|
+
GLES20.glDeleteShader(shader)
|
|
53
|
+
throw RuntimeException("Shader compile failed: $log\nSource:\n$src")
|
|
54
|
+
}
|
|
55
|
+
return shader
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// Wrap a GLES call with `glGetError` checking — caller passes a
|
|
59
|
+
/// human-readable label so the panic message tells you which call
|
|
60
|
+
/// died. Cheap; we only call it during init / per-frame setup.
|
|
61
|
+
fun checkGLError(label: String) {
|
|
62
|
+
val err = GLES20.glGetError()
|
|
63
|
+
if (err != GLES20.GL_NO_ERROR) {
|
|
64
|
+
throw RuntimeException("$label: glError 0x${err.toString(16)}")
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
package io.imagestitcher.rn.ar
|
|
3
|
+
|
|
4
|
+
import android.graphics.ImageFormat
|
|
5
|
+
import android.graphics.Rect
|
|
6
|
+
import android.graphics.YuvImage
|
|
7
|
+
import android.media.Image
|
|
8
|
+
import android.view.Surface
|
|
9
|
+
import androidx.exifinterface.media.ExifInterface
|
|
10
|
+
import java.io.ByteArrayOutputStream
|
|
11
|
+
import java.io.File
|
|
12
|
+
import java.io.FileOutputStream
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Convert an ARCore `Image` (YUV_420_888) to a JPEG file on disk.
|
|
16
|
+
*
|
|
17
|
+
* Why JPEG → file → re-decode by OpenCV (slightly wasteful)?
|
|
18
|
+
* The incremental engine's existing API (matching iOS') consumes
|
|
19
|
+
* image PATHS, not raw planes. Threading raw YUV through the
|
|
20
|
+
* bridge would require a second native ingestion path. At ~3-5
|
|
21
|
+
* Hz of accepted frames, a ~10 ms YUV→JPEG encode is negligible
|
|
22
|
+
* next to the ~40 ms per-frame engine work — keeping the surface
|
|
23
|
+
* uniform across iOS / Android paths is worth the few-ms cost.
|
|
24
|
+
*
|
|
25
|
+
* `Image` ownership: caller MUST `image.close()` after this returns.
|
|
26
|
+
* We don't close inside because the caller may want to inspect more
|
|
27
|
+
* fields (timestamp, format) before releasing.
|
|
28
|
+
*/
|
|
29
|
+
internal object YuvImageConverter {
|
|
30
|
+
|
|
31
|
+
/// Convert + write JPEG. Returns the path (no file:// prefix)
|
|
32
|
+
/// or null on any encode/write error (caller-decides whether to
|
|
33
|
+
/// log + drop the frame).
|
|
34
|
+
///
|
|
35
|
+
/// 2026-05-15 (B3) — `displayRotation` parameter writes the
|
|
36
|
+
/// appropriate EXIF orientation tag so RN's Image loader (and
|
|
37
|
+
/// any other consumer that respects EXIF) displays the JPEG
|
|
38
|
+
/// upright regardless of how the device was held at capture.
|
|
39
|
+
///
|
|
40
|
+
/// Without this, Android's image loaders display the raw sensor
|
|
41
|
+
/// pixels (typically landscape) as-is — so a portrait-held
|
|
42
|
+
/// capture's thumbnail appears sideways. iOS's image loader
|
|
43
|
+
/// auto-respects EXIF orientation; Android's doesn't always
|
|
44
|
+
/// (depends on the loader path). Setting the tag covers both.
|
|
45
|
+
///
|
|
46
|
+
/// `displayRotation` should be the value returned from
|
|
47
|
+
/// `WindowManager.defaultDisplay.rotation` at capture time
|
|
48
|
+
/// (Surface.ROTATION_0/_90/_180/_270). We assume a typical
|
|
49
|
+
/// back-camera sensor orientation of 90° — true for all
|
|
50
|
+
/// devices we ship to (Galaxy A35 verified). Wire through
|
|
51
|
+
/// CameraCharacteristics.SENSOR_ORIENTATION in a follow-up
|
|
52
|
+
/// if we ever encounter a device that differs.
|
|
53
|
+
///
|
|
54
|
+
/// Default `displayRotation = Surface.ROTATION_0` (portrait)
|
|
55
|
+
/// preserves the previous behaviour for legacy callsites that
|
|
56
|
+
/// haven't been updated yet.
|
|
57
|
+
fun encodeToJpeg(
|
|
58
|
+
image: Image,
|
|
59
|
+
outputPath: String,
|
|
60
|
+
jpegQuality: Int = 70,
|
|
61
|
+
displayRotation: Int = Surface.ROTATION_0,
|
|
62
|
+
): String? {
|
|
63
|
+
if (image.format != ImageFormat.YUV_420_888) return null
|
|
64
|
+
val nv21 = yuv420toNV21(image) ?: return null
|
|
65
|
+
val yuvImage = YuvImage(
|
|
66
|
+
nv21,
|
|
67
|
+
ImageFormat.NV21,
|
|
68
|
+
image.width,
|
|
69
|
+
image.height,
|
|
70
|
+
null,
|
|
71
|
+
)
|
|
72
|
+
val baos = ByteArrayOutputStream()
|
|
73
|
+
val ok = yuvImage.compressToJpeg(
|
|
74
|
+
Rect(0, 0, image.width, image.height),
|
|
75
|
+
jpegQuality.coerceIn(1, 100),
|
|
76
|
+
baos,
|
|
77
|
+
)
|
|
78
|
+
if (!ok) return null
|
|
79
|
+
try {
|
|
80
|
+
FileOutputStream(File(outputPath)).use { it.write(baos.toByteArray()) }
|
|
81
|
+
} catch (e: Throwable) {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
// Write EXIF orientation tag based on display rotation.
|
|
85
|
+
// Sensor orientation 90° assumed (back camera). The math:
|
|
86
|
+
// ROTATION_0 (portrait, sensor 90° CW from screen-up)
|
|
87
|
+
// → JPEG needs 90° CW to display upright → ROTATE_90 (6)
|
|
88
|
+
// ROTATION_90 (landscape-left, sensor aligned with screen)
|
|
89
|
+
// → no rotation → NORMAL (1)
|
|
90
|
+
// ROTATION_180 (portrait-upside-down)
|
|
91
|
+
// → 270° CW → ROTATE_270 (8)
|
|
92
|
+
// ROTATION_270 (landscape-right)
|
|
93
|
+
// → 180° → ROTATE_180 (3)
|
|
94
|
+
//
|
|
95
|
+
// EXIF tag set EVEN when the orientation is normal — keeps
|
|
96
|
+
// every output JPEG self-describing for downstream
|
|
97
|
+
// consumers (cv::Stitcher does NOT auto-honour EXIF, see
|
|
98
|
+
// BatchStitcher.applyExifOrientation; this metadata
|
|
99
|
+
// exists primarily for the live thumbnail strip + future
|
|
100
|
+
// RN Image renderers).
|
|
101
|
+
val exifOrientation = when (displayRotation) {
|
|
102
|
+
Surface.ROTATION_0 -> ExifInterface.ORIENTATION_ROTATE_90
|
|
103
|
+
Surface.ROTATION_90 -> ExifInterface.ORIENTATION_NORMAL
|
|
104
|
+
Surface.ROTATION_180 -> ExifInterface.ORIENTATION_ROTATE_270
|
|
105
|
+
Surface.ROTATION_270 -> ExifInterface.ORIENTATION_ROTATE_180
|
|
106
|
+
else -> ExifInterface.ORIENTATION_NORMAL
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
val exif = ExifInterface(outputPath)
|
|
110
|
+
exif.setAttribute(
|
|
111
|
+
ExifInterface.TAG_ORIENTATION,
|
|
112
|
+
exifOrientation.toString(),
|
|
113
|
+
)
|
|
114
|
+
exif.saveAttributes()
|
|
115
|
+
} catch (e: Throwable) {
|
|
116
|
+
// EXIF write failed — JPEG itself is still valid; just
|
|
117
|
+
// missing the orientation hint. Caller doesn't need to
|
|
118
|
+
// know — non-fatal.
|
|
119
|
+
}
|
|
120
|
+
return outputPath
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Pack a YUV_420_888 `Image` into a contiguous NV21 byte array.
|
|
125
|
+
*
|
|
126
|
+
* The Image API exposes Y, U, V as three planes, each with its
|
|
127
|
+
* own row stride and pixel stride. NV21 expects a single
|
|
128
|
+
* contiguous buffer with Y plane first, then interleaved VU bytes
|
|
129
|
+
* after. The repacking handles row + pixel strides that don't
|
|
130
|
+
* match the dense layout.
|
|
131
|
+
*/
|
|
132
|
+
private fun yuv420toNV21(image: Image): ByteArray? {
|
|
133
|
+
val w = image.width
|
|
134
|
+
val h = image.height
|
|
135
|
+
val ySize = w * h
|
|
136
|
+
val uvSize = w * h / 2
|
|
137
|
+
|
|
138
|
+
val nv21 = ByteArray(ySize + uvSize)
|
|
139
|
+
val planes = image.planes
|
|
140
|
+
if (planes.size < 3) return null
|
|
141
|
+
|
|
142
|
+
// Y plane.
|
|
143
|
+
val yPlane = planes[0]
|
|
144
|
+
val yBuf = yPlane.buffer
|
|
145
|
+
val yRowStride = yPlane.rowStride
|
|
146
|
+
if (yRowStride == w) {
|
|
147
|
+
yBuf.get(nv21, 0, ySize)
|
|
148
|
+
} else {
|
|
149
|
+
// Row-by-row copy when stride != width.
|
|
150
|
+
var dstOffset = 0
|
|
151
|
+
var srcOffset = 0
|
|
152
|
+
for (row in 0 until h) {
|
|
153
|
+
yBuf.position(srcOffset)
|
|
154
|
+
yBuf.get(nv21, dstOffset, w)
|
|
155
|
+
dstOffset += w
|
|
156
|
+
srcOffset += yRowStride
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// U + V planes. YUV_420_888 has them subsampled 2:1 so each
|
|
161
|
+
// covers (w/2) × (h/2). Pixel stride is 1 (planar) or 2
|
|
162
|
+
// (semi-planar interleaved). NV21 requires interleaved VU.
|
|
163
|
+
val uPlane = planes[1]
|
|
164
|
+
val vPlane = planes[2]
|
|
165
|
+
val uBuf = uPlane.buffer
|
|
166
|
+
val vBuf = vPlane.buffer
|
|
167
|
+
val uRowStride = uPlane.rowStride
|
|
168
|
+
val uPixelStride = uPlane.pixelStride
|
|
169
|
+
val vRowStride = vPlane.rowStride
|
|
170
|
+
val vPixelStride = vPlane.pixelStride
|
|
171
|
+
|
|
172
|
+
// Most camera2 / ARCore implementations on Android already
|
|
173
|
+
// produce semi-planar interleaved data with pixelStride=2.
|
|
174
|
+
// In that case Y plane + V plane (offset by 1) form NV21
|
|
175
|
+
// directly with a single block copy. Detect + fast-path it.
|
|
176
|
+
if (uPixelStride == 2 && vPixelStride == 2 &&
|
|
177
|
+
uRowStride == vRowStride && uRowStride == w) {
|
|
178
|
+
// The V plane in NV21 layout starts at vBuf's first byte.
|
|
179
|
+
// Copy the entire V plane (which physically interleaves
|
|
180
|
+
// with U bytes since pixelStride=2 means consecutive
|
|
181
|
+
// bytes are V-U-V-U...).
|
|
182
|
+
val vBytes = vBuf.remaining().coerceAtMost(uvSize)
|
|
183
|
+
vBuf.get(nv21, ySize, vBytes)
|
|
184
|
+
return nv21
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Slow path — manual interleave.
|
|
188
|
+
var pos = ySize
|
|
189
|
+
val rowsUv = h / 2
|
|
190
|
+
val colsUv = w / 2
|
|
191
|
+
for (row in 0 until rowsUv) {
|
|
192
|
+
for (col in 0 until colsUv) {
|
|
193
|
+
val vIdx = row * vRowStride + col * vPixelStride
|
|
194
|
+
val uIdx = row * uRowStride + col * uPixelStride
|
|
195
|
+
nv21[pos++] = vBuf.get(vIdx)
|
|
196
|
+
nv21[pos++] = uBuf.get(uIdx)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return nv21
|
|
200
|
+
}
|
|
201
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
//
|
|
3
|
+
// ar_frame_pose.h — POD struct shared between iOS and Android for AR
|
|
4
|
+
// frame pose data crossing the C++ boundary.
|
|
5
|
+
//
|
|
6
|
+
// Both platforms marshal their native pose representation into this
|
|
7
|
+
// flat struct:
|
|
8
|
+
// - iOS: RNSARFramePose (Swift) → KeyframeGateBridge.mm
|
|
9
|
+
// unmarshals Swift Doubles → C++ floats.
|
|
10
|
+
// - Android: RNSARFramePose (Kotlin data class) → JNI
|
|
11
|
+
// unmarshals JVM doubles → C++ floats.
|
|
12
|
+
//
|
|
13
|
+
// Layout MUST stay stable across both platforms. Field order /
|
|
14
|
+
// padding / size is checked by static_assert in keyframe_gate.cpp.
|
|
15
|
+
//
|
|
16
|
+
// Convention notes:
|
|
17
|
+
// - tx, ty, tz: camera origin in world coordinates (metres)
|
|
18
|
+
// - qx, qy, qz, qw: orientation as a unit quaternion in JPL convention
|
|
19
|
+
// (last-real-part, matching both ARKit's simd_quatf and ARCore's
|
|
20
|
+
// Pose.getRotationQuaternion())
|
|
21
|
+
// - fx, fy, cx, cy: pinhole intrinsics (pixels)
|
|
22
|
+
// - imageWidth, imageHeight: pixel dimensions of the captured frame
|
|
23
|
+
|
|
24
|
+
#pragma once
|
|
25
|
+
#include <cstdint>
|
|
26
|
+
|
|
27
|
+
namespace retailens {
|
|
28
|
+
|
|
29
|
+
struct Pose {
|
|
30
|
+
// Translation in metres (world frame).
|
|
31
|
+
float tx;
|
|
32
|
+
float ty;
|
|
33
|
+
float tz;
|
|
34
|
+
// Rotation quaternion (JPL convention: last component is real).
|
|
35
|
+
float qx;
|
|
36
|
+
float qy;
|
|
37
|
+
float qz;
|
|
38
|
+
float qw;
|
|
39
|
+
// Pinhole camera intrinsics (pixels).
|
|
40
|
+
float fx;
|
|
41
|
+
float fy;
|
|
42
|
+
float cx;
|
|
43
|
+
float cy;
|
|
44
|
+
// Image dimensions (pixels).
|
|
45
|
+
int32_t imageWidth;
|
|
46
|
+
int32_t imageHeight;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// 4×4 column-major rotation+translation matrix. Matches both
|
|
50
|
+
// simd_float4x4 (iOS) and the array returned by ARCore Pose.toMatrix(...)
|
|
51
|
+
// layout: columns laid out contiguously, so columns.0 is m[0..3],
|
|
52
|
+
// columns.1 is m[4..7], etc. Translation is the last column (m[12..15]).
|
|
53
|
+
//
|
|
54
|
+
// ARKit ARPlaneAnchor convention:
|
|
55
|
+
// column 0 = plane tangent X (in-plane "right")
|
|
56
|
+
// column 1 = plane surface normal
|
|
57
|
+
// column 2 = plane tangent Z (in-plane "up")
|
|
58
|
+
// column 3 = plane origin
|
|
59
|
+
struct PlaneTransform {
|
|
60
|
+
float m[16];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
} // namespace retailens
|