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.
This commit is contained in:
+142
-6
@@ -12,6 +12,11 @@
|
|||||||
//! best-connected frame; rotations chained along it.
|
//! best-connected frame; rotations chained along it.
|
||||||
//! 5. Bundle adjustment over every link's inliers (`bundle`).
|
//! 5. Bundle adjustment over every link's inliers (`bundle`).
|
||||||
//!
|
//!
|
||||||
|
//! Steps 1 and 2 are [`match_pairs`] and most of the time; 3 to 5 are
|
||||||
|
//! [`solve`], which takes a subset of the frames. Leaving a frame out is
|
||||||
|
//! then a solve over the pairs already measured — the same links, not a
|
||||||
|
//! fresh RANSAC whose seeds would move with the frames' positions.
|
||||||
|
//!
|
||||||
//! What it refuses to do is guess. A frame the tree does not reach is
|
//! What it refuses to do is guess. A frame the tree does not reach is
|
||||||
//! reported by index with the reason (FR-MRG-5) and left out of the
|
//! reported by index with the reason (FR-MRG-5) and left out of the
|
||||||
//! cameras; the caller decides whether a set with a hole is worth
|
//! cameras; the caller decides whether a set with a hole is worth
|
||||||
@@ -125,13 +130,45 @@ impl Alignment {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Align a set of frames from their features.
|
/// Every pair of a set measured: steps 1 and 2, the expensive part, kept
|
||||||
|
/// so that a solve over a subset reuses it.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct Pairs {
|
||||||
|
/// Each frame's long edge, for the focal length's clamp.
|
||||||
|
long_edges: Vec<f64>,
|
||||||
|
/// Pairs with enough matches to try a geometry, whether or not it held.
|
||||||
|
matched: Vec<(usize, usize)>,
|
||||||
|
links: Vec<Link>,
|
||||||
|
/// Every link's inliers, in pixels, centred.
|
||||||
|
observations: Vec<Observation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Pairs {
|
||||||
|
/// How many frames were measured.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.long_edges.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.long_edges.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Align a set of frames from their features: [`match_pairs`], then
|
||||||
|
/// [`solve`] over all of them.
|
||||||
///
|
///
|
||||||
/// Every `Features` must be in its own frame's pixel coordinates with the
|
/// Every `Features` must be in its own frame's pixel coordinates with the
|
||||||
/// image size filled in; points are centred on the image centre here. The
|
/// image size filled in; points are centred on the image centre here. The
|
||||||
/// frames must all come from the same lens at the same focal length, which
|
/// frames must all come from the same lens at the same focal length, which
|
||||||
/// is the panorama assumption and not checked — the caller has the EXIF.
|
/// is the panorama assumption and not checked — the caller has the EXIF.
|
||||||
pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> {
|
pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, PanoError> {
|
||||||
|
let pairs = match_pairs(frames, opts)?;
|
||||||
|
solve(&pairs, &vec![true; frames.len()], opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steps 1 and 2: every pair matched, and a robust homography for each
|
||||||
|
/// pair with enough matches.
|
||||||
|
pub fn match_pairs(frames: &[Features], opts: &AlignOptions) -> Result<Pairs, PanoError> {
|
||||||
let n = frames.len();
|
let n = frames.len();
|
||||||
if n < 2 {
|
if n < 2 {
|
||||||
return Err(PanoError::Input(
|
return Err(PanoError::Input(
|
||||||
@@ -156,7 +193,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
|||||||
// 1 + 2: every pair.
|
// 1 + 2: every pair.
|
||||||
let mut links = Vec::new();
|
let mut links = Vec::new();
|
||||||
let mut observations: Vec<Observation> = Vec::new();
|
let mut observations: Vec<Observation> = Vec::new();
|
||||||
let mut matched_any = vec![false; n];
|
let mut matched = Vec::new();
|
||||||
let t_match = std::time::Instant::now();
|
let t_match = std::time::Instant::now();
|
||||||
for i in 0..n {
|
for i in 0..n {
|
||||||
for j in i + 1..n {
|
for j in i + 1..n {
|
||||||
@@ -165,8 +202,7 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
|||||||
if matches.len() < 4 {
|
if matches.len() < 4 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
matched_any[i] = true;
|
matched.push((i, j));
|
||||||
matched_any[j] = true;
|
|
||||||
let pairs: Vec<((f64, f64), (f64, f64))> = matches
|
let pairs: Vec<((f64, f64), (f64, f64))> = matches
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
@@ -214,6 +250,69 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
|||||||
}
|
}
|
||||||
|
|
||||||
log::debug!("matching and pairwise geometry in {:?}", t_match.elapsed());
|
log::debug!("matching and pairwise geometry in {:?}", t_match.elapsed());
|
||||||
|
Ok(Pairs {
|
||||||
|
long_edges: frames
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.width.max(f.height) as f64)
|
||||||
|
.collect(),
|
||||||
|
matched,
|
||||||
|
links,
|
||||||
|
observations,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Steps 3 to 5 over the frames `keep` marks, from pairs already measured.
|
||||||
|
///
|
||||||
|
/// The result is indexed by the kept frames in order: its frame `k` is the
|
||||||
|
/// `k`-th frame `keep` marks. Only pairs whose frames are both kept take
|
||||||
|
/// part, so a frame whose only overlap was with one left out is reported
|
||||||
|
/// as unaligned, as it would be had it never been measured with it.
|
||||||
|
pub fn solve(pairs: &Pairs, keep: &[bool], opts: &AlignOptions) -> Result<Alignment, PanoError> {
|
||||||
|
if keep.len() != pairs.len() {
|
||||||
|
return Err(PanoError::Input(format!(
|
||||||
|
"{} flags for {} frames",
|
||||||
|
keep.len(),
|
||||||
|
pairs.len()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
// Input index to the solve's.
|
||||||
|
let mut slot = vec![None; keep.len()];
|
||||||
|
let mut n = 0usize;
|
||||||
|
for (k, &kept) in keep.iter().enumerate() {
|
||||||
|
if kept {
|
||||||
|
slot[k] = Some(n);
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n < 2 {
|
||||||
|
return Err(PanoError::Input(
|
||||||
|
"a panorama needs at least two frames".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let both = |i: usize, j: usize| Some((slot[i]?, slot[j]?));
|
||||||
|
let mut matched_any = vec![false; n];
|
||||||
|
for &(i, j) in &pairs.matched {
|
||||||
|
if let Some((i, j)) = both(i, j) {
|
||||||
|
matched_any[i] = true;
|
||||||
|
matched_any[j] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let links: Vec<Link> = pairs
|
||||||
|
.links
|
||||||
|
.iter()
|
||||||
|
.filter_map(|l| {
|
||||||
|
let (i, j) = both(l.i, l.j)?;
|
||||||
|
Some(Link { i, j, ..l.clone() })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let observations: Vec<Observation> = pairs
|
||||||
|
.observations
|
||||||
|
.iter()
|
||||||
|
.filter_map(|o| {
|
||||||
|
let (i, j) = both(o.i, o.j)?;
|
||||||
|
Some(Observation { i, j, ..*o })
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// 3: the focal length.
|
// 3: the focal length.
|
||||||
let mut estimates: Vec<f64> = links
|
let mut estimates: Vec<f64> = links
|
||||||
@@ -221,9 +320,12 @@ pub fn align(frames: &[Features], opts: &AlignOptions) -> Result<Alignment, Pano
|
|||||||
.filter_map(|l| homography::focal_from_homography(&l.h))
|
.filter_map(|l| homography::focal_from_homography(&l.h))
|
||||||
.filter(|f| f.is_finite() && *f > 0.0)
|
.filter(|f| f.is_finite() && *f > 0.0)
|
||||||
.collect();
|
.collect();
|
||||||
let longest = frames
|
let longest = pairs
|
||||||
|
.long_edges
|
||||||
.iter()
|
.iter()
|
||||||
.map(|f| f.width.max(f.height) as f64)
|
.zip(keep)
|
||||||
|
.filter(|(_, &kept)| kept)
|
||||||
|
.map(|(&e, _)| e)
|
||||||
.fold(0.0, f64::max);
|
.fold(0.0, f64::max);
|
||||||
let focal = if !estimates.is_empty() {
|
let focal = if !estimates.is_empty() {
|
||||||
estimates.sort_by(f64::total_cmp);
|
estimates.sort_by(f64::total_cmp);
|
||||||
@@ -448,6 +550,40 @@ mod tests {
|
|||||||
assert!(out.rotations[..3].iter().all(Option::is_some));
|
assert!(out.rotations[..3].iter().all(Option::is_some));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_left_out_is_solved_without_measuring_again() {
|
||||||
|
let (frames, truth) = synthetic_sweep(6, 0.3, 1400.0, 1024, 768);
|
||||||
|
let opts = AlignOptions::default();
|
||||||
|
let pairs = match_pairs(&frames, &opts).expect("measured");
|
||||||
|
// The first frame left out: five cameras, indexed as the kept
|
||||||
|
// frames, and the links among them only.
|
||||||
|
let keep = [false, true, true, true, true, true];
|
||||||
|
let out = solve(&pairs, &keep, &opts).expect("solved");
|
||||||
|
assert!(out.is_complete(), "unaligned: {:?}", out.unaligned);
|
||||||
|
assert_eq!(out.rotations.len(), 5);
|
||||||
|
assert_eq!(out.links.len(), 4 + 3, "links: {}", out.links.len());
|
||||||
|
let root = out
|
||||||
|
.rotations
|
||||||
|
.iter()
|
||||||
|
.position(|r| *r == Some(Mat3::IDENTITY))
|
||||||
|
.unwrap();
|
||||||
|
for k in 0..5 {
|
||||||
|
let rel_truth = truth.rotations[root + 1].transpose() * truth.rotations[k + 1];
|
||||||
|
let err = angle_between(rel_truth, out.rotations[k].unwrap());
|
||||||
|
assert!(err < 2e-3, "frame {k} off by {err} rad");
|
||||||
|
}
|
||||||
|
// A frame in the middle left out splits the sweep only if nothing
|
||||||
|
// spans the gap; at 0.3 rad steps its neighbours still overlap.
|
||||||
|
let keep = [true, true, false, true, true, true];
|
||||||
|
let out = solve(&pairs, &keep, &opts).expect("solved");
|
||||||
|
assert!(out.is_complete(), "unaligned: {:?}", out.unaligned);
|
||||||
|
// And the whole set solved from the pairs is `align`'s answer.
|
||||||
|
assert_eq!(
|
||||||
|
solve(&pairs, &[true; 6], &opts).expect("solved"),
|
||||||
|
align(&frames, &opts).expect("aligned")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn one_frame_is_refused() {
|
fn one_frame_is_refused() {
|
||||||
let (frames, _) = synthetic_sweep(1, 0.3, 1400.0, 640, 480);
|
let (frames, _) = synthetic_sweep(1, 0.3, 1400.0, 640, 480);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ pub mod projection;
|
|||||||
#[cfg(feature = "xfeat")]
|
#[cfg(feature = "xfeat")]
|
||||||
pub mod xfeat;
|
pub mod xfeat;
|
||||||
|
|
||||||
pub use align::{align, AlignOptions, Alignment, Link, Unaligned};
|
pub use align::{align, match_pairs, solve, AlignOptions, Alignment, Link, Pairs, Unaligned};
|
||||||
pub use bundle::Cameras;
|
pub use bundle::Cameras;
|
||||||
pub use features::{Features, Keypoint};
|
pub use features::{Features, Keypoint};
|
||||||
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
pub use fill::{fill_border, Inpainter, Observer, Params as FillParams};
|
||||||
|
|||||||
+36
-36
File diff suppressed because one or more lines are too long
+24
-24
@@ -39,7 +39,7 @@ The list is longer than it is tall, so a way to walk it that cannot be lost to t
|
|||||||
|
|
||||||
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
|
Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as magnifying the picture rather than sliding it about. Double-tap is the way to an exact 1:1; this is the way to everything in between. Past 1:1 the pixels are shown as they are, square and unsmoothed; below it, filtered.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1972`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1974`</sub>
|
||||||
|
|
||||||
### Move a magnified photograph about
|
### Move a magnified photograph about
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ Anchored on the fingers' midpoint, and on the pointer, so the gesture reads as m
|
|||||||
|
|
||||||
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
Only once there is something outside the viewport to reach, which is why the cursor becomes a hand exactly then. The view is clamped to the frame: panning past the edge would show undefined area beside the photograph, and that reads as a rendering fault rather than as the end of the picture.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2068`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2070`</sub>
|
||||||
|
|
||||||
### Paint a mask by hand
|
### Paint a mask by hand
|
||||||
|
|
||||||
@@ -60,7 +60,7 @@ Only once there is something outside the viewport to reach, which is why the cur
|
|||||||
|
|
||||||
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
A model's mask stops inside a shoulder and leaks into the hair, and no single edge control fixes two errors that go opposite ways. The whole stroke is one step in the history, so taking a mark back costs one press however long it took to make.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2159`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2161`</sub>
|
||||||
|
|
||||||
### Open this list
|
### Open this list
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
|
|||||||
|
|
||||||
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
|
Most of the keys are develop's, and a reference that could only be opened from the grid had to be looked up before opening the photograph they were wanted for.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2385`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2387`</sub>
|
||||||
|
|
||||||
### Take back the last change
|
### Take back the last change
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ Most of the keys are develop's, and a reference that could only be opened from t
|
|||||||
|
|
||||||
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
A whole drag is one step, so undo takes back a decision rather than a frame of a gesture. The list is there because arriving six steps back costs what arriving from one does.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2415`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2417`</sub>
|
||||||
|
|
||||||
### Do it again after taking it back
|
### Do it again after taking it back
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
|
- **Keyboard** — `Ctrl+Shift+Z`, or `Ctrl+Y`
|
||||||
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
- **See it** — [in the manual](manual/README.md#history-snapshots-presets)
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2429`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2431`</sub>
|
||||||
|
|
||||||
### Remove a repair
|
### Remove a repair
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
- **Pointer** — Click it, then Delete Repair
|
- **Pointer** — Click it, then Delete Repair
|
||||||
- **Keyboard** — `Delete` or `Backspace`, while repairing
|
- **Keyboard** — `Delete` or `Backspace`, while repairing
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2449`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2451`</sub>
|
||||||
|
|
||||||
### Copy the settings from this photograph
|
### Copy the settings from this photograph
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
|||||||
|
|
||||||
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
The button is the copy that has to work: a tablet has no modifier key to hold and no menu bar to hang the action from. The shortcut is an accelerator for a control that is on screen either way.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2468`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2470`</sub>
|
||||||
|
|
||||||
### Paste the settings onto this photograph
|
### Paste the settings onto this photograph
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ The button is the copy that has to work: a tablet has no modifier key to hold an
|
|||||||
|
|
||||||
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
The button names what would be pasted — "3 adjustments", and whether the crop is coming with it — which the shortcut cannot say. Both paste the same scope.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2481`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2483`</sub>
|
||||||
|
|
||||||
### Choose which kinds of edit a copy carries
|
### Choose which kinds of edit a copy carries
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
|
|||||||
|
|
||||||
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
|
Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving each frame's crop and rotation alone, and that is a choice to make at the moment of copying.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2499`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2501`</sub>
|
||||||
|
|
||||||
### Export this photograph as the last one was
|
### Export this photograph as the last one was
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ Lightroom's Copy Settings. Pasting a look across a shoot usually means leaving e
|
|||||||
|
|
||||||
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
|
Every export runs on the defaults in Settings, so "as the last one was" is what the button already does. The chord is Lightroom's and darktable's, kept so hands that learned it there need not learn it again.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2524`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2526`</sub>
|
||||||
|
|
||||||
### Choose how to export, then export
|
### Choose how to export, then export
|
||||||
|
|
||||||
@@ -153,7 +153,7 @@ Every export runs on the defaults in Settings, so "as the last one was" is what
|
|||||||
|
|
||||||
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
|
The export sheet is the export defaults alone with an Export button. What is chosen there is kept, so it is also what the next Ctrl+Shift+E uses.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2537`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2539`</sub>
|
||||||
|
|
||||||
### Keep a crop that leaves a mask outside
|
### Keep a crop that leaves a mask outside
|
||||||
|
|
||||||
@@ -161,7 +161,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
|
|||||||
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
|
- **Pointer** — Press "Keep crop" on the notice, or "Undo crop" to take it back
|
||||||
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
|
- **Keyboard** — `Enter` keeps it; `Ctrl+Z` takes the crop back, like any other step
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2602`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2604`</sub>
|
||||||
|
|
||||||
### Go back to the grid
|
### Go back to the grid
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ The export sheet is the export defaults alone with an Export button. What is cho
|
|||||||
|
|
||||||
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
|
Lightroom's key for the grid. Escape gets there too, but a step at a time — out of a mode, then out of a zoom — where this goes straight back.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2619`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2621`</sub>
|
||||||
|
|
||||||
### Nudge the control last moved
|
### Nudge the control last moved
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ Lightroom's key for the grid. Escape gets there too, but a step at a time — ou
|
|||||||
|
|
||||||
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
|
Lightroom's keys for the selected slider. There is no focus ring on a slider here, so "selected" is the last one moved — the same control `R` puts back — which covers the framing sliders, perspective included, as well as the adjustments.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2648`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2650`</sub>
|
||||||
|
|
||||||
### Change which group of adjustments is on screen
|
### Change which group of adjustments is on screen
|
||||||
|
|
||||||
@@ -192,7 +192,7 @@ Lightroom's keys for the selected slider. There is no focus ring on a slider her
|
|||||||
|
|
||||||
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
The groups are whatever the operation set declares itself to be about, so there are as many as the pipeline has and no key can be assigned to one of them by name. Stepping is the binding that survives a node being added.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2676`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2678`</sub>
|
||||||
|
|
||||||
### Look at the photograph at 1:1
|
### Look at the photograph at 1:1
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ The groups are whatever the operation set declares itself to be about, so there
|
|||||||
|
|
||||||
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
|
Noise reduction and capture sharpening are judgements about single pixels, and a fitted view averages several of the file's into each one on screen — so the frame looks softer than it is and the correction goes too far. The point and the magnification survive opening the next photograph, which is what makes checking the same eye across forty portraits forty keystrokes rather than forty pans. From 1:1 on the photograph is drawn as its own pixels, each a hard-edged square, rather than smoothed into a blur.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2712`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2714`</sub>
|
||||||
|
|
||||||
### Rate this photograph
|
### Rate this photograph
|
||||||
|
|
||||||
@@ -211,7 +211,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
|||||||
- **Pointer** — Click a star in the top bar
|
- **Pointer** — Click a star in the top bar
|
||||||
- **Keyboard** — `0`–`5`
|
- **Keyboard** — `0`–`5`
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2769`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2771`</sub>
|
||||||
|
|
||||||
### Pick or reject this photograph
|
### Pick or reject this photograph
|
||||||
|
|
||||||
@@ -221,7 +221,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
|||||||
|
|
||||||
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
|
The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging here does not move on to the next frame: that belongs to culling, and in develop the photograph in front of you is the one being worked on.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2775`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2777`</sub>
|
||||||
|
|
||||||
### Give this photograph a colour label
|
### Give this photograph a colour label
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ The grid's keys, on the photograph that is open (FR-UI-5, 2026-09-19). Judging h
|
|||||||
|
|
||||||
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
|
The grid's keys, on the photograph that is open, so labelling while stepping through a folder is one hand's work. The bar names the label in words beside its mark.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2805`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2807`</sub>
|
||||||
|
|
||||||
### Move to the next or previous photograph
|
### Move to the next or previous photograph
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ The grid's keys, on the photograph that is open, so labelling while stepping thr
|
|||||||
|
|
||||||
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
|
The edit on screen is saved on the way out, so stepping through a folder is as much a departure as going back to the grid and loses nothing. A and D as well as the arrows, so the left hand steps along the roll while the right stays on the mouse.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2830`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2832`</sub>
|
||||||
|
|
||||||
### See the photograph before you edited it
|
### See the photograph before you edited it
|
||||||
|
|
||||||
@@ -254,7 +254,7 @@ The edit on screen is saved on the way out, so stepping through a folder is as m
|
|||||||
|
|
||||||
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
Held rather than toggled, and no split screen: a split halves the working image on the tablet the column was sized for, and the comparison photographers describe making is a flick back and forth. It takes no history step, so checking whether a frame is overcooked costs nothing to undo afterwards.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:2960`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:2962`</sub>
|
||||||
|
|
||||||
### Put one control back to its default
|
### Put one control back to its default
|
||||||
|
|
||||||
@@ -338,7 +338,7 @@ The question a correction raises is whether it did what it was for — whether t
|
|||||||
|
|
||||||
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
|
One key for "up one", innermost first: a question before the sheet under it, a sheet before the view, a view before the library. Nothing is left behind a dialogue that the key walked straight past.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1021`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1022`</sub>
|
||||||
|
|
||||||
### Do what a sheet offers
|
### Do what a sheet offers
|
||||||
|
|
||||||
@@ -346,7 +346,7 @@ One key for "up one", innermost first: a question before the sheet under it, a s
|
|||||||
- **Pointer** — Press its button — Export, or Copy
|
- **Pointer** — Press its button — Export, or Copy
|
||||||
- **Keyboard** — `Enter`, on the export and copy sheets
|
- **Keyboard** — `Enter`, on the export and copy sheets
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/app.slint:1031`</sub>
|
<sub>`ui/dr-ui/ui/app.slint:1032`</sub>
|
||||||
|
|
||||||
### Scroll by the scrollbar
|
### Scroll by the scrollbar
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
//! ```sh
|
//! ```sh
|
||||||
//! cargo run -p dr-ui --example merge --release -- fixtures/pano/2025-08-05/*.CR2
|
//! 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 -- --out /var/tmp --fill frame1.CR2 …
|
||||||
|
//! cargo run -p dr-ui --example merge --release -- --leave-out 3 fixtures/pano/2025-08-05/*.CR2
|
||||||
//! ```
|
//! ```
|
||||||
//!
|
//!
|
||||||
|
//! `--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
|
//! 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
|
//! 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,
|
//! proves that the alignment example cannot is the second half — the tap,
|
||||||
@@ -20,6 +24,7 @@ fn main() {
|
|||||||
let mut out_dir = None;
|
let mut out_dir = None;
|
||||||
let mut fill = false;
|
let mut fill = false;
|
||||||
let mut wait_engines = false;
|
let mut wait_engines = false;
|
||||||
|
let mut leave_out: Option<usize> = None;
|
||||||
loop {
|
loop {
|
||||||
match args.first().map(String::as_str) {
|
match args.first().map(String::as_str) {
|
||||||
Some("--out") => {
|
Some("--out") => {
|
||||||
@@ -30,6 +35,10 @@ fn main() {
|
|||||||
args.remove(0);
|
args.remove(0);
|
||||||
fill = true;
|
fill = true;
|
||||||
}
|
}
|
||||||
|
Some("--leave-out") => {
|
||||||
|
args.remove(0);
|
||||||
|
leave_out = args.remove(0).parse().ok();
|
||||||
|
}
|
||||||
Some("--wait-engines") => {
|
Some("--wait-engines") => {
|
||||||
args.remove(0);
|
args.remove(0);
|
||||||
wait_engines = true;
|
wait_engines = true;
|
||||||
@@ -121,6 +130,7 @@ fn main() {
|
|||||||
Ok((yaw, pitch)) => {
|
Ok((yaw, pitch)) => {
|
||||||
println!(" frame {k}: yaw {yaw:7.2}° pitch {pitch:6.2}°")
|
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}"),
|
Err(why) => println!(" frame {k}: NOT ALIGNED — {why}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,7 +141,41 @@ fn main() {
|
|||||||
Err(why) => why.clone(),
|
Err(why) => why.clone(),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
// Headless: the confirmation the dialog would ask for.
|
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 {
|
let _ = decide.send(dr_ui::merge::Decision::Merge {
|
||||||
projection: None,
|
projection: None,
|
||||||
fill: fill.then(dr_ui::merge::FillSettings::default),
|
fill: fill.then(dr_ui::merge::FillSettings::default),
|
||||||
|
|||||||
+290
-117
@@ -14,8 +14,10 @@
|
|||||||
//! (FR-MRG-2).
|
//! (FR-MRG-2).
|
||||||
//! 2. **Proxies.** Each frame is demosaiced and rendered through the tap
|
//! 2. **Proxies.** Each frame is demosaiced and rendered through the tap
|
||||||
//! at proxy size, upright; the keypoint detector reads that.
|
//! at proxy size, upright; the keypoint detector reads that.
|
||||||
//! 3. **Alignment.** `dr_pano::align`. A frame it could not place stops
|
//! 3. **Alignment.** `dr_pano::match_pairs` once, then `dr_pano::solve`
|
||||||
//! the job with the frame named (FR-MRG-5).
|
//! over the frames kept — again whenever the page leaves one out. A
|
||||||
|
//! frame it could not place is named (FR-MRG-5), and the merge waits
|
||||||
|
//! until it is left out.
|
||||||
//! 4. **Gain.** One scalar per frame from the proxies' overlaps, so the
|
//! 4. **Gain.** One scalar per frame from the proxies' overlaps, so the
|
||||||
//! stop of exposure drift a hand-held sweep collects does not band.
|
//! stop of exposure drift a hand-held sweep collects does not band.
|
||||||
//! 5. **Merge.** `dr_gpu::MergePass`, chunk by chunk, into a DNG written
|
//! 5. **Merge.** `dr_gpu::MergePass`, chunk by chunk, into a DNG written
|
||||||
@@ -124,7 +126,7 @@ impl MergeRequest {
|
|||||||
|
|
||||||
/// What the photographer decides once the alignment is shown (FR-MRG-1:
|
/// What the photographer decides once the alignment is shown (FR-MRG-1:
|
||||||
/// never automatic).
|
/// never automatic).
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum Decision {
|
pub enum Decision {
|
||||||
Merge {
|
Merge {
|
||||||
projection: Option<Projection>,
|
projection: Option<Projection>,
|
||||||
@@ -142,6 +144,17 @@ pub enum Decision {
|
|||||||
projection: Option<Projection>,
|
projection: Option<Projection>,
|
||||||
fill: Option<FillSettings>,
|
fill: Option<FillSettings>,
|
||||||
},
|
},
|
||||||
|
/// Align again over these frames — one flag per input frame, `true` to
|
||||||
|
/// keep it — and report, drawn as `projection` and `fill` say. The
|
||||||
|
/// page's checkboxes: a frame that does not fit is left out without
|
||||||
|
/// leaving the page, and the frames are not read, demosaiced or
|
||||||
|
/// searched for keypoints again; only the matching, the solve and the
|
||||||
|
/// gains run over the ones kept.
|
||||||
|
Frames {
|
||||||
|
keep: Vec<bool>,
|
||||||
|
projection: Option<Projection>,
|
||||||
|
fill: Option<FillSettings>,
|
||||||
|
},
|
||||||
Abandon,
|
Abandon,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,8 +261,10 @@ pub struct AlignmentReport {
|
|||||||
pub width: u32,
|
pub width: u32,
|
||||||
pub height: u32,
|
pub height: u32,
|
||||||
/// Per frame, in input order: yaw and pitch in degrees if aligned, or
|
/// Per frame, in input order: yaw and pitch in degrees if aligned, or
|
||||||
/// why not.
|
/// why not — "left out" for a frame the photographer unticked.
|
||||||
pub frames: Vec<Result<(f64, f64), String>>,
|
pub frames: Vec<Result<(f64, f64), String>>,
|
||||||
|
/// Per frame, in input order: whether it is in this alignment.
|
||||||
|
pub included: Vec<bool>,
|
||||||
pub links: usize,
|
pub links: usize,
|
||||||
/// The aligned set drawn on the suggested surface at proxy resolution,
|
/// The aligned set drawn on the suggested surface at proxy resolution,
|
||||||
/// `(width, height, rgba)` — what the photographer confirms.
|
/// `(width, height, rgba)` — what the photographer confirms.
|
||||||
@@ -260,6 +275,19 @@ pub struct AlignmentReport {
|
|||||||
pub filler: Result<String, String>,
|
pub filler: Result<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AlignmentReport {
|
||||||
|
/// Whether every frame kept was placed, and there are two of them: the
|
||||||
|
/// only alignment the job will merge.
|
||||||
|
pub fn is_complete(&self) -> bool {
|
||||||
|
self.included.iter().filter(|k| **k).count() >= 2
|
||||||
|
&& self
|
||||||
|
.frames
|
||||||
|
.iter()
|
||||||
|
.zip(&self.included)
|
||||||
|
.all(|(f, &kept)| !kept || f.is_ok())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Run the whole job on the calling thread, reporting on `events`.
|
/// Run the whole job on the calling thread, reporting on `events`.
|
||||||
///
|
///
|
||||||
/// After `Aligned` the job waits on `decision` — the photographer's
|
/// After `Aligned` the job waits on `decision` — the photographer's
|
||||||
@@ -378,45 +406,17 @@ fn run_inner(
|
|||||||
}
|
}
|
||||||
log::info!("merge: features in {:?}", t.elapsed());
|
log::info!("merge: features in {:?}", t.elapsed());
|
||||||
|
|
||||||
// 3. Alignment.
|
// 3–4. Alignment and gains, over the frames the photographer keeps.
|
||||||
let t = Instant::now();
|
// Everything up to the pairs — each frame read, demosaiced, searched for
|
||||||
|
// keypoints, and every pair matched — is done once; unticking a frame
|
||||||
|
// on the page solves again from the pairs already measured.
|
||||||
progress("Aligning", 0, 1);
|
progress("Aligning", 0, 1);
|
||||||
let alignment =
|
let t = Instant::now();
|
||||||
dr_pano::align(&features, &dr_pano::AlignOptions::default()).map_err(|e| e.to_string())?;
|
let pairs = dr_pano::match_pairs(&features, &dr_pano::AlignOptions::default())
|
||||||
log::info!(
|
.map_err(|e| e.to_string())?;
|
||||||
"merge: aligned in {:?}, focal {:.1} px, rms {:.2} px, {} links",
|
log::info!("merge: pairs matched in {:?}", t.elapsed());
|
||||||
t.elapsed(),
|
let mut included = vec![true; frames.len()];
|
||||||
alignment.focal,
|
let mut solved = solve(&frames, &pairs, &proxies, &included, request.projection)?;
|
||||||
alignment.rms_px,
|
|
||||||
alignment.links.len()
|
|
||||||
);
|
|
||||||
|
|
||||||
// The geometry at full resolution: the proxy's long edge against the
|
|
||||||
// frame's.
|
|
||||||
let proxy_long = proxies[0].width.max(proxies[0].height) as f64;
|
|
||||||
let full_long = frames[0].size.0.max(frames[0].size.1) as f64;
|
|
||||||
let focal_full = alignment.focal * full_long / proxy_long;
|
|
||||||
let cameras = Cameras {
|
|
||||||
rotations: alignment
|
|
||||||
.rotations
|
|
||||||
.iter()
|
|
||||||
.map(|r| r.unwrap_or(dr_pano::linalg::Mat3::IDENTITY))
|
|
||||||
.collect(),
|
|
||||||
focal: focal_full,
|
|
||||||
};
|
|
||||||
let frame_size = (frames[0].size.0 as f64, frames[0].size.1 as f64);
|
|
||||||
let (hfov, vfov) = field_of_view(&cameras, frame_size);
|
|
||||||
let projection = request
|
|
||||||
.projection
|
|
||||||
.unwrap_or_else(|| Projection::suggest(hfov, vfov));
|
|
||||||
let suggested = projection;
|
|
||||||
|
|
||||||
// 4. Gains, before the report so the preview shows them.
|
|
||||||
let gains = if alignment.is_complete() {
|
|
||||||
gains(&proxies, &alignment)
|
|
||||||
} else {
|
|
||||||
vec![1.0; frames.len()]
|
|
||||||
};
|
|
||||||
// The filler, loaded once if the device has it. Its absence is a
|
// The filler, loaded once if the device has it. Its absence is a
|
||||||
// report, not a failure: the crop is always there.
|
// report, not a failure: the crop is always there.
|
||||||
let mut filler: Option<dr_pano::migan::MiGan> = None;
|
let mut filler: Option<dr_pano::migan::MiGan> = None;
|
||||||
@@ -431,79 +431,94 @@ fn run_inner(
|
|||||||
Err(e) => Err(format!("the border-fill model could not be loaded: {e}")),
|
Err(e) => Err(format!("the border-fill model could not be loaded: {e}")),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
let first_raw = &frames[0].raw;
|
let has_filler = filler.is_some();
|
||||||
let mut report =
|
let mut report = |s: &Solved,
|
||||||
|projection: Projection, fill: Option<FillSettings>| -> Result<AlignmentReport, String> {
|
included: &[bool],
|
||||||
let bounds = projection::bounds(projection, focal_full, &cameras, frame_size)
|
projection: Projection,
|
||||||
.ok_or("the frames project nowhere")?;
|
fill: Option<FillSettings>|
|
||||||
let mut filled = false;
|
-> Result<AlignmentReport, String> {
|
||||||
let preview = match alignment.is_complete() {
|
let bounds = projection::bounds(projection, s.focal_full, &s.cameras, s.frame_size)
|
||||||
false => None,
|
.ok_or("the frames project nowhere")?;
|
||||||
true => {
|
let proxies: Vec<&Gray> = s.keep.iter().map(|&k| &proxies[k]).collect();
|
||||||
let (w, h, mut rgb, mut known) = preview_planes(
|
let colour: Vec<&[f32]> = s.keep.iter().map(|&k| colour[k].as_slice()).collect();
|
||||||
&colour, &proxies, &alignment, &gains, projection, first_raw, 1600,
|
let first_raw = &frames[s.keep[0]].raw;
|
||||||
);
|
let mut filled = false;
|
||||||
if let Some(settings) = fill {
|
let preview = match s.alignment.is_complete() {
|
||||||
if let Some(model) = filler.as_mut() {
|
false => None,
|
||||||
progress("Filling the preview", 0, 1);
|
true => {
|
||||||
// The knobs at the preview's scale.
|
let (w, h, mut rgb, mut known) = preview_planes(
|
||||||
let here = settings.at(w as f64 / bounds.width());
|
&colour,
|
||||||
dr_pano::fill::erode(&mut known, w, h, here.erosion);
|
&proxies,
|
||||||
match dr_pano::fill_border(
|
&s.alignment,
|
||||||
&mut rgb,
|
&s.gains,
|
||||||
w,
|
projection,
|
||||||
h,
|
first_raw,
|
||||||
&known,
|
1600,
|
||||||
model,
|
);
|
||||||
here.params,
|
if let Some(settings) = fill {
|
||||||
&mut |_, _| {},
|
if let Some(model) = filler.as_mut() {
|
||||||
) {
|
progress("Filling the preview", 0, 1);
|
||||||
Ok(_) => filled = true,
|
// The knobs at the preview's scale.
|
||||||
Err(e) => log::warn!("preview fill: {e}"),
|
let here = settings.at(w as f64 / bounds.width());
|
||||||
}
|
dr_pano::fill::erode(&mut known, w, h, here.erosion);
|
||||||
|
match dr_pano::fill_border(
|
||||||
|
&mut rgb,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
&known,
|
||||||
|
model,
|
||||||
|
here.params,
|
||||||
|
&mut |_, _| {},
|
||||||
|
) {
|
||||||
|
Ok(_) => filled = true,
|
||||||
|
Err(e) => log::warn!("preview fill: {e}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(preview_rgba(
|
|
||||||
&rgb,
|
|
||||||
w,
|
|
||||||
h,
|
|
||||||
&alignment,
|
|
||||||
projection,
|
|
||||||
&bounds_of(&proxies, &alignment, projection),
|
|
||||||
&proxies,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
};
|
Some(preview_rgba(
|
||||||
Ok(AlignmentReport {
|
&rgb,
|
||||||
focal_mm: focal_full * 36.0 / full_long,
|
w,
|
||||||
rms_px: alignment.rms_px,
|
h,
|
||||||
projection,
|
&s.alignment,
|
||||||
width: bounds.width().ceil() as u32,
|
projection,
|
||||||
height: bounds.height().ceil() as u32,
|
&bounds_of(&proxies, &s.alignment, projection),
|
||||||
frames: describe(&alignment),
|
&proxies,
|
||||||
links: alignment.links.len(),
|
))
|
||||||
preview,
|
}
|
||||||
filled,
|
|
||||||
filler: filler_status.clone(),
|
|
||||||
})
|
|
||||||
};
|
};
|
||||||
|
// The solve's frames back in input order, the unticked ones named.
|
||||||
|
let mut rows: Vec<Result<(f64, f64), String>> =
|
||||||
|
vec![Err("left out".to_string()); included.len()];
|
||||||
|
for (&k, row) in s.keep.iter().zip(describe(&s.alignment)) {
|
||||||
|
rows[k] = row;
|
||||||
|
}
|
||||||
|
Ok(AlignmentReport {
|
||||||
|
focal_mm: s.focal_full * 36.0 / s.full_long,
|
||||||
|
rms_px: s.alignment.rms_px,
|
||||||
|
projection,
|
||||||
|
width: bounds.width().ceil() as u32,
|
||||||
|
height: bounds.height().ceil() as u32,
|
||||||
|
frames: rows,
|
||||||
|
included: included.to_vec(),
|
||||||
|
links: s.alignment.links.len(),
|
||||||
|
preview,
|
||||||
|
filled,
|
||||||
|
filler: filler_status.clone(),
|
||||||
|
})
|
||||||
|
};
|
||||||
progress("Drawing the preview", 0, 1);
|
progress("Drawing the preview", 0, 1);
|
||||||
let _ = events.send(MergeEvent::Aligned(report(suggested, None)?));
|
let _ = events.send(MergeEvent::Aligned(report(
|
||||||
if !alignment.is_complete() {
|
&solved,
|
||||||
let names: Vec<String> = alignment
|
&included,
|
||||||
.unaligned
|
solved.suggested,
|
||||||
.iter()
|
None,
|
||||||
.map(|(k, why)| format!("{}: {why}", request.frames[*k].name))
|
)?));
|
||||||
.collect();
|
|
||||||
return Err(format!(
|
|
||||||
"not every frame could be placed — {}",
|
|
||||||
names.join("; ")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Never automatic (FR-MRG-1): nothing is written until the alignment
|
// Never automatic (FR-MRG-1): nothing is written until the alignment
|
||||||
// has been seen and confirmed. Polled, so a cancel while waiting is
|
// has been seen and confirmed. Polled, so a cancel while waiting is
|
||||||
// seen within a moment.
|
// seen within a moment. A frame that could not be placed no longer
|
||||||
|
// ends the job (FR-MRG-5 names it on its row): the page leaves it out
|
||||||
|
// and the job aligns again without it.
|
||||||
let (projection, fill) = loop {
|
let (projection, fill) = loop {
|
||||||
if cancel.is_cancelled() {
|
if cancel.is_cancelled() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -512,23 +527,76 @@ fn run_inner(
|
|||||||
Ok(Decision::Merge {
|
Ok(Decision::Merge {
|
||||||
projection: p,
|
projection: p,
|
||||||
fill,
|
fill,
|
||||||
}) => break (p.unwrap_or(suggested), fill.filter(|_| filler.is_some())),
|
}) => {
|
||||||
|
// The page's button is off until the alignment is
|
||||||
|
// complete; a press that raced a re-solve is dropped.
|
||||||
|
if !solved.alignment.is_complete() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
break (p.unwrap_or(solved.suggested), fill.filter(|_| has_filler));
|
||||||
|
}
|
||||||
Ok(Decision::Preview {
|
Ok(Decision::Preview {
|
||||||
projection: p,
|
projection: p,
|
||||||
fill,
|
fill,
|
||||||
}) => {
|
}) => {
|
||||||
let _ = events.send(MergeEvent::Aligned(report(p.unwrap_or(suggested), fill)?));
|
let _ = events.send(MergeEvent::Aligned(report(
|
||||||
|
&solved,
|
||||||
|
&included,
|
||||||
|
p.unwrap_or(solved.suggested),
|
||||||
|
fill,
|
||||||
|
)?));
|
||||||
|
}
|
||||||
|
Ok(Decision::Frames {
|
||||||
|
keep,
|
||||||
|
projection: p,
|
||||||
|
fill,
|
||||||
|
}) => {
|
||||||
|
// Two frames or it is not a panorama; the page does not
|
||||||
|
// offer fewer, and a malformed list is not acted on.
|
||||||
|
if keep.len() != frames.len() || keep.iter().filter(|k| **k).count() < 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
progress("Aligning", 0, 1);
|
||||||
|
included = keep;
|
||||||
|
solved = solve(&frames, &pairs, &proxies, &included, request.projection)?;
|
||||||
|
progress("Drawing the preview", 0, 1);
|
||||||
|
let _ = events.send(MergeEvent::Aligned(report(
|
||||||
|
&solved,
|
||||||
|
&included,
|
||||||
|
p.unwrap_or(solved.suggested),
|
||||||
|
fill,
|
||||||
|
)?));
|
||||||
}
|
}
|
||||||
Ok(Decision::Abandon) => return Ok(None),
|
Ok(Decision::Abandon) => return Ok(None),
|
||||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
|
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
|
||||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Ok(None),
|
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Ok(None),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// From here on only the kept frames exist, indexed as the alignment
|
||||||
|
// indexes them.
|
||||||
|
let Solved {
|
||||||
|
keep,
|
||||||
|
cameras,
|
||||||
|
focal_full,
|
||||||
|
full_long,
|
||||||
|
frame_size,
|
||||||
|
gains,
|
||||||
|
..
|
||||||
|
} = solved;
|
||||||
|
let first_name = request.frames[keep[0]].name.clone();
|
||||||
|
let frames: Vec<Loaded> = frames
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(k, _)| included[*k])
|
||||||
|
.map(|(_, f)| f)
|
||||||
|
.collect();
|
||||||
|
let first_raw = &frames[0].raw;
|
||||||
let bounds = projection::bounds(projection, focal_full, &cameras, frame_size)
|
let bounds = projection::bounds(projection, focal_full, &cameras, frame_size)
|
||||||
.ok_or("the frames project nowhere")?;
|
.ok_or("the frames project nowhere")?;
|
||||||
|
|
||||||
// 5. Merge, into a DNG beside the first frame.
|
// 5. Merge, into a DNG beside the first frame.
|
||||||
let name = format!("{}-pano.dng", stem(Path::new(&request.frames[0].name)));
|
let name = format!("{}-pano.dng", stem(Path::new(&first_name)));
|
||||||
let (out_path, staged) = match &request.destination {
|
let (out_path, staged) = match &request.destination {
|
||||||
MergeDestination::Local(dir) => (unused_name(dir, &name), false),
|
MergeDestination::Local(dir) => (unused_name(dir, &name), false),
|
||||||
MergeDestination::Outbox { outbox, remote_dir } => {
|
MergeDestination::Outbox { outbox, remote_dir } => {
|
||||||
@@ -943,6 +1011,83 @@ fn camera_proxy(
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An alignment over some of the frames, and what follows from it.
|
||||||
|
struct Solved {
|
||||||
|
/// The input frames solved, in input order: the alignment's frame `i`
|
||||||
|
/// is input frame `keep[i]`.
|
||||||
|
keep: Vec<usize>,
|
||||||
|
alignment: Alignment,
|
||||||
|
/// At full resolution.
|
||||||
|
cameras: Cameras,
|
||||||
|
focal_full: f64,
|
||||||
|
/// The first kept frame's long edge and upright size, full resolution.
|
||||||
|
full_long: f64,
|
||||||
|
frame_size: (f64, f64),
|
||||||
|
/// The projection the field of view suggests, or the request's.
|
||||||
|
suggested: Projection,
|
||||||
|
/// One per kept frame.
|
||||||
|
gains: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Align the frames `included` keeps, from the pairs already measured,
|
||||||
|
/// and take the geometry to full resolution and the gains from it.
|
||||||
|
fn solve(
|
||||||
|
frames: &[Loaded],
|
||||||
|
pairs: &dr_pano::Pairs,
|
||||||
|
proxies: &[Gray],
|
||||||
|
included: &[bool],
|
||||||
|
projection: Option<Projection>,
|
||||||
|
) -> Result<Solved, String> {
|
||||||
|
let keep: Vec<usize> = (0..frames.len()).filter(|&k| included[k]).collect();
|
||||||
|
let t = Instant::now();
|
||||||
|
let alignment = dr_pano::solve(pairs, included, &dr_pano::AlignOptions::default())
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
log::info!(
|
||||||
|
"merge: {} frames aligned in {:?}, focal {:.1} px, rms {:.2} px, {} links",
|
||||||
|
keep.len(),
|
||||||
|
t.elapsed(),
|
||||||
|
alignment.focal,
|
||||||
|
alignment.rms_px,
|
||||||
|
alignment.links.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// The geometry at full resolution: the proxy's long edge against the
|
||||||
|
// frame's.
|
||||||
|
let first = &frames[keep[0]];
|
||||||
|
let proxy_long = proxies[keep[0]].width.max(proxies[keep[0]].height) as f64;
|
||||||
|
let full_long = first.size.0.max(first.size.1) as f64;
|
||||||
|
let focal_full = alignment.focal * full_long / proxy_long;
|
||||||
|
let cameras = Cameras {
|
||||||
|
rotations: alignment
|
||||||
|
.rotations
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.unwrap_or(dr_pano::linalg::Mat3::IDENTITY))
|
||||||
|
.collect(),
|
||||||
|
focal: focal_full,
|
||||||
|
};
|
||||||
|
let frame_size = (first.size.0 as f64, first.size.1 as f64);
|
||||||
|
let (hfov, vfov) = field_of_view(&cameras, frame_size);
|
||||||
|
let suggested = projection.unwrap_or_else(|| Projection::suggest(hfov, vfov));
|
||||||
|
|
||||||
|
// Gains, before the report so the preview shows them.
|
||||||
|
let gains = if alignment.is_complete() {
|
||||||
|
let kept: Vec<&Gray> = keep.iter().map(|&k| &proxies[k]).collect();
|
||||||
|
gains(&kept, &alignment)
|
||||||
|
} else {
|
||||||
|
vec![1.0; keep.len()]
|
||||||
|
};
|
||||||
|
Ok(Solved {
|
||||||
|
keep,
|
||||||
|
alignment,
|
||||||
|
cameras,
|
||||||
|
focal_full,
|
||||||
|
full_long,
|
||||||
|
frame_size,
|
||||||
|
suggested,
|
||||||
|
gains,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// The aligned set on its surface, in colour, for the page.
|
/// The aligned set on its surface, in colour, for the page.
|
||||||
///
|
///
|
||||||
/// A quick look, not the pipeline: the first frame's white balance and
|
/// A quick look, not the pipeline: the first frame's white balance and
|
||||||
@@ -954,8 +1099,8 @@ fn camera_proxy(
|
|||||||
/// known)`. Display-ish space — the first frame's white balance and matrix,
|
/// known)`. Display-ish space — the first frame's white balance and matrix,
|
||||||
/// a gamma — which is also what the border filler was trained on.
|
/// a gamma — which is also what the border filler was trained on.
|
||||||
fn preview_planes(
|
fn preview_planes(
|
||||||
colour: &[Vec<f32>],
|
colour: &[&[f32]],
|
||||||
proxies: &[Gray],
|
proxies: &[&Gray],
|
||||||
alignment: &Alignment,
|
alignment: &Alignment,
|
||||||
gains: &[f32],
|
gains: &[f32],
|
||||||
projection: Projection,
|
projection: Projection,
|
||||||
@@ -1010,7 +1155,7 @@ fn preview_planes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn bounds_of(
|
fn bounds_of(
|
||||||
proxies: &[Gray],
|
proxies: &[&Gray],
|
||||||
alignment: &Alignment,
|
alignment: &Alignment,
|
||||||
projection: Projection,
|
projection: Projection,
|
||||||
) -> projection::Bounds {
|
) -> projection::Bounds {
|
||||||
@@ -1039,7 +1184,7 @@ fn preview_rgba(
|
|||||||
alignment: &Alignment,
|
alignment: &Alignment,
|
||||||
projection: Projection,
|
projection: Projection,
|
||||||
bounds: &projection::Bounds,
|
bounds: &projection::Bounds,
|
||||||
proxies: &[Gray],
|
proxies: &[&Gray],
|
||||||
) -> (u32, u32, Vec<u8>) {
|
) -> (u32, u32, Vec<u8>) {
|
||||||
let cameras = alignment.cameras();
|
let cameras = alignment.cameras();
|
||||||
let (fw, fh) = (proxies[0].width as f64, proxies[0].height as f64);
|
let (fw, fh) = (proxies[0].width as f64, proxies[0].height as f64);
|
||||||
@@ -1232,7 +1377,7 @@ fn describe(a: &Alignment) -> Vec<Result<(f64, f64), String>> {
|
|||||||
/// least squares with the reference frame held at 1. Grey here is the
|
/// least squares with the reference frame held at 1. Grey here is the
|
||||||
/// proxy's gamma-encoded value raised back to linear, so the gain is a
|
/// proxy's gamma-encoded value raised back to linear, so the gain is a
|
||||||
/// linear multiplier as the shader applies it.
|
/// linear multiplier as the shader applies it.
|
||||||
fn gains(proxies: &[Gray], a: &Alignment) -> Vec<f32> {
|
fn gains(proxies: &[&Gray], a: &Alignment) -> Vec<f32> {
|
||||||
let n = proxies.len();
|
let n = proxies.len();
|
||||||
let cameras = a.cameras();
|
let cameras = a.cameras();
|
||||||
let (w, h) = (proxies[0].width as f64, proxies[0].height as f64);
|
let (w, h) = (proxies[0].width as f64, proxies[0].height as f64);
|
||||||
@@ -1254,8 +1399,8 @@ fn gains(proxies: &[Gray], a: &Alignment) -> Vec<f32> {
|
|||||||
if qx < 0.0 || qy < 0.0 || qx >= w - 1.0 || qy >= h - 1.0 {
|
if qx < 0.0 || qy < 0.0 || qx >= w - 1.0 || qy >= h - 1.0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
sum_i += linear(&proxies[l.i], x, y);
|
sum_i += linear(proxies[l.i], x, y);
|
||||||
sum_j += linear(&proxies[l.j], qx as usize, qy as usize);
|
sum_j += linear(proxies[l.j], qx as usize, qy as usize);
|
||||||
count += 1;
|
count += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1395,6 +1540,34 @@ mod tests {
|
|||||||
assert_eq!(grey_if_blown([0.5, 0.8, 0.4], wb), [0.5, 0.8, 0.4]);
|
assert_eq!(grey_if_blown([0.5, 0.8, 0.4], wb), [0.5, 0.8, 0.4]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_frame_left_out_does_not_hold_the_merge_back() {
|
||||||
|
let report =
|
||||||
|
|frames: Vec<Result<(f64, f64), String>>, included: Vec<bool>| AlignmentReport {
|
||||||
|
focal_mm: 50.0,
|
||||||
|
rms_px: 1.0,
|
||||||
|
projection: Projection::Cylindrical,
|
||||||
|
width: 1,
|
||||||
|
height: 1,
|
||||||
|
frames,
|
||||||
|
included,
|
||||||
|
links: 1,
|
||||||
|
preview: None,
|
||||||
|
filled: false,
|
||||||
|
filler: Err(String::new()),
|
||||||
|
};
|
||||||
|
let placed = || Ok((0.0, 0.0));
|
||||||
|
let lost = || Err("no consistent overlap with any other frame".to_string());
|
||||||
|
// A frame that could not be placed holds the merge back while it
|
||||||
|
// is kept, and not once it is left out.
|
||||||
|
assert!(!report(vec![placed(), placed(), lost()], vec![true; 3]).is_complete());
|
||||||
|
assert!(report(vec![placed(), placed(), lost()], vec![true, true, false]).is_complete());
|
||||||
|
// Two frames or it is not a panorama.
|
||||||
|
assert!(
|
||||||
|
!report(vec![placed(), placed(), placed()], vec![true, false, false]).is_complete()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_worker_that_hangs_up_mid_job_is_reported_as_gone() {
|
fn a_worker_that_hangs_up_mid_job_is_reported_as_gone() {
|
||||||
// The failure the page could not see: a panic drops the sender with
|
// The failure the page could not see: a panic drops the sender with
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
//! fetches the selected originals on a worker, hands them to the job, and
|
//! 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
|
//! 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.
|
//! properties. When the alignment arrives the page shows it and waits.
|
||||||
//! "Merge" sends the decision; "Stop" or "Back" cancels. When the file is
|
//! 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
|
//! staged, the outbox drains and the library rescans, and the composite
|
||||||
//! appears in the grid beside its sources.
|
//! appears in the grid beside its sources.
|
||||||
|
|
||||||
@@ -327,6 +328,37 @@ fn wire_decision(window: &AppWindow, ctl: &Rc<MergeController>) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
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 weak = window.as_weak();
|
||||||
let ctl = ctl.clone();
|
let ctl = ctl.clone();
|
||||||
@@ -561,10 +593,11 @@ fn drain(window: &AppWindow, ctl: &Rc<MergeController>, on_done: &Rc<impl Fn(&Ap
|
|||||||
window.set_merge_running(false);
|
window.set_merge_running(false);
|
||||||
window.set_merge_stage("".into());
|
window.set_merge_stage("".into());
|
||||||
window.set_merge_fraction(0.0);
|
window.set_merge_fraction(0.0);
|
||||||
window.set_merge_status(if report.frames.iter().all(Result::is_ok) {
|
window.set_merge_status(if report.is_complete() {
|
||||||
"Check the alignment, then press Merge.".into()
|
"Check the alignment, then press Merge. Untick a frame to leave it out.".into()
|
||||||
} else {
|
} else {
|
||||||
"Not every frame could be placed. Remove the ones named and try again.".into()
|
"Not every frame could be placed. Untick the ones named to merge without them."
|
||||||
|
.into()
|
||||||
});
|
});
|
||||||
*ctl.report.borrow_mut() = Some(report);
|
*ctl.report.borrow_mut() = Some(report);
|
||||||
}
|
}
|
||||||
@@ -632,28 +665,31 @@ fn show_report(window: &AppWindow, report: &AlignmentReport, names: &[String]) {
|
|||||||
.get(k)
|
.get(k)
|
||||||
.cloned()
|
.cloned()
|
||||||
.unwrap_or_else(|| format!("frame {k}"));
|
.unwrap_or_else(|| format!("frame {k}"));
|
||||||
|
let included = report.included.get(k).copied().unwrap_or(true);
|
||||||
match f {
|
match f {
|
||||||
Ok((yaw, pitch)) => MergeFrameRow {
|
Ok((yaw, pitch)) => MergeFrameRow {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
detail: format!("yaw {yaw:.1}° · pitch {pitch:.1}°").into(),
|
detail: format!("yaw {yaw:.1}° · pitch {pitch:.1}°").into(),
|
||||||
aligned: true,
|
aligned: true,
|
||||||
|
included,
|
||||||
},
|
},
|
||||||
Err(why) => MergeFrameRow {
|
Err(why) => MergeFrameRow {
|
||||||
name: name.into(),
|
name: name.into(),
|
||||||
detail: why.clone().into(),
|
detail: why.clone().into(),
|
||||||
aligned: false,
|
aligned: false,
|
||||||
|
included,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let complete = report.frames.iter().all(Result::is_ok);
|
let complete = report.is_complete();
|
||||||
window.set_merge_frames(slint::ModelRc::new(slint::VecModel::from(rows)));
|
window.set_merge_frames(slint::ModelRc::new(slint::VecModel::from(rows)));
|
||||||
window.set_merge_aligned(true);
|
window.set_merge_aligned(true);
|
||||||
window.set_merge_complete(complete);
|
window.set_merge_complete(complete);
|
||||||
window.set_merge_summary(
|
window.set_merge_summary(
|
||||||
format!(
|
format!(
|
||||||
"{} frames, {} overlaps · {:.0} mm equivalent · {:?} · {}×{} px · fit {:.1} px",
|
"{} frames, {} overlaps · {:.0} mm equivalent · {:?} · {}×{} px · fit {:.1} px",
|
||||||
report.frames.len(),
|
report.included.iter().filter(|k| **k).count(),
|
||||||
report.links,
|
report.links,
|
||||||
report.focal_mm,
|
report.focal_mm,
|
||||||
report.projection,
|
report.projection,
|
||||||
@@ -663,9 +699,16 @@ fn show_report(window: &AppWindow, report: &AlignmentReport, names: &[String]) {
|
|||||||
)
|
)
|
||||||
.into(),
|
.into(),
|
||||||
);
|
);
|
||||||
if let Some((w, h, rgba)) = &report.preview {
|
// No preview is no picture, not the last one: after a frame is
|
||||||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(rgba, *w, *h);
|
// unticked, the old composite would show a set that is not the one
|
||||||
window.set_merge_preview(slint::Image::from_rgba8(buffer));
|
// 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);
|
window.set_merge_preview_filled(report.filled);
|
||||||
match &report.filler {
|
match &report.filler {
|
||||||
|
|||||||
@@ -616,6 +616,7 @@ export component AppWindow inherits Window {
|
|||||||
callback merge-confirm();
|
callback merge-confirm();
|
||||||
callback merge-abandon();
|
callback merge-abandon();
|
||||||
callback merge-projection-picked(int);
|
callback merge-projection-picked(int);
|
||||||
|
callback merge-frame-toggled(int, bool);
|
||||||
callback merge-border-picked(int);
|
callback merge-border-picked(int);
|
||||||
/// A fill knob turned: its name and the new value.
|
/// A fill knob turned: its name and the new value.
|
||||||
callback merge-fill-knob(string, float);
|
callback merge-fill-knob(string, float);
|
||||||
@@ -1182,6 +1183,7 @@ in property <bool> panel-visible: true;
|
|||||||
merge() => { root.merge-confirm(); }
|
merge() => { root.merge-confirm(); }
|
||||||
abandon() => { root.merge-abandon(); }
|
abandon() => { root.merge-abandon(); }
|
||||||
projection-picked(i) => { root.merge-projection-picked(i); }
|
projection-picked(i) => { root.merge-projection-picked(i); }
|
||||||
|
frame-toggled(i, on) => { root.merge-frame-toggled(i, on); }
|
||||||
border-picked(i) => { root.merge-border-picked(i); }
|
border-picked(i) => { root.merge-border-picked(i); }
|
||||||
fill-knob(name, value) => { root.merge-fill-knob(name, value); }
|
fill-knob(name, value) => { root.merge-fill-knob(name, value); }
|
||||||
close() => { root.merge-close(); }
|
close() => { root.merge-close(); }
|
||||||
|
|||||||
+24
-8
@@ -7,11 +7,13 @@
|
|||||||
// one. The difference is the pause in the middle: the job aligns, this
|
// one. The difference is the pause in the middle: the job aligns, this
|
||||||
// page shows what it found, and nothing is written until the photographer
|
// page shows what it found, and nothing is written until the photographer
|
||||||
// presses Merge (FR-MRG-1). A frame that could not be placed is named on
|
// presses Merge (FR-MRG-1). A frame that could not be placed is named on
|
||||||
// its row and the button stays off (FR-MRG-5).
|
// its row and the button stays off (FR-MRG-5) until it is unticked: each
|
||||||
|
// row's box keeps or leaves out its frame, and the job aligns again over
|
||||||
|
// the ones kept without reading any of them again.
|
||||||
|
|
||||||
import { Theme } from "theme.slint";
|
import { Theme } from "theme.slint";
|
||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar } from "widgets.slint";
|
||||||
import { Segmented, SliderRow } from "controls.slint";
|
import { Segmented, SliderRow, Check } from "controls.slint";
|
||||||
|
|
||||||
/// One source frame's line in the alignment table.
|
/// One source frame's line in the alignment table.
|
||||||
export struct MergeFrameRow {
|
export struct MergeFrameRow {
|
||||||
@@ -19,6 +21,8 @@ export struct MergeFrameRow {
|
|||||||
/// "yaw −55.9° · pitch −1.6°", or why it is not aligned.
|
/// "yaw −55.9° · pitch −1.6°", or why it is not aligned.
|
||||||
detail: string,
|
detail: string,
|
||||||
aligned: bool,
|
aligned: bool,
|
||||||
|
/// Whether the frame is in the alignment; unticked, it is left out.
|
||||||
|
included: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
export component MergePage inherits Rectangle {
|
export component MergePage inherits Rectangle {
|
||||||
@@ -75,6 +79,8 @@ export component MergePage inherits Rectangle {
|
|||||||
callback merge();
|
callback merge();
|
||||||
callback abandon();
|
callback abandon();
|
||||||
callback projection-picked(int);
|
callback projection-picked(int);
|
||||||
|
/// A frame's box: its index in `frames`, and whether to keep it.
|
||||||
|
callback frame-toggled(int, bool);
|
||||||
callback border-picked(int);
|
callback border-picked(int);
|
||||||
/// A knob turned: its name ("scale", "erosion", "coarse", "band",
|
/// A knob turned: its name ("scale", "erosion", "coarse", "band",
|
||||||
/// "mirror", "feather") and the new value.
|
/// "mirror", "feather") and the new value.
|
||||||
@@ -276,18 +282,28 @@ export component MergePage inherits Rectangle {
|
|||||||
Panel {
|
Panel {
|
||||||
PanelHeading { text: "Frames"; }
|
PanelHeading { text: "Frames"; }
|
||||||
|
|
||||||
for row in root.frames: HorizontalLayout {
|
// Controlled: the tick follows the report the job sends
|
||||||
|
// back, so a box never claims a frame the alignment on
|
||||||
|
// screen does not have.
|
||||||
|
for row[i] in root.frames: HorizontalLayout {
|
||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
Label {
|
Check {
|
||||||
text: row.name;
|
label: row.name;
|
||||||
width: 160px;
|
width: 200px;
|
||||||
overflow: elide;
|
checked: row.included;
|
||||||
|
controlled: true;
|
||||||
|
toggled(on) => {
|
||||||
|
if (!root.running && !root.done) {
|
||||||
|
root.frame-toggled(i, on);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Caption {
|
Caption {
|
||||||
text: row.detail;
|
text: row.detail;
|
||||||
warn: !row.aligned;
|
warn: row.included && !row.aligned;
|
||||||
overflow: elide;
|
overflow: elide;
|
||||||
horizontal-stretch: 1;
|
horizontal-stretch: 1;
|
||||||
|
vertical-alignment: center;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user