aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--desktop/modules/appearance/README.md30
-rw-r--r--desktop/modules/appearance/WallpaperTab.qml30
-rw-r--r--desktop/modules/appearance/Wallpapers.qml87
3 files changed, 147 insertions, 0 deletions
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