Commit ·
479f206
1
Parent(s): 6286dd8
npm run dev
Browse files- backend/README.md +137 -0
- backend/__init__.py +2 -0
- backend/main.py +158 -0
- backend/requirements.txt +10 -0
- backend/utils/image_processing.py +88 -0
- backend/utils/ocr.py +34 -0
- backend/utils/supabase_helpers.py +71 -0
backend/README.md
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Slip Scanner Backend (FastAPI)
|
| 2 |
+
|
| 3 |
+
FastAPI service exposing a /scan endpoint to process receipt images:
|
| 4 |
+
|
| 5 |
+
- Validates Supabase user via Authorization: Bearer <supabase_jwt>
|
| 6 |
+
- Detects edges and crops the receipt (OpenCV, perspective transform)
|
| 7 |
+
- Performs OCR via Hugging Face Inference API (TrOCR)
|
| 8 |
+
- Uploads the cropped image to Supabase Storage
|
| 9 |
+
- Inserts a row into the slips table with extracted metadata
|
| 10 |
+
|
| 11 |
+
## Prerequisites
|
| 12 |
+
|
| 13 |
+
- Python 3.11+
|
| 14 |
+
- Supabase project with Storage and Postgres
|
| 15 |
+
- Hugging Face account and API token
|
| 16 |
+
|
| 17 |
+
## Environment Variables
|
| 18 |
+
|
| 19 |
+
Create a .env file in backend/ with:
|
| 20 |
+
|
| 21 |
+
SUPABASE_URL=...
|
| 22 |
+
SUPABASE_ANON_KEY=...
|
| 23 |
+
SUPABASE_SERVICE_ROLE_KEY=...
|
| 24 |
+
SUPABASE_STORAGE_BUCKET=slips
|
| 25 |
+
|
| 26 |
+
HUGGINGFACE_API_TOKEN=...
|
| 27 |
+
HF_OCR_MODEL=microsoft/trocr-base-printed
|
| 28 |
+
|
| 29 |
+
FRONTEND_CORS_ORIGIN=http://localhost:3000
|
| 30 |
+
|
| 31 |
+
Notes:
|
| 32 |
+
|
| 33 |
+
- Use the Service Role key server-side only. Never expose it to the frontend.
|
| 34 |
+
- If you prefer signed URLs, adjust upload_bytes_to_supabase_storage accordingly and do not make the bucket public.
|
| 35 |
+
|
| 36 |
+
## Install & Run (Windows PowerShell)
|
| 37 |
+
|
| 38 |
+
cd backend
|
| 39 |
+
python -m venv .venv
|
| 40 |
+
. .venv\Scripts\Activate.ps1
|
| 41 |
+
pip install -r requirements.txt
|
| 42 |
+
uvicorn backend.main:app --reload --host 0.0.0.0 --port 8000
|
| 43 |
+
|
| 44 |
+
## API
|
| 45 |
+
|
| 46 |
+
### Health
|
| 47 |
+
|
| 48 |
+
GET http://localhost:8000/health
|
| 49 |
+
|
| 50 |
+
### Scan
|
| 51 |
+
|
| 52 |
+
POST http://localhost:8000/scan
|
| 53 |
+
Authorization: Bearer <supabase_jwt>
|
| 54 |
+
Content-Type: multipart/form-data
|
| 55 |
+
file=@/path/to/receipt.jpg
|
| 56 |
+
|
| 57 |
+
cURL example:
|
| 58 |
+
|
| 59 |
+
curl -X POST http://localhost:8000/scan \
|
| 60 |
+
-H "Authorization: Bearer $SUPABASE_JWT" \
|
| 61 |
+
-F "file=@receipt.jpg"
|
| 62 |
+
|
| 63 |
+
Response:
|
| 64 |
+
{
|
| 65 |
+
"ok": true,
|
| 66 |
+
"slip": {
|
| 67 |
+
"id": "...",
|
| 68 |
+
"user_id": "...",
|
| 69 |
+
"image_url": "https://...",
|
| 70 |
+
"amount": 250.0,
|
| 71 |
+
"category": "Food",
|
| 72 |
+
"raw_text": "...",
|
| 73 |
+
"created_at": "2025-09-15T10:00:00Z",
|
| 74 |
+
"crop_confidence": 0.8
|
| 75 |
+
}
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
## Supabase Setup
|
| 79 |
+
|
| 80 |
+
### Storage Bucket (public or signed)
|
| 81 |
+
|
| 82 |
+
- Create bucket slips.
|
| 83 |
+
- For public previews, enable public read on the bucket or a specific folder per user. Alternatively, keep private and generate signed URLs server-side.
|
| 84 |
+
|
| 85 |
+
### Table
|
| 86 |
+
|
| 87 |
+
Create table slips (adjust types as desired):
|
| 88 |
+
|
| 89 |
+
create table if not exists public.slips (
|
| 90 |
+
id uuid primary key default gen_random_uuid(),
|
| 91 |
+
user_id uuid not null,
|
| 92 |
+
image_path text not null,
|
| 93 |
+
image_url text,
|
| 94 |
+
raw_text text,
|
| 95 |
+
amount numeric,
|
| 96 |
+
category text,
|
| 97 |
+
crop_confidence real,
|
| 98 |
+
created_at timestamptz not null default now()
|
| 99 |
+
);
|
| 100 |
+
|
| 101 |
+
alter table public.slips enable row level security;
|
| 102 |
+
|
| 103 |
+
RLS policies (only owner reads/writes their slips):
|
| 104 |
+
|
| 105 |
+
create policy "slips select own" on public.slips
|
| 106 |
+
for select
|
| 107 |
+
using (auth.uid() = user_id);
|
| 108 |
+
|
| 109 |
+
create policy "slips insert own" on public.slips
|
| 110 |
+
for insert
|
| 111 |
+
with check (auth.uid() = user_id);
|
| 112 |
+
|
| 113 |
+
create policy "slips update own" on public.slips
|
| 114 |
+
for update
|
| 115 |
+
using (auth.uid() = user_id);
|
| 116 |
+
|
| 117 |
+
### Frontend Config
|
| 118 |
+
|
| 119 |
+
In Next.js .env.local:
|
| 120 |
+
|
| 121 |
+
NEXT_PUBLIC_API_URL=http://localhost:8000
|
| 122 |
+
NEXT_PUBLIC_SUPABASE_URL=...
|
| 123 |
+
NEXT_PUBLIC_SUPABASE_ANON_KEY=...
|
| 124 |
+
|
| 125 |
+
From the client, include the Supabase session JWT as the Authorization header when calling /scan.
|
| 126 |
+
|
| 127 |
+
## Design Notes (rules.md alignment)
|
| 128 |
+
|
| 129 |
+
- Mobile-first UX is handled in the Next.js app; backend is stateless and fast.
|
| 130 |
+
- No sensitive keys in frontend; backend uses Service Role for Storage/DB.
|
| 131 |
+
- Performance: edge detection and OCR happen server-side; images are resized and optimized.
|
| 132 |
+
- Security: RLS ensures users only see their own slips; Storage access can be scoped.
|
| 133 |
+
|
| 134 |
+
## Dev Tips
|
| 135 |
+
|
| 136 |
+
- OpenAPI docs: http://localhost:8000/docs
|
| 137 |
+
- If HF model cold-starts and returns 503, retry the request after a few seconds.
|
backend/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Makes backend a package for uvicorn import paths
|
| 2 |
+
|
backend/main.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException, Header
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from starlette.responses import JSONResponse
|
| 4 |
+
import io
|
| 5 |
+
import os
|
| 6 |
+
import uuid
|
| 7 |
+
from datetime import datetime, timezone
|
| 8 |
+
|
| 9 |
+
from utils.image_processing import crop_receipt_from_image
|
| 10 |
+
from utils.ocr import run_hf_ocr
|
| 11 |
+
from utils.supabase_helpers import (
|
| 12 |
+
fetch_user_from_supabase_token,
|
| 13 |
+
upload_bytes_to_supabase_storage,
|
| 14 |
+
insert_slip_row,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
APP_NAME = "Slip Scanner Backend"
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
from dotenv import load_dotenv # type: ignore
|
| 22 |
+
load_dotenv()
|
| 23 |
+
except Exception:
|
| 24 |
+
# Optional dependency; ignore if not present
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
app = FastAPI(title=APP_NAME)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
frontend_origin = os.getenv("FRONTEND_CORS_ORIGIN", "*")
|
| 31 |
+
app.add_middleware(
|
| 32 |
+
CORSMiddleware,
|
| 33 |
+
allow_origins=[frontend_origin] if frontend_origin != "*" else ["*"],
|
| 34 |
+
allow_credentials=True,
|
| 35 |
+
allow_methods=["*"],
|
| 36 |
+
allow_headers=["*"],
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@app.get("/health")
|
| 41 |
+
def health():
|
| 42 |
+
required_env = [
|
| 43 |
+
"SUPABASE_URL",
|
| 44 |
+
"SUPABASE_ANON_KEY",
|
| 45 |
+
"SUPABASE_SERVICE_ROLE_KEY",
|
| 46 |
+
"SUPABASE_STORAGE_BUCKET",
|
| 47 |
+
"HUGGINGFACE_API_TOKEN",
|
| 48 |
+
]
|
| 49 |
+
missing = [k for k in required_env if not os.getenv(k)]
|
| 50 |
+
return {
|
| 51 |
+
"service": APP_NAME,
|
| 52 |
+
"status": "ok" if not missing else "degraded",
|
| 53 |
+
"missing_env": missing,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@app.post("/scan")
|
| 58 |
+
async def scan(
|
| 59 |
+
file: UploadFile = File(...),
|
| 60 |
+
authorization: str | None = Header(default=None, convert_underscores=False),
|
| 61 |
+
):
|
| 62 |
+
# Validate auth header
|
| 63 |
+
if not authorization or not authorization.lower().startswith("bearer "):
|
| 64 |
+
raise HTTPException(status_code=401, detail="Missing or invalid Authorization header")
|
| 65 |
+
|
| 66 |
+
user_jwt = authorization.split(" ", 1)[1]
|
| 67 |
+
user = fetch_user_from_supabase_token(user_jwt)
|
| 68 |
+
if not user or not user.get("id"):
|
| 69 |
+
raise HTTPException(status_code=401, detail="Invalid Supabase session")
|
| 70 |
+
|
| 71 |
+
user_id = user["id"]
|
| 72 |
+
|
| 73 |
+
# Read upload
|
| 74 |
+
original_bytes = await file.read()
|
| 75 |
+
if not original_bytes:
|
| 76 |
+
raise HTTPException(status_code=400, detail="Empty file upload")
|
| 77 |
+
|
| 78 |
+
# Crop receipt via edge detection & perspective transform
|
| 79 |
+
try:
|
| 80 |
+
cropped_bytes, crop_meta = crop_receipt_from_image(original_bytes)
|
| 81 |
+
except Exception as exc:
|
| 82 |
+
raise HTTPException(status_code=422, detail=f"Failed to process image: {exc}")
|
| 83 |
+
|
| 84 |
+
# OCR via Hugging Face Inference API
|
| 85 |
+
try:
|
| 86 |
+
ocr_text = run_hf_ocr(cropped_bytes)
|
| 87 |
+
except Exception as exc:
|
| 88 |
+
raise HTTPException(status_code=502, detail=f"OCR failed: {exc}")
|
| 89 |
+
|
| 90 |
+
# Extract naive amount (ZAR or generic currency) and a fallback category
|
| 91 |
+
amount = _extract_amount(ocr_text)
|
| 92 |
+
category = _infer_category(ocr_text)
|
| 93 |
+
|
| 94 |
+
# Upload cropped image to Supabase Storage
|
| 95 |
+
bucket = os.getenv("SUPABASE_STORAGE_BUCKET", "slips")
|
| 96 |
+
object_name = f"{user_id}/{uuid.uuid4()}.jpg"
|
| 97 |
+
try:
|
| 98 |
+
public_url = upload_bytes_to_supabase_storage(
|
| 99 |
+
bucket=bucket,
|
| 100 |
+
object_path=object_name,
|
| 101 |
+
content_bytes=cropped_bytes,
|
| 102 |
+
content_type="image/jpeg",
|
| 103 |
+
)
|
| 104 |
+
except Exception as exc:
|
| 105 |
+
raise HTTPException(status_code=502, detail=f"Upload failed: {exc}")
|
| 106 |
+
|
| 107 |
+
# Insert DB row into slips table
|
| 108 |
+
try:
|
| 109 |
+
inserted = insert_slip_row(
|
| 110 |
+
{
|
| 111 |
+
"user_id": user_id,
|
| 112 |
+
"image_path": object_name,
|
| 113 |
+
"image_url": public_url,
|
| 114 |
+
"raw_text": ocr_text,
|
| 115 |
+
"amount": amount,
|
| 116 |
+
"category": category,
|
| 117 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 118 |
+
"crop_confidence": crop_meta.get("confidence"),
|
| 119 |
+
}
|
| 120 |
+
)
|
| 121 |
+
except Exception as exc:
|
| 122 |
+
raise HTTPException(status_code=502, detail=f"Database insert failed: {exc}")
|
| 123 |
+
|
| 124 |
+
return JSONResponse(
|
| 125 |
+
{
|
| 126 |
+
"ok": True,
|
| 127 |
+
"slip": inserted,
|
| 128 |
+
}
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _extract_amount(text: str) -> float | None:
|
| 133 |
+
import re
|
| 134 |
+
|
| 135 |
+
if not text:
|
| 136 |
+
return None
|
| 137 |
+
# Look for amounts with optional currency symbol/letter (e.g., R250.00, $12.34)
|
| 138 |
+
candidates: list[float] = []
|
| 139 |
+
for match in re.finditer(r"(?:R|\$)?\s*(\d{1,3}(?:[\,\s]\d{3})*(?:\.\d{2})?|\d+(?:\.\d{2}))", text, flags=re.IGNORECASE):
|
| 140 |
+
num_str = match.group(1).replace(",", "").replace(" ", "")
|
| 141 |
+
try:
|
| 142 |
+
candidates.append(float(num_str))
|
| 143 |
+
except ValueError:
|
| 144 |
+
continue
|
| 145 |
+
return max(candidates) if candidates else None
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _infer_category(text: str) -> str:
|
| 149 |
+
text_l = (text or "").lower()
|
| 150 |
+
if any(k in text_l for k in ["fuel", "petrol", "gas"]):
|
| 151 |
+
return "Fuel"
|
| 152 |
+
if any(k in text_l for k in ["restaurant", "meal", "food", "dine"]):
|
| 153 |
+
return "Food"
|
| 154 |
+
if any(k in text_l for k in ["grocery", "supermarket", "market", "store"]):
|
| 155 |
+
return "Grocery"
|
| 156 |
+
return "Uncategorized"
|
| 157 |
+
|
| 158 |
+
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.115.6
|
| 2 |
+
uvicorn[standard]==0.32.0
|
| 3 |
+
python-multipart==0.0.9
|
| 4 |
+
requests==2.32.3
|
| 5 |
+
opencv-python-headless==4.10.0.84
|
| 6 |
+
numpy==2.1.3
|
| 7 |
+
supabase==2.7.4
|
| 8 |
+
starlette==0.41.3
|
| 9 |
+
python-dotenv==1.0.1
|
| 10 |
+
|
backend/utils/image_processing.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
from typing import Tuple, Dict
|
| 4 |
+
import io
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def crop_receipt_from_image(image_bytes: bytes) -> Tuple[bytes, Dict]:
|
| 8 |
+
"""Detect receipt edges and return a perspective-corrected JPEG.
|
| 9 |
+
|
| 10 |
+
Returns (jpeg_bytes, meta)
|
| 11 |
+
meta: { confidence: float, width: int, height: int }
|
| 12 |
+
"""
|
| 13 |
+
image_array = np.frombuffer(image_bytes, dtype=np.uint8)
|
| 14 |
+
img = cv2.imdecode(image_array, cv2.IMREAD_COLOR)
|
| 15 |
+
if img is None:
|
| 16 |
+
raise ValueError("Invalid image data")
|
| 17 |
+
|
| 18 |
+
orig = img.copy()
|
| 19 |
+
ratio = 500.0 / max(img.shape[0], img.shape[1])
|
| 20 |
+
if ratio < 1.0:
|
| 21 |
+
img = cv2.resize(img, None, fx=ratio, fy=ratio, interpolation=cv2.INTER_AREA)
|
| 22 |
+
|
| 23 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 24 |
+
gray = cv2.GaussianBlur(gray, (5, 5), 0)
|
| 25 |
+
edged = cv2.Canny(gray, 50, 150)
|
| 26 |
+
|
| 27 |
+
contours, _ = cv2.findContours(edged.copy(), cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE)
|
| 28 |
+
contours = sorted(contours, key=cv2.contourArea, reverse=True)[:10]
|
| 29 |
+
|
| 30 |
+
receipt_contour = None
|
| 31 |
+
for c in contours:
|
| 32 |
+
peri = cv2.arcLength(c, True)
|
| 33 |
+
approx = cv2.approxPolyDP(c, 0.02 * peri, True)
|
| 34 |
+
if len(approx) == 4:
|
| 35 |
+
receipt_contour = approx
|
| 36 |
+
break
|
| 37 |
+
|
| 38 |
+
if receipt_contour is None:
|
| 39 |
+
# Fallback: treat whole image as receipt
|
| 40 |
+
h, w = img.shape[:2]
|
| 41 |
+
receipt_contour = np.array([[[0, 0]], [[w - 1, 0]], [[w - 1, h - 1]], [[0, h - 1]]])
|
| 42 |
+
confidence = 0.2
|
| 43 |
+
else:
|
| 44 |
+
confidence = 0.8
|
| 45 |
+
|
| 46 |
+
pts = receipt_contour.reshape(4, 2).astype("float32")
|
| 47 |
+
# Order points: top-left, top-right, bottom-right, bottom-left
|
| 48 |
+
rect = _order_points(pts)
|
| 49 |
+
(tl, tr, br, bl) = rect
|
| 50 |
+
|
| 51 |
+
width_top = np.linalg.norm(tr - tl)
|
| 52 |
+
width_bottom = np.linalg.norm(br - bl)
|
| 53 |
+
max_width = int(max(width_top, width_bottom))
|
| 54 |
+
|
| 55 |
+
height_right = np.linalg.norm(br - tr)
|
| 56 |
+
height_left = np.linalg.norm(bl - tl)
|
| 57 |
+
max_height = int(max(height_right, height_left))
|
| 58 |
+
|
| 59 |
+
dst = np.array(
|
| 60 |
+
[[0, 0], [max_width - 1, 0], [max_width - 1, max_height - 1], [0, max_height - 1]],
|
| 61 |
+
dtype="float32",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
M = cv2.getPerspectiveTransform(rect, dst)
|
| 65 |
+
warped = cv2.warpPerspective(img, M, (max_width, max_height))
|
| 66 |
+
|
| 67 |
+
# Slight contrast enhancement and grayscale for OCR readiness
|
| 68 |
+
warped_gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
|
| 69 |
+
warped_eq = cv2.equalizeHist(warped_gray)
|
| 70 |
+
|
| 71 |
+
# Encode as JPEG
|
| 72 |
+
success, jpeg = cv2.imencode('.jpg', warped_eq, [int(cv2.IMWRITE_JPEG_QUALITY), 90])
|
| 73 |
+
if not success:
|
| 74 |
+
raise ValueError("Failed to encode JPEG")
|
| 75 |
+
|
| 76 |
+
return jpeg.tobytes(), {"confidence": float(confidence), "width": int(max_width), "height": int(max_height)}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def _order_points(pts: np.ndarray) -> np.ndarray:
|
| 80 |
+
x_sorted = pts[np.argsort(pts[:, 0]), :]
|
| 81 |
+
left = x_sorted[:2, :]
|
| 82 |
+
right = x_sorted[2:, :]
|
| 83 |
+
|
| 84 |
+
tl, bl = left[np.argsort(left[:, 1]), :]
|
| 85 |
+
tr, br = right[np.argsort(right[:, 1]), :]
|
| 86 |
+
return np.array([tl, tr, br, bl], dtype="float32")
|
| 87 |
+
|
| 88 |
+
|
backend/utils/ocr.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import requests
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
HF_MODEL = os.getenv("HF_OCR_MODEL", "microsoft/trocr-base-printed")
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def run_hf_ocr(image_bytes: bytes) -> str:
|
| 9 |
+
token = os.getenv("HUGGINGFACE_API_TOKEN")
|
| 10 |
+
if not token:
|
| 11 |
+
raise RuntimeError("Missing HUGGINGFACE_API_TOKEN")
|
| 12 |
+
|
| 13 |
+
# Use Inference API for text recognition
|
| 14 |
+
url = f"https://api-inference.huggingface.co/models/{HF_MODEL}"
|
| 15 |
+
headers = {"Authorization": f"Bearer {token}"}
|
| 16 |
+
response = requests.post(url, headers=headers, data=image_bytes, timeout=60)
|
| 17 |
+
if response.status_code >= 400:
|
| 18 |
+
raise RuntimeError(f"HF API error: {response.status_code} {response.text}")
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
data = response.json()
|
| 22 |
+
except Exception as exc:
|
| 23 |
+
raise RuntimeError(f"Invalid HF response: {exc}")
|
| 24 |
+
|
| 25 |
+
# trocr returns dicts with 'generated_text'
|
| 26 |
+
if isinstance(data, list) and data and isinstance(data[0], dict):
|
| 27 |
+
text = data[0].get("generated_text")
|
| 28 |
+
if isinstance(text, str):
|
| 29 |
+
return text
|
| 30 |
+
|
| 31 |
+
# fallback to raw
|
| 32 |
+
return str(data)
|
| 33 |
+
|
| 34 |
+
|
backend/utils/supabase_helpers.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from typing import Dict, Any
|
| 3 |
+
from supabase import create_client, Client
|
| 4 |
+
import requests
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _get_supabase_client() -> Client:
|
| 8 |
+
url = os.getenv("SUPABASE_URL")
|
| 9 |
+
key = os.getenv("SUPABASE_SERVICE_ROLE_KEY") or os.getenv("SUPABASE_ANON_KEY")
|
| 10 |
+
if not url or not key:
|
| 11 |
+
raise RuntimeError("Missing SUPABASE_URL or SUPABASE_*_KEY")
|
| 12 |
+
return create_client(url, key)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def fetch_user_from_supabase_token(jwt: str) -> Dict[str, Any] | None:
|
| 16 |
+
# Validate JWT via auth endpoint
|
| 17 |
+
supabase_url = os.getenv("SUPABASE_URL")
|
| 18 |
+
if not supabase_url:
|
| 19 |
+
return None
|
| 20 |
+
resp = requests.get(
|
| 21 |
+
f"{supabase_url}/auth/v1/user",
|
| 22 |
+
headers={
|
| 23 |
+
"Authorization": f"Bearer {jwt}",
|
| 24 |
+
"apikey": os.getenv("SUPABASE_ANON_KEY", ""),
|
| 25 |
+
},
|
| 26 |
+
timeout=20,
|
| 27 |
+
)
|
| 28 |
+
if resp.status_code != 200:
|
| 29 |
+
return None
|
| 30 |
+
return resp.json()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def upload_bytes_to_supabase_storage(
|
| 34 |
+
bucket: str,
|
| 35 |
+
object_path: str,
|
| 36 |
+
content_bytes: bytes,
|
| 37 |
+
content_type: str = "application/octet-stream",
|
| 38 |
+
) -> str:
|
| 39 |
+
client = _get_supabase_client()
|
| 40 |
+
storage = client.storage.from_(bucket)
|
| 41 |
+
storage.upload(object_path, content_bytes, {
|
| 42 |
+
"contentType": content_type,
|
| 43 |
+
"upsert": False,
|
| 44 |
+
})
|
| 45 |
+
# Create public URL (assumes bucket is public or policy allows reads)
|
| 46 |
+
# Handle both string or dict responses across library versions
|
| 47 |
+
url_candidate = storage.get_public_url(object_path)
|
| 48 |
+
if isinstance(url_candidate, dict):
|
| 49 |
+
public_url = (
|
| 50 |
+
url_candidate.get("publicUrl")
|
| 51 |
+
or url_candidate.get("public_url")
|
| 52 |
+
or url_candidate.get("url")
|
| 53 |
+
)
|
| 54 |
+
else:
|
| 55 |
+
public_url = str(url_candidate)
|
| 56 |
+
|
| 57 |
+
if not public_url:
|
| 58 |
+
# Fallback construct (public bucket assumption)
|
| 59 |
+
base = os.getenv("SUPABASE_URL", "").rstrip("/")
|
| 60 |
+
public_url = f"{base}/storage/v1/object/public/{bucket}/{object_path}"
|
| 61 |
+
return public_url
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def insert_slip_row(row: Dict[str, Any]) -> Dict[str, Any]:
|
| 65 |
+
client = _get_supabase_client()
|
| 66 |
+
response = client.table("slips").insert(row).select("*").execute()
|
| 67 |
+
if not response.data:
|
| 68 |
+
raise RuntimeError("Insert returned no data")
|
| 69 |
+
return response.data[0]
|
| 70 |
+
|
| 71 |
+
|