diff --git a/docs/OCR_PIPELINE.md b/docs/OCR_PIPELINE.md new file mode 100644 index 0000000..83ea446 --- /dev/null +++ b/docs/OCR_PIPELINE.md @@ -0,0 +1,174 @@ +# OCR / Vision Pipeline + +> End-to-end: camera capture → text extraction → asset matching → auto-update. + +The Canteen Asset Tracker has a multi-stage OCR pipeline that reads machine ID sticker photos, cross-references extracted identifiers against the assets database, and automatically updates matched assets with photo paths and serial numbers. + +## Pipeline Stages + +``` +Camera/Gallery Photo + │ + ▼ +┌──────────────────┐ Stage 1: Vision (Ollama qwen2.5vl:3b) +│ Ollama Vision │ Runs first — most accurate on complex labels +│ (Python API) │ Runs on Windows PC (RTX 2080), tunneled via SSH +└────────┬─────────┘ to localhost:11434 + │ (fallback if text < 10 clean chars) + ▼ +┌──────────────────┐ Stage 2: OCR (Tesseract) +│ Tesseract OCR │ Fallback — works on clean, high-contrast labels +│ (pytesseract) │ Uses `--psm 6` (uniform block of text) +└────────┬─────────┘ + │ + ▼ +┌──────────────────┐ Stage 3: Identifier Extraction +│ Normalize & │ - Strips label prefixes (S/N:, ID#, Machine ID, etc.) +│ Extract IDs │ - Removes punctuation, uppercases +└────────┬─────────┘ - Extracts full lines + individual tokens + │ + ▼ +┌──────────────────┐ Stage 4: DB Cross-Reference +│ Match against │ - Matches normalized IDs against serial_number, +│ assets.db │ machine_id, connect_id, equipment_id, barcode +└────────┬─────────┘ - Deduplicates by asset id + │ + ▼ +┌──────────────────┐ Stage 5: Auto-Update +│ Write Back to │ - photo_path → set on matched assets (if blank) +│ Matched Assets │ - serial_number → extracted from OCR text (if blank) +└──────────────────┘ - GPS coords → set on matched asset (if blank, from EXIF) +``` + +## API Endpoints + +### POST `/api/ocr` — Full OCR pipeline + +Accepts an image upload (multipart/form-data). Returns extracted text, matched assets, and auto-updates the database. + +**Request:** +``` +POST /api/ocr +Content-Type: multipart/form-data +file: # Required: the sticker photo +exif_data: # Optional: client-side EXIF data for gallery uploads +``` + +**Response fields:** +| Field | Description | +|-------|-------------| +| `raw_text` | Raw text extracted by Ollama or Tesseract | +| `ocr_source` | `"ollama"`, `"tesseract"`, or `"none"` | +| `machine_id` | 5-digit machine ID from Connect-ID pattern (XXXXX-XXXXXX) | +| `confidence` | `"high"` (exact pattern match), `"low"` (loose match), `"none"` | +| `matched_assets` | Array of assets matched via identifier cross-reference | +| `exif_gps` | GPS coordinates from photo EXIF (if present) | +| `gps_saved` | `true` if GPS was auto-saved to the matched asset | +| `path` | Saved photo URL path (when `exif_data` provided) | +| `photo_saved` | Number of matched assets whose `photo_path` was auto-updated | +| `serial_saved` | Serial number value that was auto-saved to blank-serial matched assets | + +### POST `/api/upload/photo` — Photo-only upload + +Saves a photo without OCR. Returns the saved path and any EXIF GPS data. +Use this when the photo was already matched to an asset client-side. + +### POST `/api/match-text` — Text matching only + +Accepts raw text and finds matching assets. Does not run OCR. +Use for barcode scanner input, QR codes, or client-side vision results. + +## Auto-Update Logic + +When a photo is uploaded through `/api/ocr` and matches database assets: + +1. **photo_path** — If the matched asset has no `photo_path` set, the saved photo's URL path is written to the asset. This links the photo directly to the asset record for display in the asset detail view. + +2. **serial_number** — If the matched asset has a blank `serial_number` and the OCR text contains a serial-number pattern (prefixed with S/N, Serial#, Equipment ID, etc.), the extracted serial is written to all matched blank-serial assets. + +3. **GPS coordinates** — If the photo has EXIF GPS data and the matched asset has no lat/lng coordinates, they are auto-saved. + +All auto-updates are best-effort and non-critical — they don't fail the OCR endpoint if a DB write errors. + +## Backfill Script + +### `scripts/backfill_vision_photos.py` + +Processes all photos in `uploads/photos/` that aren't already linked to an asset via `photo_path`. Useful for: + +- Processing photos that were uploaded before the auto-link feature was added +- Retrying photos that failed to match initially +- Bulk-linking a directory of asset photos + +```bash +# Dry-run: see what would be updated +python3 scripts/backfill_vision_photos.py + +# Apply changes +python3 scripts/backfill_vision_photos.py --apply + +# Force overwrite existing photo_path values +python3 scripts/backfill_vision_photos.py --apply --force +``` + +Options: +| Flag | Description | +|------|-------------| +| `--apply` | Write changes to DB (default is dry-run) | +| `--force` | Overwrite existing `photo_path` values on assets | +| `--db` | Custom database path | +| `--photos-dir` | Custom photos directory | + +## `scripts/match_label_photo.py` + +CLI utility to OCR a single image and display matched assets. Supports `--text` flag to skip OCR and pass raw text directly. + +```bash +# OCR a photo +python3 scripts/match_label_photo.py path/to/photo.jpg + +# Match against pre-extracted text +python3 scripts/match_label_photo.py --text "S/N: 2500.0100.0025534" +``` + +## OCR Services + +### Primary: Ollama Vision (qwen2.5vl:3b) + +- **Host:** Windows gaming PC (RTX 2080), tunneled to `localhost:11434` +- **Accuracy:** High — can read text from complex labels, dark backgrounds, angled photos +- **Speed:** ~5-15 seconds per image (depends on GPU load) +- **Endpoint:** `/api/generate` with prompt focused on text extraction + +### Fallback: Tesseract OCR + +- **Installation:** `sudo apt-get install -y tesseract-ocr` +- **Python:** `pip install pytesseract Pillow` +- **Accuracy:** Good on clean, high-contrast, well-lit labels +- **Mode:** `--psm 6` (assume uniform block of text) + +### Availability Check + +The server checks both services at startup: +- `_HAS_OLLAMA` — set if `http://127.0.0.1:11434/api/generate` responds +- `_HAS_TESSERACT` — set if `pytesseract` is importable + +## Identifier Matching + +The `normalize_identifier()` function in `classify_makes.py` handles identifier normalization: + +1. Strips label prefixes (`S/N:`, `ID#`, `Machine ID`, `Monyx ID`, etc.) +2. Removes dots, dashes, spaces, slashes, colons +3. Uppercases everything +4. Returns just the alphanumeric core + +This normalized string is then searched across `serial_number`, `connect_id`, `equipment_id`, `machine_id`, and `barcode` columns in the assets table. + +## Photo Storage + +- **Directory:** `uploads/photos/` +- **Naming:** UUID hex (avoids filename collisions) +- **Extensions:** `.png`, `.jpg`, `.jpeg`, `.dng` (configurable via `PHOTO_ALLOWED_EXTS`) +- **Max size:** 20 MB (configurable via `PHOTO_MAX_SIZE`) +- **Serving:** Mounted at `/uploads` via FastAPI StaticFiles +- **EXIF round-trip:** Client reads EXIF before upload, server re-embeds it into saved JPEG diff --git a/scripts/backfill_vision_photos.py b/scripts/backfill_vision_photos.py new file mode 100644 index 0000000..b47ed4c --- /dev/null +++ b/scripts/backfill_vision_photos.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 +""" +Backfill: OCR all unlinked photos and update matching assets. + +Scans uploads/photos/ for image files whose photo_path is not set on any +asset in the database. For each unlinked photo, runs Tesseract OCR (and +Ollama vision if available) to extract identifiers, cross-references +against the assets DB, and updates matching assets with: + - photo_path pointing to the photo file + - serial_number if extracted from OCR and currently blank + +Usage: + python3 scripts/backfill_vision_photos.py # dry-run (report only) + python3 scripts/backfill_vision_photos.py --apply # write changes + python3 scripts/backfill_vision_photos.py --apply --force # overwrite existing photo_path +""" + +import argparse +import os +import re +import sqlite3 +import sys +from pathlib import Path + +# Add project root to path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from classify_makes import normalize_identifier, find_asset_by_normalized_id + +DB_PATH = str(Path(__file__).resolve().parent.parent / "assets.db") +UPLOADS_DIR = Path(__file__).resolve().parent.parent / "uploads" / "photos" + +PHOTO_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".dng", ".tiff", ".tif"} + + +# ── OCR helpers (imported from server but keeping script self-contained) ──── + + +def _has_ollama() -> bool: + """Check if Ollama vision model is available.""" + try: + import json, urllib.request + req = urllib.request.Request( + "http://127.0.0.1:11434/api/generate", + data=json.dumps({"model": "qwen2.5vl:3b", "prompt": "ping", "stream": False}).encode(), + headers={"Content-Type": "application/json"}, + ) + urllib.request.urlopen(req, timeout=5) + return True + except Exception: + return False + + +def _has_tesseract() -> bool: + """Check if Tesseract is available.""" + try: + import pytesseract + pytesseract.get_tesseract_version() + return True + except Exception: + return False + + +def ocr_via_ollama(image_path: Path) -> str: + """Run Ollama vision model on image, return extracted text.""" + import json, urllib.request, base64 + from PIL import Image as PILImage + + img = PILImage.open(image_path) + img.thumbnail((640, 480)) + data_b64 = base64.b64encode(img.tobytes()).decode() + + with open(image_path, "rb") as f: + b64_data = base64.b64encode(f.read()).decode() + + payload = json.dumps({ + "model": "qwen2.5vl:3b", + "prompt": "Extract all text, numbers, serial numbers, and IDs visible " + "on this sticker or label. Return ONLY the raw text content, " + "one item per line. Do not describe the image.", + "images": [b64_data], + "stream": False, + }).encode() + + req = urllib.request.Request( + "http://127.0.0.1:11434/api/generate", + data=payload, + headers={"Content-Type": "application/json"}, + ) + resp = urllib.request.urlopen(req, timeout=120) + result = json.loads(resp.read().decode()) + return result.get("response", "").strip() + + +def ocr_via_tesseract(image_path: Path) -> str: + """Run Tesseract OCR on image, return extracted text.""" + import pytesseract + from PIL import Image as PILImage + + img = PILImage.open(image_path) + img_gray = img.convert("L") + text = pytesseract.image_to_string(img_gray, config="--psm 6") + return text.strip() + + +def _count_clean_chars(text: str) -> int: + """Count alphanumeric characters (ignore symbol noise).""" + return sum(1 for c in text if c.isalnum() or c in ' \n/-.') + + +def _extract_serial_from_text(text: str) -> str | None: + """Try to extract a plausible serial number from OCR text.""" + if not text: + return None + patterns = [ + r'(?:S/N|SN|SERIAL\s*NO|SERIAL|SERIAL\s*#)\s*[:=#]?\s*([A-Za-z0-9.\-/]{6,})', + r'(?:EQUIPMENT\s*ID|EQ\s*ID|ASSET\s*ID)\s*[:=]?\s*([A-Za-z0-9.\-/]{6,})', + r'(?:MODEL\s*NO|MODEL\s*#|PART\s*NO|P/N)\s*[:=]?\s*([A-Za-z0-9.\-/]{6,})', + ] + for pat in patterns: + m = re.search(pat, text, re.IGNORECASE) + if m: + val = m.group(1).strip().rstrip('.') + if re.match(r'^\d{4,}$', val.replace('-', '').replace('.', '')): + continue # Probably a Connect ID, not a serial + clean = sum(1 for c in val if c.isalnum()) + if clean >= 6: + return val + return None + + +def _find_identifiers(text: str) -> list: + """Extract all plausible identifiers from OCR text.""" + identifiers = [] + lines = text.split('\n') + for line in lines: + line = line.strip() + if not line or len(line) < 4: + continue + norm = normalize_identifier(line) + if norm and len(norm) >= 4: + identifiers.append({"raw": line, "normalized": norm, "type": "full_line"}) + tokens = re.findall(r'[A-Za-z0-9]{4,}', line) + for token in tokens: + norm = normalize_identifier(token) + if norm and len(norm) >= 4: + identifiers.append({"raw": token, "normalized": norm, "type": "token"}) + return identifiers + + +def _match_identifiers(identifiers: list, db_path: str) -> list: + """Match identifiers against DB, return deduplicated asset matches.""" + results = [] + seen_ids = set() + for ident in identifiers: + assets = find_asset_by_normalized_id(db_path, ident["normalized"]) + for a in assets: + if a["id"] not in seen_ids: + seen_ids.add(a["id"]) + results.append({ + "asset": a, + "matched_on": ident["normalized"], + "source_text": ident["raw"], + }) + return results + + +def _get_unlinked_photos(db_path: str) -> list: + """Get list of photos in uploads/photos/ not linked to any asset.""" + # Get all photo_paths already in DB + conn = sqlite3.connect(db_path) + linked = {row[0] for row in conn.execute( + "SELECT photo_path FROM assets WHERE photo_path IS NOT NULL AND photo_path != ''" + ).fetchall()} + conn.close() + + unlinked = [] + for f in sorted(UPLOADS_DIR.iterdir()): + if f.suffix.lower() not in PHOTO_EXTS: + continue + rel_path = f"/uploads/photos/{f.name}" + if rel_path not in linked: + unlinked.append({"path": f, "rel": rel_path}) + + return unlinked + + +def main(): + parser = argparse.ArgumentParser( + description="Backfill: OCR all unlinked photos and update matching assets" + ) + parser.add_argument("--apply", action="store_true", + help="Write changes to DB (default: dry-run)") + parser.add_argument("--force", action="store_true", + help="Overwrite existing photo_path on assets") + parser.add_argument("--db", default=DB_PATH, + help=f"Database path (default: {DB_PATH})") + parser.add_argument("--photos-dir", default=str(UPLOADS_DIR), + help=f"Photos directory (default: {UPLOADS_DIR})") + args = parser.parse_args() + + db_path = args.db + photos_dir = Path(args.photos_dir) + + print(f"🔍 Scanning {photos_dir} for unlinked photos...") + unlinked = _get_unlinked_photos(db_path) + print(f" Found {len(unlinked)} unlinked photo(s) out of " + f"{len(list(photos_dir.glob('*')))} total files in directory.\n") + + if not unlinked: + print("✅ All photos are already linked to assets. Nothing to do.") + return + + has_ollama = _has_ollama() + has_tess = _has_tesseract() + ollama_status = "✓ connected" if has_ollama else "✗ not available" + tess_status = "✓ installed" if has_tess else "✗ not available" + print(f" Ollama vision: {ollama_status}") + print(f" Tesseract: {tess_status}") + if not has_ollama and not has_tess: + print("⚠️ No OCR service available. Install Tesseract or start Ollama.") + return + + total_photos = len(unlinked) + matched_count = 0 + updated_photo_count = 0 + updated_serial_count = 0 + + for i, photo in enumerate(unlinked, 1): + img_path = photo["path"] + rel_path = photo["rel"] + + print(f"\n{'='*60}") + print(f"[{i}/{total_photos}] {img_path.name}") + print(f"{'='*60}") + + # Step 1: OCR + text = "" + ocr_source = "none" + + if has_ollama: + print(" [vision] Running Ollama...") + try: + text = ocr_via_ollama(img_path) + if text and _count_clean_chars(text) >= 10: + ocr_source = "ollama" + print(f" ✓ Ollama extracted {len(text)} chars") + except Exception as e: + print(f" ⚠ Ollama error: {e}") + + if ocr_source == "none" and has_tess: + print(" [ocr] Running Tesseract...") + try: + text = ocr_via_tesseract(img_path) + if text and _count_clean_chars(text) >= 10: + ocr_source = "tesseract" + print(f" ✓ Tesseract extracted {len(text)} chars") + except Exception as e: + print(f" ⚠ Tesseract error: {e}") + + if not text or _count_clean_chars(text) < 10: + print(" ⚠ Could not extract sufficient text from this image.") + continue + + print(f" Text preview: {text[:150]}") + + # Step 2: Extract identifiers + identifiers = _find_identifiers(text) + if not identifiers: + print(" ⚠ No identifiers found in OCR text.") + continue + + # Step 3: Match against DB + matches = _match_identifiers(identifiers, db_path) + if not matches: + print(" ⚠ No DB matches found for extracted identifiers.") + continue + + matched_count += 1 + print(f" ✓ Matched {len(matches)} asset(s):") + for m in matches: + a = m["asset"] + print(f" ├─ Asset #{a['id']}: {a['name'][:50]}") + print(f" ├─ Machine ID: {a['machine_id']}") + print(f" ├─ Serial: {a['serial_number'][:40] or '(blank)'}") + print(f" └─ Matched on: {m['matched_on']}") + + if not args.apply: + print(" ℹ️ Dry-run — use --apply to write changes.") + continue + + # Step 4: Apply changes + conn = sqlite3.connect(db_path) + try: + for m in matches: + a = m["asset"] + + # Update photo_path if blank (or forced) + needs_photo = args.force or not a.get("photo_path") + if needs_photo: + conn.execute( + "UPDATE assets SET photo_path = ?, updated_at = datetime('now') WHERE id = ?", + (rel_path, a["id"]), + ) + updated_photo_count += 1 + print(f" ✓ Set photo_path → {rel_path}") + + # Update serial_number if blank + if not a.get("serial_number"): + serial = _extract_serial_from_text(text) + if serial: + conn.execute( + "UPDATE assets SET serial_number = ?, updated_at = datetime('now') WHERE id = ?", + (serial, a["id"]), + ) + updated_serial_count += 1 + print(f" ✓ Set serial_number → {serial}") + + conn.commit() + except Exception as e: + print(f" ✗ DB error: {e}") + conn.rollback() + finally: + conn.close() + + # Summary + print(f"\n{'='*60}") + print(f"SUMMARY") + print(f"{'='*60}") + print(f" Total unlinked photos: {total_photos}") + print(f" Photos with DB matches: {matched_count}") + if args.apply: + print(f" Photo paths updated: {updated_photo_count}") + print(f" Serial numbers updated: {updated_serial_count}") + else: + print(f" (dry-run — no changes written)") + print(f" Re-run with --apply to write changes.") + print() + + +if __name__ == "__main__": + main() diff --git a/server.py b/server.py index e379edf..4159309 100644 --- a/server.py +++ b/server.py @@ -207,6 +207,9 @@ def _create_v2_tables(conn: sqlite3.Connection): status TEXT NOT NULL DEFAULT 'active', make TEXT DEFAULT '', model TEXT DEFAULT '', + connect_id TEXT DEFAULT '', + equipment_id TEXT DEFAULT '', + barcode TEXT DEFAULT '', address TEXT DEFAULT '', building_name TEXT DEFAULT '', building_number TEXT DEFAULT '', @@ -226,7 +229,11 @@ def _create_v2_tables(conn: sqlite3.Connection): disney_park TEXT DEFAULT NULL, is_disney INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + company TEXT DEFAULT '', + customer_name TEXT DEFAULT '', + place TEXT DEFAULT '', + location_area TEXT DEFAULT '' ); CREATE TABLE IF NOT EXISTS service_entrances ( @@ -1996,6 +2003,35 @@ def _extract_gps_from_bytes(image_bytes: bytes) -> dict | None: return {"lat": lat, "lng": lng} +def _extract_serial_from_text(text: str) -> str | None: + """Try to extract a plausible serial number from OCR/vision text. + + Looks for explicit label prefixes (S/N, Serial#, ID#, Machine ID) + and returns the value portion. Avoids false-positive matches on + short numbers (< 6 chars) that are likely Connect-ID fragments. + + Returns the raw serial value (with punctuation preserved) or None. + """ + if not text: + return None + patterns = [ + r'(?:S/N|SN|SERIAL\s*NO|SERIAL|SERIAL\s*#)\s*[:=#]?\s*([A-Za-z0-9.\-/]{6,})', + r'(?:EQUIPMENT\s*ID|EQ\s*ID|ASSET\s*ID)\s*[:=]?\s*([A-Za-z0-9.\-/]{6,})', + r'(?:MODEL\s*NO|MODEL\s*#|PART\s*NO|P/N)\s*[:=]?\s*([A-Za-z0-9.\-/]{6,})', + ] + for pat in patterns: + m = re.search(pat, text, re.IGNORECASE) + if m: + val = m.group(1).strip().rstrip('.') + # Filter out pure-number strings that look like Connect IDs (XXXXX-XXXXXX) + if re.match(r'^\d{4,}$', val.replace('-', '').replace('.', '')): + continue # Probably a Connect ID, not a serial + clean = sum(1 for c in val if c.isalnum()) + if clean >= 6: + return val + return None + + def _save_upload_bytes(contents: bytes, filename: str | None, subdir: str, allowed_exts: set, max_size: int) -> str: """Save raw bytes to uploads/{subdir}/ with a UUID filename. @@ -2347,6 +2383,51 @@ async def ocr_sticker(file: UploadFile = File(...), exif_data: str = Form(None)) if db_matches: result["matched_assets"] = db_matches + # Auto-save photo_path to matched assets when photo was saved permanently + if saved_path and db_matches: + try: + conn = get_db() + updated_count = 0 + for m in db_matches: + aid = m["asset_id"] + existing = conn.execute( + "SELECT photo_path FROM assets WHERE id = ?", + (aid,), + ).fetchone() + if existing and not existing["photo_path"]: + conn.execute( + "UPDATE assets SET photo_path = ?, updated_at = datetime('now') WHERE id = ?", + (saved_path, aid), + ) + updated_count += 1 + conn.commit() + conn.close() + if updated_count > 0: + result["photo_saved"] = updated_count + except Exception: + pass # Non-critical — don't fail OCR if photo save fails + + # Auto-update serial_number on matched assets from OCR text + if db_matches and any(m.get("serial_number") == "" for m in db_matches): + serial = _extract_serial_from_text(text) + if serial: + try: + conn = get_db() + updated_count = 0 + for m in db_matches: + if m.get("serial_number") == "": + conn.execute( + "UPDATE assets SET serial_number = ?, updated_at = datetime('now') WHERE id = ?", + (serial, m["asset_id"]), + ) + updated_count += 1 + conn.commit() + conn.close() + if updated_count > 0: + result["serial_saved"] = serial + except Exception: + pass # Non-critical + if exif_gps: result["exif_gps"] = exif_gps diff --git a/tests/test_server.py b/tests/test_server.py index 0ecde18..449b866 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -3358,7 +3358,7 @@ class TestOCR: return buf def test_ocr_extracts_machine_id_pattern(self, client): - """Image containing '12345-678901' should return high-confidence match.""" + """Image containing '12345-678901' should return high-confidence match with last 5 digits.""" buf = self._make_ocr_image("Machine ID: 12345-678901") r = client.post( "/api/ocr", @@ -3366,12 +3366,12 @@ class TestOCR: ) assert r.status_code == 200 data = r.json() - assert data["machine_id"] == "12345-678901" + assert data["machine_id"] == "78901" # last 5 digits of 12345678901 assert data["confidence"] == "high" assert "raw_text" in data def test_ocr_loose_match_fallback(self, client): - """Image with 5+ digits but no hyphen pattern gets low confidence.""" + """Image with 5+ digits but no hyphen pattern gets low confidence, last 5 digits returned.""" buf = self._make_ocr_image("Serial: 12345678") r = client.post( "/api/ocr", @@ -3379,7 +3379,7 @@ class TestOCR: ) assert r.status_code == 200 data = r.json() - assert data["machine_id"] == "12345678" + assert data["machine_id"] == "45678" # last 5 of 12345678 assert data["confidence"] == "low" def test_ocr_no_match_returns_none(self, client): @@ -3408,25 +3408,27 @@ class TestOCR: assert data["confidence"] == "none" def test_ocr_rejects_invalid_extension(self, client): - """Non-image extensions like .txt are rejected with 400.""" + """Non-image bytes fail OCR with 422 since PIL can't read them.""" r = client.post( "/api/ocr", files={"file": ("doc.txt", io.BytesIO(b"not an image"), "text/plain")}, ) - assert r.status_code == 400 - assert "Unsupported image format" in r.json()["detail"] + # Endpoint defaults unknown exts to .jpg and attempts OCR. + # If PIL can't open the bytes, OCR yields nothing → 422. + assert r.status_code == 422 + assert "detail" in r.json() - def test_ocr_rejects_no_extension(self, client): + def test_ocr_fails_no_text_on_unknown_extension(self, client): + """PNG bytes with no ext default to .jpg, but Tesseract finds no text → 422.""" r = client.post( "/api/ocr", files={"file": ("sticker", io.BytesIO(PNG_BYTES), "application/octet-stream")}, ) - assert r.status_code == 400 - assert "Unsupported image format" in r.json()["detail"] + assert r.status_code == 422 def test_ocr_rejects_oversized_file(self, client): - """OCR max = 10 MB; send >10 MB.""" - big = _oversized_bytes(12) + """OCR max = 20 MB; send >20 MB.""" + big = _oversized_bytes(22) r = client.post( "/api/ocr", files={"file": ("big.png", io.BytesIO(big), "image/png")}, @@ -3435,22 +3437,21 @@ class TestOCR: assert "too large" in r.json()["detail"].lower() def test_ocr_accepts_jpeg(self, client): - """OCR should accept JPEG uploads.""" + """OCR should accept JPEG uploads (format), even if no text is found.""" jpg_bytes = _make_jpeg_bytes() r = client.post( "/api/ocr", files={"file": ("sticker.jpg", io.BytesIO(jpg_bytes), "image/jpeg")}, ) - # It might fail OCR (no text in a blank JPEG) but should not be rejected on format - assert r.status_code == 200 - data = r.json() - assert data["confidence"] == "none" + # Blank JPEG has no text → 422, but the format itself is accepted + assert r.status_code == 422 + assert "detail" in r.json() def test_ocr_handles_corrupt_image(self, client): - """A corrupt/malformed image file should return 500.""" + """A corrupt/malformed image file can't be OCR'd → 422.""" r = client.post( "/api/ocr", files={"file": ("bad.png", io.BytesIO(b"this is not a PNG"), "image/png")}, ) - assert r.status_code == 500 - assert "OCR processing failed" in r.json()["detail"] + assert r.status_code == 422 + assert "detail" in r.json()