invoice-layoutlmv3-multidomain / inference_example.py
rhlprj's picture
Upload inference_example.py with huggingface_hub
b766707 verified
Raw
History Blame Contribute Delete
21.5 kB
#!/usr/bin/env python3
"""
Self-contained inference example for invoice-layoutlmv3-multidomain.
Downloads the model from HuggingFace, runs EasyOCR + LayoutLMv3, and prints
extracted fields as JSON.
Requirements:
pip install transformers torch easyocr huggingface_hub Pillow
Usage:
python inference_example.py path/to/invoice.png # auto-detect domain
python inference_example.py path/to/invoice.png --domain general
Domains: general, receipt, medical, insurance, logistics
"""
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import numpy as np
import torch
import torch.nn.functional as F
from PIL import Image
from huggingface_hub import snapshot_download
from transformers import AutoModelForTokenClassification, LayoutLMv3Processor
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
REPO_ID = "rhlprj/invoice-layoutlmv3-multidomain"
MAX_SEQ_LENGTH = 512
# Domain routing keywords (used when --domain is not specified)
DOMAIN_KEYWORDS = {
"insurance": [
"eob", "claim", "policy", "insurer", "deductible", "copay",
"eligible", "approved", "not covered", "patient liability",
"insurance pays", "explanation of benefits",
],
"medical": [
"patient", "hospital", "doctor", "diagnosis", "uhid", "blood group",
"department", "procedure", "attending", "discharge", "admission",
],
"logistics": [
"waybill", "awb", "bol", "freight", "carrier", "consignee",
"shipper", "container", "incoterms", "port of loading",
"origin port", "destination port", "gross weight",
],
"receipt": [
"receipt", "cash", "change", "subtotal", "cashier", "store",
],
"general": [
"invoice", "due date", "payment terms", "bank", "account",
],
}
# Anchor keywords per domain+field for stripping label prefixes from OCR text.
# When EasyOCR merges "Invoice #: FRT-2025-5862" into one box, the model tags
# the whole box correctly but the extracted text includes the label prefix.
# These anchors let us strip "Invoice #:" to get just "FRT-2025-5862".
ANCHORS = {
"general": {
"invoice_number": ["invoice no", "invoice number", "invoice #", "invoice"],
"invoice_date": ["invoice date", "date"],
"due_date": ["due date", "due"],
"subtotal": ["subtotal", "sub total"],
"total": ["total", "grand total", "amount due"],
"tax": ["tax", "gst", "vat"],
"payment_terms": ["payment terms", "terms"],
"bank_name": ["bank", "bank name"],
"account_number": ["account", "account number", "a/c"],
"vendor_email": ["email", "e-mail"],
"vendor_phone": ["phone", "tel", "mobile", "contact"],
},
"receipt": {
"subtotal": ["subtotal", "sub total"],
"tax": ["tax", "gst", "vat", "service"],
"total": ["total", "grand total"],
"cash_paid": ["cash", "paid", "cash paid"],
"change": ["change"],
"invoice_date": ["date"],
},
"medical": {
"bill_number": ["bill no", "bill number", "bill #", "invoice"],
"bill_date": ["bill date", "date"],
"patient_name": ["patient", "patient name", "name"],
"patient_age": ["age"],
"patient_gender": ["gender", "sex"],
"hospital_name": ["hospital"],
"attending_doctor": ["doctor", "physician", "consultant", "dr"],
"department": ["department", "dept"],
"diagnosis": ["diagnosis"],
"patient_type": ["patient type", "type"],
"subtotal": ["subtotal", "sub total"],
"total_due": ["total due", "total", "amount due"],
"uhid": ["uhid"],
"gst": ["gst", "tax"],
"insurance_covered": ["insurance", "insurance covered", "covered"],
"patient_blood_group": ["blood group", "blood"],
},
"insurance": {
"eob_number": ["eob no", "eob number", "eob"],
"claim_number": ["claim no", "claim number", "claim"],
"policy_number": ["policy no", "policy number", "policy"],
"claim_date": ["claim date"],
"process_date": ["process date", "processed"],
"claim_type": ["claim type", "type"],
"claim_status": ["status", "claim status"],
"insurer_name": ["insurer", "insurance company"],
"policy_holder": ["policy holder", "holder", "insured"],
"patient_name": ["patient", "patient name"],
"patient_age": ["age"],
"hospital_name": ["hospital", "provider"],
"diagnosis": ["diagnosis"],
"total_billed": ["total billed", "billed", "claimed"],
"eligible_amount": ["eligible"],
"approved_amount": ["approved"],
"insurance_pays": ["insurance pays", "payable", "insurer pays"],
"patient_liability": ["patient liability", "liability", "patient pays"],
"not_covered": ["not covered", "disallowed"],
"deductible": ["deductible"],
"admission_date": ["admission", "admission date"],
"discharge_date": ["discharge", "discharge date"],
},
"logistics": {
"invoice_number": ["invoice no", "invoice number", "invoice"],
"invoice_date": ["invoice date", "date"],
"due_date": ["due date", "due"],
"waybill_number": ["waybill", "awb", "bol", "tracking"],
"freight_type": ["freight type", "service", "mode"],
"carrier_name": ["carrier", "shipper"],
"cargo_description": ["cargo", "commodity", "goods"],
"gross_weight": ["weight", "gross weight"],
"num_packages": ["packages", "pieces", "pkgs"],
"origin_port": ["origin", "from", "port of loading"],
"destination_port": ["destination", "to", "port of discharge"],
"shipper_name": ["shipper", "shipper name", "from"],
"consignee_name": ["consignee", "consignee name", "to"],
"container_number": ["container", "container no", "container number", "cntr"],
"notify_party": ["notify", "notify party", "also notify"],
"volume": ["volume", "cbm", "measurement"],
"payment_mode": ["payment", "payment mode", "terms"],
"incoterms": ["incoterms", "incoterm"],
"subtotal": ["subtotal", "sub total"],
"total": ["total", "grand total"],
"po_number": ["po no", "po number", "po", "purchase order"],
"gst": ["gst", "tax"],
},
}
# ---------------------------------------------------------------------------
# OCR (EasyOCR)
# ---------------------------------------------------------------------------
_easyocr_reader = None
def _get_reader():
global _easyocr_reader
if _easyocr_reader is None:
import easyocr
_easyocr_reader = easyocr.Reader(["en"], gpu=torch.cuda.is_available())
return _easyocr_reader
def run_ocr(image: Image.Image) -> List[Dict[str, Any]]:
"""Run EasyOCR on a PIL image, return list of {text, bbox, confidence}.
bbox is [x0, y0, x1, y1] in pixel coordinates.
"""
reader = _get_reader()
np_img = np.array(image)
raw = reader.readtext(np_img, canvas_size=1280, min_size=5)
words = []
for coords, text, conf in raw:
text = text.strip()
if not text:
continue
xs = [p[0] for p in coords]
ys = [p[1] for p in coords]
words.append({
"text": text,
"bbox": [min(xs), min(ys), max(xs), max(ys)],
"confidence": float(conf),
})
return words
# ---------------------------------------------------------------------------
# Domain routing
# ---------------------------------------------------------------------------
def route_domain(ocr_words: List[Dict[str, Any]]) -> str:
"""Score OCR text against domain keywords, return best domain."""
full_text = " ".join(w["text"] for w in ocr_words).lower()
scores = {}
for domain, keywords in DOMAIN_KEYWORDS.items():
scores[domain] = sum(1 for kw in keywords if kw in full_text)
best = max(scores, key=scores.get)
return best if scores[best] > 0 else "general"
# ---------------------------------------------------------------------------
# Model loading
# ---------------------------------------------------------------------------
_model_cache: Dict[str, Dict[str, Any]] = {}
def load_model(domain: str, models_dir: str = "models") -> Dict[str, Any]:
"""Load model + processor + label maps for a domain. Caches in memory."""
if domain in _model_cache:
return _model_cache[domain]
model_path = Path(models_dir) / domain
if not model_path.exists():
print(f"Downloading models from {REPO_ID}...")
snapshot_download(REPO_ID, local_dir=models_dir)
# Label maps
with open(model_path / "label_maps.json", "r", encoding="utf-8") as f:
lm = json.load(f)
id2label = {int(k): v for k, v in lm["id2label"].items()}
# Processor (apply_ocr=False — we supply our own OCR words + boxes)
processor = LayoutLMv3Processor.from_pretrained(str(model_path), apply_ocr=False)
# Model
model = AutoModelForTokenClassification.from_pretrained(str(model_path))
device = "cuda" if torch.cuda.is_available() else "cpu"
model = model.to(device).eval()
entry = {"model": model, "processor": processor, "id2label": id2label, "device": device}
_model_cache[domain] = entry
return entry
# ---------------------------------------------------------------------------
# Subword → word alignment (CRITICAL — this is what the reviewer got wrong)
# ---------------------------------------------------------------------------
def map_token_preds_to_words(
encoding,
token_logits: torch.Tensor, # [seq_len, num_labels]
n_words: int,
) -> Tuple[List[int], List[float]]:
"""Map subword predictions back to word-level predictions.
LayoutLMv3's tokenizer splits words into subword tokens. For example:
"INV-2025-00782" -> ["IN", "V", "-", "2025", "-", "007", "82"] (7 tokens)
The model predicts one label per subword token. We need ONE label per
original word. Strategy: take the FIRST subword's prediction for each word.
encoding.word_ids(0) returns a list mapping each token position to its
source word index (None for special tokens like [CLS], [SEP], [PAD]).
WRONG approach (what the reviewer did):
preds[1:len(words)+1] # Assumes 1 token per word — BROKEN
CORRECT approach (what we do):
Use word_ids() to find the first subword for each word, take that
subword's prediction.
Returns:
pred_label_ids: List[int] of length n_words
pred_confs: List[float] of length n_words (softmax confidence)
"""
word_ids = encoding.word_ids(0)
probs = F.softmax(token_logits, dim=-1)
# Find first subword token index for each word
first_subword: Dict[int, int] = {}
for tok_idx, w_id in enumerate(word_ids):
if w_id is not None and w_id not in first_subword:
first_subword[w_id] = tok_idx
pred_label_ids = []
pred_confs = []
for w_idx in range(n_words):
if w_idx in first_subword:
tok_idx = first_subword[w_idx]
label_id = int(token_logits[tok_idx].argmax().item())
conf = float(probs[tok_idx, label_id].item())
else:
# Word was truncated (beyond 512 tokens) — default to "O"
label_id = 0
conf = 1.0
pred_label_ids.append(label_id)
pred_confs.append(conf)
return pred_label_ids, pred_confs
# ---------------------------------------------------------------------------
# BIO span merging
# ---------------------------------------------------------------------------
def merge_spans(
words: List[str],
bboxes: List[List[int]],
pred_label_ids: List[int],
id2label: Dict[int, str],
confidences: List[float],
) -> List[Dict[str, Any]]:
"""Walk word-level BIO predictions and merge consecutive tokens into spans.
BIO scheme:
B-field_name = beginning of a new span for field_name
I-field_name = inside/continuation of the current span
O = outside any span
Returns list of span dicts with keys:
label_field, text, bbox, confidence, word_idxs
"""
spans = []
current = None
for idx, (word, bbox, label_id) in enumerate(zip(words, bboxes, pred_label_ids)):
label = id2label.get(label_id, "O")
if label == "O":
if current:
spans.append(_finalise_span(current))
current = None
continue
if label.startswith("B-"):
if current:
spans.append(_finalise_span(current))
current = {
"field": label[2:],
"words": [word], "bboxes": [bbox],
"confs": [confidences[idx]], "idxs": [idx],
}
elif label.startswith("I-"):
field = label[2:]
if current and current["field"] == field:
current["words"].append(word)
current["bboxes"].append(bbox)
current["confs"].append(confidences[idx])
current["idxs"].append(idx)
else:
# Stray I- without matching B- — start new span
if current:
spans.append(_finalise_span(current))
current = {
"field": field,
"words": [word], "bboxes": [bbox],
"confs": [confidences[idx]], "idxs": [idx],
}
if current:
spans.append(_finalise_span(current))
return spans
def _finalise_span(acc: dict) -> dict:
bboxes = acc["bboxes"]
return {
"label_field": acc["field"],
"text": " ".join(acc["words"]),
"bbox": [
min(b[0] for b in bboxes), min(b[1] for b in bboxes),
max(b[2] for b in bboxes), max(b[3] for b in bboxes),
],
"confidence": sum(acc["confs"]) / len(acc["confs"]),
"word_idxs": acc["idxs"],
}
# ---------------------------------------------------------------------------
# Label-prefix stripping
# ---------------------------------------------------------------------------
def strip_label_prefix(text: str, domain: str, label_field: str) -> str:
"""Remove leading anchor/label keywords from extracted span text.
EasyOCR often merges a printed label and its value into one box:
"Invoice #: FRT-2025-5862" -> "FRT-2025-5862"
"Date: 22/08/2025" -> "22/08/2025"
"Total: Rs. 57,269.19" -> "Rs. 57,269.19"
Matches the longest anchor phrase, then strips it plus trailing separators.
"""
anchors = ANCHORS.get(domain, {}).get(label_field, [])
if not anchors:
return text
lower = text.lower()
best_end = 0
for anchor in sorted(anchors, key=len, reverse=True):
if lower.startswith(anchor.lower()):
best_end = len(anchor)
break
if best_end == 0:
return text
rest = text[best_end:].lstrip(" \t:#-.|/")
return rest if rest else text
# ---------------------------------------------------------------------------
# Amount parsing
# ---------------------------------------------------------------------------
_CURRENCY_RE = re.compile(r"\b(rs\.?|inr|gbp|eur|usd)\b|[£€₹$]", re.IGNORECASE)
def parse_amount(text: str) -> Dict[str, Any]:
"""Parse a currency/amount string into {value, currency, raw}."""
currency_match = _CURRENCY_RE.search(text)
currency = currency_match.group(0) if currency_match else None
cleaned = _CURRENCY_RE.sub("", text)
cleaned = re.sub(r"[^\d.\-]", "", cleaned)
# Handle multiple dots (e.g. "1.000.00" -> "1000.00")
parts = cleaned.split(".")
if len(parts) > 2:
cleaned = "".join(parts[:-1]) + "." + parts[-1]
try:
value = float(cleaned)
except ValueError:
value = None
return {"value": value, "currency": currency, "raw": text}
# ---------------------------------------------------------------------------
# Main extract()
# ---------------------------------------------------------------------------
def extract(
image_path: str,
domain: Optional[str] = None,
models_dir: str = "models",
) -> Dict[str, Any]:
"""Full pipeline: image -> OCR -> LayoutLMv3 -> canonical JSON.
Args:
image_path: path to invoice image (PNG/JPG).
domain: one of general/receipt/medical/insurance/logistics.
If None, auto-detected from OCR text.
models_dir: directory containing downloaded model subdirectories.
Returns:
Dict with extracted fields. Each scalar field is either:
{"value": ..., "confidence": float, "raw": str} (text fields)
{"value": float, "currency": str, "raw": str} (amount fields)
Plus "_meta": {"domain": str, "num_spans": int}
"""
image = Image.open(image_path).convert("RGB")
img_w, img_h = image.size
# 1. OCR
ocr_words = run_ocr(image)
if not ocr_words:
return {"_meta": {"domain": domain or "unknown", "error": "No text found"}}
# 2. Route domain
if domain is None:
domain = route_domain(ocr_words)
# 3. Load model
entry = load_model(domain, models_dir)
model = entry["model"]
processor = entry["processor"]
id2label = entry["id2label"]
device = entry["device"]
# 4. Normalise bboxes to 0-1000 (LayoutLMv3 convention)
words_text = [w["text"] for w in ocr_words]
boxes_1000 = [
[
int(min(max(w["bbox"][0] * 1000 / img_w, 0), 1000)),
int(min(max(w["bbox"][1] * 1000 / img_h, 0), 1000)),
int(min(max(w["bbox"][2] * 1000 / img_w, 0), 1000)),
int(min(max(w["bbox"][3] * 1000 / img_h, 0), 1000)),
]
for w in ocr_words
]
ocr_confs = [w["confidence"] for w in ocr_words]
# 5. Encode with processor
encoding = processor(
images=image,
text=words_text,
boxes=boxes_1000,
truncation=True,
padding="max_length",
max_length=MAX_SEQ_LENGTH,
return_tensors="pt",
)
# 6. Run model
with torch.no_grad():
inputs = {k: v.to(device) for k, v in encoding.items()}
outputs = model(**inputs)
token_logits = outputs.logits[0].cpu()
# 7. Map subword predictions -> word predictions (CRITICAL STEP)
pred_label_ids, pred_confs = map_token_preds_to_words(
encoding, token_logits, len(words_text)
)
# 8. Merge BIO spans
spans = merge_spans(words_text, boxes_1000, pred_label_ids, id2label, pred_confs)
# 9. Build output — pick highest-confidence span per field
best_spans: Dict[str, Dict[str, Any]] = {}
for span in spans:
lf = span["label_field"]
if lf not in best_spans or span["confidence"] > best_spans[lf]["confidence"]:
best_spans[lf] = span
# 10. Format output with label-prefix stripping
result: Dict[str, Any] = {}
for lf, span in best_spans.items():
raw_text = span["text"]
clean_text = strip_label_prefix(raw_text, domain, lf)
# Detect amounts by checking if the field name suggests a numeric value
amount_fields = {
"subtotal", "total", "tax", "gst", "total_due", "total_billed",
"eligible_amount", "approved_amount", "insurance_pays",
"patient_liability", "not_covered", "deductible", "insurance_covered",
"cash_paid", "change", "li_amount", "charge_amount", "item_billed",
"proc_amount",
}
if lf in amount_fields:
result[lf] = parse_amount(clean_text)
else:
result[lf] = {
"value": clean_text,
"confidence": round(span["confidence"], 4),
"raw": raw_text,
}
result["_meta"] = {"domain": domain, "num_spans": len(spans)}
return result
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main():
parser = argparse.ArgumentParser(
description="Extract fields from an invoice image using LayoutLMv3.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python inference_example.py invoice.png
python inference_example.py invoice.png --domain general
python inference_example.py invoice.png --models-dir ./my_models
The first run downloads ~2.5 GB of model weights from HuggingFace.
""",
)
parser.add_argument("image", help="Path to invoice image (PNG/JPG)")
parser.add_argument("--domain", default=None,
choices=["general", "receipt", "medical", "insurance", "logistics"],
help="Force domain (default: auto-detect)")
parser.add_argument("--models-dir", default="models",
help="Directory for model weights (default: ./models)")
args = parser.parse_args()
if not Path(args.image).exists():
print(f"Error: {args.image} not found", file=sys.stderr)
sys.exit(1)
result = extract(args.image, domain=args.domain, models_dir=args.models_dir)
print(json.dumps(result, indent=2, default=str))
if __name__ == "__main__":
main()