diff --git a/server.py b/server.py
index 7756484..3addbb3 100644
--- a/server.py
+++ b/server.py
@@ -1626,9 +1626,9 @@ def get_visit_stats():
# ─── File Uploads ───────────────────────────────────────────────────────────
ICON_MAX_SIZE = 2 * 1024 * 1024 # 2 MB
-PHOTO_MAX_SIZE = 10 * 1024 * 1024 # 10 MB
+PHOTO_MAX_SIZE = 20 * 1024 * 1024 # 20 MB
ICON_ALLOWED_EXTS = {".png", ".jpg", ".jpeg", ".svg"}
-PHOTO_ALLOWED_EXTS = {".png", ".jpg", ".jpeg"}
+PHOTO_ALLOWED_EXTS = {".png", ".jpg", ".jpeg", ".dng"}
def _dms_to_decimal(dms_tuple: tuple, ref: str) -> float:
@@ -1827,14 +1827,17 @@ async def upload_photo(file: UploadFile = File(...), exif_data: str = Form(None)
@app.post("/api/ocr", status_code=200)
-async def ocr_sticker(file: UploadFile = File(...)):
+async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None)):
"""OCR a sticker photo to extract machine_id from XXXXX-XXXXXX format.
Accepts an image upload, runs Tesseract OCR, and looks for a pattern like
'12345-678901'. Also extracts EXIF GPS data from the raw file bytes before
processing, returning both in one response.
- Returns {machine_id, exif_gps (if found), raw_text, raw_match, confidence}.
+ When exif_data is provided (gallery upload), saves the photo permanently and
+ re-embeds the client-side EXIF data for a proper round-trip.
+
+ Returns {machine_id, exif_gps (if found), path (if permanent), raw_text, ...}.
"""
# Read file contents first (validate size before saving)
contents = await file.read()
@@ -1844,26 +1847,40 @@ async def ocr_sticker(file: UploadFile = File(...)):
# Extract EXIF GPS from raw bytes before any processing
exif_gps = _extract_gps_from_bytes(contents) if contents else None
- # Save temp file for OCR — try original extension, fallback to jpg
- ext = file.filename.rsplit(".", 1)[-1].lower() if "." in (file.filename or "") else "jpg"
- if ext not in {"png", "jpg", "jpeg", "webp", "bmp", "dng", "tiff", "tif"}:
- ext = "jpg" # PIL detects format from bytes regardless of extension
- temp_dir = Path(UPLOADS_DIR / "ocr")
- temp_dir.mkdir(parents=True, exist_ok=True)
- temp_path = temp_dir / f"{uuid.uuid4().hex}.{ext or 'jpg'}"
- temp_path.write_bytes(contents)
+ # If client sent EXIF data (gallery upload), save permanently with EXIF round-trip
+ saved_path: str | None = None
+ if exif_data:
+ try:
+ saved_path = _save_upload_bytes(contents, file.filename, "photos", PHOTO_ALLOWED_EXTS, PHOTO_MAX_SIZE)
+ _re_embed_exif(Path(UPLOADS_DIR / saved_path.lstrip("/")), exif_data)
+ except Exception:
+ saved_path = None # fall through to temp-file path below
+
+ # Save file for OCR — use permanent path if available, otherwise temp
+ if saved_path:
+ ocr_path = Path(UPLOADS_DIR / saved_path.lstrip("/"))
+ else:
+ ext = file.filename.rsplit(".", 1)[-1].lower() if "." in (file.filename or "") else "jpg"
+ if ext not in {"png", "jpg", "jpeg", "webp", "bmp", "dng", "tiff", "tif"}:
+ ext = "jpg"
+ temp_dir = Path(UPLOADS_DIR / "ocr")
+ temp_dir.mkdir(parents=True, exist_ok=True)
+ ocr_path = temp_dir / f"{uuid.uuid4().hex}.{ext or 'jpg'}"
+ ocr_path.write_bytes(contents)
try:
- img = PILImage.open(temp_path)
+ img = PILImage.open(ocr_path)
# Preprocess: convert to grayscale and increase contrast for better OCR
img_gray = img.convert("L")
text = pytesseract.image_to_string(img_gray, config="--psm 6")
except Exception as e:
- temp_path.unlink(missing_ok=True)
+ if not saved_path:
+ ocr_path.unlink(missing_ok=True)
raise HTTPException(status_code=500, detail=f"OCR processing failed: {str(e)}")
- # Don't keep temp file after processing
- temp_path.unlink(missing_ok=True)
+ # Clean up temp file (but keep permanent photos)
+ if not saved_path:
+ ocr_path.unlink(missing_ok=True)
# Build response — search for XXXXX-XXXXXX pattern (5 digits - 6 digits or more)
match = re.search(r"(\d{5})[-\s]*(\d{6,})", text)
@@ -1900,6 +1917,8 @@ async def ocr_sticker(file: UploadFile = File(...)):
if exif_gps:
result["exif_gps"] = exif_gps
+ if saved_path:
+ result["path"] = saved_path
return result
diff --git a/static/index.html b/static/index.html
index 97322b2..ad4251e 100644
--- a/static/index.html
+++ b/static/index.html
@@ -883,6 +883,8 @@
+
+
Create from OCR
@@ -1769,29 +1771,52 @@
return (bytes / 1048576).toFixed(1) + ' MB';
}
- function clearPickedPhoto() {
- pickedPhotoFile = null;
- document.getElementById('photoPreviewCard').style.display = 'none';
- document.getElementById('pickedPhotoPreview').src = '';
- document.getElementById('pickedExifData').style.display = 'none';
- document.getElementById('pickedExifData').innerHTML = '';
- // Reset the file input so the same file can be picked again
- const pp = document.getElementById('photoPicker');
- if (pp) pp.value = '';
- }
-
- function ocrPickedPhoto() {
+ async function ocrPickedPhoto() {
if (!pickedPhotoFile) return;
- // Switch to OCR mode with the picked photo available
+ // Switch to OCR mode
setAddAssetMode('ocr');
- showToast('Photo loaded in preview — OCR processing ready');
- }
- function extractGpsFromPicked() {
- if (!pickedPhotoFile) return;
- // Browser FileReader doesn't expose EXIF GPS without a library.
- // For now, show what we have and note server-side parsing is available.
- showToast('GPS data will be extracted server-side — check EXIF info below for file details');
+ // Read EXIF data from the picked photo (defense against transport stripping)
+ let exifDataStr = null;
+ try {
+ const exifData = await exifr.parse(pickedPhotoFile);
+ if (exifData && Object.keys(exifData).length > 0) {
+ exifDataStr = JSON.stringify(exifData);
+ }
+ } catch (_) { /* EXIF is best-effort */ }
+
+ // Build form data with file + EXIF
+ const fd = new FormData();
+ fd.append('file', pickedPhotoFile, pickedPhotoFile.name || 'photo.jpg');
+ if (exifDataStr) {
+ fd.append('exif_data', exifDataStr);
+ }
+
+ setOcrStatus('Processing OCR...', 'working');
+ document.getElementById('ocrResult').style.display = 'none';
+ document.getElementById('ocrCreateCard').style.display = 'none';
+
+ let data = null;
+ try {
+ const res = await fetch('/api/ocr', {
+ method: 'POST',
+ body: fd,
+ headers: AppState.authToken
+ ? { 'Authorization': 'Bearer ' + AppState.authToken }
+ : {},
+ });
+ if (res.ok) {
+ data = await res.json();
+ data.method = 'server';
+ } else {
+ throw new Error('Server returned ' + res.status);
+ }
+ } catch (e) {
+ setOcrStatus('Server OCR failed: ' + e.message, 'error');
+ return;
+ }
+
+ displayOcrResult(data);
}
async function createAssetFromPicked() {
@@ -2329,6 +2354,51 @@
return { machine_id: null, confidence: 'none' };
}
+ // ── Shared: Display OCR result and GPS ─────────────────────────────────────
+ function displayOcrResult(data) {
+ const el = document.getElementById('ocrResult');
+ el.style.display = 'block';
+
+ if (data.machine_id) {
+ const methodBadge = data.method === 'client'
+ ? '
📡 Offline OCR'
+ : '';
+ const confLabel = data.confidence === 'high' ? '✅ High confidence' :
+ data.confidence === 'low' ? '⚠️ Low confidence' : '';
+ el.innerHTML = `
+
${esc(data.machine_id)}${methodBadge}
+
${confLabel}
+ ${data.raw_text ? `
OCR text: ${esc(data.raw_text)}
` : ''}`;
+ setOcrStatus('Machine ID found!', 'success');
+
+ // Show quick create form
+ document.getElementById('ocrMachineId').value = data.machine_id;
+ document.getElementById('ocrName').value = '';
+ populateCategorySelect('ocrCatSelect');
+ document.getElementById('ocrStatus').value = 'active';
+ document.getElementById('ocrCreateCard').style.display = 'block';
+ document.getElementById('ocrName').focus();
+ } else {
+ el.innerHTML = `
+
No machine ID detected
+
Try again with better lighting and focus on the sticker.
+ ${data.raw_text ? `
OCR text: ${esc(data.raw_text)}
` : ''}`;
+ setOcrStatus('No machine ID found — try again', 'error');
+ }
+
+ // If GPS was extracted from the photo EXIF, fill it into AppState
+ if (data.exif_gps && data.exif_gps.lat && data.exif_gps.lng) {
+ AppState.gpsLat = data.exif_gps.lat;
+ AppState.gpsLng = data.exif_gps.lng;
+ const badge = document.getElementById('ocrGpsBadge');
+ if (badge) {
+ badge.style.display = 'block';
+ badge.textContent = '📍 GPS: ' + data.exif_gps.lat.toFixed(6) + ', ' + data.exif_gps.lng.toFixed(6);
+ }
+ showToast('📍 GPS extracted from photo: ' + data.exif_gps.lat.toFixed(6) + ', ' + data.exif_gps.lng.toFixed(6));
+ }
+ }
+
async function ocrImageClient(imageBlob) {
// Timeout race — 30s max for client-side Tesseract.js
const ocrPromise = Tesseract.recognize(imageBlob, 'eng', {
@@ -2421,35 +2491,7 @@
setOcrStatus('Processing OCR...', 'working');
}
- const el = document.getElementById('ocrResult');
- el.style.display = 'block';
-
- if (data.machine_id) {
- const methodBadge = data.method === 'client'
- ? '
📡 Offline OCR'
- : '';
- const confLabel = data.confidence === 'high' ? '✅ High confidence' :
- data.confidence === 'low' ? '⚠️ Low confidence' : '';
- el.innerHTML = `
-
${esc(data.machine_id)}${methodBadge}
-
${confLabel}
- ${data.raw_text ? `
OCR text: ${esc(data.raw_text)}
` : ''}`;
- setOcrStatus('Machine ID found!', 'success');
-
- // Show quick create form
- document.getElementById('ocrMachineId').value = data.machine_id;
- document.getElementById('ocrName').value = '';
- populateCategorySelect('ocrCatSelect');
- document.getElementById('ocrStatus').value = 'active';
- document.getElementById('ocrCreateCard').style.display = 'block';
- document.getElementById('ocrName').focus();
- } else {
- el.innerHTML = `
-
No machine ID detected
-
Try again with better lighting and focus on the sticker.
- ${data.raw_text ? `
OCR text: ${esc(data.raw_text)}
` : ''}`;
- setOcrStatus('No machine ID found — try again', 'error');
- }
+ displayOcrResult(data);
}
async function createOcrAsset() {
diff --git a/test_dng.dng b/test_dng.dng
new file mode 100644
index 0000000..b7df30c
Binary files /dev/null and b/test_dng.dng differ