Skip to content

Instantly share code, notes, and snippets.

@NickPax
Created March 11, 2026 10:59
Show Gist options
  • Select an option

  • Save NickPax/50df6d0924b91581d4793d80903755bb to your computer and use it in GitHub Desktop.

Select an option

Save NickPax/50df6d0924b91581d4793d80903755bb to your computer and use it in GitHub Desktop.
Screenshot Triage - local Ollama vision analysis
#!/usr/bin/env python3
"""
Screenshot Triage Tool (Ollama Edition)
Scans a folder of screenshots, uses a LOCAL vision model via Ollama to
analyze & categorize them, then generates a browsable HTML gallery.
Everything stays on your machine. No data leaves.
Usage:
python3 triage.py # scan ~/Desktop
python3 triage.py --dir ~/Screenshots # scan different folder
python3 triage.py --limit 10 # test with 10 files first
python3 triage.py --move # organize into folders
python3 triage.py --model llava:13b # use a specific model
Requirements:
pip install pillow requests
ollama pull llama3.2-vision (or llava, llava:13b, etc.)
"""
import os
import sys
import json
import base64
import argparse
import hashlib
import mimetypes
import logging
import signal
from pathlib import Path
from datetime import datetime, timedelta
import time
import io
try:
import requests
except ImportError:
print("❌ Need requests package: pip install requests")
sys.exit(1)
try:
from PIL import Image
except ImportError:
print("❌ Need Pillow package: pip install pillow")
sys.exit(1)
# ─── Config ───────────────────────────────────────────────────────────────────
OLLAMA_URL = os.environ.get("OLLAMA_URL", "http://localhost:11434")
# Models to try in order of preference
PREFERRED_MODELS = [
"llama3.2-vision",
"llama3.2-vision:11b",
"llava:13b",
"llava",
"llava:7b",
"bakllava",
"moondream",
]
CATEGORIES = [
"code", # Code snippets, terminal output, IDE screenshots
"article", # Blog posts, news articles, web content worth reading
"conversation", # Chat messages, emails, DMs
"reference", # Documentation, how-tos, settings, configs
"design", # UI mockups, design inspiration, layouts
"data", # Charts, graphs, dashboards, analytics
"receipt", # Purchases, invoices, confirmations
"error", # Error messages, bugs, stack traces
"social", # Social media posts, tweets, profiles
"photo", # Actual photos (not screenshots of apps)
"meme", # Memes, funny stuff
"junk", # Duplicates, accidental screenshots, low value
"other", # Anything that doesn't fit
]
CATEGORY_ICONS = {
"code": "💻", "article": "📰", "conversation": "💬",
"reference": "📚", "design": "🎨", "data": "📊",
"receipt": "🧾", "error": "🐛", "social": "📱",
"photo": "📸", "meme": "😂", "junk": "🗑️", "other": "❓"
}
CACHE_FILE = ".triage_cache.json"
LOG_FILE = ".triage.log"
# Graceful shutdown
shutdown_requested = False
def signal_handler(sig, frame):
global shutdown_requested
print("\n\n⚠️ Shutdown requested — saving progress and exiting cleanly...")
shutdown_requested = True
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
# ─── Logging ──────────────────────────────────────────────────────────────────
def setup_logging(directory: Path):
log_path = directory / LOG_FILE
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(message)s",
datefmt="%H:%M:%S",
handlers=[
logging.FileHandler(log_path),
logging.StreamHandler(sys.stdout)
]
)
return logging.getLogger("triage")
# ─── Ollama ───────────────────────────────────────────────────────────────────
def check_ollama() -> bool:
"""Check if Ollama is running."""
try:
r = requests.get(f"{OLLAMA_URL}/api/tags", timeout=5)
return r.status_code == 200
except:
return False
def list_vision_models() -> list[str]:
"""List available vision-capable models."""
try:
r = requests.get(f"{OLLAMA_URL}/api/tags", timeout=5)
models = r.json().get("models", [])
# Filter for known vision models
vision_keywords = ["llava", "vision", "bakllava", "moondream", "minicpm-v"]
vision_models = []
for m in models:
name = m["name"]
if any(kw in name.lower() for kw in vision_keywords):
vision_models.append(name)
return vision_models
except:
return []
def pick_model(preferred: str = None) -> str:
"""Pick the best available vision model."""
available = list_vision_models()
if not available:
return None
if preferred and any(preferred in m for m in available):
# Exact or partial match
for m in available:
if preferred in m:
return m
# Try preferred list
for pref in PREFERRED_MODELS:
for m in available:
if pref in m:
return m
# Fall back to first available
return available[0] if available else None
def pull_model(model: str, log) -> bool:
"""Pull a model if not available."""
log.info(f"📥 Pulling model {model}... (this may take a while)")
try:
r = requests.post(
f"{OLLAMA_URL}/api/pull",
json={"name": model, "stream": True},
stream=True, timeout=3600
)
for line in r.iter_lines():
if line:
data = json.loads(line)
status = data.get("status", "")
if "pulling" in status or "downloading" in status:
total = data.get("total", 0)
completed = data.get("completed", 0)
if total > 0:
pct = completed / total * 100
print(f"\r ⬇️ {status}: {pct:.0f}%", end="", flush=True)
elif "success" in status:
print()
log.info(f"✅ Model {model} ready")
return True
return True
except Exception as e:
log.error(f"❌ Failed to pull model: {e}")
return False
# ─── File operations ─────────────────────────────────────────────────────────
def find_screenshots(directory: Path, limit: int = None, recursive: bool = False) -> list[Path]:
"""Find image files that look like screenshots."""
image_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.tiff', '.bmp'}
files = []
iterator = directory.rglob("*") if recursive else directory.iterdir()
for f in iterator:
if f.is_file() and f.suffix.lower() in image_extensions:
# Skip hidden dirs and triage output
if any(part.startswith('.') for part in f.parts):
continue
if '_triage' in str(f):
continue
# Skip tiny files (< 5KB probably not useful)
if f.stat().st_size > 5000:
files.append(f)
files.sort(key=lambda f: f.stat().st_mtime, reverse=True)
if limit:
files = files[:limit]
return files
def file_hash(path: Path) -> str:
"""Quick hash for cache invalidation."""
stat = path.stat()
return hashlib.md5(f"{path.name}:{stat.st_size}:{stat.st_mtime}".encode()).hexdigest()
def load_cache(directory: Path) -> dict:
cache_path = directory / CACHE_FILE
if cache_path.exists():
try:
return json.loads(cache_path.read_text())
except:
return {}
return {}
def save_cache(directory: Path, cache: dict):
cache_path = directory / CACHE_FILE
cache_path.write_text(json.dumps(cache, indent=2))
def make_thumbnail(path: Path, thumb_dir: Path, max_size: int = 400) -> str:
"""Create a thumbnail, return its path."""
thumb_path = thumb_dir / f"thumb_{path.stem}.jpg"
if thumb_path.exists():
return str(thumb_path)
try:
with Image.open(path) as img:
img.thumbnail((max_size, max_size), Image.LANCZOS)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
img.save(thumb_path, "JPEG", quality=80)
return str(thumb_path)
except Exception as e:
return None
def encode_image(path: Path, max_dim: int = 1024) -> str:
"""Resize and base64-encode an image for Ollama."""
try:
with Image.open(path) as img:
if max(img.size) > max_dim:
img.thumbnail((max_dim, max_dim), Image.LANCZOS)
if img.mode in ('RGBA', 'P'):
img = img.convert('RGB')
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=85)
return base64.b64encode(buffer.getvalue()).decode('utf-8')
except:
data = path.read_bytes()
return base64.b64encode(data).decode('utf-8')
# ─── Analysis ────────────────────────────────────────────────────────────────
def analyze_screenshot(model: str, path: Path, log, retries: int = 2) -> dict:
"""Use Ollama vision model to analyze a screenshot."""
b64_data = encode_image(path)
categories_str = ", ".join(CATEGORIES)
prompt = f"""Analyze this screenshot. You must reply with ONLY a JSON object, no other text before or after.
{{
"category": "<one of: {categories_str}>",
"description": "<1-2 sentence description>",
"value": "<high|medium|low>",
"text_content": "<key visible text, max 100 chars, or empty string>",
"tags": ["<tag1>", "<tag2>"]
}}
Value guide:
- high = useful reference, important info, worth archiving
- medium = somewhat useful, might want later
- low = junk, accidental, duplicate, or not worth keeping
JSON only. No explanation."""
for attempt in range(retries + 1):
try:
r = requests.post(
f"{OLLAMA_URL}/api/chat",
json={
"model": model,
"messages": [{
"role": "user",
"content": prompt,
"images": [b64_data]
}],
"stream": False,
"options": {
"temperature": 0.1,
"num_predict": 400,
}
},
timeout=300 # Vision models can be slow
)
r.raise_for_status()
text = r.json()["message"]["content"].strip()
# Extract JSON from response
result = extract_json(text)
if result:
# Validate category
if result.get("category") not in CATEGORIES:
result["category"] = "other"
# Validate value
if result.get("value") not in ("high", "medium", "low"):
result["value"] = "medium"
return result
if attempt < retries:
log.warning(f" ⚠️ Bad JSON from model (attempt {attempt+1}), retrying...")
continue
except requests.exceptions.Timeout:
if attempt < retries:
log.warning(f" ⏱️ Timeout (attempt {attempt+1}), retrying...")
continue
log.error(f" ❌ Timeout analyzing {path.name}")
except Exception as e:
if attempt < retries:
log.warning(f" ⚠️ Error (attempt {attempt+1}): {e}")
time.sleep(2)
continue
log.error(f" ❌ Failed: {path.name}: {e}")
return {
"category": "other",
"description": "Analysis failed — manual review needed",
"value": "medium",
"text_content": "",
"tags": ["needs-review"]
}
def extract_json(text: str) -> dict | None:
"""Robustly extract JSON from model output."""
# Try direct parse
try:
return json.loads(text)
except:
pass
# Find JSON block
start = text.find("{")
if start == -1:
return None
# Find matching closing brace
depth = 0
for i in range(start, len(text)):
if text[i] == '{':
depth += 1
elif text[i] == '}':
depth -= 1
if depth == 0:
try:
return json.loads(text[start:i+1])
except:
return None
return None
# ─── Processing ──────────────────────────────────────────────────────────────
def process_screenshots(directory: Path, files: list[Path], model: str, log) -> list[dict]:
"""Analyze all screenshots sequentially (Ollama is single-threaded anyway)."""
cache = load_cache(directory)
results = []
thumb_dir = directory / ".triage_thumbs"
thumb_dir.mkdir(exist_ok=True)
to_analyze = []
for f in files:
fh = file_hash(f)
if fh in cache:
cached = cache[fh].copy()
cached["path"] = str(f)
cached["filename"] = f.name
cached["thumbnail"] = make_thumbnail(f, thumb_dir)
results.append(cached)
else:
to_analyze.append((f, fh))
cached_count = len(results)
if cached_count:
log.info(f"📦 {cached_count} already analyzed (cached)")
if not to_analyze:
log.info("All files already analyzed!")
return results
total = len(to_analyze)
log.info(f"🔍 Analyzing {total} screenshots with {model}")
log.info(f" (running locally — no data leaves your machine)\n")
# Estimate time
est_per_file = 15 # seconds, rough estimate for local vision model
est_total = total * est_per_file
est_finish = datetime.now() + timedelta(seconds=est_total)
log.info(f"⏱️ Estimated time: ~{format_duration(est_total)}")
log.info(f" Estimated finish: ~{est_finish.strftime('%H:%M')}\n")
start_time = time.time()
for i, (f, fh) in enumerate(to_analyze):
if shutdown_requested:
log.info(f"\n💾 Progress saved! {i}/{total} completed. Run again to continue.")
save_cache(directory, cache)
break
elapsed = time.time() - start_time
if i > 0:
avg = elapsed / i
remaining = avg * (total - i)
eta = f" | ETA: {format_duration(remaining)}"
else:
eta = ""
log.info(f" [{i+1}/{total}]{eta} Analyzing {f.name}...")
analysis = analyze_screenshot(model, f, log)
analysis["path"] = str(f)
analysis["filename"] = f.name
analysis["file_hash"] = fh
analysis["thumbnail"] = make_thumbnail(f, thumb_dir)
analysis["file_size"] = f.stat().st_size
analysis["modified"] = datetime.fromtimestamp(f.stat().st_mtime).isoformat()
analysis["model"] = model
results.append(analysis)
# Cache it (exclude path since it's session-specific)
cache[fh] = {k: v for k, v in analysis.items() if k != "path"}
save_cache(directory, cache)
value_emoji = {"high": "🌟", "medium": "📌", "low": "🗑️"}.get(analysis.get("value", ""), "❓")
desc = analysis.get("description", "")[:65]
log.info(f" {value_emoji} {analysis.get('category', '?')}: {desc}")
total_time = time.time() - start_time
analyzed = min(i + 1, total) if to_analyze else 0
if analyzed > 0:
log.info(f"\n⏱️ Analyzed {analyzed} files in {format_duration(total_time)} ({total_time/analyzed:.1f}s avg)")
return results
def format_duration(seconds: float) -> str:
"""Format seconds into human-readable duration."""
if seconds < 60:
return f"{seconds:.0f}s"
elif seconds < 3600:
m = int(seconds // 60)
s = int(seconds % 60)
return f"{m}m {s}s"
else:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
return f"{h}h {m}m"
# ─── HTML Report ─────────────────────────────────────────────────────────────
def generate_html(results: list[dict], output_path: Path, source_dir: Path):
"""Generate a browsable HTML gallery."""
by_category = {}
for r in results:
cat = r.get("category", "other")
by_category.setdefault(cat, []).append(r)
def category_priority(cat):
items = by_category[cat]
high = sum(1 for i in items if i.get("value") == "high")
med = sum(1 for i in items if i.get("value") == "medium")
return (-high, -med)
sorted_cats = sorted(by_category.keys(), key=category_priority)
total = len(results)
high = sum(1 for r in results if r.get("value") == "high")
medium = sum(1 for r in results if r.get("value") == "medium")
low = sum(1 for r in results if r.get("value") == "low")
html = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Screenshot Triage — {total} files</title>
<style>
:root {{
--bg: #0f0f0f;
--surface: #1a1a1a;
--surface2: #242424;
--border: #333;
--text: #e0e0e0;
--text-muted: #888;
--accent: #6C8EEF;
--high: #4CAF50;
--medium: #FF9800;
--low: #666;
--junk: #f44336;
}}
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}}
.header {{
padding: 24px 32px;
background: var(--surface);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 100;
}}
.header h1 {{ font-size: 24px; font-weight: 600; }}
.stats {{
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 14px;
color: var(--text-muted);
flex-wrap: wrap;
}}
.stats .high {{ color: var(--high); }}
.stats .medium {{ color: var(--medium); }}
.stats .low {{ color: var(--low); }}
.controls {{
display: flex;
gap: 12px;
margin-top: 12px;
flex-wrap: wrap;
align-items: center;
}}
.filters {{
display: flex;
gap: 6px;
flex-wrap: wrap;
}}
.filter-btn {{
padding: 5px 12px;
border-radius: 20px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}}
.filter-btn:hover {{ border-color: var(--accent); }}
.filter-btn.active {{ background: var(--accent); border-color: var(--accent); color: #fff; }}
.search-box {{
padding: 7px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--surface2);
color: var(--text);
font-size: 13px;
outline: none;
min-width: 250px;
}}
.search-box:focus {{ border-color: var(--accent); }}
.container {{ padding: 24px 32px; }}
.category-section {{ margin-bottom: 36px; }}
.category-header {{
font-size: 18px;
font-weight: 600;
margin-bottom: 14px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
}}
.category-count {{
font-size: 13px;
color: var(--text-muted);
font-weight: normal;
}}
.grid {{
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
}}
.card {{
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
transition: all 0.2s;
cursor: pointer;
position: relative;
}}
.card:hover {{
border-color: var(--accent);
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}}
.card-img {{
width: 100%;
aspect-ratio: 16/10;
object-fit: cover;
background: var(--surface2);
display: block;
}}
.card-body {{ padding: 10px 12px; }}
.card-desc {{
font-size: 12px;
color: var(--text);
margin-bottom: 4px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}}
.card-meta {{
font-size: 11px;
color: var(--text-muted);
display: flex;
justify-content: space-between;
align-items: center;
}}
.card-text {{
font-size: 10px;
color: var(--text-muted);
font-style: italic;
margin-top: 3px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}}
.value-badge {{
padding: 2px 8px;
border-radius: 10px;
font-size: 10px;
font-weight: 600;
position: absolute;
top: 8px;
right: 8px;
}}
.value-high {{ background: rgba(76,175,80,0.85); color: #fff; }}
.value-medium {{ background: rgba(255,152,0,0.85); color: #fff; }}
.value-low {{ background: rgba(100,100,100,0.85); color: #fff; }}
.tags {{
display: flex;
gap: 3px;
flex-wrap: wrap;
margin-top: 4px;
}}
.tag {{
font-size: 10px;
padding: 1px 6px;
border-radius: 8px;
background: var(--surface2);
color: var(--text-muted);
}}
/* Lightbox */
.lightbox {{
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.92);
z-index: 200;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 40px;
}}
.lightbox.open {{ display: flex; }}
.lightbox img {{
max-width: 90vw;
max-height: 80vh;
object-fit: contain;
border-radius: 6px;
}}
.lightbox-close {{
position: absolute;
top: 12px;
right: 20px;
color: #fff;
font-size: 36px;
cursor: pointer;
background: none;
border: none;
z-index: 210;
}}
.lightbox-nav {{
position: absolute;
top: 50%;
transform: translateY(-50%);
color: #fff;
font-size: 48px;
cursor: pointer;
background: rgba(0,0,0,0.4);
border: none;
padding: 20px 16px;
border-radius: 8px;
z-index: 210;
}}
.lightbox-nav:hover {{ background: rgba(0,0,0,0.7); }}
.lightbox-prev {{ left: 12px; }}
.lightbox-next {{ right: 12px; }}
.lightbox-info {{
color: #fff;
text-align: center;
font-size: 14px;
background: rgba(0,0,0,0.6);
padding: 10px 24px;
border-radius: 8px;
margin-top: 12px;
max-width: 600px;
}}
.lightbox-actions {{
display: flex;
gap: 8px;
margin-top: 8px;
justify-content: center;
}}
.lightbox-actions button {{
padding: 6px 16px;
border-radius: 8px;
border: none;
cursor: pointer;
font-size: 13px;
font-weight: 500;
}}
.btn-reveal {{ background: var(--accent); color: #fff; }}
.btn-open {{ background: var(--high); color: #fff; }}
.privacy-note {{
text-align: center;
color: var(--text-muted);
font-size: 11px;
padding: 12px;
border-top: 1px solid var(--border);
margin-top: 40px;
}}
</style>
</head>
<body>
<div class="header">
<h1>📸 Screenshot Triage</h1>
<div class="stats">
<span>{total} screenshots</span>
<span>•</span>
<span class="high">🌟 {high} high value</span>
<span class="medium">📌 {medium} medium</span>
<span class="low">🗑️ {low} low value</span>
<span>•</span>
<span>🔒 analyzed locally</span>
</div>
<div class="controls">
<div class="filters">
<button class="filter-btn active" onclick="filterAll()">All</button>
<button class="filter-btn" onclick="filterValue('high')">🌟 High</button>
<button class="filter-btn" onclick="filterValue('medium')">📌 Med</button>
<button class="filter-btn" onclick="filterValue('low')">🗑️ Low</button>
<span style="color:var(--border)">|</span>
"""
for cat in sorted_cats:
icon = CATEGORY_ICONS.get(cat, "❓")
count = len(by_category[cat])
html += f' <button class="filter-btn" onclick="filterCategory(\'{cat}\')">{icon} {cat} ({count})</button>\n'
html += """ </div>
<input type="text" class="search-box" placeholder="🔍 Search descriptions, text, tags..." oninput="searchCards(this.value)">
</div>
</div>
<div class="container">
"""
# Build flat list for lightbox navigation
all_items = []
for cat in sorted_cats:
items = by_category[cat]
value_order = {"high": 0, "medium": 1, "low": 2}
items.sort(key=lambda x: value_order.get(x.get("value", "low"), 3))
all_items.extend(items)
# Card index for lightbox nav
card_idx = 0
for cat in sorted_cats:
items = by_category[cat]
icon = CATEGORY_ICONS.get(cat, "❓")
value_order = {"high": 0, "medium": 1, "low": 2}
items.sort(key=lambda x: value_order.get(x.get("value", "low"), 3))
html += f'<div class="category-section" data-category="{cat}">\n'
html += f'<div class="category-header">{icon} {cat.title()} <span class="category-count">({len(items)})</span></div>\n'
html += '<div class="grid">\n'
for item in items:
thumb = item.get("thumbnail", "")
full_path = item.get("path", "")
desc = item.get("description", "Unknown")
desc_safe = desc.replace('"', '&quot;').replace("'", "&#39;").replace("<", "&lt;")
value = item.get("value", "low")
filename = item.get("filename", "")
text = item.get("text_content", "")
text_safe = text.replace('"', '&quot;').replace("'", "&#39;").replace("<", "&lt;")
tags = item.get("tags", [])
modified = item.get("modified", "")[:10]
tags_json = json.dumps(tags).replace("'", "&#39;")
thumb_url = f"file://{thumb}" if thumb else ""
full_url = f"file://{full_path}"
html += f'''<div class="card" data-category="{cat}" data-value="{value}"
data-desc="{desc_safe}" data-text="{text_safe}" data-tags='{tags_json}'
data-fullpath="{full_url}" data-idx="{card_idx}"
onclick="openLightbox({card_idx})">
<span class="value-badge value-{value}">{value}</span>
<img class="card-img" src="{thumb_url}" alt="{desc_safe}" loading="lazy"
onerror="this.style.display='none'">
<div class="card-body">
<div class="card-desc">{desc_safe}</div>
<div class="card-meta">
<span title="{filename}">{filename[:30]}{'…' if len(filename) > 30 else ''}</span>
<span>{modified}</span>
</div>
{f'<div class="card-text">"{text_safe[:80]}"</div>' if text else ''}
<div class="tags">
{''.join(f'<span class="tag">{t}</span>' for t in tags[:4])}
</div>
</div>
</div>
'''
card_idx += 1
html += '</div>\n</div>\n'
# Serialize all items for lightbox JS
items_json = json.dumps([{
"fullpath": f"file://{item.get('path', '')}",
"desc": item.get("description", ""),
"filename": item.get("filename", ""),
"value": item.get("value", ""),
"category": item.get("category", ""),
"path": item.get("path", ""),
} for item in all_items])
html += f"""</div>
<div class="privacy-note">🔒 All analysis performed locally via Ollama. No data was sent to external servers.</div>
<div class="lightbox" id="lightbox">
<button class="lightbox-close" onclick="closeLightbox()">&times;</button>
<button class="lightbox-nav lightbox-prev" onclick="navLightbox(-1)">‹</button>
<button class="lightbox-nav lightbox-next" onclick="navLightbox(1)">›</button>
<img id="lightbox-img" src="">
<div class="lightbox-info">
<div id="lightbox-desc"></div>
<div id="lightbox-counter" style="font-size:12px;color:#aaa;margin-top:4px"></div>
<div class="lightbox-actions">
<button class="btn-reveal" onclick="revealInFinder()">📂 Reveal in Finder</button>
</div>
</div>
</div>
<script>
const allItems = {items_json};
let currentIdx = 0;
function filterAll() {{
document.querySelectorAll('.card').forEach(c => c.style.display = '');
document.querySelectorAll('.category-section').forEach(c => c.style.display = '');
setActiveFilter(null);
}}
function filterCategory(cat) {{
document.querySelectorAll('.category-section').forEach(s => {{
s.style.display = s.dataset.category === cat ? '' : 'none';
}});
document.querySelectorAll('.card').forEach(c => c.style.display = '');
setActiveFilter(cat);
}}
function filterValue(val) {{
document.querySelectorAll('.category-section').forEach(s => s.style.display = '');
document.querySelectorAll('.card').forEach(c => {{
c.style.display = c.dataset.value === val ? '' : 'none';
}});
document.querySelectorAll('.category-section').forEach(s => {{
const vis = [...s.querySelectorAll('.card')].filter(c => c.style.display !== 'none');
s.style.display = vis.length > 0 ? '' : 'none';
}});
setActiveFilter(val);
}}
function setActiveFilter(active) {{
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
if (!active) document.querySelector('.filter-btn').classList.add('active');
}}
function searchCards(query) {{
const q = query.toLowerCase();
document.querySelectorAll('.card').forEach(c => {{
const t = (c.dataset.desc + ' ' + c.dataset.text + ' ' + c.dataset.tags + ' ' + c.querySelector('.card-meta span').textContent).toLowerCase();
c.style.display = t.includes(q) ? '' : 'none';
}});
document.querySelectorAll('.category-section').forEach(s => {{
const vis = [...s.querySelectorAll('.card')].filter(c => c.style.display !== 'none');
s.style.display = vis.length > 0 ? '' : 'none';
}});
}}
function openLightbox(idx) {{
currentIdx = idx;
showLightboxItem();
document.getElementById('lightbox').classList.add('open');
}}
function closeLightbox() {{
document.getElementById('lightbox').classList.remove('open');
}}
function navLightbox(dir) {{
currentIdx = (currentIdx + dir + allItems.length) % allItems.length;
showLightboxItem();
}}
function showLightboxItem() {{
const item = allItems[currentIdx];
document.getElementById('lightbox-img').src = item.fullpath;
document.getElementById('lightbox-desc').textContent = item.desc;
document.getElementById('lightbox-counter').textContent =
`${{currentIdx + 1}} / ${{allItems.length}} · ${{item.category}} · ${{item.value}} value`;
}}
function revealInFinder() {{
const path = allItems[currentIdx].path;
navigator.clipboard.writeText(`open -R "${{path}}"`).then(() => {{
const btn = event.target;
btn.textContent = '✅ Copied command!';
setTimeout(() => btn.textContent = '📂 Reveal in Finder', 2000);
}});
}}
document.addEventListener('keydown', (e) => {{
const lb = document.getElementById('lightbox');
if (!lb.classList.contains('open')) return;
if (e.key === 'Escape') closeLightbox();
if (e.key === 'ArrowLeft') navLightbox(-1);
if (e.key === 'ArrowRight') navLightbox(1);
}});
// Click outside image to close
document.getElementById('lightbox').addEventListener('click', (e) => {{
if (e.target.id === 'lightbox') closeLightbox();
}});
</script>
</body>
</html>
"""
output_path.write_text(html)
# ─── Organize ────────────────────────────────────────────────────────────────
def organize_files(results: list[dict], source_dir: Path, log):
"""Move files into category folders."""
moved = 0
for r in results:
cat = r.get("category", "other")
value = r.get("value", "low")
src = Path(r["path"])
if not src.exists():
continue
if value == "low" or cat == "junk":
target_dir = source_dir / "_triage" / "_trash"
else:
target_dir = source_dir / "_triage" / cat
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / src.name
if target.exists():
stem = src.stem
suffix = src.suffix
i = 1
while target.exists():
target = target_dir / f"{stem}_{i}{suffix}"
i += 1
src.rename(target)
moved += 1
log.info(f" 📁 {src.name} → _triage/{target_dir.name}/")
log.info(f"\n✅ Moved {moved} files into _triage/ subfolders")
# ─── Main ────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="🔒 AI-powered screenshot triage (100% local via Ollama)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python3 triage.py # scan Desktop
python3 triage.py -d ~/Screenshots -l 20 # test with 20 files
python3 triage.py --move # organize into folders
python3 triage.py --model llava:13b # use specific model
python3 triage.py --recursive # include subdirectories
The script caches results, so you can Ctrl+C anytime and resume later.
All processing happens locally — nothing leaves your machine.
"""
)
parser.add_argument("--dir", "-d", default="~/Desktop",
help="Directory to scan (default: ~/Desktop)")
parser.add_argument("--output", "-o", default=None,
help="Output HTML file (default: <dir>/screenshot_triage.html)")
parser.add_argument("--limit", "-l", type=int, default=None,
help="Max screenshots to process")
parser.add_argument("--model", "-m", default=None,
help="Ollama vision model (auto-detected if not set)")
parser.add_argument("--move", action="store_true",
help="Move files into categorized folders")
parser.add_argument("--recursive", "-r", action="store_true",
help="Scan subdirectories too")
parser.add_argument("--pull", action="store_true",
help="Auto-pull model if not available")
parser.add_argument("--ollama-url", default=None,
help=f"Ollama API URL (default: {OLLAMA_URL})")
args = parser.parse_args()
if args.ollama_url:
global OLLAMA_URL
OLLAMA_URL = args.ollama_url
directory = Path(args.dir).expanduser().resolve()
if not directory.exists():
print(f"❌ Directory not found: {directory}")
sys.exit(1)
log = setup_logging(directory)
output = Path(args.output) if args.output else directory / "screenshot_triage.html"
# Banner
print()
log.info("📸 Screenshot Triage (Local Edition)")
log.info(f" 🔒 All processing stays on your machine")
log.info(f" 📂 Scanning: {directory}")
print()
# Check Ollama
if not check_ollama():
log.error("❌ Ollama is not running!")
log.error(f" Start it with: ollama serve")
log.error(f" Or set OLLAMA_URL if it's on another machine")
sys.exit(1)
# Pick model
model = pick_model(args.model)
if not model:
available = list_vision_models()
if args.pull or args.model:
target_model = args.model or PREFERRED_MODELS[0]
if pull_model(target_model, log):
model = target_model
else:
sys.exit(1)
else:
log.error("❌ No vision model found in Ollama!")
log.error(f" Pull one with: ollama pull llama3.2-vision")
log.error(f" Or use --pull to auto-download")
if available:
log.info(f" Available models (non-vision): {', '.join(available)}")
sys.exit(1)
log.info(f" 🤖 Model: {model}")
# Find files
files = find_screenshots(directory, args.limit, args.recursive)
log.info(f" 📷 Found {len(files)} screenshots")
if not files:
log.info("No screenshots found!")
sys.exit(0)
print()
# Process
results = process_screenshots(directory, files, model, log)
if not results:
log.info("No results to report.")
sys.exit(0)
# Sort for report
results.sort(key=lambda r: (
{"high": 0, "medium": 1, "low": 2}.get(r.get("value", "low"), 3),
r.get("category", "other")
))
generate_html(results, output, directory)
log.info(f"\n✅ Report saved: {output}")
log.info(f" Open: open '{output}'")
if args.move:
log.info("\n📁 Organizing files...")
organize_files(results, directory, log)
else:
log.info(f"\n💡 To move files into folders: python3 triage.py --move")
# Summary
print()
log.info("📊 Summary:")
cats = {}
vals = {"high": 0, "medium": 0, "low": 0}
for r in results:
cat = r.get("category", "other")
cats[cat] = cats.get(cat, 0) + 1
v = r.get("value", "low")
vals[v] = vals.get(v, 0) + 1
for cat, count in sorted(cats.items(), key=lambda x: -x[1]):
icon = CATEGORY_ICONS.get(cat, "❓")
log.info(f" {icon} {cat}: {count}")
print()
log.info(f" 🌟 Keep (high): {vals['high']}")
log.info(f" 📌 Review (medium): {vals['medium']}")
log.info(f" 🗑️ Trash candidates (low): {vals['low']}")
print()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment