leptons1618@anishgiri.dev:~/case-studies/menu-ocr less writeup.md
Menu OCR: Turning Restaurant Menu Photos into Structured JSON
How a six-stage pipeline handles multi-column layouts, ambiguous price-item pairing, and hierarchy extraction that off-the-shelf OCR cannot.
- published
- November 8, 2023
- read
- 2 min
- stack
- 7 tools
- outcomes
- 5
- media
- —
01The Problem
A menu photo is one of the least structured inputs you can hand an OCR system: no declared reading order, prices far from their items, hierarchy implied only by font size. Off-the-shelf OCR returns boxes and strings, not structure, and models trained on receipts (CORD-v2) don't transfer to menus.
02The Solution
Build the structure explicitly instead of hoping a model learns it. Cluster text x-coordinates into columns, assign prices globally with Hungarian matching, enforce the section-group-item hierarchy with an FSM decoded by Viterbi. Rules do the primary work; ML only steps in when rule confidence drops below 0.7.
The pipeline
1. Image normalization Menus arrive as phone photos: uneven lighting, skew from handheld shots, shadows down the page fold. OpenCV handles contrast stretching, deskew via the dominant text angle, and denoising before anything touches a model. Skipping this stage costs more downstream than it costs here — OCR confidence on a skewed image degrades enough that later stages inherit garbage coordinates.
2. OCR extraction PaddleOCR produces text candidates with bounding boxes and confidence scores. At this point the output is still just lines: “Butter Chicken”, “380”, “Starters”, “Paneer Tikka”, with no knowledge of what any of it means. The only useful structure so far is geometric — where each box sits on the page.
3. Layout reconstruction This is where columns get discovered. All text x-coordinates go through DBSCAN clustering, which separates a two- or three-column menu without any hardcoded layout template. The price column falls out automatically: it’s the rightmost cluster with a high digit ratio. Menus almost always right-align prices, so this heuristic holds even when descriptions wrap and push their boxes leftward.
4. Semantic correction Each line gets classified as Section, Group, Item, or Price. Rule-based classification leads: font-scale clustering catches sections (they’re bigger), spaCy-based lexical priors catch obvious item names, position does most of the rest. ML classifiers only adjust predictions when rule confidence drops below 0.7 — more on why below.
5. Price-item assignment Greedy matching — “nearest price to each item, first come first served” — breaks on real menus. Two items stacked vertically can both claim the same price while a third goes unassigned. Replacing greedy with Hungarian-algorithm bipartite matching over vertical alignment, horizontal distance, and column consistency guarantees every item owns exactly one price and no price is claimed twice.
6. Hierarchy decoding Per-line classification alone produces sequences that violate the schema — a Group appearing after its Items, two Sections interleaved. So transitions run through a finite-state machine (Section→Group→Item→Price) and Viterbi decodes the globally optimal label sequence instead of taking each line’s best label independently. One wrong local classification can be outvoted by the sequence around it.
Why the rules beat the models
I benchmarked five classifiers against the rule-based baseline. Ensemble price accuracy came out at 47.2% versus 13.6–15.9% for individual receipt-trained models. The reason is domain mismatch: CORD-v2 is tuned for receipts — linear layouts, different label semantics, different price patterns. On menus those models actively hurt.
That result settled the architecture: rules are the primary signal, ML is a fallback for low-confidence lines only.
Performance
End-to-end, an image processes in about 330ms on GPU — a 3.5× speedup over CPU. Most of that budget is OCR; the layout and matching stages are cheap because they operate on dozens of boxes, not pixels.
03Achievements
- Cut per-image processing to roughly 330ms on GPU, a 3.5x speedup over CPU.
- Eliminated conflicting price assignments by replacing greedy matching with Hungarian-algorithm bipartite matching over vertical alignment, horizontal distance, and column consistency.
- Resolved multi-column reading order through DBSCAN clustering of text coordinates, with automatic identification of the price column.
- Produced valid Section-to-Group-to-Item-to-Price hierarchies using an FSM decoded via Viterbi rather than per-line classification.
- Benchmarked five classifiers against the rule-based baseline and showed ensemble price accuracy of 47.2% versus 13.6-15.9% for individual receipt-trained models, confirming domain mismatch and demoting ML to a secondary signal.