[{"data":1,"prerenderedAt":-1},["ShallowReactive",2],{"skill:01M7C7ZGSNQ85YNDZEN0A0RDYN":3,"skill-same:01M7C7ZGSNQ85YNDZEN0A0RDYN":45},{"skillNo":4,"name":5,"nameZh":6,"description":7,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":16,"categories":17,"keywords":18,"publishedAt":19,"linkOnly":20,"markdownContent":21,"markdownBody":22,"frontMatter":23,"refName":24,"commitSha":25,"skillDirectory":26,"forksCount":27,"repositoryHtmlUrl":28,"repositoryHomepageUrl":6,"ownerHtmlUrl":29,"ownerBlogUrl":6,"ownerDescription":6,"updatedAt":30,"install":31,"resources":44},"01M7C7ZGSNQ85YNDZEN0A0RDYN","image-ocr",null,"Extract text content from images using Tesseract OCR via Python, including structured field extraction from receipts\u002Fforms with keyword-priority, rounding-aware totals, locale-aware dates, cross-anchor sanity checks, and strict null semantics","linyouxue\u002Fskill-repair-benchmark","linyouxue","https:\u002F\u002Favatars.githubusercontent.com\u002Fu\u002F216151464?v=4","01M7BJEVCXKD74KH6FG9CBVSQB","林宥学","01M7BJEVE02NP217V93BKZFC0J",0,"Apache-2.0","https:\u002F\u002Fgithub.com\u002Flinyouxue\u002Fskill-repair-benchmark\u002Fblob\u002Fmain\u002Fjpg-ocr-stat\u002Fresult\u002Fskilltta-claude4.7\u002Fevidence\u002Fattempts\u002Fjpg-ocr-stat-skilltta-opus47-aigw-v2-r004\u002Finputs\u002Fskills\u002Fimage-ocr\u002FSKILL.md",[],[],"2026-11-15 14:19:17",false,"---\nname: image-ocr\ndescription: Extract text content from images using Tesseract OCR via Python, including structured field extraction from receipts\u002Fforms with keyword-priority, rounding-aware totals, locale-aware dates, cross-anchor sanity checks, and strict null semantics\n---\n\n# Image OCR Skill\n\n## Purpose\nExtract text from images (JPG\u002FPNG\u002FWEBP\u002Fetc.) using Tesseract via `pytesseract`, and extract structured fields (dates, monetary totals) from receipts\u002Finvoices with rules that are robust to common OCR errors and to variations in receipt layout.\n\n## When to Use\n- Scanned documents, screenshots, photos of text, receipts, forms.\n- Batch OCR into tabular outputs (CSV\u002FExcel) with strict schemas and nullable fields.\n- Structured extraction of date and total amount where accuracy depends on keyword priority, exclusions, cross-anchor validation, and locale-aware date parsing.\n\n## Required Libraries\n\n```python\nimport pytesseract\nfrom PIL import Image, ImageOps, ImageFilter\nimport re, os\nfrom datetime import datetime\nfrom pathlib import Path\n```\n\n## Basic OCR Extraction\n\n```python\ndef extract_text_from_image(image_path):\n    img = Image.open(image_path)\n    return pytesseract.image_to_string(img).strip()\n```\n\n## Preprocessing for Receipts \u002F Low-Quality Scans\n\n```python\ndef preprocess_for_receipt(image_path):\n    img = Image.open(image_path).convert('L')\n    img = ImageOps.autocontrast(img)\n    w, h = img.size\n    if max(w, h) \u003C 1500:\n        img = img.resize((w*2, h*2), Image.LANCZOS)\n    return img\n```\n\nFor faint\u002Fdense receipts also try a 3x upscale and\u002For `ImageFilter.SHARPEN`, plus a binarized variant when results are empty.\n\n## Multi-Pass OCR (per-pass extraction, then arbitrate)\n\nDo NOT concatenate OCR outputs across variants and run extraction on the blob — that creates spurious cross-line matches. Run OCR on multiple variants, run extraction PER PASS, then arbitrate.\n\n```python\ndef ocr_variants(image_path):\n    img = Image.open(image_path)\n    gray = ImageOps.autocontrast(img.convert('L'))\n    big = gray.resize((gray.width*2, gray.height*2), Image.LANCZOS) if max(gray.size) \u003C 1500 else gray\n    big3 = gray.resize((gray.width*3, gray.height*3), Image.LANCZOS) if max(gray.size) \u003C 1200 else big\n    variants = [\n        (gray, '--psm 6'),\n        (big, '--psm 6'),\n        (big, '--psm 4'),\n        (big3, '--psm 4'),\n    ]\n    outs = []\n    for v, cfg in variants:\n        try:\n            outs.append(pytesseract.image_to_string(v, config=cfg))\n        except Exception:\n            pass\n    return outs\n```\n\n## Structured Field Extraction from Receipts\n\n### Keyword priority tiers (highest to lowest)\n\n1. `GRAND TOTAL`\n2. `TOTAL ROUNDED`, `ROUNDED TOTAL`, `TOTAL AFTER ROUNDING`, `TOTAL AMT ROUNDED`, `NET TOTAL`, `NETT TOTAL`\n3. `TOTAL RM`, `TOTAL: RM`, `TOTAL (RM)`, `TOTAL RM INCLUDING GST`, `TOTAL RM INCL GST`\n4. `TOTAL AMOUNT`, `AMOUNT DUE`, `BALANCE DUE`, `TOTAL DUE`, `TOTAL INCLUSIVE GST`, `TOTAL INCL GST`, `TOTAL INCLUDING GST`, `TOTAL INCL. OF GST`\n5. Generic `TOTAL`, `AMOUNT`, including `TOTAL (INCLUSIVE OF GST)` \u002F `TOTAL INCL. GST` \u002F `TOTAL SALES INCL GST`\n\n### Keyword matching tolerance\n\nBefore matching, normalise the line: uppercase, collapse whitespace, strip trailing punctuation. Then also test a `nospace` form (remove all spaces) against each keyword's nospace form. This recovers OCR corruptions like `GRANDTOTAL`, `TOTALRM`, `GRANDTOTA.` (truncated — see `tier_match` below for stem matching).\n\n### Exclusion keywords (checked BEFORE any tier match on the same line)\n\nAlways skip lines whose semantic label is one of:\n`SUBTOTAL`, `SUB TOTAL`, `SUB-TOTAL`, `TAX`, `GST`, `SST`, `DISCOUNT`, `CHANGE`, `CHANGE DUE`, `CASH TENDERED`, `CASH RECEIVED`, `CASH PAYMENT`, `PAYMENT`, `TENDER`, `TENDERED`, `VISA`, `MASTERCARD`, `MASTER CARD`, `DEBIT`, `CREDIT CARD`, `CARD`, `POINTS`, `LOYALTY`, `TOTAL ITEM(S)`, `TOTAL QTY`, `ITEM COUNT`, `TOTAL SAVINGS`, `AVERAGE \u002F PAX`, `TAX TOTAL`, `GST SUMMARY`, `TAX SUMMARY`, `SERVICE CHARGE`, `SERV. CHARGE`, `ROUNDING ADJ`, `ROUNDING ADJUSTMENT`.\n\nNote: `ROUNDED TOTAL` \u002F `TOTAL ROUNDED` \u002F `TOTAL AFTER ROUNDING` are NOT exclusions — they are high-priority tiers. `AMT EXCL GST`, `AMT. EXCL. GST`, `AMOUNT EXCL GST`, `TOTAL EXCL GST`, `TOTAL EXCLUDING GST` are exclusions (they are pre-tax amounts).\n\n### Qualifier-downgrade keywords (not full exclusion)\n\nLines containing these are matched but ranked AFTER a cleaner plain-keyword line in the same tier:\n`INCLUSIVE OF GST`, `INCL GST`, `INCL. GST`, `INCLUDING GST`, `BEFORE ROUNDING`, `GROSS`, `TOTAL SALES INCL`.\n\n### Number regex and normalization\n\nMonetary totals should match with exactly two decimals; accept looser forms only as a last-resort fallback.\n\n```python\nNUM_STRICT = re.compile(r'(?\u003C![\\d.,])(\\d{1,3}(?:,\\d{3})*\\.\\d{2}|\\d+\\.\\d{2})(?![\\d])')\nNUM_LOOSE  = re.compile(r'(\\d{1,3}(?:,\\d{3})*(?:\\.\\d{1,2})?|\\d+)')\n\ndef normalize_amount_token(tok):\n    t = tok.strip().replace(',', '').lstrip('-')\n    t = re.sub(r'(?\u003C=\\d):(?=\\d{2}\\b)', '.', t)\n    try:\n        return float(t)\n    except ValueError:\n        return None\n\ndef last_strict_number(line):\n    nums = NUM_STRICT.findall(line)\n    if not nums:\n        return None\n    return normalize_amount_token(nums[-1])\n\ndef last_loose_number(line):\n    nums = NUM_LOOSE.findall(line)\n    for raw in reversed(nums):\n        v = normalize_amount_token(raw)\n        if v is not None:\n            return v\n    return None\n```\n\n### Total extraction algorithm (per OCR pass)\n\n```python\nTIERS = [\n    ['GRAND TOTAL'],\n    ['TOTAL ROUNDED', 'ROUNDED TOTAL', 'TOTAL AFTER ROUNDING', 'TOTAL AMT ROUNDED', 'NET TOTAL', 'NETT TOTAL'],\n    ['TOTAL RM', 'TOTAL: RM', 'TOTAL (RM)'],\n    ['TOTAL AMOUNT', 'AMOUNT DUE', 'BALANCE DUE', 'TOTAL DUE'],\n    ['TOTAL', 'AMOUNT'],\n]\n\nEXCLUSIONS = [\n    'SUBTOTAL','SUB TOTAL','SUB-TOTAL','TAX','GST','SST','DISCOUNT',\n    'CHANGE','CHANGE DUE','CASH TENDERED','CASH RECEIVED','CASH PAYMENT',\n    'PAYMENT','TENDER','TENDERED','VISA','MASTERCARD','MASTER CARD',\n    'DEBIT','CREDIT CARD','CARD','POINTS','LOYALTY',\n    'TOTAL ITEM(S)','TOTAL QTY','ITEM COUNT','TOTAL SAVINGS',\n    'AVERAGE \u002F PAX','AVERAGE\u002FPAX','TAX TOTAL','GST SUMMARY','TAX SUMMARY',\n    'SERVICE CHARGE','SERV. CHARGE','ROUNDING ADJ','ROUNDING ADJUSTMENT',\n    'AMT EXCL GST','AMT. EXCL. GST','AMOUNT EXCL GST','TOTAL EXCL GST','TOTAL EXCLUDING GST','EXCL. GST',\n]\n\nQUALIFIER_DOWNGRADE = [\n    'INCLUSIVE OF GST','INCL GST','INCL. GST','INCLUDING GST',\n    'EXCLUSIVE OF GST','EXCL GST','EXCL. GST','EXCLUDING GST',\n    'BEFORE ROUNDING','GROSS',\n]\n\ndef _normalize(line):\n    return re.sub(r'\\s+', ' ', line.strip().upper())\n\ndef _nospace(s):\n    return re.sub(r'\\s+', '', s)\n\ndef _line_is_excluded(norm_line):\n    return any(kw in norm_line for kw in EXCLUSIONS)\n\ndef _tier_match(norm_line, tier_keywords):\n    ns_line = _nospace(norm_line)\n    for kw in tier_keywords:\n        if kw in norm_line:\n            return True\n        if _nospace(kw) in ns_line:\n            return True\n    return False\n\ndef _is_qualifier_line(norm_line):\n    return any(q in norm_line for q in QUALIFIER_DOWNGRADE)\n\ndef _rounding_adj_proximity(norm_lines, i, window=4):\n    # True if a ROUNDING ADJ line appears within `window` lines before i\n    for j in range(max(0, i-window), i):\n        if 'ROUNDING ADJ' in norm_lines[j] or 'ROUNDING ADJUSTMENT' in norm_lines[j]:\n            return True\n    return False\n\ndef _is_plain_keyword_start(norm_line, tier_keywords):\n    # True when the keyword is at the start of the normalised line (plain final TOTAL style),\n    # and the line is not a qualifier line.\n    for kw in tier_keywords:\n        if norm_line.startswith(kw) and not _is_qualifier_line(norm_line):\n            return True\n    return False\n\ndef extract_total_from_text(text):\n    raw = [ln for ln in text.splitlines() if ln.strip()]\n    norm = [_normalize(ln) for ln in raw]\n\n    for tier_idx, tier in enumerate(TIERS):\n        cands = []  # (line_index, value, is_qualifier, after_rounding_adj, is_plain_start)\n        for i, nln in enumerate(norm):\n            if _line_is_excluded(nln):\n                continue\n            if not _tier_match(nln, tier):\n                continue\n            v = last_strict_number(raw[i])\n            if v is None:\n                # Two-line fallback: ONLY when the keyword line has no digits at all\n                if not re.search(r'\\d', raw[i]):\n                    for j in range(i+1, min(i+3, len(raw))):\n                        if not raw[j].strip():\n                            continue\n                        if _line_is_excluded(norm[j]):\n                            break\n                        v2 = last_strict_number(raw[j]) or last_loose_number(raw[j])\n                        if v2 is not None:\n                            cands.append((j, v2, _is_qualifier_line(nln), _rounding_adj_proximity(norm, j), False))\n                            break\n                continue\n            cands.append((i, v, _is_qualifier_line(nln), _rounding_adj_proximity(norm, i), _is_plain_keyword_start(nln, tier)))\n\n        if not cands:\n            continue\n\n        # Preference within tier (higher wins):\n        # 1) after a 'ROUNDING ADJ' line (final rounded total)\n        # 2) plain-keyword-start lines over qualifier lines (final 'TOTAL:' > 'Total (Inclusive of GST)')\n        # 3) non-qualifier over qualifier\n        # 4) latest (bottom-most) in reading order\n        cands.sort(key=lambda c: (c[3], c[4], not c[2], c[0]), reverse=True)\n        return cands[0][1]\n    return None\n```\n\n### Cross-anchor sanity check (catches isolated OCR digit flips)\n\nAfter picking a candidate `T`, compute anchor values from the SAME OCR text: the set of monetary values appearing on lines labelled with any of the anchor labels below. Anchors are evidence that a value is the receipt's authoritative total.\n\n- If an anchor value `A` appears on 2 or more such lines (consensus) and differs from `T` by more than 0.05, prefer `A`.\n- If a SINGLE strong anchor (`TOTAL ROUNDED`, `ROUNDED TOTAL`, `TOTAL AFTER ROUNDING`, `TOTAL RM INCLUDING GST`, `GRAND TOTAL`, or Cash − Change) exists and differs from `T` by more than 0.05, prefer that anchor.\n- When computing Cash − Change, use the matching Cash\u002FChange pair from the same text; the resulting value is a strong anchor.\n\n```python\nSTRONG_ANCHOR_LABELS = ['TOTAL ROUNDED','ROUNDED TOTAL','TOTAL AFTER ROUNDING','GRAND TOTAL','TOTAL RM INCLUDING GST','TOTAL RM INCL GST','NET TOTAL','NETT TOTAL']\nWEAK_ANCHOR_LABELS = ['SUBTOTAL','SUB TOTAL','SUB-TOTAL','TOTAL (INCLUSIVE OF GST)','TOTAL INCL GST','TOTAL INCLUSIVE OF GST','TOTAL INCL. OF GST','PAYMENT','VISA','MASTERCARD','DEBIT','CREDIT CARD']\n\ndef _line_label_matches(norm_line, labels):\n    return any(lbl in norm_line for lbl in labels)\n\ndef anchor_values(text):\n    strong, weak = [], []\n    norm_lines = []\n    raw_lines = []\n    for ln in text.splitlines():\n        if not ln.strip():\n            continue\n        raw_lines.append(ln)\n        norm_lines.append(_normalize(ln))\n    for ln, nln in zip(raw_lines, norm_lines):\n        if _line_label_matches(nln, STRONG_ANCHOR_LABELS):\n            v = last_strict_number(ln)\n            if v is not None:\n                strong.append(abs(v))\n        elif _line_label_matches(nln, WEAK_ANCHOR_LABELS):\n            v = last_strict_number(ln)\n            if v is not None:\n                weak.append(abs(v))\n    # Cash − Change consensus (strong anchor)\n    cash = change = None\n    for ln, nln in zip(raw_lines, norm_lines):\n        if 'CASH' in nln and 'TENDERED' not in nln and cash is None:\n            v = last_strict_number(ln)\n            if v is not None:\n                cash = v\n        if 'CHANGE' in nln and change is None:\n            v = last_strict_number(ln)\n            if v is not None:\n                change = v\n    if cash is not None and change is not None:\n        strong.append(round(cash - change, 2))\n    return strong, weak\n\ndef reconcile_total(text, chosen):\n    if chosen is None:\n        return None\n    strong, weak = anchor_values(text)\n    from collections import Counter\n    # 1) Strong single-anchor override: any strong anchor value disagreeing with chosen by >0.05\n    for s in strong:\n        if abs(s - chosen) > 0.05:\n            # Count how many anchors (strong + weak) agree with s vs chosen\n            agree_s = sum(1 for v in strong+weak if abs(v - s) \u003C= 0.05)\n            agree_c = sum(1 for v in strong+weak if abs(v - chosen) \u003C= 0.05)\n            if agree_s >= agree_c:\n                return round(s, 2)\n    # 2) Weak consensus (≥2 agreeing weak anchors on a different value)\n    counts = Counter(round(v, 2) for v in weak)\n    if counts:\n        best_anchor, best_count = counts.most_common(1)[0]\n        if best_count >= 2 and abs(best_anchor - chosen) > 0.05:\n            return best_anchor\n    return chosen\n```\n\n### Date extraction (ISO output, DD\u002FMM default, labelled-line preferred, sanity-checked)\n\n```python\nMONTHS = {m.upper(): i for i, m in enumerate(\n    ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'], start=1)}\nMONTHS_FULL = {m.upper(): i for i, m in enumerate(\n    ['January','February','March','April','May','June','July','August','September','October','November','December'], start=1)}\n\ndef _valid_ymd(y, m, d):\n    try:\n        datetime(y, m, d)\n        return 1990 \u003C= y \u003C= 2100\n    except ValueError:\n        return False\n\ndef _parse_numeric_date(a, b, c):\n    if len(a) == 4:\n        y, m, d = int(a), int(b), int(c)\n        if _valid_ymd(y, m, d):\n            return y, m, d\n        return None\n    if len(c) == 4:\n        y = int(c); d = int(a); mo = int(b)\n        # Prefer DD\u002FMM\u002FYYYY; fall back to MM\u002FDD\u002FYYYY only if DD\u002FMM invalid\n        if _valid_ymd(y, mo, d):\n            return y, mo, d\n        if _valid_ymd(y, d, mo):\n            return y, d, mo\n        return None\n    y = 2000 + int(c) if int(c) \u003C 100 else int(c)\n    d, mo = int(a), int(b)\n    if _valid_ymd(y, mo, d):\n        return y, mo, d\n    if _valid_ymd(y, d, mo):\n        return y, d, mo\n    return None\n\nDATE_LABEL_RE = re.compile(r'(?i)\\b(date|tarikh|invoice\\s*date|receipt\\s*date|bill\\s*date|trans(?:action)?\\s*date|tax\\s*invoice\\s*date|order\\s*date|data)\\b')\nNUM_DATE_RE = re.compile(r'(\\d{1,4})[\\-\u002F\\.](\\d{1,2})[\\-\u002F\\.](\\d{2,4})')\nTEXT_DATE_RE = re.compile(r'(\\d{1,2})\\s+([A-Za-z]{3,9})\\s+(\\d{4})')\n\ndef extract_date_from_text(text):\n    lines = [ln for ln in text.splitlines() if ln.strip()]\n    labelled, unlabelled = [], []\n    for ln in lines:\n        bucket = labelled if DATE_LABEL_RE.search(ln) else unlabelled\n        for m in NUM_DATE_RE.finditer(ln):\n            bucket.append(('num', m.group(1), m.group(2), m.group(3)))\n        for m in TEXT_DATE_RE.finditer(ln):\n            bucket.append(('txt', m.group(1), m.group(2), m.group(3)))\n    for group in (labelled, unlabelled):\n        for item in group:\n            if item[0] == 'num':\n                r = _parse_numeric_date(item[1], item[2], item[3])\n                if r:\n                    y, mo, d = r\n                    return f'{y:04d}-{mo:02d}-{d:02d}'\n            else:\n                d = int(item[1]); mo_name = item[2].upper(); y = int(item[3])\n                mo = MONTHS.get(mo_name[:3]) or MONTHS_FULL.get(mo_name)\n                if mo and _valid_ymd(y, mo, d):\n                    return f'{y:04d}-{mo:02d}-{d:02d}'\n    return None\n```\n\nNotes:\n- Labelled-line candidates (lines containing `Date`, `Tarikh`, `Invoice Date`, `Data` [common OCR of `Date`], etc.) are tried first before any other date-looking token.\n- `_parse_numeric_date` prefers DD\u002FMM\u002FYYYY over MM\u002FDD\u002FYYYY when both parse; this matches the dominant convention on RM\u002FGST-style receipts.\n- Years must be in [1990, 2100]; otherwise the candidate is rejected and the next is tried.\n\n### Format and null contract\n\n- `date`: `'YYYY-MM-DD'` string or Python `None`.\n- `total_amount`: Python string with exactly two decimals via `f'{v:.2f}'` or Python `None`. Never a float, never `''`, never `'None'`, never `NaN`.\n\n```python\ndef format_amount(v):\n    return None if v is None else f'{v:.2f}'\n```\n\n## Per-image pipeline with multi-pass arbitration\n\n```python\ndef extract_fields(image_path):\n    texts = ocr_variants(image_path)\n    date_candidates, total_candidates = [], []\n    for t in texts:\n        d = extract_date_from_text(t)\n        if d:\n            date_candidates.append(d)\n        v = extract_total_from_text(t)\n        v = reconcile_total(t, v)\n        if v is not None:\n            total_candidates.append(round(v, 2))\n    from collections import Counter\n    date = Counter(date_candidates).most_common(1)[0][0] if date_candidates else None\n    total = Counter(total_candidates).most_common(1)[0][0] if total_candidates else None\n    return date, format_amount(total)\n```\n\n## Batch Pipeline to Tabular Output (strict null preservation)\n\nUse openpyxl directly so `None` becomes a truly empty cell (not `''`, not `'None'`, not `NaN`). Keep `total_amount` as a text cell (`number_format='@'`) so strings like `'20.00'` survive round-trip.\n\n```python\nIMG_EXTS = {'.jpg', '.jpeg', '.png', '.webp'}\n\ndef process_receipts(img_dir, out_xlsx):\n    from openpyxl import Workbook\n    files = sorted(f for f in os.listdir(img_dir) if Path(f).suffix.lower() in IMG_EXTS)\n    wb = Workbook()\n    ws = wb.active\n    ws.title = 'results'\n    ws.append(['filename', 'date', 'total_amount'])\n    for fn in files:\n        try:\n            date_val, amount_val = extract_fields(os.path.join(img_dir, fn))\n        except Exception:\n            date_val, amount_val = None, None\n        # Explicit None preservation: write None (empty cell), never '' or 'None'.\n        row_idx = ws.max_row + 1\n        ws.cell(row=row_idx, column=1, value=fn)\n        ws.cell(row=row_idx, column=2, value=date_val if date_val else None)\n        ws.cell(row=row_idx, column=3, value=amount_val if amount_val else None)\n        ws.cell(row=row_idx, column=3).number_format = '@'\n    wb.save(out_xlsx)\n```\n\n## Critical Rules for Receipt Extraction\n\n- Exclusions are applied FIRST on each line.\n- Priority tiers are tried in order; move to the next tier only when the current tier yields no candidates.\n- Within a tier, prefer in this order: (1) a line that follows a `ROUNDING ADJ` line within a 4-line window (final rounded total); (2) plain-keyword-start lines (e.g. bare `TOTAL:`) over qualifier lines (`Total (Inclusive of GST)`, `Before Rounding`); (3) non-qualifier lines over qualifier lines; (4) the latest (bottom-most) line in reading order.\n- Keyword matching tolerates missing whitespace (`GRANDTOTAL`, `TOTALRM`) via a nospace secondary match.\n- Two-line fallback triggers ONLY when the keyword line contains no digits at all; the fallback line must itself pass the exclusion filter.\n- Cross-anchor sanity: strong anchors (`TOTAL ROUNDED`, `ROUNDED TOTAL`, `GRAND TOTAL`, `TOTAL RM INCLUDING GST`, `NET TOTAL`, Cash−Change) override the chosen value when they disagree by >0.05 and are at least as well-supported; weak anchors require ≥2 agreeing values to override.\n- Numbers: strip currency prefixes (`RM`, `$`, `MYR`) and commas; strip leading `-` for negative-payment anchors like Visa; repair in-token `:`→`.`; prefer strict two-decimal matches; use loose matches only as last resort.\n- Dates: prefer labelled-line candidates (lines containing `Date`, `Data`, `Tarikh`, `Invoice Date`, etc.); default to DD\u002FMM\u002FYYYY when both DD\u002FMM and MM\u002FDD parse; reject years outside 1990–2100 and invalid day\u002Fmonth combinations.\n- Null on failure means Python `None` written as an empty Excel cell. Keep `total_amount` as a 2-decimal string (`f'{v:.2f}'`) when present.\n- Per-image failures must degrade to `(None, None)` without aborting the batch.\n- Run extraction PER OCR pass and arbitrate by majority value; never concatenate variant texts before extraction.\n\n## Common Pitfalls\n\n- Choosing a pre-rounding `Total (Inclusive of GST)` line when a later plain `TOTAL:` line after `Rounding Adj` is the final payable amount.\n- Picking a payment line (`Cash Received`, `Visa`, `Mastercard`) via the two-line fallback.\n- Writing `''` or `'None'` or `NaN` for failed fields, which fail strict null-equality verifiers.\n- Writing a float like `47.7` instead of the string `'47.70'`.\n- Confusing DD\u002FMM and MM\u002FDD on ambiguous dates (both components ≤ 12) due to no documented default.\n- Accepting a 1-decimal OCR truncation like `88.1` as a valid monetary total when a 2-decimal candidate exists in another variant.\n- Letting a single-variant OCR digit flip (e.g. `102`→`702`, `538`→`702`) win when strong anchors (`Total Rounded`, `Total RM Including GST`, Cash−Change) all agree on another value.\n- Requiring exact whitespace between `GRAND` and `TOTAL`: OCR commonly glues these into `GRANDTOTAL`.\n\n## Quality Self-Check\n\n- [ ] All image files in the input directory are processed (filtered by extension) and rows are sorted lexicographically by filename.\n- [ ] `date` is `'YYYY-MM-DD'` or Python `None`.\n- [ ] `total_amount` is a Python string matching `^\\d+\\.\\d{2}$` or Python `None`.\n- [ ] Exclusion keywords never source the total; qualifier lines are deprioritized when a cleaner candidate exists.\n- [ ] Rounding-aware preference and cross-anchor sanity are applied; strong anchors can override on their own.\n- [ ] None values become empty cells in the output workbook (verify by reloading with openpyxl and asserting `cell.value is None`).\n- [ ] Exactly one sheet named `results` with exactly the required columns in the required order.\n\n## Tesseract Configuration Reference\n\n```python\n# PSM 6: uniform block of text (default for receipts)\n# PSM 4: single column, variable sizes\n# PSM 11: sparse text\ntext = pytesseract.image_to_string(img, config='--psm 6', lang='eng')\n```\n\n## Limitations\n\n- Tesseract is weakest on handwriting, decorative fonts, and severely degraded scans.\n- Ambiguous dates where both components ≤ 12 and no label is present are resolved by the DD\u002FMM default; some will still be wrong when the receipt uses MM\u002FDD.\n- OCR digit confusions (`1`↔`7`, `0`↔`O\u002FQ`, `5`↔`S`, `8`↔`B`) can still produce wrong totals when no anchor consensus exists.\n","# Image OCR Skill\n\n## Purpose\nExtract text from images (JPG\u002FPNG\u002FWEBP\u002Fetc.) using Tesseract via `pytesseract`, and extract structured fields (dates, monetary totals) from receipts\u002Finvoices with rules that are robust to common OCR errors and to variations in receipt layout.\n\n## When to Use\n- Scanned documents, screenshots, photos of text, receipts, forms.\n- Batch OCR into tabular outputs (CSV\u002FExcel) with strict schemas and nullable fields.\n- Structured extraction of date and total amount where accuracy depends on keyword priority, exclusions, cross-anchor validation, and locale-aware date parsing.\n\n## Required Libraries\n\n```python\nimport pytesseract\nfrom PIL import Image, ImageOps, ImageFilter\nimport re, os\nfrom datetime import datetime\nfrom pathlib import Path\n```\n\n## Basic OCR Extraction\n\n```python\ndef extract_text_from_image(image_path):\n    img = Image.open(image_path)\n    return pytesseract.image_to_string(img).strip()\n```\n\n## Preprocessing for Receipts \u002F Low-Quality Scans\n\n```python\ndef preprocess_for_receipt(image_path):\n    img = Image.open(image_path).convert('L')\n    img = ImageOps.autocontrast(img)\n    w, h = img.size\n    if max(w, h) \u003C 1500:\n        img = img.resize((w*2, h*2), Image.LANCZOS)\n    return img\n```\n\nFor faint\u002Fdense receipts also try a 3x upscale and\u002For `ImageFilter.SHARPEN`, plus a binarized variant when results are empty.\n\n## Multi-Pass OCR (per-pass extraction, then arbitrate)\n\nDo NOT concatenate OCR outputs across variants and run extraction on the blob — that creates spurious cross-line matches. Run OCR on multiple variants, run extraction PER PASS, then arbitrate.\n\n```python\ndef ocr_variants(image_path):\n    img = Image.open(image_path)\n    gray = ImageOps.autocontrast(img.convert('L'))\n    big = gray.resize((gray.width*2, gray.height*2), Image.LANCZOS) if max(gray.size) \u003C 1500 else gray\n    big3 = gray.resize((gray.width*3, gray.height*3), Image.LANCZOS) if max(gray.size) \u003C 1200 else big\n    variants = [\n        (gray, '--psm 6'),\n        (big, '--psm 6'),\n        (big, '--psm 4'),\n        (big3, '--psm 4'),\n    ]\n    outs = []\n    for v, cfg in variants:\n        try:\n            outs.append(pytesseract.image_to_string(v, config=cfg))\n        except Exception:\n            pass\n    return outs\n```\n\n## Structured Field Extraction from Receipts\n\n### Keyword priority tiers (highest to lowest)\n\n1. `GRAND TOTAL`\n2. `TOTAL ROUNDED`, `ROUNDED TOTAL`, `TOTAL AFTER ROUNDING`, `TOTAL AMT ROUNDED`, `NET TOTAL`, `NETT TOTAL`\n3. `TOTAL RM`, `TOTAL: RM`, `TOTAL (RM)`, `TOTAL RM INCLUDING GST`, `TOTAL RM INCL GST`\n4. `TOTAL AMOUNT`, `AMOUNT DUE`, `BALANCE DUE`, `TOTAL DUE`, `TOTAL INCLUSIVE GST`, `TOTAL INCL GST`, `TOTAL INCLUDING GST`, `TOTAL INCL. OF GST`\n5. Generic `TOTAL`, `AMOUNT`, including `TOTAL (INCLUSIVE OF GST)` \u002F `TOTAL INCL. GST` \u002F `TOTAL SALES INCL GST`\n\n### Keyword matching tolerance\n\nBefore matching, normalise the line: uppercase, collapse whitespace, strip trailing punctuation. Then also test a `nospace` form (remove all spaces) against each keyword's nospace form. This recovers OCR corruptions like `GRANDTOTAL`, `TOTALRM`, `GRANDTOTA.` (truncated — see `tier_match` below for stem matching).\n\n### Exclusion keywords (checked BEFORE any tier match on the same line)\n\nAlways skip lines whose semantic label is one of:\n`SUBTOTAL`, `SUB TOTAL`, `SUB-TOTAL`, `TAX`, `GST`, `SST`, `DISCOUNT`, `CHANGE`, `CHANGE DUE`, `CASH TENDERED`, `CASH RECEIVED`, `CASH PAYMENT`, `PAYMENT`, `TENDER`, `TENDERED`, `VISA`, `MASTERCARD`, `MASTER CARD`, `DEBIT`, `CREDIT CARD`, `CARD`, `POINTS`, `LOYALTY`, `TOTAL ITEM(S)`, `TOTAL QTY`, `ITEM COUNT`, `TOTAL SAVINGS`, `AVERAGE \u002F PAX`, `TAX TOTAL`, `GST SUMMARY`, `TAX SUMMARY`, `SERVICE CHARGE`, `SERV. CHARGE`, `ROUNDING ADJ`, `ROUNDING ADJUSTMENT`.\n\nNote: `ROUNDED TOTAL` \u002F `TOTAL ROUNDED` \u002F `TOTAL AFTER ROUNDING` are NOT exclusions — they are high-priority tiers. `AMT EXCL GST`, `AMT. EXCL. GST`, `AMOUNT EXCL GST`, `TOTAL EXCL GST`, `TOTAL EXCLUDING GST` are exclusions (they are pre-tax amounts).\n\n### Qualifier-downgrade keywords (not full exclusion)\n\nLines containing these are matched but ranked AFTER a cleaner plain-keyword line in the same tier:\n`INCLUSIVE OF GST`, `INCL GST`, `INCL. GST`, `INCLUDING GST`, `BEFORE ROUNDING`, `GROSS`, `TOTAL SALES INCL`.\n\n### Number regex and normalization\n\nMonetary totals should match with exactly two decimals; accept looser forms only as a last-resort fallback.\n\n```python\nNUM_STRICT = re.compile(r'(?\u003C![\\d.,])(\\d{1,3}(?:,\\d{3})*\\.\\d{2}|\\d+\\.\\d{2})(?![\\d])')\nNUM_LOOSE  = re.compile(r'(\\d{1,3}(?:,\\d{3})*(?:\\.\\d{1,2})?|\\d+)')\n\ndef normalize_amount_token(tok):\n    t = tok.strip().replace(',', '').lstrip('-')\n    t = re.sub(r'(?\u003C=\\d):(?=\\d{2}\\b)', '.', t)\n    try:\n        return float(t)\n    except ValueError:\n        return None\n\ndef last_strict_number(line):\n    nums = NUM_STRICT.findall(line)\n    if not nums:\n        return None\n    return normalize_amount_token(nums[-1])\n\ndef last_loose_number(line):\n    nums = NUM_LOOSE.findall(line)\n    for raw in reversed(nums):\n        v = normalize_amount_token(raw)\n        if v is not None:\n            return v\n    return None\n```\n\n### Total extraction algorithm (per OCR pass)\n\n```python\nTIERS = [\n    ['GRAND TOTAL'],\n    ['TOTAL ROUNDED', 'ROUNDED TOTAL', 'TOTAL AFTER ROUNDING', 'TOTAL AMT ROUNDED', 'NET TOTAL', 'NETT TOTAL'],\n    ['TOTAL RM', 'TOTAL: RM', 'TOTAL (RM)'],\n    ['TOTAL AMOUNT', 'AMOUNT DUE', 'BALANCE DUE', 'TOTAL DUE'],\n    ['TOTAL', 'AMOUNT'],\n]\n\nEXCLUSIONS = [\n    'SUBTOTAL','SUB TOTAL','SUB-TOTAL','TAX','GST','SST','DISCOUNT',\n    'CHANGE','CHANGE DUE','CASH TENDERED','CASH RECEIVED','CASH PAYMENT',\n    'PAYMENT','TENDER','TENDERED','VISA','MASTERCARD','MASTER CARD',\n    'DEBIT','CREDIT CARD','CARD','POINTS','LOYALTY',\n    'TOTAL ITEM(S)','TOTAL QTY','ITEM COUNT','TOTAL SAVINGS',\n    'AVERAGE \u002F PAX','AVERAGE\u002FPAX','TAX TOTAL','GST SUMMARY','TAX SUMMARY',\n    'SERVICE CHARGE','SERV. CHARGE','ROUNDING ADJ','ROUNDING ADJUSTMENT',\n    'AMT EXCL GST','AMT. EXCL. GST','AMOUNT EXCL GST','TOTAL EXCL GST','TOTAL EXCLUDING GST','EXCL. GST',\n]\n\nQUALIFIER_DOWNGRADE = [\n    'INCLUSIVE OF GST','INCL GST','INCL. GST','INCLUDING GST',\n    'EXCLUSIVE OF GST','EXCL GST','EXCL. GST','EXCLUDING GST',\n    'BEFORE ROUNDING','GROSS',\n]\n\ndef _normalize(line):\n    return re.sub(r'\\s+', ' ', line.strip().upper())\n\ndef _nospace(s):\n    return re.sub(r'\\s+', '', s)\n\ndef _line_is_excluded(norm_line):\n    return any(kw in norm_line for kw in EXCLUSIONS)\n\ndef _tier_match(norm_line, tier_keywords):\n    ns_line = _nospace(norm_line)\n    for kw in tier_keywords:\n        if kw in norm_line:\n            return True\n        if _nospace(kw) in ns_line:\n            return True\n    return False\n\ndef _is_qualifier_line(norm_line):\n    return any(q in norm_line for q in QUALIFIER_DOWNGRADE)\n\ndef _rounding_adj_proximity(norm_lines, i, window=4):\n    # True if a ROUNDING ADJ line appears within `window` lines before i\n    for j in range(max(0, i-window), i):\n        if 'ROUNDING ADJ' in norm_lines[j] or 'ROUNDING ADJUSTMENT' in norm_lines[j]:\n            return True\n    return False\n\ndef _is_plain_keyword_start(norm_line, tier_keywords):\n    # True when the keyword is at the start of the normalised line (plain final TOTAL style),\n    # and the line is not a qualifier line.\n    for kw in tier_keywords:\n        if norm_line.startswith(kw) and not _is_qualifier_line(norm_line):\n            return True\n    return False\n\ndef extract_total_from_text(text):\n    raw = [ln for ln in text.splitlines() if ln.strip()]\n    norm = [_normalize(ln) for ln in raw]\n\n    for tier_idx, tier in enumerate(TIERS):\n        cands = []  # (line_index, value, is_qualifier, after_rounding_adj, is_plain_start)\n        for i, nln in enumerate(norm):\n            if _line_is_excluded(nln):\n                continue\n            if not _tier_match(nln, tier):\n                continue\n            v = last_strict_number(raw[i])\n            if v is None:\n                # Two-line fallback: ONLY when the keyword line has no digits at all\n                if not re.search(r'\\d', raw[i]):\n                    for j in range(i+1, min(i+3, len(raw))):\n                        if not raw[j].strip():\n                            continue\n                        if _line_is_excluded(norm[j]):\n                            break\n                        v2 = last_strict_number(raw[j]) or last_loose_number(raw[j])\n                        if v2 is not None:\n                            cands.append((j, v2, _is_qualifier_line(nln), _rounding_adj_proximity(norm, j), False))\n                            break\n                continue\n            cands.append((i, v, _is_qualifier_line(nln), _rounding_adj_proximity(norm, i), _is_plain_keyword_start(nln, tier)))\n\n        if not cands:\n            continue\n\n        # Preference within tier (higher wins):\n        # 1) after a 'ROUNDING ADJ' line (final rounded total)\n        # 2) plain-keyword-start lines over qualifier lines (final 'TOTAL:' > 'Total (Inclusive of GST)')\n        # 3) non-qualifier over qualifier\n        # 4) latest (bottom-most) in reading order\n        cands.sort(key=lambda c: (c[3], c[4], not c[2], c[0]), reverse=True)\n        return cands[0][1]\n    return None\n```\n\n### Cross-anchor sanity check (catches isolated OCR digit flips)\n\nAfter picking a candidate `T`, compute anchor values from the SAME OCR text: the set of monetary values appearing on lines labelled with any of the anchor labels below. Anchors are evidence that a value is the receipt's authoritative total.\n\n- If an anchor value `A` appears on 2 or more such lines (consensus) and differs from `T` by more than 0.05, prefer `A`.\n- If a SINGLE strong anchor (`TOTAL ROUNDED`, `ROUNDED TOTAL`, `TOTAL AFTER ROUNDING`, `TOTAL RM INCLUDING GST`, `GRAND TOTAL`, or Cash − Change) exists and differs from `T` by more than 0.05, prefer that anchor.\n- When computing Cash − Change, use the matching Cash\u002FChange pair from the same text; the resulting value is a strong anchor.\n\n```python\nSTRONG_ANCHOR_LABELS = ['TOTAL ROUNDED','ROUNDED TOTAL','TOTAL AFTER ROUNDING','GRAND TOTAL','TOTAL RM INCLUDING GST','TOTAL RM INCL GST','NET TOTAL','NETT TOTAL']\nWEAK_ANCHOR_LABELS = ['SUBTOTAL','SUB TOTAL','SUB-TOTAL','TOTAL (INCLUSIVE OF GST)','TOTAL INCL GST','TOTAL INCLUSIVE OF GST','TOTAL INCL. OF GST','PAYMENT','VISA','MASTERCARD','DEBIT','CREDIT CARD']\n\ndef _line_label_matches(norm_line, labels):\n    return any(lbl in norm_line for lbl in labels)\n\ndef anchor_values(text):\n    strong, weak = [], []\n    norm_lines = []\n    raw_lines = []\n    for ln in text.splitlines():\n        if not ln.strip():\n            continue\n        raw_lines.append(ln)\n        norm_lines.append(_normalize(ln))\n    for ln, nln in zip(raw_lines, norm_lines):\n        if _line_label_matches(nln, STRONG_ANCHOR_LABELS):\n            v = last_strict_number(ln)\n            if v is not None:\n                strong.append(abs(v))\n        elif _line_label_matches(nln, WEAK_ANCHOR_LABELS):\n            v = last_strict_number(ln)\n            if v is not None:\n                weak.append(abs(v))\n    # Cash − Change consensus (strong anchor)\n    cash = change = None\n    for ln, nln in zip(raw_lines, norm_lines):\n        if 'CASH' in nln and 'TENDERED' not in nln and cash is None:\n            v = last_strict_number(ln)\n            if v is not None:\n                cash = v\n        if 'CHANGE' in nln and change is None:\n            v = last_strict_number(ln)\n            if v is not None:\n                change = v\n    if cash is not None and change is not None:\n        strong.append(round(cash - change, 2))\n    return strong, weak\n\ndef reconcile_total(text, chosen):\n    if chosen is None:\n        return None\n    strong, weak = anchor_values(text)\n    from collections import Counter\n    # 1) Strong single-anchor override: any strong anchor value disagreeing with chosen by >0.05\n    for s in strong:\n        if abs(s - chosen) > 0.05:\n            # Count how many anchors (strong + weak) agree with s vs chosen\n            agree_s = sum(1 for v in strong+weak if abs(v - s) \u003C= 0.05)\n            agree_c = sum(1 for v in strong+weak if abs(v - chosen) \u003C= 0.05)\n            if agree_s >= agree_c:\n                return round(s, 2)\n    # 2) Weak consensus (≥2 agreeing weak anchors on a different value)\n    counts = Counter(round(v, 2) for v in weak)\n    if counts:\n        best_anchor, best_count = counts.most_common(1)[0]\n        if best_count >= 2 and abs(best_anchor - chosen) > 0.05:\n            return best_anchor\n    return chosen\n```\n\n### Date extraction (ISO output, DD\u002FMM default, labelled-line preferred, sanity-checked)\n\n```python\nMONTHS = {m.upper(): i for i, m in enumerate(\n    ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'], start=1)}\nMONTHS_FULL = {m.upper(): i for i, m in enumerate(\n    ['January','February','March','April','May','June','July','August','September','October','November','December'], start=1)}\n\ndef _valid_ymd(y, m, d):\n    try:\n        datetime(y, m, d)\n        return 1990 \u003C= y \u003C= 2100\n    except ValueError:\n        return False\n\ndef _parse_numeric_date(a, b, c):\n    if len(a) == 4:\n        y, m, d = int(a), int(b), int(c)\n        if _valid_ymd(y, m, d):\n            return y, m, d\n        return None\n    if len(c) == 4:\n        y = int(c); d = int(a); mo = int(b)\n        # Prefer DD\u002FMM\u002FYYYY; fall back to MM\u002FDD\u002FYYYY only if DD\u002FMM invalid\n        if _valid_ymd(y, mo, d):\n            return y, mo, d\n        if _valid_ymd(y, d, mo):\n            return y, d, mo\n        return None\n    y = 2000 + int(c) if int(c) \u003C 100 else int(c)\n    d, mo = int(a), int(b)\n    if _valid_ymd(y, mo, d):\n        return y, mo, d\n    if _valid_ymd(y, d, mo):\n        return y, d, mo\n    return None\n\nDATE_LABEL_RE = re.compile(r'(?i)\\b(date|tarikh|invoice\\s*date|receipt\\s*date|bill\\s*date|trans(?:action)?\\s*date|tax\\s*invoice\\s*date|order\\s*date|data)\\b')\nNUM_DATE_RE = re.compile(r'(\\d{1,4})[\\-\u002F\\.](\\d{1,2})[\\-\u002F\\.](\\d{2,4})')\nTEXT_DATE_RE = re.compile(r'(\\d{1,2})\\s+([A-Za-z]{3,9})\\s+(\\d{4})')\n\ndef extract_date_from_text(text):\n    lines = [ln for ln in text.splitlines() if ln.strip()]\n    labelled, unlabelled = [], []\n    for ln in lines:\n        bucket = labelled if DATE_LABEL_RE.search(ln) else unlabelled\n        for m in NUM_DATE_RE.finditer(ln):\n            bucket.append(('num', m.group(1), m.group(2), m.group(3)))\n        for m in TEXT_DATE_RE.finditer(ln):\n            bucket.append(('txt', m.group(1), m.group(2), m.group(3)))\n    for group in (labelled, unlabelled):\n        for item in group:\n            if item[0] == 'num':\n                r = _parse_numeric_date(item[1], item[2], item[3])\n                if r:\n                    y, mo, d = r\n                    return f'{y:04d}-{mo:02d}-{d:02d}'\n            else:\n                d = int(item[1]); mo_name = item[2].upper(); y = int(item[3])\n                mo = MONTHS.get(mo_name[:3]) or MONTHS_FULL.get(mo_name)\n                if mo and _valid_ymd(y, mo, d):\n                    return f'{y:04d}-{mo:02d}-{d:02d}'\n    return None\n```\n\nNotes:\n- Labelled-line candidates (lines containing `Date`, `Tarikh`, `Invoice Date`, `Data` [common OCR of `Date`], etc.) are tried first before any other date-looking token.\n- `_parse_numeric_date` prefers DD\u002FMM\u002FYYYY over MM\u002FDD\u002FYYYY when both parse; this matches the dominant convention on RM\u002FGST-style receipts.\n- Years must be in [1990, 2100]; otherwise the candidate is rejected and the next is tried.\n\n### Format and null contract\n\n- `date`: `'YYYY-MM-DD'` string or Python `None`.\n- `total_amount`: Python string with exactly two decimals via `f'{v:.2f}'` or Python `None`. Never a float, never `''`, never `'None'`, never `NaN`.\n\n```python\ndef format_amount(v):\n    return None if v is None else f'{v:.2f}'\n```\n\n## Per-image pipeline with multi-pass arbitration\n\n```python\ndef extract_fields(image_path):\n    texts = ocr_variants(image_path)\n    date_candidates, total_candidates = [], []\n    for t in texts:\n        d = extract_date_from_text(t)\n        if d:\n            date_candidates.append(d)\n        v = extract_total_from_text(t)\n        v = reconcile_total(t, v)\n        if v is not None:\n            total_candidates.append(round(v, 2))\n    from collections import Counter\n    date = Counter(date_candidates).most_common(1)[0][0] if date_candidates else None\n    total = Counter(total_candidates).most_common(1)[0][0] if total_candidates else None\n    return date, format_amount(total)\n```\n\n## Batch Pipeline to Tabular Output (strict null preservation)\n\nUse openpyxl directly so `None` becomes a truly empty cell (not `''`, not `'None'`, not `NaN`). Keep `total_amount` as a text cell (`number_format='@'`) so strings like `'20.00'` survive round-trip.\n\n```python\nIMG_EXTS = {'.jpg', '.jpeg', '.png', '.webp'}\n\ndef process_receipts(img_dir, out_xlsx):\n    from openpyxl import Workbook\n    files = sorted(f for f in os.listdir(img_dir) if Path(f).suffix.lower() in IMG_EXTS)\n    wb = Workbook()\n    ws = wb.active\n    ws.title = 'results'\n    ws.append(['filename', 'date', 'total_amount'])\n    for fn in files:\n        try:\n            date_val, amount_val = extract_fields(os.path.join(img_dir, fn))\n        except Exception:\n            date_val, amount_val = None, None\n        # Explicit None preservation: write None (empty cell), never '' or 'None'.\n        row_idx = ws.max_row + 1\n        ws.cell(row=row_idx, column=1, value=fn)\n        ws.cell(row=row_idx, column=2, value=date_val if date_val else None)\n        ws.cell(row=row_idx, column=3, value=amount_val if amount_val else None)\n        ws.cell(row=row_idx, column=3).number_format = '@'\n    wb.save(out_xlsx)\n```\n\n## Critical Rules for Receipt Extraction\n\n- Exclusions are applied FIRST on each line.\n- Priority tiers are tried in order; move to the next tier only when the current tier yields no candidates.\n- Within a tier, prefer in this order: (1) a line that follows a `ROUNDING ADJ` line within a 4-line window (final rounded total); (2) plain-keyword-start lines (e.g. bare `TOTAL:`) over qualifier lines (`Total (Inclusive of GST)`, `Before Rounding`); (3) non-qualifier lines over qualifier lines; (4) the latest (bottom-most) line in reading order.\n- Keyword matching tolerates missing whitespace (`GRANDTOTAL`, `TOTALRM`) via a nospace secondary match.\n- Two-line fallback triggers ONLY when the keyword line contains no digits at all; the fallback line must itself pass the exclusion filter.\n- Cross-anchor sanity: strong anchors (`TOTAL ROUNDED`, `ROUNDED TOTAL`, `GRAND TOTAL`, `TOTAL RM INCLUDING GST`, `NET TOTAL`, Cash−Change) override the chosen value when they disagree by >0.05 and are at least as well-supported; weak anchors require ≥2 agreeing values to override.\n- Numbers: strip currency prefixes (`RM`, `$`, `MYR`) and commas; strip leading `-` for negative-payment anchors like Visa; repair in-token `:`→`.`; prefer strict two-decimal matches; use loose matches only as last resort.\n- Dates: prefer labelled-line candidates (lines containing `Date`, `Data`, `Tarikh`, `Invoice Date`, etc.); default to DD\u002FMM\u002FYYYY when both DD\u002FMM and MM\u002FDD parse; reject years outside 1990–2100 and invalid day\u002Fmonth combinations.\n- Null on failure means Python `None` written as an empty Excel cell. Keep `total_amount` as a 2-decimal string (`f'{v:.2f}'`) when present.\n- Per-image failures must degrade to `(None, None)` without aborting the batch.\n- Run extraction PER OCR pass and arbitrate by majority value; never concatenate variant texts before extraction.\n\n## Common Pitfalls\n\n- Choosing a pre-rounding `Total (Inclusive of GST)` line when a later plain `TOTAL:` line after `Rounding Adj` is the final payable amount.\n- Picking a payment line (`Cash Received`, `Visa`, `Mastercard`) via the two-line fallback.\n- Writing `''` or `'None'` or `NaN` for failed fields, which fail strict null-equality verifiers.\n- Writing a float like `47.7` instead of the string `'47.70'`.\n- Confusing DD\u002FMM and MM\u002FDD on ambiguous dates (both components ≤ 12) due to no documented default.\n- Accepting a 1-decimal OCR truncation like `88.1` as a valid monetary total when a 2-decimal candidate exists in another variant.\n- Letting a single-variant OCR digit flip (e.g. `102`→`702`, `538`→`702`) win when strong anchors (`Total Rounded`, `Total RM Including GST`, Cash−Change) all agree on another value.\n- Requiring exact whitespace between `GRAND` and `TOTAL`: OCR commonly glues these into `GRANDTOTAL`.\n\n## Quality Self-Check\n\n- [ ] All image files in the input directory are processed (filtered by extension) and rows are sorted lexicographically by filename.\n- [ ] `date` is `'YYYY-MM-DD'` or Python `None`.\n- [ ] `total_amount` is a Python string matching `^\\d+\\.\\d{2}$` or Python `None`.\n- [ ] Exclusion keywords never source the total; qualifier lines are deprioritized when a cleaner candidate exists.\n- [ ] Rounding-aware preference and cross-anchor sanity are applied; strong anchors can override on their own.\n- [ ] None values become empty cells in the output workbook (verify by reloading with openpyxl and asserting `cell.value is None`).\n- [ ] Exactly one sheet named `results` with exactly the required columns in the required order.\n\n## Tesseract Configuration Reference\n\n```python\n# PSM 6: uniform block of text (default for receipts)\n# PSM 4: single column, variable sizes\n# PSM 11: sparse text\ntext = pytesseract.image_to_string(img, config='--psm 6', lang='eng')\n```\n\n## Limitations\n\n- Tesseract is weakest on handwriting, decorative fonts, and severely degraded scans.\n- Ambiguous dates where both components ≤ 12 and no label is present are resolved by the DD\u002FMM default; some will still be wrong when the receipt uses MM\u002FDD.\n- OCR digit confusions (`1`↔`7`, `0`↔`O\u002FQ`, `5`↔`S`, `8`↔`B`) can still produce wrong totals when no anchor consensus exists.\n",{"name":5,"description":7},"main","cdcb6d230f3b15c3c33f8a2bdd16439f43602cbe","jpg-ocr-stat\u002Fresult\u002Fskilltta-claude4.7\u002Fevidence\u002Fattempts\u002Fjpg-ocr-stat-skilltta-opus47-aigw-v2-r004\u002Finputs\u002Fskills\u002Fimage-ocr",1,"https:\u002F\u002Fgithub.com\u002Flinyouxue\u002Fskill-repair-benchmark","https:\u002F\u002Fgithub.com\u002Flinyouxue","2026-11-14 20:28:35",{"source":8,"skillName":5,"commands":32,"prompt":42,"zipAvailable":43},[33,36,39],{"tool":34,"command":35},"npx","npx skills add linyouxue\u002Fskill-repair-benchmark --skill image-ocr",{"tool":37,"command":38},"bunx","bunx skills add linyouxue\u002Fskill-repair-benchmark --skill image-ocr",{"tool":40,"command":41},"pnpm","pnpm dlx skills add linyouxue\u002Fskill-repair-benchmark --skill image-ocr","安装技能，技能地址：https:\u002F\u002Fgithub.com\u002Flinyouxue\u002Fskill-repair-benchmark\u002Fblob\u002Fmain\u002Fjpg-ocr-stat\u002Fresult\u002Fskilltta-claude4.7\u002Fevidence\u002Fattempts\u002Fjpg-ocr-stat-skilltta-opus47-aigw-v2-r004\u002Finputs\u002Fskills\u002Fimage-ocr\u002FSKILL.md",true,[],[46,53,60,66,71,76],{"skillNo":47,"name":48,"nameZh":6,"description":49,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":50,"categories":51,"keywords":52,"publishedAt":19},"01M7E1HGVRY73NBJ022CJG13X6","video-frame-extraction","Extract frames from video files and save them as images using OpenCV","https:\u002F\u002Fgithub.com\u002Flinyouxue\u002Fskill-repair-benchmark\u002Fblob\u002Fmain\u002Fjpg-ocr-stat\u002Fresult\u002Fskilltta-claude4.7\u002Fgroup_03\u002Frun_03\u002Finputs\u002Fskills\u002Fvideo-frame-extraction\u002FSKILL.md",[],[],{"skillNo":54,"name":55,"nameZh":6,"description":56,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":57,"categories":58,"keywords":59,"publishedAt":19},"01M7E1HAG5R7CJ9C8NYJS771MB","openai-vision","Analyze images and multi-frame sequences using OpenAI GPT vision models","https:\u002F\u002Fgithub.com\u002Flinyouxue\u002Fskill-repair-benchmark\u002Fblob\u002Fmain\u002Fjpg-ocr-stat\u002Fresult\u002Fskilltta-claude4.7\u002Fgroup_03\u002Frun_03\u002Finputs\u002Fskills\u002Fopenai-vision\u002FSKILL.md",[],[],{"skillNo":61,"name":5,"nameZh":6,"description":62,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":63,"categories":64,"keywords":65,"publishedAt":19},"01M7E1H7DADKB6QBWKC2KQ63SZ","Extract text content from images using Tesseract OCR via Python, including structured field extraction from receipts and forms","https:\u002F\u002Fgithub.com\u002Flinyouxue\u002Fskill-repair-benchmark\u002Fblob\u002Fmain\u002Fjpg-ocr-stat\u002Fresult\u002Fskilltta-claude4.7\u002Fgroup_03\u002Frun_03\u002Finputs\u002Fskills\u002Fimage-ocr\u002FSKILL.md",[],[],{"skillNo":67,"name":48,"nameZh":6,"description":49,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":68,"categories":69,"keywords":70,"publishedAt":19},"01M7E1H0WBNT43TENGQY7AVZHD","https:\u002F\u002Fgithub.com\u002Flinyouxue\u002Fskill-repair-benchmark\u002Fblob\u002Fmain\u002Fjpg-ocr-stat\u002Fresult\u002Fskilltta-claude4.7\u002Fgroup_03\u002Frun_02\u002Finputs\u002Fskills\u002Fvideo-frame-extraction\u002FSKILL.md",[],[],{"skillNo":72,"name":55,"nameZh":6,"description":56,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":73,"categories":74,"keywords":75,"publishedAt":19},"01M7E1GTC7D4N4Q2EF03875J9E","https:\u002F\u002Fgithub.com\u002Flinyouxue\u002Fskill-repair-benchmark\u002Fblob\u002Fmain\u002Fjpg-ocr-stat\u002Fresult\u002Fskilltta-claude4.7\u002Fgroup_03\u002Frun_02\u002Finputs\u002Fskills\u002Fopenai-vision\u002FSKILL.md",[],[],{"skillNo":77,"name":5,"nameZh":6,"description":62,"descriptionZh":6,"repositoryFullName":8,"ownerLogin":9,"ownerAvatarUrl":10,"ownerAccountNo":11,"ownerDisplayName":12,"repositoryNo":13,"starsCount":14,"licenseSpdxId":15,"licenseFileUrl":6,"sourceHtmlUrl":78,"categories":79,"keywords":80,"publishedAt":19},"01M7E1GQ92CWFMGH53KSSE4WER","https:\u002F\u002Fgithub.com\u002Flinyouxue\u002Fskill-repair-benchmark\u002Fblob\u002Fmain\u002Fjpg-ocr-stat\u002Fresult\u002Fskilltta-claude4.7\u002Fgroup_03\u002Frun_02\u002Finputs\u002Fskills\u002Fimage-ocr\u002FSKILL.md",[],[]]