feat: shortlist builder — read JSON, set thresholds, copy passing photos

This commit is contained in:
Kareem Horstink
2026-08-23 15:43:04 +00:00
parent d218326d47
commit 224a695ac3
3 changed files with 300 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
@echo off
REM Photo Judgers — Shortlist Builder (Windows)
REM Reads output.json, asks for thresholds, copies photos to shortlist folder
echo ========================================
echo Photo Judgers — Shortlist Builder
echo ========================================
echo.
REM Activate conda env
call conda activate photo_judgers
echo Running shortlist builder...
echo.
python -m src.shortlist %*
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Photo Judgers — Shortlist Builder (Linux/Mac)
# Reads output.json, asks for thresholds, copies photos to shortlist folder
set -e
echo "========================================"
echo " Photo Judgers — Shortlist Builder"
echo "========================================"
echo ""
# Activate conda env
source $(conda info --base)/etc/profile.d/conda.sh
conda activate photo_judgers
echo "Running shortlist builder..."
echo ""
python -m src.shortlist "$@"
+265
View File
@@ -0,0 +1,265 @@
"""
Photo Judgers — Shortlist Builder
Reads output.json, asks for thresholds per scorer,
copies photos that pass to a shortlist folder.
Usage:
python -m src.shortlist
"""
from __future__ import annotations
import json
import os
import shutil
import sys
from collections import defaultdict
from pathlib import Path
# Add project root to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# ---------------------------------------------------------------------------
# IO Helpers
# ---------------------------------------------------------------------------
def load_results(output_path: str) -> list[dict]:
"""Load results from JSON file."""
if not os.path.exists(output_path):
print(f" ERROR: File not found: {output_path}")
sys.exit(1)
try:
with open(output_path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, list):
print(" ERROR: Expected a JSON array.")
sys.exit(1)
return data
except json.JSONDecodeError as e:
print(f" ERROR: Invalid JSON: {e}")
sys.exit(1)
def ask_path(prompt: str, default: str = "") -> str:
"""Prompt user for a path."""
while True:
answer = input(f"\n {prompt}").strip().strip('"\'')
if answer:
return answer
if default:
return default
print(" Path cannot be empty.")
# ---------------------------------------------------------------------------
# Display
# ---------------------------------------------------------------------------
def show_summary(results: list[dict]) -> None:
"""Show a summary of scored photos grouped by scorer."""
print()
print("=" * 50)
print(" Scored Photos Summary")
print("=" * 50)
print(f"\n Total scored: {len(results)} images\n")
# Group by scorer (subtype)
by_scoring: dict[str, list[dict]] = defaultdict(list)
for entry in results:
subtype = entry.get("subtype", "unknown")
by_scoring[subtype].append(entry)
for subtype, entries in sorted(by_scoring.items()):
scores = [e.get("laion_score") for e in entries if "laion_score" in e]
if scores:
avg = sum(scores) / len(scores)
mn = min(scores)
mx = max(scores)
print(f" {subtype}:")
print(f" Count: {len(entries)}")
print(f" Score range: {mn:.2f}{mx:.2f}")
print(f" Average: {avg:.2f}")
else:
print(f" {subtype}: {len(entries)} images (no scores)")
print()
# ---------------------------------------------------------------------------
# Thresholds
# ---------------------------------------------------------------------------
def ask_thresholds(results: list[dict]) -> dict[str, float]:
"""Ask user for thresholds per scorer type."""
thresholds: dict[str, float] = {}
# Group by subtype
by_scoring: dict[str, list[dict]] = defaultdict(list)
for entry in results:
subtype = entry.get("subtype", "unknown")
by_scoring[subtype].append(entry)
print()
print("=" * 50)
print(" Thresholds")
print("=" * 50)
print("\n Set thresholds for each scorer type.")
print(" Photos with scores ABOVE the threshold will be shortlisted.")
print(" Press Enter to skip a scorer (won't filter by it).\n")
for subtype in sorted(by_scoring.keys()):
scores = [e.get("laion_score") for e in by_scoring[subtype] if "laion_score" in e]
if scores:
avg = sum(scores) / len(scores)
default_val = f"{avg:.1f}"
else:
default_val = "5.0"
answer = input(f" {subtype} threshold [default: {default_val}]: ").strip()
if answer:
try:
thresholds[subtype] = float(answer)
except ValueError:
print(f" Invalid value, skipping {subtype}.")
else:
if default_val:
try:
thresholds[subtype] = float(default_val)
print(f" Using default: {thresholds[subtype]}")
except ValueError:
print(f" No threshold set for {subtype}.")
return thresholds
# ---------------------------------------------------------------------------
# Filtering & Copying
# ---------------------------------------------------------------------------
def filter_photos(
results: list[dict], thresholds: dict[str, float]
) -> list[dict]:
"""Filter photos that pass all thresholds."""
kept: list[dict] = []
for entry in results:
subtype = entry.get("subtype", "unknown")
score = entry.get("laion_score")
# If no threshold for this subtype, keep it
if subtype not in thresholds:
kept.append(entry)
continue
# If no score, skip
if score is None:
continue
# Check if score meets threshold
if score >= thresholds[subtype]:
kept.append(entry)
return kept
def copy_to_shortlist(
photos: list[dict], shortlist_dir: str, dry_run: bool = False
) -> tuple[int, int]:
"""Copy photos to shortlist directory. Returns (copied, skipped)."""
os.makedirs(shortlist_dir, exist_ok=True)
copied = 0
skipped = 0
for entry in photos:
filepath = entry.get("filepath")
filename = entry.get("filename", os.path.basename(filepath))
if not filepath or not os.path.exists(filepath):
print(f" Warning: File not found: {filepath}")
skipped += 1
continue
dest = os.path.join(shortlist_dir, filename)
# Handle duplicates
if os.path.exists(dest):
base, ext = os.path.splitext(filename)
counter = 1
while os.path.exists(dest):
dest = os.path.join(shortlist_dir, f"{base}_{counter}{ext}")
counter += 1
if not dry_run:
shutil.copy2(filepath, dest)
copied += 1
return copied, skipped
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main() -> None:
print()
print("=" * 50)
print(" Photo Judgers — Shortlist Builder")
print("=" * 50)
# Load results
output_path = ask_path("Output JSON file [output.json]: ", "output.json")
results = load_results(output_path)
# Show summary
show_summary(results)
# Ask for thresholds
thresholds = ask_thresholds(results)
# Ask for shortlist destination
shortlist_dir = ask_path("Shortlist folder: ", "shortlist")
# Confirm
print()
print("=" * 50)
print(" Summary")
print("=" * 50)
print(f" Results file: {output_path}")
print(f" Total scored: {len(results)}")
print(f" Shortlist folder: {shortlist_dir}")
print(f" Thresholds: {thresholds}")
print()
confirm = input(" Continue? [y/N]: ").strip().lower()
if confirm not in ("y", "yes"):
print(" Aborted.")
return
# Filter
kept = filter_photos(results, thresholds)
print(f"\n Photos passing thresholds: {len(kept)}")
if not kept:
print(" No photos pass the thresholds. Nothing to copy.")
return
# Copy
print(f"\n Copying to {shortlist_dir}...")
copied, skipped = copy_to_shortlist(kept, shortlist_dir)
print()
print(f" Copied: {copied} images")
if skipped:
print(f" Skipped: {skipped} images")
print(f" Shortlist saved to: {shortlist_dir}")
if __name__ == "__main__":
main()