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
从您提供的调试信息来看,确实出现了问题。
现状分析:
skew_angle 是 -0.848。
calculate_skew_angle 的定义:负值表示顺时针倾斜(向右下歪)。校正逻辑:
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,意味着需要逆时针旋转。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。group[8])。结论: 如果不正确的旋转导致原本应该分开的行在 Y 轴上变得更加接近(甚至重叠),那么确实可能是旋转反了。
如果图片是顺时针歪的(右边低,左边高):
22,240,761.60 会偏低。加:会计政策变更 会偏高。那么问题出在哪里?
让我们重新审视 calculate_skew_angle 的定义:
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,说明算法认为图片是顺时针歪的(右下倾斜)。
correction = -(-0.848) = +0.848(逆时针转)。极有可能是 calculate_skew_angle 中的坐标系定义与 arctan2 的符号产生了混淆。
在图像坐标系中:
如果文字行是水平的: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:
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 轴方向。
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' = x cosθ - y sinθ 是通用的二维旋转矩阵,它定义的是:从 X 轴向 Y 轴方向旋转。
破案了! 在图像坐标系中(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 代表顺时针旋转。
回看我们的定义链:
calculate_skew_angle:
angle_rad = -arctan2(dy, dx)dy < 0 -> arctan < 0 -> angle > 0。correct_boxes_skew:
correction_angle = -skew_angle。correction 为负。rotate_point 的是负值。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 保持一致。
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。
rotate(-0.48) -> 顺时针。OK。rotate_point(..., -0.48) -> 此时希望它是顺时针。
rotate_point(-0.48) 是逆时针!最终结论:
必须修改 rotate_point 的公式,让它 正值=逆时针。这样才能和 PIL 统一,也能符合直觉。
修改计划:
修改 ocr_verify/merger/bbox_extractor.py 中的 rotate_point 方法,使其在图像坐标系下遵循 正值=逆时针 的规则。
@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)
# ...
这样改完后:
0.48。skew_angle = 0.48。correction_angle = -0.48 (表示需要顺时针转)。rotate(-0.48) -> 顺时针。对。rotate_point(-0.48) -> 公式修正后 -> 顺时针。对。我这就去修改 rotate_point。
correction_angle = -skew_angle # correction_angle = -(-0.848) = +0.848
paddle_boxes = BBoxExtractor.correct_boxes_skew(
paddle_boxes, correction_angle, image_size # 传入 +0.848
)
dx = x2 - x1
dy = y2 - y1
if abs(dx) > 10:
angle_rad = -np.arctan2(dy, dx) # 注意这个负号!
x1, y1 = poly[0] # 左上
x2, y2 = poly[1] # 右上
dx = x2 - x1
dy = y2 - y1
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)
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)
# ...
@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)
# ...
"""
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