id-ocr-engine / main.py
Kevin-KES
Deploy current main to HF Space
50c6ee2
Raw
History Blame Contribute Delete
12.5 kB
import os
import time
import uuid
import atexit
import logging
from flask import Flask, jsonify, request, g
from flask_cors import CORS
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from config import Config
from logging_config import setup_logging
logger = logging.getLogger(__name__)
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "bmp", "webp"}
SUPPORTED_DOC_TYPES = {"sa_id_card", "sa_id_book", "passport"}
_start_time = time.time()
def _allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
def create_app(config=None):
cfg = config or Config
# Setup logging before anything else
setup_logging(
log_level=getattr(cfg, "LOG_LEVEL", "INFO"),
log_format=getattr(cfg, "LOG_FORMAT", "json"),
)
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = cfg.MAX_CONTENT_LENGTH
# CORS β€” allow all origins
CORS(app, origins="*")
# Store config on app for access in routes
app.cfg = cfg
cfg.UPLOAD_DIR.mkdir(exist_ok=True)
# Rate limiting
limiter = Limiter(
get_remote_address,
app=app,
default_limits=["60 per minute"],
storage_uri="memory://",
)
# ── Auth middleware ─────────────────────────────────────────────────
@app.before_request
def authenticate():
if request.path in ("/health", "/", "/warm") or request.path.startswith("/static/"):
return None
api_key = getattr(cfg, "API_KEY", "")
if not api_key:
return None
provided = request.headers.get("X-API-Key", "")
if not provided or provided != api_key:
return jsonify({"error": "Unauthorized"}), 401
# ── Request tracking ───────────────────────────────────────────────
@app.before_request
def assign_request_id():
g.request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
g.start_time = time.time()
@app.after_request
def log_request(response):
duration = round((time.time() - g.get("start_time", time.time())) * 1000, 1)
logger.info(
"%s %s %s %sms",
request.method, request.path, response.status_code, duration,
extra={"request_id": g.get("request_id", "-")},
)
response.headers["X-Request-ID"] = g.get("request_id", "")
return response
# ── Error handlers ─────────────────────────────────────────────────
@app.errorhandler(400)
def bad_request(e):
return jsonify({"error": getattr(e, "description", "Bad request")}), 400
@app.errorhandler(401)
def unauthorized(e):
return jsonify({"error": "Unauthorized"}), 401
@app.errorhandler(404)
def not_found(e):
return jsonify({"error": getattr(e, "description", "Not found")}), 404
@app.errorhandler(413)
def request_too_large(e):
return jsonify({"error": "Request body too large"}), 413
@app.errorhandler(429)
def rate_limited(e):
return jsonify({"error": "Rate limit exceeded"}), 429
@app.errorhandler(500)
def internal_error(e):
logger.exception("Internal server error")
return jsonify({"error": "Internal server error"}), 500
# ── Routes ─────────────────────────────────────────────────────────
@app.route("/")
def dashboard():
return app.send_static_file("index.html")
@app.route("/health")
def health_check():
upload_ok = cfg.UPLOAD_DIR.exists() and os.access(str(cfg.UPLOAD_DIR), os.W_OK)
healthy = upload_ok
return jsonify({
"status": "healthy" if healthy else "unhealthy",
"uptime_seconds": round(time.time() - _start_time, 1),
"checks": {
"upload_directory": "ok" if upload_ok else "missing or not writable",
},
}), 200 if healthy else 503
@app.route("/warm")
def warm():
"""Warm up VLM + PaddleOCR + MRZ models. Call before first OCR request."""
results = {}
try:
from engine.vlm_extractor import warm_vlm
results["vlm"] = "ready" if warm_vlm() else "unavailable"
except Exception:
results["vlm"] = "unavailable"
try:
from engine.ocr import _get_ocr
_get_ocr()
results["paddleocr"] = "ready"
except Exception:
results["paddleocr"] = "unavailable"
try:
from engine.mrz_reader import warm_mrz
results["mrz"] = "ready" if warm_mrz() else "unavailable"
except Exception:
results["mrz"] = "unavailable"
return jsonify({"status": "warm", "engines": results}), 200
@app.route("/api/ocr", methods=["POST"])
@limiter.limit("10 per minute")
def process_id():
"""Accept document image(s) and return extracted fields as JSON.
Form fields:
front: Front image file (required) β€” or 'file' for legacy
back: Back image file (optional, only for sa_id_card)
doc_type: Document type (required): sa_id_card | sa_id_book | passport
"""
# Validate doc_type
doc_type = request.form.get("doc_type", "").strip()
if not doc_type:
return jsonify({
"error": "doc_type is required. Must be one of: sa_id_card, sa_id_book, passport"
}), 400
if doc_type not in SUPPORTED_DOC_TYPES:
return jsonify({
"error": f"Invalid doc_type '{doc_type}'. Must be one of: sa_id_card, sa_id_book, passport"
}), 400
# Get front image (support both 'front' and legacy 'file' key)
front_file = request.files.get("front") or request.files.get("file")
if not front_file:
return jsonify({"error": "No front image uploaded. Send a file with key 'front'."}), 400
if front_file.filename == "" or not _allowed_file(front_file.filename):
return jsonify({
"error": f"Invalid file type. Accepted: {', '.join(ALLOWED_EXTENSIONS)}"
}), 400
# Get back image (optional, only for sa_id_card)
back_file = request.files.get("back")
if back_file and doc_type != "sa_id_card":
return jsonify({"error": "Back image is only accepted for doc_type=sa_id_card"}), 400
if back_file and (back_file.filename == "" or not _allowed_file(back_file.filename)):
return jsonify({
"error": f"Invalid back file type. Accepted: {', '.join(ALLOWED_EXTENSIONS)}"
}), 400
# Save front image with UUID filename
front_ext = front_file.filename.rsplit(".", 1)[1].lower()
front_filename = f"{uuid.uuid4().hex}.{front_ext}"
front_path = os.path.join(str(cfg.UPLOAD_DIR), front_filename)
front_file.save(front_path)
# Save back image (if provided)
back_path = None
if back_file:
back_ext = back_file.filename.rsplit(".", 1)[1].lower()
back_filename = f"{uuid.uuid4().hex}.{back_ext}"
back_path = os.path.join(str(cfg.UPLOAD_DIR), back_filename)
back_file.save(back_path)
try:
from engine.ocr import process_id_image
result = process_id_image(front_path, doc_type, back_path)
return jsonify(result), 200
except ValueError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
logger.exception("OCR processing failed")
return jsonify({"error": f"OCR processing failed: {e}"}), 500
finally:
# Always clean up β€” never store personal ID images (POPIA)
if os.path.exists(front_path):
os.remove(front_path)
if back_path and os.path.exists(back_path):
os.remove(back_path)
@app.route("/api/serial", methods=["POST"])
@limiter.limit("10 per minute")
def process_serial():
"""Accept a product image and extract the serial number.
Form fields:
image: Image file (required)
"""
image_file = request.files.get("image")
if not image_file:
return jsonify({"error": "No image uploaded. Send a file with key 'image'."}), 400
if image_file.filename == "" or not _allowed_file(image_file.filename):
return jsonify({
"error": f"Invalid file type. Accepted: {', '.join(ALLOWED_EXTENSIONS)}"
}), 400
ext = image_file.filename.rsplit(".", 1)[1].lower()
filename = f"{uuid.uuid4().hex}.{ext}"
filepath = os.path.join(str(cfg.UPLOAD_DIR), filename)
image_file.save(filepath)
try:
from engine.serial_reader import process_serial_image
result = process_serial_image(filepath)
return jsonify(result), 200
except Exception as e:
logger.exception("Serial number processing failed")
return jsonify({"error": f"Serial number processing failed: {e}"}), 500
finally:
if os.path.exists(filepath):
os.remove(filepath)
@app.route("/api/barcode/parse", methods=["POST"])
@limiter.limit("60 per minute")
def parse_barcode_text():
"""Parse raw barcode text (decoded client-side) into structured fields."""
data = request.get_json()
if not data or not data.get("text"):
return jsonify({"error": "No barcode text provided"}), 400
text = data["text"]
from engine.barcode_reader import _parse_pdf417, _parse_code39
from engine.id_parser import validate_luhn
result = _parse_pdf417(text) or _parse_code39(text)
if result:
fields = {
"id_number": result.get("id_number"),
"surname": result.get("surname"),
"names": result.get("names"),
"sex": result.get("sex"),
"date_of_birth": result.get("date_of_birth"),
"nationality": result.get("nationality"),
"country_of_birth": result.get("country_of_birth"),
"citizenship_status": result.get("citizenship_status"),
}
id_valid = "not_applicable"
if fields.get("id_number"):
id_valid = "passed" if validate_luhn(fields["id_number"]) else "failed"
return jsonify({
"doc_type": "sa_id_card",
"fields": fields,
"barcode_data": result,
"extraction_method": "barcode",
"checks": {
"barcode_valid": "passed",
"id_number_valid": id_valid,
},
"overall_result": "pass" if id_valid == "passed" else "fail",
"confidence": {},
"raw_text": [],
}), 200
return jsonify({
"doc_type": "sa_id_card",
"fields": {},
"barcode_data": None,
"extraction_method": "none",
"checks": {"barcode_valid": "failed"},
"overall_result": "fail",
"confidence": {},
"raw_text": [],
}), 200
# ── Shutdown logging ───────────────────────────────────────────────
def on_shutdown():
try:
logger.info("ID OCR Engine shutting down")
except Exception:
pass
atexit.register(on_shutdown)
logger.info("ID OCR Engine started (debug=%s)", cfg.DEBUG)
return app
if __name__ == "__main__":
from dotenv import load_dotenv
load_dotenv()
# Reload config after dotenv
import importlib
import config as config_module
importlib.reload(config_module)
from config import Config as ReloadedConfig
app = create_app(ReloadedConfig)
app.run(debug=ReloadedConfig.DEBUG, host=ReloadedConfig.HOST, port=ReloadedConfig.PORT)