Created
March 11, 2026 10:59
-
-
Save NickPax/50df6d0924b91581d4793d80903755bb to your computer and use it in GitHub Desktop.
Screenshot Triage - local Ollama vision analysis
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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('"', '"').replace("'", "'").replace("<", "<") | |
| value = item.get("value", "low") | |
| filename = item.get("filename", "") | |
| text = item.get("text_content", "") | |
| text_safe = text.replace('"', '"').replace("'", "'").replace("<", "<") | |
| tags = item.get("tags", []) | |
| modified = item.get("modified", "")[:10] | |
| tags_json = json.dumps(tags).replace("'", "'") | |
| 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()">×</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