organise-photos

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.

Safety Notice

This listing is from the official public ClawHub registry. Review SKILL.md and referenced scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "organise-photos" with this command: npx skills add lemondepat/organise-photos

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

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

Neural Memory

Associative memory with spreading activation for persistent, intelligent recall. Use PROACTIVELY when: (1) You need to remember facts, decisions, errors, or...

Registry SourceRecently Updated
General

kai-slide-creator

Create beautiful, zero-dependency HTML presentations that run entirely in the browser — no npm, no build tools. 21 curated style presets with named layout va...

Registry SourceRecently Updated
General

Chronos

通用周期任务管理器 - 支持6种周期类型、每月N次配额、自动cron、统一视图,适用于所有定时任务场景

Registry SourceRecently Updated
General

AIN - AI Node Plugin

Integrates AIN providers into OpenClaw with intelligent model routing and tools for prompt execution and task classification.

Registry SourceRecently Updated