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

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

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

323 lines
14 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}
}
}
}