EXIF scanner: multi-photo gallery with GPS detection

This commit is contained in:
2026-05-25 00:32:16 -04:00
commit b079c9bf8f
8 changed files with 598 additions and 0 deletions
Binary file not shown.
+144
View File
@@ -0,0 +1,144 @@
"""EXIF + OCR test backend — validate that GPS survives upload pipeline."""
import io, json, re, uuid
from pathlib import Path
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.staticfiles import StaticFiles
from PIL import Image as PILImage
try:
import pytesseract
HAS_TESSERACT = True
except ImportError:
HAS_TESSERACT = False
UPLOADS = Path(__file__).parent / "uploads"
UPLOADS.mkdir(exist_ok=True)
app = FastAPI(title="EXIF Test")
def _dms_to_decimal(dms, ref):
"""Convert EXIF DMS tuple to decimal degrees."""
try:
deg, minutes, sec = float(dms[0]), float(dms[1]), float(dms[2])
decimal = deg + minutes / 60.0 + sec / 3600.0
if ref in ("S", "W"):
decimal = -decimal
return round(decimal, 7)
except Exception:
return None
def extract_exif(image_bytes: bytes) -> dict:
"""Pull all useful EXIF fields + GPS from raw image bytes."""
result = {"has_exif": False, "tags": {}, "gps": None}
try:
img = PILImage.open(io.BytesIO(image_bytes))
exif = img.getexif()
if not exif:
return result
# Standard EXIF tags
for tag_id, value in exif.items():
tag_name = PILImage.ExifTags.TAGS.get(tag_id, f"0x{tag_id:04x}")
# Skip binary/thumbnail blobs
if tag_name in ("MakerNote", "UserComment", "PrintImageMatching"):
continue
result["tags"][tag_name] = str(value)[:300]
if result["tags"]:
result["has_exif"] = True
# GPS IFD
gps_ifd = exif.get_ifd(0x8825)
if gps_ifd:
lat_ref = gps_ifd.get(1, "N")
lat_dms = gps_ifd.get(2)
lng_ref = gps_ifd.get(3, "E")
lng_dms = gps_ifd.get(4)
if lat_dms and lng_dms:
lat = _dms_to_decimal(lat_dms, lat_ref)
lng = _dms_to_decimal(lng_dms, lng_ref)
if lat is not None and lng is not None:
result["gps"] = {
"lat": lat,
"lng": lng,
"lat_ref": str(lat_ref),
"lng_ref": str(lng_ref),
"raw_lat_dms": [float(v) for v in lat_dms],
"raw_lng_dms": [float(v) for v in lng_dms],
}
except Exception as e:
result["error"] = str(e)
return result
def run_ocr(image_bytes: bytes) -> dict:
"""Run Tesseract OCR on the image."""
if not HAS_TESSERACT:
return {"available": False, "text": "", "error": "pytesseract not installed"}
tmp_path = UPLOADS / f"ocr_{uuid.uuid4().hex}.jpg"
tmp_path.write_bytes(image_bytes)
try:
img = PILImage.open(tmp_path)
img_gray = img.convert("L")
text = pytesseract.image_to_string(img_gray, config="--psm 6")
# Extract machine ID patterns
match_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
match_5plus = re.search(r"(\d{5,})", text)
return {
"available": True,
"raw_text": text.strip()[:500],
"match_5dash6": match_5dash.group(0) if match_5dash else None,
"match_5plus": match_5plus.group(0) if match_5plus else None,
}
finally:
tmp_path.unlink(missing_ok=True)
@app.post("/api/analyze")
async def analyze_photo(file: UploadFile = File(...)):
"""Upload a photo, get back EXIF + OCR results."""
contents = await file.read()
file_size = len(contents)
# Save
ext = Path(file.filename or "photo.jpg").suffix.lower()
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng"}:
ext = ".jpg"
fname = f"{uuid.uuid4().hex}{ext}"
(UPLOADS / fname).write_bytes(contents)
exif_result = extract_exif(contents)
ocr_result = run_ocr(contents) if HAS_TESSERACT else {"available": False, "text": ""}
return {
"filename": file.filename,
"saved_as": fname,
"file_size": file_size,
"file_size_kb": round(file_size / 1024, 1),
"exif": exif_result,
"ocr": ocr_result,
}
@app.get("/api/uploads")
async def list_uploads():
"""List previously uploaded files."""
files = sorted(UPLOADS.glob("*"), key=lambda p: p.stat().st_mtime, reverse=True)
return [
{"name": f.name, "size_kb": round(f.stat().st_size / 1024, 1)}
for f in files[:20]
]
# Mount static LAST
app.mount("/", StaticFiles(directory="static", html=True), name="static")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8903)
+454
View File
@@ -0,0 +1,454 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>EXIF Scanner — Find GPS Photos</title>
<script src="https://cdn.jsdelivr.net/npm/exifr@7/dist/lite.umd.js"></script>
<style>
:root {
--bg: #0f172a; --card: #1e293b; --card2: #334155;
--text: #f1f5f9; --text2: #94a3b8; --text3: #64748b;
--accent: #3b82f6; --accent2: #2563eb; --green: #22c55e;
--amber: #f59e0b; --red: #ef4444; --border: #334155;
--radius: 12px; --radius-sm: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text);
max-width: 480px; margin: 0 auto; padding: 16px;
min-height: 100dvh; -webkit-tap-highlight-color: transparent;
overflow-x: hidden;
}
h1 { font-size: 20px; margin-bottom: 4px; }
.subtitle { font-size: 12px; color: var(--text2); margin-bottom: 16px; }
/* Upload area */
.upload-area {
border: 2px dashed var(--border); border-radius: var(--radius);
padding: 28px 20px; text-align: center; cursor: pointer;
transition: border-color 0.2s; background: var(--card);
margin-bottom: 16px;
}
.upload-area:active { border-color: var(--accent); background: var(--card2); }
.upload-area .icon { font-size: 36px; display: block; margin-bottom: 6px; }
.upload-area .label { font-size: 15px; font-weight: 600; }
.upload-area .hint { font-size: 11px; color: var(--text2); margin-top: 4px; }
/* Summary bar */
#summary {
display: none; background: var(--card); border-radius: var(--radius);
padding: 12px 14px; margin-bottom: 12px;
}
.summary-grid {
display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 4px;
text-align: center;
}
.summary-num { font-size: 24px; font-weight: 800; }
.summary-label { font-size: 10px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.05em; }
.summary-gps { color: var(--green); }
.summary-nogps { color: var(--amber); }
.summary-total { color: var(--text); }
/* Photo grid */
#gallery {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
margin-bottom: 12px;
}
.photo-card {
position: relative; border-radius: var(--radius-sm); overflow: hidden;
aspect-ratio: 1; background: var(--card); cursor: pointer;
border: 2px solid var(--border); transition: border-color 0.15s;
}
.photo-card.selected { border-color: var(--accent); }
.photo-card img {
width: 100%; height: 100%; object-fit: cover; display: block;
}
.photo-card .overlay {
position: absolute; bottom: 0; left: 0; right: 0;
padding: 4px 6px; font-size: 10px; font-weight: 700;
display: flex; align-items: center; gap: 3px;
}
.overlay-gps { background: rgba(34,197,94,0.85); color: #000; }
.overlay-nogps { background: rgba(245,158,11,0.85); color: #000; }
.overlay-scanning { background: rgba(59,130,246,0.85); color: #fff; }
.photo-card .check {
position: absolute; top: 4px; right: 4px;
width: 22px; height: 22px; border-radius: 50%;
background: var(--accent); color: #fff;
display: none; align-items: center; justify-content: center;
font-size: 12px; font-weight: 700;
}
.photo-card.selected .check { display: flex; }
/* Detail card */
#detail {
display: none; background: var(--card); border-radius: var(--radius);
padding: 14px; margin-bottom: 12px;
}
#detailPreview { width: 100%; border-radius: var(--radius-sm); margin-bottom: 10px; }
.detail-section {
font-size: 13px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--text2); margin-bottom: 8px;
display: flex; align-items: center; gap: 6px;
}
.badge {
font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
}
.badge-ok { background: var(--green); color: #000; }
.badge-warn { background: var(--amber); color: #000; }
.badge-fail { background: var(--red); color: #fff; }
.badge-info { background: var(--accent); color: #fff; }
.exif-row {
display: flex; justify-content: space-between; padding: 6px 0;
border-bottom: 1px solid var(--border); font-size: 13px;
}
.exif-row:last-child { border-bottom: none; }
.exif-key { color: var(--text2); flex-shrink: 0; margin-right: 12px; }
.exif-val { color: var(--text); text-align: right; word-break: break-all; }
.gps-coords {
font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 16px; font-weight: 700; color: var(--green);
text-align: center; padding: 12px; background: var(--card2);
border-radius: var(--radius-sm); margin: 8px 0;
}
/* Buttons */
.btn {
display: block; width: 100%; padding: 14px; font-size: 15px;
font-weight: 600; border: none; border-radius: var(--radius-sm);
cursor: pointer; transition: opacity 0.15s;
-webkit-tap-highlight-color: transparent;
}
.btn:active { opacity: 0.8; }
.btn-primary { background: var(--accent); color: #fff; }
.btn-outline { background: transparent; border: 2px solid var(--border); color: var(--text); }
.btn-danger { background: transparent; border: 2px solid var(--red); color: var(--red); }
.btn:disabled { opacity: 0.4; pointer-events: none; }
.btn-row { display: flex; gap: 8px; margin-top: 8px; }
.btn-sm { padding: 8px 14px; font-size: 12px; width: auto; }
.spinner {
display: inline-block; width: 16px; height: 16px;
border: 2px solid var(--text3); border-top-color: var(--text);
border-radius: 50%; animation: spin 0.6s linear infinite;
vertical-align: middle; margin-right: 6px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.empty { color: var(--text3); font-size: 12px; text-align: center; padding: 12px; font-style: italic; }
#fileInput { display: none; }
/* Filter bar */
#filterBar {
display: none; align-items: center; gap: 6px; margin-bottom: 10px;
}
.filter-chip {
font-size: 11px; font-weight: 600; padding: 5px 10px;
border-radius: 14px; border: 1px solid var(--border);
background: var(--card2); color: var(--text2);
cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.filter-chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
/* Server section */
.section {
background: var(--card); border-radius: var(--radius);
padding: 14px; margin-bottom: 12px;
}
.ocr-text {
background: var(--card2); border-radius: var(--radius-sm);
padding: 10px; font-family: 'SF Mono', 'Fira Code', monospace;
font-size: 12px; white-space: pre-wrap; word-break: break-all;
max-height: 120px; overflow-y: auto; color: var(--text2);
margin-top: 6px;
}
</style>
</head>
<body>
<h1>📸 EXIF Scanner</h1>
<p class="subtitle">Select multiple photos — instantly see which have GPS</p>
<div class="upload-area" id="uploadArea" onclick="document.getElementById('fileInput').click()">
<span class="icon">📱</span>
<span class="label">Tap to Select Photos</span>
<span class="hint">Pick from gallery — multi-select enabled</span>
</div>
<input type="file" id="fileInput" accept="image/*" multiple>
<!-- Summary stats -->
<div id="summary">
<div class="summary-grid">
<div><div class="summary-num summary-total" id="sumTotal">0</div><div class="summary-label">Total</div></div>
<div><div class="summary-num summary-gps" id="sumGps">0</div><div class="summary-label">📍 Have GPS</div></div>
<div><div class="summary-num summary-nogps" id="sumNoGps">0</div><div class="summary-label">⚠️ No GPS</div></div>
</div>
</div>
<!-- Filter chips -->
<div id="filterBar">
<span class="filter-chip active" onclick="setFilter('all')">All</span>
<span class="filter-chip" onclick="setFilter('gps')">📍 GPS</span>
<span class="filter-chip" onclick="setFilter('nogps')">⚠️ No GPS</span>
</div>
<!-- Photo grid -->
<div id="gallery"></div>
<!-- Detail card (tap a photo to see) -->
<div id="detail">
<img id="detailPreview" alt="">
<div id="detailExif"></div>
<div style="margin-top:10px;">
<button class="btn btn-primary btn-sm" onclick="uploadSelected()">🔍 Upload & Run OCR</button>
</div>
</div>
<!-- Server results for uploaded photo -->
<div class="section" id="serverSection" style="display:none;">
<div class="detail-section">🖥️ Server Results <span class="badge badge-info">PIL + OCR</span></div>
<div id="serverResults"></div>
</div>
<div class="btn-row" style="margin-top:8px;">
<button class="btn btn-outline btn-sm" id="clearBtn" style="display:none;" onclick="resetAll()">🔄 Clear All & Re-select</button>
</div>
<script>
let allPhotos = []; // {file, exif, hasGps, lat, lng, thumb}
let selectedIdx = -1;
let currentFilter = 'all';
document.getElementById('fileInput').addEventListener('change', async function(e) {
const files = Array.from(e.target.files || []);
if (!files.length) return;
document.getElementById('uploadArea').style.display = 'none';
document.getElementById('filterBar').style.display = 'flex';
document.getElementById('clearBtn').style.display = 'block';
// Show scanning state
document.getElementById('gallery').innerHTML = files.map((_, i) =>
'<div class="photo-card" id="card' + i + '">' +
'<div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--text3);font-size:24px;">' +
'<span class="spinner" style="width:24px;height:24px;"></span>' +
'</div>' +
'<div class="overlay overlay-scanning">Scanning...</div>' +
'</div>'
).join('');
// Scan all in parallel (limited to 4 at a time to avoid memory issues)
const results = [];
const batchSize = 4;
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const batchResults = await Promise.all(batch.map(f => scanPhoto(f)));
results.push(...batchResults);
}
allPhotos = results;
updateSummary();
renderGallery();
});
async function scanPhoto(file) {
const result = { file, exif: null, hasGps: false, lat: null, lng: null, thumb: null };
// Generate thumbnail
result.thumb = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = e => resolve(e.target.result);
reader.readAsDataURL(file);
});
// Read EXIF
try {
const exif = await exifr.parse(file);
if (exif && Object.keys(exif).length > 0) {
result.exif = {};
for (const [k, v] of Object.entries(exif)) {
if (v !== undefined && v !== null) result.exif[k] = v;
}
if (exif.latitude !== undefined && exif.longitude !== undefined) {
result.hasGps = true;
result.lat = exif.latitude;
result.lng = exif.longitude;
}
}
} catch (_) { /* EXIF is best-effort */ }
return result;
}
function updateSummary() {
const gps = allPhotos.filter(p => p.hasGps).length;
const nogps = allPhotos.length - gps;
document.getElementById('sumTotal').textContent = allPhotos.length;
document.getElementById('sumGps').textContent = gps;
document.getElementById('sumNoGps').textContent = nogps;
document.getElementById('summary').style.display = 'block';
}
function setFilter(f) {
currentFilter = f;
document.querySelectorAll('.filter-chip').forEach(c => c.classList.remove('active'));
event.target.classList.add('active');
renderGallery();
}
function renderGallery() {
let filtered = allPhotos;
if (currentFilter === 'gps') filtered = allPhotos.filter(p => p.hasGps);
else if (currentFilter === 'nogps') filtered = allPhotos.filter(p => !p.hasGps);
const gallery = document.getElementById('gallery');
gallery.innerHTML = filtered.map(p => {
const idx = allPhotos.indexOf(p);
const selClass = idx === selectedIdx ? ' selected' : '';
const overlayClass = p.hasGps ? 'overlay-gps' : 'overlay-nogps';
const overlayText = p.hasGps ? '📍 ' + p.lat.toFixed(4) + ', ' + p.lng.toFixed(4) : '⚠️ No GPS';
return '<div class="photo-card' + selClass + '" id="card' + idx + '" onclick="showDetail(' + idx + ')">' +
'<img src="' + p.thumb + '" alt="">' +
'<div class="check">✓</div>' +
'<div class="overlay ' + overlayClass + '">' + overlayText + '</div>' +
'</div>';
}).join('');
if (filtered.length === 0) {
gallery.innerHTML = '<div class="empty" style="grid-column:1/-1;">No photos match filter</div>';
}
}
function showDetail(idx) {
selectedIdx = idx;
const p = allPhotos[idx];
if (!p) return;
renderGallery(); // update selection highlights
const detail = document.getElementById('detail');
detail.style.display = 'block';
document.getElementById('detailPreview').src = p.thumb;
document.getElementById('serverSection').style.display = 'none';
let html = '';
html += '<div class="exif-row"><span class="exif-key">Name</span><span class="exif-val">' + esc(p.file.name) + '</span></div>';
html += '<div class="exif-row"><span class="exif-key">Size</span><span class="exif-val">' + formatSize(p.file.size) + '</span></div>';
if (p.exif) {
const priority = ['Make', 'Model', 'DateTimeOriginal', 'Orientation'];
const keys = Object.keys(p.exif);
const sorted = [...new Set([...priority.filter(k => k in p.exif), ...keys.filter(k => !priority.includes(k))])];
for (const k of sorted) {
const v = p.exif[k];
const label = k.replace(/([A-Z])/g, ' $1').trim();
const display = Array.isArray(v) ? v.map(x => typeof x === 'number' ? x.toFixed(3) : x).join(', ') : String(v);
html += '<div class="exif-row"><span class="exif-key">' + esc(label) + '</span><span class="exif-val">' + esc(display) + '</span></div>';
}
if (p.hasGps) {
html = '<div class="gps-coords">📍 ' + p.lat.toFixed(6) + ', ' + p.lng.toFixed(6) + '</div>' + html;
} else {
html = '<div class="gps-coords" style="color:var(--amber);">⚠️ No GPS in EXIF</div>' + html;
}
} else {
html += '<div class="empty" style="margin-top:8px;">No EXIF data at all — file may have been stripped</div>';
}
document.getElementById('detailExif').innerHTML = html;
detail.scrollIntoView({ behavior: 'smooth' });
}
async function uploadSelected() {
if (selectedIdx < 0 || !allPhotos[selectedIdx]) return;
const file = allPhotos[selectedIdx].file;
const section = document.getElementById('serverSection');
const div = document.getElementById('serverResults');
section.style.display = 'block';
div.innerHTML = '<div style="text-align:center;padding:12px;"><span class="spinner"></span> Uploading & analyzing...</div>';
section.scrollIntoView({ behavior: 'smooth' });
const fd = new FormData();
fd.append('file', file, file.name || 'photo.jpg');
try {
const resp = await fetch('/api/analyze', { method: 'POST', body: fd });
const data = await resp.json();
let html = '';
html += '<div class="exif-row"><span class="exif-key">Saved as</span><span class="exif-val">' + esc(data.saved_as) + '</span></div>';
html += '<div class="exif-row"><span class="exif-key">File size</span><span class="exif-val">' + data.file_size_kb + ' KB</span></div>';
const exif = data.exif;
if (exif.gps) {
html = '<div class="gps-coords" style="margin:8px 0;">📍 Server GPS: ' + exif.gps.lat + ', ' + exif.gps.lng + '</div>' + html;
} else if (exif.has_exif) {
html = '<div class="gps-coords" style="color:var(--amber);margin:8px 0;">⚠️ Server found EXIF but no GPS</div>' + html;
} else {
html = '<div class="gps-coords" style="color:var(--red);margin:8px 0;">❌ Server found no EXIF — stripped during upload</div>' + html;
}
// EXIF tags
if (exif.has_exif) {
for (const [k, v] of Object.entries(exif.tags).slice(0, 8)) {
html += '<div class="exif-row"><span class="exif-key">' + esc(k) + '</span><span class="exif-val">' + esc(v) + '</span></div>';
}
}
// OCR
const ocr = data.ocr;
if (ocr.available) {
html += '<div style="margin-top:10px;font-weight:600;">🔤 OCR:</div>';
if (ocr.raw_text) {
html += '<div class="ocr-text">' + esc(ocr.raw_text) + '</div>';
}
if (ocr.match_5dash6) {
html += '<div style="margin-top:4px;color:var(--green);">✅ Matched: <strong>' + esc(ocr.match_5dash6) + '</strong> → machine ID: ' + ocr.match_5dash6.replace(/[^0-9]/g,'').slice(-5) + '</div>';
} else if (ocr.match_5plus) {
html += '<div style="margin-top:4px;color:var(--amber);">⚠️ Digits: <strong>' + esc(ocr.match_5plus) + '</strong> (no 5-6 pattern)</div>';
} else {
html += '<div style="margin-top:4px;color:var(--text3);">No machine ID found in image</div>';
}
}
div.innerHTML = html;
} catch (e) {
div.innerHTML = '<div class="empty">❌ Upload failed: ' + esc(e.message) + '</div>';
}
}
function resetAll() {
allPhotos = [];
selectedIdx = -1;
currentFilter = 'all';
document.getElementById('fileInput').value = '';
document.getElementById('uploadArea').style.display = '';
document.getElementById('gallery').innerHTML = '';
document.getElementById('summary').style.display = 'none';
document.getElementById('filterBar').style.display = 'none';
document.getElementById('detail').style.display = 'none';
document.getElementById('serverSection').style.display = 'none';
document.getElementById('clearBtn').style.display = 'none';
}
function esc(s) {
const d = document.createElement('div');
d.textContent = String(s);
return d.innerHTML;
}
function formatSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB