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:
@@ -6,6 +6,9 @@
|
||||
//! cargo run -p dr-ui --example merge --release -- --leave-out 3 fixtures/pano/2025-08-05/*.CR2
|
||||
//! ```
|
||||
//!
|
||||
//! `--feather-only` merges without seams — every overlap the feathered
|
||||
//! average — for comparing the two on the same set.
|
||||
//!
|
||||
//! `--leave-out N` unticks frame `N` once the first alignment is in, as the
|
||||
//! page's box would, so the job aligns again without it.
|
||||
//!
|
||||
@@ -25,6 +28,7 @@ fn main() {
|
||||
let mut fill = false;
|
||||
let mut wait_engines = false;
|
||||
let mut leave_out: Option<usize> = None;
|
||||
let mut seams = true;
|
||||
loop {
|
||||
match args.first().map(String::as_str) {
|
||||
Some("--out") => {
|
||||
@@ -39,6 +43,10 @@ fn main() {
|
||||
args.remove(0);
|
||||
leave_out = args.remove(0).parse().ok();
|
||||
}
|
||||
Some("--feather-only") => {
|
||||
args.remove(0);
|
||||
seams = false;
|
||||
}
|
||||
Some("--wait-engines") => {
|
||||
args.remove(0);
|
||||
wait_engines = true;
|
||||
@@ -101,6 +109,7 @@ fn main() {
|
||||
dr_ui::merge::MergeRequest::new(frames, dr_ui::merge::MergeDestination::Local(dir));
|
||||
// The filler from the shared models directory, as the app finds it.
|
||||
request.inpaint_model = dr_ui::merge::inpaint_model_path();
|
||||
request.seams = seams;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (decide, decision) = std::sync::mpsc::channel();
|
||||
let cancel = dr_ui::merge::Cancel::default();
|
||||
|
||||
+74
-11
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user