Files
dtourolle 6f33517b35 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.
2026-09-30 21:57:44 -04:00

214 lines
8.3 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Merge a set of frames into a panorama DNG, headless.
//!
//! ```sh
//! cargo run -p dr-ui --example merge --release -- fixtures/pano/2025-08-05/*.CR2
//! cargo run -p dr-ui --example merge --release -- --out /var/tmp --fill frame1.CR2 …
//! 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.
//!
//! The job the interface runs (`dr_ui::merge`), driven from a loop instead
//! of a timer: the same code, the same events, a file at the end. What it
//! proves that the alignment example cannot is the second half — the tap,
//! the chunked warp, the resolve to sixteen bits and the DNG — and that the
//! result opens in the application as one RAW.
use std::path::PathBuf;
use std::time::Instant;
fn main() {
env_logger::init();
let mut args: Vec<String> = std::env::args().skip(1).collect();
let mut out_dir = None;
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") => {
args.remove(0);
out_dir = Some(PathBuf::from(args.remove(0)));
}
Some("--fill") => {
args.remove(0);
fill = true;
}
Some("--leave-out") => {
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;
}
_ => break,
}
}
if args.len() < 2 {
eprintln!("usage: merge [--out DIR] [--fill] [--wait-engines] <frame> <frame>...");
std::process::exit(2);
}
let Ok(gpu) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
eprintln!("no GPU");
std::process::exit(1);
};
// The inference engine as the desktop app starts it, so the border fill
// runs where the app would run it — TensorRT, ONNX Runtime's CPU pool —
// rather than on tract, which is a hundred times slower for this model.
// `DARKROOM_ORT_DIR` first, as the desktop app takes it: a runtime with
// the TensorRT provider where the distribution's has none.
let mut runtime_dirs = Vec::new();
if let Some(dir) = std::env::var_os("DARKROOM_ORT_DIR") {
runtime_dirs.push(std::path::PathBuf::from(dir));
}
runtime_dirs.push(std::path::PathBuf::from("/usr/lib/darkroom"));
runtime_dirs.push(std::path::PathBuf::from("/usr/lib"));
dr_ui::inference::init(runtime_dirs);
// A compiling rung (TensorRT) serves models from the fallback until
// their engines are built, in the background, on first run; the app
// lives long enough for that, this example does not. `--wait-engines`
// waits for the cache to fill so the fill's timing is the rung's.
if wait_engines {
loop {
let s = dr_inference_engine::status();
eprintln!(
"inference: {} ({}), engines {}/{}",
s.line(),
s.reason,
s.engines.0,
s.engines.1
);
if !s.probing && s.engines.0 >= s.engines.1 {
break;
}
std::thread::sleep(std::time::Duration::from_secs(5));
}
}
let frames: Vec<dr_ui::merge::MergeInput> = args
.iter()
.map(|a| dr_ui::merge::MergeInput::read(std::path::Path::new(a)).expect("read"))
.collect();
let dir = out_dir.unwrap_or_else(|| {
PathBuf::from(&args[0])
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."))
});
let mut request =
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();
let t = Instant::now();
let worker = std::thread::spawn(move || dr_ui::merge::run(gpu, request, tx, decision, cancel));
let mut last_stage = "";
for event in rx {
use dr_ui::merge::MergeEvent::*;
match event {
Progress { stage, done, total } => {
if stage != last_stage {
println!("{stage}…");
last_stage = stage;
}
if done == total || done % 4 == 0 {
println!(" {done}/{total}");
}
}
Aligned(r) => {
println!(
"aligned: {:.1} mm equivalent, rms {:.2} px, {} links, {:?} {}×{}",
r.focal_mm, r.rms_px, r.links, r.projection, r.width, r.height
);
for (k, f) in r.frames.iter().enumerate() {
match f {
Ok((yaw, pitch)) => {
println!(" frame {k}: yaw {yaw:7.2}° pitch {pitch:6.2}°")
}
Err(why) if !r.included[k] => println!(" frame {k}: {why}"),
Err(why) => println!(" frame {k}: NOT ALIGNED — {why}"),
}
}
println!(
" border fill: {}",
match &r.filler {
Ok(rung) => format!("available, runs on {rung}"),
Err(why) => why.clone(),
}
);
if let Some(k) = leave_out.take() {
let mut keep = r.included.clone();
if let Some(slot) = keep.get_mut(k) {
*slot = false;
}
println!(" aligning again without frame {k}");
let _ = decide.send(dr_ui::merge::Decision::Frames {
keep,
projection: None,
fill: None,
});
continue;
}
// Headless: what a photographer would untick — the frames
// that could not be placed — then the confirmation.
if !r.is_complete() {
let keep: Vec<bool> = r
.frames
.iter()
.zip(&r.included)
.map(|(f, &kept)| kept && f.is_ok())
.collect();
if keep == r.included || keep.iter().filter(|k| **k).count() < 2 {
eprintln!("not enough frames can be placed to merge");
let _ = decide.send(dr_ui::merge::Decision::Abandon);
} else {
println!(" aligning again without the frames that could not be placed");
let _ = decide.send(dr_ui::merge::Decision::Frames {
keep,
projection: None,
fill: None,
});
}
continue;
}
let _ = decide.send(dr_ui::merge::Decision::Merge {
projection: None,
fill: fill.then(dr_ui::merge::FillSettings::default),
});
}
Done {
path,
width,
height,
..
} => {
println!(
"wrote {} ({width}×{height}) in {:?}",
path.display(),
t.elapsed()
);
}
Failed(e) => {
eprintln!("failed: {e}");
std::process::exit(1);
}
Cancelled => println!("cancelled"),
}
}
let _ = worker.join();
}