remove_watermark.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. """
  2. 银行流水水印去除工具
  3. 支持 PDF 和常见图片格式(jpg/png/tif/bmp/webp)。
  4. - 输入 PDF → 输出去水印 PDF(扫描件)或直接复制(文字型)
  5. - 输入图片 → 输出去水印图片(保持原格式)
  6. 适用于福建农信、邮储银行等带有半透明文字水印的银行流水单。
  7. 用法:
  8. # 处理单个 PDF 或图片
  9. python remove_watermark.py input.pdf
  10. python remove_watermark.py input.jpg
  11. # 指定输出路径
  12. python remove_watermark.py input.pdf -o output.pdf
  13. # 指定页面范围(支持 "1-5,7,9-12" 格式)
  14. python remove_watermark.py input.pdf --page-range 1-3
  15. # 调整去除阈值(默认 160,范围建议 140-180)
  16. python remove_watermark.py input.pdf --threshold 170
  17. # 批量处理目录下所有 PDF 和图片
  18. python remove_watermark.py /path/to/dir/ --batch
  19. # 预览单页/图片效果(不保存,直接展示对比图)
  20. python remove_watermark.py input.pdf --preview --page 0
  21. python remove_watermark.py input.jpg --preview
  22. """
  23. import argparse
  24. import sys
  25. from pathlib import Path
  26. from typing import Optional
  27. # 将 ocr_platform 根目录加入 sys.path,以便导入 ocr_utils
  28. _repo_root = Path(__file__).parents[2]
  29. if str(_repo_root) not in sys.path:
  30. sys.path.insert(0, str(_repo_root))
  31. from loguru import logger
  32. from ocr_utils.watermark_utils import (
  33. detect_watermark,
  34. remove_watermark_from_image,
  35. scan_pdf_watermark_xobjs,
  36. remove_txt_pdf_watermark,
  37. )
  38. # 支持的图片后缀(小写)
  39. IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".tif", ".tiff", ".bmp", ".webp"}
  40. def _try_remove_txt_pdf_watermark(input_path: Path, output_path: Path) -> int:
  41. """
  42. 对文字型 PDF 执行原生水印去除,保留文字可搜索性。
  43. 内部委托给 watermark_utils.remove_txt_pdf_watermark() 完成内存流处理,
  44. 有水印时将结果写入 output_path。
  45. 流程:
  46. 1. scan_pdf_watermark_xobjs() 快速扫描前 3 页,无水印直接返回 0
  47. 2. remove_txt_pdf_watermark() 执行全量去除,返回 bytes 或 None
  48. 3. 有水印时写 output_path
  49. Returns:
  50. 1 表示去除成功,0 表示未发现水印
  51. """
  52. pdf_bytes = input_path.read_bytes()
  53. if not scan_pdf_watermark_xobjs(pdf_bytes, sample_pages=3):
  54. return 0
  55. cleaned = remove_txt_pdf_watermark(pdf_bytes)
  56. if cleaned is None:
  57. return 0
  58. output_path.write_bytes(cleaned)
  59. return 1
  60. def process_document(
  61. input_path: Path,
  62. output_path: Path,
  63. threshold: int = 160,
  64. morph_close_kernel: int = 0,
  65. dpi: int = 200,
  66. page_range: Optional[str] = None,
  67. force_image: bool = False,
  68. ) -> int:
  69. """
  70. 统一处理函数:支持 PDF(扫描件)和图片,去除水印后保存。
  71. 使用 PDFUtils.load_and_classify_document 加载并分类:
  72. - 文字型 PDF(pdf_type='txt'):优先尝试原生 XObject 水印去除(保留可搜索性);
  73. 失败时自动回退图像化处理,或 force_image=True 时直接走图像处理
  74. - 扫描件 PDF(pdf_type='ocr'):逐页去水印后重新打包为 PDF
  75. - 图片:检测水印后去除并保存
  76. Args:
  77. input_path: 输入文件路径(PDF 或图片)
  78. output_path: 输出文件路径
  79. threshold: 灰度阈值(140-180),越大保守,越小激进
  80. morph_close_kernel: 形态学闭运算核大小,0 跳过
  81. dpi: PDF 渲染分辨率
  82. page_range: 页面范围字符串,如 "1-5,7,9-12"(从 1 开始,仅对 PDF 有效)
  83. force_image: 强制对文字型 PDF 使用图像化处理(会失去文字可搜索性,
  84. 但能处理水印嵌在内容流中的情况)
  85. Returns:
  86. 实际处理的页/图片数
  87. """
  88. import shutil
  89. import numpy as np
  90. from io import BytesIO
  91. from PIL import Image
  92. from ocr_utils.pdf_utils import PDFUtils
  93. is_pdf = input_path.suffix.lower() == ".pdf"
  94. # 统一加载 + 分类(PDF 用 MinerU pdf_classify,图片直接读取)
  95. images, pdf_type, pdf_doc, renderer = PDFUtils.load_and_classify_document(
  96. input_path, dpi=dpi, page_range=page_range
  97. )
  98. # _known_has_wm: 当 txt 分支已确认有水印时设为 True,避免公共段用更严格阈值误判
  99. _known_has_wm: Optional[bool] = None
  100. # 文字型 PDF:优先尝试原生 XObject 水印去除,保留可搜索性
  101. if is_pdf and pdf_type == "txt" and not force_image:
  102. output_path.parent.mkdir(parents=True, exist_ok=True)
  103. removed = _try_remove_txt_pdf_watermark(input_path, output_path)
  104. if removed > 0:
  105. logger.info(
  106. f"✅ 文字型 PDF '{input_path.name}':删除 {removed} 个水印 XObject,"
  107. "保留文字可搜索性,已保存。"
  108. )
  109. return removed
  110. # XObject 扫描无结果,用较低阈值(0.5%)做图像水印检测二次确认
  111. # 文字 PDF 背景干净,降低阈值以检测稀疏文字水印
  112. first_np = np.array(images[0]["img_pil"])
  113. if detect_watermark(first_np, ratio_threshold=0.005):
  114. logger.warning(
  115. f"⚠️ 文字型 PDF '{input_path.name}':未找到 XObject 水印,"
  116. "但图像检测发现水印(内联内容流水印),"
  117. "回退为图像化处理(输出将失去文字可搜索性)。"
  118. )
  119. _known_has_wm = True # 明确检测到水印,跳过公共段二次检测
  120. else:
  121. logger.info(
  122. f"✅ 文字型 PDF '{input_path.name}':未检测到水印,直接复制。"
  123. )
  124. shutil.copy2(str(input_path), str(output_path))
  125. return 0
  126. elif is_pdf and pdf_type == "txt" and force_image:
  127. logger.warning(
  128. f"⚠️ 文字型 PDF '{input_path.name}':--force-image 模式,"
  129. "强制图像化处理(输出将失去文字可搜索性)。"
  130. )
  131. _known_has_wm = True # force_image 模式不再检测,直接去除
  132. logger.info(
  133. f"{'📄' if is_pdf else '🖼️ '} 处理: {input_path.name} "
  134. f"共 {len(images)} {'页' if is_pdf else '张'} threshold={threshold}"
  135. )
  136. # 水印检测(仅用第一页/图判断,同一文档水印通常一致)
  137. # _known_has_wm 已在 txt 分支设置时,跳过重复检测
  138. if _known_has_wm is not None:
  139. has_wm = _known_has_wm
  140. logger.info("🔍 检测到水印,启动去水印处理" if has_wm else "✅ 未检测到水印,跳过")
  141. else:
  142. first_np = np.array(images[0]["img_pil"])
  143. # 扫描件/图片路径:使用宽松一档的中间调阈值(2.5%)以避免边界误判,
  144. # 斜向直线验证仍作为双重保险防止误报
  145. has_wm = detect_watermark(first_np, ratio_threshold=0.025)
  146. if has_wm:
  147. logger.info("🔍 检测到水印,启动去水印处理")
  148. else:
  149. logger.info("✅ 未检测到水印,跳过去水印处理")
  150. if not is_pdf:
  151. # 图片无水印:直接复制
  152. output_path.parent.mkdir(parents=True, exist_ok=True)
  153. shutil.copy2(str(input_path), str(output_path))
  154. return 1
  155. output_path.parent.mkdir(parents=True, exist_ok=True)
  156. if is_pdf:
  157. # 逐页处理后重新打包为 PDF
  158. try:
  159. import fitz
  160. except ImportError:
  161. raise ImportError("请安装 PyMuPDF: pip install PyMuPDF")
  162. new_doc = fitz.open()
  163. for i, img_dict in enumerate(images):
  164. pil_img = img_dict["img_pil"]
  165. img_np = np.array(pil_img)
  166. if has_wm:
  167. cleaned_gray = remove_watermark_from_image(
  168. img_np, threshold=threshold,
  169. morph_close_kernel=morph_close_kernel, return_pil=False,
  170. )
  171. out_pil = Image.fromarray(cleaned_gray).convert("RGB")
  172. else:
  173. out_pil = pil_img
  174. buf = BytesIO()
  175. out_pil.save(buf, format="PNG", optimize=False)
  176. buf.seek(0)
  177. # 按渲染图尺寸创建新页面(保持原始 DPI 尺寸)
  178. w_px, h_px = out_pil.size
  179. new_page = new_doc.new_page(width=w_px * 72 / dpi, height=h_px * 72 / dpi)
  180. new_page.insert_image(new_page.rect, stream=buf.read())
  181. if (i + 1) % 10 == 0 or i == len(images) - 1:
  182. logger.info(f" 进度: {i + 1}/{len(images)}")
  183. new_doc.save(str(output_path), garbage=4, deflate=True)
  184. else:
  185. # 图片:有水印则去除后保存
  186. img_np = np.array(images[0]["img_pil"])
  187. cleaned_gray = remove_watermark_from_image(
  188. img_np, threshold=threshold,
  189. morph_close_kernel=morph_close_kernel, return_pil=False,
  190. )
  191. Image.fromarray(cleaned_gray, mode="L").save(str(output_path))
  192. logger.info(f"✅ 保存到: {output_path}")
  193. return len(images)
  194. def preview_page(
  195. input_path: Path,
  196. page_idx: int = 0,
  197. threshold: int = 160,
  198. dpi: int = 200,
  199. ):
  200. """展示单页原图与去水印对比(需要 matplotlib)。支持 PDF 和图片文件。"""
  201. try:
  202. import numpy as np
  203. import matplotlib.pyplot as plt
  204. import matplotlib
  205. matplotlib.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'SimHei', 'sans-serif']
  206. matplotlib.rcParams['axes.unicode_minus'] = False
  207. except ImportError as e:
  208. raise ImportError(f"预览需要 matplotlib: {e}")
  209. suffix = input_path.suffix.lower()
  210. if suffix == ".pdf":
  211. try:
  212. import fitz
  213. except ImportError:
  214. raise ImportError("PDF 预览需要 PyMuPDF: pip install PyMuPDF")
  215. doc = fitz.open(str(input_path))
  216. if page_idx >= len(doc):
  217. raise ValueError(f"页码 {page_idx} 超出范围(共 {len(doc)} 页)")
  218. mat = fitz.Matrix(dpi / 72, dpi / 72)
  219. page = doc[page_idx]
  220. pix = page.get_pixmap(matrix=mat, alpha=False)
  221. img_np = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.h, pix.w, 3)
  222. title_orig = f"原图 第 {page_idx + 1} 页"
  223. elif suffix in IMAGE_SUFFIXES:
  224. from PIL import Image
  225. img_np = np.array(Image.open(str(input_path)).convert("RGB"))
  226. title_orig = f"原图 {input_path.name}"
  227. else:
  228. raise ValueError(f"不支持的文件格式: {suffix}")
  229. cleaned = remove_watermark_from_image(img_np, threshold=threshold, return_pil=False)
  230. fig, axes = plt.subplots(1, 2, figsize=(20, 14))
  231. axes[0].imshow(img_np)
  232. axes[0].set_title(title_orig, fontsize=14)
  233. axes[0].axis('off')
  234. axes[1].imshow(cleaned, cmap='gray')
  235. axes[1].set_title(f"去水印后 threshold={threshold}", fontsize=14)
  236. axes[1].axis('off')
  237. plt.tight_layout()
  238. plt.show()
  239. def main():
  240. parser = argparse.ArgumentParser(
  241. description="银行流水水印去除工具",
  242. formatter_class=argparse.RawDescriptionHelpFormatter,
  243. epilog=__doc__,
  244. )
  245. parser.add_argument("input", type=Path, help="输入 PDF / 图片文件或目录(批量模式)")
  246. parser.add_argument("-o", "--output", type=Path, default=None,
  247. help="输出路径(单文件模式;默认在原文件名后加 _cleaned)")
  248. parser.add_argument("--threshold", type=int, default=160,
  249. help="灰度阈值 (140-180),默认 160")
  250. parser.add_argument("--morph-kernel", type=int, default=2,
  251. help="形态学闭运算核大小,0 跳过,默认 2")
  252. parser.add_argument("--dpi", type=int, default=200,
  253. help="渲染 DPI,默认 200")
  254. parser.add_argument("--batch", action="store_true",
  255. help="批量模式:处理目录下所有 PDF 和图片")
  256. parser.add_argument("--preview", action="store_true",
  257. help="预览模式:展示单页对比图(不保存)")
  258. parser.add_argument("--page", type=int, default=0,
  259. help="预览页码(0-based),默认第 0 页")
  260. parser.add_argument("--page-range", type=str, default=None,
  261. help="处理页面范围,如 '1-3,5,7-9'(从 1 开始,仅对 PDF 有效)")
  262. parser.add_argument("--force-image", action="store_true",
  263. help="强制对文字型 PDF 使用图像化处理(会失去可搜索性,适用于 XObject 方法无法去除的内联水印)")
  264. args = parser.parse_args()
  265. if args.preview:
  266. preview_page(
  267. args.input,
  268. page_idx=args.page,
  269. threshold=args.threshold,
  270. dpi=args.dpi,
  271. )
  272. return
  273. if args.batch:
  274. # 批量模式:处理目录下所有 PDF 和图片
  275. input_dir = args.input
  276. if not input_dir.is_dir():
  277. logger.error(f"批量模式需要传入目录: {input_dir}")
  278. sys.exit(1)
  279. # 收集所有支持的文件
  280. all_files: list[Path] = sorted(input_dir.glob("*.pdf"))
  281. for ext in IMAGE_SUFFIXES:
  282. all_files.extend(sorted(input_dir.glob(f"*{ext}")))
  283. all_files.extend(sorted(input_dir.glob(f"*{ext.upper()}")))
  284. all_files = sorted(set(all_files))
  285. if not all_files:
  286. logger.warning(f"目录中没有可处理的文件(PDF/图片): {input_dir}")
  287. return
  288. out_dir = args.output or input_dir / "cleaned"
  289. out_dir.mkdir(parents=True, exist_ok=True)
  290. for file in all_files:
  291. out_file = out_dir / f"{file.stem}_cleaned{file.suffix}"
  292. try:
  293. process_document(file, out_file, args.threshold, args.morph_kernel, args.dpi, args.page_range, args.force_image)
  294. except Exception as e:
  295. logger.error(f"❌ 处理失败 {file.name}: {e}")
  296. logger.info(f"✅ 批量处理完成,共 {len(all_files)} 个文件 -> {out_dir}")
  297. else:
  298. # 单文件模式
  299. input_path = args.input
  300. if not input_path.is_file():
  301. logger.error(f"文件不存在: {input_path}")
  302. sys.exit(1)
  303. output_path = args.output or input_path.with_name(
  304. f"{input_path.stem}_cleaned{input_path.suffix}"
  305. )
  306. suffix = input_path.suffix.lower()
  307. if suffix == ".pdf" or suffix in IMAGE_SUFFIXES:
  308. process_document(input_path, output_path, args.threshold, args.morph_kernel, args.dpi, args.page_range, args.force_image)
  309. else:
  310. logger.error(f"不支持的文件格式: {suffix},支持 PDF 和 {IMAGE_SUFFIXES}")
  311. sys.exit(1)
  312. if __name__ == "__main__":
  313. if len(sys.argv) == 1:
  314. print("ℹ️ 未提供命令行参数,使用默认配置运行...")
  315. # 默认配置(用于开发测试)
  316. default_config = {
  317. # 测试输入
  318. # "input": "/Users/zhch158/workspace/data/流水分析/杨万益_福建农信.pdf",
  319. # "input": "Users/zhch158/workspace/data/流水分析/提取自杨万益_福建农信.png",
  320. # 文字PDF测试
  321. # "input": "/Users/zhch158/workspace/data/流水分析/提取自赤峰黄金2023年报.pdf",
  322. # "input": "/Users/zhch158/workspace/data/测试文字PDF-水印.pdf",
  323. "input": "/Users/zhch158/workspace/data/非结构化文档识别统一平台(ocr_platform)-交易流水识别,财报识别.pdf",
  324. # "output": "./output/杨万益_福建农信",
  325. # 页面范围(可选,支持 "1-5,7" 语法,仅对 PDF 有效)
  326. # "page_range": "3", # 仅处理第 1 页(对应 --page-range 参数)
  327. "dpi": 200,
  328. "threshold": 160,
  329. "morph_kernel": 0, # 遮罩替换模式下不需要闭运算
  330. # "preview": True,
  331. }
  332. # 构造参数(注意 input 是位置参数,morph_kernel 对应 --morph-kernel)
  333. sys.argv = [sys.argv[0], default_config["input"]]
  334. skip_keys = {"input"}
  335. for key, value in default_config.items():
  336. if key in skip_keys:
  337. continue
  338. # 将下划线转换为连字符(如 morph_kernel -> morph-kernel)
  339. flag = f"--{key.replace('_', '-')}"
  340. if isinstance(value, bool):
  341. if value:
  342. sys.argv.append(flag)
  343. else:
  344. sys.argv.extend([flag, str(value)])
  345. sys.exit(main())