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:
Generated
+61
@@ -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
|
||||
}
|
||||
@@ -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)."
|
||||
'';
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user