🎁 Get the FREE AI Skills Starter Guide β€” Subscribe β†’
BytesAgainBytesAgain
πŸ¦€ ClawHub

Organise photos

by @lemondepat

Organize a photo folder by cleaning non-photo files, removing bad exposures, detecting blur and burst shots, and classifying photos into numbered subfolders...

Versionv1.0.0
Downloads645
TERMINAL
clawhub install organise-photos

πŸ“– About This Skill


name: organise-photos description: Organize a photo folder by cleaning non-photo files, removing bad exposures, detecting blur and burst shots, and classifying photos into numbered subfolders using AI vision analysis.

Photo Folder Organizer

Intelligently organize a photo folder: clean non-photo files, remove bad exposures locally, detect blur/burst via Python, analyze content with AI vision, and sort into categorized subfolders.

Usage

User wants to organize a photo folder: $ARGUMENTS

If the user has not provided a folder path, ask them to provide one.

Language note: Detect the language the user is writing in and respond in that language throughout the entire session. Category folder names should also be in the user's language.


Step 1: Scan the Folder

Scan the folder for all files (non-recursive at root level):

# List all files with sizes
ls -la "$FOLDER"

Find photo files (common extensions)

find "$FOLDER" -maxdepth 1 -type f \( \ -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \ -o -iname "*.heic" -o -iname "*.heif" \ -o -iname "*.raw" -o -iname "*.cr2" -o -iname "*.cr3" \ -o -iname "*.nef" -o -iname "*.arw" -o -iname "*.dng" \ -o -iname "*.tiff" -o -iname "*.tif" -o -iname "*.bmp" \ -o -iname "*.webp" -o -iname "*.gif" \ \)

Find non-photo files

find "$FOLDER" -maxdepth 1 -type f ! \( \ -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \ -o -iname "*.heic" -o -iname "*.heif" \ -o -iname "*.raw" -o -iname "*.cr2" -o -iname "*.cr3" \ -o -iname "*.nef" -o -iname "*.arw" -o -iname "*.dng" \ -o -iname "*.tiff" -o -iname "*.tif" -o -iname "*.bmp" \ -o -iname "*.webp" -o -iname "*.gif" \ \)

Report to user:

  • Total files found
  • Number of photo files
  • Number of non-photo files (list them)

  • Step 2: Handle Non-Photo Files

    Use AskUserQuestion (only if non-photo files exist):

    Question: "Found N non-photo file(s). How would you like to handle them?" Options:

  • "Move to _misc subfolder (Recommended)"
  • "Delete all non-photo files"
  • "Leave them as-is"
  • mkdir -p "$FOLDER/_misc"
    mv [non-photo files] "$FOLDER/_misc/"
    


    Step 3: Remove Bad Exposure Photos (Local Python)

    Use AskUserQuestion:

    Question: "Would you like to remove photos with severely bad exposure (near-black or near-white)?" Options:

  • "Yes, use default thresholds (brightness mean < 5% or > 95%) (Recommended)"
  • "Yes, let me specify custom thresholds"
  • "No, keep all photos"
  • Run a Python script to detect and report bad exposures without deleting yet:

    #!/usr/bin/env python3
    """Detect near-black or near-white photos using Pillow."""
    import sys
    import os
    from pathlib import Path

    try: from PIL import Image import numpy as np except ImportError: os.system("pip install Pillow numpy -q") from PIL import Image import numpy as np

    FOLDER = sys.argv[1] LOW_THRESH = float(sys.argv[2]) if len(sys.argv) > 2 else 0.05 # < 5% = near-black HIGH_THRESH = float(sys.argv[3]) if len(sys.argv) > 3 else 0.95 # > 95% = near-white

    PHOTO_EXTS = {'.jpg', '.jpeg', '.png', '.heic', '.heif', '.bmp', '.tiff', '.tif', '.webp', '.gif'}

    bad = [] for f in sorted(Path(FOLDER).iterdir()): if f.suffix.lower() not in PHOTO_EXTS: continue try: with Image.open(f) as img: # Convert to grayscale for brightness analysis gray = img.convert('L') arr = np.array(gray, dtype=np.float32) / 255.0 mean = float(arr.mean()) if mean < LOW_THRESH or mean > HIGH_THRESH: label = "near-black" if mean < LOW_THRESH else "near-white" bad.append((f.name, mean, label)) except Exception as e: print(f"Skipping {f.name}: {e}", file=sys.stderr)

    if not bad: print("No severely bad exposure photos found.") else: print(f"Found {len(bad)} problematic photo(s):") for name, mean, label in bad: print(f" {label} brightness={mean:.3f} {name}")

    Run: python3 /tmp/detect_bad_exposure.py "$FOLDER" 0.05 0.95

    Show the list to user, then confirm before deleting:

    rm "$BAD_PHOTO"
    

    or

    mkdir -p "$FOLDER/_rejected_exposure" mv "$BAD_PHOTO" "$FOLDER/_rejected_exposure/"


    Step 4: Detect Blur and Burst Shots (Local Python)

    Run a comprehensive Python analysis script on all remaining photos. This step: 1. Scores each photo's sharpness (Laplacian variance) 2. Detects burst groups (photos taken within 3 seconds of each other OR with near-identical perceptual hash)

    #!/usr/bin/env python3
    """Analyze photos for blur and burst grouping."""
    import sys
    import os
    import json
    from pathlib import Path
    from datetime import datetime

    try: import cv2 import numpy as np from PIL import Image from PIL.ExifTags import TAGS import imagehash except ImportError: os.system("pip install opencv-python-headless Pillow imagehash numpy -q") import cv2 import numpy as np from PIL import Image from PIL.ExifTags import TAGS import imagehash

    FOLDER = sys.argv[1] BLUR_THRESHOLD = float(sys.argv[2]) if len(sys.argv) > 2 else 80.0 # Laplacian variance below this = blurry BURST_SECONDS = int(sys.argv[3]) if len(sys.argv) > 3 else 3 # seconds between shots = same burst PHASH_DIST = int(sys.argv[4]) if len(sys.argv) > 4 else 8 # perceptual hash distance threshold

    PHOTO_EXTS = {'.jpg', '.jpeg', '.png', '.heic', '.heif', '.bmp', '.tiff', '.tif', '.webp'}

    def get_exif_datetime(img_path): """Extract DateTimeOriginal from EXIF.""" try: with Image.open(img_path) as img: exif_data = img._getexif() if exif_data: for tag_id, value in exif_data.items(): tag = TAGS.get(tag_id, tag_id) if tag == 'DateTimeOriginal': return datetime.strptime(value, "%Y:%m:%d %H:%M:%S") except Exception: pass # Fall back to file modification time return datetime.fromtimestamp(Path(img_path).stat().st_mtime)

    def laplacian_sharpness(img_path): """Compute Laplacian variance as sharpness score. Higher = sharper.""" img = cv2.imread(str(img_path)) if img is None: return 0.0 gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) return float(cv2.Laplacian(gray, cv2.CV_64F).var())

    Collect all photos

    photos = [] for f in sorted(Path(FOLDER).iterdir()): if f.suffix.lower() not in PHOTO_EXTS or f.name.startswith('.'): continue photos.append(f)

    print(f"Analyzing {len(photos)} photos...", flush=True)

    results = [] for i, f in enumerate(photos): print(f" [{i+1}/{len(photos)}] {f.name}", flush=True) dt = get_exif_datetime(f) sharp = laplacian_sharpness(f) try: ph = imagehash.phash(Image.open(f)) except Exception: ph = None results.append({ "file": f.name, "path": str(f), "datetime": dt.isoformat(), "sharpness": sharp, "blurry": sharp < BLUR_THRESHOLD, "phash": str(ph) if ph else None, })

    Detect burst groups: same timestamp (within N seconds) OR similar phash

    groups = [] used = set() for i, r in enumerate(results): if i in used: continue group = [i] used.add(i) t1 = datetime.fromisoformat(r["datetime"]) for j, r2 in enumerate(results): if j <= i or j in used: continue t2 = datetime.fromisoformat(r2["datetime"]) time_close = abs((t2 - t1).total_seconds()) <= BURST_SECONDS hash_close = (r["phash"] and r2["phash"] and imagehash.hex_to_hash(r["phash"]) - imagehash.hex_to_hash(r2["phash"]) <= PHASH_DIST) if time_close or hash_close: group.append(j) used.add(j) if len(group) > 1: groups.append(group)

    Mark burst membership

    for g_idx, group in enumerate(groups): # Find sharpest in group best_idx = max(group, key=lambda i: results[i]["sharpness"]) for idx in group: results[idx]["burst_group"] = g_idx + 1 results[idx]["burst_best"] = (idx == best_idx) results[idx]["burst_size"] = len(group)

    Output JSON for further processing

    output = { "photos": results, "burst_groups": len(groups), "blurry_count": sum(1 for r in results if r.get("blurry")), "blur_threshold": BLUR_THRESHOLD, } with open("/tmp/photo_analysis.json", "w") as fout: json.dump(output, fout, indent=2, ensure_ascii=False)

    Print summary

    blurry = [r for r in results if r.get("blurry")] burst_photos = [r for r in results if "burst_group" in r] print(f"\n=== Analysis Complete ===") print(f"Blurry photos (sharpness < {BLUR_THRESHOLD}): {len(blurry)}") print(f"Burst groups: {len(groups)} group(s) ({len(burst_photos)} photos total)") if blurry: print("\nBlurry photo list:") for r in blurry: print(f" sharpness={r['sharpness']:.1f} {r['file']}") if groups: print(f"\nBurst group details:") for g_idx, group in enumerate(groups): print(f" Group {g_idx+1} ({len(group)} photos):") for idx in group: r = results[idx] best_mark = "β˜… sharpest" if r.get("burst_best") else "" print(f" {r['file']} sharpness={r['sharpness']:.1f} {best_mark}") print("\nFull analysis saved to /tmp/photo_analysis.json")

    Run: python3 /tmp/analyze_photos.py "$FOLDER" 80 3 8


    Step 5: Handle Blurry Photos

    If any blurry photos were found:

    Use AskUserQuestion:

    Question: "Found N blurry photo(s) (out of focus). How would you like to handle them?" Options:

  • "Delete all blurry photos"
  • "Move to _rejected_blur subfolder"
  • "Keep them, do nothing"
  • Execute using /tmp/photo_analysis.json:

    import json, shutil, os
    from pathlib import Path

    data = json.load(open("/tmp/photo_analysis.json")) FOLDER = "PATH_TO_FOLDER"

    for r in data["photos"]: if r.get("blurry"): src = Path(r["path"]) # delete: src.unlink() # move: shutil.move(str(src), os.path.join(FOLDER, "_rejected_blur", src.name))


    Step 6: Handle Burst Series

    If burst groups were found:

    Use AskUserQuestion:

    Question: "Found N burst group(s) (M photos total). How would you like to handle them?" Options:

  • "Keep only the sharpest photo per group, delete the rest (Recommended)"
  • "Keep only the sharpest photo per group, move the rest to _burst_extras"
  • "Keep all, do nothing"
  • "Decide group by group"
  • If "Decide group by group", for each burst group: show filenames + sharpness scores, use AskUserQuestion with: Keep sharpest / Keep all / Decide photo by photo

    Execute using /tmp/photo_analysis.json:

    import json, shutil, os
    from pathlib import Path

    data = json.load(open("/tmp/photo_analysis.json"))

    Group photos by burst_group

    groups = {} for r in data["photos"]: g = r.get("burst_group") if g: groups.setdefault(g, []).append(r)

    for g_idx, members in groups.items(): for r in members: if not r.get("burst_best"): src = Path(r["path"]) # delete: src.unlink() # or move to _burst_extras/


    Step 7: AI Vision Analysis and Classification

    For AI classification, read photo images directly using the Read tool (no frame extraction needed β€” photos are already images).

    For large folders (>100 photos): Read photos in batches of 20-30, analyzing all at once.

    Analyze each photo and produce: 1. Category: A short label describing content, in the user's language (e.g. landscape, portrait, architecture, food, interior, events, night scene, animals, street, nature, travel) 2. Quality note: any notable issue not already caught (strong motion blur, extreme overexposure, poor composition) β€” mark as "deletable" if clearly low value

    After analyzing all photos, present a summary table:

    Filename             Category    Notes
    IMG_001.jpg          landscape   none
    IMG_002.jpg          portrait    none
    IMG_003.jpg          architecture none
    ...
    


    Step 8: Classify Photos into Numbered Subfolders

    Based on AI analysis categories:

    1. Collect all unique categories 2. Sort by count (most photos first) 3. Assign two-digit numbers: 01_, 02_, etc.

    Show proposed structure (folder names in the user's language):

    Proposed folder structure:
    01_landscape   (45 photos)
    02_portrait    (30 photos)
    03_architecture (20 photos)
    

    Use AskUserQuestion:

    Question: "Does the proposed folder structure look good?" Options:

  • "Looks good, proceed"
  • "I need to rename some categories"
  • "I need to merge some categories"
  • Execute file moves:

    mkdir -p "$FOLDER/01_landscape"
    mv "$PHOTO" "$FOLDER/01_landscape/"
    

    Show final structure after moving:

    find "$FOLDER" -type d | sort
    for d in "$FOLDER"/*/; do echo "$d: $(ls "$d" | wc -l) photos"; done
    


    Step 9: Refine a Category (Optional)

    Use AskUserQuestion:

    Question: "Would you like to further organize any category folder?" Options:

  • "No, all done"
  • "Yes, let me choose a category"
  • If user wants to refine, list created folders as options.

    Then ask:

    Question: "How would you like to organize this folder?" Options:

  • "Group by date"
  • "Group by quality (picks / normal)"
  • "Group by person"
  • "Let me describe how"
  • Execute the requested sub-organization using AI analysis data or re-read images if needed.

    After completing, loop back to Step 9 to ask if any other category needs refinement.


    Technical Notes

    Prerequisites

  • Python 3 with packages: Pillow, numpy, opencv-python-headless, imagehash
  • Install: pip install Pillow numpy opencv-python-headless imagehash
  • Or use uv / uvx for isolated environments
  • Blur Detection

  • Uses Laplacian variance: measures edge sharpness in the image
  • Threshold ~80 works well for typical photos; adjust lower (e.g. 50) for lenient mode
  • Very high-resolution photos may need higher threshold (~150)
  • Note: intentionally blurred/bokeh backgrounds don't trigger this β€” it analyzes the whole image
  • Burst Detection Logic

  • Time-based: Photos taken within 3 seconds β†’ likely burst
  • Hash-based: Perceptual hash distance ≀ 8 β†’ nearly identical composition
  • Both conditions are checked independently (OR logic)
  • Sharpest photo = highest Laplacian variance score β†’ selected as "best" in group
  • Supported Formats

  • JPEG, PNG, HEIC/HEIF, WebP, BMP, TIFF
  • RAW formats (CR2, CR3, NEF, ARW, DNG) require rawpy package:
  • pip install rawpy β€” add rawpy support to detect_bad_exposure.py for RAW files

    Temp Files

  • /tmp/photo_analysis.json β€” full analysis results; clean up after: rm /tmp/photo_analysis.json
  • Folder Safety

  • Never delete files without explicit user confirmation
  • Always show what will be deleted/moved before executing
  • Prefer moving to _rejected_* subfolder over permanent deletion
  • πŸ’‘ Examples

    User wants to organize a photo folder: $ARGUMENTS

    If the user has not provided a folder path, ask them to provide one.

    Language note: Detect the language the user is writing in and respond in that language throughout the entire session. Category folder names should also be in the user's language.


    βš™οΈ Configuration

  • Python 3 with packages: Pillow, numpy, opencv-python-headless, imagehash
  • Install: pip install Pillow numpy opencv-python-headless imagehash
  • Or use uv / uvx for isolated environments
  • Blur Detection

  • Uses Laplacian variance: measures edge sharpness in the image
  • Threshold ~80 works well for typical photos; adjust lower (e.g. 50) for lenient mode
  • Very high-resolution photos may need higher threshold (~150)
  • Note: intentionally blurred/bokeh backgrounds don't trigger this β€” it analyzes the whole image
  • Burst Detection Logic

  • Time-based: Photos taken within 3 seconds β†’ likely burst
  • Hash-based: Perceptual hash distance ≀ 8 β†’ nearly identical composition
  • Both conditions are checked independently (OR logic)
  • Sharpest photo = highest Laplacian variance score β†’ selected as "best" in group
  • Supported Formats

  • JPEG, PNG, HEIC/HEIF, WebP, BMP, TIFF
  • RAW formats (CR2, CR3, NEF, ARW, DNG) require rawpy package:
  • pip install rawpy β€” add rawpy support to detect_bad_exposure.py for RAW files

    Temp Files

  • /tmp/photo_analysis.json β€” full analysis results; clean up after: rm /tmp/photo_analysis.json
  • Folder Safety

  • Never delete files without explicit user confirmation
  • Always show what will be deleted/moved before executing
  • Prefer moving to _rejected_* subfolder over permanent deletion