// 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 stage; in property running: false; in property fraction: 0; /// One line of what happened last, or what is happening. in property status; in property error; /// The alignment, once there is one. in property aligned: false; /// Whether every frame was placed — the merge can only go ahead then. in property complete: false; in property summary; in property <[MergeFrameRow]> frames; /// The preview of the aligned set, drawn by Rust from the proxies. in property preview; in property <[string]> projection-labels: ["Auto", "Perspective", "Cylindrical", "Spherical"]; in property 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 border-selected: 0; /// Whether the fill can be offered at all, and where it would run — /// "TensorRT", "CPU" — or why not. in property fill-available: false; in property fill-note; /// Whether the preview on screen has its border filled. in property 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 fill-scale: 2; in property fill-erosion: 4; in property fill-coarse: 1; in property fill-band: 192; in property fill-mirror: 0; in property fill-feather: 48; /// The composite, once written: its name for the status line, and /// whether Open can show it. in property 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; } } } } }