re-separate rename/organize, canonical --resort for AI folders

rename-ai-snaps is rename-only again (+--include-renamed, +--only-generic);
extract-ai-meta is the shared recursive metadata engine emitting canonical
model/lora dest paths; organize-images does all sorting, gains --resort for
idempotent tree re-sorts (used to fix ~10K old-scheme folders on the NAS).
This commit is contained in:
2026-07-30 21:04:37 -07:00
parent 1c1dbf7f21
commit 32859a5df0
6 changed files with 611 additions and 311 deletions

View File

@@ -1,23 +1,34 @@
#!/bin/bash
# organize-images -- Sort images into AI/, Photos/, Screenshots/, Unsorted/
# Default: sub-categorizes AI by model + LoRA combo (uses extract-ai-meta).
# Use --flat for one flat AI/ folder.
# AI files route into AI/{model}/{lora}/ via extract-ai-meta (canonical scheme).
#
# Modes:
# (default) Sort top-level files of [path] into the 4 buckets.
# --resort Recursively re-sort [path] (the Images root containing AI/),
# moving any file with ComfyUI metadata to its canonical
# AI/{model}/{lora}/ location. Filenames are never changed.
#
# Generic ComfyUI_* filenames are renamed first via rename-ai-snaps
# (--only-generic) before sorting, when that script is available.
TARGET=""
DRY_RUN=true
VERBOSE=false
INSTALL=false
FLAT=false
RESORT=false
LIMIT=0
usage() {
echo "Usage: $(basename "$0") [path] [-e|--execute] [-v|--verbose] [-i|--install] [--flat]"
echo "Usage: $(basename "$0") [path] [-e|--execute] [-v|--verbose] [-i|--install] [--flat] [--resort] [--limit N]"
echo " path Directory to organize (default: ~/Pictures)"
echo " -e Execute moves (default: dry-run only)"
echo " -v Verbose: show per-file classification"
echo " -v Verbose: show per-file classification/moves"
echo " -i Install this script to ~/.local/bin/"
echo " --flat Don't sub-categorize by model/LoRA (flat AI/ folder)"
echo " --limit N Only process N files (for batching)"
echo " --resort Recursively re-sort [path] into canonical AI/{model}/{lora}/"
echo " ([path] must be the Images root containing AI/)"
echo " --limit N Only process N files (for batching; default mode only)"
exit 0
}
@@ -34,6 +45,7 @@ for arg in "$@"; do
-v|--verbose) VERBOSE=true ;;
-i|--install) INSTALL=true ;;
--flat) FLAT=true ;;
--resort) RESORT=true ;;
--limit) LIMIT_NEXT=true ;;
--limit=*) LIMIT="${arg#*=}" ;;
-h|--help) usage ;;
@@ -63,34 +75,223 @@ if [ ! -d "$TARGET" ]; then
exit 1
fi
echo "organize-images — Classifying images in: $TARGET"
[ "$DRY_RUN" = true ] && echo " DRY RUN (use -e to execute)" || echo " EXECUTING"
[ "$LIMIT" -gt 0 ] && echo " Limit: first $LIMIT files"
# --- Resolve helper scripts (same dir as this script, else PATH) ---
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
find_tool() {
local n="$1"
if [ -x "$SCRIPT_DIR/$n" ]; then
echo "$SCRIPT_DIR/$n"
elif command -v "$n" >/dev/null 2>&1; then
command -v "$n"
else
return 1
fi
}
# --- Pre-scan: extract AI metadata for model/LoRA routing ---
META_SCRIPT="$(find_tool extract-ai-meta)" || {
echo "Error: extract-ai-meta not found (looked next to $0 and in PATH)" >&2
exit 1
}
RENAME_SCRIPT="$(find_tool rename-ai-snaps || true)"
echo "organize-images — $([ "$RESORT" = true ] && echo "Re-sorting" || echo "Classifying") images in: $TARGET"
[ "$DRY_RUN" = true ] && echo " DRY RUN (use -e to execute)" || echo " EXECUTING"
[ "$LIMIT" -gt 0 ] && [ "$RESORT" = false ] && echo " Limit: first $LIMIT files"
# --- Resort mode: recursive canonical re-sort of an existing tree ---
if [ "$RESORT" = true ]; then
if [ ! -d "$TARGET/AI" ]; then
echo "Error: --resort expects the Images root (a directory containing AI/)" >&2
exit 1
fi
# Generic-name hook: rename ComfyUI_* files in place before sorting
GENERIC_COUNT=$(find "$TARGET" -type f -name 'ComfyUI*.png' 2>/dev/null | wc -l | tr -d ' ')
if [ "$GENERIC_COUNT" -gt 0 ]; then
if [ -n "$RENAME_SCRIPT" ]; then
if [ "$DRY_RUN" = true ]; then
echo " $GENERIC_COUNT generic ComfyUI_*.png file(s) would be renamed first (rename-ai-snaps --only-generic)"
else
echo " Renaming $GENERIC_COUNT generic ComfyUI_*.png file(s) first..."
"$RENAME_SCRIPT" "$TARGET" -r -n --only-generic
fi
else
echo " Note: $GENERIC_COUNT generic ComfyUI_*.png file(s) found; rename-ai-snaps not available, keeping names"
fi
fi
# Metadata manifest (recursive)
echo " Scanning AI metadata (recursive)..."
AI_META=$(mktemp)
if ! "$META_SCRIPT" "$TARGET" -r > "$AI_META"; then
echo "Error: AI metadata scan failed" >&2
rm -f "$AI_META"
exit 1
fi
python3 - "$TARGET" "$AI_META" "$DRY_RUN" "$VERBOSE" <<'PYEOF'
import json, os, shutil, sys
root = os.path.abspath(sys.argv[1])
meta = json.load(open(sys.argv[2]))
dry = sys.argv[3] == "true"
verbose = sys.argv[4] == "true"
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
LEGACY_DIRS = {"None", "unknown"}
moved = ok = nometa = collisions = errors = 0
samples = []
legacy_no_meta = []
planned = set()
for dirpath, dirnames, filenames in os.walk(root):
dirnames.sort()
for fn in sorted(filenames):
if os.path.splitext(fn)[1].lower() not in IMAGE_EXTS:
continue
src = os.path.join(dirpath, fn)
rel = os.path.relpath(src, root)
entry = meta.get(rel)
if not entry:
nometa += 1
top = rel.split(os.sep)[0]
if top in LEGACY_DIRS:
legacy_no_meta.append(rel)
continue
dest_dir = os.path.join(root, "AI", entry["dest"])
if os.path.abspath(dest_dir) == os.path.abspath(dirpath):
ok += 1
continue
dest = os.path.join(dest_dir, fn)
taken = planned if dry else None
if os.path.exists(dest) or (taken is not None and dest in taken):
stem, ext = os.path.splitext(fn)
c = 1
while os.path.exists(os.path.join(dest_dir, f"{stem}_{c}{ext}")) or \
(taken is not None and os.path.join(dest_dir, f"{stem}_{c}{ext}") in taken):
c += 1
dest = os.path.join(dest_dir, f"{stem}_{c}{ext}")
collisions += 1
if taken is not None:
taken.add(dest)
dest_rel = os.path.relpath(dest, root)
if dry:
moved += 1
if len(samples) < 20:
samples.append((rel, dest_rel))
if verbose:
print(f" WOULD MOVE {rel} → {dest_rel}")
else:
try:
os.makedirs(dest_dir, exist_ok=True)
shutil.move(src, dest)
moved += 1
if verbose:
print(f" MOVED {rel} → {dest_rel}")
elif moved % 200 == 0:
print(f"\r …moved {moved}", end="", flush=True)
except OSError as e:
errors += 1
print(f" SKIPPED ({e}): {rel}")
if not verbose and not dry and moved:
print(f"\r Moved {moved} file(s). ")
# Prune dirs left empty by the moves (keep top-level buckets)
pruned = 0
if not dry and moved:
keep = {"AI", "Photos", "Screenshots", "Unsorted"}
for dirpath, dirnames, filenames in os.walk(root, topdown=False):
if dirpath == root:
continue
if os.path.relpath(dirpath, root).split(os.sep)[0] in keep and \
os.path.dirname(dirpath) == root:
continue
try:
os.rmdir(dirpath) # only succeeds when empty
pruned += 1
except OSError:
pass
print()
print(f" {'DRY RUN — would move' if dry else 'Moved'}: {moved}")
print(f" Already correctly placed: {ok}")
print(f" Collisions (renamed with _N suffix): {collisions}")
if errors:
print(f" Errors: {errors}")
print(f" No AI metadata (left in place): {nometa}")
if pruned:
print(f" Empty folders pruned: {pruned}")
if samples:
print()
print(" Sample moves:")
for s, d in samples:
print(f" {s}")
print(f" → {d}")
if legacy_no_meta:
print()
print(f" Note: {len(legacy_no_meta)} file(s) in None//unknown/ have no AI metadata")
print(" and were left in place for manual triage (not all are AI images).")
for rel in legacy_no_meta[:10]:
print(f" {rel}")
if len(legacy_no_meta) > 10:
print(f" … and {len(legacy_no_meta) - 10} more")
print()
PYEOF
STATUS=$?
rm -f "$AI_META"
exit $STATUS
fi
# --- Default mode: classify top-level files into AI/Photos/Screenshots/Unsorted ---
# Generic-name hook (non-recursive)
GENERIC_COUNT=$(find "$TARGET" -maxdepth 1 -type f -name 'ComfyUI*.png' 2>/dev/null | wc -l | tr -d ' ')
if [ "$GENERIC_COUNT" -gt 0 ] && [ -n "$RENAME_SCRIPT" ]; then
if [ "$DRY_RUN" = true ]; then
echo " $GENERIC_COUNT generic ComfyUI_*.png file(s) would be renamed first (rename-ai-snaps --only-generic)"
else
echo " Renaming $GENERIC_COUNT generic ComfyUI_*.png file(s) first..."
"$RENAME_SCRIPT" "$TARGET" -n --only-generic
fi
fi
# Pre-scan: extract AI metadata for model/LoRA routing
AI_META=""
if [ "$FLAT" = false ]; then
META_SCRIPT="$(cd "$(dirname "$0")" && pwd -P)/extract-ai-meta"
if [ -x "$META_SCRIPT" ]; then
echo " Scanning AI metadata for model/LoRA routing..."
AI_META=$(mktemp)
if ! "$META_SCRIPT" "$TARGET" > "$AI_META" 2>/dev/null; then
echo " Warning: AI metadata scan failed, falling back to flat AI/"
FLAT=true
rm -f "$AI_META"
elif [ ! -s "$AI_META" ]; then
FLAT=true
rm -f "$AI_META"
fi
else
echo " Warning: extract-ai-meta not found, falling back to flat AI/"
AI_META=$(mktemp)
if ! "$META_SCRIPT" "$TARGET" > "$AI_META" 2>/dev/null; then
echo " Warning: AI metadata scan failed, falling back to flat AI/"
FLAT=true
rm -f "$AI_META"
AI_META=""
elif [ ! -s "$AI_META" ]; then
FLAT=true
rm -f "$AI_META"
AI_META=""
fi
fi
[ "$FLAT" = false ] && echo " AI sub-categorization: by model/LoRA (use --flat to disable)"
echo
# Basenames of files with AI metadata (manifest presence = is an AI image)
AI_KEYS=""
if [ -n "$AI_META" ]; then
AI_KEYS=$(mktemp)
python3 -c "
import json, sys
for k in json.load(open('$AI_META')):
print(k)
" > "$AI_KEYS" 2>/dev/null
fi
classify() {
local f="$1"
@@ -110,6 +311,11 @@ classify() {
ComfyUI_*|*_DEFI_*|R.*.png|schmeeve-AI-*) echo "AI"; return ;;
esac
# Files with ComfyUI metadata are AI regardless of name
if [ -n "$AI_KEYS" ] && grep -Fxq "$name" "$AI_KEYS" 2>/dev/null; then
echo "AI"; return
fi
if mdls -name kMDItemAcquisitionMake "$f" 2>/dev/null | grep -q '= "'; then
echo "Photos"; return
fi
@@ -121,24 +327,10 @@ classify() {
echo "Photos"; return ;;
esac
if [ "$ext" = "png" ]; then
local dims w h area ratio
dims=$(sips --getProperty pixelWidth --getProperty pixelHeight "$f" 2>/dev/null | grep -E 'pixel(Width|Height)' | awk '{print $NF}')
w=$(echo "$dims" | head -1)
h=$(echo "$dims" | tail -1)
if [ -n "$w" ] && [ -n "$h" ] && [ "$w" -gt 0 ] && [ "$h" -gt 0 ]; then
area=$((w * h))
ratio=$(echo "scale=4; $w / $h" | bc 2>/dev/null || echo 1)
if [ "$area" -gt 1048576 ] && [ "$(echo "$ratio > 0.75" | bc -l)" = 1 ] && [ "$(echo "$ratio < 1.33" | bc -l)" = 1 ]; then
echo "AI"; return
fi
fi
fi
echo "Unsorted"
}
# --- Pre-scan: collect all image files ---
# --- Collect top-level image files ---
FILES=()
for f in "$TARGET"/*; do
[ -f "$f" ] || continue
@@ -155,6 +347,8 @@ if [ "$LIMIT" -gt 0 ] && [ "$LIMIT" -lt "$TOTAL" ]; then
fi
if [ "$TOTAL" -eq 0 ]; then
echo " No image files found."
[ -n "$AI_META" ] && rm -f "$AI_META"
[ -n "$AI_KEYS" ] && rm -f "$AI_KEYS"
exit 0
fi
@@ -174,27 +368,16 @@ progress_bar() {
printf "] %3d%% (%d/%d)" "$pct" "$current" "$total"
}
# Destination for an AI file: canonical AI/{model}/{lora}/ from the manifest
ai_dest() {
python3 -c "
import json, sys
m = json.load(open('$AI_META'))
AI_META_PATH="$AI_META" python3 -c "
import json, os, sys
m = json.load(open(os.environ['AI_META_PATH']))
entry = m.get(sys.argv[1])
if not entry or not isinstance(entry, dict):
if not entry or not isinstance(entry, dict) or not entry.get('dest'):
print('AI/unknown')
sys.exit(0)
def clean(s):
if not s:
return 'unknown'
return s.replace('/', '-').replace('\\\\', '-')
model = clean(entry.get('model'))
loras = entry.get('loras') or []
if loras:
combo = '+'.join(clean(l.strip()) for l in loras if l)
combo = (combo[:200]).rstrip('+')
print(f'AI/{model}/{combo}')
else:
print(f'AI/{model}/no-lora')
print('AI/' + entry['dest'])
" "$1"
}
@@ -224,7 +407,15 @@ for f in "${FILES[@]}"; do
if [ "$DRY_RUN" = false ]; then
mkdir -p "$dest"
mv -f "$f" "$dest"
base=$(basename "$f")
final="$dest/$base"
if [ -e "$final" ]; then
stem="${base%.*}"; fext="${base##*.}"
n=1
while [ -e "$dest/${stem}_${n}.${fext}" ]; do n=$((n + 1)); done
final="$dest/${stem}_${n}.${fext}"
fi
mv "$f" "$final"
fi
case "$cat" in
@@ -235,13 +426,16 @@ for f in "${FILES[@]}"; do
esac
done
[ -n "$AI_META" ] && rm -f "$AI_META"
[ -n "$AI_KEYS" ] && rm -f "$AI_KEYS"
if [ "$VERBOSE" = false ]; then
printf "\r %-60s\n" "Done."
fi
echo
echo " Results:"
if [ "$FLAT" = false ] && [ -n "$AI_META" ]; then
if [ "$FLAT" = false ] && [ "$cAI" -gt 0 ]; then
echo " AI (by model/LoRA): $cAI"
else
echo " AI: $cAI"