15 Komitmen cde2fb8faa ... 75d01a1ed5

Pembuat SHA1 Pesan Tanggal
  zhch158_admin 75d01a1ed5 feat(重构水印处理模块): 将水印处理功能从ocr_utils.watermark_utils迁移至ocr_utils.watermark子模块,新增水印检测、去除、对比度增强等功能,优化模块结构以提升可维护性和扩展性,同时保留与历史导入路径的兼容性。 1 bulan lalu
  zhch158_admin 40b88e07b3 feat(新增水印处理单元测试): 在test_watermark_processor.py中新增多个测试用例,验证WatermarkProcessor和merge_watermark_config的功能,确保水印处理逻辑的准确性和可靠性。 1 bulan lalu
  zhch158_admin b68a0e5003 feat(新增二次OCR处理与测试用例): 在test_second_pass_ocr_aggregate.py中新增多个测试类和用例,验证整体OCR处理逻辑,包括短文本高分触发整体OCR和空行触发逻辑,增强对银行对账单的二次OCR触发条件的测试,提升OCR处理的准确性和可靠性。 1 bulan lalu
  zhch158_admin 9dd99bce76 feat(优化水印处理与OCR逻辑): 重构MinerUPreprocessor类以整合WatermarkProcessor,简化水印去除流程并增强对比度调整功能,同时更新MinerUWiredTableRecognizer类以支持更灵活的单元格OCR处理,提升整体OCR准确性与灵活性。 1 bulan lalu
  zhch158_admin 73e783c91b feat(增强文本填充与OCR识别逻辑): 更新TextFiller类,新增多项配置选项以优化单元格OCR处理,重构识别逻辑以支持详细的行识别和边界框返回,提升OCR的准确性和灵活性。 1 bulan lalu
  zhch158_admin 6f98aaba58 feat(优化银行对账单水印去除与单元格二次OCR配置): 更新bank_statement_yusys_local.yaml,简化水印去除配置,增强对水印检测的支持,新增单元格预处理选项,提升OCR处理的灵活性和准确性。 1 bulan lalu
  zhch158_admin 0ba1d33741 feat(增强水印去除工具的配置与处理能力): 更新remove_watermark.py,重构水印设置加载逻辑,支持根据scope参数选择不同的水印配置,新增WatermarkProcessor类以优化水印去除过程,提升OCR处理的灵活性和准确性。 1 bulan lalu
  zhch158_admin 130984410f feat(新增单元格预处理与参数扫描功能): 在ocr_tools/cell_preprocess_lab中新增cell_preprocess_lab.py和cell121_sweep.py文件,分别实现单元格裁剪图的预处理流程和参数扫描功能,支持去水印、去噪、对比度调整及OCR识别,提升OCR处理的灵活性和准确性。 1 bulan lalu
  zhch158_admin e2bb737026 feat(新增单元格匹配框处理单元测试): 在test_second_pass_ocr_aggregate.py中新增TestResolveCellMatchedBoxes类,包含多个测试用例以验证单元格匹配框的处理逻辑,确保在不同情况下的文本填充和分数计算的准确性,提升OCR处理的可靠性和可维护性。 1 bulan lalu
  zhch158_admin fdef502446 feat(添加虚线段绘制功能): 在module_debug_viz.py中新增虚线段绘制函数,支持在OCR span无文本时使用虚线框显示,提升可视化效果和调试灵活性。 1 bulan lalu
  zhch158_admin 398929fec5 fix(修复无效ocr_poly处理逻辑): 注释掉无效的ocr_poly和文本检查逻辑,确保在ocr_poly为空时返回None,提升代码的健壮性和可读性。 1 bulan lalu
  zhch158_admin 3099890b65 feat(增强文本填充逻辑与边界框处理): 更新TextFiller类中的文本填充逻辑,确保在文本为空时返回0分数;新增多个静态方法以处理边界框的面积计算、嵌套框的识别和调试标签生成,提升OCR处理的准确性和可维护性。 1 bulan lalu
  zhch158_admin 8e61a877b0 feat(添加二次OCR聚合与择优逻辑单元测试): 新增针对二次OCR的聚合、择优逻辑及调试功能的单元测试,提升OCR处理的准确性和可维护性。 1 bulan lalu
  zhch158_admin 5511510558 feat(增强单元格OCR调试功能): 在MinerUWiredTableRecognizer类中添加debug_prefix参数,以支持更灵活的调试输出,提升OCR处理的可追踪性和调试效率。 1 bulan lalu
  zhch158_admin 815592687a feat(添加单元格二次OCR配置): 在多个银行对账单配置文件中添加second_pass_ocr选项,增强OCR处理能力,支持低分块过滤和整格兜底,提高文本识别的准确性和灵活性。 1 bulan lalu
25 mengubah file dengan 4251 tambahan dan 1887 penghapusan
  1. 194 0
      ocr_tools/cell_preprocess_lab/cell121_sweep.py
  2. 385 0
      ocr_tools/cell_preprocess_lab/cell_preprocess_lab.py
  3. 55 29
      ocr_tools/remove_watermark_tool/remove_watermark.py
  4. 8 0
      ocr_tools/universal_doc_parser/config/bank_statement_glm_vl_local.yaml
  5. 8 0
      ocr_tools/universal_doc_parser/config/bank_statement_paddle_vl_local.yaml
  6. 8 0
      ocr_tools/universal_doc_parser/config/bank_statement_smart_router.yaml
  7. 51 69
      ocr_tools/universal_doc_parser/config/bank_statement_yusys_local.yaml
  8. 9 1
      ocr_tools/universal_doc_parser/config/bank_statement_yusys_v3.yaml
  9. 8 0
      ocr_tools/universal_doc_parser/config/bank_statement_yusys_v4.yaml
  10. 3 1
      ocr_tools/universal_doc_parser/core/table_coordinate_utils.py
  11. 24 28
      ocr_tools/universal_doc_parser/models/adapters/mineru_adapter.py
  12. 4 4
      ocr_tools/universal_doc_parser/models/adapters/mineru_wired_table.py
  13. 931 44
      ocr_tools/universal_doc_parser/models/adapters/wired_table/text_filling.py
  14. 270 0
      ocr_tools/universal_doc_parser/tests/test_second_pass_ocr_aggregate.py
  15. 76 4
      ocr_utils/module_debug_viz.py
  16. 35 0
      ocr_utils/tests/test_watermark_processor.py
  17. 50 0
      ocr_utils/watermark/__init__.py
  18. 1095 0
      ocr_utils/watermark/algorithms.py
  19. 139 0
      ocr_utils/watermark/contrast.py
  20. 129 0
      ocr_utils/watermark/debug.py
  21. 226 0
      ocr_utils/watermark/pdf.py
  22. 197 0
      ocr_utils/watermark/presets.py
  23. 153 0
      ocr_utils/watermark/processor.py
  24. 152 0
      ocr_utils/watermark/removal.py
  25. 41 1707
      ocr_utils/watermark_utils.py

+ 194 - 0
ocr_tools/cell_preprocess_lab/cell121_sweep.py

@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""cell121 参数扫描:去水印方式 / threshold / contrast / upscale / det 阈值 / 整格 rec。"""
+from __future__ import annotations
+
+import json
+import os
+import sys
+from itertools import product
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+import cv2
+import numpy as np
+
+_repo_root = Path(__file__).resolve().parents[2]
+if str(_repo_root) not in sys.path:
+    sys.path.insert(0, str(_repo_root))
+
+from ocr_utils.watermark import WatermarkProcessor, merge_watermark_config
+from ocr_utils.watermark.contrast import apply_contrast_enhancement_config
+
+CELL121 = Path(
+    "/Users/zhch158/workspace/data/流水分析/彭_广东兴宁农村商业银行/"
+    "bank_statement_yusys_local/debug/table_recognition_wired/tablecell_ocr/"
+    "彭_广东兴宁农村商业银行_page_002_0/cell121_empty_empty.png"
+)
+OUT_DIR = Path(__file__).parent / "output/彭_广东兴宁农村商业银行/cell121_sweep"
+MODEL_DIR = Path(
+    "/Users/zhch158/models/modelscope_cache/models/OpenDataLab/"
+    "PDF-Extract-Kit-1___0/models/OCR/paddleocr_torch"
+)
+
+TARGET = "20240927"
+
+
+def _upscale(img: np.ndarray, min_side: int) -> np.ndarray:
+    h, w = img.shape[:2]
+    if h >= min_side and w >= min_side:
+        return img
+    s = max(min_side / max(h, 1), min_side / max(w, 1), 1.0)
+    return cv2.resize(img, None, fx=s, fy=s, interpolation=cv2.INTER_CUBIC)
+
+
+def _preprocess(
+    raw: np.ndarray,
+    *,
+    method: str,
+    thresh: Optional[int],
+    contrast: bool,
+    upscale: int,
+) -> np.ndarray:
+    user: Dict[str, Any] = {"enabled": True, "method": method}
+    if method == "threshold" and thresh is not None:
+        user["threshold"] = thresh
+    cfg = merge_watermark_config("cell", user)
+    img, _ = WatermarkProcessor(cfg, scope="cell").process(raw, force=True)
+    if contrast:
+        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
+        ce = dict(cfg.get("contrast_enhancement") or {})
+        ce["enabled"] = True
+        ce["text_black_target"] = 88
+        gray = apply_contrast_enhancement_config(gray, ce)
+        img = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
+    return _upscale(img, upscale)
+
+
+def _ocr(engine: Any, img: np.ndarray, *, det: bool, rec: bool) -> Dict[str, Any]:
+    try:
+        res = engine.ocr(img, det=det, rec=rec)
+        texts: List[str] = []
+        if res and res[0]:
+            if det:
+                for item in res[0]:
+                    if item and len(item) >= 2 and item[1]:
+                        texts.append(str(item[1][0] or ""))
+            else:
+                for item in res[0]:
+                    if isinstance(item, (list, tuple)) and len(item) >= 1:
+                        texts.append(str(item[0] or ""))
+        text = "".join(texts).strip()
+        return {
+            "text": text,
+            "det": det,
+            "rec": rec,
+            "n_boxes": len(res[0]) if res and res[0] else 0,
+        }
+    except Exception as e:
+        return {"text": "", "error": str(e), "det": det, "rec": rec}
+
+
+def _make_engine(det_thresh: float) -> Any:
+    from ocr_tools.pytorch_models.pytorch_paddle import PytorchPaddleOCR
+
+    return PytorchPaddleOCR(
+        lang="ch",
+        det_model_path=str(MODEL_DIR / "ch_PP-OCRv5_det_infer.pth"),
+        rec_model_path=str(MODEL_DIR / "ch_PP-OCRv4_rec_server_doc_infer.pth"),
+        det_db_box_thresh=det_thresh,
+    )
+
+
+def main() -> None:
+    if not CELL121.is_file():
+        raise FileNotFoundError(CELL121)
+    raw = cv2.imread(str(CELL121))
+    OUT_DIR.mkdir(parents=True, exist_ok=True)
+
+    methods = ["threshold", "masked_adaptive"]
+    thresholds = [155, 165, 170, 175, 180, None]
+    contrasts = [False, True]
+    upscales = [64, 96, 128, 192]
+    det_threshs = [0.2, 0.3, 0.4, 0.5]
+    ocr_modes = [("det_rec", True, True), ("whole_rec", False, True)]
+
+    results: List[Dict[str, Any]] = []
+    hits: List[Dict[str, Any]] = []
+    engines: Dict[float, Any] = {}
+
+    total = 0
+    for method, thresh, contrast, upscale, det_th in product(
+        methods, thresholds, contrasts, upscales, det_threshs
+    ):
+        if method != "threshold" and thresh is not None:
+            continue
+        if det_th not in engines:
+            print(f"加载 OCR det_db_box_thresh={det_th} ...")
+            engines[det_th] = _make_engine(det_th)
+
+        img = _preprocess(
+            raw, method=method, thresh=thresh, contrast=contrast, upscale=upscale
+        )
+        tag = (
+            f"{method}_t{thresh or 'd'}_c{int(contrast)}_u{upscale}_det{det_th}"
+        )
+        cv2.imwrite(str(OUT_DIR / f"{tag}.png"), img)
+
+        for mode_name, det, rec in ocr_modes:
+            total += 1
+            ocr = _ocr(engines[det_th], img, det=det, rec=rec)
+            row = {
+                "tag": tag,
+                "method": method,
+                "threshold": thresh,
+                "contrast": contrast,
+                "upscale": upscale,
+                "det_db_box_thresh": det_th,
+                "ocr_mode": mode_name,
+                **ocr,
+            }
+            results.append(row)
+            t = row.get("text", "")
+            if TARGET in t or (len(t) >= 6 and t.isdigit()):
+                row["match"] = "full" if TARGET in t else "partial"
+                hits.append(row)
+                print(f"HIT [{row['match']}] {mode_name} {tag} -> {t!r}")
+
+    # 原图对照
+    for det_th in [0.3, 0.5]:
+        if det_th not in engines:
+            engines[det_th] = _make_engine(det_th)
+        for mode_name, det, rec in ocr_modes:
+            ocr = _ocr(engines[det_th], _upscale(raw, 128), det=det, rec=rec)
+            row = {
+                "tag": "raw_upscale128",
+                "det_db_box_thresh": det_th,
+                "ocr_mode": mode_name,
+                **ocr,
+            }
+            results.append(row)
+            if TARGET in (row.get("text") or ""):
+                hits.append(row)
+
+    report = {
+        "input": str(CELL121),
+        "target": TARGET,
+        "total_trials": total,
+        "hits": hits,
+        "all_results": results,
+    }
+    out_json = OUT_DIR / "cell121_sweep_report.json"
+    out_json.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
+
+    print(f"\n完成 {total} 次 OCR 试验,命中 {len(hits)} 条")
+    print(f"报告: {out_json}")
+    if hits:
+        print("\n最佳命中:")
+        for h in hits[:10]:
+            print(f"  {h.get('ocr_mode')} {h.get('tag')}: {h.get('text')!r}")
+    else:
+        print("未出现完整 20240927,请查看 cell121_sweep/*.png 与 report 中 partial 结果")
+
+
+if __name__ == "__main__":
+    main()

+ 385 - 0
ocr_tools/cell_preprocess_lab/cell_preprocess_lab.py

@@ -0,0 +1,385 @@
+#!/usr/bin/env python3
+"""
+单元格裁剪图预处理实验:去水印 →(可选去噪/对比度)→ 放大 → OCR。
+
+与 pipeline 二次 OCR 对齐,使用 ocr_tools.pytorch_models.PytorchPaddleOCR(非 paddleocr pip 包)。
+
+用法:
+    python cell_preprocess_lab.py cell219.png -o /tmp/cell_lab
+    python cell_preprocess_lab.py /path/to/tablecell_ocr/ -o /tmp/batch --compare-methods
+    python cell_preprocess_lab.py cell217.png -o /tmp/out --denoise --contrast
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import sys
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+
+import cv2
+import numpy as np
+import yaml
+
+_repo_root = Path(__file__).resolve().parents[2]
+_parser_root = _repo_root / "ocr_tools" / "universal_doc_parser"
+for _p in (_repo_root, _parser_root):
+    if str(_p) not in sys.path:
+        sys.path.insert(0, str(_p))
+
+from ocr_utils.watermark import WatermarkProcessor, merge_watermark_config
+from ocr_utils.watermark.contrast import apply_contrast_enhancement_config
+
+_DEFAULT_CONFIG = (
+    _repo_root
+    / "ocr_tools/universal_doc_parser/config/bank_statement_yusys_local.yaml"
+)
+_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff", ".webp"}
+
+_OCR_ENGINE: Any = None
+_CONFIG_PATH: Optional[Path] = None
+
+
+def _get_ocr_engine() -> Any:
+    """与 main_v2 pipeline 相同:ModelFactory → MinerU OCR atom model。"""
+    global _OCR_ENGINE
+    if _OCR_ENGINE is not None:
+        return _OCR_ENGINE
+
+    cfg_path = _CONFIG_PATH or _DEFAULT_CONFIG
+    if not cfg_path.is_file():
+        raise FileNotFoundError(f"场景配置不存在: {cfg_path}")
+
+    with open(cfg_path, encoding="utf-8") as f:
+        raw = yaml.safe_load(f) or {}
+    ocr_cfg = raw.get("ocr_recognition") or {}
+
+    errors: List[str] = []
+    try:
+        from core.model_factory import ModelFactory
+
+        recognizer = ModelFactory.create_ocr_recognizer(ocr_cfg)
+        engine = getattr(recognizer, "ocr_model", recognizer)
+        if engine is None:
+            raise RuntimeError("ocr_model 未初始化")
+        _OCR_ENGINE = engine
+        return _OCR_ENGINE
+    except Exception as e:
+        errors.append(f"ModelFactory/MinerU: {e}")
+
+    det_path = os.environ.get("OCR_DET_MODEL_PATH")
+    rec_path = os.environ.get("OCR_REC_MODEL_PATH")
+    if det_path or rec_path:
+        try:
+            from ocr_tools.pytorch_models.pytorch_paddle import PytorchPaddleOCR
+
+            kw: Dict[str, Any] = {"lang": ocr_cfg.get("language", "ch")}
+            if det_path:
+                kw["det_model_path"] = det_path
+            if rec_path:
+                kw["rec_model_path"] = rec_path
+            _OCR_ENGINE = PytorchPaddleOCR(**kw)
+            return _OCR_ENGINE
+        except Exception as e2:
+            errors.append(f"PytorchPaddleOCR(env paths): {e2}")
+
+    try:
+        from paddleocr import PaddleOCR
+
+        _OCR_ENGINE = PaddleOCR(use_angle_cls=False, lang="ch", show_log=False)
+        return _OCR_ENGINE
+    except Exception as e3:
+        errors.append(
+            f"paddleocr pip(可选 pip install paddleocr): {e3}"
+        )
+
+    raise ImportError(
+        "无法加载 OCR 引擎。请在 mineru 环境中运行,并确保场景 YAML 中 ocr_recognition "
+        f"可正常初始化(与 main_v2 相同)。详情:\n  - " + "\n  - ".join(errors)
+    )
+
+
+def _parse_rec_item(rec_item: Any) -> Tuple[str, float]:
+    if rec_item is None:
+        return "", 0.0
+    if isinstance(rec_item, tuple) and len(rec_item) >= 2:
+        txt = str(rec_item[0] or "").strip()
+        sc = float(rec_item[1] or 0.0)
+        return txt, 0.0 if not txt else sc
+    if isinstance(rec_item, list) and len(rec_item) >= 2:
+        if isinstance(rec_item[0], (list, tuple)):
+            parts: List[str] = []
+            scores: List[float] = []
+            for item in rec_item:
+                t, s = _parse_rec_item(item)
+                if t:
+                    parts.append(t)
+                    scores.append(s)
+            if not parts:
+                return "", 0.0
+            combined = "".join(parts)
+            n = sum(len(t) for t in parts)
+            return combined, sum(len(t) * s for t, s in zip(parts, scores)) / max(n, 1)
+        txt = str(rec_item[0] or "").strip()
+        sc = float(rec_item[1] or 0.0)
+        return txt, 0.0 if not txt else sc
+    return "", 0.0
+
+
+def _ocr_cell(img_bgr: np.ndarray, *, det: bool = True, rec: bool = True) -> Dict[str, Any]:
+    """整格 det+rec,与 TextFiller._recognize_whole_cell 类似。"""
+    try:
+        engine = _get_ocr_engine()
+        # paddleocr.PaddleOCR 与 PytorchPaddleOCR / MinerU 接口略有差异
+        if engine.__class__.__name__ == "PaddleOCR":
+            rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
+            res = engine.ocr(rgb, cls=False)
+            lines = []
+            if res and res[0]:
+                for item in res[0]:
+                    if item and len(item) >= 2:
+                        text, score = str(item[1][0]), float(item[1][1])
+                        lines.append({"text": text, "score": score})
+            text = "".join(ln["text"] for ln in lines)
+            sc = (
+                sum(len(ln["text"]) * ln["score"] for ln in lines) / max(len(text), 1)
+                if lines
+                else 0.0
+            )
+            return {"text": text, "score": sc, "lines": lines, "backend": "paddleocr"}
+        res = engine.ocr(img_bgr, det=det, rec=rec)
+        lines: List[Dict[str, Any]] = []
+        if res and res[0]:
+            for item in res[0]:
+                if not item or len(item) < 2:
+                    continue
+                box, rec_part = item[0], item[1]
+                text, score = _parse_rec_item(rec_part)
+                if text:
+                    lines.append({"text": text, "score": score, "box": box})
+        text = "".join(ln["text"] for ln in lines)
+        score = (
+            sum(len(ln["text"]) * ln["score"] for ln in lines) / max(len(text), 1)
+            if lines
+            else 0.0
+        )
+        return {"text": text, "score": score, "lines": lines, "mode": f"det={det},rec={rec}"}
+    except Exception as e:
+        return {
+            "text": "",
+            "score": 0.0,
+            "lines": [],
+            "error": str(e),
+            "hint": "使用: conda activate mineru && python cell_preprocess_lab.py ...",
+        }
+
+
+def _median_denoise(img: np.ndarray) -> np.ndarray:
+    return cv2.medianBlur(img, 3)
+
+
+def _upscale_min_side(img: np.ndarray, min_side: int = 64) -> np.ndarray:
+    h, w = img.shape[:2]
+    if h >= min_side and w >= min_side:
+        return img
+    scale = max(min_side / max(h, 1), min_side / max(w, 1), 1.0)
+    return cv2.resize(img, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
+
+
+def run_cell_pipeline(
+    raw_bgr: np.ndarray,
+    *,
+    wm_method: str = "masked_adaptive",
+    apply_denoise: bool = False,
+    apply_contrast: bool = False,
+    upscale_min: int = 64,
+) -> Tuple[Dict[str, np.ndarray], List[str]]:
+    stages: Dict[str, np.ndarray] = {"00_raw": raw_bgr.copy()}
+    order: List[str] = ["00_raw"]
+
+    wm_cfg = merge_watermark_config("cell", {"enabled": True, "method": wm_method})
+    proc = WatermarkProcessor(wm_cfg, scope="cell")
+    img, _ = proc.process(raw_bgr, force=True)
+    stages["01_wm"] = img
+    order.append("01_wm")
+
+    step = 2
+    if apply_denoise:
+        img = _median_denoise(img)
+        key = f"{step:02d}_denoise"
+        stages[key] = img.copy()
+        order.append(key)
+        step += 1
+
+    if apply_contrast:
+        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img
+        ce = dict(wm_cfg.get("contrast_enhancement") or {})
+        ce["enabled"] = True
+        gray = apply_contrast_enhancement_config(gray, ce)
+        img = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
+        key = f"{step:02d}_contrast"
+        stages[key] = img.copy()
+        order.append(key)
+        step += 1
+
+    img = _upscale_min_side(img, upscale_min)
+    key = f"{step:02d}_upscale"
+    stages[key] = img
+    order.append(key)
+    return stages, order
+
+
+def process_one(
+    input_path: Path,
+    output_dir: Path,
+    *,
+    compare_methods: bool = False,
+    run_ocr: bool = True,
+    apply_denoise: bool = False,
+    apply_contrast: bool = False,
+) -> Dict[str, Any]:
+    output_dir.mkdir(parents=True, exist_ok=True)
+    raw = cv2.imread(str(input_path))
+    if raw is None:
+        raise FileNotFoundError(f"无法读取: {input_path}")
+
+    report: Dict[str, Any] = {
+        "input": str(input_path),
+        "pipeline_note": (
+            "默认 01_wm→upscale,不做 median 去噪(小格易糊笔画)。"
+            "可用 --denoise / --contrast 对比。"
+        ),
+        "stages": {},
+    }
+    methods = ["threshold", "masked_adaptive"] if compare_methods else ["masked_adaptive"]
+
+    ocr_keys = {"00_raw", "01_wm"}
+    # 总是 OCR 最终 upscale 阶段
+    for method in methods:
+        sub_dir = output_dir / method if compare_methods else output_dir
+        sub_dir.mkdir(parents=True, exist_ok=True)
+        stage_imgs, order = run_cell_pipeline(
+            raw,
+            wm_method=method,
+            apply_denoise=apply_denoise,
+            apply_contrast=apply_contrast,
+        )
+        method_report: Dict[str, Any] = {"files": {}, "ocr": {}}
+        final_key = order[-1]
+        for key in order:
+            out_path = sub_dir / f"{input_path.stem}_{key}.png"
+            cv2.imwrite(str(out_path), stage_imgs[key])
+            method_report["files"][key] = str(out_path)
+            if run_ocr and (key in ocr_keys or key == final_key):
+                method_report["ocr"][key] = _ocr_cell(stage_imgs[key])
+        if run_ocr:
+            method_report["ocr_recommended"] = method_report["ocr"].get(
+                "01_wm"
+            ) or method_report["ocr"].get(final_key)
+        report["stages"][method] = method_report
+
+    report_path = output_dir / f"{input_path.stem}_lab_report.json"
+    with open(report_path, "w", encoding="utf-8") as f:
+        json.dump(report, f, ensure_ascii=False, indent=2)
+    report["report_path"] = str(report_path)
+    return report
+
+
+def collect_inputs(path: Path) -> List[Path]:
+    if path.is_file():
+        return [path]
+    files: List[Path] = []
+    for p in sorted(path.iterdir()):
+        if p.suffix.lower() in _IMAGE_SUFFIXES and "cell" in p.name:
+            files.append(p)
+    return files
+
+
+def main() -> None:
+    global _CONFIG_PATH
+    parser = argparse.ArgumentParser(description="单元格预处理实验 lab")
+    parser.add_argument("input", type=Path, help="单元格 PNG 或 tablecell_ocr 目录")
+    parser.add_argument("-o", "--output", type=Path, required=True, help="输出目录")
+    parser.add_argument(
+        "-c",
+        "--config",
+        type=Path,
+        default=_DEFAULT_CONFIG,
+        help="场景 YAML(用于加载与 pipeline 相同的 OCR)",
+    )
+    parser.add_argument(
+        "--compare-methods",
+        action="store_true",
+        help="对比 threshold 与 masked_adaptive",
+    )
+    parser.add_argument("--no-ocr", action="store_true", help="跳过 OCR 探测")
+    parser.add_argument(
+        "--denoise",
+        action="store_true",
+        help="在去水印后增加 median 去噪(默认关闭,小图易损笔画)",
+    )
+    parser.add_argument(
+        "--contrast",
+        action="store_true",
+        help="在去噪/放大前增加 text_restore 对比度",
+    )
+    parser.add_argument(
+        "--det-model-path",
+        type=Path,
+        default=None,
+        help="覆盖检测模型 .pth(或设环境变量 OCR_DET_MODEL_PATH)",
+    )
+    parser.add_argument(
+        "--rec-model-path",
+        type=Path,
+        default=None,
+        help="覆盖识别模型 .pth(或设环境变量 OCR_REC_MODEL_PATH)",
+    )
+    args = parser.parse_args()
+    _CONFIG_PATH = args.config
+    if args.det_model_path:
+        os.environ["OCR_DET_MODEL_PATH"] = str(args.det_model_path)
+    if args.rec_model_path:
+        os.environ["OCR_REC_MODEL_PATH"] = str(args.rec_model_path)
+
+    inputs = collect_inputs(args.input)
+    if not inputs:
+        print(f"未找到输入: {args.input}")
+        sys.exit(1)
+
+    for inp in inputs:
+        out = args.output / inp.stem if len(inputs) > 1 else args.output
+        report = process_one(
+            inp,
+            out,
+            compare_methods=args.compare_methods,
+            run_ocr=not args.no_ocr,
+            apply_denoise=args.denoise,
+            apply_contrast=args.contrast,
+        )
+        print(json.dumps(report, ensure_ascii=False, indent=2))
+
+
+if __name__ == "__main__":
+    if len(sys.argv) == 1:
+        print("ℹ️  未提供命令行参数,使用默认配置运行...")
+        default_config = {
+            # "input": "/Users/zhch158/workspace/data/流水分析/彭_广东兴宁农村商业银行/bank_statement_yusys_local/debug/table_recognition_wired/tablecell_ocr/彭_广东兴宁农村商业银行_page_002_0/cell029_whole_78.0111.0111.078.0司.png",
+            "input": "/Users/zhch158/workspace/data/流水分析/彭_广东兴宁农村商业银行/bank_statement_yusys_local/debug/table_recognition_wired/tablecell_ocr/彭_广东兴宁农村商业银行_page_002_0/cell121_empty_empty.png",
+            # "input": "/Users/zhch158/workspace/data/流水分析/彭_广东兴宁农村商业银行/bank_statement_yusys_local/debug/table_recognition_wired/tablecell_ocr/彭_广东兴宁农村商业银行_page_002_0/cell217_lines_取款.png",
+            # "input": "/Users/zhch158/workspace/data/流水分析/彭_广东兴宁农村商业银行/bank_statement_yusys_local/debug/table_recognition_wired/tablecell_ocr/彭_广东兴宁农村商业银行_page_002_0/cell219_empty_empty.png",
+            "output": "./output/彭_广东兴宁农村商业银行",
+            "compare-methods": True,
+        }
+        sys.argv = [sys.argv[0], default_config["input"]]
+        for key, value in default_config.items():
+            if key == "input":
+                continue
+            flag = f"--{key.replace('_', '-')}"
+            if isinstance(value, bool) and value:
+                sys.argv.append(flag)
+            elif not isinstance(value, bool):
+                sys.argv.extend([flag, str(value)])
+
+    sys.exit(main())

+ 55 - 29
ocr_tools/remove_watermark_tool/remove_watermark.py

@@ -45,14 +45,15 @@ if str(_repo_root) not in sys.path:
     sys.path.insert(0, str(_repo_root))
 
 from loguru import logger
-from ocr_utils.watermark_utils import (
+from ocr_utils.watermark import (
+    WatermarkProcessor,
     detect_watermark,
-    remove_watermark_from_image_rgb,
+    merge_watermark_config,
+    remove_txt_pdf_watermark,
     render_watermark_mask_overlay,
-    save_watermark_removal_debug,
     save_watermark_mask_debug_layers,
+    save_watermark_removal_debug,
     scan_pdf_watermark_xobjs,
-    remove_txt_pdf_watermark,
 )
 
 # 支持的图片后缀(小写)
@@ -72,6 +73,7 @@ class WatermarkToolSettings:
     morph_close_kernel: int = 0
     dpi: int = 200
     method: str = "threshold"
+    scope: str = "page"
     contrast_enhancement: Optional[Dict[str, Any]] = None
     debug_options: Optional[Dict[str, Any]] = None
     watermark_enabled: bool = True
@@ -83,11 +85,15 @@ class WatermarkToolSettings:
         return str(opts.get("image_format") or "png").lstrip(".")
 
 
-def load_watermark_settings(config_path: Path) -> WatermarkToolSettings:
+def load_watermark_settings(
+    config_path: Path,
+    *,
+    scope: str = "page",
+) -> WatermarkToolSettings:
     """
     从 universal_doc_parser 场景配置读取 preprocessor.watermark_removal 与 input.dpi。
 
-    不依赖完整 ConfigManager,避免仅调试水印时强依赖 layout/ocr 等段。
+    scope=cell 时读取 table_recognition_wired.second_pass_ocr.cell_preprocess.watermark
     """
     config_path = Path(config_path)
     if not config_path.is_file():
@@ -96,25 +102,33 @@ def load_watermark_settings(config_path: Path) -> WatermarkToolSettings:
     with open(config_path, encoding="utf-8") as f:
         raw = yaml.safe_load(f) or {}
 
-    preprocessor = raw.get("preprocessor") or {}
-    wm = preprocessor.get("watermark_removal") or {}
     input_cfg = raw.get("input") or {}
+    if scope == "cell":
+        wired = raw.get("table_recognition_wired") or {}
+        sp = wired.get("second_pass_ocr") or {}
+        cpp = sp.get("cell_preprocess") or {}
+        wm_user = cpp.get("watermark") or {}
+        wm_full = merge_watermark_config("cell", wm_user)
+    else:
+        preprocessor = raw.get("preprocessor") or {}
+        wm_user = preprocessor.get("watermark_removal") or {}
+        wm_full = merge_watermark_config("page", wm_user)
 
-    contrast = wm.get("contrast_enhancement")
+    contrast = wm_full.get("contrast_enhancement")
     if contrast is not None and not isinstance(contrast, dict):
         contrast = None
 
-    wm_full = copy.deepcopy(wm)
     return WatermarkToolSettings(
-        threshold=int(wm.get("threshold", 160)),
-        morph_close_kernel=int(wm.get("morph_close_kernel", 0)),
+        threshold=int(wm_full.get("threshold", 160)),
+        morph_close_kernel=int(wm_full.get("morph_close_kernel", 0)),
         dpi=int(input_cfg.get("dpi", 200)),
-        method=str(wm.get("method") or "threshold"),
+        method=str(wm_full.get("method") or "masked_adaptive"),
+        scope=scope,
         contrast_enhancement=copy.deepcopy(contrast) if contrast else None,
-        debug_options=copy.deepcopy(wm.get("debug_options"))
-        if wm.get("debug_options")
+        debug_options=copy.deepcopy(wm_full.get("debug_options"))
+        if wm_full.get("debug_options")
         else None,
-        watermark_enabled=bool(wm.get("enabled", True)),
+        watermark_enabled=bool(wm_full.get("enabled", True)),
         watermark_config=wm_full,
     )
 
@@ -122,6 +136,7 @@ def load_watermark_settings(config_path: Path) -> WatermarkToolSettings:
 def resolve_watermark_settings(
     config_path: Path,
     *,
+    scope: str = "page",
     threshold: Optional[int] = None,
     morph_close_kernel: Optional[int] = None,
     dpi: Optional[int] = None,
@@ -130,7 +145,7 @@ def resolve_watermark_settings(
     method: Optional[str] = None,
 ) -> WatermarkToolSettings:
     """加载配置并应用命令行覆盖。"""
-    settings = load_watermark_settings(config_path)
+    settings = load_watermark_settings(config_path, scope=scope)
 
     if threshold is not None:
         settings.threshold = threshold
@@ -176,21 +191,19 @@ def _apply_image_watermark_removal(
     contrast_enhancement: Optional[Dict[str, Any]] = None,
     apply_watermark_removal: bool = True,
     removal_debug: Optional[Dict[str, Any]] = None,
+    scope: str = "page",
 ) -> np.ndarray:
     """与 universal_doc_parser 一致的 RGB 去水印 + 可选对比度增强。"""
-    wm_cfg = _watermark_removal_cfg_for_method(settings, settings.method)
-    return np.asarray(
-        remove_watermark_from_image_rgb(
-            img_np,
-            threshold=settings.threshold,
-            morph_close_kernel=settings.morph_close_kernel,
-            contrast_enhancement=contrast_enhancement,
-            apply_watermark_removal=apply_watermark_removal,
-            watermark_removal_cfg=wm_cfg,
-            removal_debug=removal_debug,
-            return_pil=False,
-        )
+    proc = WatermarkProcessor(settings.watermark_config or {}, scope=scope)  # type: ignore[arg-type]
+    apply_contrast = contrast_enhancement is not None
+    cleaned, _ = proc.process(
+        img_np,
+        apply_removal=apply_watermark_removal,
+        contrast_override=contrast_enhancement,
+        removal_debug=removal_debug,
+        force=scope == "cell",
     )
+    return np.asarray(cleaned)
 
 
 def _active_contrast_enhancement(
@@ -418,6 +431,7 @@ def process_document(
                     contrast_enhancement=contrast_enhancement,
                     apply_watermark_removal=apply_watermark_removal,
                     removal_debug=removal_dbg,
+                    scope=settings.scope,
                 )
                 if save_debug:
                     _maybe_save_watermark_debug(
@@ -459,6 +473,7 @@ def process_document(
             contrast_enhancement=contrast_enhancement,
             apply_watermark_removal=apply_watermark_removal,
             removal_debug=removal_dbg,
+            scope=settings.scope,
         )
         if save_debug:
             _maybe_save_watermark_debug(
@@ -519,6 +534,7 @@ def preview_page(
         settings=settings,
         contrast_enhancement=contrast,
         apply_watermark_removal=settings.watermark_enabled,
+        scope=settings.scope,
     )
     cleaned = cv2.cvtColor(cleaned_rgb, cv2.COLOR_BGR2GRAY)
 
@@ -584,11 +600,13 @@ def compare_watermark_methods(
         sub = copy.deepcopy(settings)
         sub.method = method
         dbg: Dict[str, Any] = {}
+        sub.watermark_config = _watermark_removal_cfg_for_method(sub, method)
         out = _apply_image_watermark_removal(
             img_rgb,
             settings=sub,
             contrast_enhancement=contrast,
             removal_debug=dbg,
+            scope=settings.scope,
         )
         out_rgb = cv2.cvtColor(out, cv2.COLOR_BGR2RGB)
         results[method] = out_rgb
@@ -726,6 +744,13 @@ def main():
         help="覆盖 watermark_removal.method",
     )
     parser.add_argument(
+        "--scope",
+        type=str,
+        default="page",
+        choices=["page", "cell"],
+        help="page=页级 preprocessor;cell=二次 OCR 单元格 preset",
+    )
+    parser.add_argument(
         "--compare-methods",
         action="store_true",
         help="对比 threshold 与 masked_adaptive,输出三联图到 -o 目录",
@@ -736,6 +761,7 @@ def main():
     try:
         settings = resolve_watermark_settings(
             args.config,
+            scope=args.scope,
             threshold=args.threshold,
             morph_close_kernel=args.morph_kernel,
             dpi=args.dpi,

+ 8 - 0
ocr_tools/universal_doc_parser/config/bank_statement_glm_vl_local.yaml

@@ -180,6 +180,14 @@ table_recognition_wired:
     # 功能开关
     enable_ocr_compensation: true      # 启用OCR边缘补偿
 
+
+  # 单元格二次 OCR(det 分行 + 整格兜底 + 低分块过滤)
+  second_pass_ocr:
+    line_min_score: 0.8
+    drop_low_score_blocks: true
+    whole_cell_fallback: true
+    prefer_whole_on_tie: true
+
   # Debug 可视化配置
   debug_options:
     enabled: false              # 由命令行 --debug / --debug-table 统一控制

+ 8 - 0
ocr_tools/universal_doc_parser/config/bank_statement_paddle_vl_local.yaml

@@ -180,6 +180,14 @@ table_recognition_wired:
     # 功能开关
     enable_ocr_compensation: true      # 启用OCR边缘补偿
 
+
+  # 单元格二次 OCR(det 分行 + 整格兜底 + 低分块过滤)
+  second_pass_ocr:
+    line_min_score: 0.8
+    drop_low_score_blocks: true
+    whole_cell_fallback: true
+    prefer_whole_on_tie: true
+
   # Debug 可视化配置
   debug_options:
     enabled: false              # 由命令行 --debug / --debug-table 统一控制

+ 8 - 0
ocr_tools/universal_doc_parser/config/bank_statement_smart_router.yaml

@@ -148,6 +148,14 @@ table_recognition_wired:
   # 是否启用倾斜矫正
   enable_deskew: true
 
+
+  # 单元格二次 OCR(det 分行 + 整格兜底 + 低分块过滤)
+  second_pass_ocr:
+    line_min_score: 0.8
+    drop_low_score_blocks: true
+    whole_cell_fallback: true
+    prefer_whole_on_tie: true
+
   # Debug 可视化配置
   debug_options:
     enabled: true              # 由命令行 --debug / --debug-table 统一控制

+ 51 - 69
ocr_tools/universal_doc_parser/config/bank_statement_yusys_local.yaml

@@ -22,79 +22,24 @@ preprocessor:
     model_dir: null  # 使用默认路径
   unwarping:
     enabled: false
-  # -------------------------------------------------------
-  # 水印去除配置(适用于银行流水浅色斜向文字水印)
-  # -------------------------------------------------------
+  # 页级水印(细参见 ocr_utils/watermark/presets.py PAGE_WATERMARK_PRESETS)
   watermark_removal:
-    enabled: false           # 是否启用水印去除
-    method: masked_adaptive # threshold | masked | masked_adaptive
-    threshold: 175          # 全局阈值或掩膜失败时的回退阈值(140-180)
-    morph_close_kernel: 0   # 去水印后灰度图闭运算,0 跳过
-    mask:
-      mask_mode: light_on_white     # light_on_white | diagonal_midtone
-      text_protect_gray_max: 130    # gray<=130 正文硬保护,永不置白
-      light_gray_low: 236           # 浅色候选(geom_candidate 用)
-      light_gray_high: 253
-      whiten_gray_low: 200          # 几何带内置白灰度下限(方案 E,低于 candidate)
-      direction_filter: hough       # hough=方案C斜向线段 | block=旧分块梯度
-      morph_close_kernel: 0
-      morph_dilate_kernel: 0
-      min_component_area: 200
-      debug_block_maps: true        # 输出 diag/hv 热力图
-      debug_block_size: 48
-      hough_midtone_low: 200        # Canny 仅在中间调带
-      hough_midtone_high: 254
-      hough_canny_low: 30
-      hough_canny_high: 100
-      hough_threshold: 25
-      hough_min_line_length: 35
-      hough_max_line_gap: 18
-      hough_line_thickness: 12
-      hough_band_dilate_radius: 16
-      hough_use_angle_statistics: true   # 角度直方图统计主峰
-      hough_angle_tolerance: 5.0       # 与主峰角度差≤该值(度)
-      hough_secondary_peak_ratio: 0.35 # 次峰相对主峰权重
-      hough_min_length_percentile: 25.0  # 过滤短线段
-      midtone_low: 95
-      midtone_high: 235           # diagonal_midtone 模式用
-      remove_horizontal_vertical: true
-      diagonal_enhance: true
-      diagonal_kernel_length: 25
-      horizontal_kernel_length: 35
-      vertical_kernel_length: 35
-      morph_open_kernel: 2
-      dmorph_close_kernel: 3
-      text_protect_percentile: 10.0
-      background_threshold: 248
-      seal_protect: true
-    adaptive:
-      whiten_mode: mask_fill       # mask_fill=掩膜内一律置白 | threshold_in_mask
-      text_percentile: 10.0
-      watermark_percentile: 70.0   # threshold_in_mask 时生效
-      background_percentile: 95.0
-      background_threshold: 248
-      wm_margin: 12
-      text_protect_max: 120
-    # 去水印后对比度增强(text_restore 将笔画拉深,比全局 gamma 更接近原图)
+    enabled: false
+    detect_before_remove: true
+    method: masked_adaptive   # threshold | masked | masked_adaptive
+    threshold: 175
+    morph_close_kernel: 0
     contrast_enhancement:
       enabled: true
-      method: text_restore   # text_restore | clahe | gamma | linear
-      text_black_target: 85  # 略提高,减轻去水印后笔画被拉花(原 75 过深)
-      background_threshold: 248
-      text_lo_percentile: 1.0
-      text_hi_percentile: 99.0
-      gamma: 0.75            # method=gamma 时生效
-      clip_limit: 2.0        # method=clahe
-      tile_grid_size: 8
-      black_percentile: 2.0  # method=linear
-      white_percentile: 98.0
+      method: text_restore
+      text_black_target: 85
     debug_options:
-      enabled: false              # 由命令行 --debug / --debug-layout 统一控制
-      output_dir: null            # null 时使用 pipeline 输出目录
-      prefix: ""                  # 文件名前缀(运行时注入 page_name)
-      subdir: watermark_removal   # 输出至 debug/watermark_removal/
-      save_compare: true          # 保存左右对比图 *_watermark_compare.*
-      image_format: "png"         # jpg / png
+      enabled: false
+      output_dir: null
+      prefix: ""
+      subdir: watermark_removal
+      save_compare: true
+      image_format: "png"
 
 # ============================================================
 # Layout 检测配置 - 智能路由器(按场景直接选择模型)
@@ -224,6 +169,43 @@ table_recognition_wired:
     # 功能开关
     enable_ocr_compensation: true      # 启用OCR边缘补偿
 
+  # 单元格二次 OCR(det 分行 + 整格/条带兜底 + 低分笔画增强重试)
+  second_pass_ocr:
+    reocr_mode: bank_statement       # 表体空单元必跑 + 同行多数非空则空格也跑
+    header_row: 0                    # 表头行号(0=首行)
+    row_peer_min_nonempty: 5         # 同行至少 N 个非空格时,本格空也触发二次 OCR
+    line_min_score: 0.8              # 低于此分的分行从文本与计分中丢弃
+    drop_low_score_blocks: true
+    whole_cell_fallback: true        # 整格 det=False 兜底 + 条带扫描
+    prefer_whole_on_tie: true
+    whole_longer_min_extra_chars: 2  # 整格/条带文本比分行多长至少 N 字则优先
+    strip_fallback_aspect_ratio: 1.8 # 高/宽>=该值且仅检出<=1行时滑动条带分行
+    cell_preprocess:
+      watermark:
+        enabled: true
+        method: masked_adaptive
+      denoise:
+        enabled: false   # 小格 median 易糊笔画;lab 用 --denoise 对比
+        method: median
+      contrast:
+        enabled: false
+        method: text_restore
+        text_black_target: 88
+      light:
+        upscale_min_side: 64
+      enhance_retry:
+        enabled: false
+        score_below: 0.90
+        min_chars: 4
+        short_text_in_tall_cell: true
+        contrast:
+          enabled: true
+          method: text_restore
+          text_black_target: 75
+        sharpen:
+          enabled: false
+          amount: 0.3
+
   # Debug 可视化配置
   debug_options:
     enabled: false              # 由命令行 --debug / --debug-table 统一控制

+ 9 - 1
ocr_tools/universal_doc_parser/config/bank_statement_yusys_v3.yaml

@@ -19,7 +19,7 @@ preprocessor:
   # -------------------------------------------------------
   watermark_removal:
     enabled: true           # 是否启用水印去除
-    threshold: 160          # 灰度阈值(140-180):高于此值视为水印变白
+    threshold: 175          # 灰度阈值(140-180):高于此值视为水印变白
                             # 值越大保守(残留水印),值越小激进(损失浅色正文)
     morph_close_kernel: 0   # 形态学闭运算核大小(像素),默认的 morph_kernel 改为 0(非二值图像时形态学闭运算会适得其反)
 
@@ -106,6 +106,14 @@ table_recognition_wired:
     # 功能开关
     enable_ocr_compensation: true      # 启用OCR边缘补偿
 
+
+  # 单元格二次 OCR(det 分行 + 整格兜底 + 低分块过滤)
+  second_pass_ocr:
+    line_min_score: 0.8
+    drop_low_score_blocks: true
+    whole_cell_fallback: true
+    prefer_whole_on_tie: true
+
   # Debug 可视化配置(与 MinerUWiredTableRecognizer.DebugOptions 对齐)
   # 默认关闭。开启后将保存:表格线、连通域、逻辑网格结构、文本覆盖可视化。
   debug_options:

+ 8 - 0
ocr_tools/universal_doc_parser/config/bank_statement_yusys_v4.yaml

@@ -179,6 +179,14 @@ table_recognition_wired:
     # 功能开关
     enable_ocr_compensation: true      # 启用OCR边缘补偿
 
+
+  # 单元格二次 OCR(det 分行 + 整格兜底 + 低分块过滤)
+  second_pass_ocr:
+    line_min_score: 0.8
+    drop_low_score_blocks: true
+    whole_cell_fallback: true
+    prefer_whole_on_tie: true
+
   # Debug 可视化配置
   debug_options:
     enabled: false              # 由命令行 --debug / --debug-table 统一控制

+ 3 - 1
ocr_tools/universal_doc_parser/core/table_coordinate_utils.py

@@ -68,7 +68,9 @@ class TableCoordinateUtils:
         Returns:
             转换后的字典,或 None(如果无效)
         """
-        if not ocr_poly or not text:
+        # if not ocr_poly or not text:
+        # 如果没有 ocr_poly,则返回 None, 文字为空字符串也需要返回转换后的字典
+        if not ocr_poly:
             return None
         
         poly = []

+ 24 - 28
ocr_tools/universal_doc_parser/models/adapters/mineru_adapter.py

@@ -18,7 +18,7 @@ if str(ocr_platform_root) not in sys.path:
 
 from .base import BasePreprocessor, BaseLayoutDetector, BaseVLRecognizer, BaseOCRRecognizer
 from ocr_utils.coordinate_utils import CoordinateUtils
-from ocr_utils.watermark_utils import remove_watermark_from_image_rgb
+from ocr_utils.watermark import WatermarkProcessor
 
 # 导入MinerU组件
 try:
@@ -41,6 +41,11 @@ class MinerUPreprocessor(BasePreprocessor):
             
         self.atom_model_manager = AtomModelSingleton()
         self.orientation_classifier = None
+        wm_user = config.get("watermark_removal") or {}
+        self._wm_processor = WatermarkProcessor.from_user_config(
+            wm_user if isinstance(wm_user, dict) else {},
+            scope="page",
+        )
         
     def initialize(self):
         """初始化预处理组件"""
@@ -63,46 +68,37 @@ class MinerUPreprocessor(BasePreprocessor):
         if isinstance(image, Image.Image):
             image = np.array(image)
 
-        watermark_cfg = self.config.get('watermark_removal', {})
-        wm_enabled = bool(watermark_cfg.get('enabled', False))
-        # 对比度增强只有在水印去除之后才能生效
-        contrast_cfg = watermark_cfg.get('contrast_enhancement', {})
-        contrast_enabled = bool(
-            contrast_cfg.get('enabled', False) if isinstance(contrast_cfg, dict) else False
-        )
+        if not self._wm_processor.enabled:
+            return image
 
-        if not wm_enabled:
+        page_name = getattr(self, "page_name", None) or "?"
+        if not self._wm_processor.should_apply(image):
+            logger.info(
+                f"未检测到水印,跳过去水印 (page={page_name}, detect_before_remove=true)"
+            )
             return image
 
-        threshold = watermark_cfg.get('threshold', 175)
-        morph_close_kernel = watermark_cfg.get('morph_close_kernel', 0)
         before_image = image.copy()
         try:
-            cleaned = remove_watermark_from_image_rgb(
-                image,
-                threshold=threshold,
-                morph_close_kernel=morph_close_kernel,
-                return_pil=False,
-                contrast_enhancement=contrast_cfg if isinstance(contrast_cfg, dict) else None,
-                apply_watermark_removal=wm_enabled,
-                watermark_removal_cfg=watermark_cfg,
-            )
-            if wm_enabled:
-                method = watermark_cfg.get("method", "threshold")
+            cleaned, stages = self._wm_processor.process(image)
+            if "wm" in stages:
                 logger.info(
-                    f"🧹 Watermark removed (method={method}, threshold={threshold})"
+                    f"🧹 Watermark removed (method={self._wm_processor.method}, "
+                    f"threshold={self._wm_processor.threshold})"
                 )
-            if contrast_enabled:
-                method = contrast_cfg.get('method', 'clahe') if isinstance(contrast_cfg, dict) else 'clahe'
+            if "contrast" in stages:
+                ce = self._wm_processor.config.get("contrast_enhancement") or {}
+                method = ce.get("method", "clahe") if isinstance(ce, dict) else "clahe"
                 logger.info(f"📈 Contrast enhanced (method={method})")
             if self._is_watermark_debug_enabled():
                 try:
+                    ce = self._wm_processor.contrast_config()
                     self._save_watermark_debug_images(
                         before_image,
                         np.array(cleaned),
-                        threshold,
-                        morph_close_kernel,
-                        contrast_cfg if isinstance(contrast_cfg, dict) else None,
+                        self._wm_processor.threshold,
+                        self._wm_processor.morph_close_kernel,
+                        ce,
                     )
                 except Exception as dbg_e:
                     logger.warning(f"⚠️ Watermark debug save failed: {dbg_e}")

+ 4 - 4
ocr_tools/universal_doc_parser/models/adapters/mineru_wired_table.py

@@ -464,9 +464,7 @@ class MinerUWiredTableRecognizer:
             bboxes_merged = [cell["bbox"] for cell in merged_cells]
             texts, scores, matched_boxes_list, need_reocr_indices = self.text_filler.fill_text_by_center_point(bboxes_merged, ocr_boxes or [])
             
-            # Step 4.5: 二次 OCR 修正 (强制全量 OCR)
-            # 策略调整:默认对所有单元格进行 Cropped OCR,以解决 Header 误合并和文本分配错误问题。
-            # Full-page OCR 结果仅作为 Fallback(在 text_filling.py 中逻辑是: 如果 Cropped OCR 结果为空或低分,才保留原值)
+            # Step 4.5: 二次 OCR(银行流水:表体空单元必跑 + 低分/跨格;可选笔画增强重试)
             if hasattr(self, 'ocr_engine') and self.ocr_engine:
                 cell_ocr_dir = None
                 if debug_root is not None:
@@ -475,8 +473,10 @@ class MinerUWiredTableRecognizer:
                     table_image, bboxes_merged, texts, scores,
                     need_reocr_indices=need_reocr_indices,
                     pdf_type=pdf_type,
-                    force_all=False,  # Force Per-Cell OCR
+                    force_all=False,
                     output_dir=cell_ocr_dir,
+                    debug_prefix=dbg.prefix or None,
+                    merged_cells=merged_cells,
                 )
 
             for i, cell in enumerate(merged_cells):

File diff ditekan karena terlalu besar
+ 931 - 44
ocr_tools/universal_doc_parser/models/adapters/wired_table/text_filling.py


+ 270 - 0
ocr_tools/universal_doc_parser/tests/test_second_pass_ocr_aggregate.py

@@ -0,0 +1,270 @@
+"""二次 OCR 分行聚合与择优逻辑单元测试。"""
+import pytest
+
+from ocr_tools.universal_doc_parser.models.adapters.wired_table.text_filling import TextFiller
+
+
+class TestAggregateLineOcr:
+    def test_drop_low_score_and_length_weighted(self):
+        # 模拟 cell 207:助款 0.55 应丢弃
+        blocks = [
+            ("存折息", 0.855),
+            ("助设备", 0.953),
+            ("助款", 0.547),
+        ]
+        text, score = TextFiller.aggregate_line_ocr(
+            blocks, line_min_score=0.6, drop_low_score_blocks=True
+        )
+        assert text == "存折息助设备"
+        expected = (3 * 0.855 + 3 * 0.953) / 6
+        assert abs(score - expected) < 1e-6
+
+    def test_all_dropped_returns_empty(self):
+        blocks = [("新", 0.54), ("x", 0.5)]
+        text, score = TextFiller.aggregate_line_ocr(
+            blocks, line_min_score=0.6, drop_low_score_blocks=True
+        )
+        assert text == ""
+        assert score == 0.0
+
+    def test_no_drop_keeps_all(self):
+        blocks = [("ab", 0.8), ("c", 0.7)]
+        text, score = TextFiller.aggregate_line_ocr(
+            blocks, line_min_score=0.6, drop_low_score_blocks=False
+        )
+        assert text == "abc"
+        assert abs(score - (2 * 0.8 + 1 * 0.7) / 3) < 1e-6
+
+
+class TestPickLineVsWhole:
+    def _filler(self) -> TextFiller:
+        return TextFiller(ocr_engine=None, config={"second_pass_ocr": {}})
+
+    def test_prefer_higher_score_whole(self):
+        f = self._filler()
+        t, s, strat = f._pick_line_vs_whole("存折息助设备", 0.85, "存折自助设备取款", 0.92)
+        assert t == "存折自助设备取款"
+        assert strat == "whole"
+
+    def test_prefer_higher_score_lines(self):
+        f = self._filler()
+        t, s, strat = f._pick_line_vs_whole("正确文本", 0.95, "错", 0.5)
+        assert t == "正确文本"
+        assert strat == "lines"
+
+    def test_tie_prefers_whole(self):
+        f = self._filler()
+        t, s, strat = f._pick_line_vs_whole("a", 0.8, "ab", 0.8)
+        assert t == "ab"
+        assert strat == "tie_whole"
+
+    def test_empty_line_uses_whole(self):
+        f = self._filler()
+        t, s, strat = f._pick_line_vs_whole("", 0.0, "整格", 0.7)
+        assert t == "整格"
+        assert strat == "whole"
+
+
+class TestShouldRunWholeFallback:
+    def _filler(self) -> TextFiller:
+        return TextFiller(
+            ocr_engine=None,
+            config={
+                "second_pass_ocr": {
+                    "whole_cell_fallback": True,
+                    "enhance_retry": {"min_chars": 4},
+                }
+            },
+        )
+
+    def test_high_score_short_text_triggers_whole(self):
+        f = self._filler()
+        import numpy as np
+
+        cell = np.ones((40, 120, 3), dtype=np.uint8) * 255
+        assert f._should_run_whole_fallback(
+            "取款", 0.99, cell, [("取款", 0.99)], 0.9
+        )
+
+    def test_empty_line_triggers_whole(self):
+        f = self._filler()
+        import numpy as np
+
+        cell = np.ones((40, 80, 3), dtype=np.uint8) * 255
+        assert f._should_run_whole_fallback("", 0.0, cell, [], 0.9)
+
+
+class TestPickBetterOcrResult:
+    def test_reject_invalid_pass2_score(self):
+        pass1 = {"final_text": "取款", "final_score": 0.99, "accepted": True}
+        pass2 = {"final_text": "14.089", "final_score": 44.5, "accepted": False}
+        chosen = TextFiller._pick_better_ocr_result(pass1, pass2)
+        assert chosen is pass1
+
+
+class TestSanitizeDebugFilename:
+    def test_illegal_chars(self):
+        assert TextFiller.sanitize_debug_filename("a/b:c") == "a_b_c"
+
+
+class TestSortDetBoxesReadingOrder:
+    def _box(self, x1, y1, x2, y2):
+        return [[x1, y1], [x2, y1], [x2, y2], [x1, y2]]
+
+    def test_horizontal_same_row_left_to_right(self):
+        # 「交易类」在左,「型」在右且 y 略偏(中心点排序易错)
+        boxes = [
+            self._box(60, 12, 95, 32),   # 型
+            self._box(5, 10, 55, 30),    # 交易类
+        ]
+        ordered = TextFiller.sort_det_boxes_reading_order(boxes, 50, 100)
+        assert ordered[0] is boxes[1]
+        assert ordered[1] is boxes[0]
+
+    def test_vertical_top_to_bottom(self):
+        boxes = [
+            self._box(10, 50, 40, 70),   # 型
+            self._box(10, 10, 40, 30),   # 交易类
+            self._box(10, 30, 40, 48),   # 中间行
+        ]
+        ordered = TextFiller.sort_det_boxes_reading_order(boxes, 80, 50)
+        assert [boxes.index(b) for b in ordered] == [1, 2, 0]
+
+    def test_two_row_table_header(self):
+        boxes = [
+            self._box(5, 5, 80, 22),
+            self._box(5, 28, 30, 45),
+        ]
+        ordered = TextFiller.sort_det_boxes_reading_order(boxes, 50, 90)
+        assert len(ordered) == 2
+        assert ordered[0] is boxes[0]
+        assert ordered[1] is boxes[1]
+
+
+class TestStripFallbackHeuristic:
+    def _filler(self) -> TextFiller:
+        return TextFiller(ocr_engine=None, config={"second_pass_ocr": {}})
+
+    def test_needs_strip_when_tall_and_one_block(self):
+        import numpy as np
+
+        f = self._filler()
+        img = np.zeros((90, 30, 3), dtype=np.uint8)
+        assert f._needs_strip_line_fallback(img, [("取款", 0.99)]) is True
+        assert f._needs_strip_line_fallback(img, [("a", 0.9), ("b", 0.9)]) is False
+
+    def test_pick_whole_when_much_longer(self):
+        f = self._filler()
+        t, s, strat = f._pick_line_vs_whole("取款", 0.99, "存折自助设备取款", 0.85)
+        assert t == "存折自助设备取款"
+        assert strat == "whole_longer"
+
+    def test_empty_text_zero_score(self):
+        text, score = TextFiller._parse_single_rec_item(("", 1.0))
+        assert text == ""
+        assert score == 0.0
+
+
+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,
+                }
+            },
+        )
+
+    def test_body_row_empty_triggers(self):
+        f = self._filler()
+        merged = [
+            {"row": 0, "col": 0, "bbox": [0, 0, 10, 10]},
+            {"row": 1, "col": 0, "bbox": [0, 10, 10, 20]},
+        ]
+        texts = ["header", ""]
+        scores = [0.99, 0.0]
+        ok, reasons = f._should_second_pass_cell(
+            1, texts, scores, [], merged, "ocr", False, 0
+        )
+        assert ok is True
+        assert "body_row_empty" in reasons
+
+    def test_header_empty_not_body_row_forced(self):
+        f = self._filler()
+        merged = [{"row": 0, "col": 0, "bbox": [0, 0, 10, 10]}]
+        ok, reasons = f._should_second_pass_cell(
+            0, [""], [0.99], [], merged, "ocr", False, 0
+        )
+        assert "body_row_empty" not in reasons
+
+
+class TestResolveCellMatchedBoxes:
+    """空大框套小框:避免仅用碎片字触发高置信填格。"""
+
+    def _matched_entry(self, text, bbox, score=1.0):
+        return (
+            text,
+            bbox[1],
+            bbox[0],
+            1.0,
+            score,
+            {
+                "bbox": bbox,
+                "original_bbox": bbox,
+                "text": text,
+                "confidence": score,
+            },
+        )
+
+    def test_empty_outer_with_inner_char_forces_zero_score(self):
+        # 类似 cell 196:大空框 [877,1593,1084,1671] + 小「部」[966,1644,998,1676]
+        outer = [877.0, 1593.0, 1084.0, 1671.0]
+        inner = [966.0, 1644.0, 998.0, 1676.0]
+        matched = [
+            self._matched_entry("", outer, 1.0),
+            self._matched_entry("部", inner, 0.994),
+        ]
+        resolved, force_zero = TextFiller._resolve_cell_matched_boxes(matched)
+        assert force_zero is True
+        assert len(resolved) == 1
+        assert resolved[0][0] == ""
+        text = "".join(t for t, *_ in resolved)
+        assert text == ""
+
+    def test_outer_with_text_drops_inner_fragment(self):
+        outer = [100.0, 100.0, 400.0, 150.0]
+        inner = [350.0, 110.0, 380.0, 140.0]
+        matched = [
+            self._matched_entry("广东兴宁农村商业银", outer, 0.99),
+            self._matched_entry("行", inner, 0.95),
+        ]
+        resolved, force_zero = TextFiller._resolve_cell_matched_boxes(matched)
+        assert force_zero is False
+        assert len(resolved) == 1
+        assert resolved[0][0] == "广东兴宁农村商业银"
+
+    def test_fill_by_center_point_empty_container(self):
+        filler = TextFiller(ocr_engine=None, config={})
+        cell = [900.0, 1580.0, 1100.0, 1680.0]
+        ocr_boxes = [
+            {
+                "bbox": [877.0, 1593.0, 1084.0, 1671.0],
+                "text": "",
+                "confidence": 1.0,
+            },
+            {
+                "bbox": [966.0, 1644.0, 998.0, 1676.0],
+                "text": "部",
+                "confidence": 0.994,
+            },
+        ]
+        texts, scores, matched_boxes, _ = filler.fill_text_by_center_point(
+            [cell], ocr_boxes
+        )
+        assert texts[0] == ""
+        assert scores[0] == 0.0
+        assert len(matched_boxes[0]) == 1
+        assert matched_boxes[0][0].get("text", "") == ""

+ 76 - 4
ocr_utils/module_debug_viz.py

@@ -48,6 +48,8 @@ LAYOUT_CATEGORY_COLORS_BGR = {
 # 亮蓝(BGR),在白底/浅灰流水上比黄色更易辨认;与 layout 红色框区分
 OCR_BOX_COLOR_BGR = (255, 0, 0)
 OCR_BOX_LINE_THICKNESS = 2
+OCR_BOX_DASH_LENGTH = 8
+OCR_BOX_DASH_GAP = 6
 
 
 def _to_bgr(image: Union[np.ndarray, Image.Image]) -> np.ndarray:
@@ -101,13 +103,78 @@ def draw_layout_boxes_cv2(
     return vis
 
 
+def _draw_dashed_segment(
+    vis: np.ndarray,
+    p1: np.ndarray,
+    p2: np.ndarray,
+    color: tuple,
+    thickness: int,
+    *,
+    dash_length: int = OCR_BOX_DASH_LENGTH,
+    gap_length: int = OCR_BOX_DASH_GAP,
+) -> None:
+    """在 p1→p2 上绘制虚线段。"""
+    start = p1.astype(np.float64)
+    end = p2.astype(np.float64)
+    vec = end - start
+    length = float(np.linalg.norm(vec))
+    if length < 1e-6:
+        return
+    direction = vec / length
+    pos = 0.0
+    draw = True
+    while pos < length:
+        seg = float(dash_length if draw else gap_length)
+        seg_end = min(pos + seg, length)
+        if draw:
+            s = (start + direction * pos).astype(np.int32)
+            e = (start + direction * seg_end).astype(np.int32)
+            cv2.line(
+                vis,
+                (int(s[0]), int(s[1])),
+                (int(e[0]), int(e[1])),
+                color,
+                thickness,
+                cv2.LINE_AA,
+            )
+        pos = seg_end
+        draw = not draw
+
+
+def _draw_span_outline(
+    vis: np.ndarray,
+    pts: np.ndarray,
+    color: tuple,
+    thickness: int,
+    *,
+    dashed: bool,
+) -> None:
+    n = len(pts)
+    if n < 2:
+        return
+    for i in range(n):
+        p1 = pts[i]
+        p2 = pts[(i + 1) % n]
+        if dashed:
+            _draw_dashed_segment(vis, p1, p2, color, thickness)
+        else:
+            cv2.line(
+                vis,
+                (int(p1[0]), int(p1[1])),
+                (int(p2[0]), int(p2[1])),
+                color,
+                thickness,
+                cv2.LINE_AA,
+            )
+
+
 def draw_ocr_spans_cv2(
     image: Union[np.ndarray, Image.Image],
     spans: List[Dict[str, Any]],
     *,
     max_label_chars: int = 12,
 ) -> np.ndarray:
-    """在 BGR 图像上绘制 OCR span(poly 或 bbox)。"""
+    """在 BGR 图像上绘制 OCR span(poly 或 bbox);无文字用虚线框。"""
     vis = _to_bgr(image)
     for span in spans:
         poly = span.get('poly')
@@ -121,10 +188,15 @@ def draw_ocr_spans_cv2(
                 [[x0, y0], [x1, y0], [x1, y1], [x0, y1]], dtype=np.int32
             )
         if pts is not None:
-            cv2.polylines(
-                vis, [pts], True, OCR_BOX_COLOR_BGR, OCR_BOX_LINE_THICKNESS
+            text_raw = str(span.get('text', '') or '').strip()
+            _draw_span_outline(
+                vis,
+                pts,
+                OCR_BOX_COLOR_BGR,
+                OCR_BOX_LINE_THICKNESS,
+                dashed=not text_raw,
             )
-        text = str(span.get('text', ''))[:max_label_chars]
+        text = str(span.get('text', '')).strip()[:max_label_chars]
         if text and pts is not None:
             x, y = int(pts[0][0]), int(pts[0][1])
             cv2.putText(

+ 35 - 0
ocr_utils/tests/test_watermark_processor.py

@@ -0,0 +1,35 @@
+"""WatermarkProcessor 与 preset 单测。"""
+import numpy as np
+import pytest
+
+from ocr_utils.watermark import WatermarkProcessor, merge_watermark_config, get_preset
+
+
+def test_merge_cell_preset_has_mask():
+    cfg = merge_watermark_config("cell", {"method": "masked_adaptive"})
+    assert cfg["method"] == "masked_adaptive"
+    assert "mask" in cfg
+    assert cfg["mask"]["hough_min_line_length"] < get_preset("page", "masked_adaptive")["mask"][
+        "hough_min_line_length"
+    ]
+
+
+def test_processor_disabled_passthrough():
+    proc = WatermarkProcessor({"enabled": False}, scope="cell")
+    img = np.ones((40, 80, 3), dtype=np.uint8) * 255
+    out, stages = proc.process(img)
+    assert stages == []
+    assert out.shape == img.shape
+
+
+def test_processor_cell_force_wm():
+    proc = WatermarkProcessor(
+        {"enabled": True, "method": "masked_adaptive"}, scope="cell"
+    )
+    img = np.ones((50, 100, 3), dtype=np.uint8) * 240
+    cv2 = pytest.importorskip("cv2")
+    for x in range(0, 100, 8):
+        cv2.line(img, (x, 0), (x + 30, 50), (180, 180, 180), 1)
+    out, stages = proc.process(img, force=True)
+    assert "wm" in stages
+    assert out.shape == img.shape

+ 50 - 0
ocr_utils/watermark/__init__.py

@@ -0,0 +1,50 @@
+"""水印处理:预设、门面、算法与 PDF/调试能力。"""
+from ocr_utils.watermark.algorithms import (
+    build_watermark_mask,
+    detect_watermark,
+    remove_watermark_masked_adaptive,
+    render_ratio_heatmap,
+    save_watermark_mask_debug_layers,
+)
+from ocr_utils.watermark.contrast import (
+    apply_contrast_enhancement_config,
+    enhance_document_contrast,
+)
+from ocr_utils.watermark.debug import save_watermark_removal_debug
+from ocr_utils.watermark.pdf import (
+    remove_txt_pdf_watermark,
+    scan_pdf_watermark_xobjs,
+)
+from ocr_utils.watermark.presets import (
+    CELL_WATERMARK_PRESETS,
+    PAGE_WATERMARK_PRESETS,
+    get_preset,
+    merge_watermark_config,
+)
+from ocr_utils.watermark.processor import WatermarkProcessor
+from ocr_utils.watermark.removal import (
+    remove_watermark_from_image,
+    remove_watermark_from_image_rgb,
+    render_watermark_mask_overlay,
+)
+
+__all__ = [
+    "CELL_WATERMARK_PRESETS",
+    "PAGE_WATERMARK_PRESETS",
+    "WatermarkProcessor",
+    "apply_contrast_enhancement_config",
+    "build_watermark_mask",
+    "detect_watermark",
+    "enhance_document_contrast",
+    "get_preset",
+    "merge_watermark_config",
+    "remove_txt_pdf_watermark",
+    "remove_watermark_from_image",
+    "remove_watermark_from_image_rgb",
+    "remove_watermark_masked_adaptive",
+    "render_ratio_heatmap",
+    "render_watermark_mask_overlay",
+    "save_watermark_mask_debug_layers",
+    "save_watermark_removal_debug",
+    "scan_pdf_watermark_xobjs",
+]

+ 1095 - 0
ocr_utils/watermark/algorithms.py

@@ -0,0 +1,1095 @@
+"""水印 掩膜与去水印算法(由 ocr_utils.watermark_utils 迁入)。"""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any, Dict, Optional, Tuple, Union
+
+import cv2
+import numpy as np
+from loguru import logger
+from PIL import Image
+
+def detect_watermark(
+    image: Union[np.ndarray, Image.Image],
+    midtone_low: int = 100,
+    midtone_high: int = 220,
+    ratio_threshold: float = 0.03,
+    check_diagonal: bool = True,
+    diagonal_angle_range: tuple = (30, 60),
+) -> bool:
+    """
+    检测图像中是否存在浅色斜向文字水印(银行流水类文档水印检测)。
+
+    原理:
+    1. 将图像转为灰度,提取「中间调」像素(midtone_low ~ midtone_high),
+       这些像素既不是纯白背景,也不是深黑正文,是浅灰水印的典型范围。
+    2. 若中间调像素占比超过 ratio_threshold,初步判定存在水印。
+    3. 若 check_diagonal=True,进一步用 Hough 直线变换验证中间调区域
+       是否呈现斜向(diagonal_angle_range 度)纹理,以排除灰色背景误报。
+
+    Args:
+        image: 输入图像,支持 PIL.Image 或 np.ndarray(BGR/RGB/灰度)。
+        midtone_low: 中间调下限(默认 100),低于此视为深色正文。
+        midtone_high: 中间调上限(默认 220),高于此视为纯白背景。
+        ratio_threshold: 中间调像素占全图比例阈值(默认 0.03 即 3%)。
+        check_diagonal: 是否进行斜向纹理验证(默认 True)。
+        diagonal_angle_range: 斜向角度范围(度),默认 (30, 60),含 45° 斜水印。
+
+    Returns:
+        True 表示检测到水印,False 表示未检测到。
+    """
+    if isinstance(image, Image.Image):
+        pil_img = image.convert('RGB') if image.mode == 'RGBA' else image
+        np_img = np.array(pil_img)
+        gray = cv2.cvtColor(np_img, cv2.COLOR_RGB2GRAY) if np_img.ndim == 3 else np_img
+    else:
+        np_img = image
+        gray = cv2.cvtColor(np_img, cv2.COLOR_BGR2GRAY) if np_img.ndim == 3 else np_img
+
+    midtone_mask = (gray > midtone_low) & (gray < midtone_high)
+    ratio = midtone_mask.sum() / gray.size
+
+    if ratio < ratio_threshold:
+        return False
+
+    if not check_diagonal:
+        return True
+
+    midtone_uint8 = (midtone_mask.astype(np.uint8)) * 255
+    edges = cv2.Canny(midtone_uint8, 50, 150, apertureSize=3)
+    lines = cv2.HoughLines(edges, rho=1, theta=np.pi / 180, threshold=80)
+
+    if lines is None:
+        return False
+
+    low_rad = np.deg2rad(diagonal_angle_range[0])
+    high_rad = np.deg2rad(diagonal_angle_range[1])
+    diagonal_count = 0
+    for line in lines:
+        theta = line[0][1]
+        if low_rad <= theta <= high_rad or (np.pi - high_rad) <= theta <= (np.pi - low_rad):
+            diagonal_count += 1
+
+    return diagonal_count > 0
+
+
+def _local_std_map(gray: np.ndarray, window: int = 5) -> np.ndarray:
+    """局部标准差图(返回值与输入同形状)。"""
+    gray = np.asarray(gray, dtype=np.float32)
+    size = max(3, int(window))
+    kernel = np.ones((size, size), dtype=np.float32) / (size * size)
+    mean = cv2.filter2D(gray, -1, kernel)
+    sq_mean = cv2.filter2D(gray * gray, -1, kernel)
+    var = sq_mean - mean * mean
+    var = np.maximum(var, 0)
+    return np.sqrt(var)
+
+
+def _line_structuring_kernel(length: int, angle_deg: float) -> np.ndarray:
+    """生成指定角度、长度的线形结构元(用于斜向水印形态学)。"""
+    length = max(3, int(length))
+    k = np.zeros((length, length), np.uint8)
+    c = length // 2
+    rad = np.deg2rad(angle_deg)
+    dx = int(round(np.cos(rad) * (c - 1)))
+    dy = int(round(np.sin(rad) * (c - 1)))
+    cv2.line(k, (c - dx, c - dy), (c + dx, c + dy), 1, thickness=1)
+    return k
+
+
+def _line_angle_deg(x1: int, y1: int, x2: int, y2: int) -> float:
+    """线段方向角 [0, 180)(无向)。"""
+    ang = float(np.degrees(np.arctan2(y2 - y1, x2 - x1)))
+    if ang < 0:
+        ang += 180.0
+    return ang
+
+
+def _angle_in_diagonal_ranges(
+    angle_deg: float,
+    ranges: Tuple[Tuple[float, float], Tuple[float, float]] = ((35.0, 55.0), (125.0, 145.0)),
+) -> bool:
+    for lo, hi in ranges:
+        if lo <= angle_deg <= hi:
+            return True
+    return False
+
+
+def _angle_distance_deg(a: float, b: float) -> float:
+    """无向角距离 [0, 90]。"""
+    d = abs(float(a) - float(b)) % 180.0
+    return min(d, 180.0 - d)
+
+
+def _line_length(x1: int, y1: int, x2: int, y2: int) -> float:
+    return float(np.hypot(x2 - x1, y2 - y1))
+
+
+def _find_dominant_diagonal_angles(
+    segments: list,
+    *,
+    angle_ranges: Tuple[Tuple[float, float], Tuple[float, float]] = ((25.0, 65.0), (115.0, 155.0)),
+    smooth_sigma: float = 2.0,
+    secondary_peak_ratio: float = 0.35,
+) -> Tuple[list, np.ndarray]:
+    """
+    按线段长度加权统计角度直方图,取主峰(及次峰)作为本页水印固定方向。
+
+    Returns:
+        dominant_angles: 1~2 个主导角度(度)
+        hist_smooth: 长度 180 的平滑直方图
+    """
+    hist = np.zeros(180, dtype=np.float64)
+    for x1, y1, x2, y2, ang, length in segments:
+        if not _angle_in_diagonal_ranges(ang, angle_ranges):
+            continue
+        hist[int(ang) % 180] += length
+
+    if hist.sum() <= 0:
+        return [], hist
+
+    ksize = max(3, int(smooth_sigma * 4) | 1)
+    hist_smooth = cv2.GaussianBlur(
+        hist.reshape(1, 180).astype(np.float32), (ksize, 1), smooth_sigma
+    ).flatten().astype(np.float64)
+
+    peaks: list = []
+    for lo, hi in angle_ranges:
+        lo_i, hi_i = int(lo), int(hi)
+        sub = hist_smooth[lo_i : hi_i + 1]
+        if sub.size == 0 or sub.max() <= 0:
+            continue
+        peak_ang = lo_i + int(sub.argmax())
+        peaks.append((peak_ang, float(sub.max())))
+
+    if not peaks:
+        return [], hist_smooth
+
+    peaks.sort(key=lambda x: -x[1])
+    dominant: list = [peaks[0][0]]
+    for ang, val in peaks[1:]:
+        if val >= peaks[0][1] * secondary_peak_ratio:
+            if all(_angle_distance_deg(ang, d) > 15 for d in dominant):
+                dominant.append(ang)
+    return dominant, hist_smooth
+
+
+def _render_angle_histogram(hist: np.ndarray, dominant_angles: list) -> np.ndarray:
+    """角度直方图 debug 图(BGR)。"""
+    h_img, w_img = 120, 360
+    canvas = np.ones((h_img, w_img, 3), dtype=np.uint8) * 255
+    if hist.max() <= 0:
+        return canvas
+    norm = (hist / hist.max() * (h_img - 20)).astype(np.int32)
+    for i, h in enumerate(norm):
+        x = int(i * (w_img - 1) / 179)
+        cv2.line(canvas, (x, h_img - 10), (x, h_img - 10 - int(h)), (180, 180, 180), 1)
+    for ang in dominant_angles:
+        x = int(ang * (w_img - 1) / 179)
+        cv2.line(canvas, (x, 0), (x, h_img - 1), (0, 0, 255), 2)
+    cv2.putText(canvas, "angle (deg)", (w_img // 2 - 40, h_img - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 0, 0), 1)
+    return canvas
+
+
+def _build_diag_hough_region_mask(
+    gray: np.ndarray,
+    *,
+    midtone_low: int = 200,
+    midtone_high: int = 254,
+    canny_low: int = 30,
+    canny_high: int = 100,
+    hough_threshold: int = 30,
+    min_line_length: int = 40,
+    max_line_gap: int = 15,
+    angle_ranges: Tuple[Tuple[float, float], Tuple[float, float]] = ((25.0, 65.0), (115.0, 155.0)),
+    angle_tolerance: float = 5.0,
+    use_angle_statistics: bool = True,
+    secondary_peak_ratio: float = 0.35,
+    min_length_percentile: float = 25.0,
+    line_thickness: int = 10,
+    band_dilate_radius: int = 12,
+) -> Tuple[np.ndarray, Dict[str, Any]]:
+    """
+    方案 C:Canny + HoughLinesP + 角度直方图统计主峰,仅保留与本页水印方向一致的线段。
+    """
+    gray_u8 = np.asarray(gray, dtype=np.uint8)
+    band = ((gray_u8 >= midtone_low) & (gray_u8 < midtone_high)).astype(np.uint8) * 255
+    edges = cv2.Canny(band, int(canny_low), int(canny_high), apertureSize=3)
+
+    lines_p = cv2.HoughLinesP(
+        edges,
+        rho=1,
+        theta=np.pi / 180,
+        threshold=int(hough_threshold),
+        minLineLength=int(min_line_length),
+        maxLineGap=int(max_line_gap),
+    )
+
+    line_mask = np.zeros_like(gray_u8, dtype=np.uint8)
+    lines_all_bgr = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
+    lines_filt_bgr = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
+    diag_candidates: list = []
+    total_lines = 0
+
+    if lines_p is not None:
+        for seg in lines_p:
+            x1, y1, x2, y2 = [int(v) for v in seg[0]]
+            total_lines += 1
+            ang = _line_angle_deg(x1, y1, x2, y2)
+            length = _line_length(x1, y1, x2, y2)
+            if not _angle_in_diagonal_ranges(ang, angle_ranges):
+                continue
+            diag_candidates.append((x1, y1, x2, y2, ang, length))
+            cv2.line(lines_all_bgr, (x1, y1), (x2, y2), (128, 128, 128), 1)
+
+    dominant_angles: list = []
+    hist_smooth = np.zeros(180, dtype=np.float64)
+    if use_angle_statistics and diag_candidates:
+        dominant_angles, hist_smooth = _find_dominant_diagonal_angles(
+            diag_candidates,
+            angle_ranges=angle_ranges,
+            secondary_peak_ratio=secondary_peak_ratio,
+        )
+
+    def _angle_matches(ang: float) -> bool:
+        if not use_angle_statistics or not dominant_angles:
+            return True
+        return any(_angle_distance_deg(ang, d) <= angle_tolerance for d in dominant_angles)
+
+    angle_matched = [
+        s for s in diag_candidates if _angle_matches(s[4])
+    ]
+    if angle_matched and min_length_percentile > 0:
+        lengths = np.array([s[5] for s in angle_matched], dtype=np.float32)
+        len_th = float(np.percentile(lengths, min_length_percentile))
+        angle_matched = [s for s in angle_matched if s[5] >= len_th]
+
+    matched_keys = {(s[0], s[1], s[2], s[3]) for s in angle_matched}
+    kept_lines: list = []
+    for x1, y1, x2, y2, ang, _length in angle_matched:
+        kept_lines.append((x1, y1, x2, y2, ang))
+        cv2.line(line_mask, (x1, y1), (x2, y2), 255, thickness=int(line_thickness))
+        cv2.line(lines_filt_bgr, (x1, y1), (x2, y2), (0, 0, 255), 2)
+    for x1, y1, x2, y2, _ang, _length in diag_candidates:
+        if (x1, y1, x2, y2) not in matched_keys:
+            cv2.line(lines_filt_bgr, (x1, y1), (x2, y2), (0, 180, 255), 1)
+
+    geom = line_mask > 0
+    if band_dilate_radius > 0 and np.any(geom):
+        k = cv2.getStructuringElement(
+            cv2.MORPH_ELLIPSE, (band_dilate_radius * 2 + 1, band_dilate_radius * 2 + 1)
+        )
+        geom = cv2.dilate(line_mask, k) > 0
+
+    info: Dict[str, Any] = {
+        "hough_total_lines": total_lines,
+        "hough_diag_candidates": len(diag_candidates),
+        "hough_kept_lines": len(kept_lines),
+        "dominant_angles": dominant_angles,
+        "angle_tolerance": angle_tolerance,
+        "geom_mask_ratio": float(geom.sum() / gray_u8.size),
+        "hough_lines_bgr": lines_filt_bgr,
+        "hough_lines_all_bgr": lines_all_bgr,
+        "angle_histogram_bgr": _render_angle_histogram(hist_smooth, dominant_angles),
+    }
+    return geom, info
+
+
+def _compute_block_orientation_debug_maps(
+    gray: np.ndarray,
+    *,
+    block_size: int = 48,
+) -> Tuple[np.ndarray, np.ndarray]:
+    """分块 diag/hv 弱边缘占比图(仅 debug 热力图,0~1 float)。"""
+    gray_f = np.asarray(gray, dtype=np.float32)
+    bs = max(4, int(block_size))
+    h_blocks = gray_f.shape[0] // bs
+    w_blocks = gray_f.shape[1] // bs
+    if h_blocks == 0 or w_blocks == 0:
+        z = np.zeros_like(gray_f, dtype=np.float32)
+        return z, z
+
+    ph, pw = h_blocks * bs, w_blocks * bs
+    gx = cv2.Sobel(gray_f, cv2.CV_32F, 1, 0, ksize=3)
+    gy = cv2.Sobel(gray_f, cv2.CV_32F, 0, 1, ksize=3)
+    mag = np.sqrt(gx * gx + gy * gy)
+    ori = np.arctan2(gy, gx) * 180.0 / np.pi
+
+    diag = (
+        ((ori > 25) & (ori < 65))
+        | ((ori > 115) & (ori < 155))
+        | ((ori > -155) & (ori < -115))
+        | ((ori > -65) & (ori < -25))
+    )
+    hv = (
+        ((ori > -20) & (ori < 20))
+        | ((ori > 160) | (ori < -160))
+        | ((ori > 70) & (ori < 110))
+        | ((ori > -110) & (ori < -70))
+    )
+    weak = (mag > 1) & (mag < 15)
+
+    def _to_blocks(arr: np.ndarray) -> np.ndarray:
+        return (
+            arr[:ph, :pw]
+            .reshape(h_blocks, bs, w_blocks, bs)
+            .transpose(0, 2, 1, 3)
+            .reshape(h_blocks, w_blocks, -1)
+        )
+
+    b_diag = _to_blocks(diag)
+    b_hv = _to_blocks(hv)
+    b_weak = _to_blocks(weak)
+    diag_weak = np.sum(b_diag & b_weak, axis=2)
+    hv_weak = np.sum(b_hv & b_weak, axis=2)
+    total_weak = np.sum(b_weak, axis=2)
+    with np.errstate(divide="ignore", invalid="ignore"):
+        diag_ratio = np.where(total_weak > 0, diag_weak / total_weak, 0.0).astype(np.float32)
+        hv_ratio = np.where(total_weak > 0, hv_weak / total_weak, 0.0).astype(np.float32)
+
+    diag_up = np.repeat(np.repeat(diag_ratio, bs, axis=0), bs, axis=1)
+    hv_up = np.repeat(np.repeat(hv_ratio, bs, axis=0), bs, axis=1)
+    diag_full = np.zeros_like(gray_f, dtype=np.float32)
+    hv_full = np.zeros_like(gray_f, dtype=np.float32)
+    diag_full[:ph, :pw] = diag_up
+    hv_full[:ph, :pw] = hv_up
+    return diag_full, hv_full
+
+
+def render_ratio_heatmap(ratio_map: np.ndarray) -> np.ndarray:
+    """将 0~1 浮点占比图转为 BGR 热力图。"""
+    r = np.clip(np.asarray(ratio_map, dtype=np.float32), 0.0, 1.0)
+    u8 = (r * 255).astype(np.uint8)
+    return cv2.applyColorMap(u8, cv2.COLORMAP_JET)
+
+
+def save_watermark_mask_debug_layers(
+    image: np.ndarray,
+    output_dir: Union[str, Path],
+    stem: str,
+    debug: Dict[str, Any],
+    *,
+    image_format: str = "png",
+) -> Dict[str, str]:
+    """保存分层 debug 图(方案 D)。"""
+    out_dir = Path(output_dir)
+    out_dir.mkdir(parents=True, exist_ok=True)
+    fmt = (image_format or "png").lstrip(".")
+    paths: Dict[str, str] = {}
+
+    def _save_overlay(name: str, mask: Optional[np.ndarray], color=(0, 0, 255)) -> None:
+        if mask is None or not np.any(mask):
+            return
+        from ocr_utils.watermark.removal import render_watermark_mask_overlay
+
+        ov = render_watermark_mask_overlay(image, mask, color=color)
+        p = out_dir / f"{stem}_{name}.{fmt}"
+        cv2.imwrite(str(p), cv2.cvtColor(ov, cv2.COLOR_RGB2BGR) if ov.shape[2] == 3 else ov)
+        paths[name] = str(p)
+
+    _save_overlay("wm_candidate_overlay", debug.get("wm_candidate"))
+    _save_overlay("geom_region_overlay", debug.get("geom_region"), color=(0, 180, 255))
+    _save_overlay("geom_candidate_overlay", debug.get("geom_candidate"), color=(0, 255, 0))
+    _save_overlay("wm_mask_overlay", debug.get("wm_mask"), color=(255, 0, 0))
+
+    hough_bgr = debug.get("hough_lines_bgr")
+    if hough_bgr is not None:
+        p = out_dir / f"{stem}_hough_lines.{fmt}"
+        cv2.imwrite(str(p), hough_bgr)
+        paths["hough_lines"] = str(p)
+
+    hough_all = debug.get("hough_lines_all_bgr")
+    if hough_all is not None:
+        p = out_dir / f"{stem}_hough_lines_all.{fmt}"
+        cv2.imwrite(str(p), hough_all)
+        paths["hough_lines_all"] = str(p)
+
+    angle_hist = debug.get("angle_histogram_bgr")
+    if angle_hist is not None:
+        p = out_dir / f"{stem}_angle_histogram.{fmt}"
+        cv2.imwrite(str(p), angle_hist)
+        paths["angle_histogram"] = str(p)
+
+    diag_hm = debug.get("diag_ratio_heatmap")
+    if diag_hm is not None:
+        p = out_dir / f"{stem}_diag_ratio_heatmap.{fmt}"
+        cv2.imwrite(str(p), diag_hm)
+        paths["diag_ratio_heatmap"] = str(p)
+
+    hv_hm = debug.get("hv_ratio_heatmap")
+    if hv_hm is not None:
+        p = out_dir / f"{stem}_hv_ratio_heatmap.{fmt}"
+        cv2.imwrite(str(p), hv_hm)
+        paths["hv_ratio_heatmap"] = str(p)
+
+    return paths
+
+
+def _build_diag_region_mask(
+    gray: np.ndarray,
+    *,
+    block_size: int = 48,
+    diag_ratio_thresh: float = 0.20,
+    light_gray_thresh: int = 238,
+    light_ratio_thresh: float = 0.10,
+    min_edge_count: int = 10,
+    dilate_radius: int = 3,
+) -> np.ndarray:
+    """
+    分块梯度方向检测:返回对角线方向纹理占优的区域掩膜。
+
+    原理:水印是45°斜向字符,其梯度主方向在30-60°和120-150°。
+    分块统计该方向弱边缘占比,高频块标记为水印候选区域。
+
+    Returns:
+        bool ndarray, 与 gray 同形状,True=疑似斜向水印区域。
+    """
+    gray_f = np.asarray(gray, dtype=np.float32)
+    img_h, img_w = gray_f.shape
+    bs = max(4, int(block_size))
+
+    # Sobel 梯度
+    gx = cv2.Sobel(gray_f, cv2.CV_32F, 1, 0, ksize=3)
+    gy = cv2.Sobel(gray_f, cv2.CV_32F, 0, 1, ksize=3)
+    mag = np.sqrt(gx * gx + gy * gy)
+    ori = np.arctan2(gy, gx) * 180.0 / np.pi
+
+    # 对角线方向 (±45° 附近,即梯度 30-65° / 115-155°)
+    diag = (
+        ((ori > 25) & (ori < 65))
+        | ((ori > 115) & (ori < 155))
+        | ((ori > -155) & (ori < -115))
+        | ((ori > -65) & (ori < -25))
+    )
+
+    h_blocks = img_h // bs
+    w_blocks = img_w // bs
+    if h_blocks == 0 or w_blocks == 0:
+        return np.zeros_like(gray, dtype=bool)
+
+    ph, pw = h_blocks * bs, w_blocks * bs
+
+    # 分块统计
+    def _to_blocks(arr: np.ndarray) -> np.ndarray:
+        return arr[:ph, :pw].reshape(h_blocks, bs, w_blocks, bs).transpose(0, 2, 1, 3).reshape(h_blocks, w_blocks, -1)
+
+    block_mag = _to_blocks(mag)
+    block_diag = _to_blocks(diag)
+    block_gray = _to_blocks(gray_f)
+
+    weak = (block_mag > 1) & (block_mag < 15)
+    diag_weak = np.sum(block_diag & weak, axis=2)
+    total_weak = np.sum(weak, axis=2)
+
+    with np.errstate(divide="ignore", invalid="ignore"):
+        diag_ratio = np.where(total_weak > 0, diag_weak / total_weak, 0.0)
+    light_ratio = np.mean(block_gray >= light_gray_thresh, axis=2)
+
+    wm_blocks = (
+        (diag_ratio > diag_ratio_thresh)
+        & (light_ratio > light_ratio_thresh)
+        & (total_weak > min_edge_count)
+    )
+
+    # 展开为像素掩膜
+    wm_block_mask = np.repeat(np.repeat(wm_blocks, bs, axis=0), bs, axis=1)
+    full_mask = np.zeros(gray_f.shape, dtype=bool)
+    full_mask[:ph, :pw] = wm_block_mask
+
+    if dilate_radius > 0:
+        k = cv2.getStructuringElement(
+            cv2.MORPH_ELLIPSE, (dilate_radius * 2 + 1, dilate_radius * 2 + 1)
+        )
+        full_mask = cv2.dilate(full_mask.astype(np.uint8), k) > 0
+
+    return full_mask
+
+
+def _build_seal_protect_mask(
+    bgr: np.ndarray,
+    *,
+    hue_high: int = 15,
+    sat_min: int = 40,
+    value_min: int = 30,
+) -> np.ndarray:
+    """红色/公章区域保护掩膜(True=保护,不置白)。"""
+    hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
+    lower1 = np.array([0, sat_min, value_min], dtype=np.uint8)
+    upper1 = np.array([hue_high, 255, 255], dtype=np.uint8)
+    lower2 = np.array([170, sat_min, value_min], dtype=np.uint8)
+    upper2 = np.array([180, 255, 255], dtype=np.uint8)
+    m1 = cv2.inRange(hsv, lower1, upper1)
+    m2 = cv2.inRange(hsv, lower2, upper2)
+    m2 = cv2.inRange(hsv, lower2, upper2)
+    return (m1 > 0) | (m2 > 0)
+
+
+def _build_text_edge_protect(
+    gray: np.ndarray,
+    *,
+    edge_window: int = 5,
+    edge_std_thresh: float = 6.0,
+    dilate_radius: int = 1,
+) -> np.ndarray:
+    """基于局部方差的笔画边缘保护掩膜(True=保护,不置白)。"""
+    local_std = _local_std_map(gray, window=edge_window)
+    edge_mask = local_std >= edge_std_thresh
+    if dilate_radius > 0:
+        k = cv2.getStructuringElement(
+            cv2.MORPH_ELLIPSE, (dilate_radius * 2 + 1, dilate_radius * 2 + 1)
+        )
+        edge_mask = cv2.dilate(edge_mask.astype(np.uint8), k) > 0
+    return edge_mask.astype(bool)
+
+
+def _build_watermark_mask_light_on_white(
+    gray: np.ndarray,
+    *,
+    bgr: Optional[np.ndarray] = None,
+    light_gray_low: int = 236,
+    light_gray_high: int = 253,
+    whiten_gray_low: int = 200,
+    text_protect_gray_max: int = 130,
+    text_protect_percentile: Optional[float] = None,
+    background_threshold: int = 248,
+    morph_close_kernel: int = 0,
+    morph_close_iter: int = 1,
+    morph_dilate_kernel: int = 0,
+    morph_dilate_iter: int = 1,
+    min_component_area: int = 200,
+    low_variance_thresh: float = 0.0,
+    edge_window: int = 5,
+    direction_filter: str = "hough",
+    debug_block_maps: bool = True,
+    debug_block_size: int = 48,
+    hough_midtone_low: int = 200,
+    hough_midtone_high: int = 254,
+    hough_canny_low: int = 30,
+    hough_canny_high: int = 100,
+    hough_threshold: int = 25,
+    hough_min_line_length: int = 35,
+    hough_max_line_gap: int = 18,
+    hough_line_thickness: int = 12,
+    hough_band_dilate_radius: int = 14,
+    hough_angle_tolerance: float = 5.0,
+    hough_use_angle_statistics: bool = True,
+    hough_secondary_peak_ratio: float = 0.35,
+    hough_min_length_percentile: float = 25.0,
+    diag_block_size: int = 0,
+    diag_ratio_thresh: float = 0.20,
+    diag_light_ratio_thresh: float = 0.10,
+    diag_min_edge_count: int = 10,
+    diag_dilate_radius: int = 3,
+    seal_protect: bool = True,
+    seal_hue_high: int = 15,
+    seal_sat_min: int = 40,
+) -> Tuple[np.ndarray, Dict[str, Any]]:
+    """
+    白底流水水印掩膜(方案 C + E)。
+
+    1. Hough 斜向线段 → geom_region(几何限定区域)
+    2. wm_candidate = 浅色带且非正文保护
+    3. wm_mask = geom_region(置白区域由几何约束;实际白化时再 g>=light_gray_low)
+    4. debug 输出 candidate / geom / 交集 / 热力图
+    """
+    gray_arr = np.asarray(gray)
+    bg_th = int(background_threshold)
+    low = int(light_gray_low)
+    high = int(light_gray_high)
+
+    if text_protect_gray_max > 0:
+        t_protect = float(text_protect_gray_max)
+    else:
+        dark = gray_arr[gray_arr < min(130, bg_th)]
+        if dark.size > 0 and text_protect_percentile is not None:
+            t_protect = float(np.percentile(dark, text_protect_percentile))
+        else:
+            t_protect = 120.0
+    text_protect = gray_arr <= t_protect
+    low = max(low, int(t_protect) + 25)
+
+    wm_candidate = (gray_arr >= low) & (gray_arr < high) & (~text_protect)
+
+    direction = (direction_filter or "hough").lower().strip()
+    hough_info: Dict[str, Any] = {}
+    geom_region = np.zeros_like(gray_arr, dtype=bool)
+
+    if direction == "hough":
+        geom_region, hough_info = _build_diag_hough_region_mask(
+            gray_arr,
+            midtone_low=hough_midtone_low,
+            midtone_high=hough_midtone_high,
+            canny_low=hough_canny_low,
+            canny_high=hough_canny_high,
+            hough_threshold=hough_threshold,
+            min_line_length=hough_min_line_length,
+            max_line_gap=hough_max_line_gap,
+            angle_tolerance=hough_angle_tolerance,
+            use_angle_statistics=hough_use_angle_statistics,
+            secondary_peak_ratio=hough_secondary_peak_ratio,
+            min_length_percentile=hough_min_length_percentile,
+            line_thickness=hough_line_thickness,
+            band_dilate_radius=hough_band_dilate_radius,
+        )
+    elif diag_block_size > 0:
+        geom_region = _build_diag_region_mask(
+            gray_arr,
+            block_size=diag_block_size,
+            diag_ratio_thresh=diag_ratio_thresh,
+            light_gray_thresh=low,
+            light_ratio_thresh=diag_light_ratio_thresh,
+            min_edge_count=diag_min_edge_count,
+            dilate_radius=diag_dilate_radius,
+        )
+
+    geom_candidate = geom_region & wm_candidate
+    wm_mask = geom_region.copy()
+
+    if min_component_area > 0 and np.any(wm_mask):
+        n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
+            wm_mask.astype(np.uint8), connectivity=8
+        )
+        filtered = np.zeros_like(wm_mask)
+        for i in range(1, n_labels):
+            if stats[i, cv2.CC_STAT_AREA] >= min_component_area:
+                filtered[labels == i] = True
+        if np.any(filtered):
+            wm_mask = filtered
+        elif np.any(geom_region):
+            wm_mask = geom_region
+
+    seal_mask = np.zeros_like(wm_mask, dtype=bool)
+    if seal_protect and bgr is not None and bgr.ndim == 3:
+        seal_mask = _build_seal_protect_mask(
+            bgr, hue_high=seal_hue_high, sat_min=seal_sat_min
+        )
+        wm_mask &= ~seal_mask
+
+    midtone = (gray_arr >= low) & (gray_arr < high)
+    debug: Dict[str, Any] = {
+        "mask_mode": "light_on_white",
+        "direction_filter": direction,
+        "light_gray_low": low,
+        "light_gray_high": high,
+        "midtone_ratio": float(midtone.sum() / gray_arr.size),
+        "wm_candidate_ratio": float(wm_candidate.sum() / gray_arr.size),
+        "geom_mask_ratio": float(geom_region.sum() / gray_arr.size),
+        "geom_candidate_ratio": float(geom_candidate.sum() / gray_arr.size),
+        "wm_mask_ratio": float(wm_mask.sum() / gray_arr.size),
+        "T_protect": t_protect,
+        "text_protect_gray_max": text_protect_gray_max,
+        "text_protect": text_protect,
+        "seal_protect": seal_mask,
+        "wm_candidate": wm_candidate,
+        "geom_region": geom_region,
+        "geom_candidate": geom_candidate,
+        "diag_region": geom_region,
+        "wm_mask": wm_mask,
+        "whiten_gray_low": int(whiten_gray_low),
+        "hough_lines_bgr": hough_info.get("hough_lines_bgr"),
+        "hough_lines_all_bgr": hough_info.get("hough_lines_all_bgr"),
+        "angle_histogram_bgr": hough_info.get("angle_histogram_bgr"),
+        "dominant_angles": hough_info.get("dominant_angles", []),
+        "hough_kept_lines": hough_info.get("hough_kept_lines", 0),
+        "hough_diag_candidates": hough_info.get("hough_diag_candidates", 0),
+        "hough_total_lines": hough_info.get("hough_total_lines", 0),
+    }
+
+    if debug_block_maps:
+        bs = debug_block_size if debug_block_size > 0 else 48
+        diag_map, hv_map = _compute_block_orientation_debug_maps(gray_arr, block_size=bs)
+        debug["diag_ratio_heatmap"] = render_ratio_heatmap(diag_map)
+        debug["hv_ratio_heatmap"] = render_ratio_heatmap(hv_map)
+
+    return wm_mask, debug
+
+
+def build_watermark_mask(
+    gray: np.ndarray,
+    *,
+    bgr: Optional[np.ndarray] = None,
+    mask_mode: str = "diagonal_midtone",
+    light_gray_low: int = 236,
+    light_gray_high: int = 253,
+    whiten_gray_low: int = 200,
+    text_protect_gray_max: int = 130,
+    morph_close_kernel: int = 0,
+    morph_close_iter: int = 1,
+    morph_dilate_kernel: int = 0,
+    morph_dilate_iter: int = 1,
+    low_variance_thresh: float = 0.0,
+    edge_window: int = 5,
+    direction_filter: str = "hough",
+    debug_block_maps: bool = True,
+    debug_block_size: int = 48,
+    hough_midtone_low: int = 200,
+    hough_midtone_high: int = 254,
+    hough_canny_low: int = 30,
+    hough_canny_high: int = 100,
+    hough_threshold: int = 25,
+    hough_min_line_length: int = 35,
+    hough_max_line_gap: int = 18,
+    hough_line_thickness: int = 12,
+    hough_band_dilate_radius: int = 14,
+    hough_angle_tolerance: float = 5.0,
+    hough_use_angle_statistics: bool = True,
+    hough_secondary_peak_ratio: float = 0.35,
+    hough_min_length_percentile: float = 25.0,
+    diag_block_size: int = 0,
+    diag_ratio_thresh: float = 0.20,
+    diag_light_ratio_thresh: float = 0.10,
+    diag_min_edge_count: int = 10,
+    diag_dilate_radius: int = 3,
+    # diagonal_midtone 参数
+    midtone_low: int = 100,
+    midtone_high: int = 220,
+    remove_horizontal_vertical: bool = True,
+    diagonal_enhance: bool = True,
+    diagonal_kernel_length: int = 25,
+    horizontal_kernel_length: int = 35,
+    vertical_kernel_length: int = 35,
+    morph_open_kernel: int = 2,
+    dmorph_close_kernel: int = 3,
+    min_component_area: int = 200,
+    text_protect_percentile: float = 10.0,
+    background_threshold: int = 248,
+    seal_protect: bool = True,
+    seal_hue_high: int = 15,
+    seal_sat_min: int = 40,
+) -> Tuple[np.ndarray, Dict[str, Any]]:
+    """
+    构建水印掩膜 wm_mask(True=疑似水印像素)。
+
+    mask_mode:
+        light_on_white — Hough 斜向几何带 + 浅色白化(方案 C/E)
+        diagonal_midtone — 中间调 + 斜向形态学(旧逻辑)
+    """
+    gray = np.asarray(gray)
+    if gray.ndim != 2:
+        raise ValueError("build_watermark_mask expects single-channel grayscale")
+
+    mode = (mask_mode or "light_on_white").lower().strip()
+    if mode == "light_on_white":
+        return _build_watermark_mask_light_on_white(
+            gray,
+            bgr=bgr,
+            light_gray_low=light_gray_low,
+            light_gray_high=light_gray_high,
+            whiten_gray_low=whiten_gray_low,
+            text_protect_gray_max=text_protect_gray_max,
+            text_protect_percentile=text_protect_percentile,
+            background_threshold=background_threshold,
+            morph_close_kernel=morph_close_kernel,
+            morph_close_iter=morph_close_iter,
+            morph_dilate_kernel=morph_dilate_kernel,
+            morph_dilate_iter=morph_dilate_iter,
+            low_variance_thresh=low_variance_thresh,
+            edge_window=edge_window,
+            min_component_area=min_component_area,
+            direction_filter=direction_filter,
+            debug_block_maps=debug_block_maps,
+            debug_block_size=debug_block_size,
+            hough_midtone_low=hough_midtone_low,
+            hough_midtone_high=hough_midtone_high,
+            hough_canny_low=hough_canny_low,
+            hough_canny_high=hough_canny_high,
+            hough_threshold=hough_threshold,
+            hough_min_line_length=hough_min_line_length,
+            hough_max_line_gap=hough_max_line_gap,
+            hough_line_thickness=hough_line_thickness,
+            hough_band_dilate_radius=hough_band_dilate_radius,
+            hough_angle_tolerance=hough_angle_tolerance,
+            hough_use_angle_statistics=hough_use_angle_statistics,
+            hough_secondary_peak_ratio=hough_secondary_peak_ratio,
+            hough_min_length_percentile=hough_min_length_percentile,
+            diag_block_size=diag_block_size,
+            diag_ratio_thresh=diag_ratio_thresh,
+            diag_light_ratio_thresh=diag_light_ratio_thresh,
+            diag_min_edge_count=diag_min_edge_count,
+            diag_dilate_radius=diag_dilate_radius,
+            seal_protect=seal_protect,
+            seal_hue_high=seal_hue_high,
+            seal_sat_min=seal_sat_min,
+        )
+
+    midtone = (gray > midtone_low) & (gray < midtone_high)
+    mid_u8 = (midtone.astype(np.uint8)) * 255
+
+    horiz = np.zeros_like(midtone, dtype=bool)
+    vert = np.zeros_like(midtone, dtype=bool)
+    if remove_horizontal_vertical:
+        kh = cv2.getStructuringElement(
+            cv2.MORPH_RECT, (max(3, horizontal_kernel_length), 1)
+        )
+        kv = cv2.getStructuringElement(
+            cv2.MORPH_RECT, (1, max(3, vertical_kernel_length))
+        )
+        horiz = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, kh) > 0
+        vert = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, kv) > 0
+
+    # 中间调去掉明显横竖线(保留斜向水印)
+    candidate = midtone & ~(horiz | vert)
+
+    if diagonal_enhance:
+        k45 = _line_structuring_kernel(diagonal_kernel_length, 45)
+        k135 = _line_structuring_kernel(diagonal_kernel_length, 135)
+        d45 = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, k45) > 0
+        d135 = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, k135) > 0
+        direction = d45 | d135
+        dilate_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
+        near_diag = cv2.dilate(direction.astype(np.uint8), dilate_k) > 0
+        # 斜向结构足够时收窄到斜向附近;否则保留「中间调减横竖」结果
+        if near_diag.sum() > gray.size * 0.001:
+            candidate = candidate & near_diag
+
+    cand_u8 = (candidate.astype(np.uint8)) * 255
+    if morph_open_kernel > 0:
+        k_open = cv2.getStructuringElement(
+            cv2.MORPH_ELLIPSE, (morph_open_kernel, morph_open_kernel)
+        )
+        cand_u8 = cv2.morphologyEx(cand_u8, cv2.MORPH_OPEN, k_open)
+    if dmorph_close_kernel > 0:
+        k_close = cv2.getStructuringElement(
+            cv2.MORPH_ELLIPSE, (dmorph_close_kernel, dmorph_close_kernel)
+        )
+        cand_u8 = cv2.morphologyEx(cand_u8, cv2.MORPH_CLOSE, k_close)
+
+    wm_mask = cand_u8 > 0
+
+    if min_component_area > 0:
+        n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
+            wm_mask.astype(np.uint8), connectivity=8
+        )
+        filtered = np.zeros_like(wm_mask)
+        for i in range(1, n_labels):
+            if stats[i, cv2.CC_STAT_AREA] >= min_component_area:
+                filtered[labels == i] = True
+        wm_mask = filtered
+
+    non_bg = gray[gray < background_threshold]
+    if non_bg.size > 0:
+        t_protect = float(np.percentile(non_bg, text_protect_percentile))
+    else:
+        t_protect = 85.0
+    t_protect = max(t_protect, float(midtone_low))
+    text_protect = gray <= t_protect
+
+    midtone_ratio = float(midtone.sum() / gray.size)
+    wm_ratio = float(wm_mask.sum() / gray.size)
+
+    # 掩膜过小:回退为「中间调减横竖」或整块中间调(满版斜纹水印常见)
+    min_wm_ratio = max(0.005, midtone_ratio * 0.12)
+    if wm_ratio < min_wm_ratio:
+        relaxed = midtone & ~(horiz | vert) & (~text_protect)
+        if relaxed.sum() / gray.size < min_wm_ratio:
+            relaxed = midtone & (~text_protect)
+        wm_mask = relaxed
+        wm_ratio = float(wm_mask.sum() / gray.size)
+
+    seal_mask = np.zeros_like(wm_mask, dtype=bool)
+    if seal_protect and bgr is not None and bgr.ndim == 3:
+        seal_mask = _build_seal_protect_mask(
+            bgr, hue_high=seal_hue_high, sat_min=seal_sat_min
+        )
+
+    debug: Dict[str, Any] = {
+        "mask_mode": "diagonal_midtone",
+        "midtone_ratio": midtone_ratio,
+        "wm_mask_ratio": wm_ratio,
+        "T_protect": t_protect,
+        "text_protect": text_protect,
+        "seal_protect": seal_mask,
+        "midtone_mask": midtone,
+        "wm_mask": wm_mask,
+    }
+    return wm_mask, debug
+
+
+def remove_watermark_masked_adaptive(
+    gray: np.ndarray,
+    *,
+    bgr: Optional[np.ndarray] = None,
+    mask_cfg: Optional[Dict[str, Any]] = None,
+    adaptive_cfg: Optional[Dict[str, Any]] = None,
+    threshold_fallback: int = 175,
+    morph_close_kernel: int = 0,
+) -> Tuple[np.ndarray, Dict[str, Any]]:
+    """
+    掩膜内置白(whiten_mode=mask_fill)或掩膜内动态阈值(threshold_in_mask)。
+
+    掩膜为空时回退全局 threshold_fallback。
+    """
+    gray = np.asarray(gray).copy()
+    mcfg: Dict[str, Any] = {
+        "mask_mode": "light_on_white",
+        "light_gray_low": 236,
+        "light_gray_high": 253,
+        "whiten_gray_low": 200,
+        "text_protect_gray_max": 130,
+        "morph_close_kernel": 0,
+        "morph_close_iter": 1,
+        "morph_dilate_kernel": 0,
+        "morph_dilate_iter": 1,
+        "low_variance_thresh": 0.0,
+        "edge_window": 5,
+        "min_component_area": 200,
+        "direction_filter": "hough",
+        "debug_block_maps": True,
+        "debug_block_size": 48,
+        "hough_midtone_low": 200,
+        "hough_midtone_high": 254,
+        "hough_canny_low": 30,
+        "hough_canny_high": 100,
+        "hough_threshold": 25,
+        "hough_min_line_length": 35,
+        "hough_max_line_gap": 18,
+        "hough_line_thickness": 12,
+        "hough_band_dilate_radius": 14,
+        "hough_angle_tolerance": 5.0,
+        "hough_use_angle_statistics": True,
+        "hough_secondary_peak_ratio": 0.35,
+        "hough_min_length_percentile": 25.0,
+        "diag_block_size": 0,
+        "diag_ratio_thresh": 0.20,
+        "diag_light_ratio_thresh": 0.10,
+        "diag_min_edge_count": 10,
+        "diag_dilate_radius": 3,
+        "midtone_low": 100,
+        "midtone_high": 220,
+        "remove_horizontal_vertical": True,
+        "diagonal_enhance": True,
+        "diagonal_kernel_length": 25,
+        "horizontal_kernel_length": 35,
+        "vertical_kernel_length": 35,
+        "morph_open_kernel": 2,
+        "dmorph_close_kernel": 3,
+        "text_protect_percentile": 10.0,
+        "background_threshold": 248,
+        "seal_protect": True,
+        "seal_hue_high": 15,
+        "seal_sat_min": 40,
+    }
+    mcfg.update(mask_cfg or {})
+    mask_mode = str(mcfg.get("mask_mode", "light_on_white")).lower().strip()
+
+    # light_on_white 默认 mask_fill
+    acfg: Dict[str, Any] = {
+        "whiten_mode": None,
+        "text_percentile": 10.0,
+        "watermark_percentile": 88.0,
+        "background_percentile": 95.0,
+        "background_threshold": 248,
+        "wm_margin": 12,
+        "text_protect_max": 120,
+    }
+    acfg.update(adaptive_cfg or {})
+    whiten_mode = acfg.get("whiten_mode")
+    if not whiten_mode:
+        whiten_mode = (
+            "mask_fill"
+            if mask_mode == "light_on_white"
+            else "threshold_in_mask"
+        )
+    whiten_mode = str(whiten_mode).lower().strip()
+
+    wm_mask, debug = build_watermark_mask(gray, bgr=bgr, **mcfg)
+
+    if not np.any(wm_mask):
+        cleaned = gray.copy()
+        cleaned[gray > threshold_fallback] = 255
+        debug["mode"] = "fallback_threshold"
+        debug["threshold_fallback"] = threshold_fallback
+        if morph_close_kernel > 0:
+            kernel = np.ones((morph_close_kernel, morph_close_kernel), np.uint8)
+            cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel)
+        return cleaned, debug
+
+    bg_th = int(acfg["background_threshold"])
+    bg_pixels = gray[gray >= bg_th]
+    if bg_pixels.size > 0:
+        b_level = float(np.percentile(bg_pixels, acfg["background_percentile"]))
+    else:
+        b_level = 250.0
+
+    if mask_mode == "light_on_white":
+        t_protect = float(debug.get("T_protect", 150.0))
+    else:
+        non_bg = gray[gray < bg_th]
+        if non_bg.size > 0:
+            t_protect = float(np.percentile(non_bg, acfg["text_percentile"]))
+        else:
+            t_protect = float(debug.get("T_protect", 85.0))
+        t_protect = min(t_protect, float(acfg["text_protect_max"]))
+        t_protect = max(t_protect, float(mcfg.get("midtone_low", 100)))
+
+    text_protect = debug["text_protect"]
+    seal_protect = debug["seal_protect"]
+    t_wm: Optional[float] = None
+
+    if whiten_mode == "mask_fill":
+        # 几何带内:g>=whiten_gray_low 置白;g<=130 正文硬保护(方案 E)
+        wm_gray_low = float(
+            mcfg.get("whiten_gray_low", debug.get("whiten_gray_low", 200))
+        )
+        to_white = (
+            wm_mask
+            & (gray >= wm_gray_low)
+            & (gray < int(mcfg.get("light_gray_high", 254)))
+            & (~text_protect)
+            & (~seal_protect)
+        )
+    else:
+        mask_vals = gray[wm_mask]
+        if mask_vals.size > 0:
+            t_wm = float(np.percentile(mask_vals, acfg["watermark_percentile"]))
+        else:
+            t_wm = t_protect + 0.45 * (b_level - t_protect)
+        margin = float(acfg["wm_margin"])
+        t_wm = max(t_wm, t_protect + margin)
+        t_wm = min(t_wm, b_level - 3.0)
+        t_wm = min(t_wm, float(mcfg.get("midtone_high", 220)) - 5.0)
+        to_white = wm_mask & (gray >= t_wm) & (~text_protect) & (~seal_protect)
+
+    cleaned = gray.copy()
+    cleaned[to_white] = 255
+
+    if morph_close_kernel > 0:
+        kernel = np.ones((morph_close_kernel, morph_close_kernel), np.uint8)
+        cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel)
+
+    debug.update(
+        {
+            "mode": "masked_adaptive",
+            "mask_mode": mask_mode,
+            "whiten_mode": whiten_mode,
+            "T_wm": t_wm,
+            "T_protect": t_protect,
+            "B_level": b_level,
+            "white_pixel_ratio": float(to_white.sum() / gray.size),
+            "threshold_fallback": threshold_fallback,
+        }
+    )
+    return cleaned, debug
+
+
+def _image_to_gray_and_bgr(
+    image: Union[np.ndarray, Image.Image],
+) -> Tuple[np.ndarray, Optional[np.ndarray]]:
+    """统一为灰度 + 可选 BGR(用于掩膜公章保护)。"""
+    if isinstance(image, Image.Image):
+        pil_img = image.convert("RGB") if image.mode == "RGBA" else image
+        np_img = np.array(pil_img)
+        np_img = cv2.cvtColor(np_img, cv2.COLOR_RGB2BGR)
+    else:
+        np_img = image.copy()
+
+    if np_img.ndim == 3:
+        bgr = np_img
+        gray = cv2.cvtColor(np_img, cv2.COLOR_BGR2GRAY)
+    else:
+        bgr = None
+        gray = np_img
+    return gray, bgr
+

+ 139 - 0
ocr_utils/watermark/contrast.py

@@ -0,0 +1,139 @@
+"""水印 对比度增强(由 ocr_utils.watermark_utils 迁入)。"""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any, Dict, Optional, Tuple, Union
+
+import cv2
+import numpy as np
+from loguru import logger
+from PIL import Image
+
+def _enhance_text_restore(
+    gray: np.ndarray,
+    *,
+    background_threshold: int = 248,
+    text_lo_percentile: float = 1.0,
+    text_hi_percentile: float = 99.0,
+    text_black_target: int = 85,
+) -> np.ndarray:
+    """
+    仅对非背景像素做动态范围压缩,将最深笔画拉向 text_black_target(默认 ~85,接近扫描件原图)。
+
+    背景(>= background_threshold)保持白色,避免整图 gamma 导致背景发灰。
+    """
+    result = gray.copy()
+    bg_th = int(np.clip(background_threshold, 200, 255))
+    text_mask = gray < bg_th
+    if not np.any(text_mask):
+        return result
+
+    vals = gray[text_mask].astype(np.float32)
+    lo = float(np.percentile(vals, text_lo_percentile))
+    hi = float(np.percentile(vals, text_hi_percentile))
+    target = int(np.clip(text_black_target, 10, 200))
+    if hi <= lo + 1.0:
+        return result
+
+    stretched = (vals - lo) * target / (hi - lo)
+    result[text_mask] = np.clip(stretched, 0, 255).astype(np.uint8)
+    return result
+
+
+def enhance_document_contrast(
+    gray: np.ndarray,
+    method: str = "text_restore",
+    *,
+    clip_limit: float = 2.0,
+    tile_grid_size: int = 8,
+    gamma: float = 0.85,
+    black_percentile: float = 2.0,
+    white_percentile: float = 98.0,
+    background_threshold: int = 248,
+    text_lo_percentile: float = 1.0,
+    text_hi_percentile: float = 99.0,
+    text_black_target: int = 85,
+) -> np.ndarray:
+    """
+    文档灰度图对比度增强(常用于去水印后恢复笔画深度)。
+
+    Args:
+        gray: 单通道 uint8 灰度图
+        method: text_restore | clahe | gamma | linear
+        clip_limit: CLAHE 对比度限制
+        tile_grid_size: CLAHE 分块大小
+        gamma: gamma 校正指数,<1 加深文字(去水印后发浅时适用)
+        black_percentile: linear 拉伸下分位(映射到 0)
+        white_percentile: linear 拉伸上分位(映射到 255)
+        background_threshold: text_restore 背景阈值(>= 视为白底不处理)
+        text_lo_percentile: text_restore 笔画下分位
+        text_hi_percentile: text_restore 笔画上分位(映射到 text_black_target)
+        text_black_target: text_restore 最深笔画目标灰度(越小越深,建议 75~95)
+
+    Returns:
+        增强后的灰度图
+    """
+    if gray is None or gray.size == 0:
+        return gray
+    if gray.ndim != 2:
+        raise ValueError("enhance_document_contrast expects single-channel grayscale image")
+
+    method = (method or "text_restore").lower().strip()
+
+    if method == "text_restore":
+        return _enhance_text_restore(
+            gray,
+            background_threshold=background_threshold,
+            text_lo_percentile=text_lo_percentile,
+            text_hi_percentile=text_hi_percentile,
+            text_black_target=text_black_target,
+        )
+
+    if method == "gamma":
+        gamma = max(0.1, min(float(gamma), 3.0))
+        inv_gamma = 1.0 / gamma
+        table = np.array(
+            [((i / 255.0) ** inv_gamma) * 255 for i in range(256)],
+            dtype=np.uint8,
+        )
+        return cv2.LUT(gray, table)
+
+    if method == "linear":
+        p_low = float(np.percentile(gray, black_percentile))
+        p_high = float(np.percentile(gray, white_percentile))
+        if p_high <= p_low + 1.0:
+            return gray
+        stretched = (gray.astype(np.float32) - p_low) * 255.0 / (p_high - p_low)
+        return np.clip(stretched, 0, 255).astype(np.uint8)
+
+    # 默认 CLAHE:局部对比度,适合扫描件
+    tile = max(2, int(tile_grid_size))
+    clahe = cv2.createCLAHE(
+        clipLimit=max(0.1, float(clip_limit)),
+        tileGridSize=(tile, tile),
+    )
+    return clahe.apply(gray)
+
+
+def apply_contrast_enhancement_config(
+    gray: np.ndarray,
+    contrast_cfg: Optional[Dict[str, Any]],
+) -> np.ndarray:
+    """按配置字典应用对比度增强;未启用时原样返回。"""
+    if not contrast_cfg or not contrast_cfg.get("enabled", False):
+        return gray
+    return enhance_document_contrast(
+        gray,
+        method=contrast_cfg.get("method", "text_restore"),
+        clip_limit=contrast_cfg.get("clip_limit", 2.0),
+        tile_grid_size=contrast_cfg.get("tile_grid_size", 8),
+        gamma=contrast_cfg.get("gamma", 0.85),
+        black_percentile=contrast_cfg.get("black_percentile", 2.0),
+        white_percentile=contrast_cfg.get("white_percentile", 98.0),
+        background_threshold=contrast_cfg.get("background_threshold", 248),
+        text_lo_percentile=contrast_cfg.get("text_lo_percentile", 1.0),
+        text_hi_percentile=contrast_cfg.get("text_hi_percentile", 99.0),
+        text_black_target=contrast_cfg.get("text_black_target", 75),
+    )

+ 129 - 0
ocr_utils/watermark/debug.py

@@ -0,0 +1,129 @@
+"""水印 调试图保存(由 ocr_utils.watermark_utils 迁入)。"""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any, Dict, Optional, Tuple, Union
+
+import cv2
+import numpy as np
+from loguru import logger
+from PIL import Image
+
+from ocr_utils.watermark.removal import render_watermark_mask_overlay
+
+def _image_to_bgr_for_debug(img: np.ndarray) -> np.ndarray:
+    """将 ndarray 转为 BGR,供 cv2.imwrite 使用。"""
+    if img.ndim == 2:
+        return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
+    out = img.copy()
+    if out.shape[2] == 3:
+        return cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
+    return out
+
+
+def save_watermark_removal_debug(
+    before: Union[np.ndarray, Image.Image],
+    after: Union[np.ndarray, Image.Image],
+    output_dir: Union[str, Path],
+    page_name: str,
+    *,
+    processing_params: Optional[Dict[str, Any]] = None,
+    image_format: str = "png",
+    save_compare: bool = True,
+    subdir: str = "watermark_removal",
+    mask_overlay: Optional[np.ndarray] = None,
+) -> Dict[str, str]:
+    """
+    保存去水印调试图(before / after / compare / meta.json)。
+
+    与 universal_doc_parser 的 module debug 目录结构一致:
+    ``{output_dir}/debug/{subdir}/``
+
+    Args:
+        before: 处理前图像(RGB/BGR/灰度)
+        after: 处理后图像
+        output_dir: 输出根目录(通常为 pipeline 或工具的输出目录)
+        page_name: 文件名前缀(如 ``doc_page_002``)
+        processing_params: 写入 meta.json 的参数(threshold、contrast_enhancement 等)
+        image_format: 图片格式,png/jpg
+        save_compare: 是否保存左右拼接对比图
+        subdir: debug 根目录下的子目录名(默认 watermark_removal)
+
+    Returns:
+        已保存文件路径字典(before/after/compare/meta,未保存的键省略)
+    """
+    if isinstance(before, Image.Image):
+        before = np.array(before)
+    if isinstance(after, Image.Image):
+        after = np.array(after)
+
+    from ocr_utils.module_debug_viz import resolve_module_debug_dir
+
+    debug_dir = resolve_module_debug_dir(output_dir, subdir)
+
+    fmt = (image_format or "png").lstrip(".")
+    before_bgr = _image_to_bgr_for_debug(before)
+    after_bgr = _image_to_bgr_for_debug(after)
+
+    paths: Dict[str, str] = {}
+    before_path = debug_dir / f"{page_name}_watermark_before.{fmt}"
+    after_path = debug_dir / f"{page_name}_watermark_after.{fmt}"
+    cv2.imwrite(str(before_path), before_bgr)
+    cv2.imwrite(str(after_path), after_bgr)
+    paths["before"] = str(before_path)
+    paths["after"] = str(after_path)
+
+    if save_compare:
+        h = max(before_bgr.shape[0], after_bgr.shape[0])
+        if before_bgr.shape[0] != h:
+            before_bgr = cv2.resize(before_bgr, (before_bgr.shape[1], h))
+        if after_bgr.shape[0] != h:
+            after_bgr = cv2.resize(after_bgr, (after_bgr.shape[1], h))
+        compare = np.hstack([before_bgr, after_bgr])
+        compare_path = debug_dir / f"{page_name}_watermark_compare.{fmt}"
+        cv2.imwrite(str(compare_path), compare)
+        paths["compare"] = str(compare_path)
+        logger.info(f"Saved watermark compare: {compare_path}")
+
+    if mask_overlay is not None:
+        mask_bgr = _image_to_bgr_for_debug(mask_overlay)
+        mask_path = debug_dir / f"{page_name}_watermark_mask.{fmt}"
+        cv2.imwrite(str(mask_path), mask_bgr)
+        paths["mask"] = str(mask_path)
+
+    meta: Dict[str, Any] = {"page_name": page_name}
+    if processing_params:
+        _skip_meta = (
+            "midtone_mask",
+            "wm_mask",
+            "wm_candidate",
+            "geom_region",
+            "geom_candidate",
+            "diag_region",
+            "text_protect",
+            "seal_protect",
+            "hough_lines_bgr",
+            "diag_ratio_heatmap",
+            "hv_ratio_heatmap",
+        )
+        meta_params = {
+            k: v
+            for k, v in processing_params.items()
+            if k not in _skip_meta
+        }
+        meta.update(meta_params)
+    else:
+        meta.update({})
+    meta["before"] = paths["before"]
+    meta["after"] = paths["after"]
+    if "compare" in paths:
+        meta["compare"] = paths["compare"]
+
+    meta_path = debug_dir / f"{page_name}_watermark_meta.json"
+    meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
+    paths["meta"] = str(meta_path)
+
+    logger.info(f"Saved watermark debug: {before_path}, {after_path}")
+    return paths

+ 226 - 0
ocr_utils/watermark/pdf.py

@@ -0,0 +1,226 @@
+"""水印 PDF XObject 水印(由 ocr_utils.watermark_utils 迁入)。"""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any, Dict, Optional, Tuple, Union
+
+import cv2
+import numpy as np
+from loguru import logger
+from PIL import Image
+
+def _is_watermark_xobj(doc, xref: int, obj_str: str) -> bool:
+    """
+    判断一个 Form XObject 是否为水印。
+
+    启发式规则(满足其一即视为水印):
+    1. 含旋转变换矩阵(cm 指令 sin/cos 分量非零),无论是否有 /Group
+    2. 有透明度组(/Group)且内容流包含透明度操作符(ca/CA)
+    3. 有透明度组且内容流体积 > 2KB(大量重复绘图 = 平铺水印)
+    """
+    if "/Form" not in obj_str:
+        return False
+
+    try:
+        stream = doc.xref_stream(xref)
+        if not stream:
+            return False
+        stream_text = stream.decode("latin-1", errors="ignore")
+    except Exception:
+        return False
+
+    has_group = "/Group" in obj_str
+
+    cm_pattern = re.compile(
+        r"([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+[-\d.]+\s+[-\d.]+\s+cm"
+    )
+    for m in cm_pattern.finditer(stream_text):
+        a, b, c, d = float(m.group(1)), float(m.group(2)), float(m.group(3)), float(m.group(4))
+        if abs(b) > 0.1 or abs(c) > 0.1:
+            return True
+
+    if not has_group:
+        return False
+
+    if re.search(r'\b(ca|CA)\s+[0-9.]+', stream_text) or re.search(r'[0-9.]+\s+(ca|CA)\b', stream_text):
+        return True
+
+    if len(stream_text) > 2048:
+        return True
+
+    return False
+
+
+def _is_watermark_image_xobj(doc, xref: int, obj_str: str) -> bool:
+    """
+    判断一个 Image XObject 是否为水印背景图。
+
+    判断规则(全部满足):
+    1. /Subtype /Image
+    2. 有 /SMask(半透明)
+    3. 宽 >= 600 且 高 >= 800(全页尺寸,排除小图标)
+    4. 解码后像素均值 >= 240(近乎全白,水印文字稀疏)
+    """
+    if "/Image" not in obj_str or "/SMask" not in obj_str:
+        return False
+
+    w_m = re.search(r'/Width\s+(\d+)', obj_str)
+    h_m = re.search(r'/Height\s+(\d+)', obj_str)
+    if not w_m or not h_m:
+        return False
+    if int(w_m.group(1)) < 600 or int(h_m.group(1)) < 800:
+        return False
+
+    try:
+        from io import BytesIO
+        img_info = doc.extract_image(xref)
+        pil_img = Image.open(BytesIO(img_info["image"])).convert("L")
+        return float(np.array(pil_img).mean()) >= 240.0
+    except Exception:
+        return False
+
+
+def _blank_watermark_image(doc, img_xref: int) -> None:
+    """
+    将水印 Image XObject 的 RGB 流和 SMask 替换为全白/全不透明。
+
+    关键点:必须先移除 /DecodeParms(Predictor 11),再调用 update_stream。
+    否则渲染器在 FlateDecode 之后还会尝试 Predictor 解码,失败后回退原始数据,
+    水印依然可见。
+    """
+    obj_str = doc.xref_object(img_xref)
+
+    w_m = re.search(r'/Width\s+(\d+)', obj_str)
+    h_m = re.search(r'/Height\s+(\d+)', obj_str)
+    w = int(w_m.group(1)) if w_m else 1
+    h = int(h_m.group(1)) if h_m else 1
+    cs_m = re.search(r'/ColorSpace\s+/Device(RGB|Gray|CMYK)', obj_str)
+    channels = {'RGB': 3, 'CMYK': 4}.get(cs_m.group(1) if cs_m else '', 1)
+
+    doc.xref_set_key(img_xref, "DecodeParms", "null")
+    doc.update_stream(img_xref, bytes([255]) * (w * h * channels))
+
+    smask_m = re.search(r'/SMask\s+(\d+)\s+0\s+R', obj_str)
+    if smask_m:
+        smask_xref = int(smask_m.group(1))
+        smask_obj = doc.xref_object(smask_xref)
+        sw = int(m.group(1)) if (m := re.search(r'/Width\s+(\d+)', smask_obj)) else w
+        sh = int(m.group(1)) if (m := re.search(r'/Height\s+(\d+)', smask_obj)) else h
+        doc.xref_set_key(smask_xref, "DecodeParms", "null")
+        doc.update_stream(smask_xref, bytes([255]) * (sw * sh))
+
+
+def scan_pdf_watermark_xobjs(pdf_bytes: bytes, sample_pages: int = 3) -> bool:
+    """
+    快速扫描 PDF 前 N 页,判断是否含水印 XObject。
+
+    无副作用(只读),用于在执行去水印前快速判断,避免对无水印的大文件
+    执行全量扫描和序列化,显著降低财报等大文件的处理开销。
+
+    Args:
+        pdf_bytes: PDF 文件的原始字节。
+        sample_pages: 扫描页数上限,默认 3(银行流水通常前几页有水印)。
+
+    Returns:
+        True 表示发现水印 XObject,False 表示未发现。
+    """
+    try:
+        import fitz
+    except ImportError:
+        return False
+
+    doc = fitz.open(stream=pdf_bytes, filetype="pdf")
+    pages_to_check = min(sample_pages, len(doc))
+    try:
+        for i in range(pages_to_check):
+            page = doc[i]
+            for xref, *_ in page.get_xobjects():
+                try:
+                    obj_str = doc.xref_object(xref)
+                except Exception:
+                    continue
+                if _is_watermark_xobj(doc, xref, obj_str):
+                    return True
+            for img_tuple in page.get_images(full=True):
+                try:
+                    obj_str = doc.xref_object(img_tuple[0])
+                except Exception:
+                    continue
+                if _is_watermark_image_xobj(doc, img_tuple[0], obj_str):
+                    return True
+    finally:
+        doc.close()
+    return False
+
+
+def remove_txt_pdf_watermark(pdf_bytes: bytes) -> Optional[bytes]:
+    """
+    对文字型 PDF 执行原生水印去除,完全在内存中完成,不写临时文件。
+
+    支持两种水印形式:
+    - Form XObject 水印:清空内容流
+    - Image XObject 水印(全页背景图 + SMask 透明通道):替换为全白像素
+
+    适用场景:pdf_type='txt' 的 PDF,去除后可直接传给渲染层(tobytes() → bytes)。
+    对于大文件(如财报),建议先用 scan_pdf_watermark_xobjs() 快速判断再调用本函数。
+
+    Args:
+        pdf_bytes: 原始 PDF 的字节内容。
+
+    Returns:
+        去除水印后的 PDF bytes(garbage=4 压缩);若未发现水印返回 None。
+    """
+    try:
+        import fitz
+    except ImportError:
+        raise ImportError("请安装 PyMuPDF: pip install PyMuPDF")
+
+    from loguru import logger
+
+    doc = fitz.open(stream=pdf_bytes, filetype="pdf")
+    processed_xrefs: set[int] = set()
+    total_removed = 0
+
+    for page in doc:
+        # ── Form XObject 水印 ─────────────────────────────────────────
+        for xref, name, _invoker, _unused in page.get_xobjects():
+            if xref in processed_xrefs:
+                continue
+            try:
+                obj_str = doc.xref_object(xref)
+            except Exception:
+                continue
+            if _is_watermark_xobj(doc, xref, obj_str):
+                try:
+                    doc.update_stream(xref, b"")
+                    processed_xrefs.add(xref)
+                    total_removed += 1
+                    logger.debug(f"  [Form XObject] 清空水印 xref={xref}, name={name}")
+                except Exception as e:
+                    logger.warning(f"  清空 Form XObject xref={xref} 失败: {e}")
+
+        # ── Image XObject 水印 ────────────────────────────────────────
+        for img_tuple in page.get_images(full=True):
+            img_xref = img_tuple[0]
+            if img_xref in processed_xrefs:
+                continue
+            try:
+                obj_str = doc.xref_object(img_xref)
+            except Exception:
+                continue
+            if _is_watermark_image_xobj(doc, img_xref, obj_str):
+                _blank_watermark_image(doc, img_xref)
+                processed_xrefs.add(img_xref)
+                total_removed += 1
+                logger.debug(f"  [Image XObject] 替换水印图像 xref={img_xref}")
+
+    if total_removed == 0:
+        doc.close()
+        return None
+
+    result = doc.tobytes(garbage=4, deflate=True)
+    doc.close()
+    logger.info(f"✅ PDF 层级水印去除:共清除 {total_removed} 个水印 XObject")
+    return result

+ 197 - 0
ocr_utils/watermark/presets.py

@@ -0,0 +1,197 @@
+"""
+银行流水等场景的水印去除预设(页级 / 单元格级)。
+
+对外 YAML 只需 method、enabled、contrast_enhancement 等少量键;
+mask / hough / adaptive 细参由此模块提供,避免配置漂移。
+"""
+from __future__ import annotations
+
+import copy
+from typing import Any, Dict, Literal, Optional
+
+Scope = Literal["page", "cell"]
+Method = Literal["threshold", "masked", "masked_adaptive"]
+
+_DETECT_DEFAULT: Dict[str, Any] = {
+    "ratio_threshold": 0.025,
+    "midtone_low": 100,
+    "midtone_high": 220,
+    "check_diagonal": True,
+    "diagonal_angle_range": (30, 60),
+}
+
+_MASK_PAGE: Dict[str, Any] = {
+    "mask_mode": "light_on_white",
+    "text_protect_gray_max": 130,
+    "light_gray_low": 236,
+    "light_gray_high": 253,
+    "whiten_gray_low": 200,
+    "direction_filter": "hough",
+    "morph_close_kernel": 0,
+    "morph_dilate_kernel": 0,
+    "min_component_area": 200,
+    "debug_block_maps": False,
+    "debug_block_size": 48,
+    "hough_midtone_low": 200,
+    "hough_midtone_high": 254,
+    "hough_canny_low": 30,
+    "hough_canny_high": 100,
+    "hough_threshold": 25,
+    "hough_min_line_length": 35,
+    "hough_max_line_gap": 18,
+    "hough_line_thickness": 12,
+    "hough_band_dilate_radius": 16,
+    "hough_use_angle_statistics": True,
+    "hough_angle_tolerance": 5.0,
+    "hough_secondary_peak_ratio": 0.35,
+    "hough_min_length_percentile": 25.0,
+    "midtone_low": 95,
+    "midtone_high": 235,
+    "remove_horizontal_vertical": True,
+    "diagonal_enhance": True,
+    "diagonal_kernel_length": 25,
+    "horizontal_kernel_length": 35,
+    "vertical_kernel_length": 35,
+    "morph_open_kernel": 2,
+    "dmorph_close_kernel": 3,
+    "text_protect_percentile": 10.0,
+    "background_threshold": 248,
+    "seal_protect": True,
+}
+
+_MASK_CELL: Dict[str, Any] = {
+    **_MASK_PAGE,
+    "min_component_area": 60,
+    "hough_min_line_length": 18,
+    "hough_max_line_gap": 12,
+    "hough_line_thickness": 8,
+    "hough_band_dilate_radius": 10,
+    "hough_threshold": 20,
+    "text_protect_gray_max": 125,
+}
+
+_ADAPTIVE_PAGE: Dict[str, Any] = {
+    "whiten_mode": "mask_fill",
+    "text_percentile": 10.0,
+    "watermark_percentile": 70.0,
+    "background_percentile": 95.0,
+    "background_threshold": 248,
+    "wm_margin": 12,
+    "text_protect_max": 120,
+}
+
+_ADAPTIVE_CELL: Dict[str, Any] = {
+    **_ADAPTIVE_PAGE,
+    "text_protect_max": 110,
+    "wm_margin": 10,
+}
+
+_CONTRAST_PAGE_DEFAULT: Dict[str, Any] = {
+    "enabled": True,
+    "method": "text_restore",
+    "text_black_target": 85,
+    "background_threshold": 248,
+    "text_lo_percentile": 1.0,
+    "text_hi_percentile": 99.0,
+}
+
+_CONTRAST_CELL_DEFAULT: Dict[str, Any] = {
+    "enabled": False,
+    "method": "text_restore",
+    "text_black_target": 88,
+    "background_threshold": 248,
+    "text_lo_percentile": 1.0,
+    "text_hi_percentile": 99.0,
+}
+
+
+def _base_preset(scope: Scope, method: Method) -> Dict[str, Any]:
+    mask = _MASK_CELL if scope == "cell" else _MASK_PAGE
+    adaptive = _ADAPTIVE_CELL if scope == "cell" else _ADAPTIVE_PAGE
+    contrast = (
+        copy.deepcopy(_CONTRAST_CELL_DEFAULT)
+        if scope == "cell"
+        else copy.deepcopy(_CONTRAST_PAGE_DEFAULT)
+    )
+    threshold = 175 if scope == "page" else 170
+    cfg: Dict[str, Any] = {
+        "enabled": True,
+        "detect_before_remove": scope == "page",
+        "detect": copy.deepcopy(_DETECT_DEFAULT),
+        "method": method,
+        "threshold": threshold,
+        "morph_close_kernel": 0,
+        "contrast_enhancement": contrast,
+        "debug_options": {
+            "enabled": False,
+            "save_compare": True,
+            "image_format": "png",
+            "subdir": "watermark_removal",
+        },
+    }
+    if method in ("masked", "masked_adaptive"):
+        cfg["mask"] = copy.deepcopy(mask)
+    if method == "masked_adaptive":
+        cfg["adaptive"] = copy.deepcopy(adaptive)
+    return cfg
+
+
+PAGE_WATERMARK_PRESETS: Dict[str, Dict[str, Any]] = {
+    "threshold": _base_preset("page", "threshold"),
+    "masked": _base_preset("page", "masked"),
+    "masked_adaptive": _base_preset("page", "masked_adaptive"),
+}
+
+CELL_WATERMARK_PRESETS: Dict[str, Dict[str, Any]] = {
+    "threshold": _base_preset("cell", "threshold"),
+    "masked": _base_preset("cell", "masked"),
+    "masked_adaptive": _base_preset("cell", "masked_adaptive"),
+}
+
+
+def get_preset(scope: Scope, method: str) -> Dict[str, Any]:
+    method = method or "masked_adaptive"
+    presets = CELL_WATERMARK_PRESETS if scope == "cell" else PAGE_WATERMARK_PRESETS
+    if method not in presets:
+        method = "masked_adaptive"
+    return copy.deepcopy(presets[method])
+
+
+def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> Dict[str, Any]:
+    out = copy.deepcopy(base)
+    for k, v in override.items():
+        if k in out and isinstance(out[k], dict) and isinstance(v, dict):
+            out[k] = _deep_merge(out[k], v)
+        else:
+            out[k] = copy.deepcopy(v)
+    return out
+
+
+def merge_watermark_config(
+    scope: Scope,
+    user_cfg: Optional[Dict[str, Any]] = None,
+    *,
+    method: Optional[str] = None,
+) -> Dict[str, Any]:
+    """将用户 YAML 片段与 scope 预设合并;保留旧版 mask/adaptive 全量覆盖能力。"""
+    user_cfg = user_cfg or {}
+    m = method or user_cfg.get("method") or "masked_adaptive"
+    merged = get_preset(scope, str(m))
+
+    for key in (
+        "enabled",
+        "detect_before_remove",
+        "method",
+        "threshold",
+        "morph_close_kernel",
+    ):
+        if key in user_cfg:
+            merged[key] = user_cfg[key]
+
+    for nested in ("detect", "mask", "adaptive", "contrast_enhancement", "debug_options"):
+        if nested in user_cfg and isinstance(user_cfg[nested], dict):
+            merged[nested] = _deep_merge(merged.get(nested) or {}, user_cfg[nested])
+
+    if method:
+        merged["method"] = method
+    return merged

+ 153 - 0
ocr_utils/watermark/processor.py

@@ -0,0 +1,153 @@
+"""
+水印处理门面:preset 解析、检测、去水印、对比度增强。
+"""
+from __future__ import annotations
+
+from typing import Any, Dict, List, Optional, Tuple, Union
+
+import numpy as np
+from loguru import logger
+from PIL import Image
+
+from ocr_utils.watermark.algorithms import detect_watermark
+from ocr_utils.watermark.presets import Scope, merge_watermark_config
+from ocr_utils.watermark.removal import remove_watermark_from_image_rgb
+
+
+class WatermarkProcessor:
+    """页级 / 单元格级水印去除编排。"""
+
+    def __init__(
+        self,
+        config: Dict[str, Any],
+        *,
+        scope: Scope = "page",
+    ):
+        self.scope = scope
+        self.config = merge_watermark_config(scope, config)
+
+    @classmethod
+    def from_user_config(
+        cls,
+        user_cfg: Optional[Dict[str, Any]],
+        *,
+        scope: Scope = "page",
+    ) -> "WatermarkProcessor":
+        return cls(user_cfg or {}, scope=scope)
+
+    @property
+    def enabled(self) -> bool:
+        return bool(self.config.get("enabled", False))
+
+    @property
+    def method(self) -> str:
+        return str(self.config.get("method") or "masked_adaptive")
+
+    @property
+    def threshold(self) -> int:
+        return int(self.config.get("threshold", 175))
+
+    @property
+    def morph_close_kernel(self) -> int:
+        return int(self.config.get("morph_close_kernel", 0))
+
+    def contrast_config(self) -> Optional[Dict[str, Any]]:
+        ce = self.config.get("contrast_enhancement")
+        if not isinstance(ce, dict):
+            return None
+        if not ce.get("enabled", False):
+            return None
+        return dict(ce)
+
+    def should_apply(self, image: Union[np.ndarray, Image.Image]) -> bool:
+        if not self.enabled:
+            return False
+        if not bool(self.config.get("detect_before_remove", True)):
+            return True
+
+        detect_cfg = self.config.get("detect")
+        if not isinstance(detect_cfg, dict):
+            detect_cfg = {}
+
+        angle_range = detect_cfg.get("diagonal_angle_range", (30, 60))
+        if isinstance(angle_range, list):
+            angle_range = tuple(angle_range)
+
+        return detect_watermark(
+            image,
+            midtone_low=int(detect_cfg.get("midtone_low", 100)),
+            midtone_high=int(detect_cfg.get("midtone_high", 220)),
+            ratio_threshold=float(detect_cfg.get("ratio_threshold", 0.025)),
+            check_diagonal=bool(detect_cfg.get("check_diagonal", True)),
+            diagonal_angle_range=angle_range,
+        )
+
+    def process(
+        self,
+        image: Union[np.ndarray, Image.Image],
+        *,
+        apply_removal: Optional[bool] = None,
+        apply_contrast: Optional[bool] = None,
+        contrast_override: Optional[Dict[str, Any]] = None,
+        removal_debug: Optional[Dict[str, Any]] = None,
+        force: bool = False,
+    ) -> Tuple[np.ndarray, List[str]]:
+        """
+        去水印 + 可选对比度增强。
+
+        Returns:
+            (BGR ndarray, preprocess_stages)
+        """
+        stages: List[str] = []
+        if isinstance(image, Image.Image):
+            img = np.array(image.convert("RGB"))
+            img = img[:, :, ::-1].copy()  # RGB -> BGR
+        else:
+            img = np.array(image)
+            if img.ndim == 2:
+                img = np.stack([img, img, img], axis=-1)
+
+        do_remove = apply_removal if apply_removal is not None else self.enabled
+        if do_remove and not force and not self.should_apply(img):
+            do_remove = False
+
+        if contrast_override is not None:
+            contrast_cfg = dict(contrast_override)
+            if apply_contrast is not False and not contrast_cfg.get("enabled", True):
+                contrast_cfg["enabled"] = True
+        else:
+            contrast_cfg = self.contrast_config()
+        if apply_contrast is False:
+            contrast_cfg = None
+        elif apply_contrast is True and contrast_cfg is None:
+            ce = self.config.get("contrast_enhancement") or {}
+            if isinstance(ce, dict) and ce.get("method"):
+                contrast_cfg = dict(ce)
+                contrast_cfg["enabled"] = True
+
+        if not do_remove and not contrast_cfg:
+            return img, stages
+
+        try:
+            if do_remove:
+                stages.append("wm")
+            if contrast_cfg:
+                stages.append("contrast")
+
+            cleaned = remove_watermark_from_image_rgb(
+                img,
+                threshold=self.threshold,
+                morph_close_kernel=self.morph_close_kernel,
+                return_pil=False,
+                contrast_enhancement=contrast_cfg,
+                apply_watermark_removal=do_remove,
+                watermark_removal_cfg=self.config,
+                removal_debug=removal_debug,
+            )
+            return np.asarray(cleaned), stages
+        except Exception as e:
+            logger.warning(f"WatermarkProcessor.process failed (scope={self.scope}): {e}")
+            return img, stages
+
+    def get_full_config(self) -> Dict[str, Any]:
+        return dict(self.config)

+ 152 - 0
ocr_utils/watermark/removal.py

@@ -0,0 +1,152 @@
+"""水印 去水印入口(由 ocr_utils.watermark_utils 迁入)。"""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+from typing import Any, Dict, Optional, Tuple, Union
+
+import cv2
+import numpy as np
+from loguru import logger
+from PIL import Image
+
+from ocr_utils.watermark.algorithms import (
+    _image_to_gray_and_bgr,
+    remove_watermark_masked_adaptive,
+)
+from ocr_utils.watermark.contrast import apply_contrast_enhancement_config
+
+def remove_watermark_from_image(
+    image: Union[np.ndarray, Image.Image],
+    threshold: int = 160,
+    morph_close_kernel: int = 2,
+    return_pil: Optional[bool] = None,
+    watermark_removal_cfg: Optional[Dict[str, Any]] = None,
+    removal_debug: Optional[Dict[str, Any]] = None,
+) -> Union[np.ndarray, Image.Image]:
+    """
+    去除图像中的浅色斜向文字水印,返回灰度图。
+
+    method(watermark_removal_cfg):
+        threshold(默认): gray > threshold → 255
+        masked / masked_adaptive: 掩膜 + 掩膜内动态阈值
+
+    Args:
+        image: 输入图像(PIL.Image 或 np.ndarray BGR/RGB/灰度)。
+        threshold: 全局阈值或掩膜失败时的回退阈值。
+        morph_close_kernel: 形态学闭运算核大小,0 跳过。
+        watermark_removal_cfg: 完整配置(含 method / mask / adaptive)。
+        removal_debug: 若传入 dict,写入掩膜与 T_wm 等调试字段。
+
+    Returns:
+        去除水印后的灰度图:PIL.Image(mode='L') 或 np.ndarray(HxW, uint8)。
+    """
+    input_is_pil = isinstance(image, Image.Image)
+    cfg = watermark_removal_cfg or {}
+    method = str(cfg.get("method") or "threshold").lower().strip()
+
+    gray, bgr = _image_to_gray_and_bgr(image)
+
+    if method in ("masked", "masked_adaptive"):
+        cleaned, dbg = remove_watermark_masked_adaptive(
+            gray,
+            bgr=bgr,
+            mask_cfg=cfg.get("mask") if isinstance(cfg.get("mask"), dict) else None,
+            adaptive_cfg=cfg.get("adaptive")
+            if isinstance(cfg.get("adaptive"), dict)
+            else None,
+            threshold_fallback=threshold,
+            morph_close_kernel=morph_close_kernel,
+        )
+        if removal_debug is not None:
+            removal_debug.clear()
+            removal_debug.update(dbg)
+    else:
+        cleaned = gray.copy()
+        cleaned[gray > threshold] = 255
+        if morph_close_kernel > 0:
+            kernel = np.ones((morph_close_kernel, morph_close_kernel), np.uint8)
+            cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel)
+        if removal_debug is not None:
+            removal_debug.clear()
+            removal_debug.update({"mode": "threshold", "threshold": threshold})
+
+    should_return_pil = input_is_pil if return_pil is None else return_pil
+    return Image.fromarray(cleaned, mode='L') if should_return_pil else cleaned
+
+
+def remove_watermark_from_image_rgb(
+    image: Union[np.ndarray, Image.Image],
+    threshold: int = 160,
+    morph_close_kernel: int = 2,
+    return_pil: Optional[bool] = None,
+    contrast_enhancement: Optional[Dict[str, Any]] = None,
+    apply_watermark_removal: bool = True,
+    watermark_removal_cfg: Optional[Dict[str, Any]] = None,
+    removal_debug: Optional[Dict[str, Any]] = None,
+) -> Union[np.ndarray, Image.Image]:
+    """
+    去除水印并返回 RGB 三通道图像。
+
+    与 remove_watermark_from_image 逻辑相同,但输出为 RGB(三通道),
+    方便直接传入布局检测、OCR 等需要彩色输入的下游模型。
+
+    Args:
+        contrast_enhancement: 对比度增强配置(含 enabled / method 等),见 apply_contrast_enhancement_config
+        apply_watermark_removal: False 时跳过阈值抹白,仅做对比度增强(若启用)
+
+    Args/Returns: 同 remove_watermark_from_image,但输出为 RGB/BGR 三通道。
+    """
+    input_is_pil = isinstance(image, Image.Image)
+
+    if apply_watermark_removal:
+        gray_result = remove_watermark_from_image(
+            image,
+            threshold,
+            morph_close_kernel,
+            return_pil=False,
+            watermark_removal_cfg=watermark_removal_cfg,
+            removal_debug=removal_debug,
+        )
+    else:
+        if isinstance(image, Image.Image):
+            np_img = np.array(image.convert("RGB"))
+            np_img = cv2.cvtColor(np_img, cv2.COLOR_RGB2BGR)
+        else:
+            np_img = image.copy()
+        gray_result = (
+            cv2.cvtColor(np_img, cv2.COLOR_BGR2GRAY)
+            if np_img.ndim == 3
+            else np_img
+        )
+
+    gray_result = apply_contrast_enhancement_config(gray_result, contrast_enhancement)
+    rgb_np = cv2.cvtColor(gray_result, cv2.COLOR_GRAY2BGR)
+
+    should_return_pil = input_is_pil if return_pil is None else return_pil
+    if should_return_pil:
+        return Image.fromarray(cv2.cvtColor(rgb_np, cv2.COLOR_BGR2RGB))
+    return rgb_np
+
+
+def render_watermark_mask_overlay(
+    image: np.ndarray,
+    wm_mask: np.ndarray,
+    *,
+    color: Tuple[int, int, int] = (0, 0, 255),
+    alpha: float = 0.45,
+) -> np.ndarray:
+    """在原图上叠加红色半透明水印掩膜,供调试图保存。"""
+    if image.ndim == 2:
+        base = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
+    elif image.shape[2] == 3:
+        base = image.copy()
+        if image.max() <= 1:
+            base = (image * 255).astype(np.uint8)
+    else:
+        base = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
+
+    overlay = base.copy()
+    overlay[wm_mask] = color
+    return cv2.addWeighted(base, 1.0 - alpha, overlay, alpha, 0)

+ 41 - 1707
ocr_utils/watermark_utils.py

@@ -1,1712 +1,46 @@
 """
-水印处理工具模块
+水印处理兼容入口(实现已迁至 ocr_utils.watermark 包)。
 
-统一管理所有水印检测与去除能力,供整个平台复用:
+新代码请优先使用::
 
-- 图像级(扫描 PDF / 图片):
-    detect_watermark()                检测图像中的斜向文字水印
-    build_watermark_mask()            构建斜向浅灰水印掩膜(方案 D)
-    remove_watermark_masked_adaptive() 掩膜 + 动态阈值去水印
-    remove_watermark_from_image()     去除水印,返回灰度图
-    remove_watermark_from_image_rgb() 去除水印,返回 RGB 图(适合模型输入)
-    enhance_document_contrast()       去水印后对比度/笔画深度恢复
-    save_watermark_removal_debug()    保存去水印前后对比调试图
+    from ocr_utils.watermark import WatermarkProcessor, detect_watermark, ...
 
-- PDF 层级(文字型 PDF,保留可搜索性):
-    scan_pdf_watermark_xobjs()        快速扫描 PDF 是否含水印 XObject(无副作用)
-    remove_txt_pdf_watermark()        从内存 PDF bytes 去除水印,返回新 bytes 或 None
+本模块保留与历史 import 路径的兼容。
 """
-from __future__ import annotations
-
-import json
-import re
-from pathlib import Path
-from typing import Any, Dict, Optional, Tuple, Union
-
-import cv2
-import numpy as np
-from loguru import logger
-from PIL import Image
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# 图像级水印检测与去除
-# ─────────────────────────────────────────────────────────────────────────────
-
-def detect_watermark(
-    image: Union[np.ndarray, Image.Image],
-    midtone_low: int = 100,
-    midtone_high: int = 220,
-    ratio_threshold: float = 0.03,
-    check_diagonal: bool = True,
-    diagonal_angle_range: tuple = (30, 60),
-) -> bool:
-    """
-    检测图像中是否存在浅色斜向文字水印(银行流水类文档水印检测)。
-
-    原理:
-    1. 将图像转为灰度,提取「中间调」像素(midtone_low ~ midtone_high),
-       这些像素既不是纯白背景,也不是深黑正文,是浅灰水印的典型范围。
-    2. 若中间调像素占比超过 ratio_threshold,初步判定存在水印。
-    3. 若 check_diagonal=True,进一步用 Hough 直线变换验证中间调区域
-       是否呈现斜向(diagonal_angle_range 度)纹理,以排除灰色背景误报。
-
-    Args:
-        image: 输入图像,支持 PIL.Image 或 np.ndarray(BGR/RGB/灰度)。
-        midtone_low: 中间调下限(默认 100),低于此视为深色正文。
-        midtone_high: 中间调上限(默认 220),高于此视为纯白背景。
-        ratio_threshold: 中间调像素占全图比例阈值(默认 0.03 即 3%)。
-        check_diagonal: 是否进行斜向纹理验证(默认 True)。
-        diagonal_angle_range: 斜向角度范围(度),默认 (30, 60),含 45° 斜水印。
-
-    Returns:
-        True 表示检测到水印,False 表示未检测到。
-    """
-    if isinstance(image, Image.Image):
-        pil_img = image.convert('RGB') if image.mode == 'RGBA' else image
-        np_img = np.array(pil_img)
-        gray = cv2.cvtColor(np_img, cv2.COLOR_RGB2GRAY) if np_img.ndim == 3 else np_img
-    else:
-        np_img = image
-        gray = cv2.cvtColor(np_img, cv2.COLOR_BGR2GRAY) if np_img.ndim == 3 else np_img
-
-    midtone_mask = (gray > midtone_low) & (gray < midtone_high)
-    ratio = midtone_mask.sum() / gray.size
-
-    if ratio < ratio_threshold:
-        return False
-
-    if not check_diagonal:
-        return True
-
-    midtone_uint8 = (midtone_mask.astype(np.uint8)) * 255
-    edges = cv2.Canny(midtone_uint8, 50, 150, apertureSize=3)
-    lines = cv2.HoughLines(edges, rho=1, theta=np.pi / 180, threshold=80)
-
-    if lines is None:
-        return False
-
-    low_rad = np.deg2rad(diagonal_angle_range[0])
-    high_rad = np.deg2rad(diagonal_angle_range[1])
-    diagonal_count = 0
-    for line in lines:
-        theta = line[0][1]
-        if low_rad <= theta <= high_rad or (np.pi - high_rad) <= theta <= (np.pi - low_rad):
-            diagonal_count += 1
-
-    return True | False
-
-
-def _local_std_map(gray: np.ndarray, window: int = 5) -> np.ndarray:
-    """局部标准差图(返回值与输入同形状)。"""
-    gray = np.asarray(gray, dtype=np.float32)
-    size = max(3, int(window))
-    kernel = np.ones((size, size), dtype=np.float32) / (size * size)
-    mean = cv2.filter2D(gray, -1, kernel)
-    sq_mean = cv2.filter2D(gray * gray, -1, kernel)
-    var = sq_mean - mean * mean
-    var = np.maximum(var, 0)
-    return np.sqrt(var)
-
-
-def _line_structuring_kernel(length: int, angle_deg: float) -> np.ndarray:
-    """生成指定角度、长度的线形结构元(用于斜向水印形态学)。"""
-    length = max(3, int(length))
-    k = np.zeros((length, length), np.uint8)
-    c = length // 2
-    rad = np.deg2rad(angle_deg)
-    dx = int(round(np.cos(rad) * (c - 1)))
-    dy = int(round(np.sin(rad) * (c - 1)))
-    cv2.line(k, (c - dx, c - dy), (c + dx, c + dy), 1, thickness=1)
-    return k
-
-
-def _line_angle_deg(x1: int, y1: int, x2: int, y2: int) -> float:
-    """线段方向角 [0, 180)(无向)。"""
-    ang = float(np.degrees(np.arctan2(y2 - y1, x2 - x1)))
-    if ang < 0:
-        ang += 180.0
-    return ang
-
-
-def _angle_in_diagonal_ranges(
-    angle_deg: float,
-    ranges: Tuple[Tuple[float, float], Tuple[float, float]] = ((35.0, 55.0), (125.0, 145.0)),
-) -> bool:
-    for lo, hi in ranges:
-        if lo <= angle_deg <= hi:
-            return True
-    return False
-
-
-def _angle_distance_deg(a: float, b: float) -> float:
-    """无向角距离 [0, 90]。"""
-    d = abs(float(a) - float(b)) % 180.0
-    return min(d, 180.0 - d)
-
-
-def _line_length(x1: int, y1: int, x2: int, y2: int) -> float:
-    return float(np.hypot(x2 - x1, y2 - y1))
-
-
-def _find_dominant_diagonal_angles(
-    segments: list,
-    *,
-    angle_ranges: Tuple[Tuple[float, float], Tuple[float, float]] = ((25.0, 65.0), (115.0, 155.0)),
-    smooth_sigma: float = 2.0,
-    secondary_peak_ratio: float = 0.35,
-) -> Tuple[list, np.ndarray]:
-    """
-    按线段长度加权统计角度直方图,取主峰(及次峰)作为本页水印固定方向。
-
-    Returns:
-        dominant_angles: 1~2 个主导角度(度)
-        hist_smooth: 长度 180 的平滑直方图
-    """
-    hist = np.zeros(180, dtype=np.float64)
-    for x1, y1, x2, y2, ang, length in segments:
-        if not _angle_in_diagonal_ranges(ang, angle_ranges):
-            continue
-        hist[int(ang) % 180] += length
-
-    if hist.sum() <= 0:
-        return [], hist
-
-    ksize = max(3, int(smooth_sigma * 4) | 1)
-    hist_smooth = cv2.GaussianBlur(
-        hist.reshape(1, 180).astype(np.float32), (ksize, 1), smooth_sigma
-    ).flatten().astype(np.float64)
-
-    peaks: list = []
-    for lo, hi in angle_ranges:
-        lo_i, hi_i = int(lo), int(hi)
-        sub = hist_smooth[lo_i : hi_i + 1]
-        if sub.size == 0 or sub.max() <= 0:
-            continue
-        peak_ang = lo_i + int(sub.argmax())
-        peaks.append((peak_ang, float(sub.max())))
-
-    if not peaks:
-        return [], hist_smooth
-
-    peaks.sort(key=lambda x: -x[1])
-    dominant: list = [peaks[0][0]]
-    for ang, val in peaks[1:]:
-        if val >= peaks[0][1] * secondary_peak_ratio:
-            if all(_angle_distance_deg(ang, d) > 15 for d in dominant):
-                dominant.append(ang)
-    return dominant, hist_smooth
-
-
-def _render_angle_histogram(hist: np.ndarray, dominant_angles: list) -> np.ndarray:
-    """角度直方图 debug 图(BGR)。"""
-    h_img, w_img = 120, 360
-    canvas = np.ones((h_img, w_img, 3), dtype=np.uint8) * 255
-    if hist.max() <= 0:
-        return canvas
-    norm = (hist / hist.max() * (h_img - 20)).astype(np.int32)
-    for i, h in enumerate(norm):
-        x = int(i * (w_img - 1) / 179)
-        cv2.line(canvas, (x, h_img - 10), (x, h_img - 10 - int(h)), (180, 180, 180), 1)
-    for ang in dominant_angles:
-        x = int(ang * (w_img - 1) / 179)
-        cv2.line(canvas, (x, 0), (x, h_img - 1), (0, 0, 255), 2)
-    cv2.putText(canvas, "angle (deg)", (w_img // 2 - 40, h_img - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 0, 0), 1)
-    return canvas
-
-
-def _build_diag_hough_region_mask(
-    gray: np.ndarray,
-    *,
-    midtone_low: int = 200,
-    midtone_high: int = 254,
-    canny_low: int = 30,
-    canny_high: int = 100,
-    hough_threshold: int = 30,
-    min_line_length: int = 40,
-    max_line_gap: int = 15,
-    angle_ranges: Tuple[Tuple[float, float], Tuple[float, float]] = ((25.0, 65.0), (115.0, 155.0)),
-    angle_tolerance: float = 5.0,
-    use_angle_statistics: bool = True,
-    secondary_peak_ratio: float = 0.35,
-    min_length_percentile: float = 25.0,
-    line_thickness: int = 10,
-    band_dilate_radius: int = 12,
-) -> Tuple[np.ndarray, Dict[str, Any]]:
-    """
-    方案 C:Canny + HoughLinesP + 角度直方图统计主峰,仅保留与本页水印方向一致的线段。
-    """
-    gray_u8 = np.asarray(gray, dtype=np.uint8)
-    band = ((gray_u8 >= midtone_low) & (gray_u8 < midtone_high)).astype(np.uint8) * 255
-    edges = cv2.Canny(band, int(canny_low), int(canny_high), apertureSize=3)
-
-    lines_p = cv2.HoughLinesP(
-        edges,
-        rho=1,
-        theta=np.pi / 180,
-        threshold=int(hough_threshold),
-        minLineLength=int(min_line_length),
-        maxLineGap=int(max_line_gap),
-    )
-
-    line_mask = np.zeros_like(gray_u8, dtype=np.uint8)
-    lines_all_bgr = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
-    lines_filt_bgr = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
-    diag_candidates: list = []
-    total_lines = 0
-
-    if lines_p is not None:
-        for seg in lines_p:
-            x1, y1, x2, y2 = [int(v) for v in seg[0]]
-            total_lines += 1
-            ang = _line_angle_deg(x1, y1, x2, y2)
-            length = _line_length(x1, y1, x2, y2)
-            if not _angle_in_diagonal_ranges(ang, angle_ranges):
-                continue
-            diag_candidates.append((x1, y1, x2, y2, ang, length))
-            cv2.line(lines_all_bgr, (x1, y1), (x2, y2), (128, 128, 128), 1)
-
-    dominant_angles: list = []
-    hist_smooth = np.zeros(180, dtype=np.float64)
-    if use_angle_statistics and diag_candidates:
-        dominant_angles, hist_smooth = _find_dominant_diagonal_angles(
-            diag_candidates,
-            angle_ranges=angle_ranges,
-            secondary_peak_ratio=secondary_peak_ratio,
-        )
-
-    def _angle_matches(ang: float) -> bool:
-        if not use_angle_statistics or not dominant_angles:
-            return True
-        return any(_angle_distance_deg(ang, d) <= angle_tolerance for d in dominant_angles)
-
-    angle_matched = [
-        s for s in diag_candidates if _angle_matches(s[4])
-    ]
-    if angle_matched and min_length_percentile > 0:
-        lengths = np.array([s[5] for s in angle_matched], dtype=np.float32)
-        len_th = float(np.percentile(lengths, min_length_percentile))
-        angle_matched = [s for s in angle_matched if s[5] >= len_th]
-
-    matched_keys = {(s[0], s[1], s[2], s[3]) for s in angle_matched}
-    kept_lines: list = []
-    for x1, y1, x2, y2, ang, _length in angle_matched:
-        kept_lines.append((x1, y1, x2, y2, ang))
-        cv2.line(line_mask, (x1, y1), (x2, y2), 255, thickness=int(line_thickness))
-        cv2.line(lines_filt_bgr, (x1, y1), (x2, y2), (0, 0, 255), 2)
-    for x1, y1, x2, y2, _ang, _length in diag_candidates:
-        if (x1, y1, x2, y2) not in matched_keys:
-            cv2.line(lines_filt_bgr, (x1, y1), (x2, y2), (0, 180, 255), 1)
-
-    geom = line_mask > 0
-    if band_dilate_radius > 0 and np.any(geom):
-        k = cv2.getStructuringElement(
-            cv2.MORPH_ELLIPSE, (band_dilate_radius * 2 + 1, band_dilate_radius * 2 + 1)
-        )
-        geom = cv2.dilate(line_mask, k) > 0
-
-    info: Dict[str, Any] = {
-        "hough_total_lines": total_lines,
-        "hough_diag_candidates": len(diag_candidates),
-        "hough_kept_lines": len(kept_lines),
-        "dominant_angles": dominant_angles,
-        "angle_tolerance": angle_tolerance,
-        "geom_mask_ratio": float(geom.sum() / gray_u8.size),
-        "hough_lines_bgr": lines_filt_bgr,
-        "hough_lines_all_bgr": lines_all_bgr,
-        "angle_histogram_bgr": _render_angle_histogram(hist_smooth, dominant_angles),
-    }
-    return geom, info
-
-
-def _compute_block_orientation_debug_maps(
-    gray: np.ndarray,
-    *,
-    block_size: int = 48,
-) -> Tuple[np.ndarray, np.ndarray]:
-    """分块 diag/hv 弱边缘占比图(仅 debug 热力图,0~1 float)。"""
-    gray_f = np.asarray(gray, dtype=np.float32)
-    bs = max(4, int(block_size))
-    h_blocks = gray_f.shape[0] // bs
-    w_blocks = gray_f.shape[1] // bs
-    if h_blocks == 0 or w_blocks == 0:
-        z = np.zeros_like(gray_f, dtype=np.float32)
-        return z, z
-
-    ph, pw = h_blocks * bs, w_blocks * bs
-    gx = cv2.Sobel(gray_f, cv2.CV_32F, 1, 0, ksize=3)
-    gy = cv2.Sobel(gray_f, cv2.CV_32F, 0, 1, ksize=3)
-    mag = np.sqrt(gx * gx + gy * gy)
-    ori = np.arctan2(gy, gx) * 180.0 / np.pi
-
-    diag = (
-        ((ori > 25) & (ori < 65))
-        | ((ori > 115) & (ori < 155))
-        | ((ori > -155) & (ori < -115))
-        | ((ori > -65) & (ori < -25))
-    )
-    hv = (
-        ((ori > -20) & (ori < 20))
-        | ((ori > 160) | (ori < -160))
-        | ((ori > 70) & (ori < 110))
-        | ((ori > -110) & (ori < -70))
-    )
-    weak = (mag > 1) & (mag < 15)
-
-    def _to_blocks(arr: np.ndarray) -> np.ndarray:
-        return (
-            arr[:ph, :pw]
-            .reshape(h_blocks, bs, w_blocks, bs)
-            .transpose(0, 2, 1, 3)
-            .reshape(h_blocks, w_blocks, -1)
-        )
-
-    b_diag = _to_blocks(diag)
-    b_hv = _to_blocks(hv)
-    b_weak = _to_blocks(weak)
-    diag_weak = np.sum(b_diag & b_weak, axis=2)
-    hv_weak = np.sum(b_hv & b_weak, axis=2)
-    total_weak = np.sum(b_weak, axis=2)
-    with np.errstate(divide="ignore", invalid="ignore"):
-        diag_ratio = np.where(total_weak > 0, diag_weak / total_weak, 0.0).astype(np.float32)
-        hv_ratio = np.where(total_weak > 0, hv_weak / total_weak, 0.0).astype(np.float32)
-
-    diag_up = np.repeat(np.repeat(diag_ratio, bs, axis=0), bs, axis=1)
-    hv_up = np.repeat(np.repeat(hv_ratio, bs, axis=0), bs, axis=1)
-    diag_full = np.zeros_like(gray_f, dtype=np.float32)
-    hv_full = np.zeros_like(gray_f, dtype=np.float32)
-    diag_full[:ph, :pw] = diag_up
-    hv_full[:ph, :pw] = hv_up
-    return diag_full, hv_full
-
-
-def render_ratio_heatmap(ratio_map: np.ndarray) -> np.ndarray:
-    """将 0~1 浮点占比图转为 BGR 热力图。"""
-    r = np.clip(np.asarray(ratio_map, dtype=np.float32), 0.0, 1.0)
-    u8 = (r * 255).astype(np.uint8)
-    return cv2.applyColorMap(u8, cv2.COLORMAP_JET)
-
-
-def save_watermark_mask_debug_layers(
-    image: np.ndarray,
-    output_dir: Union[str, Path],
-    stem: str,
-    debug: Dict[str, Any],
-    *,
-    image_format: str = "png",
-) -> Dict[str, str]:
-    """保存分层 debug 图(方案 D)。"""
-    out_dir = Path(output_dir)
-    out_dir.mkdir(parents=True, exist_ok=True)
-    fmt = (image_format or "png").lstrip(".")
-    paths: Dict[str, str] = {}
-
-    def _save_overlay(name: str, mask: Optional[np.ndarray], color=(0, 0, 255)) -> None:
-        if mask is None or not np.any(mask):
-            return
-        ov = render_watermark_mask_overlay(image, mask, color=color)
-        p = out_dir / f"{stem}_{name}.{fmt}"
-        cv2.imwrite(str(p), cv2.cvtColor(ov, cv2.COLOR_RGB2BGR) if ov.shape[2] == 3 else ov)
-        paths[name] = str(p)
-
-    _save_overlay("wm_candidate_overlay", debug.get("wm_candidate"))
-    _save_overlay("geom_region_overlay", debug.get("geom_region"), color=(0, 180, 255))
-    _save_overlay("geom_candidate_overlay", debug.get("geom_candidate"), color=(0, 255, 0))
-    _save_overlay("wm_mask_overlay", debug.get("wm_mask"), color=(255, 0, 0))
-
-    hough_bgr = debug.get("hough_lines_bgr")
-    if hough_bgr is not None:
-        p = out_dir / f"{stem}_hough_lines.{fmt}"
-        cv2.imwrite(str(p), hough_bgr)
-        paths["hough_lines"] = str(p)
-
-    hough_all = debug.get("hough_lines_all_bgr")
-    if hough_all is not None:
-        p = out_dir / f"{stem}_hough_lines_all.{fmt}"
-        cv2.imwrite(str(p), hough_all)
-        paths["hough_lines_all"] = str(p)
-
-    angle_hist = debug.get("angle_histogram_bgr")
-    if angle_hist is not None:
-        p = out_dir / f"{stem}_angle_histogram.{fmt}"
-        cv2.imwrite(str(p), angle_hist)
-        paths["angle_histogram"] = str(p)
-
-    diag_hm = debug.get("diag_ratio_heatmap")
-    if diag_hm is not None:
-        p = out_dir / f"{stem}_diag_ratio_heatmap.{fmt}"
-        cv2.imwrite(str(p), diag_hm)
-        paths["diag_ratio_heatmap"] = str(p)
-
-    hv_hm = debug.get("hv_ratio_heatmap")
-    if hv_hm is not None:
-        p = out_dir / f"{stem}_hv_ratio_heatmap.{fmt}"
-        cv2.imwrite(str(p), hv_hm)
-        paths["hv_ratio_heatmap"] = str(p)
-
-    return paths
-
-
-def _build_diag_region_mask(
-    gray: np.ndarray,
-    *,
-    block_size: int = 48,
-    diag_ratio_thresh: float = 0.20,
-    light_gray_thresh: int = 238,
-    light_ratio_thresh: float = 0.10,
-    min_edge_count: int = 10,
-    dilate_radius: int = 3,
-) -> np.ndarray:
-    """
-    分块梯度方向检测:返回对角线方向纹理占优的区域掩膜。
-
-    原理:水印是45°斜向字符,其梯度主方向在30-60°和120-150°。
-    分块统计该方向弱边缘占比,高频块标记为水印候选区域。
-
-    Returns:
-        bool ndarray, 与 gray 同形状,True=疑似斜向水印区域。
-    """
-    gray_f = np.asarray(gray, dtype=np.float32)
-    img_h, img_w = gray_f.shape
-    bs = max(4, int(block_size))
-
-    # Sobel 梯度
-    gx = cv2.Sobel(gray_f, cv2.CV_32F, 1, 0, ksize=3)
-    gy = cv2.Sobel(gray_f, cv2.CV_32F, 0, 1, ksize=3)
-    mag = np.sqrt(gx * gx + gy * gy)
-    ori = np.arctan2(gy, gx) * 180.0 / np.pi
-
-    # 对角线方向 (±45° 附近,即梯度 30-65° / 115-155°)
-    diag = (
-        ((ori > 25) & (ori < 65))
-        | ((ori > 115) & (ori < 155))
-        | ((ori > -155) & (ori < -115))
-        | ((ori > -65) & (ori < -25))
-    )
-
-    h_blocks = img_h // bs
-    w_blocks = img_w // bs
-    if h_blocks == 0 or w_blocks == 0:
-        return np.zeros_like(gray, dtype=bool)
-
-    ph, pw = h_blocks * bs, w_blocks * bs
-
-    # 分块统计
-    def _to_blocks(arr: np.ndarray) -> np.ndarray:
-        return arr[:ph, :pw].reshape(h_blocks, bs, w_blocks, bs).transpose(0, 2, 1, 3).reshape(h_blocks, w_blocks, -1)
-
-    block_mag = _to_blocks(mag)
-    block_diag = _to_blocks(diag)
-    block_gray = _to_blocks(gray_f)
-
-    weak = (block_mag > 1) & (block_mag < 15)
-    diag_weak = np.sum(block_diag & weak, axis=2)
-    total_weak = np.sum(weak, axis=2)
-
-    with np.errstate(divide="ignore", invalid="ignore"):
-        diag_ratio = np.where(total_weak > 0, diag_weak / total_weak, 0.0)
-    light_ratio = np.mean(block_gray >= light_gray_thresh, axis=2)
-
-    wm_blocks = (
-        (diag_ratio > diag_ratio_thresh)
-        & (light_ratio > light_ratio_thresh)
-        & (total_weak > min_edge_count)
-    )
-
-    # 展开为像素掩膜
-    wm_block_mask = np.repeat(np.repeat(wm_blocks, bs, axis=0), bs, axis=1)
-    full_mask = np.zeros(gray_f.shape, dtype=bool)
-    full_mask[:ph, :pw] = wm_block_mask
-
-    if dilate_radius > 0:
-        k = cv2.getStructuringElement(
-            cv2.MORPH_ELLIPSE, (dilate_radius * 2 + 1, dilate_radius * 2 + 1)
-        )
-        full_mask = cv2.dilate(full_mask.astype(np.uint8), k) > 0
-
-    return full_mask
-
-
-def _build_seal_protect_mask(
-    bgr: np.ndarray,
-    *,
-    hue_high: int = 15,
-    sat_min: int = 40,
-    value_min: int = 30,
-) -> np.ndarray:
-    """红色/公章区域保护掩膜(True=保护,不置白)。"""
-    hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
-    lower1 = np.array([0, sat_min, value_min], dtype=np.uint8)
-    upper1 = np.array([hue_high, 255, 255], dtype=np.uint8)
-    lower2 = np.array([170, sat_min, value_min], dtype=np.uint8)
-    upper2 = np.array([180, 255, 255], dtype=np.uint8)
-    m1 = cv2.inRange(hsv, lower1, upper1)
-    m2 = cv2.inRange(hsv, lower2, upper2)
-    m2 = cv2.inRange(hsv, lower2, upper2)
-    return (m1 > 0) | (m2 > 0)
-
-
-def _build_text_edge_protect(
-    gray: np.ndarray,
-    *,
-    edge_window: int = 5,
-    edge_std_thresh: float = 6.0,
-    dilate_radius: int = 1,
-) -> np.ndarray:
-    """基于局部方差的笔画边缘保护掩膜(True=保护,不置白)。"""
-    local_std = _local_std_map(gray, window=edge_window)
-    edge_mask = local_std >= edge_std_thresh
-    if dilate_radius > 0:
-        k = cv2.getStructuringElement(
-            cv2.MORPH_ELLIPSE, (dilate_radius * 2 + 1, dilate_radius * 2 + 1)
-        )
-        edge_mask = cv2.dilate(edge_mask.astype(np.uint8), k) > 0
-    return edge_mask.astype(bool)
-
-
-def _build_watermark_mask_light_on_white(
-    gray: np.ndarray,
-    *,
-    bgr: Optional[np.ndarray] = None,
-    light_gray_low: int = 236,
-    light_gray_high: int = 253,
-    whiten_gray_low: int = 200,
-    text_protect_gray_max: int = 130,
-    text_protect_percentile: Optional[float] = None,
-    background_threshold: int = 248,
-    morph_close_kernel: int = 0,
-    morph_close_iter: int = 1,
-    morph_dilate_kernel: int = 0,
-    morph_dilate_iter: int = 1,
-    min_component_area: int = 200,
-    low_variance_thresh: float = 0.0,
-    edge_window: int = 5,
-    direction_filter: str = "hough",
-    debug_block_maps: bool = True,
-    debug_block_size: int = 48,
-    hough_midtone_low: int = 200,
-    hough_midtone_high: int = 254,
-    hough_canny_low: int = 30,
-    hough_canny_high: int = 100,
-    hough_threshold: int = 25,
-    hough_min_line_length: int = 35,
-    hough_max_line_gap: int = 18,
-    hough_line_thickness: int = 12,
-    hough_band_dilate_radius: int = 14,
-    hough_angle_tolerance: float = 5.0,
-    hough_use_angle_statistics: bool = True,
-    hough_secondary_peak_ratio: float = 0.35,
-    hough_min_length_percentile: float = 25.0,
-    diag_block_size: int = 0,
-    diag_ratio_thresh: float = 0.20,
-    diag_light_ratio_thresh: float = 0.10,
-    diag_min_edge_count: int = 10,
-    diag_dilate_radius: int = 3,
-    seal_protect: bool = True,
-    seal_hue_high: int = 15,
-    seal_sat_min: int = 40,
-) -> Tuple[np.ndarray, Dict[str, Any]]:
-    """
-    白底流水水印掩膜(方案 C + E)。
-
-    1. Hough 斜向线段 → geom_region(几何限定区域)
-    2. wm_candidate = 浅色带且非正文保护
-    3. wm_mask = geom_region(置白区域由几何约束;实际白化时再 g>=light_gray_low)
-    4. debug 输出 candidate / geom / 交集 / 热力图
-    """
-    gray_arr = np.asarray(gray)
-    bg_th = int(background_threshold)
-    low = int(light_gray_low)
-    high = int(light_gray_high)
-
-    if text_protect_gray_max > 0:
-        t_protect = float(text_protect_gray_max)
-    else:
-        dark = gray_arr[gray_arr < min(130, bg_th)]
-        if dark.size > 0 and text_protect_percentile is not None:
-            t_protect = float(np.percentile(dark, text_protect_percentile))
-        else:
-            t_protect = 120.0
-    text_protect = gray_arr <= t_protect
-    low = max(low, int(t_protect) + 25)
-
-    wm_candidate = (gray_arr >= low) & (gray_arr < high) & (~text_protect)
-
-    direction = (direction_filter or "hough").lower().strip()
-    hough_info: Dict[str, Any] = {}
-    geom_region = np.zeros_like(gray_arr, dtype=bool)
-
-    if direction == "hough":
-        geom_region, hough_info = _build_diag_hough_region_mask(
-            gray_arr,
-            midtone_low=hough_midtone_low,
-            midtone_high=hough_midtone_high,
-            canny_low=hough_canny_low,
-            canny_high=hough_canny_high,
-            hough_threshold=hough_threshold,
-            min_line_length=hough_min_line_length,
-            max_line_gap=hough_max_line_gap,
-            angle_tolerance=hough_angle_tolerance,
-            use_angle_statistics=hough_use_angle_statistics,
-            secondary_peak_ratio=hough_secondary_peak_ratio,
-            min_length_percentile=hough_min_length_percentile,
-            line_thickness=hough_line_thickness,
-            band_dilate_radius=hough_band_dilate_radius,
-        )
-    elif diag_block_size > 0:
-        geom_region = _build_diag_region_mask(
-            gray_arr,
-            block_size=diag_block_size,
-            diag_ratio_thresh=diag_ratio_thresh,
-            light_gray_thresh=low,
-            light_ratio_thresh=diag_light_ratio_thresh,
-            min_edge_count=diag_min_edge_count,
-            dilate_radius=diag_dilate_radius,
-        )
-
-    geom_candidate = geom_region & wm_candidate
-    wm_mask = geom_region.copy()
-
-    if min_component_area > 0 and np.any(wm_mask):
-        n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
-            wm_mask.astype(np.uint8), connectivity=8
-        )
-        filtered = np.zeros_like(wm_mask)
-        for i in range(1, n_labels):
-            if stats[i, cv2.CC_STAT_AREA] >= min_component_area:
-                filtered[labels == i] = True
-        if np.any(filtered):
-            wm_mask = filtered
-        elif np.any(geom_region):
-            wm_mask = geom_region
-
-    seal_mask = np.zeros_like(wm_mask, dtype=bool)
-    if seal_protect and bgr is not None and bgr.ndim == 3:
-        seal_mask = _build_seal_protect_mask(
-            bgr, hue_high=seal_hue_high, sat_min=seal_sat_min
-        )
-        wm_mask &= ~seal_mask
-
-    midtone = (gray_arr >= low) & (gray_arr < high)
-    debug: Dict[str, Any] = {
-        "mask_mode": "light_on_white",
-        "direction_filter": direction,
-        "light_gray_low": low,
-        "light_gray_high": high,
-        "midtone_ratio": float(midtone.sum() / gray_arr.size),
-        "wm_candidate_ratio": float(wm_candidate.sum() / gray_arr.size),
-        "geom_mask_ratio": float(geom_region.sum() / gray_arr.size),
-        "geom_candidate_ratio": float(geom_candidate.sum() / gray_arr.size),
-        "wm_mask_ratio": float(wm_mask.sum() / gray_arr.size),
-        "T_protect": t_protect,
-        "text_protect_gray_max": text_protect_gray_max,
-        "text_protect": text_protect,
-        "seal_protect": seal_mask,
-        "wm_candidate": wm_candidate,
-        "geom_region": geom_region,
-        "geom_candidate": geom_candidate,
-        "diag_region": geom_region,
-        "wm_mask": wm_mask,
-        "whiten_gray_low": int(whiten_gray_low),
-        "hough_lines_bgr": hough_info.get("hough_lines_bgr"),
-        "hough_lines_all_bgr": hough_info.get("hough_lines_all_bgr"),
-        "angle_histogram_bgr": hough_info.get("angle_histogram_bgr"),
-        "dominant_angles": hough_info.get("dominant_angles", []),
-        "hough_kept_lines": hough_info.get("hough_kept_lines", 0),
-        "hough_diag_candidates": hough_info.get("hough_diag_candidates", 0),
-        "hough_total_lines": hough_info.get("hough_total_lines", 0),
-    }
-
-    if debug_block_maps:
-        bs = debug_block_size if debug_block_size > 0 else 48
-        diag_map, hv_map = _compute_block_orientation_debug_maps(gray_arr, block_size=bs)
-        debug["diag_ratio_heatmap"] = render_ratio_heatmap(diag_map)
-        debug["hv_ratio_heatmap"] = render_ratio_heatmap(hv_map)
-
-    return wm_mask, debug
-
-
-def build_watermark_mask(
-    gray: np.ndarray,
-    *,
-    bgr: Optional[np.ndarray] = None,
-    mask_mode: str = "diagonal_midtone",
-    light_gray_low: int = 236,
-    light_gray_high: int = 253,
-    whiten_gray_low: int = 200,
-    text_protect_gray_max: int = 130,
-    morph_close_kernel: int = 0,
-    morph_close_iter: int = 1,
-    morph_dilate_kernel: int = 0,
-    morph_dilate_iter: int = 1,
-    low_variance_thresh: float = 0.0,
-    edge_window: int = 5,
-    direction_filter: str = "hough",
-    debug_block_maps: bool = True,
-    debug_block_size: int = 48,
-    hough_midtone_low: int = 200,
-    hough_midtone_high: int = 254,
-    hough_canny_low: int = 30,
-    hough_canny_high: int = 100,
-    hough_threshold: int = 25,
-    hough_min_line_length: int = 35,
-    hough_max_line_gap: int = 18,
-    hough_line_thickness: int = 12,
-    hough_band_dilate_radius: int = 14,
-    hough_angle_tolerance: float = 5.0,
-    hough_use_angle_statistics: bool = True,
-    hough_secondary_peak_ratio: float = 0.35,
-    hough_min_length_percentile: float = 25.0,
-    diag_block_size: int = 0,
-    diag_ratio_thresh: float = 0.20,
-    diag_light_ratio_thresh: float = 0.10,
-    diag_min_edge_count: int = 10,
-    diag_dilate_radius: int = 3,
-    # diagonal_midtone 参数
-    midtone_low: int = 100,
-    midtone_high: int = 220,
-    remove_horizontal_vertical: bool = True,
-    diagonal_enhance: bool = True,
-    diagonal_kernel_length: int = 25,
-    horizontal_kernel_length: int = 35,
-    vertical_kernel_length: int = 35,
-    morph_open_kernel: int = 2,
-    dmorph_close_kernel: int = 3,
-    min_component_area: int = 200,
-    text_protect_percentile: float = 10.0,
-    background_threshold: int = 248,
-    seal_protect: bool = True,
-    seal_hue_high: int = 15,
-    seal_sat_min: int = 40,
-) -> Tuple[np.ndarray, Dict[str, Any]]:
-    """
-    构建水印掩膜 wm_mask(True=疑似水印像素)。
-
-    mask_mode:
-        light_on_white — Hough 斜向几何带 + 浅色白化(方案 C/E)
-        diagonal_midtone — 中间调 + 斜向形态学(旧逻辑)
-    """
-    gray = np.asarray(gray)
-    if gray.ndim != 2:
-        raise ValueError("build_watermark_mask expects single-channel grayscale")
-
-    mode = (mask_mode or "light_on_white").lower().strip()
-    if mode == "light_on_white":
-        return _build_watermark_mask_light_on_white(
-            gray,
-            bgr=bgr,
-            light_gray_low=light_gray_low,
-            light_gray_high=light_gray_high,
-            whiten_gray_low=whiten_gray_low,
-            text_protect_gray_max=text_protect_gray_max,
-            text_protect_percentile=text_protect_percentile,
-            background_threshold=background_threshold,
-            morph_close_kernel=morph_close_kernel,
-            morph_close_iter=morph_close_iter,
-            morph_dilate_kernel=morph_dilate_kernel,
-            morph_dilate_iter=morph_dilate_iter,
-            low_variance_thresh=low_variance_thresh,
-            edge_window=edge_window,
-            min_component_area=min_component_area,
-            direction_filter=direction_filter,
-            debug_block_maps=debug_block_maps,
-            debug_block_size=debug_block_size,
-            hough_midtone_low=hough_midtone_low,
-            hough_midtone_high=hough_midtone_high,
-            hough_canny_low=hough_canny_low,
-            hough_canny_high=hough_canny_high,
-            hough_threshold=hough_threshold,
-            hough_min_line_length=hough_min_line_length,
-            hough_max_line_gap=hough_max_line_gap,
-            hough_line_thickness=hough_line_thickness,
-            hough_band_dilate_radius=hough_band_dilate_radius,
-            hough_angle_tolerance=hough_angle_tolerance,
-            hough_use_angle_statistics=hough_use_angle_statistics,
-            hough_secondary_peak_ratio=hough_secondary_peak_ratio,
-            hough_min_length_percentile=hough_min_length_percentile,
-            diag_block_size=diag_block_size,
-            diag_ratio_thresh=diag_ratio_thresh,
-            diag_light_ratio_thresh=diag_light_ratio_thresh,
-            diag_min_edge_count=diag_min_edge_count,
-            diag_dilate_radius=diag_dilate_radius,
-            seal_protect=seal_protect,
-            seal_hue_high=seal_hue_high,
-            seal_sat_min=seal_sat_min,
-        )
-
-    midtone = (gray > midtone_low) & (gray < midtone_high)
-    mid_u8 = (midtone.astype(np.uint8)) * 255
-
-    horiz = np.zeros_like(midtone, dtype=bool)
-    vert = np.zeros_like(midtone, dtype=bool)
-    if remove_horizontal_vertical:
-        kh = cv2.getStructuringElement(
-            cv2.MORPH_RECT, (max(3, horizontal_kernel_length), 1)
-        )
-        kv = cv2.getStructuringElement(
-            cv2.MORPH_RECT, (1, max(3, vertical_kernel_length))
-        )
-        horiz = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, kh) > 0
-        vert = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, kv) > 0
-
-    # 中间调去掉明显横竖线(保留斜向水印)
-    candidate = midtone & ~(horiz | vert)
-
-    if diagonal_enhance:
-        k45 = _line_structuring_kernel(diagonal_kernel_length, 45)
-        k135 = _line_structuring_kernel(diagonal_kernel_length, 135)
-        d45 = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, k45) > 0
-        d135 = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, k135) > 0
-        direction = d45 | d135
-        dilate_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
-        near_diag = cv2.dilate(direction.astype(np.uint8), dilate_k) > 0
-        # 斜向结构足够时收窄到斜向附近;否则保留「中间调减横竖」结果
-        if near_diag.sum() > gray.size * 0.001:
-            candidate = candidate & near_diag
-
-    cand_u8 = (candidate.astype(np.uint8)) * 255
-    if morph_open_kernel > 0:
-        k_open = cv2.getStructuringElement(
-            cv2.MORPH_ELLIPSE, (morph_open_kernel, morph_open_kernel)
-        )
-        cand_u8 = cv2.morphologyEx(cand_u8, cv2.MORPH_OPEN, k_open)
-    if dmorph_close_kernel > 0:
-        k_close = cv2.getStructuringElement(
-            cv2.MORPH_ELLIPSE, (dmorph_close_kernel, dmorph_close_kernel)
-        )
-        cand_u8 = cv2.morphologyEx(cand_u8, cv2.MORPH_CLOSE, k_close)
-
-    wm_mask = cand_u8 > 0
-
-    if min_component_area > 0:
-        n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
-            wm_mask.astype(np.uint8), connectivity=8
-        )
-        filtered = np.zeros_like(wm_mask)
-        for i in range(1, n_labels):
-            if stats[i, cv2.CC_STAT_AREA] >= min_component_area:
-                filtered[labels == i] = True
-        wm_mask = filtered
-
-    non_bg = gray[gray < background_threshold]
-    if non_bg.size > 0:
-        t_protect = float(np.percentile(non_bg, text_protect_percentile))
-    else:
-        t_protect = 85.0
-    t_protect = max(t_protect, float(midtone_low))
-    text_protect = gray <= t_protect
-
-    midtone_ratio = float(midtone.sum() / gray.size)
-    wm_ratio = float(wm_mask.sum() / gray.size)
-
-    # 掩膜过小:回退为「中间调减横竖」或整块中间调(满版斜纹水印常见)
-    min_wm_ratio = max(0.005, midtone_ratio * 0.12)
-    if wm_ratio < min_wm_ratio:
-        relaxed = midtone & ~(horiz | vert) & (~text_protect)
-        if relaxed.sum() / gray.size < min_wm_ratio:
-            relaxed = midtone & (~text_protect)
-        wm_mask = relaxed
-        wm_ratio = float(wm_mask.sum() / gray.size)
-
-    seal_mask = np.zeros_like(wm_mask, dtype=bool)
-    if seal_protect and bgr is not None and bgr.ndim == 3:
-        seal_mask = _build_seal_protect_mask(
-            bgr, hue_high=seal_hue_high, sat_min=seal_sat_min
-        )
-
-    debug: Dict[str, Any] = {
-        "mask_mode": "diagonal_midtone",
-        "midtone_ratio": midtone_ratio,
-        "wm_mask_ratio": wm_ratio,
-        "T_protect": t_protect,
-        "text_protect": text_protect,
-        "seal_protect": seal_mask,
-        "midtone_mask": midtone,
-        "wm_mask": wm_mask,
-    }
-    return wm_mask, debug
-
-
-def remove_watermark_masked_adaptive(
-    gray: np.ndarray,
-    *,
-    bgr: Optional[np.ndarray] = None,
-    mask_cfg: Optional[Dict[str, Any]] = None,
-    adaptive_cfg: Optional[Dict[str, Any]] = None,
-    threshold_fallback: int = 175,
-    morph_close_kernel: int = 0,
-) -> Tuple[np.ndarray, Dict[str, Any]]:
-    """
-    掩膜内置白(whiten_mode=mask_fill)或掩膜内动态阈值(threshold_in_mask)。
-
-    掩膜为空时回退全局 threshold_fallback。
-    """
-    gray = np.asarray(gray).copy()
-    mcfg: Dict[str, Any] = {
-        "mask_mode": "light_on_white",
-        "light_gray_low": 236,
-        "light_gray_high": 253,
-        "whiten_gray_low": 200,
-        "text_protect_gray_max": 130,
-        "morph_close_kernel": 0,
-        "morph_close_iter": 1,
-        "morph_dilate_kernel": 0,
-        "morph_dilate_iter": 1,
-        "low_variance_thresh": 0.0,
-        "edge_window": 5,
-        "min_component_area": 200,
-        "direction_filter": "hough",
-        "debug_block_maps": True,
-        "debug_block_size": 48,
-        "hough_midtone_low": 200,
-        "hough_midtone_high": 254,
-        "hough_canny_low": 30,
-        "hough_canny_high": 100,
-        "hough_threshold": 25,
-        "hough_min_line_length": 35,
-        "hough_max_line_gap": 18,
-        "hough_line_thickness": 12,
-        "hough_band_dilate_radius": 14,
-        "hough_angle_tolerance": 5.0,
-        "hough_use_angle_statistics": True,
-        "hough_secondary_peak_ratio": 0.35,
-        "hough_min_length_percentile": 25.0,
-        "diag_block_size": 0,
-        "diag_ratio_thresh": 0.20,
-        "diag_light_ratio_thresh": 0.10,
-        "diag_min_edge_count": 10,
-        "diag_dilate_radius": 3,
-        "midtone_low": 100,
-        "midtone_high": 220,
-        "remove_horizontal_vertical": True,
-        "diagonal_enhance": True,
-        "diagonal_kernel_length": 25,
-        "horizontal_kernel_length": 35,
-        "vertical_kernel_length": 35,
-        "morph_open_kernel": 2,
-        "dmorph_close_kernel": 3,
-        "text_protect_percentile": 10.0,
-        "background_threshold": 248,
-        "seal_protect": True,
-        "seal_hue_high": 15,
-        "seal_sat_min": 40,
-    }
-    mcfg.update(mask_cfg or {})
-    mask_mode = str(mcfg.get("mask_mode", "light_on_white")).lower().strip()
-
-    # light_on_white 默认 mask_fill
-    acfg: Dict[str, Any] = {
-        "whiten_mode": None,
-        "text_percentile": 10.0,
-        "watermark_percentile": 88.0,
-        "background_percentile": 95.0,
-        "background_threshold": 248,
-        "wm_margin": 12,
-        "text_protect_max": 120,
-    }
-    acfg.update(adaptive_cfg or {})
-    whiten_mode = acfg.get("whiten_mode")
-    if not whiten_mode:
-        whiten_mode = (
-            "mask_fill"
-            if mask_mode == "light_on_white"
-            else "threshold_in_mask"
-        )
-    whiten_mode = str(whiten_mode).lower().strip()
-
-    wm_mask, debug = build_watermark_mask(gray, bgr=bgr, **mcfg)
-
-    if not np.any(wm_mask):
-        cleaned = gray.copy()
-        cleaned[gray > threshold_fallback] = 255
-        debug["mode"] = "fallback_threshold"
-        debug["threshold_fallback"] = threshold_fallback
-        if morph_close_kernel > 0:
-            kernel = np.ones((morph_close_kernel, morph_close_kernel), np.uint8)
-            cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel)
-        return cleaned, debug
-
-    bg_th = int(acfg["background_threshold"])
-    bg_pixels = gray[gray >= bg_th]
-    if bg_pixels.size > 0:
-        b_level = float(np.percentile(bg_pixels, acfg["background_percentile"]))
-    else:
-        b_level = 250.0
-
-    if mask_mode == "light_on_white":
-        t_protect = float(debug.get("T_protect", 150.0))
-    else:
-        non_bg = gray[gray < bg_th]
-        if non_bg.size > 0:
-            t_protect = float(np.percentile(non_bg, acfg["text_percentile"]))
-        else:
-            t_protect = float(debug.get("T_protect", 85.0))
-        t_protect = min(t_protect, float(acfg["text_protect_max"]))
-        t_protect = max(t_protect, float(mcfg.get("midtone_low", 100)))
-
-    text_protect = debug["text_protect"]
-    seal_protect = debug["seal_protect"]
-    t_wm: Optional[float] = None
-
-    if whiten_mode == "mask_fill":
-        # 几何带内:g>=whiten_gray_low 置白;g<=130 正文硬保护(方案 E)
-        wm_gray_low = float(
-            mcfg.get("whiten_gray_low", debug.get("whiten_gray_low", 200))
-        )
-        to_white = (
-            wm_mask
-            & (gray >= wm_gray_low)
-            & (gray < int(mcfg.get("light_gray_high", 254)))
-            & (~text_protect)
-            & (~seal_protect)
-        )
-    else:
-        mask_vals = gray[wm_mask]
-        if mask_vals.size > 0:
-            t_wm = float(np.percentile(mask_vals, acfg["watermark_percentile"]))
-        else:
-            t_wm = t_protect + 0.45 * (b_level - t_protect)
-        margin = float(acfg["wm_margin"])
-        t_wm = max(t_wm, t_protect + margin)
-        t_wm = min(t_wm, b_level - 3.0)
-        t_wm = min(t_wm, float(mcfg.get("midtone_high", 220)) - 5.0)
-        to_white = wm_mask & (gray >= t_wm) & (~text_protect) & (~seal_protect)
-
-    cleaned = gray.copy()
-    cleaned[to_white] = 255
-
-    if morph_close_kernel > 0:
-        kernel = np.ones((morph_close_kernel, morph_close_kernel), np.uint8)
-        cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel)
-
-    debug.update(
-        {
-            "mode": "masked_adaptive",
-            "mask_mode": mask_mode,
-            "whiten_mode": whiten_mode,
-            "T_wm": t_wm,
-            "T_protect": t_protect,
-            "B_level": b_level,
-            "white_pixel_ratio": float(to_white.sum() / gray.size),
-            "threshold_fallback": threshold_fallback,
-        }
-    )
-    return cleaned, debug
-
-
-def _image_to_gray_and_bgr(
-    image: Union[np.ndarray, Image.Image],
-) -> Tuple[np.ndarray, Optional[np.ndarray]]:
-    """统一为灰度 + 可选 BGR(用于掩膜公章保护)。"""
-    if isinstance(image, Image.Image):
-        pil_img = image.convert("RGB") if image.mode == "RGBA" else image
-        np_img = np.array(pil_img)
-        np_img = cv2.cvtColor(np_img, cv2.COLOR_RGB2BGR)
-    else:
-        np_img = image.copy()
-
-    if np_img.ndim == 3:
-        bgr = np_img
-        gray = cv2.cvtColor(np_img, cv2.COLOR_BGR2GRAY)
-    else:
-        bgr = None
-        gray = np_img
-    return gray, bgr
-
-
-def _enhance_text_restore(
-    gray: np.ndarray,
-    *,
-    background_threshold: int = 248,
-    text_lo_percentile: float = 1.0,
-    text_hi_percentile: float = 99.0,
-    text_black_target: int = 85,
-) -> np.ndarray:
-    """
-    仅对非背景像素做动态范围压缩,将最深笔画拉向 text_black_target(默认 ~85,接近扫描件原图)。
-
-    背景(>= background_threshold)保持白色,避免整图 gamma 导致背景发灰。
-    """
-    result = gray.copy()
-    bg_th = int(np.clip(background_threshold, 200, 255))
-    text_mask = gray < bg_th
-    if not np.any(text_mask):
-        return result
-
-    vals = gray[text_mask].astype(np.float32)
-    lo = float(np.percentile(vals, text_lo_percentile))
-    hi = float(np.percentile(vals, text_hi_percentile))
-    target = int(np.clip(text_black_target, 10, 200))
-    if hi <= lo + 1.0:
-        return result
-
-    stretched = (vals - lo) * target / (hi - lo)
-    result[text_mask] = np.clip(stretched, 0, 255).astype(np.uint8)
-    return result
-
-
-def enhance_document_contrast(
-    gray: np.ndarray,
-    method: str = "text_restore",
-    *,
-    clip_limit: float = 2.0,
-    tile_grid_size: int = 8,
-    gamma: float = 0.85,
-    black_percentile: float = 2.0,
-    white_percentile: float = 98.0,
-    background_threshold: int = 248,
-    text_lo_percentile: float = 1.0,
-    text_hi_percentile: float = 99.0,
-    text_black_target: int = 85,
-) -> np.ndarray:
-    """
-    文档灰度图对比度增强(常用于去水印后恢复笔画深度)。
-
-    Args:
-        gray: 单通道 uint8 灰度图
-        method: text_restore | clahe | gamma | linear
-        clip_limit: CLAHE 对比度限制
-        tile_grid_size: CLAHE 分块大小
-        gamma: gamma 校正指数,<1 加深文字(去水印后发浅时适用)
-        black_percentile: linear 拉伸下分位(映射到 0)
-        white_percentile: linear 拉伸上分位(映射到 255)
-        background_threshold: text_restore 背景阈值(>= 视为白底不处理)
-        text_lo_percentile: text_restore 笔画下分位
-        text_hi_percentile: text_restore 笔画上分位(映射到 text_black_target)
-        text_black_target: text_restore 最深笔画目标灰度(越小越深,建议 75~95)
-
-    Returns:
-        增强后的灰度图
-    """
-    if gray is None or gray.size == 0:
-        return gray
-    if gray.ndim != 2:
-        raise ValueError("enhance_document_contrast expects single-channel grayscale image")
-
-    method = (method or "text_restore").lower().strip()
-
-    if method == "text_restore":
-        return _enhance_text_restore(
-            gray,
-            background_threshold=background_threshold,
-            text_lo_percentile=text_lo_percentile,
-            text_hi_percentile=text_hi_percentile,
-            text_black_target=text_black_target,
-        )
-
-    if method == "gamma":
-        gamma = max(0.1, min(float(gamma), 3.0))
-        inv_gamma = 1.0 / gamma
-        table = np.array(
-            [((i / 255.0) ** inv_gamma) * 255 for i in range(256)],
-            dtype=np.uint8,
-        )
-        return cv2.LUT(gray, table)
-
-    if method == "linear":
-        p_low = float(np.percentile(gray, black_percentile))
-        p_high = float(np.percentile(gray, white_percentile))
-        if p_high <= p_low + 1.0:
-            return gray
-        stretched = (gray.astype(np.float32) - p_low) * 255.0 / (p_high - p_low)
-        return np.clip(stretched, 0, 255).astype(np.uint8)
-
-    # 默认 CLAHE:局部对比度,适合扫描件
-    tile = max(2, int(tile_grid_size))
-    clahe = cv2.createCLAHE(
-        clipLimit=max(0.1, float(clip_limit)),
-        tileGridSize=(tile, tile),
-    )
-    return clahe.apply(gray)
-
-
-def apply_contrast_enhancement_config(
-    gray: np.ndarray,
-    contrast_cfg: Optional[Dict[str, Any]],
-) -> np.ndarray:
-    """按配置字典应用对比度增强;未启用时原样返回。"""
-    if not contrast_cfg or not contrast_cfg.get("enabled", False):
-        return gray
-    return enhance_document_contrast(
-        gray,
-        method=contrast_cfg.get("method", "text_restore"),
-        clip_limit=contrast_cfg.get("clip_limit", 2.0),
-        tile_grid_size=contrast_cfg.get("tile_grid_size", 8),
-        gamma=contrast_cfg.get("gamma", 0.85),
-        black_percentile=contrast_cfg.get("black_percentile", 2.0),
-        white_percentile=contrast_cfg.get("white_percentile", 98.0),
-        background_threshold=contrast_cfg.get("background_threshold", 248),
-        text_lo_percentile=contrast_cfg.get("text_lo_percentile", 1.0),
-        text_hi_percentile=contrast_cfg.get("text_hi_percentile", 99.0),
-        text_black_target=contrast_cfg.get("text_black_target", 75),
-    )
-
-
-def remove_watermark_from_image(
-    image: Union[np.ndarray, Image.Image],
-    threshold: int = 160,
-    morph_close_kernel: int = 2,
-    return_pil: Optional[bool] = None,
-    watermark_removal_cfg: Optional[Dict[str, Any]] = None,
-    removal_debug: Optional[Dict[str, Any]] = None,
-) -> Union[np.ndarray, Image.Image]:
-    """
-    去除图像中的浅色斜向文字水印,返回灰度图。
-
-    method(watermark_removal_cfg):
-        threshold(默认): gray > threshold → 255
-        masked / masked_adaptive: 掩膜 + 掩膜内动态阈值
-
-    Args:
-        image: 输入图像(PIL.Image 或 np.ndarray BGR/RGB/灰度)。
-        threshold: 全局阈值或掩膜失败时的回退阈值。
-        morph_close_kernel: 形态学闭运算核大小,0 跳过。
-        watermark_removal_cfg: 完整配置(含 method / mask / adaptive)。
-        removal_debug: 若传入 dict,写入掩膜与 T_wm 等调试字段。
-
-    Returns:
-        去除水印后的灰度图:PIL.Image(mode='L') 或 np.ndarray(HxW, uint8)。
-    """
-    input_is_pil = isinstance(image, Image.Image)
-    cfg = watermark_removal_cfg or {}
-    method = str(cfg.get("method") or "threshold").lower().strip()
-
-    gray, bgr = _image_to_gray_and_bgr(image)
-
-    if method in ("masked", "masked_adaptive"):
-        cleaned, dbg = remove_watermark_masked_adaptive(
-            gray,
-            bgr=bgr,
-            mask_cfg=cfg.get("mask") if isinstance(cfg.get("mask"), dict) else None,
-            adaptive_cfg=cfg.get("adaptive")
-            if isinstance(cfg.get("adaptive"), dict)
-            else None,
-            threshold_fallback=threshold,
-            morph_close_kernel=morph_close_kernel,
-        )
-        if removal_debug is not None:
-            removal_debug.clear()
-            removal_debug.update(dbg)
-    else:
-        cleaned = gray.copy()
-        cleaned[gray > threshold] = 255
-        if morph_close_kernel > 0:
-            kernel = np.ones((morph_close_kernel, morph_close_kernel), np.uint8)
-            cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel)
-        if removal_debug is not None:
-            removal_debug.clear()
-            removal_debug.update({"mode": "threshold", "threshold": threshold})
-
-    should_return_pil = input_is_pil if return_pil is None else return_pil
-    return Image.fromarray(cleaned, mode='L') if should_return_pil else cleaned
-
-
-def remove_watermark_from_image_rgb(
-    image: Union[np.ndarray, Image.Image],
-    threshold: int = 160,
-    morph_close_kernel: int = 2,
-    return_pil: Optional[bool] = None,
-    contrast_enhancement: Optional[Dict[str, Any]] = None,
-    apply_watermark_removal: bool = True,
-    watermark_removal_cfg: Optional[Dict[str, Any]] = None,
-    removal_debug: Optional[Dict[str, Any]] = None,
-) -> Union[np.ndarray, Image.Image]:
-    """
-    去除水印并返回 RGB 三通道图像。
-
-    与 remove_watermark_from_image 逻辑相同,但输出为 RGB(三通道),
-    方便直接传入布局检测、OCR 等需要彩色输入的下游模型。
-
-    Args:
-        contrast_enhancement: 对比度增强配置(含 enabled / method 等),见 apply_contrast_enhancement_config
-        apply_watermark_removal: False 时跳过阈值抹白,仅做对比度增强(若启用)
-
-    Args/Returns: 同 remove_watermark_from_image,但输出为 RGB/BGR 三通道。
-    """
-    input_is_pil = isinstance(image, Image.Image)
-
-    if apply_watermark_removal:
-        gray_result = remove_watermark_from_image(
-            image,
-            threshold,
-            morph_close_kernel,
-            return_pil=False,
-            watermark_removal_cfg=watermark_removal_cfg,
-            removal_debug=removal_debug,
-        )
-    else:
-        if isinstance(image, Image.Image):
-            np_img = np.array(image.convert("RGB"))
-            np_img = cv2.cvtColor(np_img, cv2.COLOR_RGB2BGR)
-        else:
-            np_img = image.copy()
-        gray_result = (
-            cv2.cvtColor(np_img, cv2.COLOR_BGR2GRAY)
-            if np_img.ndim == 3
-            else np_img
-        )
-
-    gray_result = apply_contrast_enhancement_config(gray_result, contrast_enhancement)
-    rgb_np = cv2.cvtColor(gray_result, cv2.COLOR_GRAY2BGR)
-
-    should_return_pil = input_is_pil if return_pil is None else return_pil
-    if should_return_pil:
-        return Image.fromarray(cv2.cvtColor(rgb_np, cv2.COLOR_BGR2RGB))
-    return rgb_np
-
-
-def render_watermark_mask_overlay(
-    image: np.ndarray,
-    wm_mask: np.ndarray,
-    *,
-    color: Tuple[int, int, int] = (0, 0, 255),
-    alpha: float = 0.45,
-) -> np.ndarray:
-    """在原图上叠加红色半透明水印掩膜,供调试图保存。"""
-    if image.ndim == 2:
-        base = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
-    elif image.shape[2] == 3:
-        base = image.copy()
-        if image.max() <= 1:
-            base = (image * 255).astype(np.uint8)
-    else:
-        base = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
-
-    overlay = base.copy()
-    overlay[wm_mask] = color
-    return cv2.addWeighted(base, 1.0 - alpha, overlay, alpha, 0)
-
-
-def _image_to_bgr_for_debug(img: np.ndarray) -> np.ndarray:
-    """将 ndarray 转为 BGR,供 cv2.imwrite 使用。"""
-    if img.ndim == 2:
-        return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
-    out = img.copy()
-    if out.shape[2] == 3:
-        return cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
-    return out
-
-
-def save_watermark_removal_debug(
-    before: Union[np.ndarray, Image.Image],
-    after: Union[np.ndarray, Image.Image],
-    output_dir: Union[str, Path],
-    page_name: str,
-    *,
-    processing_params: Optional[Dict[str, Any]] = None,
-    image_format: str = "png",
-    save_compare: bool = True,
-    subdir: str = "watermark_removal",
-    mask_overlay: Optional[np.ndarray] = None,
-) -> Dict[str, str]:
-    """
-    保存去水印调试图(before / after / compare / meta.json)。
-
-    与 universal_doc_parser 的 module debug 目录结构一致:
-    ``{output_dir}/debug/{subdir}/``
-
-    Args:
-        before: 处理前图像(RGB/BGR/灰度)
-        after: 处理后图像
-        output_dir: 输出根目录(通常为 pipeline 或工具的输出目录)
-        page_name: 文件名前缀(如 ``doc_page_002``)
-        processing_params: 写入 meta.json 的参数(threshold、contrast_enhancement 等)
-        image_format: 图片格式,png/jpg
-        save_compare: 是否保存左右拼接对比图
-        subdir: debug 根目录下的子目录名(默认 watermark_removal)
-
-    Returns:
-        已保存文件路径字典(before/after/compare/meta,未保存的键省略)
-    """
-    if isinstance(before, Image.Image):
-        before = np.array(before)
-    if isinstance(after, Image.Image):
-        after = np.array(after)
-
-    from ocr_utils.module_debug_viz import resolve_module_debug_dir
-
-    debug_dir = resolve_module_debug_dir(output_dir, subdir)
-
-    fmt = (image_format or "png").lstrip(".")
-    before_bgr = _image_to_bgr_for_debug(before)
-    after_bgr = _image_to_bgr_for_debug(after)
-
-    paths: Dict[str, str] = {}
-    before_path = debug_dir / f"{page_name}_watermark_before.{fmt}"
-    after_path = debug_dir / f"{page_name}_watermark_after.{fmt}"
-    cv2.imwrite(str(before_path), before_bgr)
-    cv2.imwrite(str(after_path), after_bgr)
-    paths["before"] = str(before_path)
-    paths["after"] = str(after_path)
-
-    if save_compare:
-        h = max(before_bgr.shape[0], after_bgr.shape[0])
-        if before_bgr.shape[0] != h:
-            before_bgr = cv2.resize(before_bgr, (before_bgr.shape[1], h))
-        if after_bgr.shape[0] != h:
-            after_bgr = cv2.resize(after_bgr, (after_bgr.shape[1], h))
-        compare = np.hstack([before_bgr, after_bgr])
-        compare_path = debug_dir / f"{page_name}_watermark_compare.{fmt}"
-        cv2.imwrite(str(compare_path), compare)
-        paths["compare"] = str(compare_path)
-        logger.info(f"Saved watermark compare: {compare_path}")
-
-    if mask_overlay is not None:
-        mask_bgr = _image_to_bgr_for_debug(mask_overlay)
-        mask_path = debug_dir / f"{page_name}_watermark_mask.{fmt}"
-        cv2.imwrite(str(mask_path), mask_bgr)
-        paths["mask"] = str(mask_path)
-
-    meta: Dict[str, Any] = {"page_name": page_name}
-    if processing_params:
-        _skip_meta = (
-            "midtone_mask",
-            "wm_mask",
-            "wm_candidate",
-            "geom_region",
-            "geom_candidate",
-            "diag_region",
-            "text_protect",
-            "seal_protect",
-            "hough_lines_bgr",
-            "diag_ratio_heatmap",
-            "hv_ratio_heatmap",
-        )
-        meta_params = {
-            k: v
-            for k, v in processing_params.items()
-            if k not in _skip_meta
-        }
-        meta.update(meta_params)
-    else:
-        meta.update({})
-    meta["before"] = paths["before"]
-    meta["after"] = paths["after"]
-    if "compare" in paths:
-        meta["compare"] = paths["compare"]
-
-    meta_path = debug_dir / f"{page_name}_watermark_meta.json"
-    meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
-    paths["meta"] = str(meta_path)
-
-    logger.info(f"Saved watermark debug: {before_path}, {after_path}")
-    return paths
-
-
-# ─────────────────────────────────────────────────────────────────────────────
-# PDF 层级水印去除(文字型 PDF,保留可搜索性)
-# ─────────────────────────────────────────────────────────────────────────────
-
-def _is_watermark_xobj(doc, xref: int, obj_str: str) -> bool:
-    """
-    判断一个 Form XObject 是否为水印。
-
-    启发式规则(满足其一即视为水印):
-    1. 含旋转变换矩阵(cm 指令 sin/cos 分量非零),无论是否有 /Group
-    2. 有透明度组(/Group)且内容流包含透明度操作符(ca/CA)
-    3. 有透明度组且内容流体积 > 2KB(大量重复绘图 = 平铺水印)
-    """
-    if "/Form" not in obj_str:
-        return False
-
-    try:
-        stream = doc.xref_stream(xref)
-        if not stream:
-            return False
-        stream_text = stream.decode("latin-1", errors="ignore")
-    except Exception:
-        return False
-
-    has_group = "/Group" in obj_str
-
-    cm_pattern = re.compile(
-        r"([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+([-\d.]+)\s+[-\d.]+\s+[-\d.]+\s+cm"
-    )
-    for m in cm_pattern.finditer(stream_text):
-        a, b, c, d = float(m.group(1)), float(m.group(2)), float(m.group(3)), float(m.group(4))
-        if abs(b) > 0.1 or abs(c) > 0.1:
-            return True
-
-    if not has_group:
-        return False
-
-    if re.search(r'\b(ca|CA)\s+[0-9.]+', stream_text) or re.search(r'[0-9.]+\s+(ca|CA)\b', stream_text):
-        return True
-
-    if len(stream_text) > 2048:
-        return True
-
-    return False
-
-
-def _is_watermark_image_xobj(doc, xref: int, obj_str: str) -> bool:
-    """
-    判断一个 Image XObject 是否为水印背景图。
-
-    判断规则(全部满足):
-    1. /Subtype /Image
-    2. 有 /SMask(半透明)
-    3. 宽 >= 600 且 高 >= 800(全页尺寸,排除小图标)
-    4. 解码后像素均值 >= 240(近乎全白,水印文字稀疏)
-    """
-    if "/Image" not in obj_str or "/SMask" not in obj_str:
-        return False
-
-    w_m = re.search(r'/Width\s+(\d+)', obj_str)
-    h_m = re.search(r'/Height\s+(\d+)', obj_str)
-    if not w_m or not h_m:
-        return False
-    if int(w_m.group(1)) < 600 or int(h_m.group(1)) < 800:
-        return False
-
-    try:
-        from io import BytesIO
-        img_info = doc.extract_image(xref)
-        pil_img = Image.open(BytesIO(img_info["image"])).convert("L")
-        return float(np.array(pil_img).mean()) >= 240.0
-    except Exception:
-        return False
-
-
-def _blank_watermark_image(doc, img_xref: int) -> None:
-    """
-    将水印 Image XObject 的 RGB 流和 SMask 替换为全白/全不透明。
-
-    关键点:必须先移除 /DecodeParms(Predictor 11),再调用 update_stream。
-    否则渲染器在 FlateDecode 之后还会尝试 Predictor 解码,失败后回退原始数据,
-    水印依然可见。
-    """
-    obj_str = doc.xref_object(img_xref)
-
-    w_m = re.search(r'/Width\s+(\d+)', obj_str)
-    h_m = re.search(r'/Height\s+(\d+)', obj_str)
-    w = int(w_m.group(1)) if w_m else 1
-    h = int(h_m.group(1)) if h_m else 1
-    cs_m = re.search(r'/ColorSpace\s+/Device(RGB|Gray|CMYK)', obj_str)
-    channels = {'RGB': 3, 'CMYK': 4}.get(cs_m.group(1) if cs_m else '', 1)
-
-    doc.xref_set_key(img_xref, "DecodeParms", "null")
-    doc.update_stream(img_xref, bytes([255]) * (w * h * channels))
-
-    smask_m = re.search(r'/SMask\s+(\d+)\s+0\s+R', obj_str)
-    if smask_m:
-        smask_xref = int(smask_m.group(1))
-        smask_obj = doc.xref_object(smask_xref)
-        sw = int(m.group(1)) if (m := re.search(r'/Width\s+(\d+)', smask_obj)) else w
-        sh = int(m.group(1)) if (m := re.search(r'/Height\s+(\d+)', smask_obj)) else h
-        doc.xref_set_key(smask_xref, "DecodeParms", "null")
-        doc.update_stream(smask_xref, bytes([255]) * (sw * sh))
-
-
-def scan_pdf_watermark_xobjs(pdf_bytes: bytes, sample_pages: int = 3) -> bool:
-    """
-    快速扫描 PDF 前 N 页,判断是否含水印 XObject。
-
-    无副作用(只读),用于在执行去水印前快速判断,避免对无水印的大文件
-    执行全量扫描和序列化,显著降低财报等大文件的处理开销。
-
-    Args:
-        pdf_bytes: PDF 文件的原始字节。
-        sample_pages: 扫描页数上限,默认 3(银行流水通常前几页有水印)。
-
-    Returns:
-        True 表示发现水印 XObject,False 表示未发现。
-    """
-    try:
-        import fitz
-    except ImportError:
-        return False
-
-    doc = fitz.open(stream=pdf_bytes, filetype="pdf")
-    pages_to_check = min(sample_pages, len(doc))
-    try:
-        for i in range(pages_to_check):
-            page = doc[i]
-            for xref, *_ in page.get_xobjects():
-                try:
-                    obj_str = doc.xref_object(xref)
-                except Exception:
-                    continue
-                if _is_watermark_xobj(doc, xref, obj_str):
-                    return True
-            for img_tuple in page.get_images(full=True):
-                try:
-                    obj_str = doc.xref_object(img_tuple[0])
-                except Exception:
-                    continue
-                if _is_watermark_image_xobj(doc, img_tuple[0], obj_str):
-                    return True
-    finally:
-        doc.close()
-    return False
-
-
-def remove_txt_pdf_watermark(pdf_bytes: bytes) -> Optional[bytes]:
-    """
-    对文字型 PDF 执行原生水印去除,完全在内存中完成,不写临时文件。
-
-    支持两种水印形式:
-    - Form XObject 水印:清空内容流
-    - Image XObject 水印(全页背景图 + SMask 透明通道):替换为全白像素
-
-    适用场景:pdf_type='txt' 的 PDF,去除后可直接传给渲染层(tobytes() → bytes)。
-    对于大文件(如财报),建议先用 scan_pdf_watermark_xobjs() 快速判断再调用本函数。
-
-    Args:
-        pdf_bytes: 原始 PDF 的字节内容。
-
-    Returns:
-        去除水印后的 PDF bytes(garbage=4 压缩);若未发现水印返回 None。
-    """
-    try:
-        import fitz
-    except ImportError:
-        raise ImportError("请安装 PyMuPDF: pip install PyMuPDF")
-
-    from loguru import logger
-
-    doc = fitz.open(stream=pdf_bytes, filetype="pdf")
-    processed_xrefs: set[int] = set()
-    total_removed = 0
-
-    for page in doc:
-        # ── Form XObject 水印 ─────────────────────────────────────────
-        for xref, name, _invoker, _unused in page.get_xobjects():
-            if xref in processed_xrefs:
-                continue
-            try:
-                obj_str = doc.xref_object(xref)
-            except Exception:
-                continue
-            if _is_watermark_xobj(doc, xref, obj_str):
-                try:
-                    doc.update_stream(xref, b"")
-                    processed_xrefs.add(xref)
-                    total_removed += 1
-                    logger.debug(f"  [Form XObject] 清空水印 xref={xref}, name={name}")
-                except Exception as e:
-                    logger.warning(f"  清空 Form XObject xref={xref} 失败: {e}")
-
-        # ── Image XObject 水印 ────────────────────────────────────────
-        for img_tuple in page.get_images(full=True):
-            img_xref = img_tuple[0]
-            if img_xref in processed_xrefs:
-                continue
-            try:
-                obj_str = doc.xref_object(img_xref)
-            except Exception:
-                continue
-            if _is_watermark_image_xobj(doc, img_xref, obj_str):
-                _blank_watermark_image(doc, img_xref)
-                processed_xrefs.add(img_xref)
-                total_removed += 1
-                logger.debug(f"  [Image XObject] 替换水印图像 xref={img_xref}")
-
-    if total_removed == 0:
-        doc.close()
-        return None
-
-    result = doc.tobytes(garbage=4, deflate=True)
-    doc.close()
-    logger.info(f"✅ PDF 层级水印去除:共清除 {total_removed} 个水印 XObject")
-    return result
+from ocr_utils.watermark.algorithms import (
+    build_watermark_mask,
+    detect_watermark,
+    remove_watermark_masked_adaptive,
+    render_ratio_heatmap,
+    save_watermark_mask_debug_layers,
+)
+from ocr_utils.watermark.contrast import (
+    apply_contrast_enhancement_config,
+    enhance_document_contrast,
+)
+from ocr_utils.watermark.debug import save_watermark_removal_debug
+from ocr_utils.watermark.pdf import (
+    remove_txt_pdf_watermark,
+    scan_pdf_watermark_xobjs,
+)
+from ocr_utils.watermark.removal import (
+    remove_watermark_from_image,
+    remove_watermark_from_image_rgb,
+    render_watermark_mask_overlay,
+)
+
+__all__ = [
+    "apply_contrast_enhancement_config",
+    "build_watermark_mask",
+    "detect_watermark",
+    "enhance_document_contrast",
+    "remove_txt_pdf_watermark",
+    "remove_watermark_from_image",
+    "remove_watermark_from_image_rgb",
+    "remove_watermark_masked_adaptive",
+    "render_ratio_heatmap",
+    "render_watermark_mask_overlay",
+    "save_watermark_mask_debug_layers",
+    "save_watermark_removal_debug",
+    "scan_pdf_watermark_xobjs",
+]

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini