commit 4f90f5838a9dd35bec461ab6da293bb5a8c65c7b Author: Kareem Horstink Date: Sun Aug 23 15:31:47 2026 +0000 init: project structure with LAION scorer, conda setup, and research docs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..927b199 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Pi +.pi/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..030cd10 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Imrayya + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ea2e7f --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Photo Judgers + +Judge and shortlist 4000+ photos — primarily wedding photos, with other photo types included. + +## Overview + +This project designs the workflow for curating, judging, and shortlisting large batches of photos. The main use case is wedding photography, but the system should be flexible enough to handle other photo categories too. + +**Key constraint:** This is a workflow design project. The actual execution runs on more capable hardware. This machine is underpowered for the workload. + +## Goals + +- Design a scoring pipeline to judge photos on aesthetic quality +- Support category/tagging later (initial phase focuses on scoring only) +- Shortlist the best photos from 4000+ batches +- Keep track of decisions and reasoning + +## Architecture: Phase 1 — LAION Aesthetic Predictor + +Based on research, Phase 1 implements the **LAION Aesthetic Predictor V2** as the first-pass scorer. + +### Why LAION V2 First? + +- **Simplest starting point** — one-line Python API via HuggingFace transformers +- **MIT license** — fully permissive +- **~2 GB VRAM** — lightweight, fits on consumer GPUs +- **Fast inference** — ~5-10 minutes for 4000 images on GPU +- **Good general aesthetic ranking** — trained on diverse datasets (AI-generated + professional photos) + +### Limitations (to address later) + +- No technical quality detection (blur, exposure, noise) +- Biased toward AI-generated aesthetic patterns +- Western/art-historical bias — may underrate candid moments and cultural shots +- No context understanding (can't distinguish emotional moments from empty frames) + +### Future: Combined Approach + +After LAION V2 is working, the full pipeline will combine: + +- **LAION V2** — aesthetic scoring (0-10 scale) +- **BRISQUE** — technical quality (blur/noise detection, CPU-only) +- **MUSIQ** (optional) — combined aesthetic + technical scoring + +A photo would be kept only if it passes both aesthetic and technical thresholds. + +## Structure + +``` +photo_judgers/ +├── test/ # Test folder +├── docs/ # Documentation +├── .pi/ # Pi config (gitignored) +├── README.md # This file +└── .gitignore +``` + +## Status + +**Phase 1: LAION V2 implementation** — In progress. + +Next steps: + +- Set up Python project (or decide on language) +- Implement LAION V2 scoring pipeline +- Define scoring thresholds for shortlisting +- Build output format (CSV/JSON with scores and file paths) diff --git a/docs/output-format.md b/docs/output-format.md new file mode 100644 index 0000000..67d604a --- /dev/null +++ b/docs/output-format.md @@ -0,0 +1,65 @@ +# Output Format — JSON + +## Structure + +The scorer outputs a JSON file with an array of results. Each entry represents one photo. + +## Schema + +```json +[ + { + "filename": "IMG_0001.jpg", + "filepath": "/path/to/wedding-photos/IMG_0001.jpg", + "subtype": "aesthetic", + "laion_score": 7.2, + "laion_version": "v2", + "scoring_date": "2026-01-15T10:30:00Z" + } +] +``` + +## Fields + +| Field | Type | Description | +|-------|------|-------------| +| `filename` | string | Base filename (e.g., `IMG_0001.jpg`) | +| `filepath` | string | Full absolute path to the image | +| `subtype` | string | Classifier subtype — `aesthetic` for LAION (future: `technical`, `blur`, etc.) | +| `laion_score` | float | LAION aesthetic score (0-10 scale) | +| `laion_version` | string | Version of LAION model used (e.g., `v2`) | +| `scoring_date` | string | ISO 8601 timestamp of when scoring was performed | + +## Example + +```json +[ + { + "filename": "DSC_0421.jpg", + "filepath": "C:/wedding-photos/day1/DSC_0421.jpg", + "subtype": "aesthetic", + "laion_score": 8.1, + "laion_version": "v2", + "scoring_date": "2026-01-15T10:30:00Z" + }, + { + "filename": "DSC_0422.jpg", + "filepath": "C:/wedding-photos/day1/DSC_0422.jpg", + "subtype": "aesthetic", + "laion_score": 3.4, + "laion_version": "v2", + "scoring_date": "2026-01-15T10:30:01Z" + } +] +``` + +## Thresholds (TBD) + +Thresholds for keep/reject/review are not yet defined. Will be determined after running initial batches and reviewing score distributions. + +Suggested approach: + +1. Score a sample of 100-200 photos +2. Analyze the score distribution +3. Set thresholds based on the distribution (e.g., top 25% = keep, bottom 25% = reject, middle = review) +4. Adjust based on photographer feedback diff --git a/docs/research-brisque-niqe.md b/docs/research-brisque-niqe.md new file mode 100644 index 0000000..235822b --- /dev/null +++ b/docs/research-brisque-niqe.md @@ -0,0 +1,159 @@ +# BRISQUE / NIQE — Classical No-Reference IQA + +## Model Info + +- **BRISQUE:** "No-reference Image Quality Assessment in the Spatial Domain" — Mittal et al., IEEE TIP 2012 +- **NIQE:** "Making a 'Completely Blind' Image Quality Analyzer" — Mittal et al., IEEE SPL 2013 +- **Python packages:** + - `pip install brisque[opencv-python]` — Latest BRISQUE (supports opencv-contrib, opencv-headless) + - `pip install pybrisque` — Alternative BRISQUE (older, from Bukalapak) + - `pip install image-quality` — Contains `imquality.brisque` module + - `pip install pyiqa` — IQA-PyTorch toolbox (includes BRISQUE + NIQE + 30+ other metrics) + - `pip install nr-iqa` — Comprehensive library (BRISQUE + NIQE + PIQE) +- **License:** Academic (BRISQUE: BSD-like via LearnOpenCV; NIQE: academic use) +- **Dependencies:** OpenCV, NumPy, SciPy (BRISQUE); skvideo + NumPy (NIQE) + +## Architecture + +### BRISQUE (Blind/Referenceless Image Spatial Quality Evaluator) + +- **Type:** Handcrafted features + Support Vector Regression (SVR) +- **Pipeline:** + 1. **MSCN (Mean Subtracted Contrast Normalized) coefficients** — decorrelates image from local brightness/contrast + 2. **GGD (Generalized Gaussian Distribution) fitting** to MSCN coefficients — extracts shape parameter alpha (tail heaviness) + 3. **Pairwise products** in 4 orientations (horizontal, vertical, 2 diagonals) — 16 features + 4. **AGGD (Asymmetric GGD) fitting** to pairwise products + 5. **Multi-scale analysis** at 2 scales (original + 0.5x downsampled) — 36-D feature vector total (18 per scale) + 6. **SVR prediction** maps 36-D features to quality score in [0, 100] +- **Training data:** TID2008 dataset (human-rated distorted images, MOS scores 0-100) +- **Score direction:** **Lower = better** (0 = perfect, 100 = worst quality) + +### NIQE (Natural Image Quality Evaluator) + +- **Type:** Handcrafted features + Multivariate Gaussian (MVG) model +- **Key difference from BRISQUE:** Completely opinion-unaware — does NOT need human-rated distorted images for training +- **Pipeline:** + 1. Extract 18-D NSS features from 96x96 patches (same as BRISQUE scale-1) + 2. Fit MVG model (mu_ref, Sigma_ref) to pristine images — the "naturalness" reference + 3. Fit MVG (mu_test, Sigma_test) to test image patches + 4. **Quality = Mahalanobis distance** between the two MVG models +- **Training data:** Only pristine (undistorted) images — no human ratings needed +- **Score direction:** **Lower = better** (0 = perfectly natural, higher = more distorted) + +## Installation + +```bash +# Option 1: Latest BRISQUE (recommended) +pip install brisque[opencv-python] + +# Option 2: For servers/Docker (headless) +pip install brisque[opencv-python-headless] + +# Option 3: Comprehensive NR-IQA library (BRISQUE + NIQE + PIQE) +pip install nr-iqa + +# Option 4: IQA-PyTorch toolbox (BRISQUE + NIQE + 30+ other metrics) +pip install pyiqa +``` + +## Python Usage + +```python +# --- BRISQUE via brisque package --- +from brisque import BRISQUE +import cv2 + +brisque = BRISQUE() +img = cv2.imread('photo.jpg') +score = brisque.score(img) # Returns float in [0, 100] +print(f"BRISQUE: {score:.2f}") # Lower = better + +# --- BRISQUE via image-quality package --- +from imquality.brisque import BRISQUE +from PIL import Image + +img = Image.open('photo.jpg') +score = BRISQUE().score(img) # Returns float in [0, 100] + +# --- NIQE via nr-iqa package --- +from nr_iqa import NIQE +import cv2 + +niqe = NIQE() +img = cv2.imread('photo.jpg', cv2.IMREAD_GRAYSCALE) +score = niqe.score(img) # Returns float in [0, 100] +print(f"NIQE: {score:.4f}") # Lower = better + +# --- Both via IQA-PyTorch toolbox --- +import pyiqa +device = 'cuda' if torch.cuda.is_available() else 'cpu' + +brisque = pyiqa.create_metric('brisque', device=device) +niqe = pyiqa.create_metric('niqe', device=device) + +score_b = brisque('./photo.jpg') +score_n = niqe('./photo.jpg') + +print(f"BRISQUE: {score_b:.2f}, NIQE: {score_n:.4f}") + +# Batch processing +import glob +for path in glob.glob('./wedding-photos/*.jpg'): + score = brisque(path) +``` + +## Score Interpretation for Wedding Photos + +| BRISQUE Score | NIQE Score | Interpretation | Recommended Action | +|---------------|-----------|---------------|-------------------| +| 0-10 | 0-3 | Excellent technical quality | Auto-keep | +| 10-25 | 3-6 | Good technical quality | Keep | +| 25-40 | 6-10 | Acceptable, minor issues | Review | +| 40-60 | 10-15 | Poor technical quality | Likely reject | +| 60-100 | 15+ | Very poor (blurry, noisy, compressed) | Auto-reject | + +**Recommended thresholds for wedding culling:** + +- BRISQUE < 30 AND NIQE < 8 -> keep (good technical quality) +- BRISQUE > 60 OR NIQE > 15 -> reject (poor technical quality) +- In between -> manual review + +## Performance Benchmarks + +| Metric | SRCC vs Human | PLCC vs Human | Notes | +|--------|--------------|--------------|-------| +| BRISQUE (TID2008) | ~0.85 | ~0.87 | Trained on TID2008 | +| NIQE (KADID-10k) | ~0.83 | ~0.85 | Opinion-unaware, generalizes well | +| BRISQUE (live) | ~0.81 | ~0.83 | Live (no-reference) dataset | +| NIQE (live) | ~0.80 | ~0.82 | Live (no-reference) dataset | + +## Hardware Requirements + +- **CPU-only:** No GPU needed — purely classical (no neural networks) +- **Speed per image:** ~50-200ms per image on modern CPU (single-core) +- **Speed for 4,000 images:** ~3-15 minutes on CPU (single-core, depends on image size) +- **Multi-threading:** Can parallelize across CPU cores — ~1-3 minutes with 8+ cores +- **VRAM:** None required (BRISQUE/NIQE are pure NumPy/OpenCV) +- **Memory usage:** ~50-100 MB RAM for batch processing + +## What It's Good At + +- **No GPU needed:** Runs on any CPU, even low-end machines +- **Fast:** Good for quick technical quality screening — faster than any deep learning model +- **Detects blur and compression artifacts:** Excellent at detecting out-of-focus shots, motion blur, JPEG artifacts, noise +- **Simple:** Minimal dependencies (OpenCV + NumPy + SciPy) +- **Deterministic:** No randomness — same image always gets same score +- **Lightweight:** ~50 MB total install size, no model weights to download +- **Good complement to aesthetic models:** Catches technical rejects that aesthetic models miss +- **Works on any image type:** Not biased toward any particular visual style + +## What It's Bad At + +- **Purely technical:** Scores only technical quality (blur, noise, compression), not aesthetics at all +- **Poor on artistic/creative images:** Penalizes artistic choices — shallow depth of field, high contrast, film grain, vignette, intentional blur +- **Outdated methodology:** 2012-2013 techniques; inferior to modern deep learning approaches for complex distortions +- **Sensitive to intentional artistic effects:** May score a portrait with beautiful bokeh (shallow DOF) as "low quality" +- **Color-blind:** BRISQUE operates on luminance channel; NIQE on grayscale — ignores color information entirely +- **Not suitable as sole scorer:** Must be combined with an aesthetic model for wedding photo sorting +- **No multi-scale like MUSIQ:** Only processes at one scale (BRISQUE does 2-scale but simpler than MUSIQ's 3-scale) +- **Training data limitations:** BRISQUE trained on TID2008 (lab-distorted images); may not generalize well to real-world wedding photo distortions diff --git a/docs/research-commercial-tools.md b/docs/research-commercial-tools.md new file mode 100644 index 0000000..90fd40b --- /dev/null +++ b/docs/research-commercial-tools.md @@ -0,0 +1,29 @@ +# Commercial AI Photo Culling Tools — For Reference + +These are not self-hosted but represent the state-of-the-art in wedding photo culling. + +## FilterPixel DeepCull (2026) + +- **Type:** Cloud-based, genre-specific AI +- **Speed:** 2:58 for 3,000 photos (cloud) +- **Features:** 10-parameter scoring (emotion, sharpness, composition, storytelling, etc.), memory-based learning, genre-specific models +- **Accuracy:** 85-95% agreement after training on user's past culling decisions +- **Price:** $9.99-$19/month +- **Best for:** Professional photographers needing same-day delivery + +## Aftershoot + +- **Type:** Local desktop application +- **Speed:** 8-12 minutes for 1,000 images (local) +- **Features:** Duplicate detection, blink detection, blur detection, highlight selection, genre selection (portrait/wedding/events) +- **Learning:** Adapts to user's editing style over time +- **Price:** $15/month +- **Best for:** Wedding photographers with multi-day turnaround + +## Imagen AI + +- **Type:** Cloud-based editing + culling +- **Accuracy:** 63.4% (noted as lower than FilterPixel/Aftershoot in independent testing) +- **Features:** Culling studio, AI editing, pay-per-image pricing +- **Price:** Pay per image (expensive at scale) +- **Best for:** Photographers already using Imagen for editing diff --git a/docs/research-iqa-pytorch.md b/docs/research-iqa-pytorch.md new file mode 100644 index 0000000..b389212 --- /dev/null +++ b/docs/research-iqa-pytorch.md @@ -0,0 +1,65 @@ +# IQA-PyTorch Toolbox — All-in-One + +## Model Info + +- **GitHub:** +- **PyPI:** `pip install pyiqa` +- **License:** Apache 2.0 +- **Contents:** 30+ IQA metrics including MUSIQ, TOPIQ, BRISQUE, NIQE, LPIPS, FID, NIMA, DBCNN, CLIP-IQA, LIQE, Q-Align (Q-ReAlign), and more +- **Latest updates (2026):** + - Jun 2026: Added Q-ReAlign (Qwen3.5-VL backbone) — 3 sizes: mini (0.8B), lite (4B), pro (9B) + - May 2026: Added FGResQ + - Dec 2025: Added DMM, MACLIP, AFINE + - Jan 2025: Added QualiCLIP variants +- **Results calibrated:** Against official MATLAB scripts when available +- **GPU accelerated:** PyTorch implementations are faster than MATLAB counterparts + +## Why It Matters + +This toolbox lets you test multiple IQA models on your wedding photos without installing each separately. Useful for benchmarking and comparison. It's the single easiest way to run BRISQUE + NIQE + MUSIQ + other metrics in one codebase. + +```bash +# List all available metrics +pyiqa -ls + +# Test multiple metrics on a directory +pyiqa musiq niqe brisque -t ./wedding-photos/ --device cuda + +# Python API — all metrics in one import +import pyiqa +import torch + +device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu") + +# Create any metric +brisque = pyiqa.create_metric('brisque', device=device) +musiq = pyiqa.create_metric('musiq', device=device) +niqe = pyiqa.create_metric('niqe', device=device) + +# Check scoring direction +print(f"BRISQUE lower_better: {brisque.lower_better}") # True +print(f"MUSIQ lower_better: {musiq.lower_better}") # False (higher = better) +print(f"NIQE lower_better: {niqe.lower_better}") # True + +# Batch inference +import glob +for path in glob.glob('./wedding-photos/*.jpg'): + score_b = brisque(path) + score_m = musiq(path) + score_n = niqe(path) +``` + +## Available Metrics (relevant to wedding photo sorting) + +| Metric | Type | Score Direction | GPU Required | Notes | +|--------|------|----------------|--------------|-------| +| brisque | NR-IQA | Lower better | No (CPU) | Blur/noise detection | +| niqe | NR-IQA | Lower better | No (CPU) | Blur/noise detection, opinion-unaware | +| musiq | NR-IQA (Transformer) | Higher better | Yes (~4GB) | Aesthetic + technical combined | +| toqip | NR-IQA (Transformer) | Higher better | Yes (~2GB) | Modern, self-supervised | +| clip_iqa | NR-IQA (CLIP) | Higher better | Yes (~2GB) | CLIP-based aesthetic | +| liqe | NR-IQA (CLIP) | Higher better | Yes (~2GB) | CLIP-based quality | +| lpips | FR-IQA (Perceptual) | Lower better | Yes (~1GB) | Needs reference image | +| fid | FR-IQA (Distribution) | Lower better | Yes (~5GB) | Needs reference dataset | +| nima | NR-IQA (CNN) | Higher better | Yes (~1GB) | Neural IMage Assessment | +| dbcnn | FR-IQA (CNN) | Higher better | Yes (~2GB) | Needs reference image | diff --git a/docs/research-laion.md b/docs/research-laion.md new file mode 100644 index 0000000..4f74646 --- /dev/null +++ b/docs/research-laion.md @@ -0,0 +1,93 @@ +# LAION Aesthetic Predictor — Current State + +## Model Info + +- **GitHub (V1):** +- **GitHub (V2/Improved):** +- **HuggingFace:** `shunk031/aesthetics-predictor-v1-vit-large-patch14`, `shunk031/aesthetics-predictor-v2-vit-large-patch14` +- **PyPI:** `simple-aesthetics-predictor` (wrapper), `aesthetic-predictor` (direct) +- **Creator:** Christoph Schuhmann / LAION +- **License:** MIT (model weights) +- **Last Update:** V2 released August 2022; no V3 released as of August 2026 +- **Status:** **No newer version has been released.** LAION has not published a V3 or updated model (confirmed by CMU audit paper, FAccT 2026) + +## Architecture + +- **V1:** Linear layer (768->1 for ViT-L/14, 512->1 for ViT-B/32) on top of CLIP image embeddings +- **V2:** Linear model on top of CLIP ViT-L/14 embeddings (despite testing MLPs with ReLU — the linear model was subjectively preferred for visual ranking quality) +- **Input:** CLIP image embeddings (not raw pixels) — must run CLIP encoder first, then the aesthetic head +- **Output:** Single float score (0-10 scale) +- **Training Data (V2):** + - SAC (Simulacra Aesthetic Captions): ~176,000 AI-generated image ratings + - LAION-Logos: 15,000 logo image-text pairs with aesthetic ratings + - AVA (Aesthetic Visual Analysis): 250,000 professional photos with 5-10 human ratings each +- **CLIP Version:** OpenAI CLIP ViT-L/14 (2021 model) + +## Variants + +| Variant | CLIP Backbone | Head | Score Range | Notes | +|---------|--------------|------|-------------|-------| +| V1 (ViT-L/14) | ViT-L/14 (768d) | Linear 768->1 | 0-10 | Original, trained on 5,000 SAC samples | +| V1 (ViT-B/32) | ViT-B/32 (512d) | Linear 512->1 | 0-10 | Faster, lower capacity | +| V2 (ViT-L/14) | ViT-L/14 (768d) | Linear 768->1 | 0-10 | Trained on SAC + LAION-Logos + AVA; current best | + +## Hardware Requirements + +- **CLIP ViT-L/14 encoder:** ~1.8 GB VRAM (FP16) +- **Aesthetic linear head:** Negligible +- **Total per image:** ~2 GB VRAM +- **Batch size:** Can handle ~32-64 images per batch on a 24 GB GPU (RTX 4090) +- **CPU inference:** Possible but slow (~1-2 seconds per image on modern CPU) +- **Estimated time for 4,000 images:** ~5-10 minutes on GPU, ~1-2 hours on CPU + +## Python Usage + +```python +# Via simple-aesthetics-predictor (HuggingFace Transformers-style API) +from simple_aesthetics_predictor import AestheticsPredictorV1 +from transformers import CLIPProcessor +import torch + +model = AestheticsPredictorV1.from_pretrained("shunk031/aesthetics-predictor-v2-vit-large-patch14") +processor = CLIPProcessor.from_pretrained("shunk031/aesthetics-predictor-v2-vit-large-patch14") + +# Batch inference +scores = model.predict(images, processor) # Returns list of scores +``` + +```python +# Via direct aesthetic-predictor package +from aesthetic_predictor import predict_aesthetic +from PIL import Image + +score = predict_aesthetic(Image.open("photo.jpg")) # Returns float score +``` + +## What It's Good At + +- **Speed:** Extremely fast — linear head on pre-computed embeddings +- **Simplicity:** One-line inference with HuggingFace transformers +- **Open source:** MIT license, no commercial restrictions +- **Lightweight:** ~2 GB VRAM, fits on consumer GPUs +- **Good general aesthetic ranking:** Trained on diverse datasets (AI-generated + professional photos) +- **Works on real photography:** AVA dataset contains 250K professional photos, so it generalizes to wedding photos reasonably well + +## What It's Bad At + +- **No technical quality detection:** Does NOT detect blur, motion blur, out-of-focus, exposure issues, noise, or composition problems. It scores "aesthetic appeal," not "technical correctness." +- **Trained heavily on AI-generated images:** 176K of 441K training samples are from SAC (AI-generated). This may bias scoring toward AI-aesthetic patterns (smooth gradients, saturated colors, idealized lighting) rather than genuine photography. +- **Western/art-historical bias:** CMU FAccT 2026 audit found LAP disproportionately favors landscapes, cityscapes, portraits from Western and Japanese artists. Reinforces "imperial and male gazes" from Western art history. May underrate candid moments, cultural ceremonies, and documentary-style wedding shots. +- **No genre awareness:** Treats all images the same. A technically perfect but emotionally empty portrait scores the same as a technically imperfect but emotionally powerful candid. +- **No context understanding:** Cannot distinguish between a "keep" (peak moment, emotional reaction) and a "reject" (duplicate, transitional moment, empty frame). +- **No blink/closed-eye detection:** Irrelevant for wedding photo sorting where you need to filter technical rejects. +- **No duplicate detection:** Cannot identify burst sequences or near-duplicates. +- **Score calibration:** Scores are relative rankings, not absolute quality measures. A score of 7 on one image doesn't mean the same as 7 on another. + +## Bias Concerns (FAccT 2026 Audit) + +A rigorous academic audit (CMU, published at FAccT 2026) found: + +- **Gender bias:** Disproportionately filters in images with captions mentioning women; filters out images with captions mentioning men or LGBTQ+ people +- **Cultural bias:** Rates realistic images of landscapes, cityscapes, and portraits from Western and Japanese artists most highly +- **Training data bias:** Aesthetic scores used to train LAP primarily come from English-speaking photographers and Western AI-enthusiasts +- **No V3 released:** As of January 2026, LAION has not released an updated model to address these concerns diff --git a/docs/research-musiq.md b/docs/research-musiq.md new file mode 100644 index 0000000..283e024 --- /dev/null +++ b/docs/research-musiq.md @@ -0,0 +1,126 @@ +# MUSIQ — Multi-Scale Image Quality Transformer + +## Model Info + +- **Paper:** ICCV 2021 — "MUSIQ: Multi-Scale Image Quality Transformer" (Ke et al.) +- **Source:** Google Research (Pengchuan Zhang, Xiujun Li, Ping Luo, Kai Wang, Yu-Xiong Wang) +- **Official Google Repo:** +- **Google Blog:** +- **TensorFlow Hub:** Models available at `tfhub.dev` (official hosted checkpoints) +- **PyPI (IQA-PyTorch toolbox):** `pip install pyiqa` -> `pyiqa create_metric 'musiq'` +- **PyTorch unofficial impl:** (PyTorch, works on KonIQ-10k) +- **License:** Apache 2.0 (via IQA-PyTorch reimplementation) +- **Status:** Actively maintained in IQA-PyTorch; official TensorFlow code archived but functional + +## Architecture + +- **Type:** Patch-based Vision Transformer (ViT) with multi-scale input +- **Backbone:** ResNet50 (ImageNet-pretrained) for feature extraction -> Transformer encoder for aggregation +- **Multi-scale processing:** Processes image at 3 scales simultaneously — native resolution, 224x224, and 384x384 +- **Input:** Full-resolution images (no fixed-size constraint) — handles any aspect ratio natively +- **Output:** Single float score 0-100 (technical + aesthetic combined) +- **Training checkpoints:** + - `ava_ckpt.npz` — trained on AVA dataset (aesthetic quality) + - `koniq_ckpt.npz` — trained on KonIQ-10k (technical quality) + - `paq2piq_ckpt.npz` — trained on PaQ-2-PiQ (technical quality) + - `spaq_ckpt.npz` — trained on SPAQ (technical quality) + - `imagenet_pretrain.npz` — ImageNet pretraining only +- **Key innovation:** Patch-based design bypasses CNN fixed-size constraint; hash-based 2D spatial embedding + scale embedding for positional encoding +- **MUSIQ-single variant:** Processes only native resolution (faster, slightly lower accuracy) + +## Installation + +```bash +# Option 1: IQA-PyTorch toolbox (recommended — easiest, supports all metrics) +pip install pyiqa + +# Option 2: Official TensorFlow code +git clone https://github.com/google-research/google-research.git +cd google-research/musiq +pip install -r requirements.txt + +# Option 3: Unofficial PyTorch implementation +git clone https://github.com/anse3832/MUSIQ.git +cd MUSIQ +pip install torch torchvision einops scipy tqdm +``` + +## Python Usage + +```python +# Via IQA-PyTorch (recommended — single API for all metrics) +import pyiqa +import torch + +device = torch.device("cuda") if torch.cuda.is_available() else torch.device("cpu") + +# Create MUSIQ metric (auto-downloads checkpoint on first use) +musiq = pyiqa.create_metric('musiq', device=device) + +# Single image +score = musiq('./photo.jpg') +print(f"MUSIQ score: {score:.2f} / 100") + +# Directory of images +import glob +scores = [] +for path in glob.glob('./wedding-photos/*.jpg'): + scores.append(musiq(path)) +``` + +```python +# Via official TensorFlow code +cd google-research/musiq +python3 -m musiq.run_predict_image \ + --ckpt_path=/path/to/spaq_ckpt.npz \ + --image_path=/path/to/photo.jpg +``` + +## Performance Benchmarks (from IQA-PyTorch docs & Google Research) + +| Dataset | SRCC (rank correlation) | PLCC (Pearson correlation) | +|---------|------------------------|---------------------------| +| KonIQ-10k | ~0.90 | ~0.92 | +| PaQ-2-PiQ | ~0.85 | ~0.87 | +| SPAQ | ~0.88 | ~0.90 | +| AVA | ~0.75 | ~0.78 (lower — aesthetic-only dataset) | + +## Hardware Requirements + +- **VRAM:** ~3-4 GB (FP16), ~6-8 GB (FP32) +- **Batch size:** ~8-16 on 24 GB GPU (RTX 4090) +- **Speed per image:** ~50-150ms on GPU (FP16), ~200-500ms on CPU +- **Estimated time for 4,000 images:** ~15-25 minutes on GPU (FP16, batch 16) +- **Native resolution handling:** No resizing needed — processes full-resolution images directly + +## Score Interpretation for Wedding Photos + +| Score Range | Interpretation | Recommended Action | +|-------------|---------------|-------------------| +| 85-100 | Excellent quality | Auto-keep | +| 70-84 | Good quality | Keep, manual review if borderline | +| 50-69 | Acceptable | Review — may have minor issues | +| 30-49 | Poor quality | Likely reject | +| 0-29 | Very poor | Auto-reject | + +**Recommended threshold for wedding culling:** Score >= 50 (i.e., not in the bottom third). This typically reduces 4,000 images to ~800-1,200 for manual review. + +## What It's Good At + +- **Technical quality detection:** Better than LAION predictors at detecting blur, noise, compression artifacts, over/under exposure +- **Full-resolution input:** No resizing artifacts — important for wedding photos with varying aspect ratios (portrait, landscape, square) +- **Combined aesthetic + technical scoring:** Not just "pretty" but also "technically sound" — single model for both dimensions +- **Google-backed research:** Well-documented, peer-reviewed, actively maintained in IQA-PyTorch +- **Apache 2.0 license:** Fully permissive for any use +- **Multiple training checkpoints:** Can choose checkpoint based on use case (aesthetic vs. technical focus) +- **Handles diverse aspect ratios:** Native resolution processing means no distortion from forced resizing + +## What It's Bad At + +- **Still no genre awareness:** General-purpose model, not wedding-specific +- **No context understanding:** Same as LAION — cannot distinguish emotional moments from empty frames +- **Slower than CLIP-based:** Transformer architecture is heavier (~50-150ms vs ~10-30ms for LAION V2) +- **Score range 0-100:** Different from LAION's 0-10 scale (requires threshold adjustment if combining) +- **Limited community adoption:** Less community testing and discussion compared to LAION predictors +- **Checkpoint selection matters:** Different checkpoints optimized for different datasets — no single "best" checkpoint for all use cases +- **TF/HF ecosystem split:** Official code is TensorFlow; PyTorch users need IQA-PyTorch or unofficial impl diff --git a/docs/research-overview.md b/docs/research-overview.md new file mode 100644 index 0000000..7ea02c2 --- /dev/null +++ b/docs/research-overview.md @@ -0,0 +1,40 @@ +# Research — Overview & Use Case Context + +**Date:** 2026-08-23 +**Source:** GitHub, HuggingFace, arXiv, LAION Blog, PyPI, Commercial Tool Benchmarks, Reddit, IQA Toolboxes + +## Use Case Context + +Sorting ~4,000 wedding photos — need to separate keeps from rejects based on aesthetic/technical quality. Requirements: batch processing, self-hosted or lightweight, no cloud dependency, fast inference on 4,000 images. + +## Models Covered + +1. **LAION Aesthetic Predictor** — CLIP-based aesthetic scoring (V1 & V2) +2. **SigLIP-Based Aesthetic Predictor V2.5** — SigLIP alternative to OpenAI CLIP +3. **MUSIQ** — Multi-Scale Image Quality Transformer (Google Research) +4. **Q-Align** — Qwen-based Visual Scorer +5. **BRISQUE / NIQE** — Classical no-reference IQA (CPU-only) +6. **Commercial Tools** — FilterPixel, Aftershoot, Imagen AI (for reference) +7. **IQA-PyTorch Toolbox** — All-in-one metric library + +## Key Takeaways + +1. **LAION Aesthetic Predictor V2 is the simplest starting point** — one-line Python API, MIT license, ~2 GB VRAM, fast inference. But it scores "aesthetic appeal," not "technical quality." It will NOT detect blur, out-of-focus shots, or bad exposure. + +2. **Best approach: Combine models.** Use BRISQUE/NIQE for technical quality (blur/noise detection) + LAION V2 or MUSIQ for aesthetic scoring. A photo should be kept only if it passes BOTH thresholds. + +3. **MUSIQ is the best single-model option** if you want one model that captures both aesthetic and technical quality. It's from Google Research, handles full-resolution images, and has Apache 2.0 license. + +4. **SigLIP V2.5 is a modest improvement over LAION V2** — better on illustrations/art, but same fundamental limitations for real photography. Worth trying if you have BF16 GPU support. + +5. **No open-source model understands wedding context.** None of these can distinguish a "peak moment" from a "transitional shot" or detect emotional content. For that, you need commercial tools like FilterPixel DeepCull (genre-specific AI) or Aftershoot (learning-based). + +6. **For 4,000 wedding photos on a consumer GPU (RTX 4090):** + - LAION V2 batch: ~5-10 minutes + - MUSIQ batch: ~15-20 minutes + - BRISQUE (CPU): ~10-20 minutes + - Combined approach (LAION + BRISQUE): ~15-25 minutes total + +7. **Bias warning:** LAION V2 has documented Western/cultural bias. For culturally diverse weddings (e.g., Indonesian, South Asian, African), scores may not accurately reflect the quality of ceremony shots, cultural attire, or traditional poses. + +8. **Practical recommendation:** Start with LAION V2 + BRISQUE as a first-pass filter. Score all 4,000 images, set thresholds (e.g., LAION > 5.5 AND BRISQUE < 30), and manually review the borderline cases. This reduces 4,000 images to ~500-800 for manual review. diff --git a/docs/research-q-align.md b/docs/research-q-align.md new file mode 100644 index 0000000..8641ef7 --- /dev/null +++ b/docs/research-q-align.md @@ -0,0 +1,37 @@ +# Q-Align — Qwen-based Visual Scorer + +## Model Info + +- **GitHub:** +- **Paper:** ICML 2024 — "Teaching LMMs for Visual Scoring via Discrete Text-Defined Levels" +- **HuggingFace:** Multiple checkpoints available +- **License:** Apache 2.0 +- **Latest:** Q-ReAlign (Qwen3.5-VL backbone) — June 2026 update in IQA-PyTorch + +## Architecture + +- **Type:** Vision-Language Model (VLM) fine-tuned for visual scoring +- **Backbone:** Qwen LLM + vision encoder +- **Output:** Discrete score levels (text-defined) +- **Innovation:** Uses reinforcement learning to align model scoring with human perception + +## Hardware Requirements + +- **Mini (0.8B):** ~3 GB VRAM +- **Lite (4B):** ~10 GB VRAM +- **Pro (9B):** ~20 GB VRAM +- **Batch size:** 1-4 (VLMs are memory-intensive) + +## What It's Good At + +- **Human-aligned scoring:** Trained to match human subjective ratings +- **Explainable:** Can generate text descriptions of why an image scored high/low +- **Multi-task:** Can do IQA, IAA (Image Aesthetic Assessment), and VQA +- **Fine-tunable:** Can be adapted to specific domains (e.g., wedding photos) + +## What It's Bad At + +- **Slower inference:** VLM architecture is significantly slower than CLIP-based models +- **Higher VRAM:** Even the mini variant needs 3 GB; not ideal for batch processing thousands of images +- **Still no wedding-specific training:** General-purpose model +- **Complex setup:** Requires more dependencies and configuration than LAION predictors diff --git a/docs/research-siglip-v2-5.md b/docs/research-siglip-v2-5.md new file mode 100644 index 0000000..4092017 --- /dev/null +++ b/docs/research-siglip-v2-5.md @@ -0,0 +1,55 @@ +# SigLIP-Based Aesthetic Predictor V2.5 + +## Model Info + +- **GitHub:** +- **PyPI:** `aesthetic-predictor-v2-5` +- **License:** MIT +- **Updated:** December 2024 +- **Improvement over V2:** Uses SigLIP (Google's CLIP alternative) instead of OpenAI CLIP; better at illustrations and diverse image domains + +## Architecture + +- **Backbone:** SigLIP (Google) — superior to OpenAI CLIP on many benchmarks +- **Head:** Linear layer on SigLIP embeddings +- **Output:** Single float score (0-10 scale) +- **Score threshold:** 5.5+ is considered "great" (vs. 6+ for V2) + +## Hardware Requirements + +- **SigLIP ViT:** ~2-3 GB VRAM (slightly larger than CLIP ViT-L/14) +- **Precision:** Requires BF16 for best results +- **Batch size:** ~16-32 on 24 GB GPU + +## Python Usage + +```python +from aesthetic_predictor_v2_5 import convert_v2_5_from_siglip +from PIL import Image +import torch + +model, preprocessor = convert_v2_5_from_siglip(low_cpu_mem_usage=True) +model = model.to(torch.bfloat16).cuda() + +image = Image.open("photo.jpg").convert("RGB") +pixel_values = preprocessor(images=image, return_tensors="pt").pixel_values.to(torch.bfloat16).cuda() + +with torch.inference_mode(): + score = model(pixel_values).logits.squeeze().float().cpu().numpy() + +print(f"Aesthetics score: {score:.2f}") +``` + +## What It's Good At + +- **Better than V2 on illustrations/art:** SigLIP trained on larger, more diverse dataset +- **Same simplicity:** One-line HuggingFace-style API +- **Same speed:** Linear head on embeddings +- **Slightly better generalization:** SigLIP's training data is more diverse than OpenAI CLIP's + +## What It's Bad At + +- **Same fundamental limitations as V2:** No technical quality detection, no blur/exposure detection, no genre awareness +- **Still not trained on real photography:** Same bias toward AI-generated aesthetic patterns +- **BF16 requirement:** Needs GPU with BF16 support (RTX 30-series+); not ideal for older hardware +- **Community model:** Not an official LAION release; maintained by a third party diff --git a/docs/research-summary.md b/docs/research-summary.md new file mode 100644 index 0000000..983d12a --- /dev/null +++ b/docs/research-summary.md @@ -0,0 +1,40 @@ +# Alternatives Comparison & Summary + +## Comparison Table + +| Model | Type | Score Scale | Technical Quality | Aesthetic Quality | GPU Required | Speed (4K images) | Self-Hosted | License | Wedding-Specific | +|-------|------|-------------|-------------------|-------------------|--------------|-------------------|-------------|---------|------------------| +| **LAION V2 (CLIP)** | CLIP+Linear | 0-10 | No | Basic | Yes (~2GB) | ~5-10 min | Yes | MIT | No | +| **SigLIP V2.5** | SigLIP+Linear | 0-10 | No | Better | Yes (~3GB) | ~5-10 min | Yes | MIT | No | +| **MUSIQ** | Transformer | 0-100 | Yes | Combined | Yes (~4GB) | ~15-20 min | Yes | Apache 2.0 | No | +| **Q-Align Mini** | VLM (0.8B) | Text levels | Yes | Human-aligned | Yes (~3GB) | ~30-60 min | Yes | Apache 2.0 | No | +| **BRISQUE** | Handcrafted | Inverted | Yes | No | No (CPU) | ~10-20 min | Yes | Academic | No | +| **NIQE** | Handcrafted | Direct | Yes | No | No (CPU) | ~10-20 min | Yes | Academic | No | +| **FilterPixel DeepCull** | Cloud AI | 10 params | Yes | Genre-aware | N/A | ~5 min | No | Commercial | Yes | +| **Aftershoot** | Desktop AI | Stars | Yes | Genre-aware | Yes | ~10 min | Yes (local) | Commercial | Yes | + +## Key Takeaways for Wedding Photo Sorting + +1. **LAION Aesthetic Predictor V2 is the simplest starting point** — one-line Python API, MIT license, ~2 GB VRAM, fast inference. But it scores "aesthetic appeal," not "technical quality." It will NOT detect blur, out-of-focus shots, or bad exposure. + +2. **Best approach: Combine models.** Use BRISQUE/NIQE for technical quality (blur/noise detection) + LAION V2 or MUSIQ for aesthetic scoring. A photo should be kept only if it passes BOTH thresholds. + +3. **MUSIQ is the best single-model option** if you want one model that captures both aesthetic and technical quality. It's from Google Research, handles full-resolution images, and has Apache 2.0 license. + +4. **SigLIP V2.5 is a modest improvement over LAION V2** — better on illustrations/art, but same fundamental limitations for real photography. Worth trying if you have BF16 GPU support. + +5. **No open-source model understands wedding context.** None of these can distinguish a "peak moment" from a "transitional shot" or detect emotional content. For that, you need commercial tools like FilterPixel DeepCull (genre-specific AI) or Aftershoot (learning-based). + +6. **For 4,000 wedding photos on a consumer GPU (RTX 4090):** + - LAION V2 batch: ~5-10 minutes + - MUSIQ batch: ~15-20 minutes + - BRISQUE (CPU): ~10-20 minutes + - Combined approach (LAION + BRISQUE): ~15-25 minutes total + +7. **Bias warning:** LAION V2 has documented Western/cultural bias. For culturally diverse weddings (e.g., Indonesian, South Asian, African), scores may not accurately reflect the quality of ceremony shots, cultural attire, or traditional poses. + +8. **Practical recommendation:** Start with LAION V2 + BRISQUE as a first-pass filter. Score all 4,000 images, set thresholds (e.g., LAION > 5.5 AND BRISQUE < 30), and manually review the borderline cases. This reduces 4,000 images to ~500-800 for manual review. + +## Update Log + +- **2026-08-23:** Initial research for wedding photo sorting use case (~4,000 images). Covered LAION V1/V2, SigLIP V2.5, MUSIQ, Q-Align, BRISQUE, NIQE, commercial tools (FilterPixel, Aftershoot, Imagen AI), and IQA-PyTorch toolbox. diff --git a/docs/scripts.md b/docs/scripts.md new file mode 100644 index 0000000..7152d21 --- /dev/null +++ b/docs/scripts.md @@ -0,0 +1,85 @@ +# Project Scripts + +## Setup Scripts + +### `setup.bat` (Windows) / `setup.sh` (Linux/Mac) + +Creates a new conda environment and installs all required packages from `requirements.txt`. + +**What it does:** + +1. Checks that conda is installed +2. Creates a conda environment named `photo_judgers` with Python 3.11 +3. Installs all packages from `requirements.txt`: + - `torch`, `torchvision`, `torchaudio` (PyTorch with CUDA 12.1) + - `transformers` (HuggingFace) + - `simple-aesthetics-predictor` (LAION aesthetic predictor) + - `Pillow` (image processing) + - `tqdm` (progress bars) + - `numpy` (numerical operations) + +**Usage:** + +```bash +# Windows +setup.bat + +# Linux/Mac +chmod +x setup.sh +./setup.sh +``` + +## Run Scripts + +### `run.bat` (Windows) / `run.sh` (Linux/Mac) + +Activates the conda environment and runs the scorer. + +**What it does:** + +1. Activates the `photo_judgers` conda environment +2. Runs `python -m src.scorer` with any additional arguments passed through + +**Usage:** + +```bash +# Windows +run.bat + +# Linux/Mac +chmod +x run.sh +./run.sh +``` + +**Optional arguments:** + +- `--input ` — Directory or file to score +- `--output ` — Output JSON file (default: `output.json`) +- `--threshold ` — Score threshold for keep/reject (default: TBD) + +## Project Structure + +``` +photo_judgers/ +├── src/ +│ ├── __init__.py +│ ├── scorer.py # Menu + proxy (entry point) +│ └── scorers/ +│ ├── __init__.py +│ ├── base.py # BaseScorer abstract class +│ └── laion.py # LAION V2 implementation +├── requirements.txt +├── setup.bat / setup.sh +├── run.bat / run.sh +├── output.json # Generated by scorer +├── docs/ +├── test/ +└── .pi/ +``` + +## Adding a New Scorer + +1. Create `src/scorers/.py` with a class that follows the LAION pattern +2. Register it in `src/scorer.py` by adding to the `SCORERS` list +3. Add any new dependencies to `requirements.txt` +4. Update `src/scorers/__init__.py` to export the new class diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8888c31 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +# Photo Judgers — Requirements +# Install in conda env: pip install -r requirements.txt + +# Core dependencies +simple-aesthetics-predictor +transformers +torch +torchvision +torchaudio +Pillow +tqdm +numpy diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..85163ed --- /dev/null +++ b/run.bat @@ -0,0 +1,19 @@ +@echo off +REM Photo Judgers — Run LAION Scorer (Windows) +REM Activates the conda env and runs the scorer + +echo ======================================== +echo Photo Judgers — LAION Scorer +echo ======================================== +echo. + +call conda activate photo_judgers + +echo Running scorer... +echo. + +python -m src.scorer %* + +echo. +echo Done. Check output.json for results. +pause diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..7105dee --- /dev/null +++ b/run.sh @@ -0,0 +1,21 @@ +#!/bin/bash +# Photo Judgers — Run LAION Scorer (Linux/Mac) +# Activates the conda env and runs the scorer + +set -e + +echo "========================================" +echo " Photo Judgers — LAION Scorer" +echo "========================================" +echo "" + +source $(conda info --base)/etc/profile.d/conda.sh +conda activate photo_judgers + +echo "Running scorer..." +echo "" + +python -m src.scorer "$@" + +echo "" +echo "Done. Check output.json for results." diff --git a/scorer.py b/scorer.py new file mode 100644 index 0000000..c28c34c --- /dev/null +++ b/scorer.py @@ -0,0 +1,283 @@ +""" +Photo Judgers — Scorer + +Interactive scorer with menu for selecting which model to run. +Tracks progress by reading existing JSON output to avoid re-scoring. + +Usage: + python scorer.py +""" + +from __future__ import annotations + +import glob +import json +import os +import sys +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from types import NoneType + +# --------------------------------------------------------------------------- +# Scorer Base +# --------------------------------------------------------------------------- + + +class BaseScorer: + """Base class for all scorers.""" + + name: str = "Base" + description: str = "" + + def load(self) -> None: + """Load model weights / dependencies. Override in subclasses.""" + + def score(self, image_path: str) -> dict | None: + """ + Score a single image. + + Returns a dict with scorer-specific fields, or None on failure. + """ + raise NotImplementedError + + def unload(self) -> None: + """Clean up resources. Override in subclasses.""" + + +# --------------------------------------------------------------------------- +# LAION Scorer +# --------------------------------------------------------------------------- + + +class LaionScorer(BaseScorer): + name = "LAION Aesthetic Predictor V2" + description = "CLIP-based aesthetic scoring (0-10 scale)" + + def __init__(self) -> None: + self.model: object = None # type: ignore[assignment] + self.processor: object = None # type: ignore[assignment] + self._version = "v2" + + def load(self) -> None: + try: + import torch # noqa: F401 + from simple_aesthetics_predictor import AestheticsPredictorV1 # noqa: F401 + from transformers import CLIPProcessor # noqa: F401 + + print(" Loading LAION V2 model... (first run downloads ~2GB)") + from simple_aesthetics_predictor import AestheticsPredictorV1 + from transformers import CLIPProcessor + + self.model = AestheticsPredictorV1.from_pretrained( + "shunk031/aesthetics-predictor-v2-vit-large-patch14" + ) + self.processor = CLIPProcessor.from_pretrained( + "shunk031/aesthetics-predictor-v2-vit-large-patch14" + ) + self.model.eval() + print(" Model loaded.") + except ImportError: + print(" ERROR: Required packages not installed.") + print( + " Run: pip install simple-aesthetics-predictor transformers " + "torch torchvision Pillow tqdm" + ) + sys.exit(1) + + def score(self, image_path: str) -> dict | None: + try: + import torch # noqa: F401 + from PIL import Image + + assert self.processor is not None, "Model not loaded. Call load() first." + assert self.model is not None, "Model not loaded. Call load() first." + + image = Image.open(image_path).convert("RGB") + inputs = self.processor(images=image, return_tensors="pt") + + with torch.no_grad(): + outputs = self.model(**inputs) + score = outputs.logits.squeeze().item() + + return { + "laion_score": round(score, 2), + "laion_version": self._version, + } + except Exception as e: + print(f" Warning: Failed to score {image_path}: {e}") + return None + + def unload(self) -> None: + self.model = None # type: ignore[assignment] + self.processor = None # type: ignore[assignment] + + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +SCORERS: list[type[BaseScorer]] = [ + LaionScorer, + # Add new scorers here: + # NimaScorer, + # MusiqScorer, + # BrisqueScorer, +] + + +def show_scorer_menu() -> type[BaseScorer]: + """Display scorer selection menu and return the chosen scorer class.""" + print() + print("=" * 50) + print(" Photo Judgers — Select Scorer") + print("=" * 50) + print() + for i, scorer_cls in enumerate(SCORERS, 1): + print(f" {i}. {scorer_cls.name}") + print(f" {scorer_cls.description}") + print() + + while True: + choice = input(f" Choose [1-{len(SCORERS)}]: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(SCORERS): + return SCORERS[idx] + except ValueError: + pass + print(" Invalid choice. Try again.") + + +def ask_folder() -> str: + """Prompt user for the input folder path.""" + while True: + folder = input("\n Enter folder path to score: ").strip().strip('"\'') + if not folder: + print(" Path cannot be empty.") + continue + if os.path.isdir(folder): + return folder + print(f" Folder not found: {folder}") + + +def ask_output() -> str: + """Prompt user for the output JSON file path.""" + default = "output.json" + answer = input(f"\n Output JSON file [{default}]: ").strip() + return answer if answer else default + + +def load_existing_results(output_path: str) -> dict: + """Load existing results from JSON file. Returns {filepath: result}.""" + if os.path.exists(output_path): + try: + with open(output_path, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return {entry["filepath"]: entry for entry in data} + except (json.JSONDecodeError, OSError) as e: + print(f" Warning: Could not read existing results: {e}") + return {} + + +def get_image_files(folder: str) -> list[str]: + """Get all image files from folder (recursively).""" + extensions = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".webp", ".gif"} + files: list[str] = [] + for ext in extensions: + files.extend(glob.glob(os.path.join(folder, f"**/*{ext}"), recursive=True)) + files.extend(glob.glob(os.path.join(folder, f"*{ext}"))) + return sorted(set(files)) + + +def run_scorer(scorer_cls: type[BaseScorer], folder: str, output_path: str) -> None: + """Run the selected scorer on all images in the folder.""" + print(f"\n Scorer: {scorer_cls.name}") + print(f" Folder: {folder}") + print(f" Output: {output_path}") + print() + + # Load existing results + existing = load_existing_results(output_path) + print(f" Already scored: {len(existing)} images") + + # Get all image files + all_files = get_image_files(folder) + remaining = [f for f in all_files if f not in existing] + print(f" Remaining to score: {len(remaining)} images") + print() + + if not remaining: + print(" Nothing new to score. Done!") + return + + # Load the model + scorer = scorer_cls() + scorer.load() + + # Process images + results = list(existing.values()) # Start with existing + skipped = 0 + + from tqdm import tqdm + + for image_path in tqdm(remaining, desc="Scoring"): + result = scorer.score(image_path) + if result: + entry = { + "filename": os.path.basename(image_path), + "filepath": image_path, + "subtype": scorer_cls.name.lower().replace(" ", "_"), + **result, + "scoring_date": datetime.now(timezone.utc).isoformat(), + } + results.append(entry) + else: + skipped += 1 + + scorer.unload() + + # Save results + try: + with open(output_path, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2) + except OSError as e: + print(f" ERROR: Could not write output file: {e}") + return + + print() + print(f" Scored: {len(remaining) - skipped} images") + if skipped: + print(f" Skipped (failed): {skipped} images") + print(f" Total in output: {len(results)} images") + print(f" Saved to: {output_path}") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + print() + print("=" * 50) + print(" Photo Judgers") + print("=" * 50) + + # Select scorer + scorer_cls = show_scorer_menu() + + # Get folder + folder = ask_folder() + + # Get output file + output_path = ask_output() + + # Run + run_scorer(scorer_cls, folder, output_path) + + +if __name__ == "__main__": + main() diff --git a/setup.bat b/setup.bat new file mode 100644 index 0000000..3d6bfb1 --- /dev/null +++ b/setup.bat @@ -0,0 +1,54 @@ +@echo off +REM Photo Judgers — Conda Environment Setup (Windows) +REM Run this from the project root: photo_judgers\setup.bat + +echo ======================================== +echo Photo Judgers — Setup +echo ======================================== +echo. + +REM Check if conda is available +where conda >nul 2>nul +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: conda not found. Please install Miniconda or Anaconda first. + echo Download from: https://docs.conda.io/en/latest/miniconda.html + pause + exit /b 1 +) + +echo Creating conda environment: photo_judgers +echo Python version: 3.11 +echo. + +REM Create the conda environment +conda create -y -n photo_judgers python=3.11 + +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Failed to create conda environment. + pause + exit /b 1 +) + +echo. +echo Activating environment... +call conda activate photo_judgers + +echo. +echo Installing packages... +echo. + +REM Install from requirements.txt +pip install -r requirements.txt + +echo. +echo ======================================== +echo Setup complete! +echo ======================================== +echo. +echo To activate the environment: +echo conda activate photo_judgers +echo. +echo To run the scorer: +echo run.bat +echo. +pause diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..734dcfe --- /dev/null +++ b/setup.sh @@ -0,0 +1,48 @@ +#!/bin/bash +# Photo Judgers — Conda Environment Setup (Linux/Mac) +# Run this from the project root: ./setup.sh + +set -e + +echo "========================================" +echo " Photo Judgers — Setup" +echo "========================================" +echo "" + +# Check if conda is available +if ! command -v conda &>/dev/null; then + echo "ERROR: conda not found. Please install Miniconda or Anaconda first." + echo "Download from: https://docs.conda.io/en/latest/miniconda.html" + exit 1 +fi + +echo "Creating conda environment: photo_judgers" +echo "Python version: 3.11" +echo "" + +# Create the conda environment +conda create -y -n photo_judgers python=3.11 + +echo "" +echo "Activating environment..." +source $(conda info --base)/etc/profile.d/conda.sh +conda activate photo_judgers + +echo "" +echo "Installing packages..." +echo "" + +# Install from requirements.txt +pip install -r requirements.txt + +echo "" +echo "========================================" +echo " Setup complete!" +echo "========================================" +echo "" +echo "To activate the environment:" +echo " conda activate photo_judgers" +echo "" +echo "To run the scorer:" +echo " ./run.sh" +echo "" diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/scorer.py b/src/scorer.py new file mode 100644 index 0000000..e66eba3 --- /dev/null +++ b/src/scorer.py @@ -0,0 +1,206 @@ +""" +Photo Judgers — Scorer + +Interactive menu + proxy for running scorers. +Tracks progress by reading existing JSON output to avoid re-scoring. + +Usage: + python -m src.scorer +""" + +from __future__ import annotations + +import glob +import json +import os +import sys +from datetime import datetime, timezone + +# Add project root to path so we can import src +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from src.scorers.base import BaseScorer +from src.scorers.laion import LaionScorer + +# --------------------------------------------------------------------------- +# Registry +# --------------------------------------------------------------------------- + +SCORERS: list[type[BaseScorer]] = [ + LaionScorer, + # Add new scorers here: + # NimaScorer, + # MusiqScorer, + # BrisqueScorer, +] + + +# --------------------------------------------------------------------------- +# Menu & Prompts +# --------------------------------------------------------------------------- + + +def show_scorer_menu() -> type[BaseScorer]: + """Display scorer selection menu and return the chosen scorer class.""" + print() + print("=" * 50) + print(" Photo Judgers — Select Scorer") + print("=" * 50) + print() + for i, scorer_cls in enumerate(SCORERS, 1): + print(f" {i}. {scorer_cls.name}") + print(f" {scorer_cls.description}") + print() + + while True: + choice = input(f" Choose [1-{len(SCORERS)}]: ").strip() + try: + idx = int(choice) - 1 + if 0 <= idx < len(SCORERS): + return SCORERS[idx] + except ValueError: + pass + print(" Invalid choice. Try again.") + + +def ask_folder() -> str: + """Prompt user for the input folder path.""" + while True: + folder = input("\n Enter folder path to score: ").strip().strip('"\'') + if not folder: + print(" Path cannot be empty.") + continue + if os.path.isdir(folder): + return folder + print(f" Folder not found: {folder}") + + +def ask_output() -> str: + """Prompt user for the output JSON file path.""" + default = "output.json" + answer = input(f"\n Output JSON file [{default}]: ").strip() + return answer if answer else default + + +# --------------------------------------------------------------------------- +# Progress / IO +# --------------------------------------------------------------------------- + + +def load_existing_results(output_path: str) -> dict: + """Load existing results from JSON file. Returns {filepath: result}.""" + if os.path.exists(output_path): + try: + with open(output_path, encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, list): + return {entry["filepath"]: entry for entry in data} + except (json.JSONDecodeError, OSError) as e: + print(f" Warning: Could not read existing results: {e}") + return {} + + +def get_image_files(folder: str) -> list[str]: + """Get all image files from folder (recursively).""" + extensions = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".webp", ".gif"} + files: list[str] = [] + for ext in extensions: + files.extend(glob.glob(os.path.join(folder, f"**/*{ext}"), recursive=True)) + files.extend(glob.glob(os.path.join(folder, f"*{ext}"))) + return sorted(set(files)) + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + + +def run_scorer(scorer_cls: type, folder: str, output_path: str) -> None: + """Run the selected scorer on all images in the folder.""" + print(f"\n Scorer: {scorer_cls.name}") + print(f" Folder: {folder}") + print(f" Output: {output_path}") + print() + + # Load existing results + existing = load_existing_results(output_path) + print(f" Already scored: {len(existing)} images") + + # Get all image files + all_files = get_image_files(folder) + remaining = [f for f in all_files if f not in existing] + print(f" Remaining to score: {len(remaining)} images") + print() + + if not remaining: + print(" Nothing new to score. Done!") + return + + # Load the model + scorer = scorer_cls() + scorer.load() + + # Process images with tqdm + results = list(existing.values()) # Start with existing + skipped = 0 + + from tqdm import tqdm + + for image_path in tqdm(remaining, desc="Scoring"): + result = scorer.score(image_path) + if result: + entry = { + "filename": os.path.basename(image_path), + "filepath": image_path, + "subtype": scorer_cls.name.lower().replace(" ", "_"), + **result, + "scoring_date": datetime.now(timezone.utc).isoformat(), + } + results.append(entry) + else: + skipped += 1 + + scorer.unload() + + # Save results + try: + with open(output_path, "w", encoding="utf-8") as f: + json.dump(results, f, indent=2) + except OSError as e: + print(f" ERROR: Could not write output file: {e}") + return + + print() + print(f" Scored: {len(remaining) - skipped} images") + if skipped: + print(f" Skipped (failed): {skipped} images") + print(f" Total in output: {len(results)} images") + print(f" Saved to: {output_path}") + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> None: + print() + print("=" * 50) + print(" Photo Judgers") + print("=" * 50) + + # Select scorer + scorer_cls = show_scorer_menu() + + # Get folder + folder = ask_folder() + + # Get output file + output_path = ask_output() + + # Run + run_scorer(scorer_cls, folder, output_path) + + +if __name__ == "__main__": + main() diff --git a/src/scorers/__init__.py b/src/scorers/__init__.py new file mode 100644 index 0000000..df4ffe9 --- /dev/null +++ b/src/scorers/__init__.py @@ -0,0 +1,4 @@ +from src.scorers.base import BaseScorer +from src.scorers.laion import LaionScorer + +__all__ = ["BaseScorer", "LaionScorer"] diff --git a/src/scorers/base.py b/src/scorers/base.py new file mode 100644 index 0000000..7dea73d --- /dev/null +++ b/src/scorers/base.py @@ -0,0 +1,26 @@ +"""Base class for all scorers.""" + +from abc import ABC, abstractmethod +from typing import Optional + + +class BaseScorer(ABC): + """Base class for all scorers.""" + + name: str = "Base" + description: str = "" + + @abstractmethod + def load(self) -> None: + """Load model weights / dependencies. Override in subclasses.""" + + @abstractmethod + def score(self, image_path: str) -> Optional[dict]: + """ + Score a single image. + + Returns a dict with scorer-specific fields, or None on failure. + """ + + def unload(self) -> None: + """Clean up resources. Override in subclasses.""" diff --git a/src/scorers/laion.py b/src/scorers/laion.py new file mode 100644 index 0000000..2b2186b --- /dev/null +++ b/src/scorers/laion.py @@ -0,0 +1,71 @@ +"""LAION Aesthetic Predictor V2 scorer.""" + +from typing import Optional + + +class LaionScorer: + """LAION V2 aesthetic scorer — CLIP-based, 0-10 scale.""" + + name = "LAION Aesthetic Predictor V2" + description = "CLIP-based aesthetic scoring (0-10 scale)" + + def __init__(self) -> None: + self.model = None # type: ignore[assignment] + self.processor = None # type: ignore[assignment] + self._version = "v2" + + def load(self) -> None: + """Load model weights (downloads ~2GB on first run).""" + try: + import torch # noqa: F401 + from simple_aesthetics_predictor import AestheticsPredictorV1 # noqa: F401 + from transformers import CLIPProcessor # noqa: F401 + + print(" Loading LAION V2 model... (first run downloads ~2GB)") + from simple_aesthetics_predictor import AestheticsPredictorV1 + from transformers import CLIPProcessor + + self.model = AestheticsPredictorV1.from_pretrained( + "shunk031/aesthetics-predictor-v2-vit-large-patch14" + ) + self.processor = CLIPProcessor.from_pretrained( + "shunk031/aesthetics-predictor-v2-vit-large-patch14" + ) + self.model.eval() + print(" Model loaded.") + except ImportError: + print(" ERROR: Required packages not installed.") + print( + " Run: pip install simple-aesthetics-predictor transformers " + "torch torchvision Pillow tqdm" + ) + raise + + def score(self, image_path: str) -> Optional[dict]: + """Score a single image. Returns dict with score or None on failure.""" + try: + import torch # noqa: F401 + from PIL import Image + + if self.processor is None or self.model is None: + raise RuntimeError("Model not loaded. Call load() first.") + + image = Image.open(image_path).convert("RGB") + inputs = self.processor(images=image, return_tensors="pt") + + with torch.no_grad(): + outputs = self.model(**inputs) + score = outputs.logits.squeeze().item() + + return { + "laion_score": round(score, 2), + "laion_version": self._version, + } + except Exception as e: + print(f" Warning: Failed to score {image_path}: {e}") + return None + + def unload(self) -> None: + """Clean up model references.""" + self.model = None # type: ignore[assignment] + self.processor = None # type: ignore[assignment]