T4: Connect Label upload workflow — unified photo + OCR + GPS

- Added 'Connect' mode toggle on Add Asset tab with Camera/Gallery options
- processConnectLabelPhoto(file): parallel OCR + GPS extraction
- Shows results card with editable machine_id, lat/lng, map preview
- POST /api/connect-label endpoint with photo upload + GPS + reverse geocode
- Connect Label section in Assets list (localStorage-backed recent scans)
- Handles OCR failure (manual entry) and missing EXIF GPS gracefully
This commit is contained in:
2026-05-21 11:50:09 -04:00
parent f895bf99de
commit 622996624a
2 changed files with 561 additions and 2 deletions
+102 -1
View File
@@ -22,7 +22,7 @@ from pathlib import Path
import pytesseract
from PIL import Image as PILImage
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File
from fastapi import FastAPI, HTTPException, Query, Request, UploadFile, File, Form
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
@@ -2687,6 +2687,107 @@ async def ocr_sticker(file: UploadFile = File(...)):
}
# ─── T4: Connect Label — unified photo + OCR + GPS endpoint ─────────────────
class ConnectLabelRequest(BaseModel):
"""Request body for the connect-label endpoint."""
machine_id: str
name: str = ""
latitude: Optional[float] = None
longitude: Optional[float] = None
category: Optional[str] = "Other"
photo_path: Optional[str] = None
@app.post("/api/connect-label", status_code=201)
async def connect_label(
photo: Optional[UploadFile] = File(None),
machine_id: str = Form(""),
name: str = Form(""),
latitude: Optional[float] = Form(None),
longitude: Optional[float] = Form(None),
category: str = Form("Other"),
photo_path: str = Form(""),
):
"""Unified endpoint for Connect Label workflow.
Accepts multipart form data with optional photo upload.
Creates an asset with the provided machine_id, name, GPS coords.
If photo is uploaded, it saves the file and sets photo_path.
Validates machine_id format (XXXXX-XXXXXX → last 5 digits).
"""
# If machine_id/name come as form fields, use those; otherwise try query params
machine_id = _sanitize_machine_id(machine_id)
name = _sanitize_name(name or "Scanned Asset")
_validate_status("active")
# Save photo if provided
saved_photo_path = photo_path or ""
if photo and photo.filename:
saved_photo_path = _save_upload(photo, "connect_labels", PHOTO_ALLOWED_EXTS, PHOTO_MAX_SIZE)
conn = get_db()
_validate_enum_table(conn, "categories", category or "Other", "category")
# Build insert
columns: list[str] = ["machine_id", "name", "category", "status"]
values: list = [machine_id, name, category or "Other", "active"]
if saved_photo_path:
columns.append("photo_path")
values.append(saved_photo_path)
if latitude is not None:
columns.append("latitude")
values.append(latitude)
if longitude is not None:
columns.append("longitude")
values.append(longitude)
# Auto-populate address from GPS
if latitude is not None and longitude is not None:
geo = reverse_geocode(latitude, longitude)
if geo:
if "address" not in columns:
columns.append("address")
values.append(geo.get("address", geo.get("formatted", "")))
if "building_name" not in columns and geo.get("building_name"):
columns.append("building_name")
values.append(geo.get("building_name", ""))
if "building_number" not in columns and geo.get("building_number"):
columns.append("building_number")
values.append(geo.get("building_number", ""))
placeholders = ", ".join(["?"] * len(columns))
col_names = ", ".join(columns)
try:
cursor = conn.execute(
f"INSERT INTO assets ({col_names}) VALUES ({placeholders})",
values,
)
except sqlite3.IntegrityError:
conn.close()
raise HTTPException(
status_code=409,
detail=f"Asset with machine_id '{machine_id}' already exists",
)
raw_id = cursor.lastrowid
assert raw_id is not None, "INSERT did not return lastrowid"
asset_id: int = raw_id
_log_activity(conn, "created", "asset", asset_id,
f"Asset '{name}' (machine_id: {machine_id}) created via Connect Label")
conn.commit()
row = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
result = row_to_dict(row)
result["keys"] = _get_asset_keys(conn, asset_id)
result["badges"] = _get_asset_badges(conn, asset_id)
conn.close()
return result
# ─── Reverse Geocode (Nominatim) ────────────────────────────────────────────
def reverse_geocode(lat: float, lng: float) -> dict | None:
+459 -1
View File
@@ -1212,6 +1212,7 @@
<button class="mode-toggle active" data-mode="barcode" onclick="setAddAssetMode('barcode')">📷 Barcode</button>
<button class="mode-toggle" data-mode="ocr" onclick="setAddAssetMode('ocr')">🔍 OCR</button>
<button class="mode-toggle" data-mode="manual" onclick="setAddAssetMode('manual')">✏️ Manual</button>
<button class="mode-toggle" data-mode="connect" onclick="setAddAssetMode('connect')">🏷️ Connect</button>
<button class="photo-picker-btn" onclick="document.getElementById('photoPicker').click()">📱 Pick from Gallery</button>
</div>
@@ -1407,6 +1408,62 @@
</div>
</div> </div>
<!-- ── CONNECT LABEL MODE (T4: unified photo + OCR + GPS) ──────────── -->
<div id="addConnectMode" class="add-mode">
<div class="card">
<div class="card-title">🏷️ Scan Connect Label — Photo + OCR + GPS</div>
<p style="font-size:12px;color:var(--text2);margin-bottom:10px;">
Take or pick a photo of a machine sticker. OCR reads the ID, GPS is extracted automatically.
</p>
<div style="display:flex;gap:8px;margin-bottom:4px;">
<button class="btn btn-primary" onclick="connectFromCamera()" style="flex:1;">
📷 Camera
</button>
<button class="btn btn-outline" onclick="connectFromGallery()" style="flex:1;">
🖼️ From Gallery
</button>
</div>
</div>
<!-- Hidden camera for connect mode -->
<div id="connectCameraArea" class="camera-area" style="display:none;">
<video id="connectVideo" autoplay playsinline muted style="display:none;width:100%;height:100%;object-fit:cover;position:absolute;inset:0;"></video>
<div id="connectCameraPlaceholder" class="camera-placeholder" style="display:none;">
<span class="cam-icon">📸</span>
<div class="cam-hint">Tap to start camera</div>
</div>
<div class="ocr-capture-overlay" id="connectCaptureOverlay" style="display:none;">
<button class="ocr-capture-btn" onclick="captureConnectPhoto()" title="Capture">📸</button>
</div>
</div>
<!-- Processing status -->
<div id="connectStatus" class="status-bar" style="display:none;"></div>
<!-- Results card -->
<div id="connectResults" class="card" style="display:none;">
<div class="card-title">📋 Scan Results</div>
<img id="connectThumb" src="" alt="Scanned photo" style="width:100%;max-height:200px;object-fit:cover;border-radius:var(--radius-sm);margin-bottom:10px;">
<div id="connectOcrResult" style="margin-bottom:8px;"></div>
<div id="connectGpsResult" style="margin-bottom:8px;"></div>
<div id="connectMiniMap" style="display:none;width:100%;height:160px;border-radius:var(--radius-sm);overflow:hidden;margin-bottom:8px;"></div>
<!-- Editable fields -->
<input id="connectMachineId" type="text" class="input-field" placeholder="Machine ID *">
<input id="connectName" type="text" class="input-field" placeholder="Asset Name *">
<div class="form-row" style="margin-bottom:8px;">
<input id="connectLat" type="number" step="any" class="input-field" placeholder="Latitude">
<input id="connectLng" type="number" step="any" class="input-field" placeholder="Longitude">
</div>
<select id="connectCatSelect" class="input-field"></select>
<div style="display:flex;gap:8px;">
<button class="btn btn-primary" onclick="createConnectAsset()" style="flex:2;"> Create Asset</button>
<button class="btn btn-outline" onclick="resetConnectMode()" style="flex:1;">🔄 Reset</button>
</div>
</div>
</div>
<!-- ── Photo Preview Card (shown when a photo is picked from gallery) ── -->
<div id="photoPreviewCard" class="photo-preview-card" style="display:none;">
<img id="pickedPhotoPreview" src="" alt="Picked photo">
@@ -1434,6 +1491,16 @@
<div class="filter-scroll" id="filterPills"></div>
<button class="btn btn-outline btn-sm import-btn" onclick="showImportView()">📥 Import</button>
</div>
<!-- Connect Label recent scans -->
<div class="card" id="connectLabelSection" style="display:none;">
<div class="card-title" style="display:flex;align-items:center;justify-content:space-between;">
🏷️ Recently Scanned Labels
<button class="btn btn-outline btn-sm" onclick="clearConnectLabels()" style="font-size:10px;">Clear</button>
</div>
<div id="connectLabelList" style="font-size:12px;color:var(--text2);"></div>
</div>
<div id="assetList"></div>
</div>
@@ -2486,7 +2553,7 @@
if (tabId === 'tabAddAsset') startScanning();
else stopScanning();
if (tabId === 'tabAssets') { loadAssets(); loadTechnicianOptions(); }
if (tabId === 'tabAssets') { loadAssets(); loadTechnicianOptions(); renderConnectLabels(); }
if (tabId === 'tabDashboard') loadDashboard();
if (tabId === 'tabActivity') { loadActivity(); loadActivityUserFilter(); }
@@ -2594,6 +2661,7 @@
stopScanning();
stopOcrCamera();
stopManualPhoto();
stopConnectCamera();
if (mode === 'barcode') {
startScanning();
@@ -3519,6 +3587,396 @@
}
}
// ═══════════════════════════════════════════════════════════════════════
// T4: CONNECT LABEL — unified photo + OCR + GPS
// ═══════════════════════════════════════════════════════════════════════
let connectStream = null;
let connectFile = null;
let connectGps = null; // { lat, lng, accuracy }
let connectOcrData = null; // { machine_id, confidence, raw_text }
async function connectFromCamera() {
// Stop any existing streams
stopOcrCamera();
stopManualPhoto();
const area = document.getElementById('connectCameraArea');
const video = document.getElementById('connectVideo');
const placeholder = document.getElementById('connectCameraPlaceholder');
const overlay = document.getElementById('connectCaptureOverlay');
const results = document.getElementById('connectResults');
const status = document.getElementById('connectStatus');
area.style.display = 'block';
results.style.display = 'none';
status.style.display = 'none';
status.textContent = '';
placeholder.style.display = 'flex';
video.style.display = 'none';
overlay.style.display = 'none';
// Stop any previous connect stream
if (connectStream) {
connectStream.getTracks().forEach(t => t.stop());
connectStream = null;
}
try {
connectStream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1920 }, height: { ideal: 1080 } },
audio: false,
});
video.srcObject = connectStream;
video.style.display = 'block';
placeholder.style.display = 'none';
overlay.style.display = 'flex';
} catch (e) {
showToast('Camera error: ' + e.message, true);
area.style.display = 'none';
}
}
function connectFromGallery() {
const pp = document.getElementById('photoPicker');
if (pp) {
// Override the default handler for connect mode
pp._connectMode = true;
pp.click();
}
}
async function captureConnectPhoto() {
const video = document.getElementById('connectVideo');
if (!video || !connectStream) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
canvas.getContext('2d').drawImage(video, 0, 0);
const blob = await new Promise(r => canvas.toBlob(r, 'image/jpeg', 0.85));
// Stop the camera
stopConnectCamera();
// Process the photo
await processConnectLabelPhoto(new File([blob], 'sticker.jpg', { type: 'image/jpeg' }));
}
function stopConnectCamera() {
if (connectStream) {
connectStream.getTracks().forEach(t => t.stop());
connectStream = null;
}
document.getElementById('connectVideo').srcObject = null;
document.getElementById('connectVideo').style.display = 'none';
document.getElementById('connectCameraArea').style.display = 'none';
document.getElementById('connectCaptureOverlay').style.display = 'none';
document.getElementById('connectCameraPlaceholder').style.display = 'none';
}
// ▶ Core: process photo via OCR + GPS extraction
async function processConnectLabelPhoto(file) {
connectFile = file;
connectGps = null;
connectOcrData = null;
// Show status
const statusEl = document.getElementById('connectStatus');
statusEl.style.display = 'block';
statusEl.className = 'status-bar working';
statusEl.textContent = '⏳ Processing photo...';
document.getElementById('connectResults').style.display = 'none';
// Show thumbnail
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('connectThumb').src = e.target.result;
};
reader.readAsDataURL(file);
// Run OCR and GPS in parallel
const [ocrResult, gpsResult] = await Promise.allSettled([
runConnectOcr(file),
extractGpsFromPhoto(file),
]);
// Process OCR result
if (ocrResult.status === 'fulfilled' && ocrResult.value) {
connectOcrData = ocrResult.value;
} else {
connectOcrData = { machine_id: '', confidence: 'none', raw_text: 'OCR failed' };
}
// Process GPS result
if (gpsResult.status === 'fulfilled' && gpsResult.value) {
connectGps = gpsResult.value;
} else {
connectGps = null;
}
// Render results
renderConnectResults();
}
// ▶ Run OCR for connect mode (server first, client fallback)
async function runConnectOcr(file) {
// Try server-side OCR first
try {
const controller = new AbortController();
const t = setTimeout(() => controller.abort(), 8000);
const formData = new FormData();
formData.append('file', file, 'sticker.jpg');
const res = await fetch('/api/ocr', {
method: 'POST',
body: formData,
signal: controller.signal,
headers: AppState.authToken
? { 'Authorization': 'Bearer ' + AppState.authToken }
: {},
});
clearTimeout(t);
if (res.ok) {
const data = await res.json();
data.method = 'server';
return data;
}
throw new Error('Server returned ' + res.status);
} catch (e) {
// Fall back to client-side Tesseract.js
try {
const data = await ocrImageClient(file);
return data;
} catch (clientErr) {
return { machine_id: '', confidence: 'none', raw_text: 'OCR unavailable', method: 'client' };
}
}
}
function renderConnectResults() {
const resultsEl = document.getElementById('connectResults');
const statusEl = document.getElementById('connectStatus');
resultsEl.style.display = 'block';
// OCR result line
const ocrEl = document.getElementById('connectOcrResult');
if (connectOcrData && connectOcrData.machine_id) {
const methodBadge = connectOcrData.method === 'client'
? '<span class="offline-badge">📡 Offline</span>' : '';
ocrEl.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span class="gps-badge ok">✅ Machine ID found</span>${methodBadge}
</div>
<div style="font-size:13px;color:var(--text2);">
${esc(connectOcrData.machine_id)}
${connectOcrData.confidence === 'low' ? ' ⚠️ Low confidence — verify' : ''}
</div>`;
document.getElementById('connectMachineId').value = connectOcrData.machine_id;
statusEl.className = 'status-bar success';
statusEl.textContent = '✅ Processing complete';
} else {
ocrEl.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span class="gps-badge err">❌ No ID detected</span>
</div>
<div style="font-size:12px;color:var(--text3);">Enter machine ID manually below.</div>`;
document.getElementById('connectMachineId').value = '';
statusEl.className = 'status-bar error';
statusEl.textContent = 'OCR failed — enter ID manually';
}
// GPS result line
const gpsEl = document.getElementById('connectGpsResult');
const miniMapEl = document.getElementById('connectMiniMap');
if (connectGps) {
gpsEl.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span class="gps-badge ok">📍 GPS from photo</span>
</div>
<div style="font-size:13px;color:var(--text2);">
${connectGps.lat.toFixed(6)}, ${connectGps.lng.toFixed(6)}
${connectGps.accuracy ? ` (±${connectGps.accuracy.toFixed(1)}m)` : ''}
</div>`;
document.getElementById('connectLat').value = connectGps.lat.toFixed(6);
document.getElementById('connectLng').value = connectGps.lng.toFixed(6);
miniMapEl.style.display = 'block';
setTimeout(() => renderGpsPreview(connectGps.lat, connectGps.lng, miniMapEl), 100);
} else {
gpsEl.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;">
<span class="gps-badge err">📍 No GPS in photo</span>
</div>
<div style="font-size:11px;color:var(--text3);margin-bottom:4px;">
Phone may have stripped EXIF GPS. Enter coordinates manually or skip.
</div>`;
document.getElementById('connectLat').value = '';
document.getElementById('connectLng').value = '';
miniMapEl.style.display = 'none';
}
// Populate category select
populateCategorySelect('connectCatSelect');
document.getElementById('connectName').value = '';
document.getElementById('connectName').focus();
// Save to recent connect labels
saveConnectLabel(connectOcrData ? connectOcrData.machine_id : '', connectGps);
}
async function createConnectAsset() {
const machineId = document.getElementById('connectMachineId').value.trim();
const name = document.getElementById('connectName').value.trim();
if (!machineId) { showToast('Machine ID is required', true); return; }
if (!name) { showToast('Asset name is required', true); return; }
const latStr = document.getElementById('connectLat').value.trim();
const lngStr = document.getElementById('connectLng').value.trim();
const payload = {
machine_id: machineId,
name: name,
category: document.getElementById('connectCatSelect').value || 'Other',
status: 'active',
};
if (latStr && lngStr) {
payload.latitude = parseFloat(latStr);
payload.longitude = parseFloat(lngStr);
}
// Try reverse geocode for address
if (payload.latitude && payload.longitude) {
try {
const geo = await reverseGeocode(payload.latitude, payload.longitude);
if (geo) {
payload.address = geo.address || geo.formatted || '';
payload.building_name = geo.building_name || '';
payload.building_number = geo.building_number || '';
}
} catch (e) { /* ignore */ }
}
// Upload photo if available
if (connectFile) {
try {
const formData = new FormData();
formData.append('file', connectFile);
const uploadRes = await fetch('/api/upload/photo', {
method: 'POST',
body: formData,
headers: AppState.authToken
? { 'Authorization': 'Bearer ' + AppState.authToken }
: {},
});
if (uploadRes.ok) {
const uploadData = await uploadRes.json();
payload.photo_path = uploadData.path;
}
} catch (e) { /* photo upload is optional */ }
}
try {
const asset = await api('/api/assets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (asset._queued) {
showToast('📴 Queued offline — will sync when connected');
} else {
showToast('✅ Asset created!');
AppState.currentAssetId = asset.id;
autoCheckin(asset.id);
}
// Switch to assets tab
switchTab('tabAssets');
loadAssets();
} catch (e) {
showToast(e.message, true);
}
}
function resetConnectMode() {
connectFile = null;
connectGps = null;
connectOcrData = null;
document.getElementById('connectResults').style.display = 'none';
document.getElementById('connectStatus').style.display = 'none';
document.getElementById('connectStatus').textContent = '';
document.getElementById('connectThumb').src = '';
document.getElementById('connectMachineId').value = '';
document.getElementById('connectName').value = '';
document.getElementById('connectLat').value = '';
document.getElementById('connectLng').value = '';
const miniMapEl = document.getElementById('connectMiniMap');
miniMapEl.innerHTML = '';
miniMapEl.style.display = 'none';
stopConnectCamera();
}
// ── Connect Label History ───────────────────────────────────────────────
function saveConnectLabel(machineId, gps) {
try {
const labels = JSON.parse(localStorage.getItem('connectLabels') || '[]');
labels.unshift({
machine_id: machineId || 'Manual entry',
lat: gps ? gps.lat : null,
lng: gps ? gps.lng : null,
time: new Date().toISOString(),
});
// Keep last 20
if (labels.length > 20) labels.length = 20;
localStorage.setItem('connectLabels', JSON.stringify(labels));
} catch (e) { /* ignore storage errors */ }
}
function renderConnectLabels() {
const section = document.getElementById('connectLabelSection');
const list = document.getElementById('connectLabelList');
try {
const labels = JSON.parse(localStorage.getItem('connectLabels') || '[]');
if (!labels.length) {
section.style.display = 'none';
return;
}
section.style.display = 'block';
list.innerHTML = labels.map((l, i) => {
const gpsStr = l.lat && l.lng
? `📍 (${l.lat.toFixed(4)}, ${l.lng.toFixed(4)})`
: '📍 No GPS';
const timeStr = new Date(l.time).toLocaleString();
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border);">
<span style="font-weight:600;">${esc(l.machine_id)}</span>
<span style="font-size:11px;color:var(--text3);">${gpsStr}</span>
<span style="font-size:10px;color:var(--text3);margin-left:auto;">${timeStr}</span>
</div>`;
}).join('');
} catch (e) {
section.style.display = 'none';
}
}
function clearConnectLabels() {
localStorage.removeItem('connectLabels');
document.getElementById('connectLabelSection').style.display = 'none';
document.getElementById('connectLabelList').innerHTML = '';
}
// ── Hook into gallery picker for connect mode ────────────────────────────
(function() {
const pp = document.getElementById('photoPicker');
const origHandler = pp.onchange;
pp.addEventListener('change', function(e) {
if (pp._connectMode) {
pp._connectMode = false;
if (e.target.files && e.target.files[0]) {
processConnectLabelPhoto(e.target.files[0]);
}
pp.value = ''; // Reset so same file can be picked again
return;
}
});
})();
// ═══════════════════════════════════════════════════════════════════════
// MANUAL MODE — Photo
// ═══════════════════════════════════════════════════════════════════════