Files
photo-pipeline/quality_scan.py

119 lines
3.9 KiB
Python

"""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)