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):
|
||||
"""Return thumbnail URL WITHOUT generating (lazy — /thumbs/ generates on first hit)."""
|
||||
src = Path(path)
|
||||
if not src.exists():
|
||||
return None
|
||||
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}"
|
||||
|
||||
|
||||
@@ -73,6 +64,7 @@ def _load_item(row: sqlite3.Row) -> dict:
|
||||
"thumb": _thumb(row["path"]) if _is_image(p) else None,
|
||||
"exists": p.exists(),
|
||||
"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):
|
||||
conn = _conn()
|
||||
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"
|
||||
params = []
|
||||
if source:
|
||||
@@ -111,8 +103,9 @@ def review(request: Request, source: str = None, status: str = None, page: int =
|
||||
count_q += " AND status=?"
|
||||
params.append(status)
|
||||
else:
|
||||
q += " AND status IN ('scanned','review')"
|
||||
count_q += " AND status IN ('scanned','review')"
|
||||
# default review queue: flagged items first, then keep, then scanned
|
||||
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]
|
||||
pages = max(1, (total + per_page - 1) // per_page)
|
||||
page = max(1, min(page, pages))
|
||||
@@ -234,12 +227,39 @@ def full_file(sha: str):
|
||||
|
||||
@app.get("/thumbs/{name}")
|
||||
def thumb_file(name: str):
|
||||
"""Serve thumbnail; generate on first request (cached after)."""
|
||||
f = THUMB_DIR / name
|
||||
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)
|
||||
|
||||
|
||||
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)
|
||||
def upload_page(request: Request):
|
||||
"""Upload page — drop files/archives into the incoming folder."""
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
<span class="badge {{ item["status"] }}">{{ item["status"] }}</span>
|
||||
<span style="color:#666"> · {{ item["source"] }}</span>
|
||||
{% 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 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>
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
.badge.approved { background: #1d4; color: #031; }
|
||||
.badge.rejected { background: #d43; color: #fff; }
|
||||
.badge.scanned { background: #333; }
|
||||
.badge.keep { background: #1d4; color: #031; }
|
||||
.badge.delete_candidate { background: #d43; color: #fff; }
|
||||
.badge.review { background: #da4; color: #321; }
|
||||
.card .actions { display: flex; gap: .25rem; padding: .5rem; }
|
||||
.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">
|
||||
<select name="status">
|
||||
<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>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
Reference in New Issue
Block a user