feat: add wallpaper renaming tool and nix development environment

Implement a Python script that uses a local vision model (via Ollama) to
automatically rename image files to descriptive snake_case names.

- Add `rename-wallpapers.py` to handle image processing, resizing with
  ffmpeg, and querying the Ollama API.
- Add `flake.nix` to provide a reproducible development environment
  including Python, Ollama, ffmpeg, and other necessary dependencies.
This commit is contained in:
2026-07-14 20:03:33 -03:00
parent 9d94100fe2
commit 3a45ba0f20
3 changed files with 323 additions and 0 deletions
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1767313136,
"narHash": "sha256-16KkgfdYqjaeRGBaYsNrhPRRENs0qzkQVUooNHtoy2w=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "ac62194c3917d5f474c1a844b6fd6da2db95077d",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-25.05",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+45
View File
@@ -0,0 +1,45 @@
{
description = "Wallpaper renamer: auto-rename images to descriptive snake_case names using a local vision model (Ollama + llava).";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
# The rename command. It starts an Ollama server (if needed) and pulls the
# vision model on first run, so everything lives inside this shell.
rename-wallpapers = pkgs.writeScriptBin "rename-wallpapers"
(builtins.readFile ./rename-wallpapers.py);
in
{
packages.default = rename-wallpapers;
devShells.default = pkgs.mkShell {
name = "wallpaper-renamer";
packages = [
pkgs.ollama
pkgs.python3
pkgs.curl
pkgs.ffmpeg
pkgs.fzf
pkgs.chafa
rename-wallpapers
];
shellHook = ''
echo "wallpaper-renamer shell ready."
echo "Usage examples:"
echo " rename-wallpapers # rename all images in this dir"
echo " rename-wallpapers --pick # interactively choose with fzf"
echo " rename-wallpapers --dir ~/Pics # scan another directory"
echo " rename-wallpapers --dry-run *.jpg # preview without renaming"
echo "Model is pulled on first run (one-time download)."
'';
};
});
}
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Rename image files to descriptive snake_case names using a local vision model.
The model (default: llava) is queried through a running Ollama server. This
script ensures the server is up and the model is pulled before processing.
"""
import argparse
import base64
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
IMG_EXTS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
PROMPT = (
"You are naming an image file. Look at the image and respond with ONLY a "
"filename, nothing else. Rules: all lowercase; snake_case (underscores only, "
"no spaces, hyphens, or special characters); start with 'a_' or 'an_'; describe "
"the main subject, its state, and the background; 5 to 10 words; end with the "
"correct extension (.jpg/.png/.jpeg). Example: a_snowy_mountain_with_grey_sky.jpg"
)
def run(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, **kw)
def ollama_ready():
r = run(["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
"--max-time", "3", "http://localhost:11434/api/tags"])
return r.returncode == 0 and r.stdout.strip() == "200"
def ensure_server(timeout=90):
if ollama_ready():
return
print("Starting ollama server...", flush=True)
proc = subprocess.Popen(["ollama", "serve"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
deadline = time.time() + timeout
while time.time() < deadline:
if ollama_ready():
return
time.sleep(1)
raise SystemExit("ollama server did not become ready in time")
def ensure_model(model, timeout=600):
listing = run(["ollama", "list"], timeout=30)
have = set()
for line in listing.stdout.splitlines()[1:]:
have.add(line.split()[0].replace(":latest", ""))
if model.replace(":latest", "") in have:
return
print(f"Pulling model '{model}' (one-time download)...", flush=True)
r = run(["ollama", "pull", model], timeout=timeout)
if r.returncode != 0:
raise SystemExit("failed to pull model:\n" + r.stderr)
def resize(path):
out = tempfile.mktemp(suffix=".jpg")
run(["ffmpeg", "-y", "-loglevel", "error", "-i", path,
"-vf", "scale='min(768,iw)':'min(768,ih)'", "-q:v", "4", out],
timeout=120)
if not os.path.exists(out):
# fall back to the original file if ffmpeg chokes
return path
return out
def describe(path, model):
r = resize(path)
with open(r, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
payload = {
"model": model,
"messages": [{"role": "user", "content": PROMPT, "images": [b64]}],
"stream": False,
"options": {"temperature": 0.2},
}
pf = tempfile.NamedTemporaryFile("w", suffix=".json", delete=False)
json.dump(payload, pf)
pf.close()
last = None
for _ in range(3):
try:
p = run(["curl", "-s", "http://localhost:11434/api/chat",
"-H", "Content-Type: application/json", "-d", f"@{pf.name}"],
timeout=240)
return json.loads(p.stdout)["message"]["content"].strip()
except Exception as e: # noqa: BLE001
last = e
raise last
def sanitize(model_out, ext):
m = re.search(r"[a-z0-9_]+\.(?:jpg|jpeg|png|gif|webp)", model_out.lower())
name = m.group(0) if m else re.sub(r"[^a-z0-9_]", "_", model_out.lower())
stem, _ = os.path.splitext(name)
stem = re.sub(r"[^a-z0-9_]", "_", stem)
stem = re.sub(r"_+", "_", stem).strip("_")
if not (stem.startswith("a_") or stem.startswith("an_")):
stem = "a_" + stem
return stem + ext.lower()
def already_named(fn):
s = fn.lower()
return s.startswith("a_") or s.startswith("an_") or s == "retro_mountain_red.jpg"
def find_images(directory, recursive):
found = []
walker = os.walk(directory) if recursive else [(directory, [], os.listdir(directory))]
for root, _, files in walker:
for fn in files:
if os.path.splitext(fn)[1].lower() in IMG_EXTS:
found.append(os.path.join(root, fn))
return sorted(found)
def pick_with_fzf(paths):
# Feed full paths but display only the basename (--with-nth); fzf still
# outputs the full path, and the preview renders an image thumbnail.
by_full = {p: p for p in paths}
by_base = {os.path.basename(p): p for p in paths}
preview_cmd = os.environ.get(
"RENAME_PREVIEW_CMD",
'chafa --size 50x32 --color-space rgb {}')
r = run(["fzf", "--multi", "--prompt", "pick images to rename> ",
"--delimiter", "/", "--with-nth", "-1",
"--preview", preview_cmd,
"--preview-window", "right:50%:wrap",
"--border", "--height", "100%"],
input="\n".join(sorted(paths)), timeout=None)
if r.returncode != 0:
return []
chosen = [line for line in r.stdout.splitlines() if line]
return [by_full.get(c, by_base.get(os.path.basename(c), c)) for c in chosen]
def process(path, model, force, dry_run):
fn = os.path.basename(path)
ext = os.path.splitext(fn)[1].lower()
if not force and already_named(fn):
print(f"SKIP {fn}", flush=True)
return None
try:
raw = describe(path, model)
new = sanitize(raw, ext)
except Exception as e: # noqa: BLE001
print(f"FAIL {fn}: {e}", flush=True)
return None
target = path if force else os.path.join(os.path.dirname(path), new)
i = 1
while os.path.exists(target) and os.path.abspath(target) != os.path.abspath(path):
target = os.path.join(os.path.dirname(path),
f"{os.path.splitext(new)[0]}_{i}{ext}")
i += 1
base = os.path.basename(target)
if dry_run:
print(f"{fn} => {base} (raw: {raw})", flush=True)
else:
shutil.move(path, target)
print(f"{fn} => {base}", flush=True)
return base
def main():
ap = argparse.ArgumentParser(
description="Rename images to descriptive snake_case names via a local vision model.")
ap.add_argument("files", nargs="*", help="image files to rename")
ap.add_argument("--dir", default=None, help="directory to scan (default: current dir)")
ap.add_argument("--recursive", action="store_true", help="scan directories recursively")
ap.add_argument("--pick", action="store_true", help="interactively pick with fzf")
ap.add_argument("--model", default=os.environ.get("RENAME_MODEL", "llava"),
help="ollama vision model (default: llava)")
ap.add_argument("--force", action="store_true", help="rename even already-named files")
ap.add_argument("--dry-run", action="store_true", help="print names without renaming")
args = ap.parse_args()
ensure_server()
ensure_model(args.model)
paths = list(args.files)
if args.dir:
paths += find_images(args.dir, args.recursive)
if not paths:
paths = find_images(args.dir or ".", args.recursive)
# normalise and dedupe
paths = sorted({os.path.abspath(p) for p in paths})
if args.pick:
paths = pick_with_fzf(paths)
if not paths:
print("Nothing selected.", flush=True)
return
if not paths:
print("No images found.", flush=True)
return
done = 0
for p in paths:
if process(p, args.model, args.force, args.dry_run):
done += 1
print(f"\nDone. {done} image(s) renamed"
+ (" (dry-run)" if args.dry_run else "") + ".", flush=True)
if __name__ == "__main__":
main()