aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules/appearance/Wallpapers.qml
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-29 18:26:15 +0100
committerDanilo M. <danix@danix.xyz>2026-10-29 18:26:15 +0100
commitc5ce8451d0aa634fc7bcece4a4132f9508e54bf1 (patch)
treec1c7bd8bd45023e0a351337950ba1a348ca4f049 /desktop/modules/appearance/Wallpapers.qml
parentafa0e6e7402ba19688348ab1c41b30bdf14d2d49 (diff)
downloadquickshell-c5ce8451d0aa634fc7bcece4a4132f9508e54bf1.tar.gz
quickshell-c5ce8451d0aa634fc7bcece4a4132f9508e54bf1.zip
wallpaper: upscale a too-small staged image in place
A staged wallpaper smaller than its screen (yellow or red border) gets an "Upscale 4x" button. It rewrites the file with Real-ESRGAN through stable-diffusion.cpp's `sd-cli -M upscale`, keeping the name, so wallp's state and the engine's lists, which name it by path, still find it. sd-cli writes to a dot-named sibling the scan skips, and only a successful run is moved over the original. Success needs no [E] line as well as exit 0: with a model that fails to load, sd-cli logs "[E] new_upscaler_ctx failed", saves the input unscaled and exits 0 (measured). The button takes two clicks so the model choice shows before anything is rewritten. The first runs Imajev-4B, a small Jev-style vision decision model, through llama-mtmd-cli on a 512 px thumbnail, with a grammar that allows only A (anime) or B (photo or render); greedy decoding then returns the higher-logit option, about 1.7 s. The label becomes "Upscale 4x (anime)" or "(photo)", and the second click upscales with the anime or the general model. Anything but A, including a failed decider, picks the general model, safe on any image. Imajev-4B was chosen by measurement on 22 wallpapers: all 12 anime and 4 photos right, 4 of 6 painted 3D renders called anime (those come out smoother than painted). The 2B got 17 of 22, leaning to photo; a three-way prompt adding "painted or render" dropped both to 15 or fewer, with most anime called painted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'desktop/modules/appearance/Wallpapers.qml')
-rw-r--r--desktop/modules/appearance/Wallpapers.qml87
1 files changed, 87 insertions, 0 deletions
diff --git a/desktop/modules/appearance/Wallpapers.qml b/desktop/modules/appearance/Wallpapers.qml
index 4d06a2e..73c1f25 100644
--- a/desktop/modules/appearance/Wallpapers.qml
+++ b/desktop/modules/appearance/Wallpapers.qml
@@ -150,6 +150,93 @@ Singleton {
: "each image pixel becomes a visible block, so it will look pixelated.");
}
+ // Real-ESRGAN through stable-diffusion.cpp's sd-cli, for an image too
+ // small for its screen. The file is replaced in place, so wallp's state
+ // and the engine's lists, which name it by path, still find it. sd-cli
+ // writes to a dot-named sibling first, which the scan skips, and only a
+ // successful run is moved over the original, so a failure leaves it
+ // untouched. The name keeps its extension because sd-cli writes the
+ // format the extension names. Only files under `dir`: the system ones
+ // are not ours to rewrite. Success is no [E] line as well as exit 0: a
+ // model that fails to load logs "[E] new_upscaler_ctx failed", saves
+ // the input unscaled and exits 0 (measured).
+ //
+ // Which Real-ESRGAN model is decided first, as its own step, so the page
+ // can show the choice before anything is rewritten. Imajev-4B makes it,
+ // a small Jev-style vision decision model, run once through llama-mtmd-cli on a
+ // 512 px thumbnail: the answer is held to one token, A or B, by a
+ // grammar, so greedy decoding returns the option with the highest logit,
+ // which is the decision. About 1.7 s. Anime goes to the anime model,
+ // which keeps line art clean; everything else, and any failure of the
+ // decider, goes to the general one, safe on any image. Measured on 22
+ // wallpapers it got every anime and photo right and called 4 of 6
+ // painted 3D renders anime, which then come out smoother than painted;
+ // the 2B, and a three-way prompt adding "painted or render", did worse.
+ // ponytail: argmax only, so Imajev's calibration temperature is not
+ // needed. One 4x pass, which covers both screens unless the image is
+ // under 640 px wide or 480 px tall; --upscale-repeats if that matters.
+ readonly property string upscalers: "/data/LLM-models/SD/upscalers"
+ readonly property string decider: "/data/LLM-models/SD/imajev/imajev-4b"
+ property string upscaling: ""
+ property string upscaleError: ""
+ function canUpscale(path, which) {
+ return path.startsWith(dir + "/") && upscale(path, which) > 1;
+ }
+ function sibling(path) { return path.slice(0, path.lastIndexOf("/") + 1) + "." + basename(path); }
+
+ // The decision for one image: decidedFor is the path it was made for, so
+ // staging another image leaves it standing for the first and unread.
+ property string deciding: ""
+ property string decidedFor: ""
+ property string decidedKind: "" // "anime" or "photo"
+ function decide(path) {
+ if (deciding !== "" || upscaling !== "" || path === "") return;
+ deciding = path;
+ upscaleError = "";
+ decideProc.command = ["sh", "-c",
+ 'magick "$1" -resize 512x512 "$2" 2>/dev/null; '
+ + 'llama-mtmd-cli -m "$3-Q4_K_M.gguf" --mmproj "$3-mmproj-f16.gguf" --image "$2" '
+ + '-ngl 99 --temp 0 -n 1 --grammar "$5" -p "$4" 2>/dev/null; rm -f "$2"',
+ "sh", path, sibling(path) + ".jpg", decider,
+ "Is this image A) anime, cartoon or illustration art, or B) a photograph or realistic render? Answer A or B.",
+ 'root ::= "A" | "B"'];
+ decideProc.running = true;
+ }
+ Process {
+ id: decideProc
+ stdout: StdioCollector { id: decideOut }
+ onExited: {
+ root.decidedKind = decideOut.text.trim().endsWith("A") ? "anime" : "photo";
+ root.decidedFor = root.deciding;
+ root.deciding = "";
+ }
+ }
+
+ function upscaleFile(path) {
+ if (upscaling !== "" || path === "" || path !== decidedFor) return;
+ upscaling = path;
+ upscaleError = "";
+ upscaleProc.command = ["sh", "-c",
+ 'log=$(sd-cli -M upscale --upscale-model "$1" -i "$2" -o "$3" 2>&1); s=$?; '
+ + 'err=$(printf "%s\\n" "$log" | grep -F "[E]"); '
+ + 'if [ $s -eq 0 ] && [ -z "$err" ]; then mv -f "$3" "$2"; '
+ + 'else rm -f "$3"; echo "${err:-sd-cli exited $s}" >&2; exit 1; fi',
+ "sh", `${upscalers}/RealESRGAN_x4plus${decidedKind === "anime" ? "_anime_6B" : ""}.pth`,
+ path, sibling(path)];
+ upscaleProc.running = true;
+ }
+ Process {
+ id: upscaleProc
+ stderr: StdioCollector { id: upscaleErr }
+ onExited: code => {
+ if (code !== 0) root.upscaleError = `upscaling ${root.basename(root.upscaling)} failed: ${upscaleErr.text.trim()}`;
+ root.decidedFor = "";
+ root.upscaling = "";
+ // The new size clears the quality note and the red border.
+ root.refresh();
+ }
+ }
+
function basename(path) { return path.slice(path.lastIndexOf("/") + 1); }
// The files by the subfolder they sit in, folders by name, each keeping