_bid_make_seals.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. # -*- coding: utf-8 -*-
  2. """从授权书扫描件里抠出单位公章、法人印鉴与手写签名,做成透明 PNG。
  3. 扫描件底色偏黄且有网点,直接按「非白即墨」抠图会留下一圈脏边,故按通道差
  4. 分别处理:红章看 R 与 G/B 的差,签名看整体明度,再各自把弱信号压到全透明。
  5. """
  6. import io
  7. import os
  8. import shutil
  9. import sys
  10. import numpy as np
  11. from PIL import Image
  12. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
  13. HERE = os.path.dirname(os.path.abspath(__file__))
  14. SRC = os.path.join(HERE, "_bid_assets", "seal_src", "p1_img1.png")
  15. OUT = os.path.join(HERE, "_bid_assets", "seal")
  16. os.makedirs(OUT, exist_ok=True)
  17. # 由 _tmp_seal_locate.py 定位得到(含少量留白)
  18. BOX = {
  19. "gongzhang": (843, 2235, 1310, 2739), # 圆形单位公章
  20. "yinjian": (1578, 2347, 1821, 2603), # 方形法定代表人印鉴
  21. "signature": (215, 2330, 520, 2520), # 手写签名「刘涛」
  22. }
  23. # 扫描件是整幅 A4,但像素为 2140×3264,纵向被拉长了 7.8%(正圆公章的墨迹
  24. # 框 440×479 与正方印鉴的 217×231 都印证了这一点)。按 A4 宽高比压回,
  25. # 公章才是正圆、印鉴才是正方,后面按毫米摆放时才不会变形。
  26. SQUASH = 2140 * 297 / 210 / 3264
  27. def stats(a, name):
  28. r, g, b = a[..., 0], a[..., 1], a[..., 2]
  29. print(f" {name}: 背景估计 R{np.percentile(r, 95):.0f} "
  30. f"G{np.percentile(g, 95):.0f} B{np.percentile(b, 95):.0f} "
  31. f"最暗 R{r.min()} G{g.min()} B{b.min()}")
  32. def red_seal(crop, name):
  33. """红章:以 G/B 通道的下沉量作为墨量,颜色统一成正红。"""
  34. a = np.asarray(crop, dtype=np.float32)
  35. stats(a, name)
  36. gb = (a[..., 1] + a[..., 2]) / 2
  37. bg = np.percentile(gb, 96) # 纸张底色
  38. ink = np.clip((bg - gb) / max(bg - 55.0, 1.0), 0, 1)
  39. ink[ink < 0.14] = 0 # 压掉网点与黄底
  40. alpha = (ink ** 0.85 * 255).astype(np.uint8)
  41. rgb = np.zeros(a.shape, dtype=np.uint8)
  42. rgb[..., 0], rgb[..., 1], rgb[..., 2] = 200, 26, 26
  43. out = Image.fromarray(np.dstack([rgb, alpha]), "RGBA")
  44. return out
  45. def ink_sign(crop, name):
  46. """签名:黑色笔迹,按明度取墨量。"""
  47. a = np.asarray(crop.convert("L"), dtype=np.float32)
  48. print(f" {name}: 明度 背景{np.percentile(a, 95):.0f} 最暗{a.min():.0f}")
  49. bg = np.percentile(a, 95)
  50. ink = np.clip((bg - a) / max(bg - 60.0, 1.0), 0, 1)
  51. ink[ink < 0.18] = 0
  52. alpha = (ink ** 0.9 * 255).astype(np.uint8)
  53. rgb = np.zeros(a.shape + (3,), dtype=np.uint8)
  54. rgb[..., 0], rgb[..., 1], rgb[..., 2] = 20, 24, 40 # 蓝黑墨
  55. return Image.fromarray(np.dstack([rgb, alpha]), "RGBA")
  56. def despeckle(im, min_px=40):
  57. """去掉扫描噪点:丢弃面积过小的连通块(用逐行并查集做 8 邻域标记)。"""
  58. a = np.asarray(im).copy()
  59. mask = a[..., 3] > 8
  60. h, w = mask.shape
  61. lab = np.zeros((h, w), dtype=np.int32)
  62. parent = [0]
  63. def find(x):
  64. while parent[x] != x:
  65. parent[x] = parent[parent[x]]
  66. x = parent[x]
  67. return x
  68. def union(x, y):
  69. rx, ry = find(x), find(y)
  70. if rx != ry:
  71. parent[max(rx, ry)] = min(rx, ry)
  72. for y in range(h):
  73. for x in range(w):
  74. if not mask[y, x]:
  75. continue
  76. nb = [lab[y + dy, x + dx]
  77. for dy, dx in ((-1, -1), (-1, 0), (-1, 1), (0, -1))
  78. if 0 <= y + dy < h and 0 <= x + dx < w and lab[y + dy, x + dx]]
  79. if nb:
  80. lab[y, x] = min(nb)
  81. for n in nb:
  82. union(lab[y, x], n)
  83. else:
  84. parent.append(len(parent))
  85. lab[y, x] = len(parent) - 1
  86. roots = np.zeros(len(parent), dtype=np.int32)
  87. for i in range(1, len(parent)):
  88. roots[i] = find(i)
  89. flat = roots[lab]
  90. ids, cnt = np.unique(flat[flat > 0], return_counts=True)
  91. drop = set(ids[cnt < min_px].tolist())
  92. if drop:
  93. kill = np.isin(flat, list(drop))
  94. a[..., 3][kill] = 0
  95. print(f" 去斑:丢弃 {len(drop)} 个小块,共 {int(kill.sum())} 像素")
  96. return Image.fromarray(a, "RGBA")
  97. def trim(im, pad=6):
  98. a = np.asarray(im)[..., 3]
  99. ys, xs = np.nonzero(a > 8)
  100. if not len(ys):
  101. return im
  102. return im.crop((max(0, xs.min() - pad), max(0, ys.min() - pad),
  103. min(im.width, xs.max() + pad), min(im.height, ys.max() + pad)))
  104. src = Image.open(SRC).convert("RGB")
  105. print(f"源图 {src.size} 纵向压回系数 {SQUASH:.4f}")
  106. for name, box in BOX.items():
  107. crop = src.crop(box)
  108. crop = crop.resize((crop.width, round(crop.height * SQUASH)), Image.LANCZOS)
  109. im = ink_sign(crop, name) if name == "signature" else red_seal(crop, name)
  110. im = trim(despeckle(im, 60 if name == "signature" else 30))
  111. path = os.path.join(OUT, f"{name}.png")
  112. im.save(path)
  113. a = np.asarray(im)[..., 3]
  114. ys, xs = np.nonzero(a > 8)
  115. bw, bh = xs.max() - xs.min() + 1, ys.max() - ys.min() + 1
  116. print(f" → {name}.png {im.size} 墨迹 {bw}×{bh}(长宽比 {bw / bh:.3f})"
  117. f" 非透明像素 {int((a > 8).sum()):,}")
  118. # 拼一张白底预览,便于确认抠图干净
  119. prev = Image.new("RGB", (980, 420), "white")
  120. x = 20
  121. for name in BOX:
  122. im = Image.open(os.path.join(OUT, f"{name}.png"))
  123. im.thumbnail((300, 300))
  124. prev.paste(im, (x, 60), im)
  125. x += im.width + 30
  126. prev.save(os.path.join(OUT, "_preview.png"))
  127. print(f"\n预览:{os.path.join(OUT, '_preview.png')}")
  128. # 另存一份到招投标材料目录,供另行取用
  129. SHARE = os.path.join(r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目招投标",
  130. "相关准备材料", "印鉴素材(透明底)")
  131. CN = {"gongzhang": "单位公章", "yinjian": "法定代表人印鉴", "signature": "法定代表人签名"}
  132. if os.path.isdir(os.path.dirname(SHARE)):
  133. os.makedirs(SHARE, exist_ok=True)
  134. print(f"\n素材另存至 {SHARE}")
  135. for name, cn in CN.items():
  136. shutil.copy2(os.path.join(OUT, f"{name}.png"),
  137. os.path.join(SHARE, f"{cn}-透明底.png"))
  138. print(f" {cn}-透明底.png")
  139. shutil.copy2(os.path.join(OUT, "_preview.png"),
  140. os.path.join(SHARE, "三枚印鉴预览.png"))
  141. print(" 三枚印鉴预览.png")