algorithms.py 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095
  1. """水印 掩膜与去水印算法(由 ocr_utils.watermark_utils 迁入)。"""
  2. from __future__ import annotations
  3. import json
  4. import re
  5. from pathlib import Path
  6. from typing import Any, Dict, Optional, Tuple, Union
  7. import cv2
  8. import numpy as np
  9. from loguru import logger
  10. from PIL import Image
  11. def detect_watermark(
  12. image: Union[np.ndarray, Image.Image],
  13. midtone_low: int = 100,
  14. midtone_high: int = 220,
  15. ratio_threshold: float = 0.03,
  16. check_diagonal: bool = True,
  17. diagonal_angle_range: tuple = (30, 60),
  18. ) -> bool:
  19. """
  20. 检测图像中是否存在浅色斜向文字水印(银行流水类文档水印检测)。
  21. 原理:
  22. 1. 将图像转为灰度,提取「中间调」像素(midtone_low ~ midtone_high),
  23. 这些像素既不是纯白背景,也不是深黑正文,是浅灰水印的典型范围。
  24. 2. 若中间调像素占比超过 ratio_threshold,初步判定存在水印。
  25. 3. 若 check_diagonal=True,进一步用 Hough 直线变换验证中间调区域
  26. 是否呈现斜向(diagonal_angle_range 度)纹理,以排除灰色背景误报。
  27. Args:
  28. image: 输入图像,支持 PIL.Image 或 np.ndarray(BGR/RGB/灰度)。
  29. midtone_low: 中间调下限(默认 100),低于此视为深色正文。
  30. midtone_high: 中间调上限(默认 220),高于此视为纯白背景。
  31. ratio_threshold: 中间调像素占全图比例阈值(默认 0.03 即 3%)。
  32. check_diagonal: 是否进行斜向纹理验证(默认 True)。
  33. diagonal_angle_range: 斜向角度范围(度),默认 (30, 60),含 45° 斜水印。
  34. Returns:
  35. True 表示检测到水印,False 表示未检测到。
  36. """
  37. if isinstance(image, Image.Image):
  38. pil_img = image.convert('RGB') if image.mode == 'RGBA' else image
  39. np_img = np.array(pil_img)
  40. gray = cv2.cvtColor(np_img, cv2.COLOR_RGB2GRAY) if np_img.ndim == 3 else np_img
  41. else:
  42. np_img = image
  43. gray = cv2.cvtColor(np_img, cv2.COLOR_BGR2GRAY) if np_img.ndim == 3 else np_img
  44. midtone_mask = (gray > midtone_low) & (gray < midtone_high)
  45. ratio = midtone_mask.sum() / gray.size
  46. if ratio < ratio_threshold:
  47. return False
  48. if not check_diagonal:
  49. return True
  50. midtone_uint8 = (midtone_mask.astype(np.uint8)) * 255
  51. edges = cv2.Canny(midtone_uint8, 50, 150, apertureSize=3)
  52. lines = cv2.HoughLines(edges, rho=1, theta=np.pi / 180, threshold=80)
  53. if lines is None:
  54. return False
  55. low_rad = np.deg2rad(diagonal_angle_range[0])
  56. high_rad = np.deg2rad(diagonal_angle_range[1])
  57. diagonal_count = 0
  58. for line in lines:
  59. theta = line[0][1]
  60. if low_rad <= theta <= high_rad or (np.pi - high_rad) <= theta <= (np.pi - low_rad):
  61. diagonal_count += 1
  62. return diagonal_count > 0
  63. def _local_std_map(gray: np.ndarray, window: int = 5) -> np.ndarray:
  64. """局部标准差图(返回值与输入同形状)。"""
  65. gray = np.asarray(gray, dtype=np.float32)
  66. size = max(3, int(window))
  67. kernel = np.ones((size, size), dtype=np.float32) / (size * size)
  68. mean = cv2.filter2D(gray, -1, kernel)
  69. sq_mean = cv2.filter2D(gray * gray, -1, kernel)
  70. var = sq_mean - mean * mean
  71. var = np.maximum(var, 0)
  72. return np.sqrt(var)
  73. def _line_structuring_kernel(length: int, angle_deg: float) -> np.ndarray:
  74. """生成指定角度、长度的线形结构元(用于斜向水印形态学)。"""
  75. length = max(3, int(length))
  76. k = np.zeros((length, length), np.uint8)
  77. c = length // 2
  78. rad = np.deg2rad(angle_deg)
  79. dx = int(round(np.cos(rad) * (c - 1)))
  80. dy = int(round(np.sin(rad) * (c - 1)))
  81. cv2.line(k, (c - dx, c - dy), (c + dx, c + dy), 1, thickness=1)
  82. return k
  83. def _line_angle_deg(x1: int, y1: int, x2: int, y2: int) -> float:
  84. """线段方向角 [0, 180)(无向)。"""
  85. ang = float(np.degrees(np.arctan2(y2 - y1, x2 - x1)))
  86. if ang < 0:
  87. ang += 180.0
  88. return ang
  89. def _angle_in_diagonal_ranges(
  90. angle_deg: float,
  91. ranges: Tuple[Tuple[float, float], Tuple[float, float]] = ((35.0, 55.0), (125.0, 145.0)),
  92. ) -> bool:
  93. for lo, hi in ranges:
  94. if lo <= angle_deg <= hi:
  95. return True
  96. return False
  97. def _angle_distance_deg(a: float, b: float) -> float:
  98. """无向角距离 [0, 90]。"""
  99. d = abs(float(a) - float(b)) % 180.0
  100. return min(d, 180.0 - d)
  101. def _line_length(x1: int, y1: int, x2: int, y2: int) -> float:
  102. return float(np.hypot(x2 - x1, y2 - y1))
  103. def _find_dominant_diagonal_angles(
  104. segments: list,
  105. *,
  106. angle_ranges: Tuple[Tuple[float, float], Tuple[float, float]] = ((25.0, 65.0), (115.0, 155.0)),
  107. smooth_sigma: float = 2.0,
  108. secondary_peak_ratio: float = 0.35,
  109. ) -> Tuple[list, np.ndarray]:
  110. """
  111. 按线段长度加权统计角度直方图,取主峰(及次峰)作为本页水印固定方向。
  112. Returns:
  113. dominant_angles: 1~2 个主导角度(度)
  114. hist_smooth: 长度 180 的平滑直方图
  115. """
  116. hist = np.zeros(180, dtype=np.float64)
  117. for x1, y1, x2, y2, ang, length in segments:
  118. if not _angle_in_diagonal_ranges(ang, angle_ranges):
  119. continue
  120. hist[int(ang) % 180] += length
  121. if hist.sum() <= 0:
  122. return [], hist
  123. ksize = max(3, int(smooth_sigma * 4) | 1)
  124. hist_smooth = cv2.GaussianBlur(
  125. hist.reshape(1, 180).astype(np.float32), (ksize, 1), smooth_sigma
  126. ).flatten().astype(np.float64)
  127. peaks: list = []
  128. for lo, hi in angle_ranges:
  129. lo_i, hi_i = int(lo), int(hi)
  130. sub = hist_smooth[lo_i : hi_i + 1]
  131. if sub.size == 0 or sub.max() <= 0:
  132. continue
  133. peak_ang = lo_i + int(sub.argmax())
  134. peaks.append((peak_ang, float(sub.max())))
  135. if not peaks:
  136. return [], hist_smooth
  137. peaks.sort(key=lambda x: -x[1])
  138. dominant: list = [peaks[0][0]]
  139. for ang, val in peaks[1:]:
  140. if val >= peaks[0][1] * secondary_peak_ratio:
  141. if all(_angle_distance_deg(ang, d) > 15 for d in dominant):
  142. dominant.append(ang)
  143. return dominant, hist_smooth
  144. def _render_angle_histogram(hist: np.ndarray, dominant_angles: list) -> np.ndarray:
  145. """角度直方图 debug 图(BGR)。"""
  146. h_img, w_img = 120, 360
  147. canvas = np.ones((h_img, w_img, 3), dtype=np.uint8) * 255
  148. if hist.max() <= 0:
  149. return canvas
  150. norm = (hist / hist.max() * (h_img - 20)).astype(np.int32)
  151. for i, h in enumerate(norm):
  152. x = int(i * (w_img - 1) / 179)
  153. cv2.line(canvas, (x, h_img - 10), (x, h_img - 10 - int(h)), (180, 180, 180), 1)
  154. for ang in dominant_angles:
  155. x = int(ang * (w_img - 1) / 179)
  156. cv2.line(canvas, (x, 0), (x, h_img - 1), (0, 0, 255), 2)
  157. cv2.putText(canvas, "angle (deg)", (w_img // 2 - 40, h_img - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 0, 0), 1)
  158. return canvas
  159. def _build_diag_hough_region_mask(
  160. gray: np.ndarray,
  161. *,
  162. midtone_low: int = 200,
  163. midtone_high: int = 254,
  164. canny_low: int = 30,
  165. canny_high: int = 100,
  166. hough_threshold: int = 30,
  167. min_line_length: int = 40,
  168. max_line_gap: int = 15,
  169. angle_ranges: Tuple[Tuple[float, float], Tuple[float, float]] = ((25.0, 65.0), (115.0, 155.0)),
  170. angle_tolerance: float = 5.0,
  171. use_angle_statistics: bool = True,
  172. secondary_peak_ratio: float = 0.35,
  173. min_length_percentile: float = 25.0,
  174. line_thickness: int = 10,
  175. band_dilate_radius: int = 12,
  176. ) -> Tuple[np.ndarray, Dict[str, Any]]:
  177. """
  178. 方案 C:Canny + HoughLinesP + 角度直方图统计主峰,仅保留与本页水印方向一致的线段。
  179. """
  180. gray_u8 = np.asarray(gray, dtype=np.uint8)
  181. band = ((gray_u8 >= midtone_low) & (gray_u8 < midtone_high)).astype(np.uint8) * 255
  182. edges = cv2.Canny(band, int(canny_low), int(canny_high), apertureSize=3)
  183. lines_p = cv2.HoughLinesP(
  184. edges,
  185. rho=1,
  186. theta=np.pi / 180,
  187. threshold=int(hough_threshold),
  188. minLineLength=int(min_line_length),
  189. maxLineGap=int(max_line_gap),
  190. )
  191. line_mask = np.zeros_like(gray_u8, dtype=np.uint8)
  192. lines_all_bgr = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
  193. lines_filt_bgr = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
  194. diag_candidates: list = []
  195. total_lines = 0
  196. if lines_p is not None:
  197. for seg in lines_p:
  198. x1, y1, x2, y2 = [int(v) for v in seg[0]]
  199. total_lines += 1
  200. ang = _line_angle_deg(x1, y1, x2, y2)
  201. length = _line_length(x1, y1, x2, y2)
  202. if not _angle_in_diagonal_ranges(ang, angle_ranges):
  203. continue
  204. diag_candidates.append((x1, y1, x2, y2, ang, length))
  205. cv2.line(lines_all_bgr, (x1, y1), (x2, y2), (128, 128, 128), 1)
  206. dominant_angles: list = []
  207. hist_smooth = np.zeros(180, dtype=np.float64)
  208. if use_angle_statistics and diag_candidates:
  209. dominant_angles, hist_smooth = _find_dominant_diagonal_angles(
  210. diag_candidates,
  211. angle_ranges=angle_ranges,
  212. secondary_peak_ratio=secondary_peak_ratio,
  213. )
  214. def _angle_matches(ang: float) -> bool:
  215. if not use_angle_statistics or not dominant_angles:
  216. return True
  217. return any(_angle_distance_deg(ang, d) <= angle_tolerance for d in dominant_angles)
  218. angle_matched = [
  219. s for s in diag_candidates if _angle_matches(s[4])
  220. ]
  221. if angle_matched and min_length_percentile > 0:
  222. lengths = np.array([s[5] for s in angle_matched], dtype=np.float32)
  223. len_th = float(np.percentile(lengths, min_length_percentile))
  224. angle_matched = [s for s in angle_matched if s[5] >= len_th]
  225. matched_keys = {(s[0], s[1], s[2], s[3]) for s in angle_matched}
  226. kept_lines: list = []
  227. for x1, y1, x2, y2, ang, _length in angle_matched:
  228. kept_lines.append((x1, y1, x2, y2, ang))
  229. cv2.line(line_mask, (x1, y1), (x2, y2), 255, thickness=int(line_thickness))
  230. cv2.line(lines_filt_bgr, (x1, y1), (x2, y2), (0, 0, 255), 2)
  231. for x1, y1, x2, y2, _ang, _length in diag_candidates:
  232. if (x1, y1, x2, y2) not in matched_keys:
  233. cv2.line(lines_filt_bgr, (x1, y1), (x2, y2), (0, 180, 255), 1)
  234. geom = line_mask > 0
  235. if band_dilate_radius > 0 and np.any(geom):
  236. k = cv2.getStructuringElement(
  237. cv2.MORPH_ELLIPSE, (band_dilate_radius * 2 + 1, band_dilate_radius * 2 + 1)
  238. )
  239. geom = cv2.dilate(line_mask, k) > 0
  240. info: Dict[str, Any] = {
  241. "hough_total_lines": total_lines,
  242. "hough_diag_candidates": len(diag_candidates),
  243. "hough_kept_lines": len(kept_lines),
  244. "dominant_angles": dominant_angles,
  245. "angle_tolerance": angle_tolerance,
  246. "geom_mask_ratio": float(geom.sum() / gray_u8.size),
  247. "hough_lines_bgr": lines_filt_bgr,
  248. "hough_lines_all_bgr": lines_all_bgr,
  249. "angle_histogram_bgr": _render_angle_histogram(hist_smooth, dominant_angles),
  250. }
  251. return geom, info
  252. def _compute_block_orientation_debug_maps(
  253. gray: np.ndarray,
  254. *,
  255. block_size: int = 48,
  256. ) -> Tuple[np.ndarray, np.ndarray]:
  257. """分块 diag/hv 弱边缘占比图(仅 debug 热力图,0~1 float)。"""
  258. gray_f = np.asarray(gray, dtype=np.float32)
  259. bs = max(4, int(block_size))
  260. h_blocks = gray_f.shape[0] // bs
  261. w_blocks = gray_f.shape[1] // bs
  262. if h_blocks == 0 or w_blocks == 0:
  263. z = np.zeros_like(gray_f, dtype=np.float32)
  264. return z, z
  265. ph, pw = h_blocks * bs, w_blocks * bs
  266. gx = cv2.Sobel(gray_f, cv2.CV_32F, 1, 0, ksize=3)
  267. gy = cv2.Sobel(gray_f, cv2.CV_32F, 0, 1, ksize=3)
  268. mag = np.sqrt(gx * gx + gy * gy)
  269. ori = np.arctan2(gy, gx) * 180.0 / np.pi
  270. diag = (
  271. ((ori > 25) & (ori < 65))
  272. | ((ori > 115) & (ori < 155))
  273. | ((ori > -155) & (ori < -115))
  274. | ((ori > -65) & (ori < -25))
  275. )
  276. hv = (
  277. ((ori > -20) & (ori < 20))
  278. | ((ori > 160) | (ori < -160))
  279. | ((ori > 70) & (ori < 110))
  280. | ((ori > -110) & (ori < -70))
  281. )
  282. weak = (mag > 1) & (mag < 15)
  283. def _to_blocks(arr: np.ndarray) -> np.ndarray:
  284. return (
  285. arr[:ph, :pw]
  286. .reshape(h_blocks, bs, w_blocks, bs)
  287. .transpose(0, 2, 1, 3)
  288. .reshape(h_blocks, w_blocks, -1)
  289. )
  290. b_diag = _to_blocks(diag)
  291. b_hv = _to_blocks(hv)
  292. b_weak = _to_blocks(weak)
  293. diag_weak = np.sum(b_diag & b_weak, axis=2)
  294. hv_weak = np.sum(b_hv & b_weak, axis=2)
  295. total_weak = np.sum(b_weak, axis=2)
  296. with np.errstate(divide="ignore", invalid="ignore"):
  297. diag_ratio = np.where(total_weak > 0, diag_weak / total_weak, 0.0).astype(np.float32)
  298. hv_ratio = np.where(total_weak > 0, hv_weak / total_weak, 0.0).astype(np.float32)
  299. diag_up = np.repeat(np.repeat(diag_ratio, bs, axis=0), bs, axis=1)
  300. hv_up = np.repeat(np.repeat(hv_ratio, bs, axis=0), bs, axis=1)
  301. diag_full = np.zeros_like(gray_f, dtype=np.float32)
  302. hv_full = np.zeros_like(gray_f, dtype=np.float32)
  303. diag_full[:ph, :pw] = diag_up
  304. hv_full[:ph, :pw] = hv_up
  305. return diag_full, hv_full
  306. def render_ratio_heatmap(ratio_map: np.ndarray) -> np.ndarray:
  307. """将 0~1 浮点占比图转为 BGR 热力图。"""
  308. r = np.clip(np.asarray(ratio_map, dtype=np.float32), 0.0, 1.0)
  309. u8 = (r * 255).astype(np.uint8)
  310. return cv2.applyColorMap(u8, cv2.COLORMAP_JET)
  311. def save_watermark_mask_debug_layers(
  312. image: np.ndarray,
  313. output_dir: Union[str, Path],
  314. stem: str,
  315. debug: Dict[str, Any],
  316. *,
  317. image_format: str = "png",
  318. ) -> Dict[str, str]:
  319. """保存分层 debug 图(方案 D)。"""
  320. out_dir = Path(output_dir)
  321. out_dir.mkdir(parents=True, exist_ok=True)
  322. fmt = (image_format or "png").lstrip(".")
  323. paths: Dict[str, str] = {}
  324. def _save_overlay(name: str, mask: Optional[np.ndarray], color=(0, 0, 255)) -> None:
  325. if mask is None or not np.any(mask):
  326. return
  327. from ocr_utils.watermark.removal import render_watermark_mask_overlay
  328. ov = render_watermark_mask_overlay(image, mask, color=color)
  329. p = out_dir / f"{stem}_{name}.{fmt}"
  330. cv2.imwrite(str(p), cv2.cvtColor(ov, cv2.COLOR_RGB2BGR) if ov.shape[2] == 3 else ov)
  331. paths[name] = str(p)
  332. _save_overlay("wm_candidate_overlay", debug.get("wm_candidate"))
  333. _save_overlay("geom_region_overlay", debug.get("geom_region"), color=(0, 180, 255))
  334. _save_overlay("geom_candidate_overlay", debug.get("geom_candidate"), color=(0, 255, 0))
  335. _save_overlay("wm_mask_overlay", debug.get("wm_mask"), color=(255, 0, 0))
  336. hough_bgr = debug.get("hough_lines_bgr")
  337. if hough_bgr is not None:
  338. p = out_dir / f"{stem}_hough_lines.{fmt}"
  339. cv2.imwrite(str(p), hough_bgr)
  340. paths["hough_lines"] = str(p)
  341. hough_all = debug.get("hough_lines_all_bgr")
  342. if hough_all is not None:
  343. p = out_dir / f"{stem}_hough_lines_all.{fmt}"
  344. cv2.imwrite(str(p), hough_all)
  345. paths["hough_lines_all"] = str(p)
  346. angle_hist = debug.get("angle_histogram_bgr")
  347. if angle_hist is not None:
  348. p = out_dir / f"{stem}_angle_histogram.{fmt}"
  349. cv2.imwrite(str(p), angle_hist)
  350. paths["angle_histogram"] = str(p)
  351. diag_hm = debug.get("diag_ratio_heatmap")
  352. if diag_hm is not None:
  353. p = out_dir / f"{stem}_diag_ratio_heatmap.{fmt}"
  354. cv2.imwrite(str(p), diag_hm)
  355. paths["diag_ratio_heatmap"] = str(p)
  356. hv_hm = debug.get("hv_ratio_heatmap")
  357. if hv_hm is not None:
  358. p = out_dir / f"{stem}_hv_ratio_heatmap.{fmt}"
  359. cv2.imwrite(str(p), hv_hm)
  360. paths["hv_ratio_heatmap"] = str(p)
  361. return paths
  362. def _build_diag_region_mask(
  363. gray: np.ndarray,
  364. *,
  365. block_size: int = 48,
  366. diag_ratio_thresh: float = 0.20,
  367. light_gray_thresh: int = 238,
  368. light_ratio_thresh: float = 0.10,
  369. min_edge_count: int = 10,
  370. dilate_radius: int = 3,
  371. ) -> np.ndarray:
  372. """
  373. 分块梯度方向检测:返回对角线方向纹理占优的区域掩膜。
  374. 原理:水印是45°斜向字符,其梯度主方向在30-60°和120-150°。
  375. 分块统计该方向弱边缘占比,高频块标记为水印候选区域。
  376. Returns:
  377. bool ndarray, 与 gray 同形状,True=疑似斜向水印区域。
  378. """
  379. gray_f = np.asarray(gray, dtype=np.float32)
  380. img_h, img_w = gray_f.shape
  381. bs = max(4, int(block_size))
  382. # Sobel 梯度
  383. gx = cv2.Sobel(gray_f, cv2.CV_32F, 1, 0, ksize=3)
  384. gy = cv2.Sobel(gray_f, cv2.CV_32F, 0, 1, ksize=3)
  385. mag = np.sqrt(gx * gx + gy * gy)
  386. ori = np.arctan2(gy, gx) * 180.0 / np.pi
  387. # 对角线方向 (±45° 附近,即梯度 30-65° / 115-155°)
  388. diag = (
  389. ((ori > 25) & (ori < 65))
  390. | ((ori > 115) & (ori < 155))
  391. | ((ori > -155) & (ori < -115))
  392. | ((ori > -65) & (ori < -25))
  393. )
  394. h_blocks = img_h // bs
  395. w_blocks = img_w // bs
  396. if h_blocks == 0 or w_blocks == 0:
  397. return np.zeros_like(gray, dtype=bool)
  398. ph, pw = h_blocks * bs, w_blocks * bs
  399. # 分块统计
  400. def _to_blocks(arr: np.ndarray) -> np.ndarray:
  401. return arr[:ph, :pw].reshape(h_blocks, bs, w_blocks, bs).transpose(0, 2, 1, 3).reshape(h_blocks, w_blocks, -1)
  402. block_mag = _to_blocks(mag)
  403. block_diag = _to_blocks(diag)
  404. block_gray = _to_blocks(gray_f)
  405. weak = (block_mag > 1) & (block_mag < 15)
  406. diag_weak = np.sum(block_diag & weak, axis=2)
  407. total_weak = np.sum(weak, axis=2)
  408. with np.errstate(divide="ignore", invalid="ignore"):
  409. diag_ratio = np.where(total_weak > 0, diag_weak / total_weak, 0.0)
  410. light_ratio = np.mean(block_gray >= light_gray_thresh, axis=2)
  411. wm_blocks = (
  412. (diag_ratio > diag_ratio_thresh)
  413. & (light_ratio > light_ratio_thresh)
  414. & (total_weak > min_edge_count)
  415. )
  416. # 展开为像素掩膜
  417. wm_block_mask = np.repeat(np.repeat(wm_blocks, bs, axis=0), bs, axis=1)
  418. full_mask = np.zeros(gray_f.shape, dtype=bool)
  419. full_mask[:ph, :pw] = wm_block_mask
  420. if dilate_radius > 0:
  421. k = cv2.getStructuringElement(
  422. cv2.MORPH_ELLIPSE, (dilate_radius * 2 + 1, dilate_radius * 2 + 1)
  423. )
  424. full_mask = cv2.dilate(full_mask.astype(np.uint8), k) > 0
  425. return full_mask
  426. def _build_seal_protect_mask(
  427. bgr: np.ndarray,
  428. *,
  429. hue_high: int = 15,
  430. sat_min: int = 40,
  431. value_min: int = 30,
  432. ) -> np.ndarray:
  433. """红色/公章区域保护掩膜(True=保护,不置白)。"""
  434. hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
  435. lower1 = np.array([0, sat_min, value_min], dtype=np.uint8)
  436. upper1 = np.array([hue_high, 255, 255], dtype=np.uint8)
  437. lower2 = np.array([170, sat_min, value_min], dtype=np.uint8)
  438. upper2 = np.array([180, 255, 255], dtype=np.uint8)
  439. m1 = cv2.inRange(hsv, lower1, upper1)
  440. m2 = cv2.inRange(hsv, lower2, upper2)
  441. m2 = cv2.inRange(hsv, lower2, upper2)
  442. return (m1 > 0) | (m2 > 0)
  443. def _build_text_edge_protect(
  444. gray: np.ndarray,
  445. *,
  446. edge_window: int = 5,
  447. edge_std_thresh: float = 6.0,
  448. dilate_radius: int = 1,
  449. ) -> np.ndarray:
  450. """基于局部方差的笔画边缘保护掩膜(True=保护,不置白)。"""
  451. local_std = _local_std_map(gray, window=edge_window)
  452. edge_mask = local_std >= edge_std_thresh
  453. if dilate_radius > 0:
  454. k = cv2.getStructuringElement(
  455. cv2.MORPH_ELLIPSE, (dilate_radius * 2 + 1, dilate_radius * 2 + 1)
  456. )
  457. edge_mask = cv2.dilate(edge_mask.astype(np.uint8), k) > 0
  458. return edge_mask.astype(bool)
  459. def _build_watermark_mask_light_on_white(
  460. gray: np.ndarray,
  461. *,
  462. bgr: Optional[np.ndarray] = None,
  463. light_gray_low: int = 236,
  464. light_gray_high: int = 253,
  465. whiten_gray_low: int = 200,
  466. text_protect_gray_max: int = 130,
  467. text_protect_percentile: Optional[float] = None,
  468. background_threshold: int = 248,
  469. morph_close_kernel: int = 0,
  470. morph_close_iter: int = 1,
  471. morph_dilate_kernel: int = 0,
  472. morph_dilate_iter: int = 1,
  473. min_component_area: int = 200,
  474. low_variance_thresh: float = 0.0,
  475. edge_window: int = 5,
  476. direction_filter: str = "hough",
  477. debug_block_maps: bool = True,
  478. debug_block_size: int = 48,
  479. hough_midtone_low: int = 200,
  480. hough_midtone_high: int = 254,
  481. hough_canny_low: int = 30,
  482. hough_canny_high: int = 100,
  483. hough_threshold: int = 25,
  484. hough_min_line_length: int = 35,
  485. hough_max_line_gap: int = 18,
  486. hough_line_thickness: int = 12,
  487. hough_band_dilate_radius: int = 14,
  488. hough_angle_tolerance: float = 5.0,
  489. hough_use_angle_statistics: bool = True,
  490. hough_secondary_peak_ratio: float = 0.35,
  491. hough_min_length_percentile: float = 25.0,
  492. diag_block_size: int = 0,
  493. diag_ratio_thresh: float = 0.20,
  494. diag_light_ratio_thresh: float = 0.10,
  495. diag_min_edge_count: int = 10,
  496. diag_dilate_radius: int = 3,
  497. seal_protect: bool = True,
  498. seal_hue_high: int = 15,
  499. seal_sat_min: int = 40,
  500. ) -> Tuple[np.ndarray, Dict[str, Any]]:
  501. """
  502. 白底流水水印掩膜(方案 C + E)。
  503. 1. Hough 斜向线段 → geom_region(几何限定区域)
  504. 2. wm_candidate = 浅色带且非正文保护
  505. 3. wm_mask = geom_region(置白区域由几何约束;实际白化时再 g>=light_gray_low)
  506. 4. debug 输出 candidate / geom / 交集 / 热力图
  507. """
  508. gray_arr = np.asarray(gray)
  509. bg_th = int(background_threshold)
  510. low = int(light_gray_low)
  511. high = int(light_gray_high)
  512. if text_protect_gray_max > 0:
  513. t_protect = float(text_protect_gray_max)
  514. else:
  515. dark = gray_arr[gray_arr < min(130, bg_th)]
  516. if dark.size > 0 and text_protect_percentile is not None:
  517. t_protect = float(np.percentile(dark, text_protect_percentile))
  518. else:
  519. t_protect = 120.0
  520. text_protect = gray_arr <= t_protect
  521. low = max(low, int(t_protect) + 25)
  522. wm_candidate = (gray_arr >= low) & (gray_arr < high) & (~text_protect)
  523. direction = (direction_filter or "hough").lower().strip()
  524. hough_info: Dict[str, Any] = {}
  525. geom_region = np.zeros_like(gray_arr, dtype=bool)
  526. if direction == "hough":
  527. geom_region, hough_info = _build_diag_hough_region_mask(
  528. gray_arr,
  529. midtone_low=hough_midtone_low,
  530. midtone_high=hough_midtone_high,
  531. canny_low=hough_canny_low,
  532. canny_high=hough_canny_high,
  533. hough_threshold=hough_threshold,
  534. min_line_length=hough_min_line_length,
  535. max_line_gap=hough_max_line_gap,
  536. angle_tolerance=hough_angle_tolerance,
  537. use_angle_statistics=hough_use_angle_statistics,
  538. secondary_peak_ratio=hough_secondary_peak_ratio,
  539. min_length_percentile=hough_min_length_percentile,
  540. line_thickness=hough_line_thickness,
  541. band_dilate_radius=hough_band_dilate_radius,
  542. )
  543. elif diag_block_size > 0:
  544. geom_region = _build_diag_region_mask(
  545. gray_arr,
  546. block_size=diag_block_size,
  547. diag_ratio_thresh=diag_ratio_thresh,
  548. light_gray_thresh=low,
  549. light_ratio_thresh=diag_light_ratio_thresh,
  550. min_edge_count=diag_min_edge_count,
  551. dilate_radius=diag_dilate_radius,
  552. )
  553. geom_candidate = geom_region & wm_candidate
  554. wm_mask = geom_region.copy()
  555. if min_component_area > 0 and np.any(wm_mask):
  556. n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
  557. wm_mask.astype(np.uint8), connectivity=8
  558. )
  559. filtered = np.zeros_like(wm_mask)
  560. for i in range(1, n_labels):
  561. if stats[i, cv2.CC_STAT_AREA] >= min_component_area:
  562. filtered[labels == i] = True
  563. if np.any(filtered):
  564. wm_mask = filtered
  565. elif np.any(geom_region):
  566. wm_mask = geom_region
  567. seal_mask = np.zeros_like(wm_mask, dtype=bool)
  568. if seal_protect and bgr is not None and bgr.ndim == 3:
  569. seal_mask = _build_seal_protect_mask(
  570. bgr, hue_high=seal_hue_high, sat_min=seal_sat_min
  571. )
  572. wm_mask &= ~seal_mask
  573. midtone = (gray_arr >= low) & (gray_arr < high)
  574. debug: Dict[str, Any] = {
  575. "mask_mode": "light_on_white",
  576. "direction_filter": direction,
  577. "light_gray_low": low,
  578. "light_gray_high": high,
  579. "midtone_ratio": float(midtone.sum() / gray_arr.size),
  580. "wm_candidate_ratio": float(wm_candidate.sum() / gray_arr.size),
  581. "geom_mask_ratio": float(geom_region.sum() / gray_arr.size),
  582. "geom_candidate_ratio": float(geom_candidate.sum() / gray_arr.size),
  583. "wm_mask_ratio": float(wm_mask.sum() / gray_arr.size),
  584. "T_protect": t_protect,
  585. "text_protect_gray_max": text_protect_gray_max,
  586. "text_protect": text_protect,
  587. "seal_protect": seal_mask,
  588. "wm_candidate": wm_candidate,
  589. "geom_region": geom_region,
  590. "geom_candidate": geom_candidate,
  591. "diag_region": geom_region,
  592. "wm_mask": wm_mask,
  593. "whiten_gray_low": int(whiten_gray_low),
  594. "hough_lines_bgr": hough_info.get("hough_lines_bgr"),
  595. "hough_lines_all_bgr": hough_info.get("hough_lines_all_bgr"),
  596. "angle_histogram_bgr": hough_info.get("angle_histogram_bgr"),
  597. "dominant_angles": hough_info.get("dominant_angles", []),
  598. "hough_kept_lines": hough_info.get("hough_kept_lines", 0),
  599. "hough_diag_candidates": hough_info.get("hough_diag_candidates", 0),
  600. "hough_total_lines": hough_info.get("hough_total_lines", 0),
  601. }
  602. if debug_block_maps:
  603. bs = debug_block_size if debug_block_size > 0 else 48
  604. diag_map, hv_map = _compute_block_orientation_debug_maps(gray_arr, block_size=bs)
  605. debug["diag_ratio_heatmap"] = render_ratio_heatmap(diag_map)
  606. debug["hv_ratio_heatmap"] = render_ratio_heatmap(hv_map)
  607. return wm_mask, debug
  608. def build_watermark_mask(
  609. gray: np.ndarray,
  610. *,
  611. bgr: Optional[np.ndarray] = None,
  612. mask_mode: str = "diagonal_midtone",
  613. light_gray_low: int = 236,
  614. light_gray_high: int = 253,
  615. whiten_gray_low: int = 200,
  616. text_protect_gray_max: int = 130,
  617. morph_close_kernel: int = 0,
  618. morph_close_iter: int = 1,
  619. morph_dilate_kernel: int = 0,
  620. morph_dilate_iter: int = 1,
  621. low_variance_thresh: float = 0.0,
  622. edge_window: int = 5,
  623. direction_filter: str = "hough",
  624. debug_block_maps: bool = True,
  625. debug_block_size: int = 48,
  626. hough_midtone_low: int = 200,
  627. hough_midtone_high: int = 254,
  628. hough_canny_low: int = 30,
  629. hough_canny_high: int = 100,
  630. hough_threshold: int = 25,
  631. hough_min_line_length: int = 35,
  632. hough_max_line_gap: int = 18,
  633. hough_line_thickness: int = 12,
  634. hough_band_dilate_radius: int = 14,
  635. hough_angle_tolerance: float = 5.0,
  636. hough_use_angle_statistics: bool = True,
  637. hough_secondary_peak_ratio: float = 0.35,
  638. hough_min_length_percentile: float = 25.0,
  639. diag_block_size: int = 0,
  640. diag_ratio_thresh: float = 0.20,
  641. diag_light_ratio_thresh: float = 0.10,
  642. diag_min_edge_count: int = 10,
  643. diag_dilate_radius: int = 3,
  644. # diagonal_midtone 参数
  645. midtone_low: int = 100,
  646. midtone_high: int = 220,
  647. remove_horizontal_vertical: bool = True,
  648. diagonal_enhance: bool = True,
  649. diagonal_kernel_length: int = 25,
  650. horizontal_kernel_length: int = 35,
  651. vertical_kernel_length: int = 35,
  652. morph_open_kernel: int = 2,
  653. dmorph_close_kernel: int = 3,
  654. min_component_area: int = 200,
  655. text_protect_percentile: float = 10.0,
  656. background_threshold: int = 248,
  657. seal_protect: bool = True,
  658. seal_hue_high: int = 15,
  659. seal_sat_min: int = 40,
  660. ) -> Tuple[np.ndarray, Dict[str, Any]]:
  661. """
  662. 构建水印掩膜 wm_mask(True=疑似水印像素)。
  663. mask_mode:
  664. light_on_white — Hough 斜向几何带 + 浅色白化(方案 C/E)
  665. diagonal_midtone — 中间调 + 斜向形态学(旧逻辑)
  666. """
  667. gray = np.asarray(gray)
  668. if gray.ndim != 2:
  669. raise ValueError("build_watermark_mask expects single-channel grayscale")
  670. mode = (mask_mode or "light_on_white").lower().strip()
  671. if mode == "light_on_white":
  672. return _build_watermark_mask_light_on_white(
  673. gray,
  674. bgr=bgr,
  675. light_gray_low=light_gray_low,
  676. light_gray_high=light_gray_high,
  677. whiten_gray_low=whiten_gray_low,
  678. text_protect_gray_max=text_protect_gray_max,
  679. text_protect_percentile=text_protect_percentile,
  680. background_threshold=background_threshold,
  681. morph_close_kernel=morph_close_kernel,
  682. morph_close_iter=morph_close_iter,
  683. morph_dilate_kernel=morph_dilate_kernel,
  684. morph_dilate_iter=morph_dilate_iter,
  685. low_variance_thresh=low_variance_thresh,
  686. edge_window=edge_window,
  687. min_component_area=min_component_area,
  688. direction_filter=direction_filter,
  689. debug_block_maps=debug_block_maps,
  690. debug_block_size=debug_block_size,
  691. hough_midtone_low=hough_midtone_low,
  692. hough_midtone_high=hough_midtone_high,
  693. hough_canny_low=hough_canny_low,
  694. hough_canny_high=hough_canny_high,
  695. hough_threshold=hough_threshold,
  696. hough_min_line_length=hough_min_line_length,
  697. hough_max_line_gap=hough_max_line_gap,
  698. hough_line_thickness=hough_line_thickness,
  699. hough_band_dilate_radius=hough_band_dilate_radius,
  700. hough_angle_tolerance=hough_angle_tolerance,
  701. hough_use_angle_statistics=hough_use_angle_statistics,
  702. hough_secondary_peak_ratio=hough_secondary_peak_ratio,
  703. hough_min_length_percentile=hough_min_length_percentile,
  704. diag_block_size=diag_block_size,
  705. diag_ratio_thresh=diag_ratio_thresh,
  706. diag_light_ratio_thresh=diag_light_ratio_thresh,
  707. diag_min_edge_count=diag_min_edge_count,
  708. diag_dilate_radius=diag_dilate_radius,
  709. seal_protect=seal_protect,
  710. seal_hue_high=seal_hue_high,
  711. seal_sat_min=seal_sat_min,
  712. )
  713. midtone = (gray > midtone_low) & (gray < midtone_high)
  714. mid_u8 = (midtone.astype(np.uint8)) * 255
  715. horiz = np.zeros_like(midtone, dtype=bool)
  716. vert = np.zeros_like(midtone, dtype=bool)
  717. if remove_horizontal_vertical:
  718. kh = cv2.getStructuringElement(
  719. cv2.MORPH_RECT, (max(3, horizontal_kernel_length), 1)
  720. )
  721. kv = cv2.getStructuringElement(
  722. cv2.MORPH_RECT, (1, max(3, vertical_kernel_length))
  723. )
  724. horiz = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, kh) > 0
  725. vert = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, kv) > 0
  726. # 中间调去掉明显横竖线(保留斜向水印)
  727. candidate = midtone & ~(horiz | vert)
  728. if diagonal_enhance:
  729. k45 = _line_structuring_kernel(diagonal_kernel_length, 45)
  730. k135 = _line_structuring_kernel(diagonal_kernel_length, 135)
  731. d45 = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, k45) > 0
  732. d135 = cv2.morphologyEx(mid_u8, cv2.MORPH_OPEN, k135) > 0
  733. direction = d45 | d135
  734. dilate_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
  735. near_diag = cv2.dilate(direction.astype(np.uint8), dilate_k) > 0
  736. # 斜向结构足够时收窄到斜向附近;否则保留「中间调减横竖」结果
  737. if near_diag.sum() > gray.size * 0.001:
  738. candidate = candidate & near_diag
  739. cand_u8 = (candidate.astype(np.uint8)) * 255
  740. if morph_open_kernel > 0:
  741. k_open = cv2.getStructuringElement(
  742. cv2.MORPH_ELLIPSE, (morph_open_kernel, morph_open_kernel)
  743. )
  744. cand_u8 = cv2.morphologyEx(cand_u8, cv2.MORPH_OPEN, k_open)
  745. if dmorph_close_kernel > 0:
  746. k_close = cv2.getStructuringElement(
  747. cv2.MORPH_ELLIPSE, (dmorph_close_kernel, dmorph_close_kernel)
  748. )
  749. cand_u8 = cv2.morphologyEx(cand_u8, cv2.MORPH_CLOSE, k_close)
  750. wm_mask = cand_u8 > 0
  751. if min_component_area > 0:
  752. n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
  753. wm_mask.astype(np.uint8), connectivity=8
  754. )
  755. filtered = np.zeros_like(wm_mask)
  756. for i in range(1, n_labels):
  757. if stats[i, cv2.CC_STAT_AREA] >= min_component_area:
  758. filtered[labels == i] = True
  759. wm_mask = filtered
  760. non_bg = gray[gray < background_threshold]
  761. if non_bg.size > 0:
  762. t_protect = float(np.percentile(non_bg, text_protect_percentile))
  763. else:
  764. t_protect = 85.0
  765. t_protect = max(t_protect, float(midtone_low))
  766. text_protect = gray <= t_protect
  767. midtone_ratio = float(midtone.sum() / gray.size)
  768. wm_ratio = float(wm_mask.sum() / gray.size)
  769. # 掩膜过小:回退为「中间调减横竖」或整块中间调(满版斜纹水印常见)
  770. min_wm_ratio = max(0.005, midtone_ratio * 0.12)
  771. if wm_ratio < min_wm_ratio:
  772. relaxed = midtone & ~(horiz | vert) & (~text_protect)
  773. if relaxed.sum() / gray.size < min_wm_ratio:
  774. relaxed = midtone & (~text_protect)
  775. wm_mask = relaxed
  776. wm_ratio = float(wm_mask.sum() / gray.size)
  777. seal_mask = np.zeros_like(wm_mask, dtype=bool)
  778. if seal_protect and bgr is not None and bgr.ndim == 3:
  779. seal_mask = _build_seal_protect_mask(
  780. bgr, hue_high=seal_hue_high, sat_min=seal_sat_min
  781. )
  782. debug: Dict[str, Any] = {
  783. "mask_mode": "diagonal_midtone",
  784. "midtone_ratio": midtone_ratio,
  785. "wm_mask_ratio": wm_ratio,
  786. "T_protect": t_protect,
  787. "text_protect": text_protect,
  788. "seal_protect": seal_mask,
  789. "midtone_mask": midtone,
  790. "wm_mask": wm_mask,
  791. }
  792. return wm_mask, debug
  793. def remove_watermark_masked_adaptive(
  794. gray: np.ndarray,
  795. *,
  796. bgr: Optional[np.ndarray] = None,
  797. mask_cfg: Optional[Dict[str, Any]] = None,
  798. adaptive_cfg: Optional[Dict[str, Any]] = None,
  799. threshold_fallback: int = 175,
  800. morph_close_kernel: int = 0,
  801. ) -> Tuple[np.ndarray, Dict[str, Any]]:
  802. """
  803. 掩膜内置白(whiten_mode=mask_fill)或掩膜内动态阈值(threshold_in_mask)。
  804. 掩膜为空时回退全局 threshold_fallback。
  805. """
  806. gray = np.asarray(gray).copy()
  807. mcfg: Dict[str, Any] = {
  808. "mask_mode": "light_on_white",
  809. "light_gray_low": 236,
  810. "light_gray_high": 253,
  811. "whiten_gray_low": 200,
  812. "text_protect_gray_max": 130,
  813. "morph_close_kernel": 0,
  814. "morph_close_iter": 1,
  815. "morph_dilate_kernel": 0,
  816. "morph_dilate_iter": 1,
  817. "low_variance_thresh": 0.0,
  818. "edge_window": 5,
  819. "min_component_area": 200,
  820. "direction_filter": "hough",
  821. "debug_block_maps": True,
  822. "debug_block_size": 48,
  823. "hough_midtone_low": 200,
  824. "hough_midtone_high": 254,
  825. "hough_canny_low": 30,
  826. "hough_canny_high": 100,
  827. "hough_threshold": 25,
  828. "hough_min_line_length": 35,
  829. "hough_max_line_gap": 18,
  830. "hough_line_thickness": 12,
  831. "hough_band_dilate_radius": 14,
  832. "hough_angle_tolerance": 5.0,
  833. "hough_use_angle_statistics": True,
  834. "hough_secondary_peak_ratio": 0.35,
  835. "hough_min_length_percentile": 25.0,
  836. "diag_block_size": 0,
  837. "diag_ratio_thresh": 0.20,
  838. "diag_light_ratio_thresh": 0.10,
  839. "diag_min_edge_count": 10,
  840. "diag_dilate_radius": 3,
  841. "midtone_low": 100,
  842. "midtone_high": 220,
  843. "remove_horizontal_vertical": True,
  844. "diagonal_enhance": True,
  845. "diagonal_kernel_length": 25,
  846. "horizontal_kernel_length": 35,
  847. "vertical_kernel_length": 35,
  848. "morph_open_kernel": 2,
  849. "dmorph_close_kernel": 3,
  850. "text_protect_percentile": 10.0,
  851. "background_threshold": 248,
  852. "seal_protect": True,
  853. "seal_hue_high": 15,
  854. "seal_sat_min": 40,
  855. }
  856. mcfg.update(mask_cfg or {})
  857. mask_mode = str(mcfg.get("mask_mode", "light_on_white")).lower().strip()
  858. # light_on_white 默认 mask_fill
  859. acfg: Dict[str, Any] = {
  860. "whiten_mode": None,
  861. "text_percentile": 10.0,
  862. "watermark_percentile": 88.0,
  863. "background_percentile": 95.0,
  864. "background_threshold": 248,
  865. "wm_margin": 12,
  866. "text_protect_max": 120,
  867. }
  868. acfg.update(adaptive_cfg or {})
  869. whiten_mode = acfg.get("whiten_mode")
  870. if not whiten_mode:
  871. whiten_mode = (
  872. "mask_fill"
  873. if mask_mode == "light_on_white"
  874. else "threshold_in_mask"
  875. )
  876. whiten_mode = str(whiten_mode).lower().strip()
  877. wm_mask, debug = build_watermark_mask(gray, bgr=bgr, **mcfg)
  878. if not np.any(wm_mask):
  879. cleaned = gray.copy()
  880. cleaned[gray > threshold_fallback] = 255
  881. debug["mode"] = "fallback_threshold"
  882. debug["threshold_fallback"] = threshold_fallback
  883. if morph_close_kernel > 0:
  884. kernel = np.ones((morph_close_kernel, morph_close_kernel), np.uint8)
  885. cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel)
  886. return cleaned, debug
  887. bg_th = int(acfg["background_threshold"])
  888. bg_pixels = gray[gray >= bg_th]
  889. if bg_pixels.size > 0:
  890. b_level = float(np.percentile(bg_pixels, acfg["background_percentile"]))
  891. else:
  892. b_level = 250.0
  893. if mask_mode == "light_on_white":
  894. t_protect = float(debug.get("T_protect", 150.0))
  895. else:
  896. non_bg = gray[gray < bg_th]
  897. if non_bg.size > 0:
  898. t_protect = float(np.percentile(non_bg, acfg["text_percentile"]))
  899. else:
  900. t_protect = float(debug.get("T_protect", 85.0))
  901. t_protect = min(t_protect, float(acfg["text_protect_max"]))
  902. t_protect = max(t_protect, float(mcfg.get("midtone_low", 100)))
  903. text_protect = debug["text_protect"]
  904. seal_protect = debug["seal_protect"]
  905. t_wm: Optional[float] = None
  906. if whiten_mode == "mask_fill":
  907. # 几何带内:g>=whiten_gray_low 置白;g<=130 正文硬保护(方案 E)
  908. wm_gray_low = float(
  909. mcfg.get("whiten_gray_low", debug.get("whiten_gray_low", 200))
  910. )
  911. to_white = (
  912. wm_mask
  913. & (gray >= wm_gray_low)
  914. & (gray < int(mcfg.get("light_gray_high", 254)))
  915. & (~text_protect)
  916. & (~seal_protect)
  917. )
  918. else:
  919. mask_vals = gray[wm_mask]
  920. if mask_vals.size > 0:
  921. t_wm = float(np.percentile(mask_vals, acfg["watermark_percentile"]))
  922. else:
  923. t_wm = t_protect + 0.45 * (b_level - t_protect)
  924. margin = float(acfg["wm_margin"])
  925. t_wm = max(t_wm, t_protect + margin)
  926. t_wm = min(t_wm, b_level - 3.0)
  927. t_wm = min(t_wm, float(mcfg.get("midtone_high", 220)) - 5.0)
  928. to_white = wm_mask & (gray >= t_wm) & (~text_protect) & (~seal_protect)
  929. cleaned = gray.copy()
  930. cleaned[to_white] = 255
  931. if morph_close_kernel > 0:
  932. kernel = np.ones((morph_close_kernel, morph_close_kernel), np.uint8)
  933. cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel)
  934. debug.update(
  935. {
  936. "mode": "masked_adaptive",
  937. "mask_mode": mask_mode,
  938. "whiten_mode": whiten_mode,
  939. "T_wm": t_wm,
  940. "T_protect": t_protect,
  941. "B_level": b_level,
  942. "white_pixel_ratio": float(to_white.sum() / gray.size),
  943. "threshold_fallback": threshold_fallback,
  944. }
  945. )
  946. return cleaned, debug
  947. def _image_to_gray_and_bgr(
  948. image: Union[np.ndarray, Image.Image],
  949. ) -> Tuple[np.ndarray, Optional[np.ndarray]]:
  950. """统一为灰度 + 可选 BGR(用于掩膜公章保护)。"""
  951. if isinstance(image, Image.Image):
  952. pil_img = image.convert("RGB") if image.mode == "RGBA" else image
  953. np_img = np.array(pil_img)
  954. np_img = cv2.cvtColor(np_img, cv2.COLOR_RGB2BGR)
  955. else:
  956. np_img = image.copy()
  957. if np_img.ndim == 3:
  958. bgr = np_img
  959. gray = cv2.cvtColor(np_img, cv2.COLOR_BGR2GRAY)
  960. else:
  961. bgr = None
  962. gray = np_img
  963. return gray, bgr