119 lines
3.9 KiB
Python
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)
|