Bulk OCR + GPS push: process multiple GPS photos, match assets, push coordinates to DB with Leaflet map

This commit is contained in:
2026-05-25 00:55:35 -04:00
parent 6adaf97958
commit c1516471ac
6 changed files with 402 additions and 43 deletions
+251 -1
View File
@@ -5,6 +5,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>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
:root {
--bg: #0f172a; --card: #1e293b; --card2: #334155;
@@ -184,6 +186,35 @@
max-height: 120px; overflow-y: auto; color: var(--text2);
margin-top: 6px;
}
/* Bulk results */
#mapContainer { height: 250px; border-radius: var(--radius-sm); margin-bottom: 10px; display: none; }
.bulk-card {
background: var(--card); border-radius: var(--radius-sm); padding: 10px;
margin-bottom: 8px; display: flex; align-items: center; gap: 10px;
border-left: 3px solid var(--border);
}
.bulk-card.gps-ready { border-left-color: var(--green); }
.bulk-card .bulk-thumb { width: 48px; height: 48px; border-radius: 6px; object-fit: cover; flex-shrink: 0; }
.bulk-card .bulk-info { flex: 1; min-width: 0; }
.bulk-card .bulk-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.bulk-card .bulk-meta { font-size: 11px; color: var(--text2); }
.bulk-card .bulk-action { flex-shrink: 0; }
.btn-push {
font-size: 11px; font-weight: 700; padding: 6px 12px; border-radius: 14px;
border: none; cursor: pointer; -webkit-tap-highlight-color: transparent;
background: var(--green); color: #000;
}
.btn-push:disabled { opacity: 0.4; }
.btn-push.done { background: var(--card2); color: var(--text3); }
.match-badge {
display: inline-block; font-size: 10px; padding: 2px 8px; border-radius: 10px; font-weight: 600;
}
.match-badge.matched { background: var(--green); color: #000; }
.match-badge.no-match { background: var(--border); color: var(--text2); }
.match-badge.no-gps { background: var(--amber); color: #000; }
.match-badge.has-gps { background: var(--card2); color: var(--text2); }
</style>
</head>
<body>
@@ -226,12 +257,26 @@
</div>
</div>
<!-- Server results for uploaded photo -->
<!-- Server results for single upload -->
<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>
<!-- Bulk process button -->
<button class="btn btn-primary" id="bulkBtn" style="display:none;margin-top:12px;" onclick="startBulkProcess()">
🔍 Bulk Process GPS Photos
</button>
<!-- Bulk results -->
<div class="section" id="bulkSection" style="display:none;">
<div class="detail-section">📊 Bulk Results
<span class="match-badge" id="bulkSummary" style="margin-left:auto;"></span>
</div>
<div id="mapContainer"></div>
<div id="bulkResults"></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>
@@ -309,6 +354,8 @@ function updateSummary() {
document.getElementById('sumGps').textContent = gps;
document.getElementById('sumNoGps').textContent = nogps;
document.getElementById('summary').style.display = 'block';
// Show bulk button if we have GPS photos
document.getElementById('bulkBtn').style.display = gps > 0 ? 'block' : 'none';
}
function setFilter(f) {
@@ -480,10 +527,210 @@ async function lookupAsset(machineId) {
}
}
let bulkMap = null;
let bulkData = [];
async function startBulkProcess() {
const gpsPhotos = allPhotos.filter(p => p.hasGps);
if (!gpsPhotos.length) return;
const btn = document.getElementById('bulkBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Processing ' + gpsPhotos.length + ' photos...';
// Build FormData with all GPS photos
const fd = new FormData();
gpsPhotos.forEach(p => fd.append('files', p.file, p.file.name || 'photo.jpg'));
try {
const resp = await fetch('/api/bulk-process', { method: 'POST', body: fd });
const data = await resp.json();
// Merge thumbnails back into results
bulkData = data.results.map((r, i) => ({
...r,
thumb: gpsPhotos[i]?.thumb || null,
clientLat: gpsPhotos[i]?.lat || null,
clientLng: gpsPhotos[i]?.lng || null,
}));
renderBulkResults(data.summary);
} catch (e) {
document.getElementById('bulkResults').innerHTML =
'<div class="empty">❌ Bulk process failed: ' + esc(e.message) + '</div>';
} finally {
btn.disabled = false;
btn.textContent = '🔍 Bulk Process GPS Photos';
}
}
function renderBulkResults(summary) {
const section = document.getElementById('bulkSection');
section.style.display = 'block';
// Summary badge
document.getElementById('bulkSummary').textContent =
summary.matched + ' matched, ' + summary.needs_gps + ' need GPS';
document.getElementById('bulkSummary').className =
'match-badge ' + (summary.matched > 0 ? 'matched' : 'no-match');
// Init map
const mapDiv = document.getElementById('mapContainer');
mapDiv.style.display = 'block';
if (bulkMap) bulkMap.remove();
bulkMap = L.map('mapContainer').setView([28.35, -81.55], 8);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OSM', maxZoom: 19
}).addTo(bulkMap);
const markers = [];
let html = '';
bulkData.forEach((item, i) => {
const asset = item.asset;
const hasGps = item.exif?.gps;
const gpsLat = hasGps ? item.exif.gps.lat : null;
const gpsLng = hasGps ? item.exif.gps.lng : null;
// Map marker for every GPS photo
if (gpsLat && gpsLng) {
const marker = L.marker([gpsLat, gpsLng])
.addTo(bulkMap)
.bindPopup(
(asset ? esc(asset.name) : 'Unknown machine') +
'<br>📸 ' + esc(item.filename || 'photo') +
'<br>📍 ' + gpsLat.toFixed(5) + ', ' + gpsLng.toFixed(5)
);
markers.push([gpsLat, gpsLng]);
}
// Asset marker if it already has GPS in DB
if (asset && asset.latitude && asset.longitude) {
L.marker([asset.latitude, asset.longitude], {
icon: L.divIcon({
className: 'cat-pin-icon',
html: '<div style="width:22px;height:22px;border-radius:50%;background:#3b82f6;' +
'display:flex;align-items:center;justify-content:center;font-size:11px;' +
'box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">🏠</div>',
iconSize: [24, 24], iconAnchor: [12, 12]
})
}).addTo(bulkMap)
.bindPopup('<b>' + esc(asset.name) + '</b><br>Existing location');
}
// Card
const isGpsReady = item.needs_gps;
html += '<div class="bulk-card' + (isGpsReady ? ' gps-ready' : '') + '">';
if (item.thumb) {
html += '<img class="bulk-thumb" src="' + item.thumb + '" alt="">';
}
html += '<div class="bulk-info">';
if (asset) {
html += '<div class="bulk-name">' + esc(asset.name) + '</div>';
html += '<div class="bulk-meta">';
html += '<span class="match-badge matched">🆔 ' + esc(asset.machine_id) + '</span> ';
html += '<span class="match-badge ' + (isGpsReady ? 'no-gps' : 'has-gps') + '">';
if (asset.latitude && asset.longitude) {
html += '📍 Has GPS in DB';
} else if (isGpsReady) {
html += '📍 Photo GPS: ' + gpsLat.toFixed(4) + ', ' + gpsLng.toFixed(4);
} else {
html += '⚠️ No photo GPS';
}
html += '</span>';
html += '</div>';
} else if (item.machine_id) {
html += '<div class="bulk-name">Machine #' + esc(item.machine_id) + '</div>';
html += '<div class="bulk-meta"><span class="match-badge no-match">No DB match</span></div>';
} else {
html += '<div class="bulk-name">' + esc(item.filename || 'Photo') + '</div>';
html += '<div class="bulk-meta"><span class="match-badge no-match">No OCR match</span></div>';
}
html += '</div>'; // bulk-info
// Push button
html += '<div class="bulk-action">';
if (isGpsReady) {
html += '<button class="btn-push" id="pushBtn' + i + '" onclick="pushGpsToAsset(' + i + ')">📤 Push GPS</button>';
} else if (asset && asset.latitude && asset.longitude) {
html += '<button class="btn-push done">✓ In DB</button>';
}
html += '</div>';
html += '</div>'; // bulk-card
});
document.getElementById('bulkResults').innerHTML = html;
// Fit map to markers
if (markers.length) {
const bounds = L.latLngBounds(markers);
bulkMap.fitBounds(bounds, { padding: [30, 30], maxZoom: 15 });
}
// Invalidate map size after display
setTimeout(() => { if (bulkMap) bulkMap.invalidateSize(); }, 200);
section.scrollIntoView({ behavior: 'smooth' });
}
async function pushGpsToAsset(idx) {
const item = bulkData[idx];
if (!item || !item.asset || !item.needs_gps) return;
const btn = document.getElementById('pushBtn' + idx);
btn.disabled = true;
btn.textContent = '⏳ Pushing...';
const gps = item.exif.gps;
try {
const resp = await fetch('/api/push-gps', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_id: item.asset.id,
latitude: gps.lat,
longitude: gps.lng,
}),
});
const data = await resp.json();
if (data.updated) {
btn.className = 'btn-push done';
btn.textContent = '✓ Pushed!';
// Add marker to map for pushed location
if (bulkMap) {
L.marker([gps.lat, gps.lng], {
icon: L.divIcon({
className: 'cat-pin-icon',
html: '<div style="width:22px;height:22px;border-radius:50%;background:#22c55e;' +
'display:flex;align-items:center;justify-content:center;font-size:11px;' +
'box-shadow:0 2px 5px rgba(0,0,0,0.5);border:2px solid #fff;">✓</div>',
iconSize: [24, 24], iconAnchor: [12, 12]
})
}).addTo(bulkMap)
.bindPopup('<b>' + esc(item.asset.name) + '</b><br>📍 Pushed: ' + gps.lat.toFixed(5) + ', ' + gps.lng.toFixed(5))
.openPopup();
}
} else {
btn.textContent = data.reason || 'Failed';
btn.style.background = 'var(--red)';
}
} catch (e) {
btn.textContent = 'Error';
btn.style.background = 'var(--red)';
}
}
function resetAll() {
allPhotos = [];
selectedIdx = -1;
currentFilter = 'all';
bulkData = [];
if (bulkMap) { bulkMap.remove(); bulkMap = null; }
document.getElementById('fileInput').value = '';
document.getElementById('uploadArea').style.display = '';
document.getElementById('gallery').innerHTML = '';
@@ -491,6 +738,9 @@ function resetAll() {
document.getElementById('filterBar').style.display = 'none';
document.getElementById('detail').style.display = 'none';
document.getElementById('serverSection').style.display = 'none';
document.getElementById('bulkSection').style.display = 'none';
document.getElementById('bulkBtn').style.display = 'none';
document.getElementById('mapContainer').style.display = 'none';
document.getElementById('clearBtn').style.display = 'none';
}