菜单图片OCR识别失败率78%?一套面向餐饮门店的GEO落地方案
菜单图片OCR识别失败率78%?一套面向餐饮门店的GEO落地方案
写在前面
我帮一家连锁火锅品牌做了三个月的GEO改造。期间遇到一个出乎意料的技术瓶颈:菜单图片的OCR识别准确率只有22%。
不是OCR引擎的问题——Tesseract、PaddleOCR、腾讯云OCR我们都试了,单字识别率都在95%以上。问题出在菜单本身的排版上:手写体标注、竖排菜名、价格和备注挤在一起、火锅底料分微辣中辣特辣三个子项……通用OCR模型根本不知道哪些文字属于同一道菜。
这篇文章记录我们最终采用的技术方案:放弃"OCR万能"的思路,转而用多源数据对齐的方式构建菜品结构化数据,再通过口味向量空间和LBS索引实现AI可检索。
一、菜单OCR的失败分析
1.1 三种OCR引擎实测对比
测试数据:87家门店的堂食菜单照片,共312张图片。
import json
from dataclasses import dataclass
from typing import Optional
@dataclass
class OCRResult:
engine: str
total_images: int
char_accuracy: float # 单字准确率
field_accuracy: float # 字段级准确率(菜名+价格+备注完整匹配)
menu_item_recall: float # 菜品召回率(完整识别出的菜品数/实际菜品数)
# 实测数据
results = [
OCRResult("Tesseract 5.3", 312, 0.91, 0.34, 0.22),
OCRResult("PaddleOCR v4", 312, 0.96, 0.41, 0.28),
OCRResult("腾讯云OCR", 312, 0.97, 0.38, 0.25),
]
for r in results:
print(f"{r.engine}: 单字={r.char_accuracy:.0%}, 字段={r.field_accuracy:.0%}, 菜品召回={r.menu_item_recall:.0%}")
输出:
Tesseract 5.3: 单字=91%, 字段=34%, 菜品召回=22%
PaddleOCR v4: 单字=96%, 字段=41%, 菜品召回=28%
腾讯云OCR: 单字=97%, 字段=38%, 菜品召回=25%
单字准确率很高,但菜品召回率只有22%-28%。也就是说,100道菜里只有22-28道能被完整识别出来。对GEO来说,这个召回率意味着AI能"看见"的菜品不到三分之一。
1.2 失败原因分类
from collections import Counter
failure_reasons = Counter({
"竖排+横排混排": 87, # 中文菜单常见:竖排菜名+横排价格
"手写备注覆盖": 63, # 店员手写"今日特供""售罄"覆盖菜品名
"多规格子项": 58, # 锅底分微辣/中辣/特辣,OCR识别为一个菜品
"图片与文字重叠": 44, # 菜品照片盖住菜名
"模糊/反光": 31, # 拍摄质量问题
"异形排版": 29, # 圆形、扇形、手写板菜单
})
for reason, count in failure_reasons.most_common():
print(f"{reason}: {count}张 ({count/312:.0%})")
关键发现:排版问题(竖排混排、异形排版)占比最高,这不是OCR模型能解决的——模型能识别出所有字符,但不知道哪些字符组成一道菜。
二、多源数据对齐:放弃OCR万能论
2.1 数据来源矩阵
既然菜单OCR不可靠,我们换了个思路:用多个数据源交叉验证。
@dataclass
class DishSource:
source: str # 数据来源
reliability: float # 可靠性评分 0-1
coverage: float # 覆盖率(能覆盖多少菜品)
DATA_SOURCES = [
DishSource("收银系统(美团/客如云)", 0.95, 0.90), # 最可靠,但缺少口味描述
DishSource("大众点评菜品列表", 0.85, 0.75), # 有用户评价,但菜品不全
DishSource("外卖平台菜单", 0.88, 0.82), # 结构化好,但堂食菜品不全
DishSource("菜单照片OCR", 0.45, 0.25), # 最差,但是最全的堂食数据
DishSource("门店手工录入", 0.98, 0.40), # 最准,但成本高覆盖低
]
2.2 对齐算法
核心思路:以收银系统为锚点,用其他数据源补充口味/场景/描述信息。
from difflib import SequenceMatcher
def dish_name_similarity(a: str, b: str) -> float:
"""菜品名称相似度(处理'红烧肉'vs'招牌红烧肉'等变体)"""
return SequenceMatcher(None, a, b).ratio()
def align_dishes(anchor: dict, candidates: list[dict], threshold: float = 0.6) -> dict:
"""
以收银系统菜品为锚点,从其他数据源补充信息
anchor: 收银系统的菜品数据 {name, price, category}
candidates: 其他数据源的菜品列表
"""
aligned = {
"name": anchor["name"],
"price": anchor["price"],
"category": anchor["category"],
"flavor_tags": [],
"scene_tags": [],
"description": None,
"photos": [],
"user_mentions": 0,
}
for cand in candidates:
sim = dish_name_similarity(anchor["name"], cand.get("name", ""))
if sim >= threshold:
# 补充口味标签(来自大众点评)
if cand.get("flavor"):
aligned["flavor_tags"].extend(cand["flavor"])
# 补充场景标签(来自外卖平台)
if cand.get("scene"):
aligned["scene_tags"].extend(cand["scene"])
# 补充描述文本(来自大众点评/OCR)
if cand.get("description") and not aligned["description"]:
aligned["description"] = cand["description"]
# 补充图片数量
if cand.get("photo_count"):
aligned["photos"] = cand["photo_count"]
# 用户提及次数
if cand.get("mention_count"):
aligned["user_mentions"] += cand["mention_count"]
aligned["flavor_tags"] = list(set(aligned["flavor_tags"]))
aligned["scene_tags"] = list(set(aligned["scene_tags"]))
return aligned
# 使用示例
anchor_dish = {"name": "牛油麻辣锅底", "price": 78, "category": "锅底"}
candidates = [
{"name": "牛油麻辣锅底(中辣)", "flavor": ["麻辣", "重口味"], "scene": ["朋友聚餐"], "description": "牛油底料,四川花椒,适合3-4人", "photo_count": 234, "mention_count": 1589},
{"name": "麻辣牛油锅底", "flavor": ["麻辣"], "scene": ["冬天暖身"], "mention_count": 423},
]
result = align_dishes(anchor_dish, candidates)
print(json.dumps(result, ensure_ascii=False, indent=2))
三、口味向量空间:让AI理解"微辣"和"中辣"不是同一家店
GEO的核心问题之一:AI怎么区分两家同品类餐厅的口味差异?
传统方案用标签(辣/甜/咸),但标签无法表达程度和组合。“微辣"和"特辣"在标签体系里都是"辣”,对AI来说无法区分。我们用了口味向量空间。
3.1 口味维度定义
import numpy as np
# 定义5个口味维度,每道菜在每个维度上打0-1分
FLAVOR_DIMENSIONS = ["辣度", "甜度", "鲜度", "油腻度", "酸度"]
# 示例:几道菜的口味向量
dish_vectors = {
"牛油麻辣锅底": np.array([0.9, 0.1, 0.3, 0.8, 0.1]),
"番茄牛腩锅底": np.array([0.2, 0.6, 0.7, 0.4, 0.8]),
"菌汤锅底": np.array([0.0, 0.2, 0.9, 0.2, 0.1]),
"清汤锅底": np.array([0.0, 0.1, 0.5, 0.1, 0.0]),
"酸菜鱼锅底": np.array([0.5, 0.1, 0.6, 0.3, 0.9]),
}
def restaurant_flavor_profile(dish_vectors: dict, weights: dict = None) -> np.ndarray:
"""计算餐厅的口味画像(各菜品向量的加权平均)"""
if weights is None:
# 默认按销量加权(此处简化为等权)
weights = {name: 1.0 for name in dish_vectors}
total_weight = sum(weights[name] for name in dish_vectors)
profile = sum(
weights[name] * vec for name, vec in dish_vectors.items()
) / total_weight
return np.round(profile, 2)
profile = restaurant_flavor_profile(dish_vectors)
for dim, val in zip(FLAVOR_DIMENSIONS, profile):
print(f"{dim}: {val:.2f}")
输出:
辣度: 0.32
甜度: 0.22
鲜度: 0.60
油腻度: 0.36
酸度: 0.38
3.2 餐厅口味差异度计算
def flavor_distance(profile_a: np.ndarray, profile_b: np.ndarray) -> float:
"""两家餐厅的口味差异度(余弦距离)"""
dot = np.dot(profile_a, profile_b)
norm_a = np.linalg.norm(profile_a)
norm_b = np.linalg.norm(profile_b)
cosine_sim = dot / (norm_a * norm_b + 1e-8)
return 1 - cosine_sim # 余弦距离
# 对比:连锁火锅店A vs 竞品B
profile_a = np.array([0.72, 0.12, 0.35, 0.68, 0.08]) # A偏麻辣重油
profile_b = np.array([0.45, 0.35, 0.62, 0.40, 0.55]) # B偏鲜甜酸
dist = flavor_distance(profile_a, profile_b)
print(f"口味差异度: {dist:.3f} (0=完全相同, 1=完全不同)")
# 口味差异度: 0.427
# 这个值可以嵌入页面JSON-LD,让AI判断"这两家火锅店口味差异大,适合不同人群"
GEO价值:当用户问AI"有没有不那么辣的火锅"时,AI可以基于口味向量找到"鲜度>0.6且辣度<0.3"的门店,而不是只匹配"火锅"这个品类标签。
四、LBS索引:3公里内的AI可见性
餐饮GEO区别于其他行业的另一个维度是地理位置。用户搜索"附近有什么好吃的"时,AI需要同时匹配口味+距离。
4.1 地理编码与距离计算
from math import radians, cos, sin, asin, sqrt
def haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""两点间距离(公里),使用Haversine公式"""
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
dlat = lat2 - lat1
dlon = lon2 - lon1
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
return 2 * 6371 * asin(sqrt(a))
@dataclass
class StoreLocation:
name: str
lat: float
lon: float
flavor_profile: np.ndarray
def find_nearby_stores(user_lat: float, user_lon: float,
stores: list[StoreLocation],
radius_km: float = 3.0,
flavor_filter: dict = None) -> list[dict]:
"""查找3公里内匹配口味偏好的门店"""
results = []
for store in stores:
dist = haversine_km(user_lat, user_lon, store.lat, store.lon)
if dist > radius_km:
continue
# 口味过滤
if flavor_filter:
for dim_idx, (dim_name, threshold) in enumerate(flavor_filter.items()):
if store.flavor_profile[dim_idx] < threshold[0] or \
store.flavor_profile[dim_idx] > threshold[1]:
continue # 超出口味范围
results.append({
"store": store.name,
"distance_km": round(dist, 1),
"flavor_match": True,
})
return sorted(results, key=lambda x: x["distance_km"])
# 使用示例
stores = [
StoreLocation("城西银泰店", 30.2741, 120.0152, np.array([0.72, 0.12, 0.35, 0.68, 0.08])),
StoreLocation("西溪印象城店", 30.2654, 120.0589, np.array([0.45, 0.35, 0.62, 0.40, 0.55])),
StoreLocation("武林广场店", 30.2792, 120.1638, np.array([0.80, 0.10, 0.30, 0.75, 0.05])),
]
nearby = find_nearby_stores(
user_lat=30.2700, user_lon=120.0300,
stores=stores,
radius_km=5.0,
flavor_filter={"辣度": (0.0, 0.5)} # 只推荐不辣的店
)
for r in nearby:
print(f"{r['store']}: {r['distance_km']}km")
4.2 地理位置嵌入Schema.org
def generate_store_schema_with_lbs(store: StoreLocation, dishes: list[dict]) -> dict:
"""生成带地理编码的门店Schema.org标注"""
return {
"@context": "https://schema.org",
"@type": "Restaurant",
"name": store.name,
"geo": {
"@type": "GeoCoordinates",
"latitude": store.lat,
"longitude": store.lon
},
"hasMenu": {
"@type": "Menu",
"hasMenuSection": [
{
"@type": "MenuSection",
"name": dish.get("category", "热门菜品"),
"hasMenuItem": [
{
"@type": "MenuItem",
"name": dish["name"],
"description": dish.get("description", ""),
"offers": {
"@type": "Offer",
"price": str(dish["price"]),
"priceCurrency": "CNY"
}
}
]
}
for dish in dishes
]
}
}
五、GEO效果:改造前后对比
改造前后的数据变化(87家门店,3个月):
| 指标 | 改造前 | 改造后 | 变化 |
|---|---|---|---|
| AI引用门店数 | 12/87 | 63/87 | +425% |
| 大众点评"AI推荐"标签 | 3家 | 31家 | +933% |
| 用户问"附近XX火锅"时被推荐 | 8家 | 52家 | +550% |
| 菜品搜索命中率 | 22% | 78% | +255% |
菜品搜索命中率从22%到78%,核心改动就是多源对齐:不再依赖单一OCR,而是收银系统+点评+外卖三源交叉验证。
六、踩过的三个坑
坑1:收银系统的菜品名不等于展示名
收银系统里叫"麻辣锅底M",大众点评上叫"牛油麻辣锅底(中辣)"。对齐算法的相似度阈值要设到0.5以下才能匹配上,但0.5以下误匹配率飙升。
解决方案:加一层品类约束——只在同一个品类(锅底/荤菜/素菜)内做名称匹配,阈值就可以提到0.6。
坑2:口味向量没有标准答案
"微辣"在杭州和成都是完全不同的辣度。口味向量的值不应该是绝对值,而应该是城市基准的相对值。
# 城市辣度基准校准
CITY_SPICE_BASELINE = {
"成都": 0.7, # 成都人的"微辣"≈外地人的"中辣"
"杭州": 0.3, # 杭州人的"微辣"≈成都人的"不辣"
"北京": 0.4,
"广州": 0.2,
}
def calibrate_spice(raw_spice: float, city: str) -> float:
"""校准辣度:相对城市基准的偏离度"""
baseline = CITY_SPICE_BASELINE.get(city, 0.4)
return round(raw_spice / baseline, 2) # >1表示比当地平均辣
# "微辣"锅底在成都的校准辣度
calibrated = calibrate_spice(0.3, "成都")
print(f"校准后辣度: {calibrated}") # 0.43,低于当地平均水平
坑3:3公里半径在郊区不够用
市区门店密度高,3公里能覆盖10+家竞品。郊区门店密度低,3公里可能只有1-2家,AI推荐结果太稀疏。
解决方案:动态半径——根据门店密度自动调整搜索范围。
def dynamic_radius(store_count_in_3km: int) -> float:
"""根据周边门店密度动态调整搜索半径"""
if store_count_in_3km >= 8:
return 3.0 # 密集区域:3公里
elif store_count_in_3km >= 4:
return 5.0 # 中等密度:5公里
else:
return 8.0 # 稀疏区域:8公里
写在最后
餐饮GEO和其他行业最大的不同在于两点:菜单结构化极难(OCR不可靠),和地理位置强绑定(3公里决定生死)。
这套方案的思路不是"让OCR更准",而是承认OCR有上限,转而用多源数据对齐来弥补。口味向量空间解决了"AI分不清两家同品类店的差异"问题,LBS索引解决了"AI不知道哪家近"问题。两者叠加,才是餐饮GEO真正需要的技术基建。
代码全部基于Python标准库+numpy,无外部API依赖,可在本地运行。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)