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,1357 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
/**
|
|
3
|
+
* PanoramaSettingsModal — runtime A/B testing surface for the
|
|
4
|
+
* stitcher pipeline. Operators in the field can toggle warper,
|
|
5
|
+
* blender, and tuning constants between captures to see what
|
|
6
|
+
* looks best on real shelf scenes.
|
|
7
|
+
*
|
|
8
|
+
* The modal is presentational: the host owns the settings state
|
|
9
|
+
* (typically `useState<PanoramaSettings>`) and renders the modal
|
|
10
|
+
* with `visible` toggled by a gear-icon press in the capture
|
|
11
|
+
* header. Settings flow OUT via `onChange` for each tweak.
|
|
12
|
+
*
|
|
13
|
+
* Why expose this as an SDK component instead of leaving it to
|
|
14
|
+
* each host? The set of tunable knobs IS the SDK's contract —
|
|
15
|
+
* if a new setting is added (e.g. registration MP) the SDK ships
|
|
16
|
+
* the UI for it in lockstep with the param itself, instead of
|
|
17
|
+
* forcing every host app to update its settings screen.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import React, { useState } from 'react';
|
|
21
|
+
import {
|
|
22
|
+
Modal,
|
|
23
|
+
NativeModules,
|
|
24
|
+
Pressable,
|
|
25
|
+
ScrollView,
|
|
26
|
+
StyleSheet,
|
|
27
|
+
Text,
|
|
28
|
+
View,
|
|
29
|
+
} from 'react-native';
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
export interface PanoramaSettings {
|
|
33
|
+
warperType: 'plane' | 'cylindrical' | 'spherical';
|
|
34
|
+
blenderType: 'multiband' | 'feather';
|
|
35
|
+
/**
|
|
36
|
+
* Seam finder strategy. "graphcut" finds optimal seams before
|
|
37
|
+
* blending (cleaner output, pairs with multiband, more memory).
|
|
38
|
+
* "skip" streams warp+feed (lower peak memory, fine with feather).
|
|
39
|
+
*/
|
|
40
|
+
seamFinderType: 'graphcut' | 'skip';
|
|
41
|
+
/**
|
|
42
|
+
* V16 Phase 1b.fix5c (Ram's call 2026-05-10) — toggle the
|
|
43
|
+
* max-inscribed-rectangle crop on the batch-keyframe output
|
|
44
|
+
* panorama. When false (default), the output is cropped to the
|
|
45
|
+
* bounding rectangle of non-black pixels only (cv::boundingRect)
|
|
46
|
+
* — preserves all stitched content at the cost of some black
|
|
47
|
+
* corners where cv::Stitcher's projection didn't fill. When
|
|
48
|
+
* true, the post-stitch pipeline additionally runs
|
|
49
|
+
* `MaxInscribedRectFromMask` to find the largest axis-aligned
|
|
50
|
+
* rectangle entirely inside content, followed by the
|
|
51
|
+
* column-projection second-pass. Inscribed-rect can be
|
|
52
|
+
* over-aggressive on lopsided masks (field log showed a
|
|
53
|
+
* 1146×1102 bbox shrinking to a 602×1102 strip), so default OFF
|
|
54
|
+
* lets the operator see the full stitched scene; flip ON to
|
|
55
|
+
* A/B against the cleaner-but-smaller output.
|
|
56
|
+
*/
|
|
57
|
+
enableMaxInscribedRectCrop: boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Phase 4.4 EXPERIMENTAL: when true, the host swaps the
|
|
60
|
+
* vision-camera-backed CameraView for an ARKit-backed ARCameraView
|
|
61
|
+
* during panorama capture. Default false (keeps the existing
|
|
62
|
+
* stitcher flow untouched). Phase 5 will add AR-backed photo /
|
|
63
|
+
* video capture and pose-driven stitching; until then this is
|
|
64
|
+
* preview-only — useful for verifying the AR session renders
|
|
65
|
+
* cleanly on the operator's device before we cut over.
|
|
66
|
+
*/
|
|
67
|
+
useARPreview: boolean;
|
|
68
|
+
/**
|
|
69
|
+
* V15 — Incremental engine choice for live realtime stitching.
|
|
70
|
+
* 'hybrid' — Whole-frame projection + feature matching;
|
|
71
|
+
* planar by default (was cylindrical).
|
|
72
|
+
* 'slitscan-rotate' — V13.0a baseline + 1D NCC for rotation
|
|
73
|
+
* wobble correction.
|
|
74
|
+
* 'slitscan-both' — DEFAULT. V13.0a + no accept gate +
|
|
75
|
+
* feather blend. Iterate via per-stage
|
|
76
|
+
* toggles below.
|
|
77
|
+
*
|
|
78
|
+
* All three are A/B-comparable on the same scene by toggling here
|
|
79
|
+
* without restarting the app.
|
|
80
|
+
*/
|
|
81
|
+
incrementalEngine:
|
|
82
|
+
| 'batch-keyframe'
|
|
83
|
+
| 'hybrid'
|
|
84
|
+
| 'slitscan-rotate'
|
|
85
|
+
| 'slitscan-both';
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* V15 — Slit-scan slit width (fraction of pan-axis retained per
|
|
89
|
+
* frame). Range 0.10 – 0.70. Smaller = less within-slit multi-
|
|
90
|
+
* depth disagreement but tighter overlap budget at fast pans.
|
|
91
|
+
* Default 0.30. Only applied to slitscan-* engines.
|
|
92
|
+
*/
|
|
93
|
+
slitWidthFraction: number;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* V15 — Per-stage correction toggles for slitscan-both. Settings
|
|
97
|
+
* UI exposes these so iteration happens via toggles, not rebuilds.
|
|
98
|
+
*/
|
|
99
|
+
acceptGate: 0 | 50;
|
|
100
|
+
enableTriangulation: boolean;
|
|
101
|
+
enableTriAccumulator: boolean;
|
|
102
|
+
enable2dNcc: boolean;
|
|
103
|
+
enableRansacHomography: boolean;
|
|
104
|
+
paintMode: 'FirstPaintedWins' | 'FeatherBlend';
|
|
105
|
+
hybridProjection: 'Cylindrical' | 'Planar';
|
|
106
|
+
/** 1D NCC search radius (slitscan-rotate only). */
|
|
107
|
+
nccSearchRadius1d: number;
|
|
108
|
+
/** **DEPRECATED in V15.0d** — see `planeSource`. Kept on the type
|
|
109
|
+
* for backward compat with stored settings. When `planeSource`
|
|
110
|
+
* is 'Disabled' (default) and this is true, the engine treats it
|
|
111
|
+
* as 'ARKitDetected'. */
|
|
112
|
+
useDetectedPlane: boolean;
|
|
113
|
+
/** V15.0d — source of the plane used by the V15.0b plane-projected
|
|
114
|
+
* stitch path. Slit-scan modes only.
|
|
115
|
+
*
|
|
116
|
+
* - 'Disabled': no plane projection (plain slit-scan).
|
|
117
|
+
* - 'ARKitDetected': use ARKit's first vertical plane that aligns
|
|
118
|
+
* with the camera's view direction. Falls back to slit-scan
|
|
119
|
+
* silently when no aligned plane is found.
|
|
120
|
+
* - 'Virtual': synthesize a plane perpendicular to the camera at
|
|
121
|
+
* `virtualPlaneDepthMeters` distance. Always works; loses
|
|
122
|
+
* "real depth" advantage but immune to ARKit picking the wrong
|
|
123
|
+
* surface (which is the common failure mode for ARKitDetected). */
|
|
124
|
+
planeSource: 'Disabled' | 'ARKitDetected' | 'Virtual';
|
|
125
|
+
/** V15.0d — depth (m) of the synthetic plane in front of the camera
|
|
126
|
+
* when `planeSource = 'Virtual'`. 0.3 – 5.0 m. Default 1.5 m. */
|
|
127
|
+
virtualPlaneDepthMeters: number;
|
|
128
|
+
/** V15.0d — alignment threshold (cosine) for ARKit-detected planes.
|
|
129
|
+
* Higher = stricter (fewer planes accepted). 0.0 – 1.0.
|
|
130
|
+
* Default 0.6 (≈53° max angle off-camera). */
|
|
131
|
+
arkitPlaneAlignmentThreshold: number;
|
|
132
|
+
/** V15.0g — plane-projection rendering style. Trapezoidal is the
|
|
133
|
+
* V15.0b legacy 3D-correct mapping; Rectified is V15.0g's clean-
|
|
134
|
+
* rectangle paste that eliminates tilt-induced trapezoidal
|
|
135
|
+
* distortion. Default Rectified. Ignored when planeSource =
|
|
136
|
+
* Disabled. */
|
|
137
|
+
planeProjectionStyle: 'Trapezoidal' | 'Rectified';
|
|
138
|
+
/** V15.0d — 2D NCC search half-window in pixels. 4 – 30.
|
|
139
|
+
* Default 12. */
|
|
140
|
+
nccSearchMargin2d: number;
|
|
141
|
+
/** V15.0d — 2D NCC confidence threshold below which corrections
|
|
142
|
+
* are rejected. 0.30 – 0.99. Default 0.75. */
|
|
143
|
+
nccConfidenceThreshold2d: number;
|
|
144
|
+
/** V15.0d (1B) — EMA smoothing on 2D NCC corrections to damp
|
|
145
|
+
* single-frame snaps. Default false. */
|
|
146
|
+
enableNcc2dEmaSmoothing: boolean;
|
|
147
|
+
/** V15.0d — EMA weight on the CURRENT-frame correction. 0.05 – 0.95.
|
|
148
|
+
* Default 0.4 (60% prev / 40% current). */
|
|
149
|
+
ncc2dEmaAlpha: number;
|
|
150
|
+
/** V15.0d (1C) — pan-axis-aware 2D NCC: clamp the cross-axis
|
|
151
|
+
* correction tighter than the pan-axis. Default false. */
|
|
152
|
+
enableNcc2dPanAxisLock: boolean;
|
|
153
|
+
/** V15.0d — cross-axis clamp (px) when pan-axis lock is on.
|
|
154
|
+
* 0 – 30. Default 5. */
|
|
155
|
+
ncc2dCrossAxisLockPx: number;
|
|
156
|
+
|
|
157
|
+
/** V16 — frame-selection mode for the live engine.
|
|
158
|
+
*
|
|
159
|
+
* - 'time-based' (default): every ARFrame is forwarded to the
|
|
160
|
+
* engine; the engine's own gate (kMinAcceptDeltaPx etc.) decides.
|
|
161
|
+
* Backward-compatible with all prior versions.
|
|
162
|
+
* - 'pose-based': frames are pre-filtered by a KeyframeGate that
|
|
163
|
+
* projects each onto the latched ARKit plane and accepts only
|
|
164
|
+
* when overlap with the previous keyframe is < 1 −
|
|
165
|
+
* overlapThreshold. Bounded to keyframeMaxCount frames per
|
|
166
|
+
* capture (matches iOS Camera / Samsung Pano architecture).
|
|
167
|
+
* Requires planeSource != 'Disabled' to engage.
|
|
168
|
+
* - 'flow-based' (V16 A2, DEFAULT): same KeyframeGate cap +
|
|
169
|
+
* threshold but the novelty metric is sparse-Lucas-Kanade
|
|
170
|
+
* optical flow on full-frame content instead of plane-projected
|
|
171
|
+
* polygon overlap. Plane-independent (scale-invariant — works
|
|
172
|
+
* regardless of latched plane size); the metric is "median
|
|
173
|
+
* pan-axis feature displacement / pan-axis frame dim", which is
|
|
174
|
+
* a direct measure of % new content on the leading edge. Falls
|
|
175
|
+
* back to angular delta when feature tracking fails (texture-
|
|
176
|
+
* poor scene / motion exceeds KLT pyramid window). */
|
|
177
|
+
frameSelectionMode: 'time-based' | 'pose-based' | 'flow-based';
|
|
178
|
+
/** V16 — required NEW-content fraction for a keyframe to be
|
|
179
|
+
* accepted (pose-based AND flow-based modes share this knob;
|
|
180
|
+
* both interpret 0.40 as "40 % new content"). Tuneable from
|
|
181
|
+
* 0.20 to 0.60 in the modal. */
|
|
182
|
+
keyframeOverlapThreshold: number;
|
|
183
|
+
/** V16 — hard cap on keyframes per capture (pose-based + flow-
|
|
184
|
+
* based modes). Default 6. Once reached, all further frames are
|
|
185
|
+
* rejected and the host should auto-finalize. */
|
|
186
|
+
keyframeMaxCount: number;
|
|
187
|
+
/** V16 A2 — flow-based mode: max Shi-Tomasi corners to detect per
|
|
188
|
+
* accepted keyframe. More = more robust median pan-axis
|
|
189
|
+
* displacement but slower detect (~15-25 ms at 150 on iPhone 13
|
|
190
|
+
* Pro). Range 50 – 300, default 150. */
|
|
191
|
+
flowMaxCorners: number;
|
|
192
|
+
/** V16 A2 — flow-based mode: Shi-Tomasi quality level (0, 1].
|
|
193
|
+
* Lower = more (weaker) corners detected; higher = fewer
|
|
194
|
+
* (stronger) corners. Default 0.01. Range 0.005 – 0.05 in the
|
|
195
|
+
* modal. */
|
|
196
|
+
flowQualityLevel: number;
|
|
197
|
+
/** V16 A2 — flow-based mode: minimum pixel distance between
|
|
198
|
+
* detected corners at WORKING resolution (the gate internally
|
|
199
|
+
* downscales the frame to 720 px longest side for KLT). Higher
|
|
200
|
+
* = more spatially-spread features. Default 10. */
|
|
201
|
+
flowMinDistance: number;
|
|
202
|
+
/** V16 — flow-based mode: translation budget in CENTIMETRES.
|
|
203
|
+
* When > 0, the gate force-accepts a frame if the camera has
|
|
204
|
+
* translated more than this distance (3D Euclidean) since the
|
|
205
|
+
* last accepted keyframe — even when novelty < threshold.
|
|
206
|
+
* Bounds the parallax between adjacent keyframes so the
|
|
207
|
+
* downstream affine stitcher matcher can fit a homography.
|
|
208
|
+
* Range 0 – 100 cm in the modal, default 0 = disabled.
|
|
209
|
+
* Recommended starting value once enabled: 8 cm. */
|
|
210
|
+
flowMaxTranslationCm: number;
|
|
211
|
+
/** V16 — flow-based mode: percentile used to aggregate tracked-
|
|
212
|
+
* feature absolute displacements into the novelty estimate.
|
|
213
|
+
* Pre-V16 used median (0.50); 0.85 picks up leading-edge
|
|
214
|
+
* motion sooner — matches user perception of "new content
|
|
215
|
+
* visible" better. Range 0.50 – 0.99, default 0.85. */
|
|
216
|
+
flowNoveltyPercentile: number;
|
|
217
|
+
/** V16 — flow-based mode: eval-throttle. Gate evaluation runs
|
|
218
|
+
* every Nth consumeFrame from the AR delegate instead of every
|
|
219
|
+
* frame. Pure CPU/battery savings — doesn't change WHICH
|
|
220
|
+
* frames are accepted, just samples less frequently. Range
|
|
221
|
+
* 1 – 10, default 1 (every frame). */
|
|
222
|
+
flowEvalEveryNFrames: number;
|
|
223
|
+
|
|
224
|
+
/** V15.0c — sliver position within the camera frame. 'Center' is
|
|
225
|
+
* V13.x default. 'Bottom' takes leading-edge content for top-to-
|
|
226
|
+
* bottom pan; 'Top' for bottom-to-top pan. */
|
|
227
|
+
sliverPosition: 'Center' | 'Bottom' | 'Top';
|
|
228
|
+
/** V15.0c — paint full first frame, then add slivers as user pans.
|
|
229
|
+
* Useful with 'Bottom' or 'Top' sliverPosition. */
|
|
230
|
+
firstFrameFullFrame: boolean;
|
|
231
|
+
/** Hard cap on hold duration (ms). 0 disables auto-stop. */
|
|
232
|
+
maxRecordingMs: number;
|
|
233
|
+
/** Frames per second of recording to sample for stitching. */
|
|
234
|
+
framesPerSecond: number;
|
|
235
|
+
/** Floor / ceiling on extracted frame count. */
|
|
236
|
+
minFrames: number;
|
|
237
|
+
maxFrames: number;
|
|
238
|
+
/** JPEG quality (0-100) for output panorama. */
|
|
239
|
+
quality: number;
|
|
240
|
+
|
|
241
|
+
// ── 2026-05-14: capture-source + stitch-mode axes ─────────────────
|
|
242
|
+
//
|
|
243
|
+
// These two settings are independent from the existing
|
|
244
|
+
// `incrementalEngine` / `useARPreview` axes; together they decide
|
|
245
|
+
// (a) which camera + tracking the capture screen uses, and (b)
|
|
246
|
+
// which OpenCV pipeline mode the batch stitcher uses at finalize.
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 2026-05-14 (revised) — capture-source picker for the panorama
|
|
250
|
+
* camera screen. Two options after the 2026-05-14 user-reported
|
|
251
|
+
* Galaxy A35 crash + simplification request:
|
|
252
|
+
*
|
|
253
|
+
* 'ar' (DEFAULT) — Use the AR stack (ARKit on iOS, ARCore on
|
|
254
|
+
* Android). Plane detection, pose-aware
|
|
255
|
+
* capture, pose-driven gate. Falls back to
|
|
256
|
+
* non-AR silently if the device doesn't
|
|
257
|
+
* support AR.
|
|
258
|
+
* 'non-ar' — Use vision-camera. Disables all AR-based
|
|
259
|
+
* services (planeSource=Disabled, no plane
|
|
260
|
+
* polling, no AR session, frameSelectionMode
|
|
261
|
+
* flipped to flow-based). Lens-switcher chip
|
|
262
|
+
* on the capture screen lets the operator
|
|
263
|
+
* toggle 0.5× / 1× without re-opening Settings.
|
|
264
|
+
* The chip is hidden if the device has only
|
|
265
|
+
* one physical back lens.
|
|
266
|
+
*
|
|
267
|
+
* Cascade: switching from 'ar' → 'non-ar' triggers a useEffect
|
|
268
|
+
* in `AuditCaptureScreen` that patches dependent settings
|
|
269
|
+
* (planeSource, frameSelectionMode, useARPreview) to a coherent
|
|
270
|
+
* non-AR state. Operators don't have to know which other
|
|
271
|
+
* settings to flip.
|
|
272
|
+
*
|
|
273
|
+
* Earlier draft (replaced 2026-05-14) had 4 values:
|
|
274
|
+
* 'auto' | 'ar' | 'wide' | 'ultrawide'. The pre-mount
|
|
275
|
+
* physical-lens selection ('wide' / 'ultrawide') crashed the
|
|
276
|
+
* Galaxy A35 vision-camera CameraCaptureSession with a Parcel
|
|
277
|
+
* exception (physical_camera_id=null in AidlCamera3-Device
|
|
278
|
+
* configureStreams) — Camera2 can't be reliably steered to a
|
|
279
|
+
* specific physical lens via vision-camera's `physicalDevices`
|
|
280
|
+
* filter on this hardware. The post-mount on-screen chip path
|
|
281
|
+
* works because vision-camera selects the safe multi-lens
|
|
282
|
+
* virtual device first, and the lens swap happens against an
|
|
283
|
+
* already-open camera.
|
|
284
|
+
*/
|
|
285
|
+
captureSource: 'ar' | 'non-ar';
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 2026-05-16 (Issue 5) — diagnostic toast on every successful
|
|
289
|
+
* finalize. When `true`, the host renders a transient toast
|
|
290
|
+
* summarising the C+D progressive-confidence retry telemetry:
|
|
291
|
+
*
|
|
292
|
+
* "Stitch: 6/6 frames retained at thresh 1.00 (1 attempt)"
|
|
293
|
+
*
|
|
294
|
+
* Defaults to `false` so end-users don't see it. Toggle from the
|
|
295
|
+
* Settings modal under "Debug". Independent from any log-level
|
|
296
|
+
* controls — purely a UI affordance for field testing.
|
|
297
|
+
*/
|
|
298
|
+
debug: boolean;
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 2026-05-14 — `cv::Stitcher` pipeline mode for the batch stitch.
|
|
302
|
+
*
|
|
303
|
+
* 'auto' (DEFAULT)
|
|
304
|
+
* The capture engine looks at the accumulated translation vs
|
|
305
|
+
* rotation magnitudes between first and last accepted keyframe
|
|
306
|
+
* poses (AR-mode) or the windowed IMU integration (non-AR
|
|
307
|
+
* mode) and picks PANORAMA or SCANS at finalize time.
|
|
308
|
+
*
|
|
309
|
+
* 'panorama'
|
|
310
|
+
* `cv::Stitcher::PANORAMA` — rotation-only pipeline. Best for
|
|
311
|
+
* "rotate phone in place to capture a wide field of view"
|
|
312
|
+
* captures. ORB feature matching + global BundleAdjusterRay +
|
|
313
|
+
* SphericalWarper. Sharp seams, expensive memory. WARNING:
|
|
314
|
+
* on translation-heavy input the rotation-only homography fit
|
|
315
|
+
* diverges and the canvas can blow up to multi-GB on Android
|
|
316
|
+
* (2026-05-14 lmkd kill observed). Pick this only for genuine
|
|
317
|
+
* rotation panoramas.
|
|
318
|
+
*
|
|
319
|
+
* 'scans'
|
|
320
|
+
* `cv::Stitcher::SCANS` — translational pipeline. Best for
|
|
321
|
+
* "walk past a shelf and pan sideways" captures. Affine
|
|
322
|
+
* matcher + AffineBasedEstimator + BundleAdjusterAffine +
|
|
323
|
+
* PlaneWarper. Canvas size bounded by sum of frame areas.
|
|
324
|
+
* Slight quality drop on pure rotations but works for them too.
|
|
325
|
+
*
|
|
326
|
+
* iOS NOTE: as of 2026-05-14 the iOS stitcher uses a hand-rolled
|
|
327
|
+
* PANORAMA-style pipeline (OpenCVStitcher.mm:600+) regardless of
|
|
328
|
+
* this setting. Setting is passed through to iOS but ignored.
|
|
329
|
+
* Android honours it via image_stitcher_jni.cpp. Bridging iOS is
|
|
330
|
+
* a follow-up.
|
|
331
|
+
*/
|
|
332
|
+
stitchMode: 'auto' | 'panorama' | 'scans';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
// Per-device default selection. We read the iPhone's physical
|
|
337
|
+
// RAM at SDK module load (exposed by `BatchStitcher`'s
|
|
338
|
+
// `constantsToExport`) and pick the heaviest blender + seam
|
|
339
|
+
// finder combo that fits. Threshold (2 GB) is conservative —
|
|
340
|
+
// iPhone 6s through iPhone X have 2 GB exactly; below that
|
|
341
|
+
// (iPhone 6 / 5s) is unsupported by RN 0.84 anyway. The user
|
|
342
|
+
// can still flip ANY of these in the settings modal at runtime;
|
|
343
|
+
// this only chooses the INITIAL default.
|
|
344
|
+
const _physicalMemoryBytes: number = (() => {
|
|
345
|
+
const m = (NativeModules as Record<string, unknown>).BatchStitcher;
|
|
346
|
+
const bytes =
|
|
347
|
+
m && typeof m === 'object'
|
|
348
|
+
? (m as { physicalMemoryBytes?: number }).physicalMemoryBytes
|
|
349
|
+
: undefined;
|
|
350
|
+
return typeof bytes === 'number' ? bytes : 0;
|
|
351
|
+
})();
|
|
352
|
+
|
|
353
|
+
const _isLowMem = _physicalMemoryBytes > 0
|
|
354
|
+
&& _physicalMemoryBytes < 2 * 1024 * 1024 * 1024;
|
|
355
|
+
|
|
356
|
+
// One-line diagnostic so the host's Metro console shows what the
|
|
357
|
+
// SDK saw at module load. If `physicalMemoryBytes=0` here, the
|
|
358
|
+
// native bridge's `constantsToExport` isn't being picked up by
|
|
359
|
+
// React Native and we should investigate the @objc registration.
|
|
360
|
+
// The defaults always pick the SAFE fallback (multiband+graphcut)
|
|
361
|
+
// when the value is 0 — this log is the only signal we have.
|
|
362
|
+
// eslint-disable-next-line no-console
|
|
363
|
+
console.log(
|
|
364
|
+
'[capture-sdk] PanoramaSettings defaults: '
|
|
365
|
+
+ `physicalMemoryBytes=${_physicalMemoryBytes} `
|
|
366
|
+
+ `isLowMem=${_isLowMem} `
|
|
367
|
+
+ `→ blender=${_isLowMem ? 'feather' : 'multiband'} `
|
|
368
|
+
+ `seam=${_isLowMem ? 'skip' : 'graphcut'}`,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
export const DEFAULT_PANORAMA_SETTINGS: PanoramaSettings = {
|
|
373
|
+
warperType: 'plane',
|
|
374
|
+
// High-quality defaults on devices with ≥2 GB RAM (iPhone X+):
|
|
375
|
+
// MultiBandBlender + GraphCutSeamFinder, the same combo
|
|
376
|
+
// cv::Stitcher::PANORAMA uses internally and what produced the
|
|
377
|
+
// sharpest output during iteration.
|
|
378
|
+
// Low-memory devices (<2 GB) fall back to FeatherBlender + skip
|
|
379
|
+
// seam (streams warp+feed) so peak memory stays under the
|
|
380
|
+
// tighter jetsam threshold. Either way, the user can switch
|
|
381
|
+
// both in the settings modal.
|
|
382
|
+
blenderType: _isLowMem ? 'feather' : 'multiband',
|
|
383
|
+
seamFinderType: _isLowMem ? 'skip' : 'graphcut',
|
|
384
|
+
// V16 Phase 1b.fix5c — default OFF. See PanoramaSettings.enableMaxInscribedRectCrop.
|
|
385
|
+
enableMaxInscribedRectCrop: false,
|
|
386
|
+
// AR-backed capture is the default — vision-camera path is kept as
|
|
387
|
+
// a fallback while we shake out edge cases.
|
|
388
|
+
useARPreview: true,
|
|
389
|
+
// V16 Phase 1 — batch-keyframe is the new default-recommended
|
|
390
|
+
// engine: KeyframeGate caps input at ≤ keyframeMaxCount frames,
|
|
391
|
+
// OpenCVStitcher's BA + GraphCut + ExposureCompensator +
|
|
392
|
+
// MultiBandBlender runs once on shutter release. Existing
|
|
393
|
+
// slitscan-* engines remain available for wide-pan fallback.
|
|
394
|
+
incrementalEngine: 'batch-keyframe',
|
|
395
|
+
slitWidthFraction: 0.30,
|
|
396
|
+
acceptGate: 0,
|
|
397
|
+
enableTriangulation: false,
|
|
398
|
+
enableTriAccumulator: false,
|
|
399
|
+
enable2dNcc: false,
|
|
400
|
+
enableRansacHomography: false,
|
|
401
|
+
// V15.0c — Ram observation: FirstPaintedWins is consistently the best
|
|
402
|
+
// output across all combinations. Default switched from FeatherBlend.
|
|
403
|
+
paintMode: 'FirstPaintedWins',
|
|
404
|
+
hybridProjection: 'Planar',
|
|
405
|
+
nccSearchRadius1d: 15,
|
|
406
|
+
useDetectedPlane: false,
|
|
407
|
+
// V16 Phase 1 — Virtual plane is the default since batch-keyframe
|
|
408
|
+
// is the recommended engine and the gate needs a plane to compute
|
|
409
|
+
// polygon overlap. Virtual works without ARKit-detected planes (a
|
|
410
|
+
// synthesized plane perpendicular to the first-frame camera at
|
|
411
|
+
// virtualPlaneDepthMeters); operators can flip to ARKitDetected
|
|
412
|
+
// when in a controlled scene with a clearly-visible wall. Disabled
|
|
413
|
+
// is still selectable for the older slit-scan paths that don't
|
|
414
|
+
// need a plane.
|
|
415
|
+
// V16 Phase 1b.fix5c (Ram's call 2026-05-10): switched default
|
|
416
|
+
// from 'Virtual' to 'ARKitDetected'. ARKit's real plane gives
|
|
417
|
+
// better intrinsics-to-pixel alignment than a synthesised plane
|
|
418
|
+
// at a fixed depth, when ARKit can find a vertical plane. Falls
|
|
419
|
+
// back to slit-scan when no plane latches.
|
|
420
|
+
planeSource: 'ARKitDetected',
|
|
421
|
+
virtualPlaneDepthMeters: 1.5,
|
|
422
|
+
arkitPlaneAlignmentThreshold: 0.6,
|
|
423
|
+
// V15.0g — Rectified is the default (Trapezoidal had the tilt-
|
|
424
|
+
// induced bottom-wider-than-top distortion that was the field
|
|
425
|
+
// blocker on V15.0e/f). Trapezoidal stays available for
|
|
426
|
+
// operator A/B comparison.
|
|
427
|
+
planeProjectionStyle: 'Rectified',
|
|
428
|
+
// V15.0d — NCC 2D defaults match V15.0c.4's hardcoded values, now
|
|
429
|
+
// tunable via the settings UI. EMA smoothing and pan-axis lock are
|
|
430
|
+
// off by default so the V15.0c.4 baseline behaviour is preserved
|
|
431
|
+
// until the operator explicitly opts in.
|
|
432
|
+
nccSearchMargin2d: 12,
|
|
433
|
+
// V15.0i.1 — default raised to 0.99 per Ram (only apply on near-
|
|
434
|
+
// perfect overlap matches; reject ambiguous matches that snap to
|
|
435
|
+
// wrong patterns on repetitive textures like shelf rails).
|
|
436
|
+
nccConfidenceThreshold2d: 0.99,
|
|
437
|
+
enableNcc2dEmaSmoothing: false,
|
|
438
|
+
ncc2dEmaAlpha: 0.4,
|
|
439
|
+
enableNcc2dPanAxisLock: false,
|
|
440
|
+
ncc2dCrossAxisLockPx: 5,
|
|
441
|
+
// V16 A2 (2026-05-13) — flow-based is now the default. Ram report
|
|
442
|
+
// 2026-05-13 13:05 showed that pose-based on a small latched plane
|
|
443
|
+
// produces "bursts" of accepts on small physical motion: a 0.64 m²
|
|
444
|
+
// plane at 2.7 m perpDist gave 6 accepts in 1 s over 12 cm of
|
|
445
|
+
// translation because the plane-projected polygon covers only a
|
|
446
|
+
// sliver of the frame, hyperinflating newContent. Flow-based
|
|
447
|
+
// measures novelty from real image content (sparse KLT), is
|
|
448
|
+
// plane-independent, and is invariant to plane size. Operators
|
|
449
|
+
// can still flip back to 'pose-based' or 'time-based' in the modal
|
|
450
|
+
// for A/B testing or low-texture scenes. Same defaults shared
|
|
451
|
+
// between pose-based and flow-based (40 % new content per
|
|
452
|
+
// keyframe, ≤ 6 keyframes per capture).
|
|
453
|
+
frameSelectionMode: 'flow-based',
|
|
454
|
+
// 2026-05-15 (U4) — flow-based default novelty 0.40 → 0.20.
|
|
455
|
+
// Accept frames with 20 % new content (was 40 %). More inclusive
|
|
456
|
+
// selection for shelf-pan captures where panning slowly produces
|
|
457
|
+
// gradual content reveal. Operator can still bump via Settings.
|
|
458
|
+
keyframeOverlapThreshold: 0.20,
|
|
459
|
+
keyframeMaxCount: 6,
|
|
460
|
+
// V16 A2 — flow-based mode tuning. Defaults are the values that
|
|
461
|
+
// tested cleanly on iPhone 13 Pro / 14 Pro: 150 corners give a
|
|
462
|
+
// stable median across the frame; quality=0.01 + minDistance=10
|
|
463
|
+
// give spatially-spread, repeatable detection. All three are
|
|
464
|
+
// tunable in the modal under "Flow tuning".
|
|
465
|
+
flowMaxCorners: 150,
|
|
466
|
+
flowQualityLevel: 0.01,
|
|
467
|
+
flowMinDistance: 10,
|
|
468
|
+
// V16 — translation-budget force-accept (Flow strategy only).
|
|
469
|
+
// 2026-05-16 (Issue 4a fix) — default flipped from 0 (disabled) to
|
|
470
|
+
// 25 cm so the "Rotate the camera instead of moving it sideways"
|
|
471
|
+
// warning fires out-of-the-box. Set to 0 in Settings to disable
|
|
472
|
+
// both the warning AND the gate's force-accept on budget crossing.
|
|
473
|
+
// 2026-05-17 (Issue 4-A v2) — raised 25 → 50 cm. The 25-cm budget
|
|
474
|
+
// was too tight given IMU double-integration drift (the
|
|
475
|
+
// accelerometer's noise floor accumulates several cm of bogus
|
|
476
|
+
// "translation" per second even when the phone is held still).
|
|
477
|
+
// Combined with the new `resetAnchor` at handleHoldStart (so drift
|
|
478
|
+
// doesn't compound across captures), 50 cm gives the warning real
|
|
479
|
+
// headroom for genuine sideways motion without false positives.
|
|
480
|
+
flowMaxTranslationCm: 50,
|
|
481
|
+
// V16 — novelty aggregation percentile. 0.85 picks up leading-
|
|
482
|
+
// edge motion sooner than the pre-V16 median (0.50). Operator
|
|
483
|
+
// can dial down toward 0.5 for more-conservative captures or up
|
|
484
|
+
// toward 0.99 for more-aggressive.
|
|
485
|
+
flowNoveltyPercentile: 0.85,
|
|
486
|
+
// V16 — every-Nth-frame eval throttle. 2026-05-15 (U4): default
|
|
487
|
+
// 1 → 5 to reduce per-frame KeyframeGate CPU cost (Shi-Tomasi +
|
|
488
|
+
// calcOpticalFlowPyrLK is ~3-5 ms per ARFrame on Galaxy A35; at
|
|
489
|
+
// 30 fps that's ~15 % CPU on flow alone). Evaluating every 5th
|
|
490
|
+
// frame yields novelty samples at ~6 Hz which is still well above
|
|
491
|
+
// the 1-2 Hz keyframe-accept cadence.
|
|
492
|
+
// matches pre-V16 behaviour). Set higher to cut CPU on long
|
|
493
|
+
// captures at the cost of acceptance latency.
|
|
494
|
+
flowEvalEveryNFrames: 5,
|
|
495
|
+
// V15.0c — sliver tweaks: leading-edge sliver from BOTTOM for typical
|
|
496
|
+
// top-to-bottom pan + full first-frame anchor produced the best
|
|
497
|
+
// outputs in early iteration.
|
|
498
|
+
sliverPosition: 'Bottom',
|
|
499
|
+
firstFrameFullFrame: true,
|
|
500
|
+
maxRecordingMs: 8000,
|
|
501
|
+
framesPerSecond: 3,
|
|
502
|
+
minFrames: 6,
|
|
503
|
+
maxFrames: 16,
|
|
504
|
+
quality: 85,
|
|
505
|
+
|
|
506
|
+
// 2026-05-14 (revised) — capture source defaults to 'ar' (AR-backed
|
|
507
|
+
// is the recommended path; non-AR is the explicit opt-out). Stitch
|
|
508
|
+
// mode stays 'auto' — the auto-resolution heuristic between PANORAMA
|
|
509
|
+
// and SCANS is per-capture, not per-mode, so it's safe to leave on.
|
|
510
|
+
captureSource: 'ar',
|
|
511
|
+
stitchMode: 'auto',
|
|
512
|
+
debug: false,
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
export interface PanoramaSettingsModalProps {
|
|
517
|
+
visible: boolean;
|
|
518
|
+
settings: PanoramaSettings;
|
|
519
|
+
onChange: (next: PanoramaSettings) => void;
|
|
520
|
+
onClose: () => void;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
export function PanoramaSettingsModal({
|
|
525
|
+
visible,
|
|
526
|
+
settings,
|
|
527
|
+
onChange,
|
|
528
|
+
onClose,
|
|
529
|
+
}: PanoramaSettingsModalProps): React.JSX.Element {
|
|
530
|
+
const update = (patch: Partial<PanoramaSettings>) =>
|
|
531
|
+
onChange({ ...settings, ...patch });
|
|
532
|
+
|
|
533
|
+
// V16 Phase 1b — derive the 2-axis (timing × algorithm) UI state
|
|
534
|
+
// from the underlying single `incrementalEngine` field. Storage
|
|
535
|
+
// shape is unchanged; the modal just presents it in two segmented
|
|
536
|
+
// controls so the user's mental model matches the system's actual
|
|
537
|
+
// primary axis (batch vs realtime).
|
|
538
|
+
//
|
|
539
|
+
// Mapping:
|
|
540
|
+
// incrementalEngine === 'batch-keyframe' → timing='batch'
|
|
541
|
+
// incrementalEngine === 'hybrid' → timing='realtime', algo='hybrid'
|
|
542
|
+
// incrementalEngine === 'slitscan-rotate' → timing='realtime', algo='slitscan-rotate'
|
|
543
|
+
// incrementalEngine === 'slitscan-both' → timing='realtime', algo='slitscan-both'
|
|
544
|
+
const timing: 'batch' | 'realtime' =
|
|
545
|
+
settings.incrementalEngine === 'batch-keyframe' ? 'batch' : 'realtime';
|
|
546
|
+
// When in batch mode, remember 'hybrid' as the realtime algorithm
|
|
547
|
+
// the user would land on if they flipped timing back. When already
|
|
548
|
+
// in realtime, the engine field IS the algorithm.
|
|
549
|
+
const realtimeAlgorithm:
|
|
550
|
+
'hybrid' | 'slitscan-rotate' | 'slitscan-both' =
|
|
551
|
+
settings.incrementalEngine === 'batch-keyframe'
|
|
552
|
+
? 'hybrid'
|
|
553
|
+
: settings.incrementalEngine;
|
|
554
|
+
const setTiming = (t: 'batch' | 'realtime') => {
|
|
555
|
+
if (t === 'batch') {
|
|
556
|
+
update({ incrementalEngine: 'batch-keyframe' });
|
|
557
|
+
} else {
|
|
558
|
+
update({ incrementalEngine: realtimeAlgorithm });
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// Frame Selection only makes sense for batch and hybrid engines —
|
|
563
|
+
// slit-scan needs dense input and the gate would starve it.
|
|
564
|
+
const showFrameSelection =
|
|
565
|
+
timing === 'batch' || realtimeAlgorithm === 'hybrid';
|
|
566
|
+
|
|
567
|
+
return (
|
|
568
|
+
<Modal
|
|
569
|
+
visible={visible}
|
|
570
|
+
animationType="slide"
|
|
571
|
+
transparent
|
|
572
|
+
statusBarTranslucent
|
|
573
|
+
onRequestClose={onClose}
|
|
574
|
+
>
|
|
575
|
+
<View style={styles.backdrop}>
|
|
576
|
+
<View style={styles.sheet}>
|
|
577
|
+
<View style={styles.header}>
|
|
578
|
+
<Text style={styles.title}>Panorama settings</Text>
|
|
579
|
+
<Pressable
|
|
580
|
+
onPress={onClose}
|
|
581
|
+
hitSlop={12}
|
|
582
|
+
accessibilityRole="button"
|
|
583
|
+
accessibilityLabel="Close settings"
|
|
584
|
+
style={styles.closeBtn}
|
|
585
|
+
>
|
|
586
|
+
<Text style={styles.closeText}>×</Text>
|
|
587
|
+
</Pressable>
|
|
588
|
+
</View>
|
|
589
|
+
|
|
590
|
+
<ScrollView contentContainerStyle={styles.body}>
|
|
591
|
+
<Text style={styles.debugLine}>
|
|
592
|
+
{`device: physicalMemoryBytes=${_physicalMemoryBytes} `
|
|
593
|
+
+ `(${(_physicalMemoryBytes / (1024 ** 3)).toFixed(2)} GB) · `
|
|
594
|
+
+ `isLowMem=${_isLowMem ? 'yes' : 'no'} · `
|
|
595
|
+
+ `default blender=${_isLowMem ? 'feather' : 'multiband'}`}
|
|
596
|
+
</Text>
|
|
597
|
+
|
|
598
|
+
{/* ──────────────────────────────────────────────
|
|
599
|
+
* 2026-05-14 (revised) — CAPTURE SOURCE picker.
|
|
600
|
+
* Two options after the Galaxy A35 vision-camera
|
|
601
|
+
* CameraCaptureSession crash on pre-mount physical-
|
|
602
|
+
* lens selection: 'ar' (AR-backed, default) and
|
|
603
|
+
* 'non-ar' (vision-camera + lens-switcher chip on
|
|
604
|
+
* the capture screen). Switching to 'non-ar'
|
|
605
|
+
* cascades to disable plane detection, flip frame-
|
|
606
|
+
* selection to flow-based, and turn off useARPreview
|
|
607
|
+
* (see useEffect in AuditCaptureScreen.tsx).
|
|
608
|
+
* ────────────────────────────────────────────── */}
|
|
609
|
+
<SectionHeader title="Capture source" />
|
|
610
|
+
<SegmentedControl
|
|
611
|
+
options={['ar', 'non-ar']}
|
|
612
|
+
value={settings.captureSource}
|
|
613
|
+
onChange={(v) => update({ captureSource: v as PanoramaSettings['captureSource'] })}
|
|
614
|
+
caption="ar (default): ARKit / ARCore — plane detection, pose-aware capture, full AR stack. non-ar: vision-camera only — disables AR plane detection, flips frame-selection to flow-based, runs IMU translation gate. In non-ar mode, the on-screen lens-switcher chip lets you toggle 0.5× / 1× lens during capture (only shown when the device has both lenses)."
|
|
615
|
+
/>
|
|
616
|
+
|
|
617
|
+
{/* 2026-05-14 — Stitch-mode picker. THE 2026-05-14 OOM
|
|
618
|
+
* root cause: cv::Stitcher PANORAMA mode breaks down
|
|
619
|
+
* on translation-heavy input. 'auto' (DEFAULT) routes
|
|
620
|
+
* between PANORAMA and SCANS based on accumulated
|
|
621
|
+
* translation vs rotation magnitudes at finalize time.
|
|
622
|
+
* Lifted to the top of the modal alongside captureSource
|
|
623
|
+
* for the same reason: it's a top-level pipeline
|
|
624
|
+
* decision, not a per-engine tuning. */}
|
|
625
|
+
<SectionHeader title="Stitch mode" />
|
|
626
|
+
<SegmentedControl
|
|
627
|
+
options={['auto', 'panorama', 'scans']}
|
|
628
|
+
value={settings.stitchMode}
|
|
629
|
+
onChange={(v) => update({ stitchMode: v as PanoramaSettings['stitchMode'] })}
|
|
630
|
+
caption="auto (default): pick PANORAMA or SCANS based on translation/rotation totals at finalize. panorama: cv::Stitcher::PANORAMA — rotation-only (spherical warper, BA-ray); best for rotate-in-place captures, BAD on translation. scans: cv::Stitcher::SCANS — affine pipeline (plane warper, BA-affine); best for shelf-pan captures."
|
|
631
|
+
/>
|
|
632
|
+
|
|
633
|
+
{/* ──────────────────────────────────────────────
|
|
634
|
+
* STITCH TIMING — top-level decision. Maps to the
|
|
635
|
+
* `incrementalEngine` storage field via setTiming().
|
|
636
|
+
* ────────────────────────────────────────────── */}
|
|
637
|
+
<SectionHeader title="Stitch timing" />
|
|
638
|
+
<SegmentedControl
|
|
639
|
+
options={['batch', 'realtime']}
|
|
640
|
+
value={timing}
|
|
641
|
+
onChange={(v) => setTiming(v as 'batch' | 'realtime')}
|
|
642
|
+
caption="batch (recommended): full cv::Stitcher pipeline at shutter release. Highest quality. ~1–2 s post-release. realtime: incremental during pan; lower latency, fewer quality stages."
|
|
643
|
+
/>
|
|
644
|
+
|
|
645
|
+
{/* ──────────────────────────────────────────────
|
|
646
|
+
* FRAME SELECTION (V16) — only for batch + hybrid.
|
|
647
|
+
* Slit-scan needs dense input; gate would starve it.
|
|
648
|
+
* ────────────────────────────────────────────── */}
|
|
649
|
+
{showFrameSelection && (
|
|
650
|
+
<>
|
|
651
|
+
<SectionHeader title="Frame selection (V16)" />
|
|
652
|
+
<SegmentedControl
|
|
653
|
+
options={['time-based', 'pose-based', 'flow-based']}
|
|
654
|
+
value={settings.frameSelectionMode}
|
|
655
|
+
onChange={(v) => update({ frameSelectionMode: v as PanoramaSettings['frameSelectionMode'] })}
|
|
656
|
+
caption="flow-based (V16 A2, default): KeyframeGate uses sparse-Lucas-Kanade optical flow on full frame — plane-independent, invariant to plane size. pose-based: plane-polygon overlap (oversensitive on small latched planes). time-based: every ARFrame goes to the engine."
|
|
657
|
+
/>
|
|
658
|
+
{(settings.frameSelectionMode === 'pose-based' ||
|
|
659
|
+
settings.frameSelectionMode === 'flow-based') && (
|
|
660
|
+
<>
|
|
661
|
+
<SectionHeader title="Overlap threshold (new content per keyframe)" />
|
|
662
|
+
<SegmentedControl
|
|
663
|
+
options={['20%', '30%', '40%', '50%', '60%']}
|
|
664
|
+
value={`${Math.round(settings.keyframeOverlapThreshold * 100)}%`}
|
|
665
|
+
onChange={(v) => update({ keyframeOverlapThreshold: parseInt(v, 10) / 100 })}
|
|
666
|
+
caption="Required NEW content per keyframe. 40% (default) ≈ 4–5 keyframes for a 90° pan. Same threshold semantics for both pose-based and flow-based."
|
|
667
|
+
/>
|
|
668
|
+
<SectionHeader title="Max keyframes per capture" />
|
|
669
|
+
<SegmentedControl
|
|
670
|
+
options={['3', '4', '5', '6', '8', '10']}
|
|
671
|
+
value={String(settings.keyframeMaxCount)}
|
|
672
|
+
onChange={(v) => update({ keyframeMaxCount: parseInt(v, 10) })}
|
|
673
|
+
caption="Hard cap. 6 (default) matches Samsung's behaviour. Once reached, host auto-finalizes."
|
|
674
|
+
/>
|
|
675
|
+
</>
|
|
676
|
+
)}
|
|
677
|
+
{settings.frameSelectionMode === 'flow-based' && (
|
|
678
|
+
<>
|
|
679
|
+
<SectionHeader title="Flow tuning — max corners" />
|
|
680
|
+
<SegmentedControl
|
|
681
|
+
options={['50', '100', '150', '200', '300']}
|
|
682
|
+
value={String(settings.flowMaxCorners)}
|
|
683
|
+
onChange={(v) => update({ flowMaxCorners: parseInt(v, 10) })}
|
|
684
|
+
caption="Max Shi-Tomasi corners detected per accepted keyframe. More = more robust median, slower detect. 150 = default."
|
|
685
|
+
/>
|
|
686
|
+
<SectionHeader title="Flow tuning — quality level" />
|
|
687
|
+
<SegmentedControl
|
|
688
|
+
options={['0.005', '0.01', '0.02', '0.03', '0.05']}
|
|
689
|
+
value={String(settings.flowQualityLevel)}
|
|
690
|
+
onChange={(v) => update({ flowQualityLevel: parseFloat(v) })}
|
|
691
|
+
caption="Shi-Tomasi corner quality threshold. Lower = more (weaker) corners; higher = fewer (stronger) corners. 0.01 = default."
|
|
692
|
+
/>
|
|
693
|
+
<SectionHeader title="Flow tuning — min distance" />
|
|
694
|
+
<SegmentedControl
|
|
695
|
+
options={['5', '8', '10', '15', '20']}
|
|
696
|
+
value={String(settings.flowMinDistance)}
|
|
697
|
+
onChange={(v) => update({ flowMinDistance: parseInt(v, 10) })}
|
|
698
|
+
caption="Min pixel distance between detected corners (working resolution = 720 px longest side). Higher = more spatially-spread features. 10 = default."
|
|
699
|
+
/>
|
|
700
|
+
<SectionHeader title="Flow tuning — translation budget (cm)" />
|
|
701
|
+
<SegmentedControl
|
|
702
|
+
options={['0', '5', '8', '12', '20', '50']}
|
|
703
|
+
value={String(settings.flowMaxTranslationCm)}
|
|
704
|
+
onChange={(v) => update({ flowMaxTranslationCm: parseInt(v, 10) })}
|
|
705
|
+
caption="Force-accept when camera has moved this many cm since last keyframe, even if novelty < overlap threshold. Bounds parallax so the stitcher can match. 0 = disabled (default). 8 = recommended starting value."
|
|
706
|
+
/>
|
|
707
|
+
<SectionHeader title="Flow tuning — novelty percentile" />
|
|
708
|
+
<SegmentedControl
|
|
709
|
+
options={['0.50', '0.70', '0.85', '0.95', '0.99']}
|
|
710
|
+
value={settings.flowNoveltyPercentile.toFixed(2)}
|
|
711
|
+
onChange={(v) => update({ flowNoveltyPercentile: parseFloat(v) })}
|
|
712
|
+
caption="How tracked-feature displacements are aggregated into novelty. 0.50 = pre-V16 median behaviour (conservative). 0.85 = picks up leading-edge motion sooner (default, matches user perception). 0.99 = near-max, very aggressive."
|
|
713
|
+
/>
|
|
714
|
+
<SectionHeader title="Flow tuning — eval every N frames" />
|
|
715
|
+
<SegmentedControl
|
|
716
|
+
options={['1', '2', '3', '5', '10']}
|
|
717
|
+
value={String(settings.flowEvalEveryNFrames)}
|
|
718
|
+
onChange={(v) => update({ flowEvalEveryNFrames: parseInt(v, 10) })}
|
|
719
|
+
caption="Throttle gate evaluation to every Nth AR frame. 1 = every frame (default, no throttle). 3-5 = noticeable CPU/battery savings on long captures, up to N-1 frames of acceptance latency."
|
|
720
|
+
/>
|
|
721
|
+
</>
|
|
722
|
+
)}
|
|
723
|
+
</>
|
|
724
|
+
)}
|
|
725
|
+
|
|
726
|
+
{/* ──────────────────────────────────────────────
|
|
727
|
+
* AR PLANE PROJECTION — used by KeyframeGate's overlap
|
|
728
|
+
* calculation, slit-scan plane-projection, and (future)
|
|
729
|
+
* pose-driven batch. Sub-fields reveal based on source.
|
|
730
|
+
* ────────────────────────────────────────────── */}
|
|
731
|
+
<SectionHeader title="AR plane projection" />
|
|
732
|
+
<SegmentedControl
|
|
733
|
+
options={['Disabled', 'ARKitDetected', 'Virtual']}
|
|
734
|
+
value={settings.planeSource}
|
|
735
|
+
onChange={(v) => update({ planeSource: v as PanoramaSettings['planeSource'] })}
|
|
736
|
+
caption="Disabled: no plane (gate falls back to angular delta). ARKitDetected: latch ARKit's vertical plane (best fidelity, picky). Virtual: synthesise plane perpendicular to camera at a fixed depth (always works)."
|
|
737
|
+
/>
|
|
738
|
+
{settings.planeSource === 'ARKitDetected' && (
|
|
739
|
+
<>
|
|
740
|
+
<SectionHeader title="ARKit alignment threshold" />
|
|
741
|
+
<SegmentedControl
|
|
742
|
+
options={['0.3', '0.5', '0.6', '0.7', '0.85']}
|
|
743
|
+
value={settings.arkitPlaneAlignmentThreshold.toFixed(2)}
|
|
744
|
+
onChange={(v) => update({ arkitPlaneAlignmentThreshold: parseFloat(v) })}
|
|
745
|
+
caption="Min dot product between candidate plane normal and camera facing. 0.6 (default) = ~53° max angle off-camera. Higher = stricter."
|
|
746
|
+
/>
|
|
747
|
+
</>
|
|
748
|
+
)}
|
|
749
|
+
{settings.planeSource === 'Virtual' && (
|
|
750
|
+
<>
|
|
751
|
+
<SectionHeader title="Virtual plane depth" />
|
|
752
|
+
<SegmentedControl
|
|
753
|
+
options={['0.5m', '1.0m', '1.5m', '2.0m', '3.0m']}
|
|
754
|
+
value={`${settings.virtualPlaneDepthMeters.toFixed(1)}m`}
|
|
755
|
+
onChange={(v) => update({ virtualPlaneDepthMeters: parseFloat(v) })}
|
|
756
|
+
caption="Synthetic plane depth at first frame. Set to your typical scan distance."
|
|
757
|
+
/>
|
|
758
|
+
</>
|
|
759
|
+
)}
|
|
760
|
+
{settings.planeSource !== 'Disabled' && (
|
|
761
|
+
<>
|
|
762
|
+
<SectionHeader title="Plane projection style" />
|
|
763
|
+
<SegmentedControl
|
|
764
|
+
options={['Rectified', 'Trapezoidal']}
|
|
765
|
+
value={settings.planeProjectionStyle}
|
|
766
|
+
onChange={(v) => update({ planeProjectionStyle: v as PanoramaSettings['planeProjectionStyle'] })}
|
|
767
|
+
caption="Rectified (default): clean rectangle paste, no tilt distortion. Trapezoidal: V15.0b legacy 3D-correct raycast — geometric purity at the cost of tilt artifacts."
|
|
768
|
+
/>
|
|
769
|
+
</>
|
|
770
|
+
)}
|
|
771
|
+
|
|
772
|
+
{/* ──────────────────────────────────────────────
|
|
773
|
+
* ALGORITHM — what runs at stitch time. In batch mode
|
|
774
|
+
* there's no choice (cv::Stitcher feature-matched
|
|
775
|
+
* pipeline). In realtime, three live engines.
|
|
776
|
+
* ────────────────────────────────────────────── */}
|
|
777
|
+
<SectionHeader title="Algorithm" />
|
|
778
|
+
{timing === 'batch' ? (
|
|
779
|
+
<View style={styles.infoBox}>
|
|
780
|
+
<Text style={styles.infoText}>
|
|
781
|
+
Full feature-matched pipeline:
|
|
782
|
+
ORB → BFMatcher → RANSAC → BundleAdjusterRay →
|
|
783
|
+
waveCorrect → Warper → GraphCutSeamFinder →
|
|
784
|
+
ExposureCompensator → MultiBandBlender. No engine
|
|
785
|
+
choice in batch mode.
|
|
786
|
+
</Text>
|
|
787
|
+
</View>
|
|
788
|
+
) : (
|
|
789
|
+
<SegmentedControl
|
|
790
|
+
options={['hybrid', 'slitscan-rotate', 'slitscan-both']}
|
|
791
|
+
value={realtimeAlgorithm}
|
|
792
|
+
onChange={(v) => update({ incrementalEngine: v as PanoramaSettings['incrementalEngine'] })}
|
|
793
|
+
caption="hybrid: streaming planar projection + feature matching. slitscan-rotate: V13.0a + 1D NCC. slitscan-both: V13.0a + no accept gate + feather blend (iteration playground)."
|
|
794
|
+
/>
|
|
795
|
+
)}
|
|
796
|
+
|
|
797
|
+
{/* ──────────────────────────────────────────────
|
|
798
|
+
* ALGORITHM TUNING — engine-specific knobs revealed
|
|
799
|
+
* by current Algorithm choice.
|
|
800
|
+
* ────────────────────────────────────────────── */}
|
|
801
|
+
{timing === 'batch' && (
|
|
802
|
+
<>
|
|
803
|
+
{/* Capture source + stitch mode were lifted to the
|
|
804
|
+
* TOP of the modal (above the timing picker) on
|
|
805
|
+
* 2026-05-14 since they're pipeline-level decisions,
|
|
806
|
+
* not batch-tuning knobs. See the top of the
|
|
807
|
+
* ScrollView for those controls. */}
|
|
808
|
+
<SectionHeader title="Batch tuning — Warper" />
|
|
809
|
+
<SegmentedControl
|
|
810
|
+
options={['plane', 'cylindrical', 'spherical']}
|
|
811
|
+
value={settings.warperType}
|
|
812
|
+
onChange={(v) => update({ warperType: v as PanoramaSettings['warperType'] })}
|
|
813
|
+
caption="plane (default, recommended for retail shelves): flat rectangular output. cylindrical: rotational mid-arc, gentle curvature. spherical: wide pans (180°+) but always-curved."
|
|
814
|
+
/>
|
|
815
|
+
<SectionHeader title="Batch tuning — Blender" />
|
|
816
|
+
<SegmentedControl
|
|
817
|
+
options={['multiband', 'feather']}
|
|
818
|
+
value={settings.blenderType}
|
|
819
|
+
onChange={(v) => update({ blenderType: v as PanoramaSettings['blenderType'] })}
|
|
820
|
+
caption="multiband (default): Laplacian-pyramid blending; cleanest seams. feather: faster, no halo when exposure varies."
|
|
821
|
+
/>
|
|
822
|
+
<SectionHeader title="Batch tuning — Seam finder" />
|
|
823
|
+
<SegmentedControl
|
|
824
|
+
options={['graphcut', 'skip']}
|
|
825
|
+
value={settings.seamFinderType}
|
|
826
|
+
onChange={(v) => update({ seamFinderType: v as PanoramaSettings['seamFinderType'] })}
|
|
827
|
+
caption="graphcut (default): cv::detail::GraphCutSeamFinder; optimal seams, pairs with multiband, holds all warps in memory. skip: stream warp+feed (lower peak memory)."
|
|
828
|
+
/>
|
|
829
|
+
<SectionHeader title="Batch tuning — Inscribed-rect crop" />
|
|
830
|
+
<SegmentedControl
|
|
831
|
+
options={['off', 'on']}
|
|
832
|
+
value={settings.enableMaxInscribedRectCrop ? 'on' : 'off'}
|
|
833
|
+
onChange={(v) => update({ enableMaxInscribedRectCrop: v === 'on' })}
|
|
834
|
+
caption="off (default): final crop is just cv::boundingRect of non-black pixels — preserves all stitched content; may have black corners. on: additionally run MaxInscribedRectFromMask + column-projection second-pass for a clean-cornered rectangle — can shrink the output if the panorama mask is lopsided. A/B against the bbox crop on real scenes."
|
|
835
|
+
/>
|
|
836
|
+
</>
|
|
837
|
+
)}
|
|
838
|
+
{timing === 'realtime' && realtimeAlgorithm === 'hybrid' && (
|
|
839
|
+
<>
|
|
840
|
+
<SectionHeader title="Hybrid tuning — Projection" />
|
|
841
|
+
<SegmentedControl
|
|
842
|
+
options={['Planar', 'Cylindrical']}
|
|
843
|
+
value={settings.hybridProjection}
|
|
844
|
+
onChange={(v) => update({ hybridProjection: v as PanoramaSettings['hybridProjection'] })}
|
|
845
|
+
caption="Planar (default): cv::detail::PlaneWarper. Cylindrical: V12.x – V14.0a behaviour (legacy)."
|
|
846
|
+
/>
|
|
847
|
+
</>
|
|
848
|
+
)}
|
|
849
|
+
{timing === 'realtime' && realtimeAlgorithm.startsWith('slitscan') && (
|
|
850
|
+
<>
|
|
851
|
+
<SectionHeader title="Slit-scan tuning — Slit width" />
|
|
852
|
+
<SegmentedControl
|
|
853
|
+
options={['0.01', '0.05', '0.10', '0.20', '0.30', '0.50']}
|
|
854
|
+
value={settings.slitWidthFraction.toFixed(2)}
|
|
855
|
+
onChange={(v) => update({ slitWidthFraction: parseFloat(v) })}
|
|
856
|
+
caption="Fraction of pan-axis retained per sliver. 0.30 (V15 default) ≈ 324 px. Smaller = less within-slit depth disagreement."
|
|
857
|
+
/>
|
|
858
|
+
<SectionHeader title="Slit-scan tuning — Sliver position" />
|
|
859
|
+
<SegmentedControl
|
|
860
|
+
options={['Center', 'Bottom', 'Top']}
|
|
861
|
+
value={settings.sliverPosition}
|
|
862
|
+
onChange={(v) => update({ sliverPosition: v as PanoramaSettings['sliverPosition'] })}
|
|
863
|
+
caption="Where on the camera sensor frame the sliver is taken."
|
|
864
|
+
/>
|
|
865
|
+
<SectionHeader title="Slit-scan tuning — Full first-frame" />
|
|
866
|
+
<SegmentedControl
|
|
867
|
+
options={['off', 'on']}
|
|
868
|
+
value={settings.firstFrameFullFrame ? 'on' : 'off'}
|
|
869
|
+
onChange={(v) => update({ firstFrameFullFrame: v === 'on' })}
|
|
870
|
+
caption="ON: first accepted frame paints the full camera frame at the canvas anchor; subsequent frames use sliver clip."
|
|
871
|
+
/>
|
|
872
|
+
<SectionHeader title="Slit-scan tuning — Paint mode" />
|
|
873
|
+
<SegmentedControl
|
|
874
|
+
options={['FirstPaintedWins', 'FeatherBlend']}
|
|
875
|
+
value={settings.paintMode}
|
|
876
|
+
onChange={(v) => update({ paintMode: v as PanoramaSettings['paintMode'] })}
|
|
877
|
+
caption="FirstPaintedWins (default): protect already-painted pixels. FeatherBlend: alpha-blend new content into overlap."
|
|
878
|
+
/>
|
|
879
|
+
</>
|
|
880
|
+
)}
|
|
881
|
+
|
|
882
|
+
{/* ──────────────────────────────────────────────
|
|
883
|
+
* ADVANCED — 2D NCC fine-alignment (closed by default).
|
|
884
|
+
* Used by slit-scan plane mode and any 2D NCC stage.
|
|
885
|
+
* ────────────────────────────────────────────── */}
|
|
886
|
+
<Accordion title="Advanced — 2D NCC fine-alignment" badge="advanced">
|
|
887
|
+
<SectionHeader title="Enable 2D NCC" />
|
|
888
|
+
<SegmentedControl
|
|
889
|
+
options={['off', 'on']}
|
|
890
|
+
value={settings.enable2dNcc ? 'on' : 'off'}
|
|
891
|
+
onChange={(v) => update({ enable2dNcc: v === 'on' })}
|
|
892
|
+
caption="V13.0g 2D NCC fine-alignment after pose-driven projection. Refines (Δx, Δy) translation via cv::matchTemplate."
|
|
893
|
+
/>
|
|
894
|
+
{settings.enable2dNcc && (
|
|
895
|
+
<>
|
|
896
|
+
<SectionHeader title="Confidence threshold" />
|
|
897
|
+
<SegmentedControl
|
|
898
|
+
options={['0.50', '0.65', '0.75', '0.85', '0.95', '0.99']}
|
|
899
|
+
value={settings.nccConfidenceThreshold2d.toFixed(2)}
|
|
900
|
+
onChange={(v) => update({ nccConfidenceThreshold2d: parseFloat(v) })}
|
|
901
|
+
caption="Reject NCC corrections below this confidence. 0.99 = only apply on near-perfect overlap."
|
|
902
|
+
/>
|
|
903
|
+
<SectionHeader title="Search half-window (px)" />
|
|
904
|
+
<SegmentedControl
|
|
905
|
+
options={['6', '10', '12', '20', '30']}
|
|
906
|
+
value={String(settings.nccSearchMargin2d)}
|
|
907
|
+
onChange={(v) => update({ nccSearchMargin2d: parseInt(v, 10) })}
|
|
908
|
+
caption="Pixels: 2D NCC searches ±this around the pose-predicted match."
|
|
909
|
+
/>
|
|
910
|
+
<SectionHeader title="EMA smoothing" />
|
|
911
|
+
<SegmentedControl
|
|
912
|
+
options={['off', 'on']}
|
|
913
|
+
value={settings.enableNcc2dEmaSmoothing ? 'on' : 'off'}
|
|
914
|
+
onChange={(v) => update({ enableNcc2dEmaSmoothing: v === 'on' })}
|
|
915
|
+
caption="Damp single-frame snaps to spurious peaks via EMA."
|
|
916
|
+
/>
|
|
917
|
+
{settings.enableNcc2dEmaSmoothing && (
|
|
918
|
+
<>
|
|
919
|
+
<SectionHeader title="EMA alpha (current-frame weight)" />
|
|
920
|
+
<SegmentedControl
|
|
921
|
+
options={['0.20', '0.30', '0.40', '0.60', '0.80']}
|
|
922
|
+
value={settings.ncc2dEmaAlpha.toFixed(2)}
|
|
923
|
+
onChange={(v) => update({ ncc2dEmaAlpha: parseFloat(v) })}
|
|
924
|
+
/>
|
|
925
|
+
</>
|
|
926
|
+
)}
|
|
927
|
+
<SectionHeader title="Pan-axis lock" />
|
|
928
|
+
<SegmentedControl
|
|
929
|
+
options={['off', 'on']}
|
|
930
|
+
value={settings.enableNcc2dPanAxisLock ? 'on' : 'off'}
|
|
931
|
+
onChange={(v) => update({ enableNcc2dPanAxisLock: v === 'on' })}
|
|
932
|
+
caption="Clamp cross-axis correction tighter than pan-axis (pose + 1D NCC handle cross-axis already)."
|
|
933
|
+
/>
|
|
934
|
+
{settings.enableNcc2dPanAxisLock && (
|
|
935
|
+
<>
|
|
936
|
+
<SectionHeader title="Cross-axis clamp (px)" />
|
|
937
|
+
<SegmentedControl
|
|
938
|
+
options={['2', '5', '10', '15']}
|
|
939
|
+
value={String(settings.ncc2dCrossAxisLockPx)}
|
|
940
|
+
onChange={(v) => update({ ncc2dCrossAxisLockPx: parseInt(v, 10) })}
|
|
941
|
+
/>
|
|
942
|
+
</>
|
|
943
|
+
)}
|
|
944
|
+
</>
|
|
945
|
+
)}
|
|
946
|
+
</Accordion>
|
|
947
|
+
|
|
948
|
+
{/* ──────────────────────────────────────────────
|
|
949
|
+
* ADVANCED — Slit-scan experimental. Only relevant
|
|
950
|
+
* when slitscan-both is the active engine.
|
|
951
|
+
* ────────────────────────────────────────────── */}
|
|
952
|
+
{timing === 'realtime' && realtimeAlgorithm === 'slitscan-both' && (
|
|
953
|
+
<Accordion title="Advanced — Slit-scan experimental" badge="experimental">
|
|
954
|
+
<SectionHeader title="Triangulation parallax" />
|
|
955
|
+
<SegmentedControl
|
|
956
|
+
options={['off', 'on']}
|
|
957
|
+
value={settings.enableTriangulation ? 'on' : 'off'}
|
|
958
|
+
onChange={(v) => update({ enableTriangulation: v === 'on' })}
|
|
959
|
+
caption="V13.0e ORB triangulation + median-Z parallax correction. Adds ~10ms/accept."
|
|
960
|
+
/>
|
|
961
|
+
<SectionHeader title="RANSAC homography" />
|
|
962
|
+
<SegmentedControl
|
|
963
|
+
options={['off', 'on']}
|
|
964
|
+
value={settings.enableRansacHomography ? 'on' : 'off'}
|
|
965
|
+
onChange={(v) => update({ enableRansacHomography: v === 'on' })}
|
|
966
|
+
caption="V14.0a RANSAC homography per slit + cv::warpPerspective. Known limitation: can absorb pan as scale, leaving gaps."
|
|
967
|
+
/>
|
|
968
|
+
<SectionHeader title="Accept gate (px)" />
|
|
969
|
+
<SegmentedControl
|
|
970
|
+
options={['0', '50']}
|
|
971
|
+
value={String(settings.acceptGate)}
|
|
972
|
+
onChange={(v) => update({ acceptGate: parseInt(v, 10) as PanoramaSettings['acceptGate'] })}
|
|
973
|
+
caption="0 = accept on every frame (Apple-dense). 50 = V13.0g throttle."
|
|
974
|
+
/>
|
|
975
|
+
</Accordion>
|
|
976
|
+
)}
|
|
977
|
+
|
|
978
|
+
{/* ──────────────────────────────────────────────
|
|
979
|
+
* OUTPUT — always visible.
|
|
980
|
+
* ────────────────────────────────────────────── */}
|
|
981
|
+
<SectionHeader title="Recording cap" />
|
|
982
|
+
<SegmentedControl
|
|
983
|
+
options={['4 s', '6 s', '8 s', '10 s']}
|
|
984
|
+
value={`${Math.round(settings.maxRecordingMs / 1000)} s`}
|
|
985
|
+
onChange={(v) => update({ maxRecordingMs: parseInt(v, 10) * 1000 })}
|
|
986
|
+
caption="Auto-stops the hold-recording at this duration."
|
|
987
|
+
/>
|
|
988
|
+
<SectionHeader title="JPEG quality" />
|
|
989
|
+
<SegmentedControl
|
|
990
|
+
options={['70', '85', '92']}
|
|
991
|
+
value={String(settings.quality)}
|
|
992
|
+
onChange={(v) => update({ quality: parseInt(v, 10) })}
|
|
993
|
+
caption="Higher = bigger files, sharper detail. 85 is the recommended default."
|
|
994
|
+
/>
|
|
995
|
+
|
|
996
|
+
{/* ──────────────────────────────────────────────
|
|
997
|
+
* DEBUG — surfaces stitch telemetry to the operator on
|
|
998
|
+
* every successful finalize. See PanoramaSettings.debug.
|
|
999
|
+
* ────────────────────────────────────────────── */}
|
|
1000
|
+
<SectionHeader title="Debug" />
|
|
1001
|
+
<SegmentedControl
|
|
1002
|
+
options={['off', 'on']}
|
|
1003
|
+
value={settings.debug ? 'on' : 'off'}
|
|
1004
|
+
onChange={(v) => update({ debug: v === 'on' })}
|
|
1005
|
+
caption="When ON, every successful stitch shows a popup with the C+D progressive-confidence retry telemetry (frames included, final threshold, retry attempts). Off by default."
|
|
1006
|
+
/>
|
|
1007
|
+
|
|
1008
|
+
{/* ──────────────────────────────────────────────
|
|
1009
|
+
* DIAGNOSTICS / FALLBACKS — closed by default. AR is
|
|
1010
|
+
* the active path for 99% of use; the vision-camera
|
|
1011
|
+
* fallback path lives here for emergencies.
|
|
1012
|
+
* ────────────────────────────────────────────── */}
|
|
1013
|
+
<Accordion title="Diagnostics / fallbacks" badge="rarely needed">
|
|
1014
|
+
<View style={styles.infoBox}>
|
|
1015
|
+
<Text style={styles.infoText}>
|
|
1016
|
+
AR-backed capture is the recommended path. Toggle off
|
|
1017
|
+
ONLY if ARKit fails on a specific device (very rare on
|
|
1018
|
+
modern iPhones). Doing so falls back to vision-camera
|
|
1019
|
+
video recording + post-stitch via cv::Stitcher.
|
|
1020
|
+
</Text>
|
|
1021
|
+
</View>
|
|
1022
|
+
<SectionHeader title="AR-backed capture" />
|
|
1023
|
+
<SegmentedControl
|
|
1024
|
+
options={['on', 'off']}
|
|
1025
|
+
value={settings.useARPreview ? 'on' : 'off'}
|
|
1026
|
+
onChange={(v) => update({ useARPreview: v === 'on' })}
|
|
1027
|
+
caption="Default ON. OFF only when ARKit is unavailable or for A/B testing."
|
|
1028
|
+
/>
|
|
1029
|
+
{!settings.useARPreview && (
|
|
1030
|
+
<>
|
|
1031
|
+
<SectionHeader title="Frame extraction — Frames per second" />
|
|
1032
|
+
<SegmentedControl
|
|
1033
|
+
options={['2', '3', '4']}
|
|
1034
|
+
value={String(settings.framesPerSecond)}
|
|
1035
|
+
onChange={(v) => update({ framesPerSecond: parseInt(v, 10) })}
|
|
1036
|
+
caption="Frames/sec extracted from the recorded video. Lower = faster but riskier overlap."
|
|
1037
|
+
/>
|
|
1038
|
+
<SectionHeader title="Frame extraction — Frame count clamp" />
|
|
1039
|
+
<SegmentedControl
|
|
1040
|
+
options={['4-12', '6-16', '8-20']}
|
|
1041
|
+
value={`${settings.minFrames}-${settings.maxFrames}`}
|
|
1042
|
+
onChange={(v) => {
|
|
1043
|
+
const [min, max] = v.split('-').map((n) => parseInt(n, 10));
|
|
1044
|
+
update({ minFrames: min, maxFrames: max });
|
|
1045
|
+
}}
|
|
1046
|
+
caption="Floor/ceiling for extracted frames."
|
|
1047
|
+
/>
|
|
1048
|
+
</>
|
|
1049
|
+
)}
|
|
1050
|
+
</Accordion>
|
|
1051
|
+
|
|
1052
|
+
<Pressable
|
|
1053
|
+
onPress={() => onChange(DEFAULT_PANORAMA_SETTINGS)}
|
|
1054
|
+
style={styles.resetBtn}
|
|
1055
|
+
accessibilityRole="button"
|
|
1056
|
+
accessibilityLabel="Reset to defaults"
|
|
1057
|
+
>
|
|
1058
|
+
<Text style={styles.resetText}>Reset to defaults</Text>
|
|
1059
|
+
</Pressable>
|
|
1060
|
+
</ScrollView>
|
|
1061
|
+
</View>
|
|
1062
|
+
</View>
|
|
1063
|
+
</Modal>
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
function SectionHeader({ title }: { title: string }) {
|
|
1069
|
+
return <Text style={styles.sectionHeader}>{title}</Text>;
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Collapsible section. Used for closed-by-default groupings
|
|
1075
|
+
* ("Advanced", "Diagnostics / fallbacks") so the modal's primary
|
|
1076
|
+
* surface stays focused on the controls operators actually touch
|
|
1077
|
+
* day-to-day.
|
|
1078
|
+
*
|
|
1079
|
+
* State is local — each Accordion instance manages its own open
|
|
1080
|
+
* flag. The modal opens fresh-collapsed every mount which is what
|
|
1081
|
+
* we want for now; persisting open state across mounts (e.g. via
|
|
1082
|
+
* AsyncStorage) is a future enhancement.
|
|
1083
|
+
*/
|
|
1084
|
+
function Accordion({
|
|
1085
|
+
title,
|
|
1086
|
+
initiallyOpen = false,
|
|
1087
|
+
badge,
|
|
1088
|
+
children,
|
|
1089
|
+
}: {
|
|
1090
|
+
title: string;
|
|
1091
|
+
initiallyOpen?: boolean;
|
|
1092
|
+
badge?: string;
|
|
1093
|
+
children: React.ReactNode;
|
|
1094
|
+
}): React.JSX.Element {
|
|
1095
|
+
const [open, setOpen] = useState(initiallyOpen);
|
|
1096
|
+
return (
|
|
1097
|
+
<View style={styles.accordion}>
|
|
1098
|
+
<Pressable
|
|
1099
|
+
onPress={() => setOpen((v) => !v)}
|
|
1100
|
+
style={styles.accordionHeader}
|
|
1101
|
+
accessibilityRole="button"
|
|
1102
|
+
accessibilityState={{ expanded: open }}
|
|
1103
|
+
accessibilityLabel={`${title}, ${open ? 'expanded' : 'collapsed'}`}
|
|
1104
|
+
>
|
|
1105
|
+
<Text style={styles.accordionChevron}>{open ? '▼' : '▶'}</Text>
|
|
1106
|
+
<Text style={styles.accordionTitle}>{title}</Text>
|
|
1107
|
+
{badge ? <Tag label={badge} /> : null}
|
|
1108
|
+
</Pressable>
|
|
1109
|
+
{open ? <View style={styles.accordionBody}>{children}</View> : null}
|
|
1110
|
+
</View>
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
|
|
1115
|
+
/**
|
|
1116
|
+
* Small grey-text badge. Marks sections / fields as "advanced",
|
|
1117
|
+
* "experimental", "legacy", or similar — quick visual signal that
|
|
1118
|
+
* the operator can usually ignore them.
|
|
1119
|
+
*/
|
|
1120
|
+
function Tag({ label }: { label: string }): React.JSX.Element {
|
|
1121
|
+
return (
|
|
1122
|
+
<View style={styles.tag}>
|
|
1123
|
+
<Text style={styles.tagText}>{label}</Text>
|
|
1124
|
+
</View>
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
function SegmentedControl({
|
|
1130
|
+
options,
|
|
1131
|
+
value,
|
|
1132
|
+
onChange,
|
|
1133
|
+
caption,
|
|
1134
|
+
}: {
|
|
1135
|
+
options: string[];
|
|
1136
|
+
value: string;
|
|
1137
|
+
onChange: (next: string) => void;
|
|
1138
|
+
caption?: string;
|
|
1139
|
+
}) {
|
|
1140
|
+
return (
|
|
1141
|
+
<View>
|
|
1142
|
+
<View style={styles.segmentedRow}>
|
|
1143
|
+
{options.map((opt) => {
|
|
1144
|
+
const selected = opt === value;
|
|
1145
|
+
return (
|
|
1146
|
+
<Pressable
|
|
1147
|
+
key={opt}
|
|
1148
|
+
onPress={() => onChange(opt)}
|
|
1149
|
+
style={[
|
|
1150
|
+
styles.segment,
|
|
1151
|
+
selected && styles.segmentSelected,
|
|
1152
|
+
]}
|
|
1153
|
+
accessibilityRole="button"
|
|
1154
|
+
accessibilityState={{ selected }}
|
|
1155
|
+
accessibilityLabel={`${opt}${selected ? ' (selected)' : ''}`}
|
|
1156
|
+
>
|
|
1157
|
+
<Text
|
|
1158
|
+
style={[
|
|
1159
|
+
styles.segmentText,
|
|
1160
|
+
selected && styles.segmentTextSelected,
|
|
1161
|
+
]}
|
|
1162
|
+
>
|
|
1163
|
+
{opt}
|
|
1164
|
+
</Text>
|
|
1165
|
+
</Pressable>
|
|
1166
|
+
);
|
|
1167
|
+
})}
|
|
1168
|
+
</View>
|
|
1169
|
+
{caption ? <Text style={styles.caption}>{caption}</Text> : null}
|
|
1170
|
+
</View>
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
|
|
1175
|
+
const styles = StyleSheet.create({
|
|
1176
|
+
backdrop: {
|
|
1177
|
+
flex: 1,
|
|
1178
|
+
backgroundColor: 'rgba(0,0,0,0.55)',
|
|
1179
|
+
justifyContent: 'flex-end',
|
|
1180
|
+
},
|
|
1181
|
+
sheet: {
|
|
1182
|
+
backgroundColor: '#1c1c1e',
|
|
1183
|
+
borderTopLeftRadius: 16,
|
|
1184
|
+
borderTopRightRadius: 16,
|
|
1185
|
+
paddingBottom: 32,
|
|
1186
|
+
maxHeight: '88%',
|
|
1187
|
+
},
|
|
1188
|
+
header: {
|
|
1189
|
+
flexDirection: 'row',
|
|
1190
|
+
alignItems: 'center',
|
|
1191
|
+
justifyContent: 'space-between',
|
|
1192
|
+
paddingHorizontal: 20,
|
|
1193
|
+
paddingVertical: 16,
|
|
1194
|
+
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
1195
|
+
borderBottomColor: 'rgba(255,255,255,0.15)',
|
|
1196
|
+
},
|
|
1197
|
+
title: {
|
|
1198
|
+
color: '#ffffff',
|
|
1199
|
+
fontSize: 18,
|
|
1200
|
+
fontWeight: '600',
|
|
1201
|
+
},
|
|
1202
|
+
closeBtn: {
|
|
1203
|
+
width: 36,
|
|
1204
|
+
height: 36,
|
|
1205
|
+
borderRadius: 18,
|
|
1206
|
+
backgroundColor: 'rgba(255,255,255,0.12)',
|
|
1207
|
+
alignItems: 'center',
|
|
1208
|
+
justifyContent: 'center',
|
|
1209
|
+
},
|
|
1210
|
+
closeText: {
|
|
1211
|
+
color: '#ffffff',
|
|
1212
|
+
fontSize: 22,
|
|
1213
|
+
fontWeight: '300',
|
|
1214
|
+
lineHeight: 24,
|
|
1215
|
+
},
|
|
1216
|
+
body: {
|
|
1217
|
+
paddingHorizontal: 20,
|
|
1218
|
+
paddingTop: 12,
|
|
1219
|
+
},
|
|
1220
|
+
sectionHeader: {
|
|
1221
|
+
color: '#ffffff',
|
|
1222
|
+
opacity: 0.85,
|
|
1223
|
+
fontSize: 13,
|
|
1224
|
+
fontWeight: '600',
|
|
1225
|
+
textTransform: 'uppercase',
|
|
1226
|
+
letterSpacing: 0.5,
|
|
1227
|
+
marginTop: 18,
|
|
1228
|
+
marginBottom: 8,
|
|
1229
|
+
},
|
|
1230
|
+
row: {
|
|
1231
|
+
marginTop: 4,
|
|
1232
|
+
},
|
|
1233
|
+
label: {
|
|
1234
|
+
color: '#ffffff',
|
|
1235
|
+
opacity: 0.85,
|
|
1236
|
+
fontSize: 13,
|
|
1237
|
+
fontWeight: '600',
|
|
1238
|
+
textTransform: 'uppercase',
|
|
1239
|
+
letterSpacing: 0.5,
|
|
1240
|
+
marginTop: 18,
|
|
1241
|
+
marginBottom: 8,
|
|
1242
|
+
},
|
|
1243
|
+
segmentedRow: {
|
|
1244
|
+
flexDirection: 'row',
|
|
1245
|
+
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
1246
|
+
borderRadius: 10,
|
|
1247
|
+
padding: 4,
|
|
1248
|
+
gap: 4,
|
|
1249
|
+
},
|
|
1250
|
+
segment: {
|
|
1251
|
+
flex: 1,
|
|
1252
|
+
paddingVertical: 10,
|
|
1253
|
+
paddingHorizontal: 8,
|
|
1254
|
+
borderRadius: 7,
|
|
1255
|
+
alignItems: 'center',
|
|
1256
|
+
justifyContent: 'center',
|
|
1257
|
+
},
|
|
1258
|
+
segmentSelected: {
|
|
1259
|
+
backgroundColor: '#ffffff',
|
|
1260
|
+
},
|
|
1261
|
+
segmentText: {
|
|
1262
|
+
color: '#ffffff',
|
|
1263
|
+
fontSize: 13,
|
|
1264
|
+
fontWeight: '500',
|
|
1265
|
+
opacity: 0.85,
|
|
1266
|
+
},
|
|
1267
|
+
segmentTextSelected: {
|
|
1268
|
+
color: '#000000',
|
|
1269
|
+
fontWeight: '700',
|
|
1270
|
+
opacity: 1,
|
|
1271
|
+
},
|
|
1272
|
+
caption: {
|
|
1273
|
+
color: 'rgba(255,255,255,0.55)',
|
|
1274
|
+
fontSize: 11,
|
|
1275
|
+
marginTop: 6,
|
|
1276
|
+
lineHeight: 16,
|
|
1277
|
+
},
|
|
1278
|
+
debugLine: {
|
|
1279
|
+
color: 'rgba(255,200,0,0.85)',
|
|
1280
|
+
fontFamily: 'Menlo',
|
|
1281
|
+
fontSize: 10,
|
|
1282
|
+
paddingVertical: 8,
|
|
1283
|
+
paddingHorizontal: 6,
|
|
1284
|
+
backgroundColor: 'rgba(255,200,0,0.08)',
|
|
1285
|
+
borderRadius: 6,
|
|
1286
|
+
marginBottom: 4,
|
|
1287
|
+
},
|
|
1288
|
+
resetBtn: {
|
|
1289
|
+
marginTop: 28,
|
|
1290
|
+
paddingVertical: 12,
|
|
1291
|
+
borderRadius: 8,
|
|
1292
|
+
borderWidth: 1,
|
|
1293
|
+
borderColor: 'rgba(255,255,255,0.25)',
|
|
1294
|
+
alignItems: 'center',
|
|
1295
|
+
},
|
|
1296
|
+
resetText: {
|
|
1297
|
+
color: '#ffffff',
|
|
1298
|
+
fontSize: 14,
|
|
1299
|
+
fontWeight: '500',
|
|
1300
|
+
},
|
|
1301
|
+
// V16 Phase 1b — Accordion + Tag + InfoBox
|
|
1302
|
+
accordion: {
|
|
1303
|
+
marginTop: 18,
|
|
1304
|
+
backgroundColor: 'rgba(255,255,255,0.04)',
|
|
1305
|
+
borderRadius: 8,
|
|
1306
|
+
overflow: 'hidden',
|
|
1307
|
+
},
|
|
1308
|
+
accordionHeader: {
|
|
1309
|
+
flexDirection: 'row',
|
|
1310
|
+
alignItems: 'center',
|
|
1311
|
+
paddingVertical: 12,
|
|
1312
|
+
paddingHorizontal: 12,
|
|
1313
|
+
gap: 8,
|
|
1314
|
+
},
|
|
1315
|
+
accordionChevron: {
|
|
1316
|
+
color: 'rgba(255,255,255,0.5)',
|
|
1317
|
+
fontSize: 11,
|
|
1318
|
+
width: 14,
|
|
1319
|
+
},
|
|
1320
|
+
accordionTitle: {
|
|
1321
|
+
color: '#ffffff',
|
|
1322
|
+
opacity: 0.85,
|
|
1323
|
+
fontSize: 13,
|
|
1324
|
+
fontWeight: '600',
|
|
1325
|
+
textTransform: 'uppercase',
|
|
1326
|
+
letterSpacing: 0.5,
|
|
1327
|
+
flex: 1,
|
|
1328
|
+
},
|
|
1329
|
+
accordionBody: {
|
|
1330
|
+
paddingHorizontal: 12,
|
|
1331
|
+
paddingBottom: 14,
|
|
1332
|
+
},
|
|
1333
|
+
tag: {
|
|
1334
|
+
backgroundColor: 'rgba(255,255,255,0.12)',
|
|
1335
|
+
paddingHorizontal: 6,
|
|
1336
|
+
paddingVertical: 2,
|
|
1337
|
+
borderRadius: 4,
|
|
1338
|
+
},
|
|
1339
|
+
tagText: {
|
|
1340
|
+
color: 'rgba(255,255,255,0.7)',
|
|
1341
|
+
fontSize: 9,
|
|
1342
|
+
fontWeight: '600',
|
|
1343
|
+
textTransform: 'uppercase',
|
|
1344
|
+
letterSpacing: 0.5,
|
|
1345
|
+
},
|
|
1346
|
+
infoBox: {
|
|
1347
|
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
1348
|
+
borderRadius: 8,
|
|
1349
|
+
padding: 12,
|
|
1350
|
+
marginTop: 4,
|
|
1351
|
+
},
|
|
1352
|
+
infoText: {
|
|
1353
|
+
color: 'rgba(255,255,255,0.75)',
|
|
1354
|
+
fontSize: 12,
|
|
1355
|
+
lineHeight: 17,
|
|
1356
|
+
},
|
|
1357
|
+
});
|