EXIF scanner: multi-photo gallery with GPS detection
This commit is contained in:
Binary file not shown.
@@ -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)
|
||||
@@ -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 |
Reference in New Issue
Block a user