"""photo-pipeline: quality scan flow (v4 — calibrated thresholds). Fast PIL-based verdicts written to DB: dark → mean luminance < 40 (downscaled 300px) blurry → edge variance < 200 (downscaled 300px; calibrated on real corpus: median 649, p25 335, so 200 flags the clearly-blurry tail) unreadable → corrupt image Verdicts: delete_candidate (dark/blurry/unreadable) or keep. Written to image_hashes.status + flag_reason. No file moves here. """ import sqlite3 from pathlib import Path from prefect import flow, task from PIL import Image, ImageFilter, ImageStat import photo_db as db DARK_THRESHOLD = 40 # mean luminance below = dark/underexposed BLUR_THRESHOLD = 200.0 # edge variance below = blurry (calibrated) ANALYZE_SIZE = 300 # downscale for analysis (fast, consistent) @task def assess_image(path: str) -> dict: """Fast quality assessment of one image via PIL (downscaled).""" p = Path(path) flags = [] mean_lum = 0.0 var = 0.0 try: with Image.open(p) as im: g = im.convert("L") g.thumbnail((ANALYZE_SIZE, ANALYZE_SIZE)) mean_lum = ImageStat.Stat(g).mean[0] if mean_lum < DARK_THRESHOLD: flags.append("dark") edges = g.filter(ImageFilter.FIND_EDGES) var = ImageStat.Stat(edges).var[0] if var < BLUR_THRESHOLD: flags.append("blurry") except Exception as e: return {"flags": ["unreadable"], "error": str(e)} return {"flags": flags, "mean_lum": round(mean_lum, 1), "edge_var": round(var, 1)} @task def assign_verdicts(source: str = None, limit: int = 1000) -> dict: """Walk scanned DB rows; assign keep/delete_candidate (dark/blurry).""" db.init_db() conn = db.get_db() counts = {"keep": 0, "review": 0, "delete_candidate": 0, "skipped": 0} q = "SELECT sha256, path FROM image_hashes WHERE status='scanned'" params = [] if source: q += " AND source=?" params.append(source) q += " ORDER BY added_at DESC LIMIT ?" params.append(limit) rows = conn.execute(q, params).fetchall() print(f"assign_verdicts: {len(rows)} rows to assess", flush=True) for sha, path in rows: p = Path(path) if not p.exists(): conn.execute("UPDATE image_hashes SET status='missing' WHERE sha256=?", (sha,)) counts["skipped"] += 1 continue try: r = assess_image.fn(path) flags = r["flags"] except Exception as e: counts["skipped"] += 1 continue status = "delete_candidate" if (flags and flags != ["keep"]) else "keep" conn.execute( "UPDATE image_hashes SET status=?, flag_reason=? WHERE sha256=?", (status, ",".join(flags), sha)) counts[status] += 1 conn.commit() conn.close() print(f"assign_verdicts done: {counts}", flush=True) return counts @task def notify_result(counts: dict, base_dir: str): import apprise_helper body = ( f"Quality scan: {base_dir}\n" f"Keep: {counts['keep']} | Review: {counts['review']} | " f"Delete-candidates: {counts['delete_candidate']} | Skipped: {counts['skipped']}\n" f"Review: http://192.168.20.13:8092/review" ) apprise_helper.notify("📸 photo-pipeline quality scan complete", body) @flow(name="photo-quality-scan") def quality_scan(base_dir: str, move: bool = False, notify: bool = True, max_files: int = 0, source: str = "", limit: int = 1000): """Fast quality verdicts (dark/blurry via PIL, downscaled) written to DB.""" result = assign_verdicts(source or None, limit) if notify: notify_result(result, base_dir) return result if __name__ == "__main__": import sys d = sys.argv[1] if len(sys.argv) > 1 else "/mnt/ubuntu_storage_3TB/archive/03_photos/Pictures" src = sys.argv[2] if len(sys.argv) > 2 else "" quality_scan(d, source=src)