Instructions to use rhlprj/invoice-layoutlmv3-multidomain with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use rhlprj/invoice-layoutlmv3-multidomain with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("token-classification", model="rhlprj/invoice-layoutlmv3-multidomain")# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("rhlprj/invoice-layoutlmv3-multidomain", dtype="auto") - Notebooks
- Google Colab
- Kaggle
| #!/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() | |