From ce23f496a6679528609ffc6bfb0df43b26418431 Mon Sep 17 00:00:00 2001 From: Rufan Date: Tue, 14 Jul 2026 23:07:24 +0200 Subject: [PATCH] feat(reco-core,reco-gui,reco-cli): manual seam positioning + debug line Reimplemented from scratch against the current architecture (post "stitch unification" / "executor spine" refactors) - the original implementation predates both, and neither seam_offset nor a debug-line concept exist anywhere in this codebase currently. Core addition: Topology::seam_offset (f32, default 0.0, bounded to +/-SEAM_OFFSET_RANGE=0.3), following this codebase's established pattern of homing render-affecting rig state on the Calibration document (alongside blend_width) rather than on ViewportConfig. Shifts only the alpha-crossfade threshold within the coverage the calibration already guarantees - never the plane geometry itself, so it cannot open a black gap the way moving `intersect` could. Threaded through both render paths that must agree by construction: - GPU: fisheye.wgsl's previously-unused lens_preview.z slot now carries seam_offset; lens_preview.w carries show_seam_line (a debug line drawn by reusing the exact same edge_dist the alpha blend computes, so it can never disagree with where the blend actually sits). - CPU: BlendRule::Smoothstep gained an `offset` field (was a bare width). This is the software stitcher that must mirror the shader exactly for the agreement-oracle tests - added cpu_and_gpu_backends_agree_with_seam_offset (a real end-to-end GPU test on real hardware, not just a compile check) alongside a focused BlendRule::alpha unit test proving the offset shifts the curve without changing its shape. show_seam_line is deliberately NOT calibration state (lives on StitchPipeline directly, GPU-executor-only - the CPU executor is a headless correctness oracle / GPU-less render path with no interactive preview to draw a debug overlay onto). Wiring: StitchCore::set_seam_offset/set_show_seam_line -> StitchSession::set_show_seam_line (seam_offset flows through Calibration directly, matching blend_width) -> StitchJob::seam_offset()/ show_seam_line() builders -> `reco stitch --seam-offset/--show-seam-line` -> reco-gui "Seam position" slider + "Show seam line" checkbox (Stitching panel and export, mirroring the existing Seam blend slider's wiring exactly, including calibration-load/reset sync). Verified: cargo fmt --check clean; cargo test -p reco-core (153 passed, including both new tests; only the 2 pre-existing CUDA hardware-gap failures, unrelated); cargo check clean across the whole workspace (reco-obs excluded, pre-existing unrelated OBS-SDK build gap). clippy blocked by the same 4 pre-existing issues on upstream's own current main fixed in the separate fix/d3d11-stage-frame-unsafe branch - not caused by this change (confirmed identical on a clean origin/main checkout before any of this branch's commits). Co-Authored-By: Claude Sonnet 5 --- crates/reco-autocam/src/panners/field.rs | 1 + crates/reco-autocam/src/panners/sweep.rs | 1 + crates/reco-calibrate/src/optimizer.rs | 1 + crates/reco-cli/src/main.rs | 19 ++++++ crates/reco-cli/src/stitch.rs | 8 +++ crates/reco-core/src/calibration.rs | 30 +++++++++ crates/reco-core/src/core/mod.rs | 12 ++++ crates/reco-core/src/detect/panner.rs | 1 + crates/reco-core/src/projection/mod.rs | 11 +++- crates/reco-core/src/render/pipeline.rs | 25 +++++++ crates/reco-core/src/render/renderer.rs | 7 ++ crates/reco-core/src/render/scene.rs | 2 + crates/reco-core/src/session/tests.rs | 1 + crates/reco-core/src/session/wiring.rs | 8 +++ crates/reco-core/src/shaders/fisheye.wgsl | 23 ++++++- crates/reco-core/src/stitch/executor.rs | 80 +++++++++++++++++++++++ crates/reco-core/src/stitch/mod.rs | 45 +++++++++++-- crates/reco-gui/src/export.rs | 4 ++ crates/reco-gui/src/main.rs | 60 +++++++++++++++++ crates/reco-gui/ui/main.slint | 19 ++++++ crates/reco-io/src/stitch_job.rs | 32 +++++++++ 21 files changed, 380 insertions(+), 10 deletions(-) diff --git a/crates/reco-autocam/src/panners/field.rs b/crates/reco-autocam/src/panners/field.rs index 2e4ff4fd0..5188adf3e 100644 --- a/crates/reco-autocam/src/panners/field.rs +++ b/crates/reco-autocam/src/panners/field.rs @@ -1151,6 +1151,7 @@ mod tests { x_rx: 0.0, z_rz: 0.0, blend_width: 0.05, + seam_offset: 0.0, }, Framing { axis_offset: 0.24, diff --git a/crates/reco-autocam/src/panners/sweep.rs b/crates/reco-autocam/src/panners/sweep.rs index cfc3e5109..e140d2929 100644 --- a/crates/reco-autocam/src/panners/sweep.rs +++ b/crates/reco-autocam/src/panners/sweep.rs @@ -108,6 +108,7 @@ mod tests { x_rx: 0.0, z_rz: 0.0, blend_width: 0.05, + seam_offset: 0.0, }, Framing { axis_offset: 0.24, diff --git a/crates/reco-calibrate/src/optimizer.rs b/crates/reco-calibrate/src/optimizer.rs index f38d4e804..a05aeb935 100644 --- a/crates/reco-calibrate/src/optimizer.rs +++ b/crates/reco-calibrate/src/optimizer.rs @@ -416,6 +416,7 @@ impl Optimizer for NelderMeadOptimizer { }, z_rz: 0.0, blend_width: reco_core::calibration::DEFAULT_BLEND_WIDTH, + seam_offset: 0.0, }; let framing = Framing { axis_offset: params.cam_d, diff --git a/crates/reco-cli/src/main.rs b/crates/reco-cli/src/main.rs index 0c65bcfde..6b8c0477f 100644 --- a/crates/reco-cli/src/main.rs +++ b/crates/reco-cli/src/main.rs @@ -176,6 +176,21 @@ enum Commands { #[arg(long, value_parser = parse_blend)] blend: Option, + /// Manual nudge of the seam position, in the right plane's own + /// local UV units (same space as `--blend`). `0.0` (default) = + /// seam sits exactly where the calibration's geometry puts it. + /// Overrides the calibration's saved value; when omitted, the + /// calibration decides. + #[arg(long)] + seam_offset: Option, + + /// Draw a thin debug line at the exact geometric seam position, + /// independent of how wide `--blend` currently feathers it. Pure + /// visualization aid for tuning calibration - has no effect on + /// the blend math itself. + #[arg(long, default_value_t = false)] + show_seam_line: bool, + /// Frame offset for temporal sync between cameras. /// Positive: skip N right frames (right started first). /// Negative: skip N left frames (left started first). @@ -787,6 +802,8 @@ fn main() -> anyhow::Result<()> { codec, quality, blend, + seam_offset, + show_seam_line, sync_offset, model, detection_interval, @@ -812,6 +829,8 @@ fn main() -> anyhow::Result<()> { width, height, blend, + seam_offset, + show_seam_line, start_time, end_time, max_frames, diff --git a/crates/reco-cli/src/stitch.rs b/crates/reco-cli/src/stitch.rs index b2490b89c..acedb296d 100644 --- a/crates/reco-cli/src/stitch.rs +++ b/crates/reco-cli/src/stitch.rs @@ -24,6 +24,8 @@ pub struct StitchArgs<'a> { pub width: u32, pub height: u32, pub blend: Option, + pub seam_offset: Option, + pub show_seam_line: bool, pub start_time: Option, pub end_time: Option, pub max_frames: Option, @@ -126,6 +128,12 @@ pub fn run_stitch(args: StitchArgs<'_>, interrupted: &Arc) -> anyhow if let Some(b) = args.blend { job = job.blend_width(b); } + if let Some(o) = args.seam_offset { + job = job.seam_offset(o); + } + if args.show_seam_line { + job = job.show_seam_line(true); + } if let Some(t) = args.start_time { job = job.start_time(t); } diff --git a/crates/reco-core/src/calibration.rs b/crates/reco-core/src/calibration.rs index 35bb7d05a..eec35468c 100644 --- a/crates/reco-core/src/calibration.rs +++ b/crates/reco-core/src/calibration.rs @@ -227,6 +227,13 @@ pub struct Topology { /// Seam blend width as a fraction of the plane overlap. `0.0` = hard seam. #[serde(default = "default_blend_width")] pub blend_width: f32, + /// Manual nudge of the seam position, in the right plane's own local + /// UV units (same space as `blend_width`). `0.0` (default) = seam sits + /// exactly where the plane geometry's own edge puts it. Shifts only + /// the alpha-crossfade threshold, never the plane geometry itself, so + /// it cannot open a coverage gap the way adjusting `intersect` can. + #[serde(default)] + pub seam_offset: f32, } /// Default seam blend width for calibrations that do not specify one. @@ -237,6 +244,10 @@ fn default_blend_width() -> f32 { DEFAULT_BLEND_WIDTH } +/// Safe bound (both directions) for [`Topology::seam_offset`], in the +/// right plane's own local UV units. +pub const SEAM_OFFSET_RANGE: f32 = 0.3; + /// The virtual camera's calibrated coordinate frame: the axis/orientation that /// panning evolves *within*. Pan (yaw/pitch) and output framing (fov/size) are /// runtime state and are deliberately NOT stored here. @@ -598,6 +609,24 @@ fn validate_topology(t: &Topology) -> Result<(), CalibrationError> { }); } + if !t.seam_offset.is_finite() { + return Err(CalibrationError::NonFiniteFloat { + field: "topology.seam_offset".to_owned(), + value: format!("{}", t.seam_offset), + }); + } + // Bounded to a safe range within the coverage the geometry already + // guarantees - well past this and the shift itself (not the smoothstep + // math) starts pushing the visible seam into unrelated content. + if !(-SEAM_OFFSET_RANGE..=SEAM_OFFSET_RANGE).contains(&t.seam_offset) { + return Err(CalibrationError::OutOfRange { + field: "topology.seam_offset".to_owned(), + value: t.seam_offset as f64, + min: -SEAM_OFFSET_RANGE as f64, + max: SEAM_OFFSET_RANGE as f64, + }); + } + Ok(()) } @@ -721,6 +750,7 @@ mod tests { x_rx: 0.0, z_rz: 0.0, blend_width: 0.05, + seam_offset: 0.0, }, framing: Framing { axis_offset: 0.25, diff --git a/crates/reco-core/src/core/mod.rs b/crates/reco-core/src/core/mod.rs index 0352cc049..df49884aa 100644 --- a/crates/reco-core/src/core/mod.rs +++ b/crates/reco-core/src/core/mod.rs @@ -487,6 +487,18 @@ impl StitchCore { self.executor.set_blend_width(width); } + /// Set the manual seam-position nudge (per-frame uniform; coverage + /// unaffected). See [`crate::calibration::Topology::seam_offset`]. + pub fn set_seam_offset(&mut self, offset: f32) { + self.executor.set_seam_offset(offset); + } + + /// Toggle the seam debug line (GPU executor only; see + /// [`crate::stitch::Executor::set_show_seam_line`]). + pub fn set_show_seam_line(&mut self, show: bool) { + self.executor.set_show_seam_line(show); + } + /// Set the lens-correction strength on every lens (`0` = pinhole, /// `1` = full KB4). pub fn set_lens_correction_amount(&mut self, amount: f32) { diff --git a/crates/reco-core/src/detect/panner.rs b/crates/reco-core/src/detect/panner.rs index d4a1f1330..ca4a7df2b 100644 --- a/crates/reco-core/src/detect/panner.rs +++ b/crates/reco-core/src/detect/panner.rs @@ -247,6 +247,7 @@ mod tests { x_rx: 0.0, z_rz: 0.0, blend_width: 0.05, + seam_offset: 0.0, }, Framing { axis_offset: 0.24, diff --git a/crates/reco-core/src/projection/mod.rs b/crates/reco-core/src/projection/mod.rs index bb25e682c..07283f505 100644 --- a/crates/reco-core/src/projection/mod.rs +++ b/crates/reco-core/src/projection/mod.rs @@ -126,7 +126,10 @@ impl Projection for LShapeProjection { (Box::new(left), BlendRule::Opaque), ( Box::new(right), - BlendRule::Smoothstep(calibration.topology.blend_width as f64), + BlendRule::Smoothstep { + width: calibration.topology.blend_width as f64, + offset: calibration.topology.seam_offset as f64, + }, ), ] } @@ -497,6 +500,7 @@ mod tests { x_rx: 0.0, z_rz: 0.0, blend_width: 0.05, + seam_offset: 0.0, }, Framing { axis_offset: 0.2398, @@ -909,7 +913,10 @@ mod tests { assert_eq!(surfaces[0].1, crate::stitch::BlendRule::Opaque); assert_eq!( surfaces[1].1, - crate::stitch::BlendRule::Smoothstep(cal.topology.blend_width as f64) + crate::stitch::BlendRule::Smoothstep { + width: cal.topology.blend_width as f64, + offset: cal.topology.seam_offset as f64, + } ); } diff --git a/crates/reco-core/src/render/pipeline.rs b/crates/reco-core/src/render/pipeline.rs index 3f75178b7..c8a863466 100644 --- a/crates/reco-core/src/render/pipeline.rs +++ b/crates/reco-core/src/render/pipeline.rs @@ -78,6 +78,10 @@ pub struct StitchPipeline { pub(crate) calibration: Calibration, /// Output viewport configuration. pub(crate) viewport: ViewportConfig, + /// Draw a debug line at the exact seam position. Pure visualization + /// (calibration-tuning aid), not calibration state - deliberately not + /// part of `Calibration` so toggling it never touches the saved file. + pub(crate) show_seam_line: bool, /// GPU renderer (textures, pipelines, bind groups). renderer: Renderer, /// Input frame dimensions. @@ -161,6 +165,7 @@ impl StitchPipeline { scene, calibration, viewport, + show_seam_line: false, renderer, input_width, input_height, @@ -271,6 +276,20 @@ impl StitchPipeline { self.calibration.topology.blend_width = width; } + /// Set the manual seam-position nudge (per-frame uniform; no scene + /// rebuild). See [`crate::calibration::Topology::seam_offset`]. + pub fn set_seam_offset(&mut self, offset: f32) { + self.calibration.topology.seam_offset = offset; + } + + /// Toggle the seam debug line (per-frame uniform; no scene rebuild). + /// Draws a thin highlight at the exact rendered seam position by + /// reusing the same alpha-threshold math the blend itself uses, so + /// it can never disagree with where the blend actually sits. + pub fn set_show_seam_line(&mut self, show: bool) { + self.show_seam_line = show; + } + /// Update calibration parameters. Recomputes [`SceneGeometry`] from the /// new layout. Takes effect on the next render call (uniforms are rebuilt /// each frame from the stored calibration and scene). @@ -546,6 +565,7 @@ impl StitchPipeline { &self.calibration, &viewport, self.calibration.topology.blend_width, + self.show_seam_line, target_view, ); Ok(()) @@ -583,6 +603,7 @@ impl StitchPipeline { &self.calibration, &viewport, self.calibration.topology.blend_width, + self.show_seam_line, target_view, ); Ok(()) @@ -624,6 +645,7 @@ impl StitchPipeline { &self.calibration, &viewport, self.calibration.topology.blend_width, + self.show_seam_line, )) } @@ -662,6 +684,7 @@ impl StitchPipeline { &self.calibration, &viewport, self.calibration.topology.blend_width, + self.show_seam_line, )) } @@ -700,6 +723,7 @@ impl StitchPipeline { &self.calibration, &viewport, self.calibration.topology.blend_width, + self.show_seam_line, )) } @@ -763,6 +787,7 @@ impl StitchPipeline { &self.calibration, &viewport, self.calibration.topology.blend_width, + self.show_seam_line, ) } diff --git a/crates/reco-core/src/render/renderer.rs b/crates/reco-core/src/render/renderer.rs index 8db9701fd..af58ef381 100644 --- a/crates/reco-core/src/render/renderer.rs +++ b/crates/reco-core/src/render/renderer.rs @@ -809,6 +809,7 @@ impl Renderer { calibration: &Calibration, viewport: &ResolvedViewport, blend_width: f32, + show_seam_line: bool, target_view: &wgpu::TextureView, aspect: f32, encoder_label: &str, @@ -852,6 +853,8 @@ impl Renderer { self.is_full_range, ); right_uniforms.lens_preview[0] = calibration.lenses[1].correction; + right_uniforms.lens_preview[2] = calibration.topology.seam_offset; + right_uniforms.lens_preview[3] = if show_seam_line { 1.0 } else { 0.0 }; gpu.queue.write_buffer( &self.left.uniform_buffer, @@ -920,6 +923,7 @@ impl Renderer { calibration: &Calibration, viewport: &ResolvedViewport, blend_width: f32, + show_seam_line: bool, ) -> wgpu::CommandBuffer { let aspect = self.output_width as f32 / self.output_height as f32; let encoder = self.encode_stitch_pass( @@ -928,6 +932,7 @@ impl Renderer { calibration, viewport, blend_width, + show_seam_line, &self.render_target_view, aspect, "stitch_to_target", @@ -954,6 +959,7 @@ impl Renderer { calibration: &Calibration, viewport: &ResolvedViewport, blend_width: f32, + show_seam_line: bool, target_view: &wgpu::TextureView, ) { let aspect = viewport.config.width as f32 / viewport.config.height as f32; @@ -963,6 +969,7 @@ impl Renderer { calibration, viewport, blend_width, + show_seam_line, target_view, aspect, "preview_frame", diff --git a/crates/reco-core/src/render/scene.rs b/crates/reco-core/src/render/scene.rs index 9f3256bae..0d846c7c5 100644 --- a/crates/reco-core/src/render/scene.rs +++ b/crates/reco-core/src/render/scene.rs @@ -131,6 +131,7 @@ mod tests { x_rx: 0.0, z_rz: 0.0, blend_width: 0.05, + seam_offset: 0.0, } } @@ -164,6 +165,7 @@ mod tests { x_rx: 0.0, z_rz: 0.0, blend_width: 0.05, + seam_offset: 0.0, }; let geom = SceneGeometry::new(&topology, &framing(0.24), 16.0 / 9.0); diff --git a/crates/reco-core/src/session/tests.rs b/crates/reco-core/src/session/tests.rs index 86e5ff5fb..450145e71 100644 --- a/crates/reco-core/src/session/tests.rs +++ b/crates/reco-core/src/session/tests.rs @@ -41,6 +41,7 @@ fn test_calibration() -> Calibration { x_rx: 0.0, z_rz: 0.0, blend_width: 0.05, + seam_offset: 0.0, }, Framing { axis_offset: 0.25, diff --git a/crates/reco-core/src/session/wiring.rs b/crates/reco-core/src/session/wiring.rs index a0240fea1..4aec1ef10 100644 --- a/crates/reco-core/src/session/wiring.rs +++ b/crates/reco-core/src/session/wiring.rs @@ -111,6 +111,14 @@ impl StitchSession { self.lookahead_frames = frames; } + /// Toggle the seam debug line (see + /// [`StitchCore::set_show_seam_line`](crate::core::StitchCore::set_show_seam_line)). + /// Pure visualization aid - has no effect on the rendered seam blend + /// itself. + pub fn set_show_seam_line(&mut self, show: bool) { + self.core.set_show_seam_line(show); + } + /// Attach a stacked-video replay recorder. /// /// Forwards to `StitchCore::set_stacked_recorder` on the diff --git a/crates/reco-core/src/shaders/fisheye.wgsl b/crates/reco-core/src/shaders/fisheye.wgsl index 1ddbfe9f6..6cc53a13f 100644 --- a/crates/reco-core/src/shaders/fisheye.wgsl +++ b/crates/reco-core/src/shaders/fisheye.wgsl @@ -27,6 +27,12 @@ struct Uniforms { flags: vec4, // lens_preview.x: correction_amount (0.0 = no correction, 1.0 = full KB4) // lens_preview.y: split_view (> 0.5 = left half uncorrected, right half corrected) + // lens_preview.z: seam_offset (Topology::seam_offset) - manual nudge of + // the seam-blend alpha threshold, in the right plane's own local UV + // units. 0.0 = no-op (seam sits exactly where the plane geometry's + // own edge puts it). + // lens_preview.w: show_seam_line (> 0.5 = draw a debug line at the + // exact seam position, offset included) lens_preview: vec4, }; @@ -228,14 +234,27 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { // Apply YUV-space color transfer color = apply_color_transfer(color, u.color_scale.xyz, u.color_offset_blend.xyz); - // Compute alpha for seam blending (right plane fades in at left edge) + // Compute alpha for seam blending (right plane fades in at left edge). + // seam_offset (lens_preview.z) manually nudges the crossfade + // threshold within the coverage the calibration already guarantees - + // it shifts only this alpha math, never the plane geometry, so it + // cannot open a black gap the way moving the plane itself could. var alpha = 1.0; let blend_width = u.color_offset_blend.w; + let seam_offset = u.lens_preview.z; + let edge_dist = uv.x - seam_offset; if u.flags.x == 1u && blend_width > 0.0 { - let edge_dist = uv.x; alpha = smoothstep(0.0, blend_width, edge_dist); } + // Seam debug line: a thin highlight at the exact seam position + // (offset included), reusing `edge_dist` from the alpha computation + // above so the line can never disagree with where the blend + // actually sits. + if u.flags.x == 1u && u.lens_preview.w > 0.5 && abs(edge_dist) < 0.0015 { + return vec4(1.0, 0.15, 0.15, 1.0); + } + // Split-view separator line (1px white at the midpoint) if u.lens_preview.y > 0.5 && abs(uv.x - 0.5) < 0.001 { return vec4(1.0, 1.0, 1.0, alpha); diff --git a/crates/reco-core/src/stitch/executor.rs b/crates/reco-core/src/stitch/executor.rs index cab10707d..da871994c 100644 --- a/crates/reco-core/src/stitch/executor.rs +++ b/crates/reco-core/src/stitch/executor.rs @@ -531,6 +531,28 @@ impl Executor { } } + /// Set the manual seam-position nudge (document field; no geometry + /// rebuild). See [`crate::calibration::Topology::seam_offset`]. + pub fn set_seam_offset(&mut self, offset: f32) { + match self { + Executor::Cpu(c) => c.calib.topology.seam_offset = offset, + #[cfg(feature = "gpu")] + Executor::Gpu(g) => g.pipeline.set_seam_offset(offset), + } + } + + /// Toggle the seam debug line. GPU-only visualization aid - the CPU + /// executor is a headless correctness oracle / GPU-less render path + /// with no interactive preview to draw a debug overlay onto, so this + /// is a no-op on that arm. + pub fn set_show_seam_line(&mut self, show: bool) { + match self { + Executor::Cpu(_) => {} + #[cfg(feature = "gpu")] + Executor::Gpu(g) => g.pipeline.set_show_seam_line(show), + } + } + /// Set the lens-correction strength on every lens, clamped to `[0, 1]`. pub fn set_lens_correction_amount(&mut self, amount: f32) { match self { @@ -835,4 +857,62 @@ mod tests { Agreement::compare(gpu_rgba, cpu_rgba) .assert_within(AgreementBounds::DEFAULT, "backend cpu-vs-gpu"); } + + /// Same as [`cpu_and_gpu_backends_agree`] but with a non-zero + /// `Topology::seam_offset` - the CPU (`BlendRule::Smoothstep::offset`) + /// and GPU (`fisheye.wgsl`'s `lens_preview.z`) seam-offset math must + /// shift the alpha threshold identically, or this codebase's + /// agreement-oracle guarantee would be silently broken for this + /// feature specifically. + #[test] + #[cfg(feature = "gpu")] + fn cpu_and_gpu_backends_agree_with_seam_offset() { + let Some(gpu) = gpu_or_skip() else { + return; + }; + + let (cam_w, cam_h) = (192u32, 108u32); + let (out_w, out_h) = (160u32, 90u32); + let mut calib = calib(cam_w, cam_h); + calib.topology.seam_offset = 0.08; + let config = ViewportConfig { + width: out_w, + height: out_h, + ..Default::default() + }; + let (ly, luv) = nv12(cam_w, cam_h, 0); + let (ry, ruv) = nv12(cam_w, cam_h, 30); + let left = Nv12Planes { y: &ly, uv: &luv }; + let right = Nv12Planes { y: &ry, uv: &ruv }; + let (yaw, pitch) = (0.08f32, -0.04f32); + + let mut cpu = CpuExecutor::new( + Box::new(crate::projection::LShapeProjection), + calib.clone(), + config.clone(), + cam_w, + cam_h, + false, + ) + .expect("cpu backend"); + let mut gpu = GpuExecutor::new( + gpu, + GpuExecutorConfig { + viewport: config, + ..GpuExecutorConfig::new(calib, cam_w, cam_h, InputFormat::Nv12) + }, + ) + .expect("gpu backend"); + + let backends: [&mut dyn StitchExecutor; 2] = [&mut cpu, &mut gpu]; + let mut outputs = Vec::new(); + for b in backends { + outputs.push(b.stitch(&left, &right, yaw, pitch).expect("stitch")); + } + let (cpu_rgba, gpu_rgba) = (&outputs[0], &outputs[1]); + Agreement::compare(gpu_rgba, cpu_rgba).assert_within( + AgreementBounds::DEFAULT, + "backend cpu-vs-gpu, seam_offset=0.08", + ); + } } diff --git a/crates/reco-core/src/stitch/mod.rs b/crates/reco-core/src/stitch/mod.rs index f12258152..91d255f37 100644 --- a/crates/reco-core/src/stitch/mod.rs +++ b/crates/reco-core/src/stitch/mod.rs @@ -44,10 +44,11 @@ pub use executor::{GpuExecutor, GpuExecutorConfig}; pub enum BlendRule { /// Fully replaces whatever is underneath wherever this surface covers. Opaque, - /// Fades in over `[0, width]` of the surface's [`SurfaceUv::edge`] - /// coordinate (the GPU's seam smoothstep). A non-positive width - /// renders fully opaque wherever the surface covers. - Smoothstep(f64), + /// Fades in over `[offset, offset + width]` of the surface's + /// [`SurfaceUv::edge`] coordinate (the GPU's seam smoothstep, + /// `Topology::seam_offset`-shifted). A non-positive width renders + /// fully opaque wherever the surface covers. + Smoothstep { width: f64, offset: f64 }, } impl BlendRule { @@ -55,8 +56,10 @@ impl BlendRule { pub(crate) fn alpha(&self, edge: f64) -> f64 { match *self { BlendRule::Opaque => 1.0, - BlendRule::Smoothstep(width) if width > 0.0 => smoothstep(0.0, width, edge), - BlendRule::Smoothstep(_) => 1.0, + BlendRule::Smoothstep { width, offset } if width > 0.0 => { + smoothstep(0.0, width, edge - offset) + } + BlendRule::Smoothstep { .. } => 1.0, } } } @@ -129,6 +132,7 @@ pub(crate) mod test_support { x_rx: 0.0, z_rz: 0.0, blend_width: 0.05, + seam_offset: 0.0, }, Framing { axis_offset: 0.25, @@ -280,6 +284,35 @@ mod tests { use crate::render::planes::Nv12Planes; use crate::render::viewport::ViewportConfig; + #[test] + fn seam_offset_shifts_the_smoothstep_without_changing_its_shape() { + use super::BlendRule; + + let rule = BlendRule::Smoothstep { + width: 0.1, + offset: 0.0, + }; + let offset_rule = BlendRule::Smoothstep { + width: 0.1, + offset: 0.05, + }; + + // A zero offset must reproduce the pre-existing (no-offset) + // behavior exactly - default calibrations must render unchanged. + assert_eq!(rule.alpha(0.0), 0.0); + assert_eq!(rule.alpha(0.05), 0.5); + assert_eq!(rule.alpha(0.1), 1.0); + + // A +0.05 offset must shift the same curve by exactly +0.05: the + // point that used to be the midpoint (edge=0.05) is now where the + // curve starts (alpha=0), and the old start (edge=0.0) is now + // fully outside the band (alpha=0, clamped, not negative). + assert_eq!(offset_rule.alpha(0.05), 0.0); + assert_eq!(offset_rule.alpha(0.10), 0.5); + assert_eq!(offset_rule.alpha(0.15), 1.0); + assert_eq!(offset_rule.alpha(0.0), 0.0); + } + #[test] fn output_dimensions_and_opaque_alpha() { let (w, h) = (96u32, 54u32); diff --git a/crates/reco-gui/src/export.rs b/crates/reco-gui/src/export.rs index e1c8c3b35..745d3af42 100644 --- a/crates/reco-gui/src/export.rs +++ b/crates/reco-gui/src/export.rs @@ -117,6 +117,8 @@ pub fn run_export( codec_str: String, quality_str: String, blend: f32, + seam_offset: f32, + show_seam_line: bool, start_secs: f32, end_secs: f32, autocam: AutocamUiConfig, @@ -196,6 +198,8 @@ pub fn run_export( .format(format) .resolution(width, height) .blend_width(blend) + .seam_offset(seam_offset) + .show_seam_line(show_seam_line) .on_progress(move |p: &reco_core::session::types::FrameProgress| { let frames = p.frames_completed; let elapsed = progress_start.elapsed().as_secs_f64(); diff --git a/crates/reco-gui/src/main.rs b/crates/reco-gui/src/main.rs index 2763a88b8..ad5120797 100644 --- a/crates/reco-gui/src/main.rs +++ b/crates/reco-gui/src/main.rs @@ -931,6 +931,31 @@ impl AppState { } } + /// Set the manual seam-position nudge. Same mirror-into-calibration + /// rationale as [`Self::set_blend_width`]. + fn set_seam_offset(&mut self, offset: f32) { + let offset = offset.clamp( + -reco_core::calibration::SEAM_OFFSET_RANGE, + reco_core::calibration::SEAM_OFFSET_RANGE, + ); + if let Some(cal) = self.calibration.as_mut() { + cal.topology.seam_offset = offset; + } + if let Some(bridge) = self.bridge.as_mut() { + bridge.engine_mut().set_seam_offset(offset); + self.preview_dirty = true; + } + } + + /// Toggle the seam debug line. Pure visualization - not calibration + /// state, not persisted. + fn set_show_seam_line(&mut self, show: bool) { + if let Some(bridge) = self.bridge.as_mut() { + bridge.engine_mut().set_show_seam_line(show); + self.preview_dirty = true; + } + } + fn set_rig_tilt(&mut self, deg: f32) { if let Some(cal) = self.calibration.as_mut() { cal.framing.tilt = (deg as f64).to_radians(); @@ -2763,6 +2788,22 @@ fn main() -> anyhow::Result<()> { } }); + let state_ref = Rc::clone(&state); + let app_weak = app.as_weak(); + app.on_changed_seam_offset(move |offset| { + state_ref.borrow_mut().set_seam_offset(offset); + // Seam offset is persisted with the calibration, so a change is + // unsaved work - surface the Save Calibration button. + if let Some(app) = app_weak.upgrade() { + app.set_cal_dirty(true); + } + }); + + let state_ref = Rc::clone(&state); + app.on_toggled_show_seam_line(move |show| { + state_ref.borrow_mut().set_show_seam_line(show); + }); + let state_ref = Rc::clone(&state); let app_weak = app.as_weak(); app.on_changed_rig_tilt(move |deg| { @@ -2902,6 +2943,7 @@ fn main() -> anyhow::Result<()> { app.set_rig_tilt((layout.framing.tilt as f32).to_degrees()); app.set_rig_roll((layout.framing.roll as f32).to_degrees()); app.set_blend_width(layout.topology.blend_width); + app.set_seam_offset(layout.topology.seam_offset); app.set_cal_dirty(false); } }); @@ -3479,6 +3521,8 @@ fn main() -> anyhow::Result<()> { let codec_str = app.get_export_codec().to_string(); let quality_str = app.get_export_quality().to_string(); let blend = app.get_blend_width(); + let seam_offset = app.get_seam_offset(); + let show_seam_line = app.get_show_seam_line(); let start_secs = app.get_export_start_secs(); let end_secs = app.get_export_end_secs(); log::info!("Export range: start={start_secs:.1}s, end={end_secs:.1}s"); @@ -3563,6 +3607,8 @@ fn main() -> anyhow::Result<()> { codec_str, quality_str, blend, + seam_offset, + show_seam_line, start_secs, end_secs, autocam, @@ -4269,6 +4315,10 @@ fn try_init_and_update(state: &Rc>, app_weak: &slint::Weak>, app_weak: &slint::Weak pitch: 0.0; // radians in-out property fov: 75.0; // degrees in-out property blend-width: 0.05; + in-out property seam-offset: 0.0; + in-out property show-seam-line: false; in-out property rig-tilt: 0.0; // degrees in-out property rig-roll: 0.0; // degrees in-out property sync-offset: 0; // frames @@ -704,6 +706,8 @@ export component RecoApp inherits Window { callback reset-view(); // Settings-panel slider callbacks. callback changed-blend-width(float); + callback changed-seam-offset(float); + callback toggled-show-seam-line(bool); callback changed-rig-tilt(float); callback changed-rig-roll(float); callback changed-sync-offset(int); @@ -1117,6 +1121,21 @@ export component RecoApp inherits Window { changed(v) => { root.changed-blend-width(v); } } + if root.files-loaded: LabeledSlider { + label: "Seam position"; + minimum: -0.3; + maximum: 0.3; + value <=> root.seam-offset; + decimals: 3; + changed(v) => { root.changed-seam-offset(v); } + } + + if root.files-loaded: CheckBox { + text: "Show seam line"; + checked <=> root.show-seam-line; + toggled => { root.toggled-show-seam-line(self.checked); } + } + if root.files-loaded: LabeledSlider { label: "Rig tilt"; minimum: -30; diff --git a/crates/reco-io/src/stitch_job.rs b/crates/reco-io/src/stitch_job.rs index 9a6350ce9..2b3205108 100644 --- a/crates/reco-io/src/stitch_job.rs +++ b/crates/reco-io/src/stitch_job.rs @@ -56,6 +56,12 @@ pub struct StitchJob { /// Seam blend override. `None` (default) respects the calibration /// document's saved value - the single home for render params. blend_width: Option, + /// Manual seam-position nudge override, same override semantics as + /// `blend_width`. + seam_offset: Option, + /// Draw a debug line at the exact seam position. Pure visualization, + /// not calibration state - always defaults off. + show_seam_line: bool, // Callbacks on_progress: Option, @@ -250,6 +256,8 @@ impl StitchJob { max_frames: None, sync_offset: None, blend_width: None, + seam_offset: None, + show_seam_line: false, on_progress: None, on_finalizing: None, session_hooks: Vec::new(), @@ -374,6 +382,22 @@ impl StitchJob { self } + /// Override the calibration's saved manual seam-position nudge, in + /// the right plane's own local UV units (same space as `blend_width`). + /// When not called, the calibration document's value is used. + pub fn seam_offset(mut self, offset: f32) -> Self { + self.seam_offset = Some(offset); + self + } + + /// Draw a debug line at the exact seam position (offset included). + /// Pure visualization aid for tuning calibration - has no effect on + /// the blend math itself. Off by default. + pub fn show_seam_line(mut self, show: bool) -> Self { + self.show_seam_line = show; + self + } + // ── Replay recording (M6.5 stacked-video) ── /// Record pre-stitch source frames to a stacked-video file at @@ -600,6 +624,13 @@ impl StitchJob { cal.topology.blend_width ); } + if let Some(offset) = self.seam_offset { + log::info!( + "seam offset: overriding calibration value {} with {offset}", + cal.topology.seam_offset + ); + cal.topology.seam_offset = offset; + } let viewport = reco_core::render::viewport::ViewportConfig { width: out_w, height: out_h, @@ -616,6 +647,7 @@ impl StitchJob { right_rotation: source.right_rotation(), }; let mut session = reco_core::session::StitchSession::with_gpu(gpu, session_config)?; + session.set_show_seam_line(self.show_seam_line); session.telemetry_mut().set_gpu_name(gpu_name.clone()); session.telemetry_mut().set_decode_mode(decode_mode.clone());