sinful1992 commited on
Commit
bfba334
·
1 Parent(s): 2f4c0b3

Sync from GitHub main 955be71: dual-pass merge fix + Costco receipt support

Browse files

fix(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

Files changed (2) hide show
  1. ocr/parser.py +58 -4
  2. ocr/reader.py +21 -3
ocr/parser.py CHANGED
@@ -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 current is not None:
 
 
 
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
- _append_desc(current, desc_blocks, receipt_width)
 
 
 
 
 
 
 
 
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).
ocr/reader.py CHANGED
@@ -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
- """Check if two blocks overlap: y-proximity AND horizontal bbox overlap."""
 
 
 
 
 
 
 
 
 
 
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
- return ax1 < bx2 and ax2 > bx1
 
 
 
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: