The merge job and its page: a selection to a panorama DNG, confirmed first
dr_ui::merge is the orchestration with no interface in it: decode each frame to sensor data and build its graph as a session would (orientation, lens profile); render each through the camera-space tap at proxy size and detect keypoints there, so the alignment is measured in the undistorted frame the tiles are rendered in; align; solve one gain per frame from the proxies' overlaps; draw the aligned set in colour for the page; then wait. Nothing is written until a Decision arrives (FR-MRG-1). The merge writes a linear DNG through the outbox with a destination record, so the drain puts it beside its sources on a folder library and a server alike, and the library rescans (FR-MRG-3). merge.slint is the page, on the import page's model: the alignment table with a failed frame named on its row and the button held off (FR-MRG-5), the preview, the projection choice, Stop and Back. A "Merge to panorama" button joins the grid's selection bar at two frames. Headless, the example produces the fixture's 22 993 x 5 980 DNG in 45 s on the reference desktop, exposures balanced across the stop of drift.
This commit is contained in:
+25
-34
@@ -5,7 +5,7 @@
|
||||
|
||||
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
|
||||
|
||||
41 gestures, in 4 places.
|
||||
40 gestures, in 4 places.
|
||||
|
||||
## Develop
|
||||
|
||||
@@ -25,7 +25,7 @@ Sampling a neutral is the first move of the tonal pass — every colour judgemen
|
||||
|
||||
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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:1988`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2028`</sub>
|
||||
|
||||
### Move a magnified photograph about
|
||||
|
||||
@@ -34,7 +34,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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2079`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2119`</sub>
|
||||
|
||||
### Paint a mask by hand
|
||||
|
||||
@@ -43,7 +43,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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2166`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2206`</sub>
|
||||
|
||||
### Take back the last change
|
||||
|
||||
@@ -53,7 +53,7 @@ A model's mask stops inside a shoulder and leaks into the hair, and no single ed
|
||||
|
||||
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:2387`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2427`</sub>
|
||||
|
||||
### Do it again after taking it back
|
||||
|
||||
@@ -61,7 +61,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
||||
- **Pointer** — Click it, or press Redo in the History header
|
||||
- **Keyboard** — Ctrl+Shift+Z
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2400`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2440`</sub>
|
||||
|
||||
### Copy the settings from this photograph
|
||||
|
||||
@@ -71,7 +71,7 @@ A whole drag is one step, so undo takes back a decision rather than a frame of a
|
||||
|
||||
The panel 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:2433`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2473`</sub>
|
||||
|
||||
### Paste the settings onto this photograph
|
||||
|
||||
@@ -81,7 +81,7 @@ The panel is the copy that has to work: a tablet has no modifier key to hold and
|
||||
|
||||
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:2445`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2485`</sub>
|
||||
|
||||
### Change which group of adjustments is on screen
|
||||
|
||||
@@ -91,7 +91,7 @@ The button names what would be pasted — "3 adjustments", and whether the crop
|
||||
|
||||
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:2473`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2513`</sub>
|
||||
|
||||
### Look at the photograph at 1:1
|
||||
|
||||
@@ -101,7 +101,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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2508`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2548`</sub>
|
||||
|
||||
### Move to the next or previous photograph
|
||||
|
||||
@@ -111,7 +111,7 @@ Noise reduction and capture sharpening are judgements about single pixels, and a
|
||||
|
||||
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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2560`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2600`</sub>
|
||||
|
||||
### See the photograph before you edited it
|
||||
|
||||
@@ -121,7 +121,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.
|
||||
|
||||
<sub>`ui/dr-ui/ui/app.slint:2684`</sub>
|
||||
<sub>`ui/dr-ui/ui/app.slint:2724`</sub>
|
||||
|
||||
### Put one control back to its default
|
||||
|
||||
@@ -226,7 +226,7 @@ Double-click is what a file manager and a Lightroom panel use for the same thing
|
||||
|
||||
Grouping over-merges on siblings, on parents and children, and on the same person a decade apart, so splitting is as prominent as merging. A tool that can only merge makes its own errors permanent.
|
||||
|
||||
<sub>`ui/dr-ui/ui/identity.slint:188`</sub>
|
||||
<sub>`ui/dr-ui/ui/identity.slint:161`</sub>
|
||||
|
||||
### Rule on a suggested face
|
||||
|
||||
@@ -235,7 +235,7 @@ Grouping over-merges on siblings, on parents and children, and on the same perso
|
||||
|
||||
A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again. The gesture note above is the whole label: a tick and a cross are only "confirm" and "reject" to someone who can see the suggestion they sit beside, and `IconButton`'s fallback would announce them as "check" and "cross" — two icon names that say nothing about which person is being ruled on.
|
||||
|
||||
<sub>`ui/dr-ui/ui/identity.slint:208`</sub>
|
||||
<sub>`ui/dr-ui/ui/identity.slint:181`</sub>
|
||||
|
||||
### See a person's photographs
|
||||
|
||||
@@ -244,7 +244,7 @@ A face is either the system's guess or the user's judgement, and the two are nev
|
||||
|
||||
This is the point of having identified anybody. Without it the screen is a filing cabinet with no drawer handles.
|
||||
|
||||
<sub>`ui/dr-ui/ui/identity.slint:635`</sub>
|
||||
<sub>`ui/dr-ui/ui/identity.slint:602`</sub>
|
||||
|
||||
### Change how faces are grouped
|
||||
|
||||
@@ -253,7 +253,7 @@ This is the point of having identified anybody. Without it the screen is a filin
|
||||
|
||||
The right match confidence is a property of your library, not of the model. "What would this do?" answers for this library without writing anything; names, confirmations and the groups you have set aside are kept whatever the dials say.
|
||||
|
||||
<sub>`ui/dr-ui/ui/identity.slint:672`</sub>
|
||||
<sub>`ui/dr-ui/ui/identity.slint:639`</sub>
|
||||
|
||||
## Library grid
|
||||
|
||||
@@ -301,15 +301,6 @@ This replaced a double tap, which had no visible state and could take forty phot
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1414`</sub>
|
||||
|
||||
### Take the blinks out of a burst
|
||||
|
||||
- **Touch** — Narrow to a person, then tap "Eyes open" beside their name on the filter bar
|
||||
- **Pointer** — Narrow to a person, then click "Eyes open" beside their name on the filter bar
|
||||
|
||||
Face indexing reads each face's eyes. The chip drops frames where the chosen people are caught blinking, and leaves sunglasses and eyes it could not read alone.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2102`</sub>
|
||||
|
||||
### Find photographs with two people in them
|
||||
|
||||
- **Touch** — Open the People chip on the filter bar, tap each name, then switch the chip beside them to "all of them"
|
||||
@@ -317,7 +308,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
|
||||
|
||||
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2131`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2106`</sub>
|
||||
|
||||
### Resize the thumbnails
|
||||
|
||||
@@ -326,7 +317,7 @@ Face indexing reads each face's eyes. The chip drops frames where the chosen peo
|
||||
|
||||
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2785`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2759`</sub>
|
||||
|
||||
### File photographs in a collection
|
||||
|
||||
@@ -335,7 +326,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
|
||||
|
||||
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:2982`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:2956`</sub>
|
||||
|
||||
### Open a photograph
|
||||
|
||||
@@ -344,7 +335,7 @@ The selection is what the drag carries, which is why selecting several is worth
|
||||
|
||||
A tap opens; a tap that *moved* does not. Travel is what separates a deliberate tap from a hand brushing past, and it is the only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3250`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3224`</sub>
|
||||
|
||||
### Rate a photograph without opening it
|
||||
|
||||
@@ -354,7 +345,7 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate
|
||||
|
||||
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3370`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3344`</sub>
|
||||
|
||||
### Choose the frame a folded burst shows
|
||||
|
||||
@@ -363,7 +354,7 @@ A star has to take the press without it also reaching the cell, or every rating
|
||||
|
||||
A folded burst draws its earliest frame, which is a fact about the clock and not a judgement about the photograph — nothing in this application ranks a frame (FR-CULL-5). But the point of a burst is that one of the twelve is better than the other eleven, and the photographer is the only one who knows which. So the choice is offered on the frames themselves, while they are open and side by side, which is the one moment the alternatives are on screen to be compared.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:3501`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:3475`</sub>
|
||||
|
||||
### Drop the selection but keep selecting
|
||||
|
||||
@@ -372,7 +363,7 @@ A folded burst draws its earliest frame, which is a fact about the clock and not
|
||||
|
||||
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:4162`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:4136`</sub>
|
||||
|
||||
### Select everything the grid is showing
|
||||
|
||||
@@ -381,7 +372,7 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
|
||||
|
||||
A scoped grid of two hundred frames is two hundred taps otherwise, and "all of them, except those three" is a far more common shape than the taps it took to say it.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:4179`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:4153`</sub>
|
||||
|
||||
### Take photographs out of a collection
|
||||
|
||||
@@ -390,4 +381,4 @@ A scoped grid of two hundred frames is two hundred taps otherwise, and "all of t
|
||||
|
||||
The badge on a cell says a photograph is filed in three collections and never which. This is the sheet that names them, and the only way out of one the grid is not currently scoped to.
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:4294`</sub>
|
||||
<sub>`ui/dr-ui/ui/library.slint:4277`</sub>
|
||||
|
||||
+57
-57
File diff suppressed because one or more lines are too long
@@ -33,6 +33,9 @@ dr-xmp.workspace = true
|
||||
dr-sync-folder.workspace = true
|
||||
dr-sync-nextcloud.workspace = true
|
||||
dr-export.workspace = true
|
||||
# The panorama's geometry and its keypoint detector (§3.11). The detector's
|
||||
# runtime is the same tract the faces and masks already carry.
|
||||
dr-pano = { workspace = true, features = ["xfeat", "embedded-model"] }
|
||||
dr-ingest.workspace = true
|
||||
dr-film.workspace = true
|
||||
# The lens profile database, here for the same reason dr-film is: dr-pipeline
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Merge a set of frames into a panorama DNG, headless.
|
||||
//!
|
||||
//! ```sh
|
||||
//! cargo run -p dr-ui --example merge --release -- fixtures/pano/2025-08-05/*.CR2
|
||||
//! cargo run -p dr-ui --example merge --release -- --out /var/tmp frame1.CR2 …
|
||||
//! ```
|
||||
//!
|
||||
//! The job the interface runs (`dr_ui::merge`), driven from a loop instead
|
||||
//! of a timer: the same code, the same events, a file at the end. What it
|
||||
//! proves that the alignment example cannot is the second half — the tap,
|
||||
//! the chunked warp, the resolve to sixteen bits and the DNG — and that the
|
||||
//! result opens in the application as one RAW.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let mut args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let mut out_dir = None;
|
||||
if args.first().map(String::as_str) == Some("--out") {
|
||||
args.remove(0);
|
||||
out_dir = Some(PathBuf::from(args.remove(0)));
|
||||
}
|
||||
if args.len() < 2 {
|
||||
eprintln!("usage: merge [--out DIR] <frame> <frame>...");
|
||||
std::process::exit(2);
|
||||
}
|
||||
let Ok(gpu) = pollster::block_on(dr_gpu::GpuContext::new_headless()) else {
|
||||
eprintln!("no GPU");
|
||||
std::process::exit(1);
|
||||
};
|
||||
|
||||
let frames: Vec<dr_ui::merge::MergeInput> = args
|
||||
.iter()
|
||||
.map(|a| dr_ui::merge::MergeInput::read(std::path::Path::new(a)).expect("read"))
|
||||
.collect();
|
||||
let dir = out_dir.unwrap_or_else(|| {
|
||||
PathBuf::from(&args[0])
|
||||
.parent()
|
||||
.map(std::path::Path::to_path_buf)
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
});
|
||||
let request = dr_ui::merge::MergeRequest::new(frames, dr_ui::merge::MergeDestination::Local(dir));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let (decide, decision) = std::sync::mpsc::channel();
|
||||
let cancel = dr_ui::merge::Cancel::default();
|
||||
let t = Instant::now();
|
||||
let worker = std::thread::spawn(move || dr_ui::merge::run(gpu, request, tx, decision, cancel));
|
||||
|
||||
let mut last_stage = "";
|
||||
for event in rx {
|
||||
use dr_ui::merge::MergeEvent::*;
|
||||
match event {
|
||||
Progress { stage, done, total } => {
|
||||
if stage != last_stage {
|
||||
println!("{stage}…");
|
||||
last_stage = stage;
|
||||
}
|
||||
if done == total || done % 4 == 0 {
|
||||
println!(" {done}/{total}");
|
||||
}
|
||||
}
|
||||
Aligned(r) => {
|
||||
println!(
|
||||
"aligned: {:.1} mm equivalent, rms {:.2} px, {} links, {:?} {}×{}",
|
||||
r.focal_mm, r.rms_px, r.links, r.projection, r.width, r.height
|
||||
);
|
||||
for (k, f) in r.frames.iter().enumerate() {
|
||||
match f {
|
||||
Ok((yaw, pitch)) => println!(" frame {k}: yaw {yaw:7.2}° pitch {pitch:6.2}°"),
|
||||
Err(why) => println!(" frame {k}: NOT ALIGNED — {why}"),
|
||||
}
|
||||
}
|
||||
// Headless: the confirmation the dialog would ask for.
|
||||
let _ = decide.send(dr_ui::merge::Decision::Merge { projection: None });
|
||||
}
|
||||
Done { path, width, height, .. } => {
|
||||
println!("wrote {} ({width}×{height}) in {:?}", path.display(), t.elapsed());
|
||||
}
|
||||
Failed(e) => {
|
||||
eprintln!("failed: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
Cancelled => println!("cancelled"),
|
||||
}
|
||||
}
|
||||
let _ = worker.join();
|
||||
}
|
||||
@@ -4578,7 +4578,7 @@ impl DevelopSession {
|
||||
/// lens's distortion and not its vignetting, or covers only part of a
|
||||
/// zoom's range, and a partial profile is worth applying — discarding it
|
||||
/// because one field is missing would turn a good correction into none.
|
||||
fn profile_for(meta: &dr_decode::Metadata) -> Option<dr_pipeline::LensProfile> {
|
||||
pub(crate) fn profile_for(meta: &dr_decode::Metadata) -> Option<dr_pipeline::LensProfile> {
|
||||
// All three are needed to ask the question at all. A lens name alone
|
||||
// does not identify a correction: distortion is interpolated across a
|
||||
// zoom's focal range and vignetting depends strongly on aperture — a
|
||||
|
||||
+20
-7
@@ -230,13 +230,7 @@ fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf,
|
||||
std::fs::write(&candidate, &encoded.bytes)
|
||||
.map_err(|e| format!("{}: {e}", candidate.display()))?;
|
||||
|
||||
let record = candidate.with_extension(format!(
|
||||
"{}.dest",
|
||||
candidate
|
||||
.extension()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
));
|
||||
let record = destination_record(&candidate);
|
||||
// The remote folder and the intended name, one per line. Not JSON: two
|
||||
// strings do not need a parser, and a format a human can repair by hand
|
||||
// is worth something for a queue holding the only copy of someone's work.
|
||||
@@ -246,6 +240,19 @@ fn stage(encoded: &Encoded, remote_dir: &str, outbox: &Path) -> Result<PathBuf,
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
/// The record that names where a staged payload goes: `name.ext.dest`
|
||||
/// beside it. One function so the merge, which stages its own composite,
|
||||
/// and the drain agree on the name.
|
||||
pub(crate) fn destination_record(payload: &Path) -> PathBuf {
|
||||
payload.with_extension(format!(
|
||||
"{}.dest",
|
||||
payload
|
||||
.extension()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
))
|
||||
}
|
||||
|
||||
/// Everything currently waiting in the outbox.
|
||||
///
|
||||
/// A payload with no record is skipped rather than guessed at — see the write
|
||||
@@ -691,6 +698,12 @@ fn export_one(
|
||||
/// `{date}` is the capture date, not today's: a template naming exports by
|
||||
/// when the shutter fired is the reason the token exists. A file that recorded
|
||||
/// no capture time gets an empty string, never a substitute.
|
||||
/// The date and the carried record for one file's header — for the batch,
|
||||
/// and for a merge writing its composite's header from its first source.
|
||||
pub(crate) fn header_for_file(meta: &dr_decode::Metadata) -> (String, dr_export::SourceMetadata) {
|
||||
from_header(meta)
|
||||
}
|
||||
|
||||
fn from_header(meta: &dr_decode::Metadata) -> (String, dr_export::SourceMetadata) {
|
||||
let date = meta
|
||||
.captured_at
|
||||
|
||||
@@ -46,6 +46,8 @@ mod library_ui;
|
||||
mod live_style;
|
||||
mod masks_ui;
|
||||
pub mod memory;
|
||||
pub mod merge;
|
||||
mod merge_ui;
|
||||
mod net_runtime;
|
||||
mod peaking;
|
||||
mod place;
|
||||
@@ -1542,6 +1544,35 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
|
||||
// through (`dr_plat::imports_supported`).
|
||||
window.set_import_supported(dr_plat::imports_supported());
|
||||
|
||||
// TRACES: FR-MRG-1
|
||||
{
|
||||
let merge = merge_ui::MergeController::new(activity.clone());
|
||||
let library_for_sources = library.clone();
|
||||
let collections_for_sources = collections.clone();
|
||||
let library_for_context = library.clone();
|
||||
let library_for_done = library.clone();
|
||||
merge_ui::wire(
|
||||
&window,
|
||||
merge,
|
||||
gpu.clone(),
|
||||
move || library_for_sources.export_sources(&collections_for_sources.selected()),
|
||||
move || {
|
||||
let conn = library_for_context.session()?;
|
||||
Some(merge_ui::Context {
|
||||
outbox: export::outbox_dir(&conn.account),
|
||||
conn,
|
||||
})
|
||||
},
|
||||
move |w| {
|
||||
// Staged beside its sources: upload it now rather than
|
||||
// on the next sync pass, then look for it, exactly as an
|
||||
// import does.
|
||||
drain_outbox(&library_for_done);
|
||||
w.invoke_library_rescan();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let import = import_ui::ImportController::new(settings.clone(), activity.clone());
|
||||
let library_for_context = library.clone();
|
||||
let weak = window.as_weak();
|
||||
|
||||
@@ -0,0 +1,735 @@
|
||||
//! TRACES: FR-MRG-1 | FR-MRG-2 | FR-MRG-3 | FR-MRG-5 | FR-MRG-7
|
||||
//! The merge job: from a set of files to a linear DNG beside them.
|
||||
//!
|
||||
//! The orchestration of a panorama, with no interface types in it: it runs
|
||||
//! on a thread of its own and reports through a channel, on the pattern
|
||||
//! [`crate::export`]'s batch established (FR-MRG-7). The interface drains
|
||||
//! the channel from a timer; a headless example drains it from a loop.
|
||||
//!
|
||||
//! # Stages
|
||||
//!
|
||||
//! 1. **Load.** Each file is read and decoded to sensor data, and its edit
|
||||
//! graph built the way a session would — orientation, lens profile —
|
||||
//! because those are the two things the camera-space tap uses
|
||||
//! (FR-MRG-2).
|
||||
//! 2. **Proxies.** Each frame is demosaiced and rendered through the tap
|
||||
//! at proxy size, upright; the keypoint detector reads that.
|
||||
//! 3. **Alignment.** `dr_pano::align`. A frame it could not place stops
|
||||
//! the job with the frame named (FR-MRG-5).
|
||||
//! 4. **Gain.** One scalar per frame from the proxies' overlaps, so the
|
||||
//! stop of exposure drift a hand-held sweep collects does not band.
|
||||
//! 5. **Merge.** `dr_gpu::MergePass`, chunk by chunk, into a DNG written
|
||||
//! strip by strip beside the first source (FR-MRG-3).
|
||||
//!
|
||||
//! The demosaiced frames are the memory: 160 MB each at f16 for a 20 MP
|
||||
//! body, so at most two are resident and the rest are demosaiced again
|
||||
//! when a band needs them. That is the trade FR-MRG-11 asks for — the
|
||||
//! sensor data stays on the CPU at a fifth of the size — and it is what
|
||||
//! makes a twelve-frame set fit.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use dr_decode::RawImage;
|
||||
use dr_gpu::{AdjustPass, DemosaicedImage, Demosaicer, GpuContext, MergeFrame, MergeOutput, MergePass};
|
||||
use dr_pano::bundle::Cameras;
|
||||
use dr_pano::projection::{self, Projection};
|
||||
use dr_pano::{Alignment, Gray};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
pub use crate::export::Cancel;
|
||||
|
||||
/// One frame as the job receives it: the file's bytes, already fetched,
|
||||
/// and a name for messages and for the composite's own name.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MergeInput {
|
||||
/// The file's name, `_MG_8320.CR2`.
|
||||
pub name: String,
|
||||
pub bytes: Arc<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl MergeInput {
|
||||
pub fn read(path: &Path) -> Result<Self, String> {
|
||||
Ok(MergeInput {
|
||||
name: path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "frame".into()),
|
||||
bytes: Arc::new(std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the composite goes (FR-MRG-3).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MergeDestination {
|
||||
/// A folder on this device: written there directly.
|
||||
Local(PathBuf),
|
||||
/// Beside its sources in the library, through the export outbox: the
|
||||
/// file is staged with a destination record and the drain uploads it —
|
||||
/// the one path that works for a folder library and a server alike, and
|
||||
/// the one FR-MRG-3 names for a folder the job cannot write itself.
|
||||
Outbox {
|
||||
outbox: PathBuf,
|
||||
/// The sources' folder, relative to the library root.
|
||||
remote_dir: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// What the job is told.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MergeRequest {
|
||||
/// The frames, in capture order.
|
||||
pub frames: Vec<MergeInput>,
|
||||
pub destination: MergeDestination,
|
||||
/// `None` for the projection the field of view suggests.
|
||||
pub projection: Option<Projection>,
|
||||
/// Pixels over which a frame's weight ramps up from its edge.
|
||||
pub feather_px: f32,
|
||||
/// GPU work unit; also the DNG strip height.
|
||||
pub chunk: (u32, u32),
|
||||
}
|
||||
|
||||
impl MergeRequest {
|
||||
pub fn new(frames: Vec<MergeInput>, destination: MergeDestination) -> Self {
|
||||
MergeRequest {
|
||||
frames,
|
||||
destination,
|
||||
projection: None,
|
||||
feather_px: 200.0,
|
||||
chunk: (2048, 512),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What the photographer decides once the alignment is shown (FR-MRG-1:
|
||||
/// never automatic).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Decision {
|
||||
Merge { projection: Option<Projection> },
|
||||
Abandon,
|
||||
}
|
||||
|
||||
/// What the job says while it runs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MergeEvent {
|
||||
/// A stage, with progress within it.
|
||||
Progress {
|
||||
stage: &'static str,
|
||||
done: usize,
|
||||
total: usize,
|
||||
},
|
||||
/// The alignment, before any pixel is written: what the interface shows
|
||||
/// for the photographer to confirm, and what names a failure.
|
||||
Aligned(AlignmentReport),
|
||||
/// The composite is written: on the device at `path`, or staged in the
|
||||
/// outbox for the drain to upload, in which case `staged` is true and
|
||||
/// the library learns of it when the folder is next scanned.
|
||||
Done {
|
||||
path: PathBuf,
|
||||
staged: bool,
|
||||
width: u32,
|
||||
height: u32,
|
||||
},
|
||||
Failed(String),
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// The alignment, described for a panel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AlignmentReport {
|
||||
/// Equivalent focal length in millimetres on full frame, from the fit.
|
||||
pub focal_mm: f64,
|
||||
pub rms_px: f64,
|
||||
pub projection: Projection,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
/// Per frame, in input order: yaw and pitch in degrees if aligned, or
|
||||
/// why not.
|
||||
pub frames: Vec<Result<(f64, f64), String>>,
|
||||
pub links: usize,
|
||||
/// The aligned set drawn on the suggested surface at proxy resolution,
|
||||
/// `(width, height, rgba)` — what the photographer confirms.
|
||||
pub preview: Option<(u32, u32, Vec<u8>)>,
|
||||
}
|
||||
|
||||
/// Run the whole job on the calling thread, reporting on `events`.
|
||||
///
|
||||
/// After `Aligned` the job waits on `decision` — the photographer's
|
||||
/// confirmation, or the headless caller's immediate `Merge` — and returns
|
||||
/// when the file is written, the job failed, or `cancel` was seen.
|
||||
pub fn run(
|
||||
ctx: GpuContext,
|
||||
request: MergeRequest,
|
||||
events: Sender<MergeEvent>,
|
||||
decision: Receiver<Decision>,
|
||||
cancel: Cancel,
|
||||
) {
|
||||
let send = |e: MergeEvent| {
|
||||
let _ = events.send(e);
|
||||
};
|
||||
match run_inner(&ctx, &request, &events, &decision, &cancel) {
|
||||
Ok(Some(done)) => send(done),
|
||||
Ok(None) => send(MergeEvent::Cancelled),
|
||||
Err(e) => send(MergeEvent::Failed(e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frames as loaded: sensor data on the CPU and the graph the tap uses.
|
||||
struct Loaded {
|
||||
raw: RawImage,
|
||||
meta: dr_decode::Metadata,
|
||||
graph: Arc<EditGraph>,
|
||||
/// The upright size, which is what the cameras are in.
|
||||
size: (u32, u32),
|
||||
}
|
||||
|
||||
fn run_inner(
|
||||
ctx: &GpuContext,
|
||||
request: &MergeRequest,
|
||||
events: &Sender<MergeEvent>,
|
||||
decision: &Receiver<Decision>,
|
||||
cancel: &Cancel,
|
||||
) -> Result<Option<MergeEvent>, String> {
|
||||
if request.frames.len() < 2 {
|
||||
return Err("a panorama needs at least two frames".into());
|
||||
}
|
||||
let progress = |stage: &'static str, done: usize, total: usize| {
|
||||
let _ = events.send(MergeEvent::Progress { stage, done, total });
|
||||
};
|
||||
|
||||
// 1. Load.
|
||||
let t = Instant::now();
|
||||
let mut frames: Vec<Loaded> = Vec::with_capacity(request.frames.len());
|
||||
for (i, input) in request.frames.iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
return Ok(None);
|
||||
}
|
||||
progress("Reading", i, request.frames.len());
|
||||
let bytes = &input.bytes;
|
||||
let meta = dr_decode::metadata(bytes).unwrap_or_default();
|
||||
let raw = dr_decode::decode(bytes).map_err(|e| format!("{}: {e}", input.name))?;
|
||||
let orientation = meta.orientation.unwrap_or_default();
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_orientation(orientation);
|
||||
graph.set_lens_profile(crate::develop::DevelopSession::profile_for(&meta));
|
||||
let (w, h) = (raw.crop.width, raw.crop.height);
|
||||
let size = if orientation.quarter_turns % 2 == 1 { (h, w) } else { (w, h) };
|
||||
frames.push(Loaded {
|
||||
raw,
|
||||
meta,
|
||||
graph: Arc::new(graph),
|
||||
size,
|
||||
});
|
||||
}
|
||||
log::info!("merge: {} frames read in {:?}", frames.len(), t.elapsed());
|
||||
|
||||
// 2. Proxies and keypoints.
|
||||
let t = Instant::now();
|
||||
let demosaicer = Demosaicer::new(ctx).map_err(|e| e.to_string())?;
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
let mut detector = dr_pano::xfeat::XFeat::embedded().map_err(|e| e.to_string())?;
|
||||
let mut features = Vec::with_capacity(frames.len());
|
||||
let mut proxies: Vec<Gray> = Vec::with_capacity(frames.len());
|
||||
let mut colour: Vec<Vec<f32>> = Vec::with_capacity(frames.len());
|
||||
for (i, f) in frames.iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
return Ok(None);
|
||||
}
|
||||
progress("Finding features", i, frames.len());
|
||||
let image = demosaicer.run(&f.raw).map_err(|e| e.to_string())?;
|
||||
let (proxy, rgb) = camera_proxy(&mut adjust, &image, &f.graph, f.size)?;
|
||||
features.push(detector.detect(&proxy).map_err(|e| e.to_string())?);
|
||||
proxies.push(proxy);
|
||||
colour.push(rgb);
|
||||
}
|
||||
log::info!("merge: features in {:?}", t.elapsed());
|
||||
|
||||
// 3. Alignment.
|
||||
let t = Instant::now();
|
||||
progress("Aligning", 0, 1);
|
||||
let alignment = dr_pano::align(&features, &dr_pano::AlignOptions::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
log::info!(
|
||||
"merge: aligned in {:?}, focal {:.1} px, rms {:.2} px, {} links",
|
||||
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 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 bounds = projection::bounds(projection, focal_full, &cameras, frame_size)
|
||||
.ok_or("the frames project nowhere")?;
|
||||
|
||||
// 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()]
|
||||
};
|
||||
let preview = if alignment.is_complete() {
|
||||
progress("Drawing the preview", 0, 1);
|
||||
Some(preview(&colour, &proxies, &alignment, &gains, projection, &frames[0].raw))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let report = AlignmentReport {
|
||||
focal_mm: focal_full * 36.0 / full_long,
|
||||
rms_px: alignment.rms_px,
|
||||
projection,
|
||||
width: bounds.width().ceil() as u32,
|
||||
height: bounds.height().ceil() as u32,
|
||||
frames: describe(&alignment),
|
||||
links: alignment.links.len(),
|
||||
preview,
|
||||
};
|
||||
let _ = events.send(MergeEvent::Aligned(report));
|
||||
if !alignment.is_complete() {
|
||||
let names: Vec<String> = alignment
|
||||
.unaligned
|
||||
.iter()
|
||||
.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
|
||||
// has been seen and confirmed. Polled, so a cancel while waiting is
|
||||
// seen within a moment.
|
||||
let projection = loop {
|
||||
if cancel.is_cancelled() {
|
||||
return Ok(None);
|
||||
}
|
||||
match decision.recv_timeout(std::time::Duration::from_millis(100)) {
|
||||
Ok(Decision::Merge { projection: p }) => break p.unwrap_or(projection),
|
||||
Ok(Decision::Abandon) => return Ok(None),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => continue,
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Ok(None),
|
||||
}
|
||||
};
|
||||
let bounds = projection::bounds(projection, focal_full, &cameras, frame_size)
|
||||
.ok_or("the frames project nowhere")?;
|
||||
|
||||
// 5. Merge, into a DNG beside the first frame.
|
||||
let name = format!("{}-pano.dng", stem(Path::new(&request.frames[0].name)));
|
||||
let (out_path, staged) = match &request.destination {
|
||||
MergeDestination::Local(dir) => (unused_name(dir, &name), false),
|
||||
MergeDestination::Outbox { outbox, remote_dir } => {
|
||||
std::fs::create_dir_all(outbox).map_err(|e| format!("{}: {e}", outbox.display()))?;
|
||||
let path = unused_name(outbox, &name);
|
||||
// The record first here, unlike an export: the payload is
|
||||
// written over minutes and a record naming a half-written file
|
||||
// is worse than a payload with no record, so the record is
|
||||
// removed again if the merge fails. The drain skips payloads
|
||||
// without records.
|
||||
let record = crate::export::destination_record(&path);
|
||||
let final_name = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or(name.clone());
|
||||
std::fs::write(&record, format!("{remote_dir}\n{final_name}\n"))
|
||||
.map_err(|e| format!("{}: {e}", record.display()))?;
|
||||
(path, true)
|
||||
}
|
||||
};
|
||||
let cleanup = |path: &Path| {
|
||||
let _ = std::fs::remove_file(path);
|
||||
if staged {
|
||||
let _ = std::fs::remove_file(crate::export::destination_record(path));
|
||||
}
|
||||
};
|
||||
let (out_w, out_h) = (report_size(&bounds).0, report_size(&bounds).1);
|
||||
let first = &frames[0];
|
||||
let black = first.raw.black_level[0];
|
||||
let white_level = u32::from(first.raw.white_level.saturating_sub(black)).max(1);
|
||||
let profile = dng_profile(first, white_level);
|
||||
let (_, carried) = crate::export::header_for_file(&first.meta);
|
||||
|
||||
let output = MergeOutput {
|
||||
projection,
|
||||
scale: focal_full,
|
||||
bounds,
|
||||
feather: request.feather_px,
|
||||
chunk: request.chunk,
|
||||
sample_scale: white_level as f32,
|
||||
};
|
||||
let merge_frames: Vec<MergeFrame> = frames
|
||||
.iter()
|
||||
.zip(&gains)
|
||||
.map(|(f, &gain)| MergeFrame {
|
||||
graph: f.graph.clone(),
|
||||
gain,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// The writer pulls strips; the merge pushes bands. A channel between
|
||||
// them, and the writer on its own thread, so neither waits on the
|
||||
// other's pace more than one band.
|
||||
let (band_tx, band_rx) = std::sync::mpsc::sync_channel::<Vec<u16>>(1);
|
||||
let rows_per_strip = request.chunk.1.max(1);
|
||||
let file = std::fs::File::create(&out_path).map_err(|e| format!("{}: {e}", out_path.display()))?;
|
||||
let writer = std::thread::spawn(move || -> Result<(), String> {
|
||||
let mut file = std::io::BufWriter::new(file);
|
||||
dr_export::write_linear_dng(
|
||||
&mut file,
|
||||
out_w,
|
||||
out_h,
|
||||
rows_per_strip,
|
||||
&profile,
|
||||
Some(&carried),
|
||||
|_, buf| {
|
||||
let band = band_rx
|
||||
.recv()
|
||||
.map_err(|_| dr_export::ExportError::Encode("the merge stopped early".into()))?;
|
||||
buf.extend_from_slice(&band);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
|
||||
let t = Instant::now();
|
||||
let mut pass = MergePass::new(ctx).map_err(|e| e.to_string())?;
|
||||
let mut resident: VecDeque<(usize, Arc<DemosaicedImage>)> = VecDeque::new();
|
||||
let total_bands = out_h.div_ceil(rows_per_strip) as usize;
|
||||
let mut bands_done = 0usize;
|
||||
progress("Merging", 0, total_bands);
|
||||
let merged = pass.merge(
|
||||
&mut adjust,
|
||||
&merge_frames,
|
||||
&cameras,
|
||||
(frames[0].size.0, frames[0].size.1),
|
||||
&output,
|
||||
|k| {
|
||||
if let Some((_, img)) = resident.iter().find(|(i, _)| *i == k) {
|
||||
return Ok(img.clone());
|
||||
}
|
||||
let img = Arc::new(demosaicer.run(&frames[k].raw)?);
|
||||
resident.push_back((k, img.clone()));
|
||||
while resident.len() > 2 {
|
||||
resident.pop_front();
|
||||
}
|
||||
Ok(img)
|
||||
},
|
||||
|band| {
|
||||
bands_done += 1;
|
||||
progress("Merging", bands_done, total_bands);
|
||||
band_tx
|
||||
.send(band.rgb.to_vec())
|
||||
.map_err(|_| dr_gpu::GpuError::Readback("the writer stopped".into()))
|
||||
},
|
||||
|| cancel.is_cancelled(),
|
||||
);
|
||||
drop(band_tx);
|
||||
let written = writer.join().unwrap_or_else(|_| Err("the writer panicked".into()));
|
||||
match merged {
|
||||
Ok(()) => {}
|
||||
Err(e) if cancel.is_cancelled() => {
|
||||
cleanup(&out_path);
|
||||
log::info!("merge cancelled: {e}");
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => {
|
||||
cleanup(&out_path);
|
||||
return Err(e.to_string());
|
||||
}
|
||||
}
|
||||
if let Err(e) = written {
|
||||
cleanup(&out_path);
|
||||
return Err(e);
|
||||
}
|
||||
log::info!("merge: {}×{} written to {} in {:?}", out_w, out_h, out_path.display(), t.elapsed());
|
||||
Ok(Some(MergeEvent::Done {
|
||||
path: out_path,
|
||||
staged,
|
||||
width: out_w,
|
||||
height: out_h,
|
||||
}))
|
||||
}
|
||||
|
||||
/// The proxy the detector reads: the frame through the camera-space tap at
|
||||
/// proxy size, upright, as gamma-encoded grey.
|
||||
///
|
||||
/// Through the tap rather than the embedded preview so that the keypoints
|
||||
/// are in the *undistorted* frame the tiles will be rendered in — a lens
|
||||
/// profile moves the corners by tens of pixels at full resolution, and an
|
||||
/// alignment measured on the distorted preview would be wrong by that much
|
||||
/// at the seams.
|
||||
fn camera_proxy(
|
||||
adjust: &mut AdjustPass,
|
||||
image: &DemosaicedImage,
|
||||
graph: &EditGraph,
|
||||
upright: (u32, u32),
|
||||
) -> Result<(Gray, Vec<f32>), String> {
|
||||
let long = dr_pano::xfeat::INPUT_LONG_EDGE as f64;
|
||||
let scale = (long / f64::from(upright.0.max(upright.1))).min(1.0);
|
||||
let w = ((f64::from(upright.0) * scale).round() as u32).max(1);
|
||||
let h = ((f64::from(upright.1) * scale).round() as u32).max(1);
|
||||
let shader = graph.compose_camera_linear(dr_pipeline::CropRect::default());
|
||||
adjust
|
||||
.render_camera_linear(image, &shader, w, h)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let (rgba, rw, rh) = adjust.read_camera_linear().map_err(|e| e.to_string())?;
|
||||
// Camera RGB is unbalanced — green-heavy on every Bayer body — and
|
||||
// linear. Luma weights would over-count green further; an equal mean is
|
||||
// as good a grey as any for corners and edges, and the gamma is what
|
||||
// gives the detector the contrast range it was trained on.
|
||||
let data: Vec<f32> = rgba
|
||||
.chunks_exact(4)
|
||||
.map(|p| ((p[0] + p[1] + p[2]) / 3.0).clamp(0.0, 1.0).powf(1.0 / 2.2))
|
||||
.collect();
|
||||
let rgb: Vec<f32> = rgba.chunks_exact(4).flat_map(|p| [p[0], p[1], p[2]]).collect();
|
||||
Ok((
|
||||
Gray {
|
||||
width: rw as usize,
|
||||
height: rh as usize,
|
||||
data,
|
||||
},
|
||||
rgb,
|
||||
))
|
||||
}
|
||||
|
||||
/// The aligned set on its surface, in colour, for the page.
|
||||
///
|
||||
/// A quick look, not the pipeline: the first frame's white balance and
|
||||
/// matrix, a gamma, and the frames averaged where they overlap with their
|
||||
/// gains applied. Ghosting here is the alignment's error and banding is
|
||||
/// the gains', which is exactly what the photographer is being asked to
|
||||
/// look at. Fitted to 1600 px across.
|
||||
fn preview(
|
||||
colour: &[Vec<f32>],
|
||||
proxies: &[Gray],
|
||||
alignment: &Alignment,
|
||||
gains: &[f32],
|
||||
projection: Projection,
|
||||
first: &RawImage,
|
||||
) -> (u32, u32, Vec<u8>) {
|
||||
let cameras = alignment.cameras();
|
||||
let (fw, fh) = (proxies[0].width as f64, proxies[0].height as f64);
|
||||
let scale = alignment.focal;
|
||||
let Some(bounds) = projection::bounds(projection, scale, &cameras, (fw, fh)) else {
|
||||
return (0, 0, Vec::new());
|
||||
};
|
||||
let out_w = 1600usize.min(bounds.width().ceil() as usize).max(1);
|
||||
let px = bounds.width() / out_w as f64;
|
||||
let out_h = ((bounds.height() / px).ceil() as usize).max(1);
|
||||
let wb = first.wb_coeffs;
|
||||
let m = first
|
||||
.color_matrix
|
||||
.unwrap_or([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]);
|
||||
let mut out = vec![0u8; out_w * out_h * 4];
|
||||
for oy in 0..out_h {
|
||||
for ox in 0..out_w {
|
||||
let u = bounds.min_u + (ox as f64 + 0.5) * px;
|
||||
let v = bounds.min_v + (oy as f64 + 0.5) * px;
|
||||
let d = projection.to_direction(scale, u, v);
|
||||
let mut sum = [0.0f32; 3];
|
||||
let mut n = 0u32;
|
||||
for (k, g) in proxies.iter().enumerate() {
|
||||
let Some((x, y)) = cameras.project(k, d) else { continue };
|
||||
let (x, y) = (x + fw / 2.0, y + fh / 2.0);
|
||||
if x < 0.0 || y < 0.0 || x >= fw - 1.0 || y >= fh - 1.0 {
|
||||
continue;
|
||||
}
|
||||
let i = (y as usize * g.width + x as usize) * 3;
|
||||
for c in 0..3 {
|
||||
sum[c] += colour[k][i + c] * gains[k];
|
||||
}
|
||||
n += 1;
|
||||
}
|
||||
let o = (oy * out_w + ox) * 4;
|
||||
if n == 0 {
|
||||
out[o + 3] = 255;
|
||||
continue;
|
||||
}
|
||||
let cam = [sum[0] / n as f32 * wb[0], sum[1] / n as f32 * wb[1], sum[2] / n as f32 * wb[2]];
|
||||
for c in 0..3 {
|
||||
let lin = m[c * 3] * cam[0] + m[c * 3 + 1] * cam[1] + m[c * 3 + 2] * cam[2];
|
||||
out[o + c] = (lin.clamp(0.0, 1.0).powf(1.0 / 2.2) * 255.0) as u8;
|
||||
}
|
||||
out[o + 3] = 255;
|
||||
}
|
||||
}
|
||||
(out_w as u32, out_h as u32, out)
|
||||
}
|
||||
|
||||
/// The horizontal and vertical field of view the cameras span, in radians:
|
||||
/// the angle between the extreme frame centres plus one frame's own field.
|
||||
fn field_of_view(cameras: &Cameras, frame: (f64, f64)) -> (f64, f64) {
|
||||
let f = cameras.focal;
|
||||
let own_h = 2.0 * (frame.0 / (2.0 * f)).atan();
|
||||
let own_v = 2.0 * (frame.1 / (2.0 * f)).atan();
|
||||
let (mut min_yaw, mut max_yaw, mut min_pitch, mut max_pitch) = (0.0f64, 0.0f64, 0.0f64, 0.0f64);
|
||||
for r in &cameras.rotations {
|
||||
let d = *r * dr_pano::linalg::Vec3::new(0.0, 0.0, 1.0);
|
||||
let yaw = d.x().atan2(d.z());
|
||||
let pitch = d.y().asin();
|
||||
min_yaw = min_yaw.min(yaw);
|
||||
max_yaw = max_yaw.max(yaw);
|
||||
min_pitch = min_pitch.min(pitch);
|
||||
max_pitch = max_pitch.max(pitch);
|
||||
}
|
||||
(max_yaw - min_yaw + own_h, max_pitch - min_pitch + own_v)
|
||||
}
|
||||
|
||||
fn report_size(b: &projection::Bounds) -> (u32, u32) {
|
||||
(b.width().ceil().max(1.0) as u32, b.height().ceil().max(1.0) as u32)
|
||||
}
|
||||
|
||||
fn describe(a: &Alignment) -> Vec<Result<(f64, f64), String>> {
|
||||
a.rotations
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(k, r)| match r {
|
||||
Some(r) => {
|
||||
let yaw = r.0[0][2].atan2(r.0[2][2]).to_degrees();
|
||||
let pitch = (-r.0[1][2]).asin().to_degrees();
|
||||
Ok((yaw, pitch))
|
||||
}
|
||||
None => Err(a
|
||||
.unaligned
|
||||
.iter()
|
||||
.find(|(i, _)| *i == k)
|
||||
.map(|(_, why)| why.to_string())
|
||||
.unwrap_or_else(|| "not aligned".into())),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One gain per frame, from the proxies' overlaps.
|
||||
///
|
||||
/// For each link, the mean grey of each frame over the region both see is
|
||||
/// compared; the log-gains that best reconcile every link are solved in
|
||||
/// 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
|
||||
/// linear multiplier as the shader applies it.
|
||||
fn gains(proxies: &[Gray], a: &Alignment) -> Vec<f32> {
|
||||
let n = proxies.len();
|
||||
let cameras = a.cameras();
|
||||
let (w, h) = (proxies[0].width as f64, proxies[0].height as f64);
|
||||
let linear = |g: &Gray, x: usize, y: usize| g.data[y * g.width + x].powf(2.2) as f64;
|
||||
|
||||
// Ratios per link.
|
||||
let mut ratios: Vec<(usize, usize, f64)> = Vec::new();
|
||||
for l in &a.links {
|
||||
let (mut sum_i, mut sum_j, mut count) = (0.0, 0.0, 0usize);
|
||||
let step = 8;
|
||||
for y in (0..proxies[l.i].height).step_by(step) {
|
||||
for x in (0..proxies[l.i].width).step_by(step) {
|
||||
let p = (x as f64 - w / 2.0, y as f64 - h / 2.0);
|
||||
let d = cameras.bearing(l.i, p);
|
||||
let Some((qx, qy)) = cameras.project(l.j, d) else { continue };
|
||||
let (qx, qy) = (qx + w / 2.0, qy + h / 2.0);
|
||||
if qx < 0.0 || qy < 0.0 || qx >= w - 1.0 || qy >= h - 1.0 {
|
||||
continue;
|
||||
}
|
||||
sum_i += linear(&proxies[l.i], x, y);
|
||||
sum_j += linear(&proxies[l.j], qx as usize, qy as usize);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if count >= 50 && sum_i > 0.0 && sum_j > 0.0 {
|
||||
ratios.push((l.i, l.j, (sum_i / sum_j).ln()));
|
||||
}
|
||||
}
|
||||
if ratios.is_empty() {
|
||||
return vec![1.0; n];
|
||||
}
|
||||
// Least squares on log gains: g_j - g_i = ln(mean_i / mean_j), with
|
||||
// the reference frame's gain fixed at 0 by a strong prior.
|
||||
let root = a
|
||||
.rotations
|
||||
.iter()
|
||||
.position(|r| *r == Some(dr_pano::linalg::Mat3::IDENTITY))
|
||||
.unwrap_or(0);
|
||||
let mut ata = dr_pano::linalg::DMat::zeros(n);
|
||||
let mut atb = vec![0.0f64; n];
|
||||
for &(i, j, r) in &ratios {
|
||||
// Row: +1 at j, −1 at i, rhs r.
|
||||
ata[(j, j)] += 1.0;
|
||||
ata[(i, i)] += 1.0;
|
||||
ata[(i, j)] -= 1.0;
|
||||
ata[(j, i)] -= 1.0;
|
||||
atb[j] += r;
|
||||
atb[i] -= r;
|
||||
}
|
||||
ata[(root, root)] += 1e6;
|
||||
// A tiny ridge keeps an unlinked frame (there are none if the alignment
|
||||
// is complete) from making the system singular.
|
||||
for k in 0..n {
|
||||
ata[(k, k)] += 1e-9;
|
||||
}
|
||||
match ata.solve_spd(&atb) {
|
||||
Some(g) => g.iter().map(|v| v.exp() as f32).collect(),
|
||||
None => vec![1.0; n],
|
||||
}
|
||||
}
|
||||
|
||||
fn dng_profile(first: &Loaded, white_level: u32) -> dr_export::DngProfile {
|
||||
let wb = first.raw.wb_coeffs;
|
||||
let neutral_from_wb = [1.0 / wb[0].max(1e-3), 1.0 / wb[1].max(1e-3), 1.0 / wb[2].max(1e-3)];
|
||||
let (calibrations, as_shot_neutral) = match &first.raw.profile {
|
||||
Some(p) => (p.dng_calibrations(), p.neutral().unwrap_or(neutral_from_wb)),
|
||||
None => (Vec::new(), neutral_from_wb),
|
||||
};
|
||||
let unique_model = match (first.raw.make.trim(), first.raw.model.trim()) {
|
||||
("", "") => "DarkRoom panorama".to_string(),
|
||||
(make, model) if model.starts_with(make) => model.to_string(),
|
||||
(make, model) => format!("{make} {model}"),
|
||||
};
|
||||
dr_export::DngProfile {
|
||||
unique_model,
|
||||
calibrations,
|
||||
as_shot_neutral,
|
||||
white_level,
|
||||
}
|
||||
}
|
||||
|
||||
fn stem(p: &Path) -> String {
|
||||
p.file_stem()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| p.display().to_string())
|
||||
}
|
||||
|
||||
/// `name` in `dir`, numbered if that name is taken: a merge never
|
||||
/// overwrites (FR-MRG-3).
|
||||
fn unused_name(dir: &Path, name: &str) -> PathBuf {
|
||||
let mut candidate = dir.join(name);
|
||||
let base = stem(Path::new(name));
|
||||
let mut n = 2;
|
||||
while candidate.exists() {
|
||||
candidate = dir.join(format!("{base}-{n}.dng"));
|
||||
n += 1;
|
||||
}
|
||||
candidate
|
||||
}
|
||||
|
||||
/// Drain everything a job has said so far.
|
||||
pub fn drain(rx: &Receiver<MergeEvent>) -> Vec<MergeEvent> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(e) = rx.try_recv() {
|
||||
out.push(e);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
//! 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.
|
||||
//! "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 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, MergeDestination, MergeEvent, MergeInput, MergeRequest,
|
||||
};
|
||||
use crate::{AppWindow, MergeFrameRow};
|
||||
|
||||
/// 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>,
|
||||
report: RefCell<Option<AlignmentReport>>,
|
||||
}
|
||||
|
||||
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),
|
||||
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,
|
||||
{
|
||||
let on_done = Rc::new(on_done);
|
||||
|
||||
// --- start -----------------------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let on_done = on_done.clone();
|
||||
window.on_library_merge_selection(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
if ctl.job.borrow().is_some() {
|
||||
w.set_library_status("A panorama is already being merged".into());
|
||||
return;
|
||||
}
|
||||
let Some(gpu) = gpu.clone() else {
|
||||
w.set_library_status("No GPU: a panorama cannot be merged on this device".into());
|
||||
return;
|
||||
};
|
||||
let Some(context) = context() else {
|
||||
w.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.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();
|
||||
|
||||
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(sources.len());
|
||||
|
||||
// The worker: fetch every original, then run the job. Fetching
|
||||
// here rather than in the job keeps the job free of the library
|
||||
// (it takes bytes), and sequential rather than all at once so
|
||||
// twelve downloads do not halve each other's bandwidth.
|
||||
{
|
||||
let conn = context.conn.clone();
|
||||
let cancel = cancel.clone();
|
||||
let tx = tx.clone();
|
||||
let names = names.clone();
|
||||
std::thread::spawn(move || {
|
||||
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;
|
||||
}
|
||||
let _ = tx.send(MergeEvent::Progress {
|
||||
stage: "Fetching",
|
||||
done: i,
|
||||
total: 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!("{}: {}", names[i], e.message)));
|
||||
return;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = tx.send(MergeEvent::Failed(format!(
|
||||
"{}: the download ended without answering",
|
||||
names[i]
|
||||
)));
|
||||
return;
|
||||
}
|
||||
};
|
||||
frames.push(MergeInput {
|
||||
name: names[i].clone(),
|
||||
bytes: std::sync::Arc::new(bytes),
|
||||
});
|
||||
}
|
||||
let request = MergeRequest::new(frames, destination);
|
||||
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);
|
||||
|
||||
// The page, from a clean slate.
|
||||
w.set_merge_stage("Fetching".into());
|
||||
w.set_merge_running(true);
|
||||
w.set_merge_fraction(0.0);
|
||||
w.set_merge_status("".into());
|
||||
w.set_merge_error("".into());
|
||||
w.set_merge_aligned(false);
|
||||
w.set_merge_complete(false);
|
||||
w.set_merge_summary("".into());
|
||||
w.set_merge_frames(slint::ModelRc::new(slint::VecModel::from(Vec::<MergeFrameRow>::new())));
|
||||
w.set_merge_preview(slint::Image::default());
|
||||
w.set_merge_projection_selected(0);
|
||||
w.set_merge_done(false);
|
||||
w.set_show_merge(true);
|
||||
|
||||
let timer = slint::Timer::default();
|
||||
{
|
||||
let weak = w.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 decision ----------------------------------------------------
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
window.on_merge_confirm(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let projection = match ctl.projection.get() {
|
||||
1 => Some(dr_pano::Projection::Perspective),
|
||||
2 => Some(dr_pano::Projection::Cylindrical),
|
||||
3 => Some(dr_pano::Projection::Spherical),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(job) = ctl.job.borrow().as_ref() {
|
||||
let _ = job.decide.send(Decision::Merge { projection });
|
||||
job.activity.detail("merging");
|
||||
}
|
||||
w.set_merge_running(true);
|
||||
w.set_merge_stage("Merging".into());
|
||||
w.set_merge_status("".into());
|
||||
});
|
||||
}
|
||||
{
|
||||
let ctl = ctl.clone();
|
||||
window.on_merge_projection_picked(move |i| {
|
||||
ctl.projection.set(i);
|
||||
});
|
||||
}
|
||||
|
||||
// --- stopping and leaving --------------------------------------------
|
||||
{
|
||||
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_show_merge(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = {
|
||||
let job = ctl.job.borrow();
|
||||
let Some(job) = job.as_ref() else { return };
|
||||
merge::drain(&job.rx)
|
||||
};
|
||||
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.frames.iter().all(Result::is_ok) {
|
||||
"Check the alignment, then press Merge.".into()
|
||||
} else {
|
||||
"Not every frame could be placed. Remove the ones named and try again.".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}"));
|
||||
match f {
|
||||
Ok((yaw, pitch)) => MergeFrameRow {
|
||||
name: name.into(),
|
||||
detail: format!("yaw {yaw:.1}° · pitch {pitch:.1}°").into(),
|
||||
aligned: true,
|
||||
},
|
||||
Err(why) => MergeFrameRow {
|
||||
name: name.into(),
|
||||
detail: why.clone().into(),
|
||||
aligned: false,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let complete = report.frames.iter().all(Result::is_ok);
|
||||
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.frames.len(),
|
||||
report.links,
|
||||
report.focal_mm,
|
||||
report.projection,
|
||||
report.width,
|
||||
report.height,
|
||||
report.rms_px
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
if let Some((w, h, rgba)) = &report.preview {
|
||||
let buffer = slint::SharedPixelBuffer::<slint::Rgba8Pixel>::clone_from_slice(rgba, *w, *h);
|
||||
window.set_merge_preview(slint::Image::from_rgba8(buffer));
|
||||
}
|
||||
}
|
||||
+54
-6
@@ -18,10 +18,11 @@ import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
|
||||
import { FocusMarks, FocusPanel, Peaking } from "peaking.slint";
|
||||
import { SettingsPage } from "settings.slint";
|
||||
import { ImportPage } from "import.slint";
|
||||
import { MergePage, MergeFrameRow } from "merge.slint";
|
||||
import { Capture, StatusBar, InfoPanel } from "develop.slint";
|
||||
import { ToolRail } from "toolrail.slint";
|
||||
|
||||
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip }
|
||||
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip, MergeFrameRow }
|
||||
export { GestureRow }
|
||||
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
|
||||
|
||||
@@ -239,7 +240,7 @@ export component AppWindow inherits Window {
|
||||
/// So the guard has to be this, and the `if` has to be spelled from it, or
|
||||
/// the two can drift apart again.
|
||||
out property <bool> library-visible:
|
||||
!root.show-import && !root.show-settings && !root.show-identity
|
||||
!root.show-import && !root.show-merge && !root.show-settings && !root.show-identity
|
||||
&& !root.show-launch && root.show-library;
|
||||
in-out property <[LibraryCell]> library-cells;
|
||||
in property <int> library-total: 0;
|
||||
@@ -821,6 +822,30 @@ export component AppWindow inherits Window {
|
||||
in property <string> import-error: "";
|
||||
in property <bool> import-can-start: false;
|
||||
|
||||
// TRACES: FR-MRG-1 | FR-MRG-7
|
||||
// The merge page, on the import page's model: a decision to be
|
||||
// committed or abandoned, with a running state, over the library.
|
||||
in-out property <bool> show-merge: false;
|
||||
in property <string> merge-stage: "";
|
||||
in property <bool> merge-running: false;
|
||||
in property <float> merge-fraction: 0.0;
|
||||
in property <string> merge-status: "";
|
||||
in property <string> merge-error: "";
|
||||
in property <bool> merge-aligned: false;
|
||||
in property <bool> merge-complete: false;
|
||||
in property <string> merge-summary: "";
|
||||
in property <[MergeFrameRow]> merge-frames;
|
||||
in property <image> merge-preview;
|
||||
in property <int> merge-projection-selected: 0;
|
||||
in property <bool> merge-done: false;
|
||||
/// Merge the grid's selection: opens the page and starts the alignment.
|
||||
callback library-merge-selection();
|
||||
/// The confirmation (FR-MRG-1). Nothing is written before this.
|
||||
callback merge-confirm();
|
||||
callback merge-abandon();
|
||||
callback merge-projection-picked(int);
|
||||
callback merge-close();
|
||||
|
||||
callback import-open();
|
||||
callback import-close();
|
||||
callback import-volume-picked(int);
|
||||
@@ -1270,7 +1295,28 @@ in property <bool> panel-visible: true;
|
||||
close() => { root.import-close(); }
|
||||
}
|
||||
|
||||
if !root.show-import && root.show-settings: SettingsPage {
|
||||
if !root.show-import && root.show-merge: MergePage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stage: root.merge-stage;
|
||||
running: root.merge-running;
|
||||
fraction: root.merge-fraction;
|
||||
status: root.merge-status;
|
||||
error: root.merge-error;
|
||||
aligned: root.merge-aligned;
|
||||
complete: root.merge-complete;
|
||||
summary: root.merge-summary;
|
||||
frames: root.merge-frames;
|
||||
preview: root.merge-preview;
|
||||
projection-selected: root.merge-projection-selected;
|
||||
done: root.merge-done;
|
||||
merge() => { root.merge-confirm(); }
|
||||
abandon() => { root.merge-abandon(); }
|
||||
projection-picked(i) => { root.merge-projection-picked(i); }
|
||||
close() => { root.merge-close(); }
|
||||
}
|
||||
|
||||
if !root.show-import && !root.show-merge && root.show-settings: SettingsPage {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -1404,7 +1450,7 @@ in property <bool> panel-visible: true;
|
||||
|
||||
// Like the launch screen, this replaces the window rather than
|
||||
// overlaying it — see `show-identity`.
|
||||
if !root.show-import && !root.show-settings && root.show-identity: IdentityScreen {
|
||||
if !root.show-import && !root.show-merge && !root.show-settings && root.show-identity: IdentityScreen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
people: root.identity-people;
|
||||
@@ -1462,7 +1508,7 @@ in property <bool> panel-visible: true;
|
||||
|
||||
// The launch screen replaces the whole window rather than overlaying it:
|
||||
// there is no library to look at until an account is configured.
|
||||
if !root.show-import && !root.show-settings && !root.show-identity && root.show-launch: LaunchScreen {
|
||||
if !root.show-import && !root.show-merge && !root.show-settings && !root.show-identity && root.show-launch: LaunchScreen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
signed-in: root.launch-signed-in;
|
||||
@@ -1638,6 +1684,8 @@ in property <bool> panel-visible: true;
|
||||
export-to-server: root.export-to-server;
|
||||
export-selection => { root.library-export-selection(); }
|
||||
cancel-export => { root.library-cancel-export(); }
|
||||
// TRACES: FR-MRG-1
|
||||
merge-selection => { root.library-merge-selection(); }
|
||||
|
||||
sweep-done: root.library-sweep-done;
|
||||
sweep-total: root.library-sweep-total;
|
||||
@@ -1753,7 +1801,7 @@ in property <bool> panel-visible: true;
|
||||
}
|
||||
}
|
||||
|
||||
if !root.show-import && !root.show-settings && !root.show-identity && !root.show-launch && !root.show-library: VerticalLayout {
|
||||
if !root.show-import && !root.show-merge && !root.show-settings && !root.show-identity && !root.show-launch && !root.show-library: VerticalLayout {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
|
||||
@@ -1593,6 +1593,10 @@ export component LibraryGrid inherits Rectangle {
|
||||
in property <bool> export-to-server: false;
|
||||
callback export-selection();
|
||||
callback cancel-export();
|
||||
// TRACES: FR-MRG-1
|
||||
// Merge the selection into a panorama. Two frames at least; the page
|
||||
// that opens says what it found and asks before anything is written.
|
||||
callback merge-selection();
|
||||
|
||||
// --- the keyboard cursor ------------------------------------------------
|
||||
//
|
||||
@@ -4259,6 +4263,15 @@ export component LibraryGrid inherits Rectangle {
|
||||
clicked => { root.open-presets(); }
|
||||
}
|
||||
|
||||
// TRACES: FR-MRG-1
|
||||
// A panorama is a selection of at least two; one frame has
|
||||
// nothing to merge with, so the button waits for a second.
|
||||
if !root.ranging && root.selected-count > 1: Button {
|
||||
text: "Merge to panorama";
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { root.merge-selection(); }
|
||||
}
|
||||
|
||||
// TRACES: FR-DEV-6
|
||||
// Batch-apply the copied settings. Shown only with both a
|
||||
// selection and a clipboard, because it is meaningless without
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
// TRACES: FR-MRG-1 | FR-MRG-4 | FR-MRG-5 | FR-MRG-7
|
||||
// The merge page: a panorama proposed, then confirmed.
|
||||
//
|
||||
// Same shape as the import page — a header with the one verb, a progress
|
||||
// bar under it while work runs, a scrolling body — because that is the
|
||||
// shape a page that runs a job has in this application, and the merge is
|
||||
// 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
|
||||
// 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).
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar } from "widgets.slint";
|
||||
import { Segmented } from "controls.slint";
|
||||
|
||||
/// One source frame's line in the alignment table.
|
||||
export struct MergeFrameRow {
|
||||
name: string,
|
||||
/// "yaw −55.9° · pitch −1.6°", or why it is not aligned.
|
||||
detail: string,
|
||||
aligned: bool,
|
||||
}
|
||||
|
||||
export component MergePage inherits Rectangle {
|
||||
/// The stage the job is in, as a word for the header: "Reading",
|
||||
/// "Aligning", "Merging"; empty when it is waiting or done.
|
||||
in property <string> stage;
|
||||
in property <bool> running: false;
|
||||
in property <float> fraction: 0;
|
||||
/// One line of what happened last, or what is happening.
|
||||
in property <string> status;
|
||||
in property <string> error;
|
||||
|
||||
/// The alignment, once there is one.
|
||||
in property <bool> aligned: false;
|
||||
/// Whether every frame was placed — the merge can only go ahead then.
|
||||
in property <bool> complete: false;
|
||||
in property <string> summary;
|
||||
in property <[MergeFrameRow]> frames;
|
||||
/// The preview of the aligned set, drawn by Rust from the proxies.
|
||||
in property <image> preview;
|
||||
|
||||
in property <[string]> projection-labels: ["Auto", "Perspective", "Cylindrical", "Spherical"];
|
||||
in property <int> projection-selected: 0;
|
||||
|
||||
/// The composite, once written: its name for the status line, and
|
||||
/// whether Open can show it.
|
||||
in property <bool> done: false;
|
||||
|
||||
callback merge();
|
||||
callback abandon();
|
||||
callback projection-picked(int);
|
||||
callback close();
|
||||
|
||||
background: Theme.ground;
|
||||
|
||||
FocusScope {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
init => { self.focus(); }
|
||||
}
|
||||
|
||||
VerticalLayout {
|
||||
Rectangle {
|
||||
height: 44px;
|
||||
background: Theme.surface;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Button {
|
||||
text: "‹ Back";
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { root.close(); }
|
||||
}
|
||||
|
||||
Value { text: "Panorama"; }
|
||||
|
||||
Rectangle { horizontal-stretch: 1; }
|
||||
|
||||
if root.running: Button {
|
||||
text: "Stop";
|
||||
y: (parent.height - self.height) / 2;
|
||||
clicked => { root.abandon(); }
|
||||
}
|
||||
|
||||
// The one verb, and it is off until there is a complete
|
||||
// alignment to confirm: pressing it is the confirmation.
|
||||
if !root.running && !root.done: Button {
|
||||
text: "Merge";
|
||||
primary: true;
|
||||
y: (parent.height - self.height) / 2;
|
||||
enabled: root.aligned && root.complete;
|
||||
clicked => { root.merge(); }
|
||||
}
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
y: parent.height - 1px;
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
}
|
||||
}
|
||||
|
||||
if root.running: ProgressBar {
|
||||
fraction: root.fraction;
|
||||
indeterminate: root.stage == "Aligning";
|
||||
label: root.stage;
|
||||
}
|
||||
|
||||
if root.error != "": Rectangle {
|
||||
height: 32px;
|
||||
background: Theme.surface;
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap;
|
||||
padding-right: Theme.gap;
|
||||
Caption { text: root.error; warn: true; overflow: elide; }
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
vertical-stretch: 1;
|
||||
viewport-height: content.preferred-height;
|
||||
|
||||
content := VerticalLayout {
|
||||
width: 100%;
|
||||
padding: Theme.gap;
|
||||
spacing: Theme.gap;
|
||||
alignment: start;
|
||||
|
||||
// --- the proposal --------------------------------------
|
||||
if root.aligned: Panel {
|
||||
PanelHeading { text: "Alignment"; }
|
||||
|
||||
// The set as it will lie on the chosen surface. Drawn at
|
||||
// proxy resolution, which is the resolution the alignment
|
||||
// was found at: ghosting here is the alignment's error,
|
||||
// and what it looks like is what the photographer is
|
||||
// confirming.
|
||||
if root.preview.width > 0: Rectangle {
|
||||
height: min(320px, parent.width * root.preview.height / max(1, root.preview.width));
|
||||
background: Theme.rule;
|
||||
Image {
|
||||
source: root.preview;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
image-fit: contain;
|
||||
}
|
||||
}
|
||||
|
||||
Caption { text: root.summary; wrap: word-wrap; }
|
||||
|
||||
Segmented {
|
||||
label: "Projection";
|
||||
options: root.projection-labels;
|
||||
selected: root.projection-selected;
|
||||
enabled: !root.running && !root.done;
|
||||
picked(i) => { root.projection-picked(i); }
|
||||
}
|
||||
}
|
||||
|
||||
// --- the frames ----------------------------------------
|
||||
Panel {
|
||||
PanelHeading { text: "Frames"; }
|
||||
|
||||
for row in root.frames: HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
Label {
|
||||
text: row.name;
|
||||
width: 160px;
|
||||
overflow: elide;
|
||||
}
|
||||
Caption {
|
||||
text: row.detail;
|
||||
warn: !row.aligned;
|
||||
overflow: elide;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
}
|
||||
|
||||
if root.frames.length == 0: Caption {
|
||||
text: "Reading the frames…";
|
||||
}
|
||||
}
|
||||
|
||||
if root.status != "": Caption {
|
||||
text: root.status;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user