Spaces:
Running
Sync from GitHub main 955be71: dual-pass merge fix + Costco receipt support
Browse filesfix(ocr): the dual-pass merge ranked candidates by confidence and treated any
horizontal overlap as a duplicate, so a short high-confidence fragment was
selected before the complete line and then suppressed it — "1 Guylian
Seashells Boxed Chocolates" came out as "Chocolates". A candidate is now a
duplicate only when a selected block covers most of its width. Every price was
already correct, so this only ever showed up in the description text.
feat(parser): Costco prints the product name on the line above its item code
and price, which shifted every description onto the wrong item; and its
"TOTAL(INCL VAT)" is under a redaction bar, so "AMOUNT:" is now accepted as a
total, anchored on the colon so "VAT Amount" cannot match.
Measured on the 15-receipt corpus added in the GitHub repo (eval/, not synced
here — the Dockerfile only copies main.py, ocr/ and utils/):
description word recall 64.7% -> 90.0%
line-item price accuracy 89.4% -> 97.2%
totals 13/15 -> 15/15
correct item counts 12/15 -> 15/15
- ocr/parser.py +58 -4
- ocr/reader.py +21 -3
|
@@ -63,7 +63,7 @@ _PRICE_EXTRACT_RE = re.compile(r"(-?)\s*[^\d\s]{0,2}\s*(\d{1,6}[.,\s]\d{2})")
|
|
| 63 |
|
| 64 |
_TOTALS_KEYWORDS = re.compile(
|
| 65 |
r"\b(total|sub[\s-]?total|subtotal|savings|promotions|tax|gst|hst|balance"
|
| 66 |
-
r"|amount\s+due|amount\s+payable|to\s+pay)\b",
|
| 67 |
re.IGNORECASE,
|
| 68 |
)
|
| 69 |
|
|
@@ -104,6 +104,11 @@ _TRAILING_CODE_RE = re.compile(r"\s+\d{6,}$")
|
|
| 104 |
_QTY_HEADER_RE = re.compile(r"^\s*qty\b", re.IGNORECASE)
|
| 105 |
_QTY_PREFIX_RE = re.compile(r"^([1-9]\d?)\s+(\S.{2,})$")
|
| 106 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
# Per-unit price denominator: £0.90/kg, £1.50/litre — weight annotation, not an item price
|
| 108 |
_PER_UNIT_PRICE_RE = re.compile(r"[£$€¥]\s*\d+[.,]\d+\s*/")
|
| 109 |
|
|
@@ -119,8 +124,12 @@ _PAYMENT_SKIP = re.compile(
|
|
| 119 |
# spelling variants _TOTALS_KEYWORDS accepts for section detection.
|
| 120 |
_SUBTOTAL_RE = re.compile(r"\bsub[\s-]?total\b", re.IGNORECASE)
|
| 121 |
_SAVINGS_RE = re.compile(r"\b(saving|promotion)", re.IGNORECASE)
|
|
|
|
|
|
|
|
|
|
| 122 |
_TOTAL_RE = re.compile(
|
| 123 |
-
r"\b(total|amount\s+due|amount\s+payable|to\s+pay|balance\s+due)\b"
|
|
|
|
| 124 |
re.IGNORECASE,
|
| 125 |
)
|
| 126 |
|
|
@@ -841,6 +850,8 @@ def _extract_line_items(
|
|
| 841 |
"""
|
| 842 |
items: list[dict[str, Any]] = []
|
| 843 |
current: dict[str, Any] | None = None
|
|
|
|
|
|
|
| 844 |
|
| 845 |
for row in item_rows:
|
| 846 |
price_blk = next(
|
|
@@ -850,7 +861,10 @@ def _extract_line_items(
|
|
| 850 |
desc_blocks = [b for b in row if b is not price_blk]
|
| 851 |
|
| 852 |
if price_blk is None:
|
| 853 |
-
if
|
|
|
|
|
|
|
|
|
|
| 854 |
_append_desc(current, desc_blocks, receipt_width)
|
| 855 |
continue
|
| 856 |
|
|
@@ -879,12 +893,52 @@ def _extract_line_items(
|
|
| 879 |
"total_price": price_str,
|
| 880 |
"discount": None,
|
| 881 |
}
|
| 882 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 883 |
items.append(current)
|
| 884 |
|
| 885 |
return items
|
| 886 |
|
| 887 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
def _is_desc_block(block: dict, receipt_width: float) -> bool:
|
| 889 |
"""
|
| 890 |
Description blocks start before the price column (70% mark).
|
|
|
|
| 63 |
|
| 64 |
_TOTALS_KEYWORDS = re.compile(
|
| 65 |
r"\b(total|sub[\s-]?total|subtotal|savings|promotions|tax|gst|hst|balance"
|
| 66 |
+
r"|amount\s+due|amount\s+payable|to\s+pay)\b|\bamount\s*:",
|
| 67 |
re.IGNORECASE,
|
| 68 |
)
|
| 69 |
|
|
|
|
| 104 |
_QTY_HEADER_RE = re.compile(r"^\s*qty\b", re.IGNORECASE)
|
| 105 |
_QTY_PREFIX_RE = re.compile(r"^([1-9]\d?)\s+(\S.{2,})$")
|
| 106 |
|
| 107 |
+
# Bare product code opening a priced row — the Costco "description above the
|
| 108 |
+
# price row" signature (see _is_desc_above_layout). Three digits minimum so a
|
| 109 |
+
# leading quantity can never match.
|
| 110 |
+
_ITEM_CODE_RE = re.compile(r"^\d{3,}$")
|
| 111 |
+
|
| 112 |
# Per-unit price denominator: £0.90/kg, £1.50/litre — weight annotation, not an item price
|
| 113 |
_PER_UNIT_PRICE_RE = re.compile(r"[£$€¥]\s*\d+[.,]\d+\s*/")
|
| 114 |
|
|
|
|
| 124 |
# spelling variants _TOTALS_KEYWORDS accepts for section detection.
|
| 125 |
_SUBTOTAL_RE = re.compile(r"\bsub[\s-]?total\b", re.IGNORECASE)
|
| 126 |
_SAVINGS_RE = re.compile(r"\b(saving|promotion)", re.IGNORECASE)
|
| 127 |
+
# "AMOUNT: £112.55" is the only readable total on a Costco receipt, whose
|
| 128 |
+
# "TOTAL(INCL VAT)" is overprinted with a redaction bar. Anchored on the colon
|
| 129 |
+
# so the "VAT Amount" line further down cannot match.
|
| 130 |
_TOTAL_RE = re.compile(
|
| 131 |
+
r"\b(total|amount\s+due|amount\s+payable|to\s+pay|balance\s+due)\b"
|
| 132 |
+
r"|\bamount\s*:",
|
| 133 |
re.IGNORECASE,
|
| 134 |
)
|
| 135 |
|
|
|
|
| 850 |
"""
|
| 851 |
items: list[dict[str, Any]] = []
|
| 852 |
current: dict[str, Any] | None = None
|
| 853 |
+
desc_above = _is_desc_above_layout(item_rows, receipt_width)
|
| 854 |
+
pending_desc: list[dict] = []
|
| 855 |
|
| 856 |
for row in item_rows:
|
| 857 |
price_blk = next(
|
|
|
|
| 861 |
desc_blocks = [b for b in row if b is not price_blk]
|
| 862 |
|
| 863 |
if price_blk is None:
|
| 864 |
+
if desc_above:
|
| 865 |
+
# The product name precedes the row carrying its price.
|
| 866 |
+
pending_desc = desc_blocks
|
| 867 |
+
elif current is not None:
|
| 868 |
_append_desc(current, desc_blocks, receipt_width)
|
| 869 |
continue
|
| 870 |
|
|
|
|
| 893 |
"total_price": price_str,
|
| 894 |
"discount": None,
|
| 895 |
}
|
| 896 |
+
if desc_above and pending_desc:
|
| 897 |
+
# This row holds the item code, "1x" and the unit price, not the
|
| 898 |
+
# product name — that came on the row before.
|
| 899 |
+
_append_desc(current, pending_desc, receipt_width)
|
| 900 |
+
pending_desc = []
|
| 901 |
+
else:
|
| 902 |
+
# No name row was banked. On a skewed scan the name and price rows
|
| 903 |
+
# can merge into one, so this row still carries the description.
|
| 904 |
+
_append_desc(current, desc_blocks, receipt_width)
|
| 905 |
items.append(current)
|
| 906 |
|
| 907 |
return items
|
| 908 |
|
| 909 |
|
| 910 |
+
def _is_desc_above_layout(item_rows: list[list[dict]], receipt_width: float) -> bool:
|
| 911 |
+
"""
|
| 912 |
+
Detect the Costco-style layout where the product name sits on its own line
|
| 913 |
+
*above* the row carrying its item code, quantity and price:
|
| 914 |
+
|
| 915 |
+
KS STRAWB C/CAKE
|
| 916 |
+
7963 1x 14.99 14.99 Z
|
| 917 |
+
|
| 918 |
+
Signature: the priced row begins with a bare item code. Three or more
|
| 919 |
+
digits, so a leading quantity ("1 Pringles...", "2 Tesco...") on the
|
| 920 |
+
ordinary description-and-price-on-one-line layout can't trigger it.
|
| 921 |
+
"""
|
| 922 |
+
priced = coded = 0
|
| 923 |
+
for row in item_rows:
|
| 924 |
+
price_blk = next(
|
| 925 |
+
(b for b in reversed(row) if _is_price_block(b, receipt_width)),
|
| 926 |
+
None,
|
| 927 |
+
)
|
| 928 |
+
if price_blk is None:
|
| 929 |
+
continue
|
| 930 |
+
priced += 1
|
| 931 |
+
desc_blocks = [b for b in row if b is not price_blk]
|
| 932 |
+
if not desc_blocks:
|
| 933 |
+
continue
|
| 934 |
+
leftmost = min(desc_blocks, key=lambda b: _left_x(b["bbox"]))
|
| 935 |
+
tokens = leftmost["text"].split()
|
| 936 |
+
if tokens and _ITEM_CODE_RE.match(tokens[0]):
|
| 937 |
+
coded += 1
|
| 938 |
+
|
| 939 |
+
return priced >= 3 and coded / priced >= 0.6
|
| 940 |
+
|
| 941 |
+
|
| 942 |
def _is_desc_block(block: dict, receipt_width: float) -> bool:
|
| 943 |
"""
|
| 944 |
Description blocks start before the price column (70% mark).
|
|
@@ -14,6 +14,12 @@ logger = logging.getLogger(__name__)
|
|
| 14 |
# Tolerance for merging overlapping blocks from dual-pass OCR (pixels)
|
| 15 |
_MERGE_Y_TOL = 15
|
| 16 |
_MERGE_X_TOL = 40
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class PaddleOCRReader:
|
|
@@ -170,19 +176,31 @@ def _merge_blocks(primary: list[dict], secondary: list[dict]) -> list[dict]:
|
|
| 170 |
|
| 171 |
|
| 172 |
def _blocks_overlap(a: dict, b: dict) -> bool:
|
| 173 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
ay = _top_y(a["bbox"])
|
| 175 |
by = _top_y(b["bbox"])
|
| 176 |
if abs(ay - by) > _MERGE_Y_TOL:
|
| 177 |
return False
|
| 178 |
|
| 179 |
-
# Check horizontal overlap of bounding boxes
|
| 180 |
ax1 = min(pt[0] for pt in a["bbox"])
|
| 181 |
ax2 = max(pt[0] for pt in a["bbox"])
|
| 182 |
bx1 = min(pt[0] for pt in b["bbox"])
|
| 183 |
bx2 = max(pt[0] for pt in b["bbox"])
|
| 184 |
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
| 186 |
|
| 187 |
|
| 188 |
def _top_y(bbox: list) -> float:
|
|
|
|
| 14 |
# Tolerance for merging overlapping blocks from dual-pass OCR (pixels)
|
| 15 |
_MERGE_Y_TOL = 15
|
| 16 |
_MERGE_X_TOL = 40
|
| 17 |
+
# A candidate block is a duplicate only when a selected block covers at least
|
| 18 |
+
# this fraction of its width. Below it the two are complementary fragments of
|
| 19 |
+
# one line and both are kept, for the parser to join. On the eval corpus
|
| 20 |
+
# description recall climbs to 0.7 and is flat to 0.9 (88.7% at 0.5, 90.0% at
|
| 21 |
+
# 0.8) with price accuracy unchanged; below 0.3 items start being lost.
|
| 22 |
+
_MERGE_COVER_RATIO = 0.8
|
| 23 |
|
| 24 |
|
| 25 |
class PaddleOCRReader:
|
|
|
|
| 176 |
|
| 177 |
|
| 178 |
def _blocks_overlap(a: dict, b: dict) -> bool:
|
| 179 |
+
"""
|
| 180 |
+
True when candidate `a` duplicates already-selected `b`: y-proximity AND
|
| 181 |
+
`b` covering most of `a`'s width.
|
| 182 |
+
|
| 183 |
+
Treating *any* horizontal overlap as duplication discards text. The two
|
| 184 |
+
passes routinely split one line differently — pass 1 gives
|
| 185 |
+
"Guylian Seashells Boxed C" + "Chocolates", pass 2 gives the whole line —
|
| 186 |
+
and those fragments only clip each other at the edges. Dropping on a
|
| 187 |
+
one-pixel touch loses whole words; requiring real coverage keeps
|
| 188 |
+
complementary fragments so the parser can join them into one description.
|
| 189 |
+
"""
|
| 190 |
ay = _top_y(a["bbox"])
|
| 191 |
by = _top_y(b["bbox"])
|
| 192 |
if abs(ay - by) > _MERGE_Y_TOL:
|
| 193 |
return False
|
| 194 |
|
|
|
|
| 195 |
ax1 = min(pt[0] for pt in a["bbox"])
|
| 196 |
ax2 = max(pt[0] for pt in a["bbox"])
|
| 197 |
bx1 = min(pt[0] for pt in b["bbox"])
|
| 198 |
bx2 = max(pt[0] for pt in b["bbox"])
|
| 199 |
|
| 200 |
+
overlap = min(ax2, bx2) - max(ax1, bx1)
|
| 201 |
+
if overlap <= 0:
|
| 202 |
+
return False
|
| 203 |
+
return overlap / max(ax2 - ax1, 1) >= _MERGE_COVER_RATIO
|
| 204 |
|
| 205 |
|
| 206 |
def _top_y(bbox: list) -> float:
|