Files
exif-test/FEATURE_PLAN.md
T

25 KiB
Raw Blame History

EXIF Scanner — Feature Roadmap

Living feature specification document. Covers all proposed features for the photo upload / OCR / GPS-push app at ~/projects/exif-test/.

Legend: done · 💡 proposed


1. Offline Queue — Service Worker + IndexedDB

Use Case

Facility audits happen in warehouses, basements, parking lots, and rural sites — all places with unreliable or zero cellular signal. Currently if the server is unreachable the app shows nothing useful and queued photos are lost on page refresh. The user needs to "fire and forget": snap photos, move to the next machine, and have the uploads drain silently when signal returns.

User Experience

  1. Open the app — service worker installs, page loads from cache instantly
  2. If online: everything works as today (live upload via fetch to /api/analyze)
  3. If offline (navigator.onLine === false or fetch fails):
    • Photo thumbnails still generate (client-side EXIF via exifr.js works offline)
    • The gallery, summary, filter chips all work from IndexedDB-local
    • "Bulk Process" button changes to "📡 Queue for Upload (N pending)"
    • A small banner at the top: "📡 Offline — N photos queued"
  4. When signal returns (online event or periodic background sync):
    • Queue drains FIFO, one at a time, with retry (3 attempts, exponential backoff)
    • Banner updates: "📡 Syncing… (3/12 remaining)"
    • On completion: banner disappears, results load from server /api/photos
  5. Page refresh mid-queue: IndexedDB persists the queue, resumes on next load

Spec

IndexedDB schema — "photos-queue" store:
  { id: auto_inc, file_blob: Blob, filename: string, timestamp: number,
    retries: number, status: 'queued'|'uploading'|'done'|'failed' }

Service worker:
  - Cache-first strategy for static assets (/, /static/*)
  - Network-first for /api/* (falls back to cached error page)
  - Listen for 'sync' events from periodicSync API if available

Tech

  • Service Worker registration in <script> at page top
  • IndexedDB via idb-keyval or raw indexedDB API (no extra dep)
  • Background Sync API (navigator.serviceWorker.ready.then(r => r.sync.register('sync-photos'))) — falls back to online event listener
  • Queue managerclass PhotoQueue in the main JS, not in SW (SW can't read blobs efficiently)
  • Retry — exponential backoff: 2s, 5s, 15s, then mark failed. Failed queue items show " Retry" button in UI.

Files Touched

  • static/index.html — add <script> for sw registration, queue manager init
  • static/sw.js — new file, ~40 lines
  • static/index.html — add offline banner and queue status UI (~50 lines)
  • static/index.html — modify uploadSelected() and startBulkProcess() to check navigator.onLine and route to queue if offline

Test Cases

# Scenario Expected
1 Load app while offline Page renders from cache, banner shows "Offline"
2 Select photos while offline Gallery thumbnails appear, summary counts work
3 Click "Bulk Process" while offline Photos go to IndexedDB queue, banner shows count
4 Go online while queue has items Queue drains, results appear once all uploaded
5 Refresh mid-queue Queue persists, resumes draining on reload
6 Server returns 500 during drain Retry 3 times, then mark failed with ""
7 Select 100 photos while offline All queued, memory usage stays reasonable (Blob refs)
8 Delete queue item Removed from IndexedDB, count decrements

4. Export — CSV / KML / Copy Coordinates

Use Case

After a walk-around audit, the collected data (photo GPS, OCR'd machine IDs, matched asset names) needs to get into spreadsheets for facility management, Google Earth for visual review, or a clipboard for pasting into an internal tool. Currently the data is trapped in the photos.db SQLite on the server.

User Experience

  1. After any bulk process, a new Export button appears in the bulk section header
  2. Tap Export → modal/action sheet shows three options:
    • 📊 CSV — downloads exif-export-YYYY-MM-DD.csv
    • 🌍 KML — downloads exif-export-YYYY-MM-DD.kml (open in Google Earth)
    • 📋 Copy all GPS — copies lat,lng pairs to clipboard
  3. Also available from the Previously Processed section header
  4. CSV columns: filename, lat, lng, machine_id, asset_name, building, floor, room, make, model, ocr_engine, sticker_color, timestamp
  5. KML: each photo is a Placemark with name=asset name (or filename), coords from GPS, description with all metadata

Spec

GET /api/export?format=csv&session_id=optional
GET /api/export?format=kml&session_id=optional
GET /api/export?format=clipboard&session_id=optional   # returns plain text lat,lng pairs

Defaults to all photos if no session_id. Supports ?limit=500 for large exports.

Tech

  • CSV: FastAPI StreamingResponse with csv.writer — streams line by line, no memory spike
  • KML: Build XML manually (simple template, no library needed). Validates against KML 2.2 schema.
  • Clipboard: Frontend-only — parse server response, use navigator.clipboard.writeText()
  • Character encoding: All text fields UTF-8, CSV with BOM for Excel compatibility

Files Touched

  • server.py — add /api/export endpoint (~60 lines)
  • static/index.html — add export button + modal, clipboard handler (~40 lines)

Test Cases

# Scenario Expected
1 Export CSV from bulk results Downloads valid CSV, opens in Excel without encoding issues
2 Export KML from bulk results Downloads KML, opens in Google Earth, pins show at correct coords
3 Copy GPS from prev photos Clipboard contains lat,lng pairs, one per line
4 Export with 0 results Returns empty CSV with headers only
5 Export 200 photos Streams correctly, no timeout
6 CSV column count All 11 columns present, no missing data

5. Inline Machine ID Edit

Use Case

OCR is not perfect — stickers get dirty, bent, faded, or partially covered. A human can still read "12345" from the sticker even when OCR returns gibberish. Currently the only way to assign the correct machine ID is to re-upload the photo with a different engine/model, or use the manual entry field in the detail view. Neither works in bulk mode. The user needs to tap a failed OCR result and type the correct ID inline, then push GPS immediately.

User Experience

  1. In bulk results, a card with "No OCR match" is tappable
  2. Tap the label → it turns into a text <input> pre-filled with any partial digits
  3. Type the machine ID (e.g. 12345-678901 or just 12345)
  4. Press Enter or tap away → app calls /api/lookup?machine_id=...
  5. If found: card updates to show the asset name, and a Push GPS button appears
  6. If not found: shows "⚠️ No asset match" — user can try again
  7. Works identically in the Previously Processed section

Spec

On card: <span class="ocr-edit" data-idx="N" data-photoid="PHOTO_ID">
  No OCR match
</span>

On click → replace with:
  <input class="ocr-input" data-idx="N" data-photoid="PHOTO_ID"
         placeholder="Type machine ID…" value="">

On blur/Enter → POST /api/assign-machine-id with {photo_id, machine_id}
  → updates photos DB → returns {asset, needs_gps}
  → re-renders bulk card

Tech

  • No backend changes needed for lookup (already has /api/lookup)
  • New endpoint POST /api/assign-machine-id to persist the override to photos.db (sets machine_id field)
  • Frontend: event delegation on bulk cards for the tap handler
  • Debounced lookup (300ms) to avoid hammering the API while typing

Files Touched

  • server.py — add POST /api/assign-machine-id (~20 lines)
  • static/index.html — modify renderBulkResults() to make OCR labels editable (~40 lines)

Test Cases

# Scenario Expected
1 Tap "No OCR match" Label becomes editable input
2 Type valid machine ID Asset loads, card shows asset details, Push GPS appears
3 Type invalid machine ID "⚠️ No asset match" shown, input stays editable
4 Type partial ID (5 digits) Lookup by first 5 digits works
5 Clear input and blur Input reverts to "No OCR match"
6 Assign ID on photo that already has GPS Push GPS button works immediately
7 Assign ID when GPS missing Card shows "No GPS — upload new photo"

6. GPS Proximity Comparison

Use Case

When a photo has GPS coordinates, there may be canteen assets nearby that you haven't photographed yet — or you may have photographed the same machine twice from different angles. Knowing what's within, say, 10 meters helps the auditor decide: "did I already get that one?" or "there's another machine right there."

Also useful for discovering machines whose DB address is wrong (GPS says they're 200m from their listed building).

User Experience

  1. In bulk results, each card with GPS shows a new line: "📍 Nearby: 2 assets within 10m"
  2. Tap the line → expands a list of nearby machines:
    • 🆔 12345 · Ice Machine 300 · 8m away · 📶 has GPS or
    • 🆔 67890 · Soda Dispenser · 12m away ⚠️ no GPS yet
  3. The proximity badge color indicates:
    • 🟢 Green: 0-5m (probable same machine)
    • 🟡 Amber: 5-20m (nearby machines to check)
    • 🔴 Red: >20m but <50m (far neighbor)
  4. Also shown on the bulk map: proximity rings (10m circles) around each new photo pin

Spec

GET /api/nearby?lat=<float>&lng=<float>&radius=<meters>&exclude_photo_id=<optional>

Returns:
  { nearby: [
      { asset_id, machine_id, name, distance_m, latitude, longitude,
        has_gps: bool, building, floor, room }
    ],
    count: N,
    radius_m: 10 }

Tech

  • Spatial query: SQLite with (lat - ?)^2 + (lng - ?)^2 < (rad_deg)^2 approximation. At small radii (<1km), the Euclidean approximation over lat/lng is accurate enough. Formula: 1° lat ≈ 111,320m, 1° lng ≈ 111,320 * cos(lat) at given latitude.
  • Radius default: 10m, configurable via query param
  • Exclusion: pass exclude_photo_id to avoid self-matches (the photo's own asset)
  • Frontend: Leaflet circle markers + expandable inline list in bulk cards

Files Touched

  • server.py — add GET /api/nearby endpoint (~30 lines with spatial math)
  • static/index.html — modify renderBulkResults() to fetch and display nearby data (~60 lines)
  • static/index.html — add Leaflet circle overlays in renderBulkResults() (~10 lines)

Test Cases

# Scenario Expected
1 Photo GPS at (28.5, -81.5), asset at (28.5001, -81.5001) Nearby returns asset, distance ~12m
2 No assets within 10m count: 0, card shows "No nearby assets"
3 Asset at exactly 10.5m Not returned (radius is <10m, not <=)
4 Photo has no GPS Nearby line hidden
5 5 assets within 10m All returned, sorted by distance ascending
6 Nearby includes asset already photographed Excluded by exclude_photo_id
7 Spam-tap the nearby label Only one request fires (debounce)

7. Walk-Path Timeline

Use Case

After a session, the auditor needs to see their actual route through the building to spot coverage gaps. A polyline connecting photo locations in chronological order reveals skipped aisles, missed corners, or areas where photos were taken out of walking order (suggesting you backtracked because you missed something).

User Experience

  1. After bulk processing, a toggle switch above the map: 🗺️ Show Walk Path
  2. On: the map draws a connected polyline between photo pins in upload order, with numbered markers (① → ② → ③ …) showing the sequence
  3. Each segment is color-coded by time gap:
    • 🟢 Green: <30s between photos (natural pace)
    • 🟡 Amber: 30s2m (stopped to look around)
    • 🔴 Red: >2m (took a break or got distracted)
  4. Hover/tap a segment → tooltip: "Walked 15m from Machine A to Machine B in 45s"
  5. Off the map, a timeline list shows each photo in order with distance from previous

Spec

No new backend endpoint. The bulk results already include filename, exif.gps, and
we can derive "upload order" from the response array order. Frontend-only feature.

Timeline data shape (derived client-side):
  [{ step: 1, lat, lng, name, filename, time_gap_s, distance_m }]

Tech

  • Polyline: Leaflet L.polyline(latlngs, {color, weight, opacity}) with gradient segments using L.polylineDecorator or manual segment drawing
  • Segment coloring: Draw N-1 polylines between consecutive points, each with its own color based on time_gap
  • Distance calc: Haversine formula in JS between consecutive GPS coords
  • Time gap: From client-side Date.now() at photo selection time (stored in allPhotos[] array)

Files Touched

  • static/index.html — add walk-path toggle + polyline drawing in renderBulkResults() (~60 lines)
  • static/index.html — store selection timestamps in allPhotos[] (~5 lines)
  • static/index.html — add Leaflet polyline-decorator plugin (CDN) (~1 line)

Test Cases

# Scenario Expected
1 5 photos in a straight line Polyline connects ①→②→③→④→⑤ in order
2 Photos taken 10s apart All segments green
3 One 5-minute gap between photo 3 and 4 Segment between them is red
4 Toggle off → on Polyline clears and redraws
5 Only 1 photo has GPS No polyline drawn, toggle hidden
6 Consecutive photos 500m apart Segment shows correct distance in tooltip
7 Timeline list scrollable with 50 entries Virtual scroll works smoothly

8. 💡 Batch Machine ID Assign

Use Case

Large equipment (walk-in coolers, industrial washers, HVAC units) may need 3-5 photos from different angles to properly document. Currently each photo gets its own card, processed independently. The auditor needs to say "these 4 photos are all of the same machine" and push GPS once for all of them.

Also useful when a machine ID sticker appears in multiple shots (e.g. zoomed out + close up) but only one photo has a readable barcode. The auditor can set the machine ID once and apply it to all linked photos.

User Experience

  1. In bulk results, each card gets a checkbox on the left (hidden by default)
  2. Tap 📎 Batch Select button in the header → checkboxes appear on all cards
  3. Check 2+ cards → a floating action bar appears: "📎 3 selected → Assign all to Machine ID: [________] [🔍 Lookup] [📤 Push GPS]"
  4. Type a machine ID, tap Lookup → confirms the asset name
  5. Tap Push GPS → pushes GPS from every selected photo to that asset (only photos that have GPS; ones without are listed as "no GPS available")
  6. Selected cards get a batch icon and link to the same asset

Spec

POST /api/bulk-assign
  Body: { photo_ids: [int], machine_id: str }
  → Updates photos.machine_id for each photo in DB
  → If any photos have GPS and asset has NULL lat/lng, optionally push GPS

Returns:
  { updated: N, gps_pushed: M, no_gps: [...filenames] }

Tech

  • New endpoint: POST /api/bulk-assign — similar to assign-machine-id but array
  • Frontend: checkbox state array, floating action bar with position: sticky; bottom: 0
  • No extra dependencies: pure CSS for sticky bar, vanilla JS for selection state

Files Touched

  • server.py — add POST /api/bulk-assign (~30 lines)
  • static/index.html — add checkbox mode, selection state, floating bar (~80 lines)
  • static/index.html — modify renderBulkResults() to render checkboxes when in batch mode

Test Cases

# Scenario Expected
1 Select 3 photos, assign machine ID All 3 photos show the matched asset
2 Select 1 photo only Floating bar shows "Select 2+ photos"
3 Assign with all photos having GPS GPS pushed to asset, button shows "✓ Pushed!"
4 Assign with 2 photos having GPS, 1 without GPS pushed, warning lists the one without
5 Deselect all Floating bar disappears
6 Refresh page after batch assign Prev photos section shows the assignment

9. Session Management

Use Case

An auditor does weekly walk-throughs of different buildings. Each session (a batch of photos taken at a specific time/place) should be a first-class object: named, tagged with a building/floor, and reviewable later. Currently all photos go into a flat photos table with no grouping.

Without sessions, answering "when was Building B, Floor 2 last audited?" requires scanning all photo timestamps and cross-referencing GPS against building boundaries.

User Experience

  1. Before starting a bulk process, the app shows: "Session: [📂 New Session ▼]"
  2. Tap it → name the session (e.g. "Building B Floor 2 — May 25")
  3. Or tap 📋 → pick from recent buildings/floors in the DB: Building A Floor 1 · Building A Floor 2 · Building B Floor 1
  4. After processing, the session shows in a new Sessions section above "Previously Processed"
  5. Each session card:
    • "Building B Floor 2 — May 25"
    • 📍 23 photos · 18 matched · 2 need GPS · Last: 10m ago
    • Tap → expands to show all photos from that session
    • Share button → export CSV/KML for just this session
    • Calendar icon → shows on calendar view
  6. Calendar view: toggle to see sessions on a calendar grid. Green = full coverage, amber = partial, red = no data. Tap a date → that session's results.

Spec

New DB table: sessions
  id INTEGER PRIMARY KEY
  name TEXT
  building TEXT
  floor TEXT
  photo_count INTEGER
  matched_count INTEGER
  needs_gps_count INTEGER
  created_at TEXT DEFAULT (datetime('now'))

photos table: add session_id INTEGER REFERENCES sessions(id)

GET /api/sessions
  → [{ id, name, building, floor, photo_count, matched_count, needs_gps_count, created_at }]

GET /api/sessions/{id}
  → session detail + all photos in it

POST /api/sessions
  Body: { name?, building?, floor? }
  → creates session, returns { id }

POST /api/sessions/{id}/close
  → finalizes session (no more photos can be added)

Migration

ALTER TABLE photos ADD COLUMN session_id INTEGER REFERENCES sessions(id);

No data loss — existing photos get session_id = NULL and appear in "Unassigned" section.

Files Touched

  • server.py — add sessions table, CRUD endpoints, migration (~80 lines)
  • server.py — modify /api/bulk-process to accept session_id param (~10 lines)
  • static/index.html — add session picker UI, sessions section, calendar view (~120 lines)

Test Cases

# Scenario Expected
1 Create new session, process 5 photos Session shows in list with count 5
2 Process photos without a session Go to "Unassigned" section (backward compatible)
3 Open session detail Shows all photos with map
4 Calendar view with 3 sessions on different dates Green cells on correct dates
5 Export session as CSV Only that session's photos in the CSV
6 Delete session All photos moved to "Unassigned"

10. GPS Drift Check

Use Case

The canteen DB stores an asset's expected location (from its address/building). When a photo's GPS is far from that expected location, something is wrong:

  • The photo is of a different machine than the sticker suggests (wrong sticker on site?)
  • The asset was moved (machines get relocated without updating the DB)
  • GPS drift (tall buildings, narrow corridors cause coordinate noise up to 30m)
  • The asset's address is wrong in the DB

A flag alerts the auditor to investigate rather than blindly pushing GPS data.

User Experience

  1. In bulk results, a matched asset shows a new badge next to its name:
    • 🟢 GPS OK: photo within 10m of asset's DB coordinates
    • 🟡 ⚠️ Drift 30m: photo is 30m from asset's expected location
    • 🔴 ⚠️⚠️ Drift 200m: photo is 200m away — probable mismatch
  2. Tap the drift badge → explanation tooltip: "Photo GPS is 30m from this asset's DB location (28.5383, -81.3794). Possible: GPS drift, asset was moved, or wrong sticker."
  3. If asset has no DB coordinates → no drift check (show "📍 No DB coords to compare")
  4. On the map: a line connects the photo pin to the asset's DB pin with the distance labeled. Color = drift severity.

Spec

No new endpoint. Drift is calculated client-side from existing data:
  - Photo GPS: from exif.gps (already in bulk result)
  - Asset DB coords: from asset.latitude/asset.longitude (already in bulk result)
  - Distance: Haversine calculation in JS

Severity thresholds:
  < 10m  → green  "GPS OK"
  10-50m → amber  "⚠️ Drift Nm"
  > 50m  → red    "⚠️⚠️ Drift Nm"

Tech

  • Haversine: copy the existing Python Haversine or use a small JS library
  • Rendering: modify renderBulkResults() card template to include drift badge
  • Map line: Leaflet L.polyline between photo pin and asset DB pin (dashed, color = severity)
  • No backend changes: all data already available in the bulk response

Files Touched

  • static/index.html — add haversine function (~10 lines)
  • static/index.html — modify renderBulkResults() to render drift badges (~30 lines)
  • static/index.html — add dashed connecting lines on map (~15 lines)

Test Cases

# Scenario Expected
1 Photo GPS 5m from asset DB coords 🟢 GPS OK badge
2 Photo GPS 25m from asset DB coords 🟡 ⚠️ Drift 25m badge, amber line on map
3 Photo GPS 100m from asset DB coords 🔴 ⚠️⚠️ Drift 100m badge, red line on map
4 Asset has NULL lat/lng in DB "📍 No DB coords to compare" shown
5 No asset match (OCR failed) No drift check shown
6 Drift line on map matches compass direction Line goes from photo pin toward asset DB pin

11. Coverage Heatmap

Use Case

After auditing a large floor, the auditor needs to know did I miss anything? A heatmap overlay on the map shows photo density as a color gradient:

  • Red/hot areas = many photos taken (well covered)
  • Blue/cold areas = few or no photos (potential gaps)
  • White = no data

Combined with the walk-path timeline, this is the fastest way to spot skipped aisles, unvisited rooms, or areas where you didn't take enough establishing shots.

User Experience

  1. In the bulk results map, a toggle 🔥 Heatmap (alongside "🗺️ Show Walk Path")
  2. On: a smooth color gradient overlay appears on the map
  3. The heatmap is computed from all photo GPS points + all asset DB locations
  4. Adjustable intensity slider: low → shows broad coverage patterns; high → shows tight clusters (multiple photos of the same machine)
  5. Radius slider: how far each photo's "influence" extends (default 15m)
  6. Tap a hot spot → shows how many photos contributed to that area

Spec

Frontend-only. Uses leaflet-heat (https://github.com/Leaflet/Leaflet.heat).

Input data:
  - Photo GPS coordinates (from bulk results or prev photos)
  - Asset DB coordinates (from matched assets)

Heatmap options:
  - radius: 15 (default), adjustable 5-50
  - blur: 15 (default)
  - maxZoom: 18
  - gradient: { 0.0: 'blue', 0.3: 'cyan', 0.5: 'lime', 0.8: 'yellow', 1.0: 'red' }

Tech

  • Leaflet.heat: CDN script tag — lightweight (~10KB), no build step
  • Data preparation: Extract [lat, lng, intensity] tuples where intensity is 1.0 for each photo (or higher if multiple photos at the same point)
  • Asset overlay: Optionally include asset DB locations at 0.5 intensity to show where machines exist but have no photos yet (double-check areas)
  • Sliders: HTML <input type="range"> for radius and intensity

Files Touched

  • static/index.html — add Leaflet.heat CDN script (~1 line)
  • static/index.html — add heatmap toggle + sliders (~20 lines)
  • static/index.html — add heatmap layer control in renderBulkResults() and loadPreviousPhotos() (~30 lines)

Test Cases

# Scenario Expected
1 10 photos clustered within 20m Red hotspot at that area
2 2 photos 100m apart Two separate blue spots
3 Toggle heatmap on → off → on Clears and redraws correctly
4 Adjust radius from 15 to 50 Hotspots get larger/smoother
5 50 photos spread across a 200m floor Gradient shows red in high-density areas, blue in gaps
6 Leaflet.heat not loaded (network error) Toggle hidden, no errors in console

Appendix: Implementation Order

Phase 1 (Current) — Core UX fixes

  • Fix API key loading
  • Add reset endpoint
  • Improve maps

Phase 2 — High-impact additions

  1. Inline Machine ID Edit (#5) — smallest scope, biggest daily-ux gain
  2. GPS Proximity (#6) — catches missed machines
  3. GPS Drift Check (#10) — data quality
  4. Walk-Path Timeline (#7) — visual route validation

Phase 3 — Offline + Export + Sessions

  1. Export (#4) — gets data out of the app
  2. Offline Queue (#1) — works anywhere
  3. Session Management (#9) — historical tracking

Phase 4 — Power-user features

  1. Batch Machine ID Assign (#8) — speed for multi-photo equipment
  2. Coverage Heatmap (#11) — completeness guarantee

Last updated: 2026-05-25 Proposed by: Hermes Agent / Shawn