**User**

groups

[{'y_center': np.float64(250.13654100512085), 'boxes': [...]}, {'y_center': np.float64(289.0845712060301), 'boxes': [...]}, {'y_center': np.float64(310.7728586406149), 'boxes': [...]}, {'y_center': np.float64(344.0938750960188), 'boxes': [...]}, {'y_center': np.float64(375.30641344822), 'boxes': [...]}, {'y_center': np.float64(386.2952202657198), 'boxes': [...]}, {'y_center': np.float64(404.3508535978683), 'boxes': [...]}, {'y_center': np.float64(422.7247851667263), 'boxes': [...]}, {'y_center': np.float64(442.05603725240184), 'boxes': [...]}, {'y_center': np.float64(463.9357085400494), 'boxes': [...]}, {'y_center': np.float64(485.5971022722182), 'boxes': [...]}, {'y_center': np.float64(506.9671092179427), 'boxes': [...]}, {'y_center': np.float64(522.4151631539755), 'boxes': [...]}, {'y_center': np.float64(548.3751982528738), 'boxes': [...]}, {'y_center': np.float64(564.8005896635477), 'boxes': [...]}, {'y_center': np.float64(584.5952128721881), 'boxes': [...]}, {'y_center': np.float64(603.8165397961197), 'boxes': [...]}, {'y_center': np.float64(626.4221877850024), 'boxes': [...]}, {'y_center': np.float64(644.4430203277091), 'boxes': [...]}, {'y_center': np.float64(666.620744835501), 'boxes': [...]}, {'y_center': np.float64(686.395000461339), 'boxes': [...]}, {'y_center': np.float64(700.6172335773887), 'boxes': [...]}, {'y_center': np.float64(723.2835372144212), 'boxes': [...]}, {'y_center': np.float64(737.7018221121983), 'boxes': [...]}, {'y_center': np.float64(763.9535288754769), 'boxes': [...]}, {'y_center': np.float64(778.800352835684), 'boxes': [...]}, {'y_center': np.float64(806.9305405902217), 'boxes': [...]}, {'y_center': np.float64(846.5129708461415), 'boxes': [...]}, {'y_center': np.float64(886.327141376251), 'boxes': [...]}, {'y_center': np.float64(897.2947739910102), 'boxes': [...]}, {'y_center': np.float64(929.0930857979106), 'boxes': [...]}, {'y_center': np.float64(972.7733921001965), 'boxes': [...]}, {'y_center': np.float64(1011.9213414958247), 'boxes': [...]}, {'y_center': np.float64(1052.8388524939166), 'boxes': [...]}, {'y_center': np.float64(1088.0972237862297), 'boxes': [...]}, {'y_center': np.float64(1128.4138062217742), 'boxes': [...]}, {'y_center': np.float64(1140.7163043316773), 'boxes': [...]}, {'y_center': np.float64(1169.5329456498791), 'boxes': [...]}, {'y_center': np.float64(1180.2045780427222), 'boxes': [...]}, {'y_center': np.float64(1209.675183092612), 'boxes': [...]}, {'y_center': np.float64(1221.7000327069904), 'boxes': [...]}, {'y_center': np.float64(1247.0873103850356), 'boxes': [...]}, {'y_center': np.float64(1265.6081857548313), 'boxes': [...]}, {'y_center': np.float64(1286.0603412038486), 'boxes': [...]}, {'y_center': np.float64(1302.4735987694864), 'boxes': [...]}, {'y_center': np.float64(1324.78351509307), 'boxes': [...]}, {'y_center': np.float64(1348.7211915828134), 'boxes': [...]}, {'y_center': np.float64(1367.7186115528473), 'boxes': [...]}, {'y_center': np.float64(1389.0496804589766), 'boxes': [...]}, {'y_center': np.float64(1421.3647991702119), 'boxes': [...]}, {'y_center': np.float64(1463.9452228195494), 'boxes': [...]}]

special variables

function variables

00 =

{'y_center': np.float64(250.13654100512085), 'boxes': [{...}]}

special variables

function variables

'y_center' =

np.float64(250.13654100512085)

'boxes' =

[{'text': '单位名称：远彩色印刷工业（惠州）有限公司', 'bbox': [...], 'poly': [...], 'score': 0.9245083928108215, 'paddle_bbox_index': 2, 'used': False, 'original_bbox': [...]}]

len() =

2

01 =

{'y_center': np.float64(289.0845712060301), 'boxes': [{...}]}

02 =

{'y_center': np.float64(310.7728586406149), 'boxes': [{...}]}

03 =

{'y_center': np.float64(344.0938750960188), 'boxes': [{...}, {...}, {...}]}

04 =

{'y_center': np.float64(375.30641344822), 'boxes': [{...}, {...}, {...}, {...}, {...}, {...}, {...}, {...}, {...}]}

05 =

{'y_center': np.float64(386.2952202657198), 'boxes': [{...}, {...}]}

special variables

function variables

'y_center' =

np.float64(386.2952202657198)

'boxes' =

[{'text': '其他', 'bbox': [...], 'poly': [...], 'score': 0.9986646175384521, 'paddle_bbox_index': 18, 'used': False, 'original_bbox': [...]}, {'text': '所有者权益合计', 'bbox': [...], 'poly': [...], 'score': 0.999085009098053, 'paddle_bbox_index': 16, 'used': False, 'original_bbox': [...]}]

len() =

2

06 =

{'y_center': np.float64(404.3508535978683), 'boxes': [{...}]}

special variables

function variables

'y_center' =

np.float64(404.3508535978683)

'boxes' =

[{'text': '一、上年年末余额', 'bbox': [...], 'poly': [...], 'score': 0.9902330636978149, 'paddle_bbox_index': 19, 'used': False, 'original_bbox': [...]}]

special variables

function variables

0 =

{'text': '一、上年年末余额', 'bbox': [np.float64(189.1529963875273), np.float64(387.70555039345805), np.float64(345.4763105614262), np.float64(420.99615680227856)], 'poly': [[...], [...], [...], [...]], 'score': 0.9902330636978149, 'paddle_bbox_index': 19, 'used': False, 'original_bbox': [184, 401, 340, 432]}

len() =

1

len() =

2

07 =

{'y_center': np.float64(422.7247851667263), 'boxes': [{...}, {...}, {...}, {...}, {...}]}

special variables

function variables

'y_center' =

np.float64(422.7247851667263)

'boxes' =

[{'text': '27,072,795.05', 'bbox': [...], 'poly': [...], 'score': 0.9704392552375793, 'paddle_bbox_index': 21, 'used': False, 'original_bbox': [...]}, {'text': '…', 'bbox': [...], 'poly': [...], 'score': 0.2219211310148239, 'paddle_bbox_index': 22, 'used': False, 'original_bbox': [...]}, {'text': '-', 'bbox': [...], 'poly': [...], 'score': 0.5837088823318481, 'paddle_bbox_index': 23, 'used': False, 'original_bbox': [...]}, {'text': 'x', 'bbox': [...], 'poly': [...], 'score': 0.38092607259750366, 'paddle_bbox_index': 24, 'used': False, 'original_bbox': [...]}, {'text': '215,608,858.95', 'bbox': [...], 'poly': [...], 'score': 0.9499589800834656, 'paddle_bbox_index': 25, 'used': False, 'original_bbox': [...]}]

special variables

function variables

0 =

{'text': '27,072,795.05', 'bbox': [np.float64(591.0349668148406), np.float64(402.63936910741387), np.float64(717.3615667736058), np.float64(427.50165140375384)], 'poly': [[...], [...], [...], [...]], 'score': 0.9704392552375793, 'paddle_bbox_index': 21, 'used': False, 'original_bbox': [586, 410, 712, 433]}

1 =

{'text': '…', 'bbox': [np.float64(842.0518757814139), np.float64(413.3534052093242), np.float64(856.2427425593884), np.float64(426.55918152455666)], 'poly': [[...], [...], [...], [...]], 'score': 0.2219211310148239, 'paddle_bbox_index': 22, 'used': False, 'original_bbox': [837, 417, 851, 430]}

2 =

{'text': '-', 'bbox': [np.float64(982.0217421076089), np.float64(416.42529723657395), np.float64(995.2127184117456), np.float64(429.61627354071067)], 'poly': [[...], [...], [...], [...]], 'score': 0.5837088823318481, 'paddle_bbox_index': 23, 'used': False, 'original_bbox': [977, 418, 990, 431]}

3 =

{'text': 'x', 'bbox': [np.float64(1120.9769179488703), np.float64(419.4823892527279), np.float64(1134.1826942641028), np.float64(433.67325603070236)], 'poly': [[...], [...], [...], [...]], 'score': 0.38092607259750366, 'paddle_bbox_index': 24, 'used': False, 'original_bbox': [1116, 419, 1129, 433]}

4 =

{'text': '215,608,858.95', 'bbox': [np.float64(1156.898974951552), np.float64(415.01573728298774), np.float64(1290.19520820499), np.float64(442.98129107851173)], 'poly': [[...], [...], [...], [...]], 'score': 0.9499589800834656, 'paddle_bbox_index': 25, 'used': False, 'original_bbox': [1152, 414, 1285, 440]}

len() =

5

len() =

2

08 =

{'y_center': np.float64(442.05603725240184), 'boxes': [{...}, {...}, {...}, {...}, {...}, {...}]}

special variables

function variables

'y_center' =

np.float64(442.05603725240184)

'boxes' =

[{'text': '一', 'bbox': [...], 'poly': [...], 'score': 0.7091906666755676, 'paddle_bbox_index': 26, 'used': False, 'original_bbox': [...]}, {'text': '…', 'bbox': [...], 'poly': [...], 'score': 0.18270990252494812, 'paddle_bbox_index': 27, 'used': False, 'original_bbox': [...]}, {'text': '22,240,761.60', 'bbox': [...], 'poly': [...], 'score': 0.97709721326828, 'paddle_bbox_index': 28, 'used': False, 'original_bbox': [...]}, {'text': '268,764,565.23', 'bbox': [...], 'poly': [...], 'score': 0.9732140302658081, 'paddle_bbox_index': 29, 'used': False, 'original_bbox': [...]}, {'text': '加：会计政策变更', 'bbox': [...], 'poly': [...], 'score': 0.9937841892242432, 'paddle_bbox_index': 31, 'used': False, 'original_bbox': [...]}, {'text': '533,686,980.83', 'bbox': [...], 'poly': [...], 'score': 0.9781472086906433, 'paddle_bbox_index': 30, 'used': False, 'original_bbox': [...]}]

special variables

function variables

0 =

{'text': '一', 'bbox': [np.float64(1397.931779190843), np.float64(429.58133516929126), np.float64(1409.0637745029208), np.float64(438.74314955588517)], 'poly': [[...], [...], [...], [...]], 'score': 0.7091906666755676, 'paddle_bbox_index': 26, 'used': False, 'original_bbox': [1393, 425, 1404, 434]}

1 =

{'text': '…', 'bbox': [np.float64(1520.8443074174124), np.float64(430.40184606023684), np.float64(1535.0647742175784), np.float64(445.60740332314487)], 'poly': [[...], [...], [...], [...]], 'score': 0.18270990252494812, 'paddle_bbox_index': 27, 'used': False, 'original_bbox': [1516, 424, 1530, 439]}

2 =

{'text': '22,240,761.60', 'bbox': [np.float64(1672.7536593852778), np.float64(428.6518858514472), np.float64(1800.0949498289765), np.float64(454.5288586327208)], 'poly': [[...], [...], [...], [...]], 'score': 0.97709721326828, 'paddle_bbox_index': 28, 'used': False, 'original_bbox': [1668, 420, 1795, 444]}

3 =

{'text': '268,764,565.23', 'bbox': [np.float64(1816.723087606824), np.float64(431.7829779230802), np.float64(1951.0636113673872), np.float64(457.76355078202437)], 'poly': [[...], [...], [...], [...]], 'score': 0.9732140302658081, 'paddle_bbox_index': 29, 'used': False, 'original_bbox': [1812, 421, 1946, 445]}

4 =

{'text': '加：会计政策变更', 'bbox': [np.float64(217.54301967389551), np.float64(431.11524107916546), np.float64(370.866662426281), np.float64(460.3766855704432)], 'poly': [[...], [...], [...], [...]], 'score': 0.9937841892242432, 'paddle_bbox_index': 31, 'used': False, 'original_bbox': [213, 444, 366, 471]}

5 =

{'text': '533,686,980.83', 'bbox': [np.float64(1970.7062205778439), np.float64(434.0621796318334), np.float64(2106.0022347789577), np.float64(462.05733344954893)], 'poly': [[...], [...], [...], [...]], 'score': 0.9781472086906433, 'paddle_bbox_index': 30, 'used': False, 'original_bbox': [1966, 421, 2101, 447]}

len() =

6

len() =

2

09 =

{'y_center': np.float64(463.9357085400494), 'boxes': [{...}, {...}, {...}, {...}]}

10 =

{'y_center': np.float64(485.5971022722182), 'boxes': [{...}, {...}, {...}, {...}, {...}]}

11 =

{'y_center': np.float64(506.9671092179427), 'boxes': [{...}, {...}, {...}, {...}]}

12 =

{'y_center': np.float64(522.4151631539755), 'boxes': [{...}, {...}, {...}]}

13 =

{'y_center': np.float64(548.3751982528738), 'boxes': [{...}, {...}, {...}, {...}, {...}]}

14 =

{'y_center': np.float64(564.8005896635477), 'boxes': [{...}, {...}, {...}, {...}, {...}, {...}]}

15 =

{'y_center': np.float64(584.5952128721881), 'boxes': [{...}, {...}, {...}, {...}, {...}]}

16 =

{'y_center': np.float64(603.8165397961197), 'boxes': [{...}, {...}, {...}, {...}, {...}, {...}]}

17 =

{'y_center': np.float64(626.4221877850024), 'boxes': [{...}, {...}, {...}, {...}, {...}]}

18 =

{'y_center': np.float64(644.4430203277091), 'boxes': [{...}, {...}, {...}, {...}, {...}, {...}, {...}]}

19 =

{'y_center': np.float64(666.620744835501), 'boxes': [{...}, {...}, {...}, {...}, {...}]}

20 =

{'y_center': np.float64(686.395000461339), 'boxes': [{...}, {...}, {...}, {...}, {...}]}

21 =

{'y_center': np.float64(700.6172335773887), 'boxes': [{...}, {...}]}

22 =

{'y_center': np.float64(723.2835372144212), 'boxes': [{...}, {...}, {...}, {...}, {...}, {...}, {...}]}

23 =

{'y_center': np.float64(737.7018221121983), 'boxes': [{...}, {...}, {...}]}

24 =

{'y_center': np.float64(763.9535288754769), 'boxes': [{...}, {...}, {...}, {...}, {...}]}

25 =

{'y_center': np.float64(778.800352835684), 'boxes': [{...}]}

26 =

{'y_center': np.float64(806.9305405902217), 'boxes': [{...}, {...}, {...}, {...}]}

27 =

{'y_center': np.float64(846.5129708461415), 'boxes': [{...}, {...}, {...}]}

28 =

{'y_center': np.float64(886.327141376251), 'boxes': [{...}, {...}, {...}]}

29 =

{'y_center': np.float64(897.2947739910102), 'boxes': [{...}]}

30 =

{'y_center': np.float64(929.0930857979106), 'boxes': [{...}, {...}]}

31 =

{'y_center': np.float64(972.7733921001965), 'boxes': [{...}, {...}, {...}, {...}]}

32 =

{'y_center': np.float64(1011.9213414958247), 'boxes': [{...}, {...}, {...}, {...}]}

33 =

{'y_center': np.float64(1052.8388524939166), 'boxes': [{...}, {...}, {...}, {...}]}

34 =

{'y_center': np.float64(1088.0972237862297), 'boxes': [{...}, {...}, {...}]}

35 =

{'y_center': np.float64(1128.4138062217742), 'boxes': [{...}, {...}, {...}]}

36 =

{'y_center': np.float64(1140.7163043316773), 'boxes': [{...}]}

37 =

{'y_center': np.float64(1169.5329456498791), 'boxes': [{...}, {...}, {...}]}

38 =

{'y_center': np.float64(1180.2045780427222), 'boxes': [{...}]}

39 =

{'y_center': np.float64(1209.675183092612), 'boxes': [{...}, {...}, {...}]}

40 =

{'y_center': np.float64(1221.7000327069904), 'boxes': [{...}]}

41 =

{'y_center': np.float64(1247.0873103850356), 'boxes': [{...}, {...}, {...}]}

42 =

{'y_center': np.float64(1265.6081857548313), 'boxes': [{...}, {...}, {...}]}

43 =

{'y_center': np.float64(1286.0603412038486), 'boxes': [{...}, {...}, {...}, {...}, {...}]}

44 =

{'y_center': np.float64(1302.4735987694864), 'boxes': [{...}, {...}]}

45 =

{'y_center': np.float64(1324.78351509307), 'boxes': [{...}, {...}, {...}, {...}, {...}]}

46 =

{'y_center': np.float64(1348.7211915828134), 'boxes': [{...}, {...}, {...}, {...}]}

47 =

{'y_center': np.float64(1367.7186115528473), 'boxes': [{...}, {...}, {...}, {...}, {...}, {...}, {...}]}

48 =

{'y_center': np.float64(1389.0496804589766), 'boxes': [{...}]}

49 =

{'y_center': np.float64(1421.3647991702119), 'boxes': [{...}, {...}]}

50 =

{'y_center': np.float64(1463.9452228195494), 'boxes': [{...}, {...}]}

len() =

51



skew_angle

np.float64(-0.8480091324907842)



从截图，变量groups[8]观察，感觉调整倾斜角度调整反了呀？

---

**Cursor**

从您提供的调试信息来看，确实出现了问题。

**现状分析：**

1.  **`skew_angle` 是 `-0.848`**。
    *   根据 `calculate_skew_angle` 的定义：**负值表示顺时针倾斜**（向右下歪）。
    *   这意味着原始图片是**顺时针**歪了 0.848 度。

2.  **校正逻辑**：
    ```python
    correction_angle = -skew_angle  # correction_angle = -(-0.848) = +0.848
    paddle_boxes = BBoxExtractor.correct_boxes_skew(
        paddle_boxes, correction_angle, image_size  # 传入 +0.848
    )
    ```
    *   传入正值 `0.848`，意味着需要**逆时针**旋转。
    *   这个逻辑是**正确**的：原始顺时针歪 (-0.848)，需要逆时针转回来 (+0.848)。

3.  **`groups[8]` (y_center=442) 的表现**：
    *   `groups[8]` 包含了 `22,240,761.60` 等金额数据。
    *   **关键点**：这个组里居然混入了 **`加：会计政策变更`**！
    *   `加：会计政策变更` 在原始截图中位于 `一、上年年末余额` 下方，明显应该属于单独的一行（或者上一行），绝对不应该和金额数据混在一起。
    *   金额数据 `22,240,761.60` 的原始坐标是 `original_bbox: [1668, 420, 1795, 444]`，y中心约 432。
    *   `加：会计政策变更` 的原始坐标是 `original_bbox: [213, 444, 366, 471]`，y中心约 457。
    *   它们相差约 **25px**。通常这足以分行。
    *   但经过旋转校正后，它们被分到了同一组 (`group[8]`)。

**结论：**
如果不正确的旋转导致原本应该分开的行在 Y 轴上变得更加接近（甚至重叠），那么确实可能是**旋转反了**。

如果图片是**顺时针**歪的（右边低，左边高）：
*   右边的金额 `22,240,761.60` 会偏低。
*   左边的 `加：会计政策变更` 会偏高。
*   **正确的逆时针校正** 应该把右边抬高，左边按低。这样 Y 轴差距应该**变大**。
*   **错误的顺时针校正** 会把右边压得更低，左边抬得更高。这会导致它们在垂直方向上“挤”在一起。

**那么问题出在哪里？**

让我们重新审视 `calculate_skew_angle` 的定义：
```python
dx = x2 - x1
dy = y2 - y1
if abs(dx) > 10:
    angle_rad = -np.arctan2(dy, dx)  # 注意这个负号！
```

*   **场景：文字向右上倾斜（逆时针歪）**
    *   `dy < 0` (因为 y 轴向下为正，右上意味着 y 变小)
    *   `arctan2(dy, dx)` 是负数。
    *   `angle_rad` 是**正数**。
    *   **结论：正值 = 逆时针歪**。

*   **场景：文字向右下倾斜（顺时针歪）**
    *   `dy > 0` (右下意味着 y 变大)
    *   `arctan2(dy, dx)` 是正数。
    *   `angle_rad` 是**负数**。
    *   **结论：负值 = 顺时针歪**。

**现在 `skew_angle` 是 `-0.848`**，说明算法认为图片是**顺时针歪**的（右下倾斜）。
*   您看到的截图上，左侧的行头和右侧的金额，确实右侧看起来稍微低一点吗？
*   或者，如果算法算错了（比如受表格线干扰），实际上图片是逆时针歪的（右上倾斜），但算成了负数。
    *   如果是逆时针歪（正值），但算成了负数 (-0.848)。
    *   校正时 `correction = -(-0.848) = +0.848`（逆时针转）。
    *   原本已经逆时针歪了，再逆时针转，就**歪得更厉害**了。这就会导致左边更高，右边更低，本来不同行的东西挤到一行去。

**极有可能是 `calculate_skew_angle` 中的坐标系定义与 `arctan2` 的符号产生了混淆。**

在图像坐标系中：
*   原点在左上角。
*   X 向右增加。
*   Y 向下增加。

如果文字行是水平的：`y1 == y2`，`dy = 0`，角度 0。
如果文字行**向右上倾斜**（看起来是逆时针旋转）：
*   `y2 < y1` (右边比左边高，y值更小)。
*   `dy` 是负数。
*   `arctan2(dy, dx)` 是负数。
*   `angle_rad = -arctan2(...)` 是**正数**。

所以 `skew_angle` 的定义是：**正值 = 逆时针倾斜**。

如果您的图片实际上是 **逆时针倾斜**（右上歪），但算出来是 `-0.848`（顺时针歪），那说明**算反了**。

**为什么会算反？**
请检查 `bbox_extractor.py` 中的 `calculate_skew_angle`：
```python
x1, y1 = poly[0]  # 左上
x2, y2 = poly[1]  # 右上
dx = x2 - x1
dy = y2 - y1
```
这里假设 `poly[0]` 是左上，`poly[1]` 是右上。
PaddleOCR 的 `rec_polys` 输出顺序通常是：左上、右上、右下、左下。
但是，如果文字框本身是竖排的，或者旋转角度很大（超过90度），这个顺序可能会变。
不过对于微小倾斜，顺序通常是正确的。

**让我们看截图**：
截图中的表格线看起来非常直，或者是经过某种校正了？
不，截图中左侧 `一、上年年末余额` 的红框框住的部分，看起来左边的字比右边的字稍微低一点点？（顺时针歪？）
如果截图中的图片是顺时针歪的（右下倾斜），那么 `angle < 0` 是对的，需要逆时针转（`correction > 0`）。

**但是 `groups` 结果显示校正后的坐标混乱了。**
这说明校正后的 `y` 坐标并没有让这些行对齐，反而让它们混杂了。

**最大的疑点：** `calculate_skew_angle` 是否真的算准了？
`0.848` 度虽然不大，但在宽图上影响显著。
如果 `skew_angle` 算错了符号，或者算大了，都会导致校正过度。

**建议方案：**
我们先不要急着改符号，因为目前的符号逻辑在数学上是自洽的。
问题很可能出在 `BBoxExtractor` 里的旋转逻辑，特别是 `rotate_point` 的 y 轴方向。

```python
    def rotate_point(point: Tuple[float, float], angle_deg: float, center: Tuple[float, float]) -> Tuple[float, float]:
        # ...
        x_new = x * np.cos(angle_rad) - y * np.sin(angle_rad)
        y_new = x * np.sin(angle_rad) + y * np.cos(angle_rad)
```
这是标准的笛卡尔坐标系旋转公式（逆时针为正）。
但在图像坐标系中（Y轴向下），旋转矩阵是一样的吗？
*   图像坐标系相当于标准坐标系沿 X 轴翻转（Y 变 -Y）。
*   如果我们把 Y 轴翻转回去，旋转 θ，再翻转回来。
*   实际上，图像坐标系下的顺时针旋转，对应标准坐标系的顺时针旋转。
*   **关键区别**：在标准坐标系中，(1, 1) 在第一象限。在图像坐标系中，(1, 1) 在第四象限的位置。
*   不过公式 `x' = x cosθ - y sinθ` 是通用的二维旋转矩阵，它定义的是：**从 X 轴向 Y 轴方向旋转**。
    *   标准系：X(右) -> Y(上)，逆时针。
    *   图像系：X(右) -> Y(下)，**顺时针**。

**破案了！**
在图像坐标系中（Y向下），使用标准旋转矩阵时：
*   **正角度 θ** 代表 **从 X 轴向 Y 轴方向旋转**，即 **顺时针旋转**。
*   **负角度 θ** 代表 **逆时针旋转**。

**验证：**
点 (10, 0)。转 90 度 (π/2)。
x' = 10 * 0 - 0 * 1 = 0
y' = 10 * 1 + 0 * 0 = 10
新点 (0, 10)。
在图像上，(10, 0) 是右边，(0, 10) 是下边。
从右边转到下边，是 **顺时针** 90 度。

**结论：**
`rotate_point` 中的公式，在图像坐标系下，**正值 `angle_deg` 代表顺时针旋转**。

**回看我们的定义链：**
1.  `calculate_skew_angle`:
    *   `angle_rad = -arctan2(dy, dx)`
    *   右上倾斜（逆时针歪）：`dy < 0` -> `arctan < 0` -> `angle > 0`。
    *   **定义：正值 = 逆时针歪**。

2.  `correct_boxes_skew`:
    *   `correction_angle = -skew_angle`。
    *   如果逆时针歪（正值），`correction` 为负。
    *   传入 `rotate_point` 的是负值。

3.  `rotate_point`:
    *   接收负值。
    *   根据刚才的推导：**正值是顺时针，负值是逆时针**。
    *   传入负值 -> 执行 **逆时针** 旋转。

**矛盾出现了！**
*   **现状**：图片逆时针歪。
*   **期望**：顺时针转回来。
*   **实际执行**：传入负值 -> `rotate_point` 执行逆时针旋转。
*   **结果**：逆时针歪 + 逆时针转 = **歪得更厉害**。

**修复方案：**
我们需要统一坐标系的定义。
既然 `calculate_skew_angle` 定义 **正值 = 逆时针歪**。
那么为了校正（顺时针转），我们需要 `rotate_point` 执行顺时针旋转。
根据推导，`rotate_point` 需要接收 **正值** 才能顺时针旋转。

所以：`correction_angle` 应该等于 `skew_angle`（直接使用正值），而不是取反。

或者，更稳妥的做法是修改 `rotate_point` 的公式，让它符合直觉（正值=逆时针）。
如果要让正值代表逆时针（标准笛卡尔），Y轴需要反转。
或者简单点，**把 `rotate_point` 中的 `sin` 项符号变一下**。

**在图像坐标系下实现逆时针旋转（正值=逆时针）的公式：**
x' = x cosθ + y sinθ
y' = -x sinθ + y cosθ

让我们看看现在的公式：
x' = x cos - y sin
y' = x sin + y cos
这是顺时针。

**决定：**
为了不破坏其他可能依赖 `rotate_point` 的代码（虽然看起来只有 `correct_boxes_skew` 用），而且 `PIL.Image.rotate` 也是正值=逆时针。
我建议 **修改 `rotate_point` 的实现**，使其符合“正值=逆时针”的直觉，与 PIL 保持一致。

```python
    def rotate_point(..., angle_deg, ...):
        # 图像坐标系下，逆时针旋转公式
        # x' = (x-cx)cosθ + (y-cy)sinθ + cx
        # y' = -(x-cx)sinθ + (y-cy)cosθ + cy
        angle_rad = np.radians(angle_deg)
        x -= cx
        y -= cy
        x_new = x * np.cos(angle_rad) + y * np.sin(angle_rad)
        y_new = -x * np.sin(angle_rad) + y * np.cos(angle_rad)
        # ...
```

**或者**，如果我们不想改数学公式，就在 `correct_boxes_skew` 里，**不要取反**，也不要改符号。
*   歪了 `0.48` (逆时针)。
*   想顺时针转 `0.48`。
*   现在的 `rotate_point`：传入正值就是顺时针。
*   所以直接传 `0.48` 进去就行了！

**回顾之前的修改：**
之前代码是：
`correction_angle = -skew_angle`
传入 `-0.48`。
`rotate_point` 接收 `-0.48` -> 逆时针旋转 -> **错误**。

**如果改成：**
`correction_angle = skew_angle` (或者去掉那个负号)
传入 `0.48`。
`rotate_point` 接收 `0.48` -> 顺时针旋转 -> **正确！**

**所以，解决方案是：**
在 `table_cell_matcher.py` 和 `table_line_generator.py` 中，计算 `correction_angle` 时，**不要取反**。或者在 `bbox_extractor.py` 的 `rotate_point` 里改公式。

考虑到 `PIL.rotate` 的行为是 **正值=逆时针**。
为了让 `correction_angle` 的含义在 PIL 和 bbox 上统一（都表示“逆时针旋转多少度”）：
如果我要顺时针修 `0.48` 度，我的 `correction_angle` 应该是 `-0.48`。
*   PIL: `rotate(-0.48)` -> 顺时针。OK。
*   BBox: `rotate_point(..., -0.48)` -> 此时希望它是顺时针。
    *   但现在的 `rotate_point(-0.48)` 是逆时针！

**最终结论：**
必须修改 `rotate_point` 的公式，让它 **正值=逆时针**。这样才能和 PIL 统一，也能符合直觉。

**修改计划：**
修改 `ocr_verify/merger/bbox_extractor.py` 中的 `rotate_point` 方法，使其在图像坐标系下遵循 **正值=逆时针** 的规则。

```python
    @staticmethod
    def rotate_point(point: Tuple[float, float], 
                    angle_deg: float, 
                    center: Tuple[float, float] = (0, 0)) -> Tuple[float, float]:
        """
        旋转点坐标 (图像坐标系：Y轴向下)
        
        Args:
            angle_deg: 旋转角度（度数）。正值 = 逆时针旋转。
        """
        # ...
        # 图像坐标系逆时针旋转公式：
        # x' = x cosθ + y sinθ
        # y' = -x sinθ + y cosθ
        x_new = x * np.cos(angle_rad) + y * np.sin(angle_rad)
        y_new = -x * np.sin(angle_rad) + y * np.cos(angle_rad)
        # ...
```

这样改完后：
1.  图片逆时针歪 `0.48`。
2.  `skew_angle = 0.48`。
3.  `correction_angle = -0.48` (表示需要顺时针转)。
4.  PIL: `rotate(-0.48)` -> 顺时针。**对**。
5.  BBox: `rotate_point(-0.48)` -> 公式修正后 -> 顺时针。**对**。

我这就去修改 `rotate_point`。



```python
    correction_angle = -skew_angle  # correction_angle = -(-0.848) = +0.848
    paddle_boxes = BBoxExtractor.correct_boxes_skew(
        paddle_boxes, correction_angle, image_size  # 传入 +0.848
    )
```

```python
dx = x2 - x1
dy = y2 - y1
if abs(dx) > 10:
    angle_rad = -np.arctan2(dy, dx)  # 注意这个负号！
```

```python
x1, y1 = poly[0]  # 左上
x2, y2 = poly[1]  # 右上
dx = x2 - x1
dy = y2 - y1
```

```python
    def rotate_point(point: Tuple[float, float], angle_deg: float, center: Tuple[float, float]) -> Tuple[float, float]:
        # ...
        x_new = x * np.cos(angle_rad) - y * np.sin(angle_rad)
        y_new = x * np.sin(angle_rad) + y * np.cos(angle_rad)
```

```python
    def rotate_point(..., angle_deg, ...):
        # 图像坐标系下，逆时针旋转公式
        # x' = (x-cx)cosθ + (y-cy)sinθ + cx
        # y' = -(x-cx)sinθ + (y-cy)cosθ + cy
        angle_rad = np.radians(angle_deg)
        x -= cx
        y -= cy
        x_new = x * np.cos(angle_rad) + y * np.sin(angle_rad)
        y_new = -x * np.sin(angle_rad) + y * np.cos(angle_rad)
        # ...
```

```python
    @staticmethod
    def rotate_point(point: Tuple[float, float], 
                    angle_deg: float, 
                    center: Tuple[float, float] = (0, 0)) -> Tuple[float, float]:
        """
        旋转点坐标 (图像坐标系：Y轴向下)
        
        Args:
            angle_deg: 旋转角度（度数）。正值 = 逆时针旋转。
        """
        # ...
        # 图像坐标系逆时针旋转公式：
        # x' = x cosθ + y sinθ
        # y' = -x sinθ + y cosθ
        x_new = x * np.cos(angle_rad) + y * np.sin(angle_rad)
        y_new = -x * np.sin(angle_rad) + y * np.cos(angle_rad)
        # ...
```

```python
"""
bbox 提取模块
负责从 PaddleOCR 结果中提取文字框信息
"""
from typing import List, Dict, Tuple
import numpy as np
from pathlib import Path


class BBoxExtractor:
    """bbox 提取器"""
    
    @staticmethod
    def extract_paddle_text_boxes(paddle_data: Dict) -> Tuple[List[Dict], float, Tuple[int, int]]:
        """
        提取 PaddleOCR 的文字框信息
        
        Args:
            paddle_data: PaddleOCR 输出的数据
        
        Returns:
            文字框列表（保持旋转后的angle角度）和旋转角度
        """
        text_boxes = []
        rotation_angle = 0.0
        orig_image_size = (0,0)
        
        if 'overall_ocr_res' not in paddle_data:
            return text_boxes, rotation_angle, orig_image_size
        
        ocr_res = paddle_data['overall_ocr_res']
        rec_texts = ocr_res.get('rec_texts', [])
        rec_polys = ocr_res.get('rec_polys', [])
        rec_scores = ocr_res.get('rec_scores', [])
        
        # 🎯 获取旋转角度
        rotation_angle = BBoxExtractor._get_rotation_angle(paddle_data)
        if rotation_angle != 0:
            orig_image_size = BBoxExtractor._get_original_image_size(paddle_data)
            print(f"🔄 检测到旋转角度: {rotation_angle}°")
            print(f"📐 原始图像尺寸: {orig_image_size[0]} x {orig_image_size[1]}")
                
        for i, (text, poly, score) in enumerate(zip(rec_texts, rec_polys, rec_scores)):
            if text and text.strip():
                # 计算 bbox (x_min, y_min, x_max, y_max)
                bbox = BBoxExtractor._poly_to_bbox(poly)
                
                text_boxes.append({
                    'text': text,
                    'bbox': bbox,
                    'poly': poly,
                    'score': score,
                    'paddle_bbox_index': i,
                    'used': False
                })
        
        return text_boxes, rotation_angle, orig_image_size
    
    @staticmethod
    def extract_paddle_text_boxes_inverse_rotate(paddle_data: Dict) -> Tuple[List[Dict], float, Tuple[int, int]]:
        """
        提取 PaddleOCR 的文字框信息
        
        Args:
            paddle_data: PaddleOCR 输出的数据
        
        Returns:
            文字框列表（坐标已转换为 angle=0 时的坐标）
        """
        text_boxes = []
        rotation_angle = 0.0
        orig_image_size = (0,0)
        
        if 'overall_ocr_res' not in paddle_data:
            return text_boxes, rotation_angle, orig_image_size
        
        ocr_res = paddle_data['overall_ocr_res']
        rec_texts = ocr_res.get('rec_texts', [])
        rec_polys = ocr_res.get('rec_polys', [])
        rec_scores = ocr_res.get('rec_scores', [])
        
        # 🎯 获取旋转角度
        rotation_angle = BBoxExtractor._get_rotation_angle(paddle_data)
        
        if rotation_angle != 0:
            orig_image_size = BBoxExtractor._get_original_image_size(paddle_data)
            print(f"🔄 检测到旋转角度: {rotation_angle}°")
            print(f"📐 原始图像尺寸: {orig_image_size[0]} x {orig_image_size[1]}")
        
        for i, (text, poly, score) in enumerate(zip(rec_texts, rec_polys, rec_scores)):
            if text and text.strip():
                # 🎯 如果有旋转角度，转换坐标
                if rotation_angle != 0 and orig_image_size:
                    poly = BBoxExtractor.inverse_rotate_coordinates(
                        poly, rotation_angle, orig_image_size
                    )
                
                # 计算 bbox (x_min, y_min, x_max, y_max)
                bbox = BBoxExtractor._poly_to_bbox(poly)
                
                text_boxes.append({
                    'text': text,
                    'bbox': bbox,
                    'poly': poly,
                    'score': score,
                    'paddle_bbox_index': i,
                    'used': False
                })
        
        return text_boxes, rotation_angle, orig_image_size
    
    @staticmethod
    def _get_rotation_angle(paddle_data: Dict) -> float:
        """获取旋转角度"""
        if 'doc_preprocessor_res' not in paddle_data:
            return 0.0
        
        doc_res = paddle_data['doc_preprocessor_res']
        if isinstance(doc_res, dict) and 'angle' in doc_res:
            return float(doc_res['angle'])
        
        return 0.0
    
    @staticmethod
    def _get_original_image_size(paddle_data: Dict) -> tuple:
        """
        获取原始图像尺寸（从图片文件读取）
        
        Args:
            paddle_data: PaddleOCR 数据
        
        Returns:
            (width, height) 元组
        """
        from PIL import Image
        
        # 🎯 从 input_path 读取图像
        input_path = paddle_data.get('input_path')
        
        if input_path and Path(input_path).exists():
            try:
                with Image.open(input_path) as img:
                    # 返回原始图像尺寸
                    return img.size  # (width, height)
            except Exception as e:
                print(f"⚠️ 无法读取图像文件 {input_path}: {e}")
        
        # 🎯 降级方案：从 layout_det_res 推断
        if 'layout_det_res' in paddle_data:
            layout_res = paddle_data['layout_det_res']
            if 'boxes' in layout_res and layout_res['boxes']:
                max_x = 0
                max_y = 0
                for box in layout_res['boxes']:
                    coord = box.get('coordinate', [])
                    if len(coord) >= 4:
                        max_x = max(max_x, coord[2])
                        max_y = max(max_y, coord[3])
                
                if max_x > 0 and max_y > 0:
                    return (int(max_x) + 50, int(max_y) + 50)
        
        # 🎯 最后降级：从 overall_ocr_res 推断
        if 'overall_ocr_res' in paddle_data:
            ocr_res = paddle_data['overall_ocr_res']
            rec_polys = ocr_res.get('rec_polys', [])
            if rec_polys:
                max_x = 0
                max_y = 0
                for poly in rec_polys:
                    for point in poly:
                        max_x = max(max_x, point[0])
                        max_y = max(max_y, point[1])
                
                if max_x > 0 and max_y > 0:
                    return (int(max_x) + 50, int(max_y) + 50)
        
        # 🎯 默认 A4 尺寸
        print("⚠️ 无法确定原始图像尺寸，使用默认值")
        return (2480, 3508)
    
    @staticmethod
    def rotate_box_coordinates(bbox: List[float], 
                             angle: float,
                             orig_image_size: tuple) -> List[float]:
        """
        旋转 bbox 坐标（与图像旋转保持一致）
        
        参考 ocr_validator_utils.rotate_image_and_coordinates 的操作
        
        旋转逻辑：
        - 0°: 不旋转
        - 90°: 逆时针旋转 90°
        - 180°: 旋转 180°
        - 270°: 顺时针旋转 90°（或逆时针 270°）
        
        Args:
            bbox: 原图像上的边界框 [x_min, y_min, x_max, y_max]
            angle: 旋转角度（0, 90, 180, 270）
            orig_image_size: 原始图像尺寸 (width, height)
        """
        poly = BBoxExtractor._bbox_to_poly(bbox)
        rotated_poly = BBoxExtractor.rotate_coordinates(poly, angle, orig_image_size)
        rotated_bbox = BBoxExtractor._poly_to_bbox(rotated_poly)
        return rotated_bbox

    @staticmethod
    def inverse_rotate_box_coordinates(bbox: List[float], 
                                    angle: float,
                                    orig_image_size: tuple) -> List[float]:
        """
        反向旋转 bbox 坐标
        
        参考 ocr_validator_utils.rotate_image_and_coordinates 的逆操作
        
        PaddleOCR 在旋转后的图像上识别，坐标是旋转后的
        我们需要将坐标转换回原始图像（未旋转）
        
        Args:
            bbox: 旋转后图像上的边界框 [x_min, y_min, x_max, y_max]
            angle: 旋转角度（度数，PaddleX 使用的角度）
            orig_image_size: 原始图像尺寸 (width, height)
        """
        poly = BBoxExtractor._bbox_to_poly(bbox)
        inverse_poly = BBoxExtractor.inverse_rotate_coordinates(poly, angle, orig_image_size)
        inverse_bbox = BBoxExtractor._poly_to_bbox(inverse_poly)
        return inverse_bbox

    @staticmethod
    def inverse_rotate_coordinates(poly: List[List[float]], 
                                    angle: float,
                                    orig_image_size: tuple) -> List[List[float]]:
        """
        反向旋转坐标
        
        参考 ocr_validator_utils.rotate_image_and_coordinates 的逆操作
        
        PaddleOCR 在旋转后的图像上识别，坐标是旋转后的
        我们需要将坐标转换回原始图像（未旋转）
        
        Args:
            poly: 旋转后图像上的多边形坐标 [[x',y'], ...]
            angle: 旋转角度（度数，PaddleX 使用的角度）
            orig_image_size: 原始图像尺寸 (width, height)
        
        Returns:
            原始图像上的多边形坐标 [[x,y], ...]
        """
        orig_width, orig_height = orig_image_size
        
        # 🎯 根据旋转角度计算旋转后的图像尺寸
        if angle == 90:
            rotated_width, rotated_height = orig_height, orig_width
        elif angle == 270:
            rotated_width, rotated_height = orig_height, orig_width
        else:
            rotated_width, rotated_height = orig_width, orig_height
        
        inverse_poly = []
        
        for point in poly:
            x_rot, y_rot = point[0], point[1]  # 旋转后的坐标
            
            # 🎯 反向旋转（参考 rotate_image_and_coordinates 的逆操作）
            if angle == 90:
                # 正向: rotated = image.rotate(90, expand=True)
                #      x_rot = y_orig
                #      y_rot = rotated_width - x_orig = orig_height - x_orig
                # 反向: x_orig = rotated_width - y_rot = orig_height - y_rot
                #      y_orig = x_rot
                x_orig = rotated_width - y_rot
                y_orig = x_rot
                
            elif angle == 270:
                # 正向: rotated = image.rotate(-90, expand=True)
                #      x_rot = rotated_width - y_orig = orig_height - y_orig
                #      y_rot = x_orig
                # 反向: y_orig = rotated_width - x_rot = orig_height - x_rot
                #      x_orig = y_rot
                x_orig = y_rot
                y_orig = rotated_width - x_rot
                
            elif angle == 180:
                # 正向: rotated = image.rotate(180)
                #      x_rot = orig_width - x_orig
                #      y_rot = orig_height - y_orig
                # 反向: x_orig = orig_width - x_rot
                #      y_orig = orig_height - y_rot
                x_orig = orig_width - x_rot
                y_orig = orig_height - y_rot
                
            else:
                # 其他角度或0度，不转换
                x_orig = x_rot
                y_orig = y_rot
            
            inverse_poly.append([x_orig, y_orig])
        
        return inverse_poly
    
    @staticmethod
    def rotate_coordinates(poly: List[List[float]], 
                        angle: float,
                        orig_image_size: tuple) -> List[List[float]]:
        """
        旋转多边形坐标（与图像旋转保持一致）
        
        参考 ocr_validator_utils.rotate_image_and_coordinates 的操作
        
        旋转逻辑：
        - 0°: 不旋转
        - 90°: 逆时针旋转 90°
        - 180°: 旋转 180°
        - 270°: 顺时针旋转 90°（或逆时针 270°）
        
        Args:
            poly: 原图像上的多边形坐标 [[x', y'], ...]
            angle: 旋转角度（0, 90, 180, 270）
            orig_image_size: 原始图像尺寸 (width, height)
        
        Returns:
            旋转后的多边形坐标 [[x, y], ...]
        
        Example:
            >>> poly = [[100, 200], [150, 200], [150, 250], [100, 250]]
            >>> rotated = rotate_coordinates(poly, 90, (1000, 800))
            >>> print(rotated)
            [[200, 900], [200, 850], [250, 850], [250, 900]]
        """
        if not poly or angle == 0:
            return poly
        
        orig_width, orig_height = orig_image_size
        rotated_poly = []
        
        for point in poly:
            x, y = point[0], point[1]
            
            if angle == 90:
                # 逆时针旋转 90°
                # 新坐标系: 宽度=原高度, 高度=原宽度
                # x_new = y_old
                # y_new = 原宽度 - x_old
                new_x = y
                new_y = orig_width - x
                
            elif angle == 180:
                # 旋转 180°
                # 新坐标系: 宽度=原宽度, 高度=原高度
                # x_new = 原宽度 - x_old
                # y_new = 原高度 - y_old
                new_x = orig_width - x
                new_y = orig_height - y
                
            elif angle == 270:
                # 顺时针旋转 90°（或逆时针 270°）
                # 新坐标系: 宽度=原高度, 高度=原宽度
                # x_new = 原高度 - y_old
                # y_new = x_old
                new_x = orig_height - y
                new_y = x
                
            else:
                # 不支持的角度，保持原坐标
                new_x, new_y = x, y
            
            rotated_poly.append([new_x, new_y])
        
        return rotated_poly

    @staticmethod
    def _bbox_to_poly(bbox: List[float]) -> List[List[float]]:
        """
        将 bbox 转换为多边形（4个角点，逆时针顺序）
        
        Args:
            bbox: 边界框 [x_min, y_min, x_max, y_max]
        
        Returns:
            多边形坐标 [[x1, y1], [x2, y2], [x3, y3], [x4, y4]]
            顺序：左上 -> 右上 -> 右下 -> 左下（逆时针）
        
        Example:
            >>> bbox = [100, 200, 150, 250]
            >>> poly = BBoxExtractor._bbox_to_poly(bbox)
            >>> print(poly)
            [[100, 200], [150, 200], [150, 250], [100, 250]]
        """
        if not bbox or len(bbox) < 4:
            return []
        
        x_min, y_min, x_max, y_max = bbox[:4]
        
        # 🎯 4个角点（逆时针顺序）
        poly = [
            [x_min, y_min],  # 左上角
            [x_max, y_min],  # 右上角
            [x_max, y_max],  # 右下角
            [x_min, y_max]   # 左下角
        ]
        
        return poly

    @staticmethod
    def _poly_to_bbox(poly: List[List[float]]) -> List[float]:
        """将多边形转换为 bbox [x_min, y_min, x_max, y_max]"""
        xs = [p[0] for p in poly]
        ys = [p[1] for p in poly]
        return [min(xs), min(ys), max(xs), max(ys)]
    
    @staticmethod
    def extract_table_cells_with_bbox(merged_data: List[Dict]) -> List[Dict]:
        """
        提取所有表格单元格及其 bbox 信息
        
        Args:
            merged_data: 合并后的数据
        
        Returns:
            单元格列表
        """
        import json
        from bs4 import BeautifulSoup
        
        cells = []
        
        for item in merged_data:
            if item['type'] != 'table':
                continue
            
            html = item.get('table_body_with_bbox', item.get('table_body', ''))
            soup = BeautifulSoup(html, 'html.parser')
            
            for row_idx, row in enumerate(soup.find_all('tr')):
                for col_idx, cell in enumerate(row.find_all(['td', 'th'])):
                    cell_text = cell.get_text(strip=True)
                    bbox_str = cell.get('data-bbox', '')
                    
                    if bbox_str:
                        try:
                            bbox = json.loads(bbox_str)
                            cells.append({
                                'text': cell_text,
                                'bbox': bbox,
                                'row': row_idx,
                                'col': col_idx,
                                'score': float(cell.get('data-score', 0)),
                                'paddle_index': int(cell.get('data-paddle-index', -1))
                            })
                        except (json.JSONDecodeError, ValueError):
                            pass
        
        return cells

    @staticmethod
    def calculate_skew_angle(paddle_boxes: List[Dict], 
                            sample_ratio: float = 0.5,
                            outlier_threshold: float = 0.3) -> float:
        """
        计算文档倾斜角度（基于文本行分析）
        
        Args:
            paddle_boxes: Paddle OCR 结果（包含 poly）
            sample_ratio: 采样比例（使用中间区域）
            outlier_threshold: 异常值阈值（弧度）
        
        Returns:
            倾斜角度（度数，正值=逆时针，负值=顺时针）
        """
        if not paddle_boxes:
            return 0.0
        
        # 收集文本行的倾斜角度
        line_angles = []
        
        for box in paddle_boxes:
            poly = box.get('poly', [])
            if len(poly) < 4:
                continue
            
            x1, y1 = poly[0]
            x2, y2 = poly[1]
            
            width = abs(x2 - x1)
            height = abs(poly[2][1] - y1)
            
            # 过滤条件
            if width < 50 or width < height * 0.5:
                continue
            
            dx = x2 - x1
            dy = y2 - y1
            
            if abs(dx) > 10:
                angle_rad = -np.arctan2(dy, dx)
                
                if abs(angle_rad) < np.radians(15):
                    line_angles.append({
                        'angle': angle_rad,
                        'weight': width,
                        'y_center': (y1 + poly[2][1]) / 2
                    })
        
        if len(line_angles) < 5:
            return 0.0
        
        # 中间区域采样
        line_angles.sort(key=lambda x: x['y_center'])
        start_idx = int(len(line_angles) * (1 - sample_ratio) / 2)
        end_idx = int(len(line_angles) * (1 + sample_ratio) / 2)
        sampled_angles = line_angles[start_idx:end_idx]
        
        # 计算中位数
        raw_angles = [item['angle'] for item in sampled_angles]
        median_angle = np.median(raw_angles)
        
        # 过滤异常值
        filtered_angles = [
            item for item in sampled_angles 
            if abs(item['angle'] - median_angle) < outlier_threshold
        ]
        
        if len(filtered_angles) < 3:
            return np.degrees(median_angle)
        
        # 加权平均
        total_weight = sum(item['weight'] for item in filtered_angles)
        weighted_angle = sum(
            item['angle'] * item['weight'] for item in filtered_angles
        ) / total_weight
        
        return np.degrees(weighted_angle)
    
    @staticmethod
    def rotate_point(point: Tuple[float, float], 
                    angle_deg: float, 
                    center: Tuple[float, float] = (0, 0)) -> Tuple[float, float]:
        """
        旋转点坐标 (图像坐标系：Y轴向下)
        
        Args:
            point: 原始点 (x, y)
            angle_deg: 旋转角度（度数，正值=逆时针）
            center: 旋转中心
        
        Returns:
            旋转后的点 (x', y')
        """
        x, y = point
        cx, cy = center
        
        angle_rad = np.radians(angle_deg)
        
        x -= cx
        y -= cy
        
        # 图像坐标系（Y轴向下）下的逆时针旋转公式
        # x' = x cosθ + y sinθ
        # y' = -x sinθ + y cosθ
        x_new = x * np.cos(angle_rad) + y * np.sin(angle_rad)
        y_new = -x * np.sin(angle_rad) + y * np.cos(angle_rad)
        
        x_new += cx
        y_new += cy
        
        return (x_new, y_new)
    
    @staticmethod
    def correct_boxes_skew(paddle_boxes: List[Dict], 
                          correction_angle: float,
                          image_size: Tuple[int, int]) -> List[Dict]:
        """
        校正文本框的倾斜
        
        Args:
            paddle_boxes: Paddle OCR 结果
            correction_angle: 校正旋转角度（度数，正值=逆时针，负值=顺时针）
                              注意：这里直接传入需要旋转的角度，不再自动取反
            image_size: 图像尺寸 (width, height)
        
        Returns:
            校正后的文本框列表
        """
        if abs(correction_angle) < 0.01:
            return paddle_boxes
        
        width, height = image_size
        center = (width / 2, height / 2)
        
        corrected_boxes = []
        
        for box in paddle_boxes:
            poly = box.get('poly', [])
            
            # 🆕 修复：如果没有 poly，尝试从 bbox 生成
            # 这是为了兼容 MinerU 或其他没有 poly 的数据源
            if not poly or len(poly) < 4:
                if 'bbox' in box and len(box['bbox']) == 4:
                    poly = BBoxExtractor._bbox_to_poly(box['bbox'])
                else:
                    corrected_boxes.append(box)
                    continue
            
            # 旋转多边形
            rotated_poly = []
            for point in poly:
                # 确保点是 tuple 或 list，并只有 2 个坐标
                p = (point[0], point[1]) if isinstance(point, (list, tuple)) and len(point) >= 2 else (0.0, 0.0)
                # 直接使用 correction_angle 进行旋转
                rotated_point = BBoxExtractor.rotate_point(p, correction_angle, center)
                rotated_poly.append([rotated_point[0], rotated_point[1]]) # 转换回 list 以匹配 _poly_to_bbox 类型
            
            # 重新计算 bbox
            corrected_bbox = BBoxExtractor._poly_to_bbox(rotated_poly)
            
            corrected_box = box.copy()
            corrected_box['bbox'] = corrected_bbox
            corrected_box['poly'] = rotated_poly
            corrected_box['original_bbox'] = box['bbox']
            
            corrected_boxes.append(corrected_box)
        
        return corrected_boxes
```

