react-native-image-stitcher 0.3.0 → 0.4.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.
@@ -1,26 +1,61 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
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.
3
+ * PanoramaSettingsModal — runtime tuning surface for <Camera>'s
4
+ * batch-keyframe panorama capture.
7
5
  *
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.
6
+ * v0.4 rewrite (Phase 2 of F10):
7
+ * ──────────────────────────────
12
8
  *
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.
9
+ * The v0.3 modal exposed a flat 45-field surface that mixed
10
+ * batch-keyframe knobs with slit-scan, hybrid, and video-recording
11
+ * fallback fields the engine never reads in <Camera>'s
12
+ * `engine: 'batch-keyframe'` path. The 2026-05-22 audit (v0.3.0
13
+ * CHANGELOG) traced every field's native consumer and proved most of
14
+ * the cross-engine fields were dead surface in this modal.
15
+ *
16
+ * v0.4 narrows the modal to exactly the surface <Camera> consumes:
17
+ * the `PanoramaSettings` type defined in `./PanoramaSettings.ts`. Each
18
+ * section in the modal mirrors a sub-tree of that type — operators see
19
+ * the same shape in the UI as the code, and host apps that want to
20
+ * tune slit-scan or hybrid engines build their own analogous
21
+ * SlitscanSettingsModal / HybridSettingsModal on top of those types.
22
+ *
23
+ * UI structure (matches the type tree):
24
+ *
25
+ * - Debug (top-level, `debug`)
26
+ * - Frame selection (`frameSelection`, closed by default)
27
+ * - Mode
28
+ * - Max keyframes
29
+ * - Overlap threshold
30
+ * - Flow tunables (`frameSelection.flow`, only when
31
+ * mode === 'flow-based')
32
+ * - Max corners
33
+ * - Quality level
34
+ * - Min distance
35
+ * - Max translation cm
36
+ * - Novelty percentile
37
+ * - Eval every N frames
38
+ * - Stitcher (`stitcher`, closed by default)
39
+ * - Stitch mode
40
+ * - Warper type
41
+ * - Blender
42
+ * - Seam finder
43
+ * - Inscribed-rect crop
44
+ * - Reset to defaults (button)
45
+ *
46
+ * Note: `captureSource` (AR vs non-AR) is NOT surfaced here. The
47
+ * camera-screen AR toggle owns that state — Camera.tsx overrides the
48
+ * native bridge's `captureSource` with the derived
49
+ * `effectiveCaptureSource` so settings and runtime stay in sync.
50
+ *
51
+ * The reusable `Accordion` + `SectionHeader` + `SegmentedControl` +
52
+ * `Tag` helpers from the v0.3 modal are preserved verbatim — only the
53
+ * data-binding layer changed.
18
54
  */
19
55
 
20
56
  import React, { useState } from 'react';
21
57
  import {
22
58
  Modal,
23
- NativeModules,
24
59
  Pressable,
25
60
  ScrollView,
26
61
  StyleSheet,
@@ -28,490 +63,32 @@ import {
28
63
  View,
29
64
  } from 'react-native';
30
65
 
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
- * Both platforms honour this as of 2026-05-22 (audit F2). Android
327
- * routes through `image_stitcher_jni.cpp` → `cpp/stitcher.cpp`;
328
- * iOS routes through `OpenCVStitcher.stitchFramePaths(stitchMode:)`
329
- * → `cpp/stitcher.cpp`. Both 'auto' resolutions use the same
330
- * translation/rotation ratio heuristic
331
- * (`resolveStitchModeAuto` on each side).
332
- */
333
- stitchMode: 'auto' | 'panorama' | 'scans';
334
- }
335
-
336
-
337
- // Per-device default selection. We read the iPhone's physical
338
- // RAM at SDK module load (exposed by `BatchStitcher`'s
339
- // `constantsToExport`) and pick the heaviest blender + seam
340
- // finder combo that fits. Threshold (2 GB) is conservative —
341
- // iPhone 6s through iPhone X have 2 GB exactly; below that
342
- // (iPhone 6 / 5s) is unsupported by RN 0.84 anyway. The user
343
- // can still flip ANY of these in the settings modal at runtime;
344
- // this only chooses the INITIAL default.
345
- const _physicalMemoryBytes: number = (() => {
346
- const m = (NativeModules as Record<string, unknown>).BatchStitcher;
347
- const bytes =
348
- m && typeof m === 'object'
349
- ? (m as { physicalMemoryBytes?: number }).physicalMemoryBytes
350
- : undefined;
351
- return typeof bytes === 'number' ? bytes : 0;
352
- })();
353
-
354
- const _isLowMem = _physicalMemoryBytes > 0
355
- && _physicalMemoryBytes < 2 * 1024 * 1024 * 1024;
356
-
357
- // One-line diagnostic so the host's Metro console shows what the
358
- // SDK saw at module load. If `physicalMemoryBytes=0` here, the
359
- // native bridge's `constantsToExport` isn't being picked up by
360
- // React Native and we should investigate the @objc registration.
361
- // The defaults always pick the SAFE fallback (multiband+graphcut)
362
- // when the value is 0 — this log is the only signal we have.
363
- // eslint-disable-next-line no-console
364
- console.log(
365
- '[capture-sdk] PanoramaSettings defaults: '
366
- + `physicalMemoryBytes=${_physicalMemoryBytes} `
367
- + `isLowMem=${_isLowMem} `
368
- + `→ blender=${_isLowMem ? 'feather' : 'multiband'} `
369
- + `seam=${_isLowMem ? 'skip' : 'graphcut'}`,
370
- );
66
+ import {
67
+ DEFAULT_FLOW_GATE_SETTINGS,
68
+ DEFAULT_PANORAMA_SETTINGS,
69
+ type BatchStitcherSettings,
70
+ type CaptureBaseSettings,
71
+ type FlowGateSettings,
72
+ type FrameSelectionSettings,
73
+ type PanoramaSettings,
74
+ } from './PanoramaSettings';
75
+ import {
76
+ getPhysicalMemoryBytes,
77
+ isLowMemDevice,
78
+ } from './lowMemDevice';
371
79
 
372
80
 
373
- export const DEFAULT_PANORAMA_SETTINGS: PanoramaSettings = {
374
- warperType: 'plane',
375
- // High-quality defaults on devices with ≥2 GB RAM (iPhone X+):
376
- // MultiBandBlender + GraphCutSeamFinder, the same combo
377
- // cv::Stitcher::PANORAMA uses internally and what produced the
378
- // sharpest output during iteration.
379
- // Low-memory devices (<2 GB) fall back to FeatherBlender + skip
380
- // seam (streams warp+feed) so peak memory stays under the
381
- // tighter jetsam threshold. Either way, the user can switch
382
- // both in the settings modal.
383
- blenderType: _isLowMem ? 'feather' : 'multiband',
384
- seamFinderType: _isLowMem ? 'skip' : 'graphcut',
385
- // V16 Phase 1b.fix5c — default OFF. See PanoramaSettings.enableMaxInscribedRectCrop.
386
- enableMaxInscribedRectCrop: false,
387
- // AR-backed capture is the default — vision-camera path is kept as
388
- // a fallback while we shake out edge cases.
389
- useARPreview: true,
390
- // V16 Phase 1 — batch-keyframe is the new default-recommended
391
- // engine: KeyframeGate caps input at ≤ keyframeMaxCount frames,
392
- // OpenCVStitcher's BA + GraphCut + ExposureCompensator +
393
- // MultiBandBlender runs once on shutter release. Existing
394
- // slitscan-* engines remain available for wide-pan fallback.
395
- incrementalEngine: 'batch-keyframe',
396
- slitWidthFraction: 0.30,
397
- acceptGate: 0,
398
- enableTriangulation: false,
399
- enableTriAccumulator: false,
400
- enable2dNcc: false,
401
- enableRansacHomography: false,
402
- // V15.0c — Ram observation: FirstPaintedWins is consistently the best
403
- // output across all combinations. Default switched from FeatherBlend.
404
- paintMode: 'FirstPaintedWins',
405
- hybridProjection: 'Planar',
406
- nccSearchRadius1d: 15,
407
- useDetectedPlane: false,
408
- // V16 Phase 1 — Virtual plane is the default since batch-keyframe
409
- // is the recommended engine and the gate needs a plane to compute
410
- // polygon overlap. Virtual works without ARKit-detected planes (a
411
- // synthesized plane perpendicular to the first-frame camera at
412
- // virtualPlaneDepthMeters); operators can flip to ARKitDetected
413
- // when in a controlled scene with a clearly-visible wall. Disabled
414
- // is still selectable for the older slit-scan paths that don't
415
- // need a plane.
416
- // V16 Phase 1b.fix5c (Ram's call 2026-05-10): switched default
417
- // from 'Virtual' to 'ARKitDetected'. ARKit's real plane gives
418
- // better intrinsics-to-pixel alignment than a synthesised plane
419
- // at a fixed depth, when ARKit can find a vertical plane. Falls
420
- // back to slit-scan when no plane latches.
421
- planeSource: 'ARKitDetected',
422
- virtualPlaneDepthMeters: 1.5,
423
- arkitPlaneAlignmentThreshold: 0.6,
424
- // V15.0g — Rectified is the default (Trapezoidal had the tilt-
425
- // induced bottom-wider-than-top distortion that was the field
426
- // blocker on V15.0e/f). Trapezoidal stays available for
427
- // operator A/B comparison.
428
- planeProjectionStyle: 'Rectified',
429
- // V15.0d — NCC 2D defaults match V15.0c.4's hardcoded values, now
430
- // tunable via the settings UI. EMA smoothing and pan-axis lock are
431
- // off by default so the V15.0c.4 baseline behaviour is preserved
432
- // until the operator explicitly opts in.
433
- nccSearchMargin2d: 12,
434
- // V15.0i.1 — default raised to 0.99 per Ram (only apply on near-
435
- // perfect overlap matches; reject ambiguous matches that snap to
436
- // wrong patterns on repetitive textures like shelf rails).
437
- nccConfidenceThreshold2d: 0.99,
438
- enableNcc2dEmaSmoothing: false,
439
- ncc2dEmaAlpha: 0.4,
440
- enableNcc2dPanAxisLock: false,
441
- ncc2dCrossAxisLockPx: 5,
442
- // V16 A2 (2026-05-13) — flow-based is now the default. Ram report
443
- // 2026-05-13 13:05 showed that pose-based on a small latched plane
444
- // produces "bursts" of accepts on small physical motion: a 0.64 m²
445
- // plane at 2.7 m perpDist gave 6 accepts in 1 s over 12 cm of
446
- // translation because the plane-projected polygon covers only a
447
- // sliver of the frame, hyperinflating newContent. Flow-based
448
- // measures novelty from real image content (sparse KLT), is
449
- // plane-independent, and is invariant to plane size. Operators
450
- // can still flip back to 'pose-based' or 'time-based' in the modal
451
- // for A/B testing or low-texture scenes. Same defaults shared
452
- // between pose-based and flow-based (40 % new content per
453
- // keyframe, ≤ 6 keyframes per capture).
454
- frameSelectionMode: 'flow-based',
455
- // 2026-05-15 (U4) — flow-based default novelty 0.40 → 0.20.
456
- // Accept frames with 20 % new content (was 40 %). More inclusive
457
- // selection for shelf-pan captures where panning slowly produces
458
- // gradual content reveal. Operator can still bump via Settings.
459
- keyframeOverlapThreshold: 0.20,
460
- keyframeMaxCount: 6,
461
- // V16 A2 — flow-based mode tuning. Defaults are the values that
462
- // tested cleanly on iPhone 13 Pro / 14 Pro: 150 corners give a
463
- // stable median across the frame; quality=0.01 + minDistance=10
464
- // give spatially-spread, repeatable detection. All three are
465
- // tunable in the modal under "Flow tuning".
466
- flowMaxCorners: 150,
467
- flowQualityLevel: 0.01,
468
- flowMinDistance: 10,
469
- // V16 — translation-budget force-accept (Flow strategy only).
470
- // 2026-05-16 (Issue 4a fix) — default flipped from 0 (disabled) to
471
- // 25 cm so the "Rotate the camera instead of moving it sideways"
472
- // warning fires out-of-the-box. Set to 0 in Settings to disable
473
- // both the warning AND the gate's force-accept on budget crossing.
474
- // 2026-05-17 (Issue 4-A v2) — raised 25 → 50 cm. The 25-cm budget
475
- // was too tight given IMU double-integration drift (the
476
- // accelerometer's noise floor accumulates several cm of bogus
477
- // "translation" per second even when the phone is held still).
478
- // Combined with the new `resetAnchor` at handleHoldStart (so drift
479
- // doesn't compound across captures), 50 cm gives the warning real
480
- // headroom for genuine sideways motion without false positives.
481
- flowMaxTranslationCm: 50,
482
- // V16 — novelty aggregation percentile. 0.85 picks up leading-
483
- // edge motion sooner than the pre-V16 median (0.50). Operator
484
- // can dial down toward 0.5 for more-conservative captures or up
485
- // toward 0.99 for more-aggressive.
486
- flowNoveltyPercentile: 0.85,
487
- // V16 — every-Nth-frame eval throttle. 2026-05-15 (U4): default
488
- // 1 → 5 to reduce per-frame KeyframeGate CPU cost (Shi-Tomasi +
489
- // calcOpticalFlowPyrLK is ~3-5 ms per ARFrame on Galaxy A35; at
490
- // 30 fps that's ~15 % CPU on flow alone). Evaluating every 5th
491
- // frame yields novelty samples at ~6 Hz which is still well above
492
- // the 1-2 Hz keyframe-accept cadence.
493
- // matches pre-V16 behaviour). Set higher to cut CPU on long
494
- // captures at the cost of acceptance latency.
495
- flowEvalEveryNFrames: 5,
496
- // V15.0c — sliver tweaks: leading-edge sliver from BOTTOM for typical
497
- // top-to-bottom pan + full first-frame anchor produced the best
498
- // outputs in early iteration.
499
- sliverPosition: 'Bottom',
500
- firstFrameFullFrame: true,
501
- maxRecordingMs: 8000,
502
- framesPerSecond: 3,
503
- minFrames: 6,
504
- maxFrames: 16,
505
- quality: 85,
81
+ // ─── Device-memory diagnostic (informational only) ─────────────────
82
+ //
83
+ // Read once at module load via the shared `lowMemDevice` helper. We
84
+ // surface this as a single Menlo-monospace line at the top of the
85
+ // modal body so operators can see what the SDK detected — useful for
86
+ // diagnosing "why am I OOMing on this device?" questions. The same
87
+ // helper feeds <Camera>'s initial-settings device adaptation; they
88
+ // were duplicated implementations pre-Phase-2-fix.
506
89
 
507
- // 2026-05-14 (revised) — capture source defaults to 'ar' (AR-backed
508
- // is the recommended path; non-AR is the explicit opt-out). Stitch
509
- // mode stays 'auto' — the auto-resolution heuristic between PANORAMA
510
- // and SCANS is per-capture, not per-mode, so it's safe to leave on.
511
- captureSource: 'ar',
512
- stitchMode: 'auto',
513
- debug: false,
514
- };
90
+ const _physicalMemoryBytes = getPhysicalMemoryBytes();
91
+ const _isLowMem = isLowMemDevice();
515
92
 
516
93
 
517
94
  export interface PanoramaSettingsModalProps {
@@ -528,42 +105,57 @@ export function PanoramaSettingsModal({
528
105
  onChange,
529
106
  onClose,
530
107
  }: PanoramaSettingsModalProps): React.JSX.Element {
531
- const update = (patch: Partial<PanoramaSettings>) =>
532
- onChange({ ...settings, ...patch });
533
-
534
- // V16 Phase 1b derive the 2-axis (timing × algorithm) UI state
535
- // from the underlying single `incrementalEngine` field. Storage
536
- // shape is unchanged; the modal just presents it in two segmented
537
- // controls so the user's mental model matches the system's actual
538
- // primary axis (batch vs realtime).
108
+ // ─── Sub-tree update helpers ─────────────────────────────────────
109
+ //
110
+ // Each settings sub-tree has its own update helper that
111
+ // non-destructively patches that branch and re-emits the whole
112
+ // settings object via `onChange`. Call sites stay short
113
+ // (`updateStitcher({ stitchMode: 'scans' })`) and avoid the
114
+ // nested-spread boilerplate the hierarchical shape would otherwise
115
+ // require at every callsite.
539
116
  //
540
- // Mapping:
541
- // incrementalEngine === 'batch-keyframe' → timing='batch'
542
- // incrementalEngine === 'hybrid' → timing='realtime', algo='hybrid'
543
- // incrementalEngine === 'slitscan-rotate' timing='realtime', algo='slitscan-rotate'
544
- // incrementalEngine === 'slitscan-both' → timing='realtime', algo='slitscan-both'
545
- const timing: 'batch' | 'realtime' =
546
- settings.incrementalEngine === 'batch-keyframe' ? 'batch' : 'realtime';
547
- // When in batch mode, remember 'hybrid' as the realtime algorithm
548
- // the user would land on if they flipped timing back. When already
549
- // in realtime, the engine field IS the algorithm.
550
- const realtimeAlgorithm:
551
- 'hybrid' | 'slitscan-rotate' | 'slitscan-both' =
552
- settings.incrementalEngine === 'batch-keyframe'
553
- ? 'hybrid'
554
- : settings.incrementalEngine;
555
- const setTiming = (t: 'batch' | 'realtime') => {
556
- if (t === 'batch') {
557
- update({ incrementalEngine: 'batch-keyframe' });
558
- } else {
559
- update({ incrementalEngine: realtimeAlgorithm });
560
- }
561
- };
117
+ // Why not a generic deep-merge? Type-safety: each helper takes
118
+ // exactly the `Partial<SubTree>` the section it backs can patch.
119
+ // A generic helper would accept arbitrary nested keys and break the
120
+ // type-level guarantee that the modal only mutates what its
121
+ // matching settings type defines.
562
122
 
563
- // Frame Selection only makes sense for batch and hybrid engines —
564
- // slit-scan needs dense input and the gate would starve it.
565
- const showFrameSelection =
566
- timing === 'batch' || realtimeAlgorithm === 'hybrid';
123
+ const updateBase = (patch: Partial<CaptureBaseSettings>) =>
124
+ onChange({ ...settings, ...patch });
125
+
126
+ const updateStitcher = (patch: Partial<BatchStitcherSettings>) =>
127
+ onChange({
128
+ ...settings,
129
+ stitcher: { ...settings.stitcher, ...patch },
130
+ });
131
+
132
+ const updateFrameSelection = (patch: Partial<FrameSelectionSettings>) =>
133
+ onChange({
134
+ ...settings,
135
+ frameSelection: { ...settings.frameSelection, ...patch },
136
+ });
137
+
138
+ // Flow has an extra wrinkle: `frameSelection.flow` is optional.
139
+ // We materialise it from `DEFAULT_FLOW_GATE_SETTINGS` (the
140
+ // canonical FlowGateSettings defaults — see PanoramaSettings.ts)
141
+ // when patching from "undefined" — happens if a host starts with
142
+ // a custom settings literal that omits the sub-tree.
143
+ const updateFlow = (patch: Partial<FlowGateSettings>) =>
144
+ onChange({
145
+ ...settings,
146
+ frameSelection: {
147
+ ...settings.frameSelection,
148
+ flow: {
149
+ ...(settings.frameSelection.flow ?? DEFAULT_FLOW_GATE_SETTINGS),
150
+ ...patch,
151
+ },
152
+ },
153
+ });
154
+
155
+ // Frame-selection mode controls the visibility of the nested
156
+ // Flow-tunables section. Mirrors the type-level optionality of
157
+ // `frameSelection.flow`.
158
+ const showFlowTunables = settings.frameSelection.mode === 'flow-based';
567
159
 
568
160
  return (
569
161
  <Modal
@@ -593,463 +185,187 @@ export function PanoramaSettingsModal({
593
185
  {`device: physicalMemoryBytes=${_physicalMemoryBytes} `
594
186
  + `(${(_physicalMemoryBytes / (1024 ** 3)).toFixed(2)} GB) · `
595
187
  + `isLowMem=${_isLowMem ? 'yes' : 'no'} · `
596
- + `default blender=${_isLowMem ? 'feather' : 'multiband'}`}
188
+ + `current blender=${settings.stitcher.blenderType} `
189
+ + `(low-mem fallback=${_isLowMem ? 'feather' : 'multiband'})`}
597
190
  </Text>
598
191
 
599
192
  {/* ──────────────────────────────────────────────
600
- * 2026-05-14 (revised) — CAPTURE SOURCE picker.
601
- * Two options after the Galaxy A35 vision-camera
602
- * CameraCaptureSession crash on pre-mount physical-
603
- * lens selection: 'ar' (AR-backed, default) and
604
- * 'non-ar' (vision-camera + lens-switcher chip on
605
- * the capture screen). Switching to 'non-ar'
606
- * cascades to disable plane detection, flip frame-
607
- * selection to flow-based, and turn off useARPreview
608
- * (see useEffect in AuditCaptureScreen.tsx).
193
+ * DEBUG (top-level, `debug`)
194
+ *
195
+ * Note: `captureSource` (AR vs non-AR) is intentionally
196
+ * NOT surfaced here — the camera-screen AR toggle is the
197
+ * sole source of truth. Camera.tsx computes
198
+ * `effectiveCaptureSource` from `arPreference + lens +
199
+ * AR-device-support` and overrides `settings.captureSource`
200
+ * on the bridge call, so the native engine always agrees
201
+ * with the runtime preview. Exposing a second control
202
+ * here led to silent split-state where the modal value
203
+ * disagreed with the on-screen toggle.
609
204
  * ────────────────────────────────────────────── */}
610
- <SectionHeader title="Capture source" />
611
- <SegmentedControl
612
- options={['ar', 'non-ar']}
613
- value={settings.captureSource}
614
- onChange={(v) => update({ captureSource: v as PanoramaSettings['captureSource'] })}
615
- 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)."
616
- />
617
-
618
- {/* 2026-05-14 — Stitch-mode picker. THE 2026-05-14 OOM
619
- * root cause: cv::Stitcher PANORAMA mode breaks down
620
- * on translation-heavy input. 'auto' (DEFAULT) routes
621
- * between PANORAMA and SCANS based on accumulated
622
- * translation vs rotation magnitudes at finalize time.
623
- * Lifted to the top of the modal alongside captureSource
624
- * for the same reason: it's a top-level pipeline
625
- * decision, not a per-engine tuning. */}
626
- <SectionHeader title="Stitch mode" />
627
- <SegmentedControl
628
- options={['auto', 'panorama', 'scans']}
629
- value={settings.stitchMode}
630
- onChange={(v) => update({ stitchMode: v as PanoramaSettings['stitchMode'] })}
631
- 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."
632
- />
633
-
634
- {/* ──────────────────────────────────────────────
635
- * STITCH TIMING — top-level decision. Maps to the
636
- * `incrementalEngine` storage field via setTiming().
637
- * ────────────────────────────────────────────── */}
638
- <SectionHeader title="Stitch timing" />
639
- <SegmentedControl
640
- options={['batch', 'realtime']}
641
- value={timing}
642
- onChange={(v) => setTiming(v as 'batch' | 'realtime')}
643
- 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."
644
- />
645
-
646
- {/* ──────────────────────────────────────────────
647
- * FRAME SELECTION (V16) — only for batch + hybrid.
648
- * Slit-scan needs dense input; gate would starve it.
649
- * ────────────────────────────────────────────── */}
650
- {showFrameSelection && (
651
- <>
652
- <SectionHeader title="Frame selection (V16)" />
653
- <SegmentedControl
654
- options={['time-based', 'pose-based', 'flow-based']}
655
- value={settings.frameSelectionMode}
656
- onChange={(v) => update({ frameSelectionMode: v as PanoramaSettings['frameSelectionMode'] })}
657
- 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."
658
- />
659
- {(settings.frameSelectionMode === 'pose-based' ||
660
- settings.frameSelectionMode === 'flow-based') && (
661
- <>
662
- <SectionHeader title="Overlap threshold (new content per keyframe)" />
663
- <SegmentedControl
664
- options={['20%', '30%', '40%', '50%', '60%']}
665
- value={`${Math.round(settings.keyframeOverlapThreshold * 100)}%`}
666
- onChange={(v) => update({ keyframeOverlapThreshold: parseInt(v, 10) / 100 })}
667
- 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."
668
- />
669
- <SectionHeader title="Max keyframes per capture" />
670
- <SegmentedControl
671
- options={['3', '4', '5', '6', '8', '10']}
672
- value={String(settings.keyframeMaxCount)}
673
- onChange={(v) => update({ keyframeMaxCount: parseInt(v, 10) })}
674
- caption="Hard cap. 6 (default) matches Samsung's behaviour. Once reached, host auto-finalizes."
675
- />
676
- </>
677
- )}
678
- {settings.frameSelectionMode === 'flow-based' && (
679
- <>
680
- <SectionHeader title="Flow tuning — max corners" />
681
- <SegmentedControl
682
- options={['50', '100', '150', '200', '300']}
683
- value={String(settings.flowMaxCorners)}
684
- onChange={(v) => update({ flowMaxCorners: parseInt(v, 10) })}
685
- caption="Max Shi-Tomasi corners detected per accepted keyframe. More = more robust median, slower detect. 150 = default."
686
- />
687
- <SectionHeader title="Flow tuning — quality level" />
688
- <SegmentedControl
689
- options={['0.005', '0.01', '0.02', '0.03', '0.05']}
690
- value={String(settings.flowQualityLevel)}
691
- onChange={(v) => update({ flowQualityLevel: parseFloat(v) })}
692
- caption="Shi-Tomasi corner quality threshold. Lower = more (weaker) corners; higher = fewer (stronger) corners. 0.01 = default."
693
- />
694
- <SectionHeader title="Flow tuning — min distance" />
695
- <SegmentedControl
696
- options={['5', '8', '10', '15', '20']}
697
- value={String(settings.flowMinDistance)}
698
- onChange={(v) => update({ flowMinDistance: parseInt(v, 10) })}
699
- caption="Min pixel distance between detected corners (working resolution = 720 px longest side). Higher = more spatially-spread features. 10 = default."
700
- />
701
- <SectionHeader title="Flow tuning — translation budget (cm)" />
702
- <SegmentedControl
703
- options={['0', '5', '8', '12', '20', '50']}
704
- value={String(settings.flowMaxTranslationCm)}
705
- onChange={(v) => update({ flowMaxTranslationCm: parseInt(v, 10) })}
706
- 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."
707
- />
708
- <SectionHeader title="Flow tuning — novelty percentile" />
709
- <SegmentedControl
710
- options={['0.50', '0.70', '0.85', '0.95', '0.99']}
711
- value={settings.flowNoveltyPercentile.toFixed(2)}
712
- onChange={(v) => update({ flowNoveltyPercentile: parseFloat(v) })}
713
- 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."
714
- />
715
- <SectionHeader title="Flow tuning — eval every N frames" />
716
- <SegmentedControl
717
- options={['1', '2', '3', '5', '10']}
718
- value={String(settings.flowEvalEveryNFrames)}
719
- onChange={(v) => update({ flowEvalEveryNFrames: parseInt(v, 10) })}
720
- 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."
721
- />
722
- </>
723
- )}
724
- </>
725
- )}
726
-
727
- {/* ──────────────────────────────────────────────
728
- * AR PLANE PROJECTION — used by KeyframeGate's overlap
729
- * calculation, slit-scan plane-projection, and (future)
730
- * pose-driven batch. Sub-fields reveal based on source.
731
- * ────────────────────────────────────────────── */}
732
- <SectionHeader title="AR plane projection" />
205
+ <SectionHeader title="Debug" />
733
206
  <SegmentedControl
734
- options={['Disabled', 'ARKitDetected', 'Virtual']}
735
- value={settings.planeSource}
736
- onChange={(v) => update({ planeSource: v as PanoramaSettings['planeSource'] })}
737
- 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)."
207
+ options={['off', 'on']}
208
+ value={settings.debug ? 'on' : 'off'}
209
+ onChange={(v) => updateBase({ debug: v === 'on' })}
210
+ caption="When ON, <Camera> mounts the diagnostic pills (memory, keyframes, orientation), the detailed metrics overlay, and the stitch-stats toast on every successful finalize. OFF (default) production end-user UI."
738
211
  />
739
- {settings.planeSource === 'ARKitDetected' && (
740
- <>
741
- <SectionHeader title="ARKit alignment threshold" />
742
- <SegmentedControl
743
- options={['0.3', '0.5', '0.6', '0.7', '0.85']}
744
- value={settings.arkitPlaneAlignmentThreshold.toFixed(2)}
745
- onChange={(v) => update({ arkitPlaneAlignmentThreshold: parseFloat(v) })}
746
- caption="Min dot product between candidate plane normal and camera facing. 0.6 (default) = ~53° max angle off-camera. Higher = stricter."
747
- />
748
- </>
749
- )}
750
- {settings.planeSource === 'Virtual' && (
751
- <>
752
- <SectionHeader title="Virtual plane depth" />
753
- <SegmentedControl
754
- options={['0.5m', '1.0m', '1.5m', '2.0m', '3.0m']}
755
- value={`${settings.virtualPlaneDepthMeters.toFixed(1)}m`}
756
- onChange={(v) => update({ virtualPlaneDepthMeters: parseFloat(v) })}
757
- caption="Synthetic plane depth at first frame. Set to your typical scan distance."
758
- />
759
- </>
760
- )}
761
- {settings.planeSource !== 'Disabled' && (
762
- <>
763
- <SectionHeader title="Plane projection style" />
764
- <SegmentedControl
765
- options={['Rectified', 'Trapezoidal']}
766
- value={settings.planeProjectionStyle}
767
- onChange={(v) => update({ planeProjectionStyle: v as PanoramaSettings['planeProjectionStyle'] })}
768
- caption="Rectified (default): clean rectangle paste, no tilt distortion. Trapezoidal: V15.0b legacy 3D-correct raycast — geometric purity at the cost of tilt artifacts."
769
- />
770
- </>
771
- )}
772
212
 
773
213
  {/* ──────────────────────────────────────────────
774
- * ALGORITHM what runs at stitch time. In batch mode
775
- * there's no choice (cv::Stitcher feature-matched
776
- * pipeline). In realtime, three live engines.
214
+ * FRAME SELECTION (`frameSelection` sub-tree, closed by default)
215
+ *
216
+ * Placed above Stitcher because keyframe-gate tuning is
217
+ * the more frequently touched control surface during
218
+ * capture-quality troubleshooting. Stitcher knobs are
219
+ * rarely changed at runtime.
220
+ *
221
+ * Nested Flow-tunables section reveals when mode is
222
+ * flow-based (mirrors the `frameSelection.flow` optional
223
+ * on the type).
777
224
  * ────────────────────────────────────────────── */}
778
- <SectionHeader title="Algorithm" />
779
- {timing === 'batch' ? (
780
- <View style={styles.infoBox}>
781
- <Text style={styles.infoText}>
782
- Full feature-matched pipeline:
783
- ORB → BFMatcher → RANSAC → BundleAdjusterRay →
784
- waveCorrect → Warper → GraphCutSeamFinder →
785
- ExposureCompensator → MultiBandBlender. No engine
786
- choice in batch mode.
787
- </Text>
788
- </View>
789
- ) : (
225
+ <Accordion title="Frame selection (KeyframeGate)">
226
+ <SectionHeader title="Mode" />
790
227
  <SegmentedControl
791
- options={['hybrid', 'slitscan-rotate', 'slitscan-both']}
792
- value={realtimeAlgorithm}
793
- onChange={(v) => update({ incrementalEngine: v as PanoramaSettings['incrementalEngine'] })}
794
- caption="hybrid: streaming planar projection + feature matching. slitscan-rotate: V13.0a + 1D NCC. slitscan-both: V13.0a + no accept gate + feather blend (iteration playground)."
228
+ options={['time-based', 'pose-based', 'flow-based']}
229
+ value={settings.frameSelection.mode}
230
+ onChange={(v) => updateFrameSelection({
231
+ mode: v as FrameSelectionSettings['mode'],
232
+ })}
233
+ caption="flow-based (default): sparse Shi-Tomasi + KLT optical flow. Plane-independent. pose-based: plane-overlap when a plane is latched, angular fallback otherwise — cheap but conservative. time-based: gate disabled; every frame accepted up to maxKeyframes."
795
234
  />
796
- )}
797
-
798
- {/* ──────────────────────────────────────────────
799
- * ALGORITHM TUNING — engine-specific knobs revealed
800
- * by current Algorithm choice.
801
- * ────────────────────────────────────────────── */}
802
- {timing === 'batch' && (
803
- <>
804
- {/* Capture source + stitch mode were lifted to the
805
- * TOP of the modal (above the timing picker) on
806
- * 2026-05-14 since they're pipeline-level decisions,
807
- * not batch-tuning knobs. See the top of the
808
- * ScrollView for those controls. */}
809
- <SectionHeader title="Batch tuning — Warper" />
810
- <SegmentedControl
811
- options={['plane', 'cylindrical', 'spherical']}
812
- value={settings.warperType}
813
- onChange={(v) => update({ warperType: v as PanoramaSettings['warperType'] })}
814
- caption="plane (default, recommended for retail shelves): flat rectangular output. cylindrical: rotational mid-arc, gentle curvature. spherical: wide pans (180°+) but always-curved."
815
- />
816
- <SectionHeader title="Batch tuning — Blender" />
817
- <SegmentedControl
818
- options={['multiband', 'feather']}
819
- value={settings.blenderType}
820
- onChange={(v) => update({ blenderType: v as PanoramaSettings['blenderType'] })}
821
- caption="multiband (default): Laplacian-pyramid blending; cleanest seams. feather: faster, no halo when exposure varies."
822
- />
823
- <SectionHeader title="Batch tuning — Seam finder" />
824
- <SegmentedControl
825
- options={['graphcut', 'skip']}
826
- value={settings.seamFinderType}
827
- onChange={(v) => update({ seamFinderType: v as PanoramaSettings['seamFinderType'] })}
828
- caption="graphcut (default): cv::detail::GraphCutSeamFinder; optimal seams, pairs with multiband, holds all warps in memory. skip: stream warp+feed (lower peak memory)."
829
- />
830
- <SectionHeader title="Batch tuning — Inscribed-rect crop" />
831
- <SegmentedControl
832
- options={['off', 'on']}
833
- value={settings.enableMaxInscribedRectCrop ? 'on' : 'off'}
834
- onChange={(v) => update({ enableMaxInscribedRectCrop: v === 'on' })}
835
- 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."
836
- />
837
- </>
838
- )}
839
- {timing === 'realtime' && realtimeAlgorithm === 'hybrid' && (
840
- <>
841
- <SectionHeader title="Hybrid tuning — Projection" />
842
- <SegmentedControl
843
- options={['Planar', 'Cylindrical']}
844
- value={settings.hybridProjection}
845
- onChange={(v) => update({ hybridProjection: v as PanoramaSettings['hybridProjection'] })}
846
- caption="Planar (default): cv::detail::PlaneWarper. Cylindrical: V12.x – V14.0a behaviour (legacy)."
847
- />
848
- </>
849
- )}
850
- {timing === 'realtime' && realtimeAlgorithm.startsWith('slitscan') && (
851
- <>
852
- <SectionHeader title="Slit-scan tuning — Slit width" />
853
- <SegmentedControl
854
- options={['0.01', '0.05', '0.10', '0.20', '0.30', '0.50']}
855
- value={settings.slitWidthFraction.toFixed(2)}
856
- onChange={(v) => update({ slitWidthFraction: parseFloat(v) })}
857
- caption="Fraction of pan-axis retained per sliver. 0.30 (V15 default) ≈ 324 px. Smaller = less within-slit depth disagreement."
858
- />
859
- <SectionHeader title="Slit-scan tuning — Sliver position" />
860
- <SegmentedControl
861
- options={['Center', 'Bottom', 'Top']}
862
- value={settings.sliverPosition}
863
- onChange={(v) => update({ sliverPosition: v as PanoramaSettings['sliverPosition'] })}
864
- caption="Where on the camera sensor frame the sliver is taken."
865
- />
866
- <SectionHeader title="Slit-scan tuning — Full first-frame" />
867
- <SegmentedControl
868
- options={['off', 'on']}
869
- value={settings.firstFrameFullFrame ? 'on' : 'off'}
870
- onChange={(v) => update({ firstFrameFullFrame: v === 'on' })}
871
- caption="ON: first accepted frame paints the full camera frame at the canvas anchor; subsequent frames use sliver clip."
872
- />
873
- <SectionHeader title="Slit-scan tuning — Paint mode" />
874
- <SegmentedControl
875
- options={['FirstPaintedWins', 'FeatherBlend']}
876
- value={settings.paintMode}
877
- onChange={(v) => update({ paintMode: v as PanoramaSettings['paintMode'] })}
878
- caption="FirstPaintedWins (default): protect already-painted pixels. FeatherBlend: alpha-blend new content into overlap."
879
- />
880
- </>
881
- )}
882
-
883
- {/* ──────────────────────────────────────────────
884
- * ADVANCED — 2D NCC fine-alignment (closed by default).
885
- * Used by slit-scan plane mode and any 2D NCC stage.
886
- * ────────────────────────────────────────────── */}
887
- <Accordion title="Advanced — 2D NCC fine-alignment" badge="advanced">
888
- <SectionHeader title="Enable 2D NCC" />
235
+ <SectionHeader title="Max keyframes per capture" />
889
236
  <SegmentedControl
890
- options={['off', 'on']}
891
- value={settings.enable2dNcc ? 'on' : 'off'}
892
- onChange={(v) => update({ enable2dNcc: v === 'on' })}
893
- caption="V13.0g 2D NCC fine-alignment after pose-driven projection. Refines (Δx, Δy) translation via cv::matchTemplate."
237
+ options={['3', '4', '5', '6', '8', '10']}
238
+ value={String(settings.frameSelection.maxKeyframes)}
239
+ onChange={(v) => updateFrameSelection({
240
+ maxKeyframes: parseInt(v, 10),
241
+ })}
242
+ caption="Hard cap on accepted keyframes; native clamps to [3, 10]. 6 (default) matches Samsung Pano's behaviour and is the sweet spot for cv::Stitcher BA convergence."
243
+ />
244
+ <SectionHeader title="Overlap threshold (new content per keyframe)" />
245
+ <SegmentedControl
246
+ options={['20%', '30%', '40%', '50%', '60%']}
247
+ value={`${Math.round(settings.frameSelection.overlapThreshold * 100)}%`}
248
+ onChange={(v) => updateFrameSelection({
249
+ overlapThreshold: parseInt(v, 10) / 100,
250
+ })}
251
+ caption="Required NEW-content fraction. 20% (default): generous, ~5–6 keyframes for a 90° pan. Native clamps to [10%, 80%]."
894
252
  />
895
- {settings.enable2dNcc && (
896
- <>
897
- <SectionHeader title="Confidence threshold" />
253
+
254
+ {showFlowTunables && (
255
+ <View style={styles.nested}>
256
+ <Text style={styles.nestedLabel}>Flow tuning</Text>
257
+ <SectionHeader title="Max corners (Shi-Tomasi)" />
258
+ <SegmentedControl
259
+ options={['50', '100', '150', '200', '300']}
260
+ value={String(settings.frameSelection.flow?.maxCorners
261
+ ?? DEFAULT_FLOW_GATE_SETTINGS.maxCorners)}
262
+ onChange={(v) => updateFlow({ maxCorners: parseInt(v, 10) })}
263
+ caption="More corners = more robust median displacement, slower detect. 150 (default) ~ 15–25 ms / frame on Galaxy A35. Native clamps to [50, 300]."
264
+ />
265
+ <SectionHeader title="Quality level (Shi-Tomasi)" />
898
266
  <SegmentedControl
899
- options={['0.50', '0.65', '0.75', '0.85', '0.95', '0.99']}
900
- value={settings.nccConfidenceThreshold2d.toFixed(2)}
901
- onChange={(v) => update({ nccConfidenceThreshold2d: parseFloat(v) })}
902
- caption="Reject NCC corrections below this confidence. 0.99 = only apply on near-perfect overlap."
267
+ options={['0.005', '0.01', '0.02', '0.03', '0.05']}
268
+ value={String(settings.frameSelection.flow?.qualityLevel
269
+ ?? DEFAULT_FLOW_GATE_SETTINGS.qualityLevel)}
270
+ onChange={(v) => updateFlow({ qualityLevel: parseFloat(v) })}
271
+ caption="Lower lets weaker corners in; higher demands stronger corners. 0.01 (default). Clamped to [0.005, 0.05]."
903
272
  />
904
- <SectionHeader title="Search half-window (px)" />
273
+ <SectionHeader title="Min distance (working-resolution px)" />
905
274
  <SegmentedControl
906
- options={['6', '10', '12', '20', '30']}
907
- value={String(settings.nccSearchMargin2d)}
908
- onChange={(v) => update({ nccSearchMargin2d: parseInt(v, 10) })}
909
- caption="Pixels: 2D NCC searches ±this around the pose-predicted match."
275
+ options={['5', '8', '10', '15', '20']}
276
+ value={String(settings.frameSelection.flow?.minDistance
277
+ ?? DEFAULT_FLOW_GATE_SETTINGS.minDistance)}
278
+ onChange={(v) => updateFlow({ minDistance: parseInt(v, 10) })}
279
+ caption="Min pixel distance between detected corners (working res = 720 px longest side). 10 (default). Clamped to [1, 50]."
910
280
  />
911
- <SectionHeader title="EMA smoothing" />
281
+ <SectionHeader title="Translation budget (cm)" />
912
282
  <SegmentedControl
913
- options={['off', 'on']}
914
- value={settings.enableNcc2dEmaSmoothing ? 'on' : 'off'}
915
- onChange={(v) => update({ enableNcc2dEmaSmoothing: v === 'on' })}
916
- caption="Damp single-frame snaps to spurious peaks via EMA."
283
+ options={['0', '5', '8', '12', '20', '50']}
284
+ value={String(settings.frameSelection.flow?.maxTranslationCm
285
+ ?? DEFAULT_FLOW_GATE_SETTINGS.maxTranslationCm)}
286
+ onChange={(v) => updateFlow({
287
+ maxTranslationCm: parseInt(v, 10),
288
+ })}
289
+ caption="Force-accept the next frame once the operator has translated this many cm since the last keyframe, even when novelty < threshold. Bounds parallax so cv::Stitcher's matcher can handle the input. 50 (default). 0 disables. Clamped to [0, 100]."
917
290
  />
918
- {settings.enableNcc2dEmaSmoothing && (
919
- <>
920
- <SectionHeader title="EMA alpha (current-frame weight)" />
921
- <SegmentedControl
922
- options={['0.20', '0.30', '0.40', '0.60', '0.80']}
923
- value={settings.ncc2dEmaAlpha.toFixed(2)}
924
- onChange={(v) => update({ ncc2dEmaAlpha: parseFloat(v) })}
925
- />
926
- </>
927
- )}
928
- <SectionHeader title="Pan-axis lock" />
291
+ <SectionHeader title="Novelty percentile" />
929
292
  <SegmentedControl
930
- options={['off', 'on']}
931
- value={settings.enableNcc2dPanAxisLock ? 'on' : 'off'}
932
- onChange={(v) => update({ enableNcc2dPanAxisLock: v === 'on' })}
933
- caption="Clamp cross-axis correction tighter than pan-axis (pose + 1D NCC handle cross-axis already)."
293
+ options={['0.50', '0.70', '0.85', '0.95', '0.99']}
294
+ value={(settings.frameSelection.flow?.noveltyPercentile
295
+ ?? DEFAULT_FLOW_GATE_SETTINGS.noveltyPercentile).toFixed(2)}
296
+ onChange={(v) => updateFlow({
297
+ noveltyPercentile: parseFloat(v),
298
+ })}
299
+ caption="How tracked-feature displacements aggregate into a per-axis novelty estimate. 0.85 (default): picks up leading-edge motion sooner — matches user perception. 0.50: pre-V16 median (conservative). 0.99: very aggressive. Clamped to [0.50, 0.99]."
934
300
  />
935
- {settings.enableNcc2dPanAxisLock && (
936
- <>
937
- <SectionHeader title="Cross-axis clamp (px)" />
938
- <SegmentedControl
939
- options={['2', '5', '10', '15']}
940
- value={String(settings.ncc2dCrossAxisLockPx)}
941
- onChange={(v) => update({ ncc2dCrossAxisLockPx: parseInt(v, 10) })}
942
- />
943
- </>
944
- )}
945
- </>
301
+ <SectionHeader title="Eval every N frames" />
302
+ <SegmentedControl
303
+ options={['1', '2', '3', '5', '10']}
304
+ value={String(settings.frameSelection.flow?.evalEveryNFrames
305
+ ?? DEFAULT_FLOW_GATE_SETTINGS.evalEveryNFrames)}
306
+ onChange={(v) => updateFlow({
307
+ evalEveryNFrames: parseInt(v, 10),
308
+ })}
309
+ caption="Throttle gate evaluation to every Nth frame for CPU savings. 5 (default) gives ~6 Hz novelty samples at 30 Hz ARCore. Doesn't change WHICH frames are accepted; only the sample rate. Clamped to [1, 10]."
310
+ />
311
+ </View>
946
312
  )}
947
313
  </Accordion>
948
314
 
949
315
  {/* ──────────────────────────────────────────────
950
- * ADVANCED Slit-scan experimental. Only relevant
951
- * when slitscan-both is the active engine.
952
- * ────────────────────────────────────────────── */}
953
- {timing === 'realtime' && realtimeAlgorithm === 'slitscan-both' && (
954
- <Accordion title="Advanced — Slit-scan experimental" badge="experimental">
955
- <SectionHeader title="Triangulation parallax" />
956
- <SegmentedControl
957
- options={['off', 'on']}
958
- value={settings.enableTriangulation ? 'on' : 'off'}
959
- onChange={(v) => update({ enableTriangulation: v === 'on' })}
960
- caption="V13.0e ORB triangulation + median-Z parallax correction. Adds ~10ms/accept."
961
- />
962
- <SectionHeader title="RANSAC homography" />
963
- <SegmentedControl
964
- options={['off', 'on']}
965
- value={settings.enableRansacHomography ? 'on' : 'off'}
966
- onChange={(v) => update({ enableRansacHomography: v === 'on' })}
967
- caption="V14.0a RANSAC homography per slit + cv::warpPerspective. Known limitation: can absorb pan as scale, leaving gaps."
968
- />
969
- <SectionHeader title="Accept gate (px)" />
970
- <SegmentedControl
971
- options={['0', '50']}
972
- value={String(settings.acceptGate)}
973
- onChange={(v) => update({ acceptGate: parseInt(v, 10) as PanoramaSettings['acceptGate'] })}
974
- caption="0 = accept on every frame (Apple-dense). 50 = V13.0g throttle."
975
- />
976
- </Accordion>
977
- )}
978
-
979
- {/* ──────────────────────────────────────────────
980
- * OUTPUT — always visible.
316
+ * STITCHER (`stitcher` sub-tree, closed by default)
981
317
  * ────────────────────────────────────────────── */}
982
- <SectionHeader title="Recording cap" />
983
- <SegmentedControl
984
- options={['4 s', '6 s', '8 s', '10 s']}
985
- value={`${Math.round(settings.maxRecordingMs / 1000)} s`}
986
- onChange={(v) => update({ maxRecordingMs: parseInt(v, 10) * 1000 })}
987
- caption="Auto-stops the hold-recording at this duration."
988
- />
989
- <SectionHeader title="JPEG quality" />
990
- <SegmentedControl
991
- options={['70', '85', '92']}
992
- value={String(settings.quality)}
993
- onChange={(v) => update({ quality: parseInt(v, 10) })}
994
- caption="Higher = bigger files, sharper detail. 85 is the recommended default."
995
- />
996
-
997
- {/* ──────────────────────────────────────────────
998
- * DEBUG — surfaces stitch telemetry to the operator on
999
- * every successful finalize. See PanoramaSettings.debug.
1000
- * ────────────────────────────────────────────── */}
1001
- <SectionHeader title="Debug" />
1002
- <SegmentedControl
1003
- options={['off', 'on']}
1004
- value={settings.debug ? 'on' : 'off'}
1005
- onChange={(v) => update({ debug: v === 'on' })}
1006
- 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."
1007
- />
1008
-
1009
- {/* ──────────────────────────────────────────────
1010
- * DIAGNOSTICS / FALLBACKS — closed by default. AR is
1011
- * the active path for 99% of use; the vision-camera
1012
- * fallback path lives here for emergencies.
1013
- * ────────────────────────────────────────────── */}
1014
- <Accordion title="Diagnostics / fallbacks" badge="rarely needed">
1015
- <View style={styles.infoBox}>
1016
- <Text style={styles.infoText}>
1017
- AR-backed capture is the recommended path. Toggle off
1018
- ONLY if ARKit fails on a specific device (very rare on
1019
- modern iPhones). Doing so falls back to vision-camera
1020
- video recording + post-stitch via cv::Stitcher.
1021
- </Text>
1022
- </View>
1023
- <SectionHeader title="AR-backed capture" />
318
+ <Accordion title="Stitcher (cv::Stitcher knobs)">
319
+ <SectionHeader title="Stitch mode" />
1024
320
  <SegmentedControl
1025
- options={['on', 'off']}
1026
- value={settings.useARPreview ? 'on' : 'off'}
1027
- onChange={(v) => update({ useARPreview: v === 'on' })}
1028
- caption="Default ON. OFF only when ARKit is unavailable or for A/B testing."
321
+ options={['auto', 'panorama', 'scans']}
322
+ value={settings.stitcher.stitchMode}
323
+ onChange={(v) => updateStitcher({
324
+ stitchMode: v as BatchStitcherSettings['stitchMode'],
325
+ })}
326
+ caption="auto (default): pick PANORAMA or SCANS based on translation/rotation totals at finalize. panorama: rotation-only (spherical warper, BA-Ray) — best for rotate-in-place captures; BAD on translation. scans: affine pipeline (plane warper, BA-affine) — best for shelf-pan captures; never diverges on rotation either. Both modes auto-retry with the opposite if camera params come out degenerate."
327
+ />
328
+ <SectionHeader title="Warper" />
329
+ <SegmentedControl
330
+ options={['plane', 'cylindrical', 'spherical']}
331
+ value={settings.stitcher.warperType}
332
+ onChange={(v) => updateStitcher({
333
+ warperType: v as BatchStitcherSettings['warperType'],
334
+ })}
335
+ caption="plane (default): flat rectangular output, best for retail shelves. cylindrical: rotational mid-arc. spherical: wide pans (180°+), always curved. Only consulted in panorama mode; scans hardwires PlaneWarper."
336
+ />
337
+ <SectionHeader title="Blender" />
338
+ <SegmentedControl
339
+ options={['multiband', 'feather']}
340
+ value={settings.stitcher.blenderType}
341
+ onChange={(v) => updateStitcher({
342
+ blenderType: v as BatchStitcherSettings['blenderType'],
343
+ })}
344
+ caption="multiband (default): Laplacian-pyramid blending; cleanest seams, holds all warped frames in memory. feather: streams warp+feed (lower peak memory, no halo with varied exposure). <Camera> auto-picks feather on low-memory devices."
345
+ />
346
+ <SectionHeader title="Seam finder" />
347
+ <SegmentedControl
348
+ options={['graphcut', 'skip']}
349
+ value={settings.stitcher.seamFinderType}
350
+ onChange={(v) => updateStitcher({
351
+ seamFinderType: v as BatchStitcherSettings['seamFinderType'],
352
+ })}
353
+ caption="graphcut (default): cv::detail::GraphCutSeamFinder for optimal seams; pairs with multiband. skip: stream warp+feed (lowest-memory configuration; pair with feather)."
354
+ />
355
+ <SectionHeader title="Inscribed-rect crop" />
356
+ <SegmentedControl
357
+ options={['off', 'on']}
358
+ value={settings.stitcher.enableMaxInscribedRectCrop ? 'on' : 'off'}
359
+ onChange={(v) => updateStitcher({
360
+ enableMaxInscribedRectCrop: v === 'on',
361
+ })}
362
+ caption="off (default): crop to cv::boundingRect of non-black pixels — preserves all stitched content; may leave black corners. on: run MaxInscribedRectFromMask + column-projection second-pass for a clean rectangle (can shrink output if mask is lopsided)."
1029
363
  />
1030
- {!settings.useARPreview && (
1031
- <>
1032
- <SectionHeader title="Frame extraction — Frames per second" />
1033
- <SegmentedControl
1034
- options={['2', '3', '4']}
1035
- value={String(settings.framesPerSecond)}
1036
- onChange={(v) => update({ framesPerSecond: parseInt(v, 10) })}
1037
- caption="Frames/sec extracted from the recorded video. Lower = faster but riskier overlap."
1038
- />
1039
- <SectionHeader title="Frame extraction — Frame count clamp" />
1040
- <SegmentedControl
1041
- options={['4-12', '6-16', '8-20']}
1042
- value={`${settings.minFrames}-${settings.maxFrames}`}
1043
- onChange={(v) => {
1044
- const [min, max] = v.split('-').map((n) => parseInt(n, 10));
1045
- update({ minFrames: min, maxFrames: max });
1046
- }}
1047
- caption="Floor/ceiling for extracted frames."
1048
- />
1049
- </>
1050
- )}
1051
364
  </Accordion>
1052
365
 
366
+ {/* ──────────────────────────────────────────────
367
+ * RESET TO DEFAULTS
368
+ * ────────────────────────────────────────────── */}
1053
369
  <Pressable
1054
370
  onPress={() => onChange(DEFAULT_PANORAMA_SETTINGS)}
1055
371
  style={styles.resetBtn}
@@ -1066,21 +382,20 @@ export function PanoramaSettingsModal({
1066
382
  }
1067
383
 
1068
384
 
385
+ // ════════════════════════════════════════════════════════════════════
386
+ // Helpers (kept verbatim from v0.3 — presentational primitives the
387
+ // modal composes; nothing in here depends on the settings shape).
388
+ // ════════════════════════════════════════════════════════════════════
389
+
1069
390
  function SectionHeader({ title }: { title: string }) {
1070
391
  return <Text style={styles.sectionHeader}>{title}</Text>;
1071
392
  }
1072
393
 
1073
394
 
1074
395
  /**
1075
- * Collapsible section. Used for closed-by-default groupings
1076
- * ("Advanced", "Diagnostics / fallbacks") so the modal's primary
1077
- * surface stays focused on the controls operators actually touch
1078
- * day-to-day.
1079
- *
1080
- * State is local — each Accordion instance manages its own open
1081
- * flag. The modal opens fresh-collapsed every mount which is what
1082
- * we want for now; persisting open state across mounts (e.g. via
1083
- * AsyncStorage) is a future enhancement.
396
+ * Collapsible section. Each instance owns its open/closed state;
397
+ * the modal opens fresh-collapsed on every mount, which is what we
398
+ * want (no AsyncStorage roundtrip on every settings tweak).
1084
399
  */
1085
400
  function Accordion({
1086
401
  title,
@@ -1114,9 +429,10 @@ function Accordion({
1114
429
 
1115
430
 
1116
431
  /**
1117
- * Small grey-text badge. Marks sections / fields as "advanced",
1118
- * "experimental", "legacy", or similar quick visual signal that
1119
- * the operator can usually ignore them.
432
+ * Small grey-text badge marks sections as "advanced",
433
+ * "experimental", or similar. No semantic effect; purely a quick
434
+ * visual signal. Kept for future Layer-2 settings modals that may
435
+ * want to flag experimental sub-trees.
1120
436
  */
1121
437
  function Tag({ label }: { label: string }): React.JSX.Element {
1122
438
  return (
@@ -1228,18 +544,21 @@ const styles = StyleSheet.create({
1228
544
  marginTop: 18,
1229
545
  marginBottom: 8,
1230
546
  },
1231
- row: {
1232
- marginTop: 4,
547
+ // Nested-sub-section label inside an accordion body — used for the
548
+ // Flow-tunables sub-tree under Frame selection.
549
+ nested: {
550
+ marginTop: 12,
551
+ paddingTop: 12,
552
+ borderTopWidth: StyleSheet.hairlineWidth,
553
+ borderTopColor: 'rgba(255,255,255,0.08)',
1233
554
  },
1234
- label: {
1235
- color: '#ffffff',
1236
- opacity: 0.85,
1237
- fontSize: 13,
555
+ nestedLabel: {
556
+ color: 'rgba(255,255,255,0.55)',
557
+ fontSize: 11,
1238
558
  fontWeight: '600',
1239
559
  textTransform: 'uppercase',
1240
- letterSpacing: 0.5,
1241
- marginTop: 18,
1242
- marginBottom: 8,
560
+ letterSpacing: 0.8,
561
+ marginBottom: 4,
1243
562
  },
1244
563
  segmentedRow: {
1245
564
  flexDirection: 'row',
@@ -1299,7 +618,6 @@ const styles = StyleSheet.create({
1299
618
  fontSize: 14,
1300
619
  fontWeight: '500',
1301
620
  },
1302
- // V16 Phase 1b — Accordion + Tag + InfoBox
1303
621
  accordion: {
1304
622
  marginTop: 18,
1305
623
  backgroundColor: 'rgba(255,255,255,0.04)',
@@ -1344,15 +662,4 @@ const styles = StyleSheet.create({
1344
662
  textTransform: 'uppercase',
1345
663
  letterSpacing: 0.5,
1346
664
  },
1347
- infoBox: {
1348
- backgroundColor: 'rgba(255,255,255,0.06)',
1349
- borderRadius: 8,
1350
- padding: 12,
1351
- marginTop: 4,
1352
- },
1353
- infoText: {
1354
- color: 'rgba(255,255,255,0.75)',
1355
- fontSize: 12,
1356
- lineHeight: 17,
1357
- },
1358
665
  });