Bulk OCR + GPS push: process multiple GPS photos, match assets, push coordinates to DB with Leaflet map
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user