菜单图片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依赖,可在本地运行。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐