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:
308
organize-images
308
organize-images
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user