rename AI snaps
This commit is contained in:
450
rename-ai-snaps
Executable file
450
rename-ai-snaps
Executable file
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
rename-ai-snaps — Scan PNGs for AI prompt metadata and propose descriptive filenames.
|
||||
|
||||
Usage:
|
||||
./rename-ai-snaps [path] [--no-interactive]
|
||||
|
||||
Scans directory (default ~/Pictures) for ComfyUI PNGs, reads their embedded prompt,
|
||||
extracts short keyword descriptions, and interactively renames them to:
|
||||
schmeeve-AI-{keywords}.png
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("Error: Pillow (PIL) is required. Install with: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
# ── stopwords and filter sets ──────────────────────────────────────────────
|
||||
|
||||
QUALITY_TAGS = {
|
||||
"score_6_up", "score_7_up", "score_8_up", "score_9",
|
||||
"score_6", "score_7", "score_8",
|
||||
"masterpiece", "best quality", "good quality", "normal quality",
|
||||
"high quality", "highly detailed", "very detailed", "extreme detail",
|
||||
"very_aesthetic", "absurdres", "8k", "4k",
|
||||
"photorealistic", "photograph",
|
||||
"depth of field", "solo focus", "cinematic",
|
||||
"newest", "amazing", "stunning",
|
||||
}
|
||||
|
||||
QUALITY_WORDS = {
|
||||
"best", "good", "high", "top", "ultra", "super",
|
||||
"mega", "hyper", "extreme", "extra", "ultimate",
|
||||
}
|
||||
|
||||
TECHNICAL_WORDS = {
|
||||
"detailed", "focus", "quality", "aesthetic", "realistic",
|
||||
"cinematic", "lighting", "rendering", "shading", "texture",
|
||||
"newest", "absurdres",
|
||||
}
|
||||
|
||||
STOP_WORDS = {
|
||||
"the", "a", "an", "of", "in", "on", "at", "to", "for", "with",
|
||||
"and", "or", "is", "are", "was", "were", "be", "been", "being",
|
||||
"have", "has", "had", "do", "does", "did", "will", "would",
|
||||
"could", "should", "may", "might", "can", "shall", "this",
|
||||
"that", "these", "those", "it", "its", "by", "from", "as",
|
||||
"into", "through", "during", "before", "after", "above", "below",
|
||||
"between", "out", "off", "over", "under", "again", "further",
|
||||
"then", "once", "here", "there", "when", "where", "why", "how",
|
||||
"all", "each", "every", "both", "few", "more", "most", "other",
|
||||
"some", "such", "no", "nor", "not", "only", "own", "same", "so",
|
||||
"than", "too", "very", "just", "about", "up", "down",
|
||||
"make", "get", "set", "put", "take", "give", "show", "use",
|
||||
"like", "look", "see", "want", "need", "let", "close", "full",
|
||||
"add", "new", "one", "two", "five",
|
||||
"also", "well", "back", "still", "even", "much",
|
||||
"you", "your", "my", "me", "we", "our", "they", "them", "their",
|
||||
}
|
||||
|
||||
GENERIC_WORDS = {
|
||||
"man", "men", "guy", "guys", "boy", "boys", "woman", "women",
|
||||
"girl", "girls", "people", "person", "human", "figure",
|
||||
"photo", "image", "picture", "shot", "view", "pose", "posing",
|
||||
"face", "head", "body", "skin", "hair", "eyes", "hand", "hands",
|
||||
"dark", "light", "bright", "color", "colour",
|
||||
}
|
||||
|
||||
NEGATIVE_INDICATORS = {
|
||||
"deformed", "distorted", "disfigured", "poorly drawn", "bad anatomy",
|
||||
"extra digits", "missing digits", "extra limbs", "missing limbs",
|
||||
"ugly", "tiling", "low quality", "worst quality", "normal quality",
|
||||
"lowres", "monochrome", "grayscale", "text", "watermark",
|
||||
"branding", "border", "cropped", "signature", "username",
|
||||
"error", "mutation", "mutated", "out of frame", "duplicate", "cloned",
|
||||
"body out of frame", "bad hands", "bad face", "blurry",
|
||||
}
|
||||
|
||||
|
||||
|
||||
def spinner():
|
||||
chars = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
||||
i = 0
|
||||
while True:
|
||||
yield chars[i % len(chars)]
|
||||
i += 1
|
||||
|
||||
|
||||
def is_negative_text(text):
|
||||
"""Heuristic: is this text block a negative prompt?"""
|
||||
lower = text.lower()
|
||||
score = 0
|
||||
for ind in NEGATIVE_INDICATORS:
|
||||
if ind in lower:
|
||||
score += 1
|
||||
return score >= 2
|
||||
|
||||
|
||||
def is_quality_only(text):
|
||||
"""Heuristic: does this text block contain only quality/technical tags?"""
|
||||
lower = text.lower()
|
||||
# Split into words, strip weighting syntax
|
||||
words = re.findall(r"[a-z_]+", lower)
|
||||
if not words:
|
||||
return False
|
||||
meaningful = sum(1 for w in words if w not in QUALITY_TAGS and len(w) > 2)
|
||||
return meaningful == 0
|
||||
|
||||
|
||||
def extract_prompts(filepath):
|
||||
"""Return (positive_prompt, negative_prompt) from a ComfyUI PNG."""
|
||||
try:
|
||||
img = Image.open(filepath)
|
||||
except Exception:
|
||||
return None, None
|
||||
|
||||
if "prompt" not in img.info:
|
||||
return None, None
|
||||
|
||||
try:
|
||||
data = json.loads(img.info["prompt"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None, None
|
||||
|
||||
# Gather all text fields from all nodes
|
||||
candidates = []
|
||||
for node in data.values():
|
||||
inputs = node.get("inputs", {})
|
||||
# Check all common text-carrying fields
|
||||
for field in ("text", "prompt", "positive", "negative", "value", "string"):
|
||||
val = inputs.get(field, "")
|
||||
if isinstance(val, str) and len(val.strip()) > 3:
|
||||
candidates.append(val.strip())
|
||||
|
||||
# Second pass: resolve node references (e.g. ["node_id", 0])
|
||||
for node in data.values():
|
||||
inputs = node.get("inputs", {})
|
||||
for field in ("text", "prompt", "positive", "negative", "value", "string"):
|
||||
val = inputs.get(field)
|
||||
if isinstance(val, list) and len(val) == 2 and isinstance(val[0], str):
|
||||
ref_node = data.get(val[0], {})
|
||||
# Check if the referenced node has a 'value' or 'text' field
|
||||
ref_inputs = ref_node.get("inputs", {})
|
||||
for rf in ("value", "text", "string"):
|
||||
rv = ref_inputs.get(rf, "")
|
||||
if isinstance(rv, str) and len(rv.strip()) > 3:
|
||||
candidates.append(rv.strip())
|
||||
break
|
||||
|
||||
if not candidates:
|
||||
return None, None
|
||||
|
||||
# Split into positive vs negative
|
||||
positives = [c for c in candidates if not is_negative_text(c)]
|
||||
negatives = [c for c in candidates if is_negative_text(c)]
|
||||
|
||||
# Further filter: quality-only texts are not useful for naming
|
||||
positives = [c for c in positives if not is_quality_only(c)]
|
||||
|
||||
pos = max(positives, key=len) if positives else None
|
||||
neg = max(negatives, key=len) if negatives else None
|
||||
return pos, neg
|
||||
|
||||
|
||||
def extract_keywords(text, max_chars=40):
|
||||
"""Generate a short dash-separated keyword description from prompt text."""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
text_lower = text.lower()
|
||||
|
||||
# Strip weighting/parenthetical syntax: (word:1.2), [word], {word}, (word)
|
||||
cleaned = re.sub(r"[\[\(\{][^\]\)\}]*[\]\)\}]", "", text_lower)
|
||||
|
||||
# Split on commas, periods, semicolons, exclamation/question marks
|
||||
segments = re.split(r"[,.;:!?]+", cleaned)
|
||||
|
||||
# Collect meaningful keywords, preserving order
|
||||
seen = set()
|
||||
keywords = []
|
||||
for seg in segments:
|
||||
seg = seg.strip()
|
||||
if not seg or len(seg) < 4:
|
||||
continue
|
||||
|
||||
# Extract individual words from segment
|
||||
words = re.findall(r"[a-zA-Z_]+", seg)
|
||||
good = []
|
||||
for w in words:
|
||||
wl = w.lower().strip("_")
|
||||
# Skip short words, stop words, quality tags, negative indicators
|
||||
if len(wl) < 3:
|
||||
continue
|
||||
if wl in STOP_WORDS or wl in GENERIC_WORDS:
|
||||
continue
|
||||
if wl in QUALITY_TAGS or wl in QUALITY_WORDS:
|
||||
continue
|
||||
if wl in TECHNICAL_WORDS:
|
||||
continue
|
||||
if wl in NEGATIVE_INDICATORS:
|
||||
continue
|
||||
if wl.startswith("score") or wl.startswith("step"):
|
||||
continue
|
||||
if wl.isdigit():
|
||||
continue
|
||||
good.append(wl)
|
||||
|
||||
if good:
|
||||
# Take up to 2 unseen keywords from this segment
|
||||
taken = 0
|
||||
for g in good:
|
||||
if g not in seen and taken < 2:
|
||||
keywords.append(g)
|
||||
seen.add(g)
|
||||
taken += 1
|
||||
|
||||
if not keywords:
|
||||
return None
|
||||
|
||||
# Build description: up to 5 keywords
|
||||
desc = "-".join(keywords[:5])
|
||||
|
||||
# Replace any non-alphanumeric (except hyphen) with hyphens
|
||||
desc = re.sub(r"[^a-z0-9-]", "-", desc)
|
||||
desc = re.sub(r"-+", "-", desc).strip("-")
|
||||
|
||||
# Truncate at max_chars, breaking at a word boundary
|
||||
if len(desc) > max_chars:
|
||||
desc = desc[:max_chars].rstrip("-")
|
||||
if max_chars > 10 and "-" in desc:
|
||||
truncated = "-".join(desc.split("-")[:-1])
|
||||
if truncated and len(truncated) > 10:
|
||||
desc = truncated
|
||||
|
||||
return desc if desc and len(desc) > 3 else None
|
||||
|
||||
|
||||
def propose_name(filepath):
|
||||
"""Propose 'schmeeve-AI-{keywords}.png' or None."""
|
||||
pos, neg = extract_prompts(filepath)
|
||||
source = pos or neg
|
||||
if not source:
|
||||
return None
|
||||
|
||||
desc = extract_keywords(source)
|
||||
if not desc:
|
||||
return None
|
||||
|
||||
return f"schmeeve-AI-{desc}.png"
|
||||
|
||||
|
||||
# ── main ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Rename AI-generated PNGs based on embedded prompt metadata.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"path", nargs="?", default=os.path.expanduser("~/Pictures"),
|
||||
help="Directory to scan for PNG files (default: ~/Pictures)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-n", "--no-interactive", action="store_true",
|
||||
help="Auto-rename without prompting",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
scan_dir = Path(args.path).expanduser().resolve()
|
||||
if not scan_dir.is_dir():
|
||||
print(f"Error: {scan_dir} is not a directory")
|
||||
sys.exit(1)
|
||||
|
||||
pngs = sorted(scan_dir.glob("*.png"))
|
||||
|
||||
# ── Phase 1: Analyze with spinner ──
|
||||
sys.stdout.write(" Analyzing PNGs")
|
||||
sys.stdout.flush()
|
||||
spin = spinner()
|
||||
raw_proposals = {}
|
||||
for p in pngs:
|
||||
sys.stdout.write(f"\r {next(spin)} Analyzing PNGs")
|
||||
sys.stdout.flush()
|
||||
# Skip already-renamed files
|
||||
if p.name.startswith("schmeeve-AI-"):
|
||||
continue
|
||||
name = propose_name(str(p))
|
||||
if name:
|
||||
raw_proposals[p] = name
|
||||
time.sleep(0.02)
|
||||
|
||||
# Clear the spinner line
|
||||
sys.stdout.write("\r" + " " * 60 + "\r")
|
||||
sys.stdout.flush()
|
||||
|
||||
# Deduplicate proposed names
|
||||
proposals = {}
|
||||
name_counts = {}
|
||||
for p, name in raw_proposals.items():
|
||||
base = name
|
||||
if base in name_counts:
|
||||
name_counts[base] += 1
|
||||
stem = base.rsplit(".", 1)[0]
|
||||
ext = ".png"
|
||||
name = f"{stem}_{name_counts[base]}{ext}"
|
||||
else:
|
||||
name_counts[base] = 0
|
||||
proposals[p] = name
|
||||
|
||||
# ── Phase 2: Display proposed names ──
|
||||
if not proposals:
|
||||
print(" No renamable PNGs found.")
|
||||
return
|
||||
|
||||
for i, (old_path, new_name) in enumerate(proposals.items(), 1):
|
||||
old = old_path.name
|
||||
stem = old_path.stem
|
||||
ext = old_path.suffix
|
||||
# Truncate old name for display
|
||||
old_display = old if len(old) < 50 else old[:22] + "…" + old[-25:]
|
||||
print(f" {i:>3}. {old_display}")
|
||||
print(f" → {new_name}")
|
||||
|
||||
print()
|
||||
print(f" {len(proposals)} file(s) to rename.\n")
|
||||
|
||||
# ── Phase 3: Rename (interactive or auto) ──
|
||||
if args.no_interactive:
|
||||
renamed = 0
|
||||
for old_path, new_name in proposals.items():
|
||||
new_path = old_path.with_name(new_name)
|
||||
if new_path.exists():
|
||||
stem = new_path.stem
|
||||
counter = 1
|
||||
while new_path.exists():
|
||||
new_path = old_path.with_name(f"{stem}_{counter}{old_path.suffix}")
|
||||
counter += 1
|
||||
old_path.rename(new_path)
|
||||
renamed += 1
|
||||
print(f" Renamed {renamed} file(s).")
|
||||
else:
|
||||
renamed = 0
|
||||
skipped = 0
|
||||
items = list(proposals.items())
|
||||
i = 0
|
||||
while i < len(items):
|
||||
old_path, new_name = items[i]
|
||||
old = old_path.name
|
||||
new = new_name
|
||||
|
||||
print(f"\n [{i+1}/{len(items)}]")
|
||||
print(f" Current: {old}")
|
||||
print(f" New: {new}")
|
||||
remaining = len(items) - i - 1
|
||||
rlabel = f"rename all {remaining}" if remaining else ""
|
||||
sys.stdout.write(f" [Enter]=rename [e]=edit [s]=skip{' [a]=' + rlabel if rlabel else ''} [q]=quit: ")
|
||||
sys.stdout.flush()
|
||||
choice = sys.stdin.readline().strip().lower()
|
||||
|
||||
if choice == "q":
|
||||
remaining = len(items) - i - 1
|
||||
if remaining:
|
||||
print(f" Skipping remaining {remaining} file(s).")
|
||||
break
|
||||
elif choice == "s":
|
||||
skipped += 1
|
||||
i += 1
|
||||
continue
|
||||
elif choice == "e":
|
||||
sys.stdout.write(f" Edit name (will be prefixed 'schmeeve-AI-'): ")
|
||||
sys.stdout.flush()
|
||||
custom = sys.stdin.readline().strip()
|
||||
if custom:
|
||||
# Sanitize
|
||||
custom_desc = re.sub(r"[^a-z0-9-]", "-", custom.lower())
|
||||
custom_desc = re.sub(r"-+", "-", custom_desc).strip("-")
|
||||
if custom_desc:
|
||||
new = f"schmeeve-AI-{custom_desc}.png"
|
||||
else:
|
||||
print(" Invalid name, skipping.")
|
||||
skipped += 1
|
||||
i += 1
|
||||
continue
|
||||
else:
|
||||
# empty = skip
|
||||
skipped += 1
|
||||
i += 1
|
||||
continue
|
||||
# Fall through to rename
|
||||
elif choice == "":
|
||||
pass # rename with proposed name
|
||||
elif choice == "a":
|
||||
# Rename current and all remaining without further prompts
|
||||
for j in range(i, len(items)):
|
||||
p, n = items[j]
|
||||
np = p.with_name(n)
|
||||
if np.exists():
|
||||
stem = np.stem
|
||||
counter = 1
|
||||
while np.exists():
|
||||
np = p.with_name(f"{stem}_{counter}{p.suffix}")
|
||||
counter += 1
|
||||
p.rename(np)
|
||||
renamed += len(items) - i
|
||||
break
|
||||
else:
|
||||
print(f" Unknown option '{choice}', skipping.")
|
||||
skipped += 1
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Perform rename
|
||||
new_path = old_path.with_name(new)
|
||||
if new_path.exists():
|
||||
stem = new_path.stem
|
||||
counter = 1
|
||||
while new_path.exists():
|
||||
new_path = old_path.with_name(f"{stem}_{counter}{old_path.suffix}")
|
||||
counter += 1
|
||||
print(f" (file existed, saved as {new_path.name})")
|
||||
old_path.rename(new_path)
|
||||
renamed += 1
|
||||
i += 1
|
||||
|
||||
print(f"\n Renamed: {renamed} Skipped: {skipped}")
|
||||
|
||||
# One more pass: also look at .jpg? (maybe later)
|
||||
remaining_ai = 0
|
||||
for f in scan_dir.glob("*.jpg"):
|
||||
try:
|
||||
img = Image.open(f)
|
||||
if "prompt" in img.info:
|
||||
remaining_ai += 1
|
||||
except Exception:
|
||||
pass
|
||||
if remaining_ai:
|
||||
print(f" Note: {remaining_ai} JPEG(s) with AI metadata found (not yet supported).")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user