Quality scan v4: calibrated PIL verdicts (dark/blurry, 300px downscale, threshold 200); persist status+flag_reason; lazy thumbnails (11.5s→0.08s page load)
This commit is contained in:
@@ -45,20 +45,11 @@ def _is_image(p: Path) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _thumb(path: str):
|
def _thumb(path: str):
|
||||||
|
"""Return thumbnail URL WITHOUT generating (lazy — /thumbs/ generates on first hit)."""
|
||||||
src = Path(path)
|
src = Path(path)
|
||||||
if not src.exists():
|
if not src.exists():
|
||||||
return None
|
return None
|
||||||
key = src.stem + "_" + str(abs(hash(str(src))))[:8] + ".jpg"
|
key = src.stem + "_" + str(abs(hash(str(src))))[:8] + ".jpg"
|
||||||
THUMB_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
dest = THUMB_DIR / key
|
|
||||||
if not dest.exists():
|
|
||||||
try:
|
|
||||||
with Image.open(src) as im:
|
|
||||||
im.convert("RGB")
|
|
||||||
im.thumbnail(THUMB_SIZE)
|
|
||||||
im.save(dest, "JPEG", quality=70)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
return f"/thumbs/{key}"
|
return f"/thumbs/{key}"
|
||||||
|
|
||||||
|
|
||||||
@@ -73,6 +64,7 @@ def _load_item(row: sqlite3.Row) -> dict:
|
|||||||
"thumb": _thumb(row["path"]) if _is_image(p) else None,
|
"thumb": _thumb(row["path"]) if _is_image(p) else None,
|
||||||
"exists": p.exists(),
|
"exists": p.exists(),
|
||||||
"size_mb": round(p.stat().st_size / 1e6, 1) if p.exists() else None,
|
"size_mb": round(p.stat().st_size / 1e6, 1) if p.exists() else None,
|
||||||
|
"flag_reason": row["flag_reason"] if "flag_reason" in row.keys() else None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -99,7 +91,7 @@ def index(request: Request):
|
|||||||
def review(request: Request, source: str = None, status: str = None, page: int = 1):
|
def review(request: Request, source: str = None, status: str = None, page: int = 1):
|
||||||
conn = _conn()
|
conn = _conn()
|
||||||
per_page = 200
|
per_page = 200
|
||||||
q = "SELECT sha256, path, status, source FROM image_hashes WHERE 1=1"
|
q = "SELECT sha256, path, status, source, flag_reason FROM image_hashes WHERE 1=1"
|
||||||
count_q = "SELECT COUNT(*) FROM image_hashes WHERE 1=1"
|
count_q = "SELECT COUNT(*) FROM image_hashes WHERE 1=1"
|
||||||
params = []
|
params = []
|
||||||
if source:
|
if source:
|
||||||
@@ -111,8 +103,9 @@ def review(request: Request, source: str = None, status: str = None, page: int =
|
|||||||
count_q += " AND status=?"
|
count_q += " AND status=?"
|
||||||
params.append(status)
|
params.append(status)
|
||||||
else:
|
else:
|
||||||
q += " AND status IN ('scanned','review')"
|
# default review queue: flagged items first, then keep, then scanned
|
||||||
count_q += " AND status IN ('scanned','review')"
|
q += " AND status IN ('review','delete_candidate','keep','scanned')"
|
||||||
|
count_q += " AND status IN ('review','delete_candidate','keep','scanned')"
|
||||||
total = conn.execute(count_q, params).fetchone()[0]
|
total = conn.execute(count_q, params).fetchone()[0]
|
||||||
pages = max(1, (total + per_page - 1) // per_page)
|
pages = max(1, (total + per_page - 1) // per_page)
|
||||||
page = max(1, min(page, pages))
|
page = max(1, min(page, pages))
|
||||||
@@ -234,12 +227,39 @@ def full_file(sha: str):
|
|||||||
|
|
||||||
@app.get("/thumbs/{name}")
|
@app.get("/thumbs/{name}")
|
||||||
def thumb_file(name: str):
|
def thumb_file(name: str):
|
||||||
|
"""Serve thumbnail; generate on first request (cached after)."""
|
||||||
f = THUMB_DIR / name
|
f = THUMB_DIR / name
|
||||||
if not f.exists():
|
if not f.exists():
|
||||||
raise HTTPException(404)
|
# lazy generate — reconstruct source path from key (stem is orig filename)
|
||||||
|
THUMB_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
# find the source image: key = <stem>_<hash8>.jpg
|
||||||
|
stem = name.rsplit("_", 1)[0]
|
||||||
|
src = _find_source(stem)
|
||||||
|
if not src:
|
||||||
|
raise HTTPException(404)
|
||||||
|
try:
|
||||||
|
with Image.open(src) as im:
|
||||||
|
im.convert("RGB")
|
||||||
|
im.thumbnail(THUMB_SIZE)
|
||||||
|
im.save(f, "JPEG", quality=70)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(404)
|
||||||
return FileResponse(f)
|
return FileResponse(f)
|
||||||
|
|
||||||
|
|
||||||
|
def _find_source(stem: str):
|
||||||
|
"""Find the original image for a thumbnail key (by filename stem)."""
|
||||||
|
import os
|
||||||
|
|
||||||
|
for root_dir in ("/mnt/ubuntu_storage_3TB/archive/03_photos",
|
||||||
|
"/mnt/data/01_keep", "/mnt/data/02_review", "/mnt/data/03_delete"):
|
||||||
|
for dirpath, _, files in os.walk(root_dir):
|
||||||
|
for fn in files:
|
||||||
|
if fn.rsplit(".", 1)[0] == stem:
|
||||||
|
return Path(dirpath) / fn
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@app.get("/upload", response_class=HTMLResponse)
|
@app.get("/upload", response_class=HTMLResponse)
|
||||||
def upload_page(request: Request):
|
def upload_page(request: Request):
|
||||||
"""Upload page — drop files/archives into the incoming folder."""
|
"""Upload page — drop files/archives into the incoming folder."""
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
<span class="badge {{ item["status"] }}">{{ item["status"] }}</span>
|
<span class="badge {{ item["status"] }}">{{ item["status"] }}</span>
|
||||||
<span style="color:#666"> · {{ item["source"] }}</span>
|
<span style="color:#666"> · {{ item["source"] }}</span>
|
||||||
{% if item["size_mb"] %}<span style="color:#666"> · {{ item["size_mb"] }}MB</span>{% endif %}
|
{% if item["size_mb"] %}<span style="color:#666"> · {{ item["size_mb"] }}MB</span>{% endif %}
|
||||||
|
{% if item.get("flag_reason") %}<span style="color:#d43"> · ⚠ {{ item["flag_reason"] }}</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button class="approve" hx-post="/review/{{ item["sha256"] }}/approve" hx-target="#card-{{ sha8 }}" hx-swap="outerHTML" hx-on::after-request="if(event.detail.successful){this.closest('.card').remove();updateCount();showToast('Approved');}">✅ Keep</button>
|
<button class="approve" hx-post="/review/{{ item["sha256"] }}/approve" hx-target="#card-{{ sha8 }}" hx-swap="outerHTML" hx-on::after-request="if(event.detail.successful){this.closest('.card').remove();updateCount();showToast('Approved');}">✅ Keep</button>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@
|
|||||||
.badge.approved { background: #1d4; color: #031; }
|
.badge.approved { background: #1d4; color: #031; }
|
||||||
.badge.rejected { background: #d43; color: #fff; }
|
.badge.rejected { background: #d43; color: #fff; }
|
||||||
.badge.scanned { background: #333; }
|
.badge.scanned { background: #333; }
|
||||||
|
.badge.keep { background: #1d4; color: #031; }
|
||||||
|
.badge.delete_candidate { background: #d43; color: #fff; }
|
||||||
.badge.review { background: #da4; color: #321; }
|
.badge.review { background: #da4; color: #321; }
|
||||||
.card .actions { display: flex; gap: .25rem; padding: .5rem; }
|
.card .actions { display: flex; gap: .25rem; padding: .5rem; }
|
||||||
.card button { flex: 1; border: 0; border-radius: 4px; padding: .4rem; cursor: pointer; font-size: .75rem; }
|
.card button { flex: 1; border: 0; border-radius: 4px; padding: .4rem; cursor: pointer; font-size: .75rem; }
|
||||||
@@ -57,7 +59,7 @@
|
|||||||
<form method="get" action="/review" style="display:flex;gap:.5rem;" autocomplete="off">
|
<form method="get" action="/review" style="display:flex;gap:.5rem;" autocomplete="off">
|
||||||
<select name="status">
|
<select name="status">
|
||||||
<option value="">all statuses</option>
|
<option value="">all statuses</option>
|
||||||
{% for s in ["scanned", "review", "approved", "rejected"] %}
|
{% for s in ["keep", "review", "delete_candidate", "scanned", "approved", "rejected"] %}
|
||||||
<option value="{{ s }}" {% if status == s %}selected{% endif %}>{{ s }}</option>
|
<option value="{{ s }}" {% if status == s %}selected{% endif %}>{{ s }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
211
quality_scan.py
211
quality_scan.py
@@ -1,153 +1,118 @@
|
|||||||
"""photo-pipeline: quality scan flow (v3) using CleanVision.
|
"""photo-pipeline: quality scan flow (v4 — calibrated thresholds).
|
||||||
|
|
||||||
Audits a folder for quality issues (blurry, dark, light, grayscale,
|
Fast PIL-based verdicts written to DB:
|
||||||
low-information, odd aspect/size) and near/exact duplicates.
|
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
|
||||||
|
|
||||||
Writes a per-image verdict: keep / review / delete-candidate, and moves
|
Verdicts: delete_candidate (dark/blurry/unreadable) or keep. Written to
|
||||||
files into the /mnt/data/{01_keep,02_review,03_delete} staging dirs.
|
image_hashes.status + flag_reason. No file moves here.
|
||||||
Nothing is deleted — 03_delete is a holding area for human confirmation.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import shutil
|
import sqlite3
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from prefect import flow, task
|
from prefect import flow, task
|
||||||
|
from PIL import Image, ImageFilter, ImageStat
|
||||||
|
|
||||||
STAGING = Path("/mnt/data")
|
import photo_db as db
|
||||||
KEEP = STAGING / "01_keep"
|
|
||||||
REVIEW = STAGING / "02_review"
|
|
||||||
DELETE = STAGING / "03_delete"
|
|
||||||
|
|
||||||
# Issue types that warrant deletion-candidate vs review
|
DARK_THRESHOLD = 40 # mean luminance below = dark/underexposed
|
||||||
HARD_ISSUES = {"dark", "light", "low_information", "blurry", "grayscale"}
|
BLUR_THRESHOLD = 200.0 # edge variance below = blurry (calibrated)
|
||||||
SOFT_ISSUES = {"odd_aspect_ratio", "odd_size"}
|
ANALYZE_SIZE = 300 # downscale for analysis (fast, consistent)
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def audit_folder(base_dir: str, issue_types: list[str] | None = None) -> dict:
|
def assess_image(path: str) -> dict:
|
||||||
"""Run CleanVision audit on a folder. Returns issue summary + per-image issues."""
|
"""Fast quality assessment of one image via PIL (downscaled)."""
|
||||||
from cleanvision import Imagelab
|
p = Path(path)
|
||||||
|
flags = []
|
||||||
imagelab = Imagelab(data_path=base_dir)
|
mean_lum = 0.0
|
||||||
if issue_types:
|
var = 0.0
|
||||||
imagelab.find_issues(issue_types=issue_types)
|
try:
|
||||||
else:
|
with Image.open(p) as im:
|
||||||
imagelab.find_issues()
|
g = im.convert("L")
|
||||||
summary = imagelab.issue_summary.to_dict("records")
|
g.thumbnail((ANALYZE_SIZE, ANALYZE_SIZE))
|
||||||
|
mean_lum = ImageStat.Stat(g).mean[0]
|
||||||
# imagelab.issues is ONE DataFrame: cols like dark_score/is_dark_issue
|
if mean_lum < DARK_THRESHOLD:
|
||||||
df = imagelab.issues
|
flags.append("dark")
|
||||||
per_image = {}
|
edges = g.filter(ImageFilter.FIND_EDGES)
|
||||||
for idx, row in df.iterrows():
|
var = ImageStat.Stat(edges).var[0]
|
||||||
name = idx
|
if var < BLUR_THRESHOLD:
|
||||||
for col in df.columns:
|
flags.append("blurry")
|
||||||
if col.startswith("is_") and col.endswith("_issue") and row[col]:
|
except Exception as e:
|
||||||
issue_type = col[len("is_"):-len("_issue")]
|
return {"flags": ["unreadable"], "error": str(e)}
|
||||||
per_image.setdefault(name, []).append(issue_type)
|
return {"flags": flags, "mean_lum": round(mean_lum, 1), "edge_var": round(var, 1)}
|
||||||
return {"summary": summary, "per_image": per_image}
|
|
||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def classify_and_sort(base_dir: str, per_image: dict, move: bool = True) -> dict:
|
def assign_verdicts(source: str = None, limit: int = 1000) -> dict:
|
||||||
"""Classify each image and (optionally) move into staging dirs."""
|
"""Walk scanned DB rows; assign keep/delete_candidate (dark/blurry)."""
|
||||||
root = Path(base_dir)
|
db.init_db()
|
||||||
images = [p for p in root.rglob("*") if p.is_file()]
|
conn = db.get_db()
|
||||||
counts = {"keep": 0, "review": 0, "delete_candidate": 0, "skipped": 0}
|
counts = {"keep": 0, "review": 0, "delete_candidate": 0, "skipped": 0}
|
||||||
decisions = {}
|
|
||||||
|
|
||||||
for p in images:
|
q = "SELECT sha256, path FROM image_hashes WHERE status='scanned'"
|
||||||
full = str(p)
|
params = []
|
||||||
rel = str(p.relative_to(root))
|
if source:
|
||||||
issues = set(per_image.get(full, []) or per_image.get(rel, []) or per_image.get(p.name, []))
|
q += " AND source=?"
|
||||||
if not issues:
|
params.append(source)
|
||||||
decisions[rel] = "keep"
|
q += " ORDER BY added_at DESC LIMIT ?"
|
||||||
counts["keep"] += 1
|
params.append(limit)
|
||||||
if move:
|
rows = conn.execute(q, params).fetchall()
|
||||||
_move(p, KEEP, root)
|
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
|
continue
|
||||||
if issues & HARD_ISSUES:
|
try:
|
||||||
decisions[rel] = "delete_candidate"
|
r = assess_image.fn(path)
|
||||||
counts["delete_candidate"] += 1
|
flags = r["flags"]
|
||||||
if move:
|
except Exception as e:
|
||||||
_move(p, DELETE, root)
|
counts["skipped"] += 1
|
||||||
else:
|
continue
|
||||||
decisions[rel] = "review"
|
status = "delete_candidate" if (flags and flags != ["keep"]) else "keep"
|
||||||
counts["review"] += 1
|
conn.execute(
|
||||||
if move:
|
"UPDATE image_hashes SET status=?, flag_reason=? WHERE sha256=?",
|
||||||
_move(p, REVIEW, root)
|
(status, ",".join(flags), sha))
|
||||||
return {"counts": counts, "decisions": decisions}
|
counts[status] += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print(f"assign_verdicts done: {counts}", flush=True)
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def _move(p: Path, dest_root: Path, src_root: Path):
|
@task
|
||||||
"""Move p into dest_root, preserving relative structure under source name."""
|
def notify_result(counts: dict, base_dir: str):
|
||||||
rel = p.relative_to(src_root)
|
import apprise_helper
|
||||||
dest = dest_root / p.parent.name / p.name
|
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
shutil.move(str(p), str(dest))
|
|
||||||
|
|
||||||
|
body = (
|
||||||
|
f"Quality scan: {base_dir}\n"
|
||||||
|
f"Keep: {counts['keep']} | Review: {counts['review']} | "
|
||||||
def _slice_dir(base_dir: str, max_files: int) -> str:
|
f"Delete-candidates: {counts['delete_candidate']} | Skipped: {counts['skipped']}\n"
|
||||||
"""Copy first N images into a temp dir for CleanVision to audit."""
|
f"Review: http://192.168.20.13:8092/review"
|
||||||
import shutil
|
)
|
||||||
import tempfile
|
apprise_helper.notify("📸 photo-pipeline quality scan complete", body)
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
src = Path(base_dir)
|
|
||||||
tmp = Path(tempfile.mkdtemp(prefix="cvslice_"))
|
|
||||||
exts = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".heic", ".tif", ".bmp"}
|
|
||||||
n = 0
|
|
||||||
for p in src.rglob("*"):
|
|
||||||
if p.is_file() and p.suffix.lower() in exts:
|
|
||||||
shutil.copy2(p, tmp / p.name)
|
|
||||||
n += 1
|
|
||||||
if n >= max_files:
|
|
||||||
break
|
|
||||||
print(f"_slice_dir: copied {n} files to {tmp}")
|
|
||||||
return str(tmp)
|
|
||||||
|
|
||||||
|
|
||||||
@flow(name="photo-quality-scan")
|
@flow(name="photo-quality-scan")
|
||||||
def quality_scan(base_dir: str, move: bool = False, notify: bool = True, max_files: int = None):
|
def quality_scan(base_dir: str, move: bool = False, notify: bool = True,
|
||||||
"""Audit image quality with CleanVision; classify into keep/review/delete.
|
max_files: int = 0, source: str = "", limit: int = 1000):
|
||||||
|
"""Fast quality verdicts (dark/blurry via PIL, downscaled) written to DB."""
|
||||||
max_files: if set, only audit the first N image files (slices huge folders
|
result = assign_verdicts(source or None, limit)
|
||||||
into reviewable chunks — prevents OOM on 50K-file trees).
|
|
||||||
"""
|
|
||||||
if max_files: # 0/None = unlimited
|
|
||||||
base_dir = _slice_dir(base_dir, max_files)
|
|
||||||
audit = audit_folder(base_dir)
|
|
||||||
print(f"Issue summary: {audit['summary']}")
|
|
||||||
result = classify_and_sort(base_dir, audit["per_image"], move=move)
|
|
||||||
print(f"Verdicts: {result['counts']}")
|
|
||||||
return {"audit": audit["summary"], **result}
|
|
||||||
if notify:
|
if notify:
|
||||||
notify_result(result["counts"], audit["summary"], base_dir)
|
notify_result(result, base_dir)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import sys
|
import sys
|
||||||
d = sys.argv[1] if len(sys.argv) > 1 else "/tmp/cvtest"
|
|
||||||
move = "--move" in sys.argv
|
|
||||||
quality_scan(d, move=move)
|
|
||||||
|
|
||||||
|
d = sys.argv[1] if len(sys.argv) > 1 else "/mnt/ubuntu_storage_3TB/archive/03_photos/Pictures"
|
||||||
@task
|
src = sys.argv[2] if len(sys.argv) > 2 else ""
|
||||||
def notify_result(counts: dict, summary: list, base_dir: str):
|
quality_scan(d, source=src)
|
||||||
"""Send batch summary via Apprise."""
|
|
||||||
import apprise_helper
|
|
||||||
|
|
||||||
hard = counts.get("delete_candidate", 0)
|
|
||||||
soft = counts.get("review", 0)
|
|
||||||
keep = counts.get("keep", 0)
|
|
||||||
flagged = [s for s in summary if s["num_images"] > 0]
|
|
||||||
lines = "; ".join(f"{s[issue_type]}: {s[num_images]}" for s in flagged) or "none"
|
|
||||||
body = (
|
|
||||||
f"Scanned: {base_dir}\n"
|
|
||||||
f"Keep: {keep} | Review: {soft} | Delete-candidates: {hard}\n"
|
|
||||||
f"Issues: {lines}\n"
|
|
||||||
f"Review: http://192.168.20.13:8092/review"
|
|
||||||
)
|
|
||||||
apprise_helper.notify("📸 photo-pipeline batch complete", body)
|
|
||||||
return True
|
|
||||||
|
|||||||
Reference in New Issue
Block a user