|
|
@@ -225,36 +225,258 @@ class TestBankStatementReocrTrigger:
|
|
|
def _filler(self) -> TextFiller:
|
|
|
return TextFiller(
|
|
|
ocr_engine=None,
|
|
|
- config={
|
|
|
- "second_pass_ocr": {
|
|
|
- "reocr_mode": "bank_statement",
|
|
|
- "header_row": 0,
|
|
|
- "row_peer_min_nonempty": 3,
|
|
|
- }
|
|
|
- },
|
|
|
+ config={"second_pass_ocr": {"reocr_mode": "bank_statement"}},
|
|
|
)
|
|
|
|
|
|
- def test_body_row_empty_triggers(self):
|
|
|
+ def test_header_empty_row_triggers(self):
|
|
|
+ """表头行空单元格触发 header_row_empty。"""
|
|
|
+ f = self._filler()
|
|
|
+ merged = [{"row": 0, "col": 0, "bbox": [0, 0, 10, 10]}]
|
|
|
+ ok, reasons = f._should_second_pass_cell(
|
|
|
+ 0, [""], [0.99], [], merged, None, "ocr", False, 0
|
|
|
+ )
|
|
|
+ assert ok is True
|
|
|
+ assert "header_row_empty" in reasons
|
|
|
+ assert "body_row_empty" not in reasons
|
|
|
+
|
|
|
+ def test_body_row_empty_column_mostly_filled_triggers(self):
|
|
|
+ """列表体大部分有值、本格为空 → body_row_empty_column_mostly_filled。"""
|
|
|
f = self._filler()
|
|
|
merged = [
|
|
|
- {"row": 0, "col": 0, "bbox": [0, 0, 10, 10]},
|
|
|
{"row": 1, "col": 0, "bbox": [0, 10, 10, 20]},
|
|
|
+ {"row": 2, "col": 0, "bbox": [0, 20, 10, 30]},
|
|
|
+ {"row": 3, "col": 0, "bbox": [0, 30, 10, 40]},
|
|
|
]
|
|
|
- texts = ["header", ""]
|
|
|
- scores = [0.99, 0.0]
|
|
|
+ texts = ["有值", "有值", ""]
|
|
|
+ scores = [0.99, 0.99, 0.0]
|
|
|
+ matched = [[{"text": "有值"}], [{"text": "有值"}], []]
|
|
|
ok, reasons = f._should_second_pass_cell(
|
|
|
- 1, texts, scores, [], merged, "ocr", False, 0
|
|
|
+ 2, texts, scores, [], merged, matched, "ocr", False, 0
|
|
|
)
|
|
|
assert ok is True
|
|
|
- assert "body_row_empty" in reasons
|
|
|
+ assert "body_row_empty_column_mostly_filled" in reasons
|
|
|
|
|
|
- def test_header_empty_not_body_row_forced(self):
|
|
|
+ def test_column_mostly_empty_skips(self):
|
|
|
+ """该列表体大多为空 → 不触发二次 OCR。"""
|
|
|
f = self._filler()
|
|
|
- merged = [{"row": 0, "col": 0, "bbox": [0, 0, 10, 10]}]
|
|
|
+ merged = [
|
|
|
+ {"row": 1, "col": 0, "bbox": [0, 10, 10, 20]},
|
|
|
+ {"row": 2, "col": 0, "bbox": [0, 20, 10, 30]},
|
|
|
+ {"row": 3, "col": 0, "bbox": [0, 30, 10, 40]},
|
|
|
+ ]
|
|
|
+ texts = ["", "", ""]
|
|
|
+ scores = [0.0, 0.0, 0.0]
|
|
|
+ matched = [[], [], []]
|
|
|
+ ok, _ = f._should_second_pass_cell(
|
|
|
+ 2, texts, scores, [], merged, matched, "ocr", False, 0
|
|
|
+ )
|
|
|
+ assert ok is False
|
|
|
+
|
|
|
+ def test_non_empty_cell_not_affected_by_bank_statement(self):
|
|
|
+ """银行流水模式下,有值格不进入空单元格逻辑。"""
|
|
|
+ f = self._filler()
|
|
|
+ merged = [
|
|
|
+ {"row": 1, "col": 0, "bbox": [0, 10, 10, 20]},
|
|
|
+ {"row": 1, "col": 1, "bbox": [10, 10, 20, 20]},
|
|
|
+ ]
|
|
|
+ texts = ["有值", ""]
|
|
|
+ scores = [0.99, 0.0]
|
|
|
ok, reasons = f._should_second_pass_cell(
|
|
|
- 0, [""], [0.99], [], merged, "ocr", False, 0
|
|
|
+ 0, texts, scores, [], merged, None, "ocr", False, 0
|
|
|
)
|
|
|
- assert "body_row_empty" not in reasons
|
|
|
+ assert ok is False # 有值不触发
|
|
|
+
|
|
|
+
|
|
|
+class TestEnhanceRetryUpscale:
|
|
|
+ def test_enhance_retry_upscale_min_side_from_yaml(self):
|
|
|
+ f = TextFiller(
|
|
|
+ ocr_engine=None,
|
|
|
+ config={
|
|
|
+ "second_pass_ocr": {
|
|
|
+ "cell_preprocess": {
|
|
|
+ "upscale_min_side": 96,
|
|
|
+ "enhance_retry": {
|
|
|
+ "enabled": True,
|
|
|
+ "upscale_min_side": 128,
|
|
|
+ },
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ )
|
|
|
+ assert f.second_pass_light_upscale_min == 96
|
|
|
+ assert f.second_pass_enhance_upscale_min == 128
|
|
|
+
|
|
|
+ def test_enhance_upscale_defaults_to_pass1(self):
|
|
|
+ f = TextFiller(
|
|
|
+ ocr_engine=None,
|
|
|
+ config={
|
|
|
+ "second_pass_ocr": {
|
|
|
+ "cell_preprocess": {"upscale_min_side": 192},
|
|
|
+ }
|
|
|
+ },
|
|
|
+ )
|
|
|
+ assert f.second_pass_light_upscale_min == 192
|
|
|
+ assert f.second_pass_enhance_upscale_min == 192
|
|
|
+
|
|
|
+ def test_preprocess_uses_different_upscale_for_enhance(self):
|
|
|
+ import numpy as np
|
|
|
+
|
|
|
+ f = TextFiller(
|
|
|
+ ocr_engine=None,
|
|
|
+ config={
|
|
|
+ "second_pass_ocr": {
|
|
|
+ "cell_preprocess": {
|
|
|
+ "upscale_min_side": 64,
|
|
|
+ "enhance_retry": {"enabled": True, "upscale_min_side": 128},
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ )
|
|
|
+ small = np.zeros((40, 30, 3), dtype=np.uint8)
|
|
|
+ light, _ = f._preprocess_cell_for_ocr(small, mode="light")
|
|
|
+ enhance, _ = f._preprocess_cell_for_ocr(small, mode="enhance")
|
|
|
+ assert max(light.shape[:2]) >= 64
|
|
|
+ assert max(enhance.shape[:2]) >= 128
|
|
|
+
|
|
|
+
|
|
|
+class TestOcrVerticallyIncomplete:
|
|
|
+ """银行流水单元格 OCR 纵向完整性判断:
|
|
|
+ - y_center 偏移检测
|
|
|
+ - 空白不对称检测(文字贴在顶部/底部时触发)
|
|
|
+ """
|
|
|
+
|
|
|
+ def test_center_aligned_no_trigger(self):
|
|
|
+ """匹配到的 OCR 纵向居中 → 不标记不完整。"""
|
|
|
+ cell_bbox = [100, 50, 200, 90] # yc=70, h=40
|
|
|
+ matched = [{"bbox": [110, 60, 190, 80]}] # yc=70, h=20 → 居中
|
|
|
+ assert TextFiller._is_ocr_vertically_incomplete(cell_bbox, matched) is False
|
|
|
+
|
|
|
+ def test_ocr_on_bottom_triggers(self):
|
|
|
+ """匹配到的 OCR 偏底部 → 标记不完整(y_center 偏移 + 空白不对称均触发)。"""
|
|
|
+ cell_bbox = [100, 50, 200, 90] # yc=70, h=40, threshold=40/3≈13.33
|
|
|
+ matched = [{"bbox": [110, 80, 190, 88]}] # yc=(80+88)/2=84, 偏离|84-70|=14>13.33
|
|
|
+ assert TextFiller._is_ocr_vertically_incomplete(cell_bbox, matched) is True
|
|
|
+
|
|
|
+ def test_empty_matched_returns_false(self):
|
|
|
+ assert TextFiller._is_ocr_vertically_incomplete([0, 0, 10, 10], []) is False
|
|
|
+
|
|
|
+ def test_tall_matched_text_not_triggered(self):
|
|
|
+ """已匹配 OCR 合并高度超过 cell_h * 0.7 → 认为已够全,不触发。"""
|
|
|
+ cell_bbox = [100, 50, 200, 90] # h=40
|
|
|
+ matched = [{"bbox": [110, 51, 190, 87]}] # h=36 > 40*0.7=28 → 不触发
|
|
|
+ assert TextFiller._is_ocr_vertically_incomplete(cell_bbox, matched) is False
|
|
|
+
|
|
|
+ def test_margin_asymmetry_short_text_on_bottom_triggers(self):
|
|
|
+ """短文本偏底部:y_center 偏差不达标,但空白不对称触发(如"支行")。"""
|
|
|
+ # cell y=[144.59, 230.38], h=85.79, yc=187.48
|
|
|
+ # text y=[190, 226], h=36, yc=208
|
|
|
+ # y_center 偏差: |208-187.48|=20.52 < 85.79/3≈28.6 → 不触发
|
|
|
+ # 上方空白: 190-144.59=45.41 → 52.9%
|
|
|
+ # 下方空白: 230.38-226=4.38 → 5.1%
|
|
|
+ # 不对称度: |0.529-0.051|=0.478 > 0.3 → 触发
|
|
|
+ cell_bbox = [900, 144.59, 1100, 230.38]
|
|
|
+ matched = [{"bbox": [940.0, 190.0, 999.0, 226.0]}]
|
|
|
+ assert TextFiller._is_ocr_vertically_incomplete(cell_bbox, matched) is True
|
|
|
+
|
|
|
+ def test_margin_asymmetry_on_top_triggers(self):
|
|
|
+ """短文本偏顶部 → 空白不对称触发。"""
|
|
|
+ cell_bbox = [100, 50, 200, 90] # h=40
|
|
|
+ matched = [{"bbox": [110, 50, 190, 58]}] # 贴在顶部
|
|
|
+ assert TextFiller._is_ocr_vertically_incomplete(cell_bbox, matched) is True
|
|
|
+
|
|
|
+ def test_margin_centered_no_asymmetry(self):
|
|
|
+ """文字居中且空白对称 → 不触发(如"广东农信"完美居中)。"""
|
|
|
+ cell_bbox = [100, 50, 200, 90] # h=40
|
|
|
+ # 上方空白≈14.5, 下方空白≈14.5 → 对称
|
|
|
+ matched = [{"bbox": [110, 64.5, 190, 75.5]}]
|
|
|
+ assert TextFiller._is_ocr_vertically_incomplete(cell_bbox, matched) is False
|
|
|
+
|
|
|
+ def test_margin_slightly_off_center_no_trigger(self):
|
|
|
+ """文字轻微偏离但不对称度在阈值内 → 不触发。"""
|
|
|
+ cell_bbox = [100, 50, 200, 90] # h=40
|
|
|
+ # 上方空白=9, 下方空白=11 → 不对称度=|0.225-0.275|=0.05 < 0.3
|
|
|
+ matched = [{"bbox": [110, 59, 190, 79]}]
|
|
|
+ assert TextFiller._is_ocr_vertically_incomplete(cell_bbox, matched) is False
|
|
|
+
|
|
|
+
|
|
|
+class TestColumnEmptyRatio:
|
|
|
+ def test_mixed(self):
|
|
|
+ merged = [
|
|
|
+ {"row": 1, "col": 0}, {"row": 2, "col": 0}, {"row": 3, "col": 0},
|
|
|
+ ]
|
|
|
+ # 第 3 个无 OCR 框命中 → 算空
|
|
|
+ matched = [[{"text": "a"}], [{"text": "b"}], []]
|
|
|
+ r = TextFiller._column_empty_ratio(merged, matched, 0, 0)
|
|
|
+ assert abs(r - 1 / 3) < 0.01
|
|
|
+
|
|
|
+ def test_all_empty(self):
|
|
|
+ merged = [{"row": 1, "col": 0}, {"row": 2, "col": 0}]
|
|
|
+ matched = [[], []]
|
|
|
+ r = TextFiller._column_empty_ratio(merged, matched, 0, 0)
|
|
|
+ assert abs(r - 1.0) < 0.01
|
|
|
+
|
|
|
+ def test_header_excluded(self):
|
|
|
+ merged = [
|
|
|
+ {"row": 0, "col": 0},
|
|
|
+ {"row": 1, "col": 0},
|
|
|
+ {"row": 2, "col": 0},
|
|
|
+ ]
|
|
|
+ matched = [[], [{"text": "a"}], [{"text": "b"}]]
|
|
|
+ r = TextFiller._column_empty_ratio(merged, matched, 0, 0)
|
|
|
+ assert abs(r - 0.0) < 0.01
|
|
|
+
|
|
|
+ def test_ocr_box_hit_but_text_empty_not_counted_empty(self):
|
|
|
+ """OCR 检测到框但未识别出文字 → 不应计为空。"""
|
|
|
+ merged = [{"row": 1, "col": 0}, {"row": 2, "col": 0}]
|
|
|
+ matched = [
|
|
|
+ [{"text": "", "bbox": [0, 0, 10, 10], "confidence": 1.0}],
|
|
|
+ [{"text": "a", "bbox": [0, 20, 10, 30], "confidence": 0.99}],
|
|
|
+ ]
|
|
|
+ r = TextFiller._column_empty_ratio(merged, matched, 0, 0)
|
|
|
+ assert abs(r - 0.0) < 0.01 # 两格都有 OCR 框命中
|
|
|
+
|
|
|
+
|
|
|
+class TestIsBboxSlanted:
|
|
|
+ """OCR 斜框检测:水印等斜向文本框应被过滤。"""
|
|
|
+
|
|
|
+ def test_slanted_poly_list_format(self):
|
|
|
+ # 上边角度 ≈ 31°
|
|
|
+ box = {"original_bbox": [[0, 0], [50, 31], [50, 51], [0, 20]]}
|
|
|
+ assert TextFiller._is_bbox_slanted(box, angle_threshold=10.0) is True
|
|
|
+
|
|
|
+ def test_horizontal_poly_list_format(self):
|
|
|
+ # 几乎水平 (dy=2, dx=50 → angle≈2.3°)
|
|
|
+ box = {"original_bbox": [[0, 0], [50, 2], [50, 52], [0, 50]]}
|
|
|
+ assert TextFiller._is_bbox_slanted(box, angle_threshold=10.0) is False
|
|
|
+
|
|
|
+ def test_slanted_flat8_format(self):
|
|
|
+ """8值平面格式:[x1,y1,x2,y2,x3,y3,x4,y4],上边 ≈35°"""
|
|
|
+ box = {"original_bbox": [0, 0, 50, 35, 50, 55, 0, 20]}
|
|
|
+ assert TextFiller._is_bbox_slanted(box, angle_threshold=10.0) is True
|
|
|
+
|
|
|
+ def test_horizontal_flat8_format(self):
|
|
|
+ box = {"original_bbox": [0, 0, 50, 1, 50, 51, 0, 50]}
|
|
|
+ assert TextFiller._is_bbox_slanted(box, angle_threshold=10.0) is False
|
|
|
+
|
|
|
+ def test_axis_aligned_bbox_not_slanted(self):
|
|
|
+ """轴对齐 bbox [x1,y1,x2,y2] 无法判断角度 → 不算斜向"""
|
|
|
+ box = {"original_bbox": [0, 0, 50, 20]}
|
|
|
+ assert TextFiller._is_bbox_slanted(box, angle_threshold=10.0) is False
|
|
|
+
|
|
|
+ def test_empty_poly_not_slanted(self):
|
|
|
+ box: dict = {}
|
|
|
+ assert TextFiller._is_bbox_slanted(box, angle_threshold=10.0) is False
|
|
|
+
|
|
|
+ def test_fallback_to_bbox_key(self):
|
|
|
+ """没有 original_bbox 时回退到 bbox 字段"""
|
|
|
+ box = {"poly": [[0, 0], [50, 31], [50, 51], [0, 20]]}
|
|
|
+ assert TextFiller._is_bbox_slanted(box, angle_threshold=10.0) is True
|
|
|
+
|
|
|
+ def test_custom_threshold(self):
|
|
|
+ """阈值调大后同一框不再斜向"""
|
|
|
+ box = {"original_bbox": [[0, 0], [50, 15], [50, 35], [0, 20]]} # ≈16.7°
|
|
|
+ assert TextFiller._is_bbox_slanted(box, angle_threshold=10.0) is True
|
|
|
+ assert TextFiller._is_bbox_slanted(box, angle_threshold=20.0) is False
|
|
|
|
|
|
|
|
|
class TestResolveCellMatchedBoxes:
|
|
|
@@ -302,6 +524,48 @@ class TestResolveCellMatchedBoxes:
|
|
|
assert len(resolved) == 1
|
|
|
assert resolved[0][0] == "广东兴宁农村商业银"
|
|
|
|
|
|
+ def test_slanted_watermark_filtered(self):
|
|
|
+ """斜向水印框被丢弃,仅保留正常水平框。"""
|
|
|
+ # 斜框:上边 ≈ 31°,text="有限公司"
|
|
|
+ slanted_box = {
|
|
|
+ "bbox": [50, 20, 100, 52],
|
|
|
+ "original_bbox": [[50, 20], [100, 51], [100, 71], [50, 40]],
|
|
|
+ "text": "有限公司",
|
|
|
+ "confidence": 0.9,
|
|
|
+ }
|
|
|
+ entry_slanted = (
|
|
|
+ "有限公司",
|
|
|
+ slanted_box["bbox"][1], # y1
|
|
|
+ slanted_box["bbox"][0], # x1
|
|
|
+ 1.0,
|
|
|
+ 0.9,
|
|
|
+ slanted_box,
|
|
|
+ )
|
|
|
+ # 水平框
|
|
|
+ normal = self._matched_entry("200.00", [55.0, 30.0, 95.0, 45.0], 0.95)
|
|
|
+ matched = [entry_slanted, normal]
|
|
|
+ resolved, force_zero = TextFiller._resolve_cell_matched_boxes(
|
|
|
+ matched, cell_idx=10, slanted_angle_threshold=10.0
|
|
|
+ )
|
|
|
+ assert force_zero is False
|
|
|
+ assert len(resolved) == 1
|
|
|
+ assert resolved[0][0] == "200.00"
|
|
|
+
|
|
|
+ def test_all_slanted_returns_empty(self):
|
|
|
+ """全部斜框 → entries 为空 → 返回值也为空。"""
|
|
|
+ box = {
|
|
|
+ "bbox": [50, 20, 100, 52],
|
|
|
+ "original_bbox": [[50, 20], [100, 51], [100, 71], [50, 40]],
|
|
|
+ "text": "有限公司",
|
|
|
+ "confidence": 0.9,
|
|
|
+ }
|
|
|
+ entry = ("有限公司", box["bbox"][1], box["bbox"][0], 1.0, 0.9, box)
|
|
|
+ resolved, force_zero = TextFiller._resolve_cell_matched_boxes(
|
|
|
+ [entry], slanted_angle_threshold=10.0
|
|
|
+ )
|
|
|
+ assert resolved == []
|
|
|
+ assert force_zero is False
|
|
|
+
|
|
|
def test_fill_by_center_point_empty_container(self):
|
|
|
filler = TextFiller(ocr_engine=None, config={})
|
|
|
cell = [900.0, 1580.0, 1100.0, 1680.0]
|