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.
323 lines
14 KiB
Plaintext
323 lines
14 KiB
Plaintext
// 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) 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 { Button, PanelHeading, Label, Value, Caption, Panel, ProgressBar } from "widgets.slint";
|
||
import { Segmented, SliderRow, Check } 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,
|
||
/// Whether the frame is in the alignment; unticked, it is left out.
|
||
included: 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;
|
||
|
||
// TRACES: FR-MRG-4
|
||
// The border: cropped to the picture, or filled with invented pixels.
|
||
// Crop is the default and the honest one; the fill is offered only
|
||
// where the device has the model, and what it invents is on the
|
||
// preview before it is confirmed.
|
||
in property <[string]> border-labels: ["Crop to the picture", "Fill the border"];
|
||
in property <int> border-selected: 0;
|
||
/// Whether the fill can be offered at all, and where it would run —
|
||
/// "TensorRT", "CPU" — or why not.
|
||
in property <bool> fill-available: false;
|
||
in property <string> fill-note;
|
||
/// Whether the preview on screen has its border filled.
|
||
in property <bool> preview-filled: false;
|
||
/// The fill's knobs — experimental, so every one is on the page: the
|
||
/// working scale (the composite's resolution over this), the coverage
|
||
/// fringe eroded, the coarse pass's reduction, the fine band width,
|
||
/// how deep the mirrored context reaches, and the feather across the
|
||
/// seam. Working pixels, at the merge's working scale.
|
||
in property <float> fill-scale: 2;
|
||
in property <float> fill-erosion: 4;
|
||
in property <float> fill-coarse: 1;
|
||
in property <float> fill-band: 192;
|
||
in property <float> fill-mirror: 0;
|
||
in property <float> fill-feather: 48;
|
||
|
||
/// 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);
|
||
/// A frame's box: its index in `frames`, and whether to keep it.
|
||
callback frame-toggled(int, bool);
|
||
callback border-picked(int);
|
||
/// A knob turned: its name ("scale", "erosion", "coarse", "band",
|
||
/// "mirror", "feather") and the new value.
|
||
callback fill-knob(string, float);
|
||
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.
|
||
// As tall as the picture needs up to most of the page,
|
||
// and as wide as it is at that height: a four-frame
|
||
// perspective merge is nearly square, a twelve-frame
|
||
// cylinder is a strip, and either should fill what it
|
||
// can rather than sit in a letterbox.
|
||
if root.preview.width > 0: Rectangle {
|
||
height: min(root.height * 0.55, parent.width * root.preview.height / max(1, root.preview.width));
|
||
background: Theme.rule;
|
||
preview-image := Image {
|
||
source: root.preview;
|
||
width: 100%;
|
||
height: 100%;
|
||
image-fit: contain;
|
||
}
|
||
}
|
||
|
||
Caption { text: root.summary; wrap: word-wrap; }
|
||
Caption {
|
||
text: "Each frame is outlined in amber where it lands. Ghosting in an overlap is the alignment's error.";
|
||
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); }
|
||
}
|
||
|
||
Segmented {
|
||
label: "Border";
|
||
options: root.border-labels;
|
||
selected: root.border-selected;
|
||
enabled: !root.running && !root.done && root.fill-available;
|
||
picked(i) => { root.border-picked(i); }
|
||
}
|
||
// Said plainly, because these are pixels the camera never
|
||
// saw: what the fill is, where it runs, and that the
|
||
// composite will say it was filled.
|
||
Caption {
|
||
text: root.fill-available
|
||
? (root.preview-filled
|
||
? "The border above is filled with pixels the model invented from the picture beside them (" + root.fill-note + "). The composite records that it was filled."
|
||
: "Filling the border invents pixels from the picture beside them (" + root.fill-note + "). Choose it to see what it would do before merging.")
|
||
: root.fill-note;
|
||
wrap: word-wrap;
|
||
}
|
||
// The knobs, while the fill is experimental: turned
|
||
// under the fill, the preview redraws with them.
|
||
if root.fill-available && root.border-selected == 1 : VerticalLayout {
|
||
spacing: 4px;
|
||
Caption { text: "Experimental — the fill's knobs. Each redraws the preview."; wrap: word-wrap; }
|
||
SliderRow {
|
||
label: "Working scale";
|
||
hint: "the composite's resolution over this: 2 is half, 4 a quarter";
|
||
value: root.fill-scale; default-value: 2; minimum: 1; maximum: 8;
|
||
enabled: !root.running && !root.done;
|
||
changed(v) => { root.fill-knob("scale", v); }
|
||
reset => { root.fill-knob("scale", 2); }
|
||
}
|
||
SliderRow {
|
||
label: "Edge erosion";
|
||
hint: "working pixels of the coverage edge regenerated as fringe";
|
||
value: root.fill-erosion; default-value: 4; minimum: 0; maximum: 64;
|
||
enabled: !root.running && !root.done;
|
||
changed(v) => { root.fill-knob("erosion", v); }
|
||
reset => { root.fill-knob("erosion", 4); }
|
||
}
|
||
SliderRow {
|
||
label: "Coarse pass";
|
||
hint: "reduction of the structure pass; 1 skips it";
|
||
value: root.fill-coarse; default-value: 1; minimum: 1; maximum: 8;
|
||
enabled: !root.running && !root.done;
|
||
changed(v) => { root.fill-knob("coarse", v); }
|
||
reset => { root.fill-knob("coarse", 1); }
|
||
}
|
||
SliderRow {
|
||
label: "Band width";
|
||
hint: "the fine passes' bands from the edge outward, working pixels";
|
||
value: root.fill-band; default-value: 192; minimum: 16; maximum: 384;
|
||
enabled: !root.running && !root.done;
|
||
changed(v) => { root.fill-knob("band", v); }
|
||
reset => { root.fill-knob("band", 192); }
|
||
}
|
||
SliderRow {
|
||
label: "Mirror depth";
|
||
hint: "0: the void as it is, what the model was trained on; otherwise how far into the picture a mirrored context reaches";
|
||
value: root.fill-mirror; default-value: 0; minimum: 0; maximum: 512;
|
||
enabled: !root.running && !root.done;
|
||
changed(v) => { root.fill-knob("mirror", v); }
|
||
reset => { root.fill-knob("mirror", 0); }
|
||
}
|
||
SliderRow {
|
||
label: "Seam feather";
|
||
hint: "working pixels inside the real edge blended with the fill; 0 is a hard cut";
|
||
value: root.fill-feather; default-value: 48; minimum: 0; maximum: 256;
|
||
enabled: !root.running && !root.done;
|
||
changed(v) => { root.fill-knob("feather", v); }
|
||
reset => { root.fill-knob("feather", 48); }
|
||
}
|
||
}
|
||
}
|
||
|
||
// --- the frames ----------------------------------------
|
||
Panel {
|
||
PanelHeading { text: "Frames"; }
|
||
|
||
// 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;
|
||
Check {
|
||
label: row.name;
|
||
width: 200px;
|
||
checked: row.included;
|
||
controlled: true;
|
||
toggled(on) => {
|
||
if (!root.running && !root.done) {
|
||
root.frame-toggled(i, on);
|
||
}
|
||
}
|
||
}
|
||
Caption {
|
||
text: row.detail;
|
||
warn: row.included && !row.aligned;
|
||
overflow: elide;
|
||
horizontal-stretch: 1;
|
||
vertical-alignment: center;
|
||
}
|
||
}
|
||
|
||
if root.frames.length == 0: Caption {
|
||
text: "Reading the frames…";
|
||
}
|
||
}
|
||
|
||
if root.status != "": Caption {
|
||
text: root.status;
|
||
wrap: word-wrap;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|