Bulk OCR + GPS push: process multiple GPS photos, match assets, push coordinates to DB with Leaflet map

This commit is contained in:
2026-05-25 00:55:35 -04:00
parent 6adaf97958
commit c1516471ac
6 changed files with 402 additions and 43 deletions
Binary file not shown.
+151 -42
View File
@@ -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
View File
@@ -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