Cut panorama overlaps along seams instead of averaging them

The merge weighted every overlap pixel by its distance from each frame's
edge, a 200 px linear cross-fade. Anything the frames disagreed on —
parallax in the near foreground, grass in the wind, a walker — came out
twice at half strength: a soft double edge at 1:1.

dr_pano::seam picks, per output texel at proxy resolution, which frame a
pixel comes from. Where a new frame overlaps the composite the cost is the
gain-corrected difference plus local detail plus nearness to either
frame's edge, taken as the worst over a small window, and the cut is a
dynamic-programming path across the overlap. merge.wgsl weights each frame
by its tent-filtered share of that map, a 64 px blend that follows the
seam, with the edge feather kept as the fallback. The page's preview uses
the same map, and examples/merge.rs takes --feather-only for comparison.
This commit is contained in:
2026-09-30 21:57:44 -04:00
parent 1d7115437b
commit 6f33517b35
8 changed files with 1012 additions and 32 deletions
+74 -11
View File
@@ -42,7 +42,7 @@ use dr_gpu::{
};
use dr_pano::bundle::Cameras;
use dr_pano::projection::{self, Projection};
use dr_pano::{Alignment, Gray};
use dr_pano::{Alignment, Gray, SeamMap};
use dr_pipeline::EditGraph;
pub use crate::export::Cancel;
@@ -103,8 +103,14 @@ pub struct MergeRequest {
pub destination: MergeDestination,
/// `None` for the projection the field of view suggests.
pub projection: Option<Projection>,
/// Pixels over which a frame's weight ramps up from its edge.
/// Pixels over which a frame's weight ramps up from its edge, where no
/// seam says which frame a pixel is taken from.
pub feather_px: f32,
/// Whether overlaps are cut along seams (`dr_pano::seam`) or averaged
/// across `feather_px` everywhere.
pub seams: bool,
/// The width, in output pixels, of the blend across a seam.
pub seam_blend_px: f32,
/// GPU work unit; also the DNG strip height.
pub chunk: (u32, u32),
/// The border filler's weights, if the device has them (FR-MRG-4).
@@ -122,6 +128,8 @@ impl MergeRequest {
destination,
projection: None,
feather_px: 200.0,
seams: true,
seam_blend_px: 64.0,
chunk: (2048, 512),
inpaint_model: None,
decoder: dr_decode::default(),
@@ -478,11 +486,20 @@ fn run_inner(
let preview = match s.alignment.is_complete() {
false => None,
true => {
let map = request
.seams
.then(|| seams(&proxies, &s.alignment, &s.gains, projection))
.flatten();
let blend = map.as_deref().map(|m| {
let full = m.scale * s.focal_full / s.alignment.focal;
(m, m.blend_radius(full, f64::from(request.seam_blend_px)))
});
let (w, h, mut rgb, mut known) = preview_planes(
&colour,
&proxies,
&s.alignment,
&s.gains,
blend,
projection,
first_raw,
1600,
@@ -605,6 +622,13 @@ fn run_inner(
}
};
// The seams, on the surface chosen, over the frames kept.
let seam_map = request.seams.then(|| {
let kept: Vec<&Gray> = solved.keep.iter().map(|&k| &proxies[k]).collect();
seams(&kept, &solved.alignment, &solved.gains, projection)
});
let seam_map = seam_map.flatten();
// From here on only the kept frames exist, indexed as the alignment
// indexes them.
let Solved {
@@ -704,6 +728,8 @@ fn run_inner(
scale: focal_full,
bounds,
feather: request.feather_px,
seams: seam_map.clone(),
seam_blend: request.seam_blend_px,
chunk: request.chunk,
sample_scale: white_level as f32,
balance,
@@ -746,6 +772,8 @@ fn run_inner(
max_v: bounds.max_v / q,
},
feather: request.feather_px,
seams: seam_map.clone(),
seam_blend: request.seam_blend_px / q as f32,
chunk: request.chunk,
sample_scale: white_level as f32,
balance,
@@ -1227,21 +1255,51 @@ fn solve(
})
}
/// Which frame each part of the composite comes from (`dr_pano::seam`), on
/// `projection` at the proxies' scale. `None` when the alignment is not
/// complete — the merge then falls back to the feathered average.
fn seams(
proxies: &[&Gray],
alignment: &Alignment,
gains: &[f32],
projection: Projection,
) -> Option<Arc<SeamMap>> {
if !alignment.is_complete() {
return None;
}
let t = Instant::now();
let map = dr_pano::seam::find(
proxies,
&alignment.cameras(),
gains,
projection,
&dr_pano::SeamOptions::default(),
);
log::info!(
"merge: seams over {} frames in {:?}",
proxies.len(),
t.elapsed()
);
map.map(Arc::new)
}
/// The aligned set on its surface, in colour, for the page.
///
/// A quick look, not the pipeline: the first frame's white balance and
/// matrix, a gamma, and the frames averaged where they overlap with their
/// gains applied. Ghosting here is the alignment's error and banding is
/// the gains', which is exactly what the photographer is being asked to
/// look at. Fitted to 1600 px across.
/// matrix, a gamma, and the frames joined along the same seams the merge
/// will use, with their gains applied. Ghosting here is the alignment's
/// error and banding is the gains', which is exactly what the photographer
/// is being asked to look at. Fitted to 1600 px across.
/// The aligned set on its surface, in colour, as planes: `(w, h, rgb 0..1,
/// known)`. Display-ish space — the first frame's white balance and matrix,
/// a gamma — which is also what the border filler was trained on.
#[allow(clippy::too_many_arguments)]
fn preview_planes(
colour: &[&[f32]],
proxies: &[&Gray],
alignment: &Alignment,
gains: &[f32],
seams: Option<(&SeamMap, f64)>,
projection: Projection,
first: &RawImage,
max_width: usize,
@@ -1264,7 +1322,7 @@ fn preview_planes(
let v = bounds.min_v + (oy as f64 + 0.5) * px;
let d = projection.to_direction(scale, u, v);
let mut sum = [0.0f32; 3];
let mut n = 0u32;
let mut n = 0.0f32;
for (k, g) in proxies.iter().enumerate() {
let Some((x, y)) = cameras.project(k, d) else {
continue;
@@ -1276,16 +1334,21 @@ fn preview_planes(
let i = (y as usize * g.width + x as usize) * 3;
let cam =
grey_if_blown([colour[k][i], colour[k][i + 1], colour[k][i + 2]], look.wb);
// The seam's share, as the merge weighs it, with the same
// floor under it; an even average where there is no map.
let w = seams
.and_then(|(m, radius)| m.share(k, u, v, scale, radius))
.map_or(1.0, |s| s + 1e-4);
for c in 0..3 {
sum[c] += cam[c] * gains[k];
sum[c] += cam[c] * gains[k] * w;
}
n += 1;
n += w;
}
if n == 0 {
if n <= 0.0 {
continue;
}
let o = oy * out_w + ox;
let d = look.to_display([sum[0] / n as f32, sum[1] / n as f32, sum[2] / n as f32]);
let d = look.to_display([sum[0] / n, sum[1] / n, sum[2] / n]);
rgb[o * 3..o * 3 + 3].copy_from_slice(&d);
known[o] = true;
}