"""
=============================================================================
第 13 讲 · 代码 2：阅读顺序与错误的种类 —— 每个字都认对了，分数也可能很低
=============================================================================

对应讲义：lectures/lecture13_churro.md 第 1、4 节

-----------------------------------------------------------------------------
【这个文件要解决什么问题？】
-----------------------------------------------------------------------------
CHURRO 把历史文献的转写定义成**整页**的任务：输入一页的图像，输出这一页的全部文字，**按正确的阅读顺序**。
这里用一张虚构的两栏报纸（每一行有坐标）和一张竖排的中文页面，演示：

    演示 1：阅读顺序 —— 按"从上到下、从左到右"排序会把两栏交错在一起；先分栏再排序才对
    演示 2：竖排、从右往左的页面 —— 按从左往右读，整页的顺序反了
    演示 3：同一页，六种错误各扣多少分 —— 看错字、漏掉小字、两栏交错、编造、重复循环
    演示 4：流水线（先检测文字框、再逐框识别、再排序）的问题 —— 每一步的错误叠加

-----------------------------------------------------------------------------
【运行方式】
-----------------------------------------------------------------------------
    python 02_reading_order_and_errors.py        # 约 3 秒
"""

import math
import random

from ocr_metrics import pad, similarity

# 一张虚构的 19 世纪两栏报纸：(x, y, 文字)。标题横跨两栏，左栏是本地新闻，右栏是航运消息，底部有一行小字的启事。
HEADER = [(60, 20, "THE RIVERTON GAZETTE. Saturday, June 4, 1853.")]
LEFT = ["LOCAL INTELLIGENCE. The new bridge over", "the river was opened on Tuesday last in",
        "the presence of a large assembly of the", "inhabitants, who were addressed by the",
        "Mayor. The structure is of stone, and", "is said to have cost upwards of four",
        "thousand pounds. A band of music played", "until the evening."]
RIGHT = ["SHIPPING NEWS. Arrived, the brig Mary", "Ann, from Lisbon, with a cargo of wine",
         "and oranges. Sailed, the schooner Hope,", "for Halifax, with timber and salt fish.",
         "The packet from Liverpool is expected", "daily, the winds having been contrary",
         "for nearly a fortnight. Insurance on", "the cargo is said to be heavy."]
FOOTER = [(60, 210, "Wanted, a steady lad to learn the printing trade. Apply at this office.")]
LINES = (HEADER + [(50, 40 + 20 * i, t) for i, t in enumerate(LEFT)]
         + [(330, 40 + 20 * i, t) for i, t in enumerate(RIGHT)] + FOOTER)
GOLD = " ".join([HEADER[0][2]] + LEFT + RIGHT + [FOOTER[0][2]])


def print_header(title):
    """打印一个带分隔线的标题。"""
    print()
    print("=" * 78)
    print(f"  {title}")
    print("=" * 78)


def row_major(lines):
    """
    最朴素的阅读顺序：按 y（从上到下）排序，y 相同再按 x（从左到右）。

    【边界】两栏的行高度对齐时，左右两栏的行会交替出现 —— 一行左栏、一行右栏。
    """
    return " ".join(t for x, y, t in sorted(lines, key=lambda l: (l[1], l[0])))


def column_aware(lines, gap=150):
    """
    先分栏再排序：横跨整页的行（标题、页脚）按位置单独处理；其余的行按 x 坐标分成几栏，
    栏从左到右，栏内从上到下。

    【思路】按 x 排序后，相邻两行的 x 相差超过 gap，就认为是新的一栏。
    【参数】gap：判断"新的一栏"的阈值（像素）
    【边界】真实的版面复杂得多（文章跨栏、插图、非矩形的文章边界）——这正是 CHURRO 论文说
        历史中文报纸最难的原因之一。
    """
    wide = [l for l in lines if len(l[2]) > 45]                  # 横跨两栏的长行（这里用长度粗略判断）
    body = [l for l in lines if l not in wide]
    xs = sorted({x for x, _, _ in body})
    cols, starts = [], []
    for x in xs:
        if not starts or x - starts[-1] > gap:
            starts.append(x)
            cols.append([])
        cols[-1].append(x)
    ordered = []
    for col in cols:
        ordered += sorted([l for l in body if l[0] in col], key=lambda l: l[1])
    top = [l for l in wide if l[1] < min(y for _, y, _ in body)]
    bottom = [l for l in wide if l not in top]
    return " ".join(t for _, _, t in top + ordered + bottom)


def demo_two_columns():
    """
    演示 1：两栏报纸的阅读顺序。

    【观察】按行排序时，**每一个字都是对的**，只是顺序错了 —— 相似度却大幅下降，读者也根本读不懂
        （"the river was opened ... Ann, from Lisbon, with a cargo of wine ..."）。
        CHURRO 论文的错误分析：零样本的 Qwen 2.5 VL（3B）有 42% 的输出有严重的阅读顺序错误，
        最常见的就是栏的顺序；微调之后的 CHURRO 降到 16%。
    """
    print_header("演示 1：两栏报纸的阅读顺序")
    naive, smart = row_major(LINES), column_aware(LINES)
    print(f"  按行排序（从上到下、从左到右）的前 160 个字符：\n    {naive[:160]}...")
    print(f"  相似度：按行排序 {similarity(naive, GOLD):.3f}    先分栏再排序 {similarity(smart, GOLD):.3f}")


# 一张虚构的竖排页面：每一"列"从上往下读，列从**右往左**排。下面按从右到左的顺序列出每一列。
VERTICAL = ["永和三年春正月大雪三日民多冻死", "二月河水溢坏田庐数百家", "夏四月旱祷于群祠乃雨",
            "秋八月蝗食禾稼", "冬十月筑城于北原"]


def demo_vertical():
    """
    演示 2：竖排的中文页面（虚构的编年体文字）。

    【观察】竖排页面要从最右边一列读起。如果模型按横排的习惯从左往右读列，整页的事件顺序就颠倒了：
        冬天的事在前，春天的事在后。论文发现，东亚文字的阅读顺序错误更常见，零样本的模型常常认不出
        "从上到下、从右到左"的书写方向；历史中文印刷品是所有语言里最难的（最好的单个模型也不到 10%）。
    """
    print_header("演示 2：竖排、从右往左的页面")
    gold = "".join(VERTICAL)
    wrong = "".join(reversed(VERTICAL))
    print(f"  正确的顺序（从右往左读列）：{gold}")
    print(f"  按从左往右读列：            {wrong}")
    print(f"  相似度：{similarity(wrong, gold):.3f}（每个字都认对了）")


CONFUSIONS = {"e": "c", "c": "e", "n": "u", "u": "n", "l": "1", "o": "ø", "h": "b", "a": "o"}


def char_errors(text, rate, rng):
    """把 CONFUSIONS 里的（易混的）字母，各以 rate 的概率换成形状相近的字母（例如 o → ø、u → n）。"""
    return "".join(CONFUSIONS[ch] if ch in CONFUSIONS and rng.random() < rate else ch for ch in text)


def demo_error_types():
    """
    演示 3：同一页，六种错误各扣多少分。

    【观察】
        · 形状相近的字母（e/c、u/n、o/ø……）有 5% 看错，约占全部字符的 2%，分数只扣两个点 —— 这种错误常出现在人名里（论文的例子：
          把挪威信件里的 Suso 读成 Süss）；
        · 漏掉底部的小字启事：扣分不多，但内容丢了（历史文献里的旁注、页边批注常常被漏掉）；
        · 两栏交错：每个字都对，分数掉得很多；
        · **编造**（幻觉）：模型认不出字，就写一段"看起来合理"的文字。论文里零样本的 Qwen 2.5 VL（3B）
          有 36% 的输出有严重的幻觉（例如认出这是一封 18 世纪的荷兰文信，就写"这是一封信"），
          微调后的 CHURRO 在同样的 50 个样例里没有；
        · **重复循环**（退化）：写到一半开始不停地重复同一句话，输出比原文长得多。
    """
    print_header("演示 3：同一页，六种错误各扣多少分")
    rng = random.Random(0)
    half = GOLD[:len(GOLD) // 2]
    fake = ("THE RIVERTON GAZETTE. Saturday, June 4, 1853. The weather this week has been fine and the "
            "harvest is expected to be abundant. The market was well attended and prices were steady. "
            "Several new houses are being built near the church, and the school will reopen in September.")
    variants = [
        ("完全正确", GOLD),
        ("易混字母的 5% 看错（约 2% 的字符）", char_errors(GOLD, 0.05, rng)),
        ("漏掉底部的小字启事", GOLD[:GOLD.index("Wanted")].strip()),
        ("两栏交错（阅读顺序错）", row_major(LINES)),
        ("编造了一篇看似合理的文章", fake),
        ("写到一半开始重复循环", half + " the river was opened on Tuesday last in" * 15),
    ]
    print(f"  这一页的标准转写有 {len(GOLD)} 个字符\n")
    for label, text in variants:
        print(f"  {pad(label, 36)}输出 {len(text):5d} 个字符   相似度 {similarity(text, GOLD):.3f}")


def demo_pipeline():
    """
    演示 4：流水线式的 OCR（先检测文字框，再逐框识别，再排序）vs 整页一次转写。

    【观察】流水线的每一步都会出错，错误会叠加：漏检了一个框，那一行就没了；排序错了，两栏就交错了。
        论文里，Azure OCR（流水线）在印刷品上平均 71.9%，有好几个 VLM 超过了它；而把 Azure 的框和顺序
        交给 Gemini 2.5 Pro 逐框识别的"混合"系统只有 52.6%，在每种语言上都不如它的两个组成部分之一。
        这里模拟三种流水线（每个框被漏检的概率 3%，每个字母识别错的概率 2%），只是排序方法不同。
    """
    print_header("演示 4：流水线的错误叠加（每种设置模拟 20 页，取平均）")
    rows = [("流水线：漏检 3% + 识别错 2% + 按行排序", True, "row"),
            ("流水线：漏检 3% + 识别错 2% + 先分栏再排序", True, "col"),
            ("流水线：识别错 2% + 先分栏再排序（不漏检）", False, "col")]
    for label, misses, order in rows:
        sims = []
        for k in range(20):
            rng = random.Random(f"pipe|{k}")
            kept = [(x, y, char_errors(t, 0.02, rng)) for x, y, t in LINES if not (misses and rng.random() < 0.03)]
            text = row_major(kept) if order == "row" else column_aware(kept)
            sims.append(similarity(text, GOLD))
        print(f"  {pad(label, 46)}平均相似度 {math.fsum(sims) / len(sims):.3f}")


def main():
    demo_two_columns()
    demo_vertical()
    demo_error_types()
    demo_pipeline()


if __name__ == "__main__":
    main()
