Files
DarkRoom/ui/dr-ui/src/merge_ui.rs
T
dtourolle 621a6b8313 Leave a panorama frame out without leaving the page
A frame that did not fit ended the job with its name, and the only way
on was Back, a smaller selection and every frame read, demosaiced and
searched for keypoints again. Each row on the page now has a box. An
unticked frame is left out and the rest are solved again from what the
first pass measured.

dr_pano::align is split for it: match_pairs does the matching and the
pairwise RANSAC once over every frame (about 5 s for twelve), and solve
takes a subset of the frames and uses only the links among them (about
0.1 s). Solving a subset by re-aligning it also moved every RANSAC seed,
which are keyed on frame position, and on the fixture set that was
enough to lose a marginal link and strand a neighbour of the frame left
out.

A frame that cannot be placed no longer stops the job either. Its row
names it and Merge stays off until it is unticked; the headless example
leaves such frames out the same way, and takes --leave-out N to try it.
2026-09-27 16:29:39 -04:00

735 lines
28 KiB
Rust
Raw 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.
//! TRACES: FR-MRG-1 | FR-MRG-4 | FR-MRG-5 | FR-MRG-7
//! The merge page: the grid's selection to a panorama, with a pause for
//! the photographer in the middle.
//!
//! The job itself is [`crate::merge`] and has no interface in it; this is
//! the wiring, on the import page's model. A press on "Merge to panorama"
//! fetches the selected originals on a worker, hands them to the job, and
//! opens the page; a timer drains the job's events into the page's
//! properties. When the alignment arrives the page shows it and waits.
//! A frame's box leaves it out or brings it back, and the job aligns again
//! over the rest. "Merge" sends the decision; "Stop" or "Back" cancels. When the file is
//! staged, the outbox drains and the library rescans, and the composite
//! appears in the grid beside its sources.
use crate::executors::{self, Executor};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use std::sync::mpsc::{Receiver, Sender};
use slint::ComponentHandle;
use crate::activity::{Activity, ActivityLog, Kind};
use crate::merge::{
self, AlignmentReport, Cancel, Decision, FillSettings, MergeDestination, MergeEvent,
MergeInput, MergeRequest,
};
use crate::{AppWindow, Library, MergeFrameRow, Page};
/// How often the page reads the job's channel.
const DRAIN_INTERVAL: std::time::Duration = std::time::Duration::from_millis(120);
/// A running merge, as the page holds it.
struct Job {
rx: Receiver<MergeEvent>,
decide: Sender<Decision>,
cancel: Cancel,
names: Vec<String>,
activity: Activity,
}
pub struct MergeController {
activity: Rc<ActivityLog>,
job: RefCell<Option<Job>>,
timer: RefCell<Option<slint::Timer>>,
/// The projection chip: 0 auto, then the three in `Projection`'s order.
projection: Cell<i32>,
/// The border chip: 0 crop, 1 fill (FR-MRG-4).
border: Cell<i32>,
/// The fill's knobs, as the page's sliders have them.
fill: Cell<FillSettings>,
report: RefCell<Option<AlignmentReport>>,
}
impl MergeController {
/// The fill to ask for: the knobs if the border chip says fill, else
/// none.
fn fill_choice(&self) -> Option<FillSettings> {
(self.border.get() == 1).then(|| self.fill.get())
}
}
impl MergeController {
pub fn new(activity: Rc<ActivityLog>) -> Rc<Self> {
Rc::new(MergeController {
activity,
job: RefCell::new(None),
timer: RefCell::new(None),
projection: Cell::new(0),
border: Cell::new(0),
fill: Cell::new(FillSettings::default()),
report: RefCell::new(None),
})
}
}
/// What the page needs from the library to start: the account for the
/// fetch, and where the outbox is.
pub struct Context {
pub conn: dr_sync::Connection,
pub outbox: std::path::PathBuf,
}
/// Wire the page. `sources` yields the selection as fetchable library
/// sources; `context` the account; `on_done` runs when a composite has
/// been staged, so the caller can drain the outbox and rescan.
pub fn wire<S, C, F>(
window: &AppWindow,
ctl: Rc<MergeController>,
gpu: Option<dr_gpu::GpuContext>,
sources: S,
context: C,
on_done: F,
) where
S: Fn() -> Vec<crate::export::Source> + 'static,
C: Fn() -> Option<Context> + 'static,
F: Fn(&AppWindow) + 'static,
{
wire_start(window, &ctl, gpu, sources, context, on_done);
wire_decision(window, &ctl);
wire_stop_and_leave(window, &ctl);
}
/// Start.
fn wire_start<S, C, F>(
window: &AppWindow,
ctl: &Rc<MergeController>,
gpu: Option<dr_gpu::GpuContext>,
sources: S,
context: C,
on_done: F,
) where
S: Fn() -> Vec<crate::export::Source> + 'static,
C: Fn() -> Option<Context> + 'static,
F: Fn(&AppWindow) + 'static,
{
let on_done = Rc::new(on_done);
let gpu_for_start = gpu.clone();
{
let weak = window.as_weak();
let ctl = ctl.clone();
let on_done = on_done.clone();
window
.global::<Library>()
.on_library_merge_selection(move || {
let Some(w) = weak.upgrade() else { return };
if ctl.job.borrow().is_some() {
w.global::<Library>()
.set_library_status("A panorama is already being merged".into());
return;
}
let Some(gpu) = gpu.clone() else {
w.global::<Library>().set_library_status(
"No GPU: a panorama cannot be merged on this device".into(),
);
return;
};
let Some(context) = context() else {
w.global::<Library>()
.set_library_status("Open a library first".into());
return;
};
// The selection, in library order. The catalog's order is the
// grid's, which is capture time — the order a sweep was shot.
let sources: Vec<(String, Option<crate::library::CacheContext>)> = sources()
.into_iter()
.filter_map(|s| match s {
crate::export::Source::Library { path, cache } => Some((path, cache)),
crate::export::Source::Rendered { .. } => None,
})
.collect();
if sources.len() < 2 {
w.global::<Library>()
.set_library_status("Select at least two frames to merge".into());
return;
}
// Where the composite goes: beside its sources, through the
// outbox (FR-MRG-3). The sources' folder, relative to the root.
let root = context.conn.account.root.trim_matches('/').to_string();
let first_dir = std::path::Path::new(&sources[0].0)
.parent()
.map(|p| p.to_string_lossy().trim_matches('/').to_string())
.unwrap_or_default();
let remote_dir = first_dir
.strip_prefix(&root)
.map(|s| s.trim_start_matches('/').to_string())
.unwrap_or(first_dir);
let destination = MergeDestination::Outbox {
outbox: context.outbox,
remote_dir,
};
let names: Vec<String> = sources
.iter()
.map(|(p, _)| {
std::path::Path::new(p)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| p.clone())
})
.collect();
// The fetch, on the job's own thread before the job: every
// original comes down in turn — sequential so twelve downloads
// do not halve each other's bandwidth — and the job then takes
// bytes, which keeps it free of the library.
let conn = context.conn.clone();
let fetch_names = names.clone();
let fetch =
move |tx: &Sender<MergeEvent>, cancel: &Cancel| -> Option<Vec<MergeInput>> {
let mut frames = Vec::with_capacity(sources.len());
for (i, (path, cache)) in sources.into_iter().enumerate() {
if cancel.is_cancelled() {
let _ = tx.send(MergeEvent::Cancelled);
return None;
}
let _ = tx.send(MergeEvent::Progress {
stage: "Fetching",
done: i,
total: fetch_names.len(),
});
let rx =
crate::library::spawn_full_fetch(conn.clone(), path.clone(), cache);
let bytes = match rx.recv() {
Ok(Ok(bytes)) => bytes,
Ok(Err(e)) => {
let _ = tx.send(MergeEvent::Failed(format!(
"{}: {}",
fetch_names[i], e.message
)));
return None;
}
Err(_) => {
let _ = tx.send(MergeEvent::Failed(format!(
"{}: the download ended without answering",
fetch_names[i]
)));
return None;
}
};
frames.push(MergeInput {
name: fetch_names[i].clone(),
bytes: std::sync::Arc::new(bytes),
});
}
Some(frames)
};
start(&w, &ctl, gpu, names, destination, fetch, &on_done);
});
}
// A way to land on the page at startup with local files, for looking
// at it without a mouse: `DARKROOM_START_MERGE=a.CR2,b.CR2`. The same
// job, the same page; only the fetch is a read. Off unless set.
if let Some(list) = std::env::var_os("DARKROOM_START_MERGE") {
let paths: Vec<std::path::PathBuf> = list
.to_string_lossy()
.split(',')
.filter(|p| !p.is_empty())
.map(std::path::PathBuf::from)
.collect();
if let (Some(gpu), Some(first)) = (gpu_for_start.clone(), paths.first()) {
let names: Vec<String> = paths
.iter()
.map(|p| {
p.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
})
.collect();
let dir = first
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_default();
let read_paths = paths.clone();
let fetch = move |tx: &Sender<MergeEvent>, _: &Cancel| -> Option<Vec<MergeInput>> {
let mut frames = Vec::new();
for p in &read_paths {
match MergeInput::read(p) {
Ok(f) => frames.push(f),
Err(e) => {
let _ = tx.send(MergeEvent::Failed(e));
return None;
}
}
}
Some(frames)
};
start(
window,
ctl,
gpu,
names,
MergeDestination::Local(dir),
fetch,
&on_done,
);
// `DARKROOM_START_MERGE_FILL=1` opens it with the fill chosen,
// to look at the knobs; the preview fills once the alignment
// is in, as a chip pick would have it.
if std::env::var_os("DARKROOM_START_MERGE_FILL").is_some() {
ctl.border.set(1);
window.set_merge_border_selected(1);
if let Some(job) = ctl.job.borrow().as_ref() {
let _ = job.decide.send(Decision::Preview {
projection: None,
fill: ctl.fill_choice(),
});
}
}
}
}
}
/// The decision.
fn wire_decision(window: &AppWindow, ctl: &Rc<MergeController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_merge_confirm(move || {
let Some(w) = weak.upgrade() else { return };
let projection = chip_projection(ctl.projection.get());
let fill = ctl.fill_choice();
if let Some(job) = ctl.job.borrow().as_ref() {
let _ = job.decide.send(Decision::Merge { projection, fill });
job.activity.detail("merging");
}
w.set_merge_running(true);
w.set_merge_stage("Merging".into());
w.set_merge_status("".into());
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_merge_projection_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
ctl.projection.set(i);
w.set_merge_projection_selected(i);
// Show it: the job redraws the preview on that surface and
// reports again, and the drain puts the new picture up.
if let Some(job) = ctl.job.borrow().as_ref() {
let _ = job.decide.send(Decision::Preview {
projection: chip_projection(i),
fill: ctl.fill_choice(),
});
}
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_merge_frame_toggled(move |i, on| {
let Some(w) = weak.upgrade() else { return };
// The set the alignment on screen was solved over, with this
// frame flipped. The box is controlled: it moves when the job's
// report comes back with the frame in or out.
let Some(mut keep) = ctl.report.borrow().as_ref().map(|r| r.included.clone()) else {
return;
};
let Some(slot) = keep.get_mut(i as usize) else {
return;
};
*slot = on;
if keep.iter().filter(|k| **k).count() < 2 {
w.set_merge_status("A panorama needs at least two frames.".into());
return;
}
if let Some(job) = ctl.job.borrow().as_ref() {
let _ = job.decide.send(Decision::Frames {
keep,
projection: chip_projection(ctl.projection.get()),
fill: ctl.fill_choice(),
});
}
w.set_merge_running(true);
w.set_merge_stage("Aligning".into());
w.set_merge_status("".into());
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_merge_border_picked(move |i| {
let Some(w) = weak.upgrade() else { return };
ctl.border.set(i);
w.set_merge_border_selected(i);
// The fill is shown before it is chosen for good: the job
// redraws the preview with the border filled, or not.
if let Some(job) = ctl.job.borrow().as_ref() {
let _ = job.decide.send(Decision::Preview {
projection: chip_projection(ctl.projection.get()),
fill: ctl.fill_choice(),
});
}
w.set_merge_running(true);
w.set_merge_stage(
if i == 1 {
"Filling the preview"
} else {
"Drawing the preview"
}
.into(),
);
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_merge_fill_knob(move |name, value| {
let Some(w) = weak.upgrade() else { return };
let mut f = ctl.fill.get();
let v = value.round().max(0.0) as usize;
match name.as_str() {
"scale" => f.scale = (v as u32).clamp(1, 8),
"erosion" => f.erosion = v,
"coarse" => f.params.coarse = v.max(1),
"band" => f.params.band = v.max(8),
"mirror" => f.params.mirror_depth = v,
"feather" => f.params.feather = v,
_ => return,
}
ctl.fill.set(f);
show_fill_knobs(&w, &f);
// A knob turned while the fill is chosen redraws the preview
// with it; turned under the crop it waits for the chip.
if ctl.border.get() != 1 {
return;
}
if let Some(job) = ctl.job.borrow().as_ref() {
let _ = job.decide.send(Decision::Preview {
projection: chip_projection(ctl.projection.get()),
fill: Some(f),
});
}
w.set_merge_running(true);
w.set_merge_stage("Filling the preview".into());
});
}
}
/// Stopping and leaving.
fn wire_stop_and_leave(window: &AppWindow, ctl: &Rc<MergeController>) {
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_merge_abandon(move || {
let Some(w) = weak.upgrade() else { return };
if let Some(job) = ctl.job.borrow().as_ref() {
job.cancel.cancel();
let _ = job.decide.send(Decision::Abandon);
}
w.set_merge_status("Stopping…".into());
});
}
{
let weak = window.as_weak();
let ctl = ctl.clone();
window.on_merge_close(move || {
let Some(w) = weak.upgrade() else { return };
// Leaving is abandoning: the page is the only thing that can
// confirm the merge, so a job left waiting behind it would wait
// for ever. A merge that is already writing is stopped too — the
// page is where it reports, and a file finishing unseen would be
// a composite the photographer did not confirm seeing.
if let Some(job) = ctl.job.borrow_mut().take() {
job.cancel.cancel();
let _ = job.decide.send(Decision::Abandon);
job.activity.finish_quietly();
}
*ctl.timer.borrow_mut() = None;
w.set_active_page(Page::None);
});
}
}
/// Start a job: `fetch` runs first on the job's thread and hands back the
/// frames (or reports why not and returns `None`); the job follows on the
/// same thread. The page opens clean, and a timer drains the events.
fn start<Fetch>(
window: &AppWindow,
ctl: &Rc<MergeController>,
gpu: dr_gpu::GpuContext,
names: Vec<String>,
destination: MergeDestination,
fetch: Fetch,
on_done: &Rc<impl Fn(&AppWindow) + 'static>,
) where
Fetch: FnOnce(&Sender<MergeEvent>, &Cancel) -> Option<Vec<MergeInput>> + Send + 'static,
{
let (tx, rx) = std::sync::mpsc::channel();
let (decide, decision) = std::sync::mpsc::channel();
let cancel = Cancel::default();
let activity = ctl.activity.begin(Kind::Download, "Panorama");
activity.total(names.len());
{
let cancel = cancel.clone();
let tx = tx.clone();
executors::spawn(Executor::GpuSubmit, "merge", move || {
let Some(frames) = fetch(&tx, &cancel) else {
return;
};
let mut request = MergeRequest::new(frames, destination);
request.inpaint_model = crate::library::inpaint_model();
merge::run(gpu, request, tx, decision, cancel);
});
}
*ctl.job.borrow_mut() = Some(Job {
rx,
decide,
cancel,
names,
activity,
});
*ctl.report.borrow_mut() = None;
ctl.projection.set(0);
ctl.border.set(0);
ctl.fill.set(FillSettings::default());
show_fill_knobs(window, &FillSettings::default());
// The page, from a clean slate.
window.set_merge_stage("Fetching".into());
window.set_merge_running(true);
window.set_merge_fraction(0.0);
window.set_merge_status("".into());
window.set_merge_error("".into());
window.set_merge_aligned(false);
window.set_merge_complete(false);
window.set_merge_summary("".into());
window.set_merge_frames(slint::ModelRc::new(slint::VecModel::from(Vec::<
MergeFrameRow,
>::new())));
window.set_merge_preview(slint::Image::default());
window.set_merge_projection_selected(0);
window.set_merge_border_selected(0);
window.set_merge_fill_available(false);
window.set_merge_fill_note("".into());
window.set_merge_preview_filled(false);
window.set_merge_done(false);
window.set_active_page(Page::Merge);
let timer = slint::Timer::default();
{
let weak = window.as_weak();
let ctl = ctl.clone();
let on_done = on_done.clone();
timer.start(slint::TimerMode::Repeated, DRAIN_INTERVAL, move || {
let Some(w) = weak.upgrade() else { return };
drain(&w, &ctl, &on_done);
});
}
*ctl.timer.borrow_mut() = Some(timer);
}
/// The projection a chip index names; 0 is "Auto".
fn chip_projection(i: i32) -> Option<dr_pano::Projection> {
match i {
1 => Some(dr_pano::Projection::Perspective),
2 => Some(dr_pano::Projection::Cylindrical),
3 => Some(dr_pano::Projection::Spherical),
_ => None,
}
}
/// Take everything the job has said and reflect it on the page.
fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&AppWindow)>) {
let (events, gone) = {
let job = ctl.job.borrow();
let Some(job) = job.as_ref() else { return };
merge::drain(&job.rx)
};
// The worker is the only sender, so a closed channel with nothing final
// said is a worker that died without saying anything — the one way
// `merge::run` cannot report, since reporting is what it was doing.
// Without this the page stayed on "Stop" for ever.
let mut events = events;
if gone && !events.iter().any(MergeEvent::is_final) {
events.push(MergeEvent::Failed(
"the merge stopped unexpectedly; the log has the reason".into(),
));
}
if events.is_empty() {
return;
}
let mut finished = false;
for event in events {
match event {
MergeEvent::Progress { stage, done, total } => {
window.set_merge_stage(stage.into());
window.set_merge_running(true);
window.set_merge_fraction(if total > 0 {
done as f32 / total as f32
} else {
0.0
});
if let Some(job) = ctl.job.borrow().as_ref() {
job.activity.detail(stage.to_lowercase());
job.activity.progress(done, total.max(1));
}
}
MergeEvent::Aligned(report) => {
let names = ctl
.job
.borrow()
.as_ref()
.map(|j| j.names.clone())
.unwrap_or_default();
show_report(window, &report, &names);
window.set_merge_running(false);
window.set_merge_stage("".into());
window.set_merge_fraction(0.0);
window.set_merge_status(if report.is_complete() {
"Check the alignment, then press Merge. Untick a frame to leave it out.".into()
} else {
"Not every frame could be placed. Untick the ones named to merge without them."
.into()
});
*ctl.report.borrow_mut() = Some(report);
}
MergeEvent::Done {
path,
staged,
width,
height,
} => {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
window.set_merge_running(false);
window.set_merge_stage("".into());
window.set_merge_done(true);
window.set_merge_status(
if staged {
format!("{name} ({width}×{height}) is queued to go beside its sources.")
} else {
format!("{name} ({width}×{height}) written to {}.", path.display())
}
.into(),
);
if let Some(job) = ctl.job.borrow().as_ref() {
job.activity.finish(format!("{name} merged"));
}
finished = true;
on_done(window);
}
MergeEvent::Failed(e) => {
window.set_merge_running(false);
window.set_merge_stage("".into());
window.set_merge_error(e.clone().into());
if let Some(job) = ctl.job.borrow().as_ref() {
job.activity.fail(e);
}
finished = true;
}
MergeEvent::Cancelled => {
window.set_merge_running(false);
window.set_merge_stage("".into());
window.set_merge_status("Stopped. Nothing was written.".into());
if let Some(job) = ctl.job.borrow().as_ref() {
job.activity.finish_quietly();
}
finished = true;
}
}
}
if finished {
*ctl.job.borrow_mut() = None;
*ctl.timer.borrow_mut() = None;
}
}
/// The alignment onto the page: the summary, the table, the preview.
fn show_report(window: &AppWindow, report: &AlignmentReport, names: &[String]) {
let rows: Vec<MergeFrameRow> = report
.frames
.iter()
.enumerate()
.map(|(k, f)| {
let name = names
.get(k)
.cloned()
.unwrap_or_else(|| format!("frame {k}"));
let included = report.included.get(k).copied().unwrap_or(true);
match f {
Ok((yaw, pitch)) => MergeFrameRow {
name: name.into(),
detail: format!("yaw {yaw:.1}° · pitch {pitch:.1}°").into(),
aligned: true,
included,
},
Err(why) => MergeFrameRow {
name: name.into(),
detail: why.clone().into(),
aligned: false,
included,
},
}
})
.collect();
let complete = report.is_complete();
window.set_merge_frames(slint::ModelRc::new(slint::VecModel::from(rows)));
window.set_merge_aligned(true);
window.set_merge_complete(complete);
window.set_merge_summary(
format!(
"{} frames, {} overlaps · {:.0} mm equivalent · {:?} · {}×{} px · fit {:.1} px",
report.included.iter().filter(|k| **k).count(),
report.links,
report.focal_mm,
report.projection,
report.width,
report.height,
report.rms_px
)
.into(),
);
// No preview is no picture, not the last one: after a frame is
// unticked, the old composite would show a set that is not the one
// being confirmed.
match &report.preview {
Some((w, h, rgba)) => {
let buffer =
slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(rgba, *w, *h);
window.set_merge_preview(slint::Image::from_rgba8(buffer));
}
None => window.set_merge_preview(slint::Image::default()),
}
window.set_merge_preview_filled(report.filled);
match &report.filler {
Ok(rung) => {
window.set_merge_fill_available(true);
window.set_merge_fill_note(format!("runs on {rung}").into());
}
Err(why) => {
window.set_merge_fill_available(false);
window.set_merge_fill_note(format!("The border can only be cropped: {why}.").into());
}
}
}
/// The sliders, from the settings.
fn show_fill_knobs(window: &AppWindow, f: &FillSettings) {
window.set_merge_fill_scale(f.scale as f32);
window.set_merge_fill_erosion(f.erosion as f32);
window.set_merge_fill_coarse(f.params.coarse as f32);
window.set_merge_fill_band(f.params.band as f32);
window.set_merge_fill_mirror(f.params.mirror_depth as f32);
window.set_merge_fill_feather(f.params.feather as f32);
}