diff --git a/__pycache__/server.cpython-311.pyc b/__pycache__/server.cpython-311.pyc index c4fc1f4..0616400 100644 Binary files a/__pycache__/server.cpython-311.pyc and b/__pycache__/server.cpython-311.pyc differ diff --git a/server.py b/server.py index 8c738fa..431709d 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,5 @@ """EXIF + OCR test backend — validate that GPS survives upload pipeline.""" -import io, json, re, uuid +import io, json, re, uuid, sqlite3 from pathlib import Path from fastapi import FastAPI, File, Form, HTTPException, UploadFile @@ -14,10 +14,20 @@ except ImportError: UPLOADS = Path(__file__).parent / "uploads" UPLOADS.mkdir(exist_ok=True) +CANTEEN_DB = Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.db" app = FastAPI(title="EXIF Test") +def _get_canteen_db(): + """Get a read/write connection to the canteen assets database.""" + if not CANTEEN_DB.exists(): + return None + conn = sqlite3.connect(str(CANTEEN_DB)) + conn.row_factory = sqlite3.Row + return conn + + def _dms_to_decimal(dms, ref): """Convert EXIF DMS tuple to decimal degrees.""" try: @@ -39,10 +49,8 @@ def extract_exif(image_bytes: bytes) -> dict: 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] @@ -50,7 +58,6 @@ def extract_exif(image_bytes: bytes) -> dict: 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") @@ -87,7 +94,6 @@ def run_ocr(image_bytes: bytes) -> dict: 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 { @@ -100,13 +106,47 @@ def run_ocr(image_bytes: bytes) -> dict: tmp_path.unlink(missing_ok=True) +def lookup_machine_id(machine_id: str) -> dict | None: + """Look up an asset by machine_id. Returns asset dict or None.""" + conn = _get_canteen_db() + if not conn: + return None + try: + row = conn.execute( + "SELECT id, machine_id, name, category, status, address, building_name, " + "floor, room, latitude, longitude, make, model, description, photo_path " + "FROM assets WHERE machine_id = ?", + (machine_id.strip(),), + ).fetchone() + if row: + return { + "id": row["id"], + "machine_id": row["machine_id"], + "name": row["name"], + "category": row["category"], + "status": row["status"], + "address": row["address"], + "building_name": row["building_name"], + "floor": row["floor"], + "room": row["room"], + "latitude": row["latitude"], + "longitude": row["longitude"], + "make": row["make"], + "model": row["model"], + "description": row["description"], + "photo_path": row["photo_path"], + } + finally: + conn.close() + return None + + @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" @@ -116,10 +156,11 @@ async def analyze_photo(file: UploadFile = File(...)): exif_result = extract_exif(contents) ocr_result = run_ocr(contents) if HAS_TESSERACT else {"available": False, "text": ""} - # Extract machine ID from OCR for auto-lookup machine_id = None + asset = None if ocr_result.get("match_5dash6"): machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:] + asset = lookup_machine_id(machine_id) return { "filename": file.filename, @@ -129,57 +170,125 @@ async def analyze_photo(file: UploadFile = File(...)): "exif": exif_result, "ocr": ocr_result, "machine_id": machine_id, + "asset": asset, } +@app.post("/api/bulk-process") +async def bulk_process(files: list[UploadFile] = File(...)): + """Process multiple photos: OCR each, extract EXIF GPS, look up matching assets. + + Returns a list of results, each with: + - filename, exif (gps), ocr match, matched asset (if found) + - needs_gps: true if asset exists AND has no coordinates AND photo has GPS + """ + if not files: + return {"results": [], "summary": {"total": 0, "has_gps": 0, "matched": 0, "needs_gps": 0}} + + results = [] + summary = {"total": len(files), "has_gps": 0, "matched": 0, "needs_gps": 0} + + for file in files: + 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": ""} + + has_gps = exif_result.get("gps") is not None + if has_gps: + summary["has_gps"] += 1 + + machine_id = None + asset = None + needs_gps = False + + if ocr_result.get("match_5dash6"): + machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:] + asset = lookup_machine_id(machine_id) + if asset: + summary["matched"] += 1 + # Check if asset needs GPS + if (asset["latitude"] is None or asset["longitude"] is None) and has_gps: + needs_gps = True + summary["needs_gps"] += 1 + + results.append({ + "filename": file.filename, + "saved_as": fname, + "file_size_kb": round(file_size / 1024, 1), + "exif": exif_result, + "ocr": ocr_result, + "machine_id": machine_id, + "asset": asset, + "needs_gps": needs_gps, + }) + + return {"results": results, "summary": summary} + + @app.get("/api/lookup") async def lookup_asset(machine_id: str = ""): """Look up an asset by machine_id in the canteen assets database.""" if not machine_id or not machine_id.strip(): return {"found": False, "reason": "No machine_id provided"} - import sqlite3 - from pathlib import Path as _Path + asset = lookup_machine_id(machine_id.strip()) + if asset: + return {"found": True, "asset": asset} + return {"found": False, "reason": f"No asset with machine_id '{machine_id}'"} - db_path = _Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.db" - if not db_path.exists(): - return {"found": False, "reason": f"Database not found at {db_path}"} + +@app.post("/api/push-gps") +async def push_gps(request: dict): + """Update an asset's GPS coordinates from photo EXIF data. + + Body: {"asset_id": 123, "latitude": 40.7417, "longitude": -73.9292} + Only updates assets that currently have NULL lat/lng. + """ + asset_id = request.get("asset_id") + latitude = request.get("latitude") + longitude = request.get("longitude") + + if not asset_id or latitude is None or longitude is None: + raise HTTPException(400, "asset_id, latitude, and longitude are required") + + conn = _get_canteen_db() + if not conn: + raise HTTPException(500, "Database not available") try: - conn = sqlite3.connect(str(db_path)) - conn.row_factory = sqlite3.Row + # Only update if currently NULL row = conn.execute( - "SELECT id, machine_id, name, category, status, address, building_name, " - "floor, room, latitude, longitude, make, model, description, photo_path " - "FROM assets WHERE machine_id = ?", - (machine_id.strip(),), + "SELECT latitude, longitude FROM assets WHERE id = ?", (int(asset_id),) ).fetchone() - conn.close() + if not row: + conn.close() + raise HTTPException(404, f"Asset {asset_id} not found") + if row["latitude"] is not None and row["longitude"] is not None: + conn.close() + return {"updated": False, "reason": "Asset already has GPS coordinates", "asset_id": asset_id} - if row: - return { - "found": True, - "asset": { - "id": row["id"], - "machine_id": row["machine_id"], - "name": row["name"], - "category": row["category"], - "status": row["status"], - "address": row["address"], - "building_name": row["building_name"], - "floor": row["floor"], - "room": row["room"], - "latitude": row["latitude"], - "longitude": row["longitude"], - "make": row["make"], - "model": row["model"], - "description": row["description"], - "photo_path": row["photo_path"], - }, - } - return {"found": False, "reason": f"No asset with machine_id '{machine_id}'"} + conn.execute( + "UPDATE assets SET latitude = ?, longitude = ?, updated_at = datetime('now') WHERE id = ?", + (float(latitude), float(longitude), int(asset_id)), + ) + conn.commit() + conn.close() + return {"updated": True, "asset_id": asset_id, "latitude": float(latitude), "longitude": float(longitude)} except Exception as e: - return {"found": False, "reason": str(e)} + try: + conn.close() + except Exception: + pass + raise HTTPException(500, str(e)) @app.get("/api/uploads") diff --git a/static/index.html b/static/index.html index f1c3832..fc64673 100644 --- a/static/index.html +++ b/static/index.html @@ -5,6 +5,8 @@ EXIF Scanner — Find GPS Photos + + @@ -226,12 +257,26 @@ - + + + + + + +
@@ -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 = ' 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 = + '
❌ Bulk process failed: ' + esc(e.message) + '
'; + } 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') + + '
📸 ' + esc(item.filename || 'photo') + + '
📍 ' + 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: '
🏠
', + iconSize: [24, 24], iconAnchor: [12, 12] + }) + }).addTo(bulkMap) + .bindPopup('' + esc(asset.name) + '
Existing location'); + } + + // Card + const isGpsReady = item.needs_gps; + html += '
'; + + if (item.thumb) { + html += ''; + } + + html += '
'; + + if (asset) { + html += '
' + esc(asset.name) + '
'; + html += '
'; + html += '🆔 ' + esc(asset.machine_id) + ' '; + html += ''; + 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 += ''; + html += '
'; + } else if (item.machine_id) { + html += '
Machine #' + esc(item.machine_id) + '
'; + html += '
No DB match
'; + } else { + html += '
' + esc(item.filename || 'Photo') + '
'; + html += '
No OCR match
'; + } + + html += '
'; // bulk-info + + // Push button + html += '
'; + if (isGpsReady) { + html += ''; + } else if (asset && asset.latitude && asset.longitude) { + html += ''; + } + html += '
'; + + html += '
'; // 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: '
', + iconSize: [24, 24], iconAnchor: [12, 12] + }) + }).addTo(bulkMap) + .bindPopup('' + esc(item.asset.name) + '
📍 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'; } diff --git a/uploads/734ce1ca47014285bf7d4f4c46c2ce82.jpg b/uploads/734ce1ca47014285bf7d4f4c46c2ce82.jpg new file mode 100644 index 0000000..c18b408 Binary files /dev/null and b/uploads/734ce1ca47014285bf7d4f4c46c2ce82.jpg differ diff --git a/uploads/7a2af4ab1e0746cb9a770ac40d28d243.jpg b/uploads/7a2af4ab1e0746cb9a770ac40d28d243.jpg new file mode 100644 index 0000000..aa742b2 Binary files /dev/null and b/uploads/7a2af4ab1e0746cb9a770ac40d28d243.jpg differ diff --git a/uploads/dc0b1a7a779c4d2a8d2240a4d0a7f6d8.jpg b/uploads/dc0b1a7a779c4d2a8d2240a4d0a7f6d8.jpg new file mode 100644 index 0000000..1b14abb Binary files /dev/null and b/uploads/dc0b1a7a779c4d2a8d2240a4d0a7f6d8.jpg differ