feat: shortlist builder — read JSON, set thresholds, copy passing photos
This commit is contained in:
@@ -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 %*
|
||||||
@@ -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 "$@"
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user