From c5ce8451d0aa634fc7bcece4a4132f9508e54bf1 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 29 Oct 2026 18:26:15 +0100 Subject: 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 --- desktop/modules/appearance/README.md | 30 ++++++++++ desktop/modules/appearance/WallpaperTab.qml | 30 ++++++++++ desktop/modules/appearance/Wallpapers.qml | 87 +++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) (limited to 'desktop/modules') diff --git a/desktop/modules/appearance/README.md b/desktop/modules/appearance/README.md index f933ac0..59868b4 100644 --- a/desktop/modules/appearance/README.md +++ b/desktop/modules/appearance/README.md @@ -111,6 +111,36 @@ and closes the settings window, as a successful Apply does, so the desk shows. It goes through `gtk-launch` and the app's desktop entry, so `~/bin` need not be on quickshell's PATH. +When the image staged for the target screen is too small for it (the yellow +or red border), "Upscale 4×" appears beside it. It runs Real-ESRGAN through +stable-diffusion.cpp's `sd-cli -M upscale` and replaces the file in place, +keeping its name, so wallp's state and the engine's lists still find it; +Apply then sets the larger copy. Only files under `~/Pictures/wallpapers` are +offered. + +Which Real-ESRGAN model is decided per image by Imajev-4B, a small Jev-style +vision decision model, run once 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, which is the +decision; about 1.7 s. Anime goes to the anime model, which keeps line art +clean; anything else, and any failure of the decider, to the general one. +Measured on 22 wallpapers, it got all 12 anime and all 4 photos right and +called 4 of 6 painted 3D renders anime, which then come out smoother than +painted. The 2B got 17 of 22, and a three-way prompt adding "painted or +render" dropped both to 15 or fewer, with most anime called painted. + +The button takes two clicks so the choice shows before anything is +rewritten: "Upscale 4×: pick model" runs the decider and becomes +"Upscale 4× (anime)" or "(photo)", and that click upscales. The decision is +kept for the image it was made for, so staging another and coming back +still shows it. + +The models are read from `/data/LLM-models/SD/upscalers/` and +`/data/LLM-models/SD/imajev/` (`Wallpapers.upscalers`, `Wallpapers.decider`). +Deciding takes about 2 seconds, upscaling a 1280x720 image about 7. sd-cli exits 0 even when the +model fails to load, saving the input unscaled, so an `[E]` line in its log +counts as a failure too, and a failure leaves the original untouched. + `wallp` does the actual work, including running `udt-accent`, so the accent follows the new wallpaper exactly as it does from a terminal. This page replaces its qarma file dialog, not the script. diff --git a/desktop/modules/appearance/WallpaperTab.qml b/desktop/modules/appearance/WallpaperTab.qml index 640f1ef..134c90e 100644 --- a/desktop/modules/appearance/WallpaperTab.qml +++ b/desktop/modules/appearance/WallpaperTab.qml @@ -142,12 +142,42 @@ Column { color: Wallpapers.qualityColor(subject, Wallpapers.target) } + Text { + width: parent.width + visible: Wallpapers.upscaleError !== "" + wrapMode: Text.Wrap + text: Wallpapers.upscaleError + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 } + color: Theme.red + } + // A Flow, not a Row: a Row overflows its width and clipped the // last button in a narrow window (AGENTS.md). Flow { + id: actions + readonly property string staged: Wallpapers.target === "V" ? Wallpapers.pendingV : Wallpapers.pendingH + readonly property bool small: Wallpapers.canUpscale(staged, Wallpapers.target) width: parent.width spacing: 8 + // Rewrite the staged image 4x larger before applying it, when + // it is too small for its screen. Two clicks: the first asks + // the decider which model suits it and shows the answer, the + // second upscales with that model (Wallpapers.decide and + // upscaleFile). + Tab { + readonly property bool decided: Wallpapers.decidedFor === actions.staged + readonly property bool busy: Wallpapers.deciding !== "" || Wallpapers.upscaling !== "" + visible: actions.small || busy + enabled: !busy + text: Wallpapers.deciding !== "" ? "deciding…" + : Wallpapers.upscaling !== "" ? "upscaling…" + : decided ? `Upscale 4× (${Wallpapers.decidedKind})` + : "Upscale 4×: pick model" + selected: decided && !busy + onClicked: decided ? Wallpapers.upscaleFile(actions.staged) : Wallpapers.decide(actions.staged) + } + // Apply and Reset are in the page header, with every settings // page's. // wallhaven-dl searches wallhaven.cc and finds larger 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 -- cgit v1.2.3