Bulk OCR + GPS push: process multiple GPS photos, match assets, push coordinates to DB with Leaflet map
This commit is contained in:
Binary file not shown.
@@ -1,5 +1,5 @@
|
|||||||
"""EXIF + OCR test backend — validate that GPS survives upload pipeline."""
|
"""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 pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
|
||||||
@@ -14,10 +14,20 @@ except ImportError:
|
|||||||
|
|
||||||
UPLOADS = Path(__file__).parent / "uploads"
|
UPLOADS = Path(__file__).parent / "uploads"
|
||||||
UPLOADS.mkdir(exist_ok=True)
|
UPLOADS.mkdir(exist_ok=True)
|
||||||
|
CANTEEN_DB = Path(__file__).parent.parent / "canteen-asset-tracker" / "assets.db"
|
||||||
|
|
||||||
app = FastAPI(title="EXIF Test")
|
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):
|
def _dms_to_decimal(dms, ref):
|
||||||
"""Convert EXIF DMS tuple to decimal degrees."""
|
"""Convert EXIF DMS tuple to decimal degrees."""
|
||||||
try:
|
try:
|
||||||
@@ -39,10 +49,8 @@ def extract_exif(image_bytes: bytes) -> dict:
|
|||||||
if not exif:
|
if not exif:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# Standard EXIF tags
|
|
||||||
for tag_id, value in exif.items():
|
for tag_id, value in exif.items():
|
||||||
tag_name = PILImage.ExifTags.TAGS.get(tag_id, f"0x{tag_id:04x}")
|
tag_name = PILImage.ExifTags.TAGS.get(tag_id, f"0x{tag_id:04x}")
|
||||||
# Skip binary/thumbnail blobs
|
|
||||||
if tag_name in ("MakerNote", "UserComment", "PrintImageMatching"):
|
if tag_name in ("MakerNote", "UserComment", "PrintImageMatching"):
|
||||||
continue
|
continue
|
||||||
result["tags"][tag_name] = str(value)[:300]
|
result["tags"][tag_name] = str(value)[:300]
|
||||||
@@ -50,7 +58,6 @@ def extract_exif(image_bytes: bytes) -> dict:
|
|||||||
if result["tags"]:
|
if result["tags"]:
|
||||||
result["has_exif"] = True
|
result["has_exif"] = True
|
||||||
|
|
||||||
# GPS IFD
|
|
||||||
gps_ifd = exif.get_ifd(0x8825)
|
gps_ifd = exif.get_ifd(0x8825)
|
||||||
if gps_ifd:
|
if gps_ifd:
|
||||||
lat_ref = gps_ifd.get(1, "N")
|
lat_ref = gps_ifd.get(1, "N")
|
||||||
@@ -87,7 +94,6 @@ def run_ocr(image_bytes: bytes) -> dict:
|
|||||||
img = PILImage.open(tmp_path)
|
img = PILImage.open(tmp_path)
|
||||||
img_gray = img.convert("L")
|
img_gray = img.convert("L")
|
||||||
text = pytesseract.image_to_string(img_gray, config="--psm 6")
|
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_5dash = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
|
||||||
match_5plus = re.search(r"(\d{5,})", text)
|
match_5plus = re.search(r"(\d{5,})", text)
|
||||||
return {
|
return {
|
||||||
@@ -100,13 +106,47 @@ def run_ocr(image_bytes: bytes) -> dict:
|
|||||||
tmp_path.unlink(missing_ok=True)
|
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")
|
@app.post("/api/analyze")
|
||||||
async def analyze_photo(file: UploadFile = File(...)):
|
async def analyze_photo(file: UploadFile = File(...)):
|
||||||
"""Upload a photo, get back EXIF + OCR results."""
|
"""Upload a photo, get back EXIF + OCR results."""
|
||||||
contents = await file.read()
|
contents = await file.read()
|
||||||
file_size = len(contents)
|
file_size = len(contents)
|
||||||
|
|
||||||
# Save
|
|
||||||
ext = Path(file.filename or "photo.jpg").suffix.lower()
|
ext = Path(file.filename or "photo.jpg").suffix.lower()
|
||||||
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng"}:
|
if ext not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".dng"}:
|
||||||
ext = ".jpg"
|
ext = ".jpg"
|
||||||
@@ -116,10 +156,11 @@ async def analyze_photo(file: UploadFile = File(...)):
|
|||||||
exif_result = extract_exif(contents)
|
exif_result = extract_exif(contents)
|
||||||
ocr_result = run_ocr(contents) if HAS_TESSERACT else {"available": False, "text": ""}
|
ocr_result = run_ocr(contents) if HAS_TESSERACT else {"available": False, "text": ""}
|
||||||
|
|
||||||
# Extract machine ID from OCR for auto-lookup
|
|
||||||
machine_id = None
|
machine_id = None
|
||||||
|
asset = None
|
||||||
if ocr_result.get("match_5dash6"):
|
if ocr_result.get("match_5dash6"):
|
||||||
machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:]
|
machine_id = re.sub(r"\D", "", ocr_result["match_5dash6"])[-5:]
|
||||||
|
asset = lookup_machine_id(machine_id)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"filename": file.filename,
|
"filename": file.filename,
|
||||||
@@ -129,57 +170,125 @@ async def analyze_photo(file: UploadFile = File(...)):
|
|||||||
"exif": exif_result,
|
"exif": exif_result,
|
||||||
"ocr": ocr_result,
|
"ocr": ocr_result,
|
||||||
"machine_id": machine_id,
|
"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")
|
@app.get("/api/lookup")
|
||||||
async def lookup_asset(machine_id: str = ""):
|
async def lookup_asset(machine_id: str = ""):
|
||||||
"""Look up an asset by machine_id in the canteen assets database."""
|
"""Look up an asset by machine_id in the canteen assets database."""
|
||||||
if not machine_id or not machine_id.strip():
|
if not machine_id or not machine_id.strip():
|
||||||
return {"found": False, "reason": "No machine_id provided"}
|
return {"found": False, "reason": "No machine_id provided"}
|
||||||
|
|
||||||
import sqlite3
|
asset = lookup_machine_id(machine_id.strip())
|
||||||
from pathlib import Path as _Path
|
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():
|
@app.post("/api/push-gps")
|
||||||
return {"found": False, "reason": f"Database not found at {db_path}"}
|
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:
|
try:
|
||||||
conn = sqlite3.connect(str(db_path))
|
# Only update if currently NULL
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT id, machine_id, name, category, status, address, building_name, "
|
"SELECT latitude, longitude FROM assets WHERE id = ?", (int(asset_id),)
|
||||||
"floor, room, latitude, longitude, make, model, description, photo_path "
|
|
||||||
"FROM assets WHERE machine_id = ?",
|
|
||||||
(machine_id.strip(),),
|
|
||||||
).fetchone()
|
).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:
|
conn.execute(
|
||||||
return {
|
"UPDATE assets SET latitude = ?, longitude = ?, updated_at = datetime('now') WHERE id = ?",
|
||||||
"found": True,
|
(float(latitude), float(longitude), int(asset_id)),
|
||||||
"asset": {
|
)
|
||||||
"id": row["id"],
|
conn.commit()
|
||||||
"machine_id": row["machine_id"],
|
conn.close()
|
||||||
"name": row["name"],
|
return {"updated": True, "asset_id": asset_id, "latitude": float(latitude), "longitude": float(longitude)}
|
||||||
"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}'"}
|
|
||||||
except Exception as e:
|
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")
|
@app.get("/api/uploads")
|
||||||
|
|||||||
+251
-1
@@ -5,6 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover">
|
||||||
<title>EXIF Scanner — Find GPS Photos</title>
|
<title>EXIF Scanner — Find GPS Photos</title>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/exifr@7/dist/lite.umd.js"></script>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0f172a; --card: #1e293b; --card2: #334155;
|
--bg: #0f172a; --card: #1e293b; --card2: #334155;
|
||||||
@@ -184,6 +186,35 @@
|
|||||||
max-height: 120px; overflow-y: auto; color: var(--text2);
|
max-height: 120px; overflow-y: auto; color: var(--text2);
|
||||||
margin-top: 6px;
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -226,12 +257,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Server results for uploaded photo -->
|
<!-- Server results for single upload -->
|
||||||
<div class="section" id="serverSection" style="display:none;">
|
<div class="section" id="serverSection" style="display:none;">
|
||||||
<div class="detail-section">🖥️ Server Results <span class="badge badge-info">PIL + OCR</span></div>
|
<div class="detail-section">🖥️ Server Results <span class="badge badge-info">PIL + OCR</span></div>
|
||||||
<div id="serverResults"></div>
|
<div id="serverResults"></div>
|
||||||
</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;">
|
<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>
|
<button class="btn btn-outline btn-sm" id="clearBtn" style="display:none;" onclick="resetAll()">🔄 Clear All & Re-select</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -309,6 +354,8 @@ function updateSummary() {
|
|||||||
document.getElementById('sumGps').textContent = gps;
|
document.getElementById('sumGps').textContent = gps;
|
||||||
document.getElementById('sumNoGps').textContent = nogps;
|
document.getElementById('sumNoGps').textContent = nogps;
|
||||||
document.getElementById('summary').style.display = 'block';
|
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) {
|
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() {
|
function resetAll() {
|
||||||
allPhotos = [];
|
allPhotos = [];
|
||||||
selectedIdx = -1;
|
selectedIdx = -1;
|
||||||
currentFilter = 'all';
|
currentFilter = 'all';
|
||||||
|
bulkData = [];
|
||||||
|
if (bulkMap) { bulkMap.remove(); bulkMap = null; }
|
||||||
document.getElementById('fileInput').value = '';
|
document.getElementById('fileInput').value = '';
|
||||||
document.getElementById('uploadArea').style.display = '';
|
document.getElementById('uploadArea').style.display = '';
|
||||||
document.getElementById('gallery').innerHTML = '';
|
document.getElementById('gallery').innerHTML = '';
|
||||||
@@ -491,6 +738,9 @@ function resetAll() {
|
|||||||
document.getElementById('filterBar').style.display = 'none';
|
document.getElementById('filterBar').style.display = 'none';
|
||||||
document.getElementById('detail').style.display = 'none';
|
document.getElementById('detail').style.display = 'none';
|
||||||
document.getElementById('serverSection').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';
|
document.getElementById('clearBtn').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 982 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Reference in New Issue
Block a user