make_png_bg_transparent.py 3.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. from collections import Counter, deque
  2. from pathlib import Path
  3. import sys
  4. from PIL import Image
  5. def make_transparent(path: Path) -> tuple[int, tuple[int, int, int], tuple[int, int, int], int, int]:
  6. img = Image.open(path).convert("RGBA")
  7. px = img.load()
  8. w, h = img.size
  9. samples = []
  10. border_points = [
  11. (0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1),
  12. (w // 2, 0), (w // 2, h - 1), (0, h // 2), (w - 1, h // 2),
  13. (1, 1), (w - 2, 1), (1, h - 2), (w - 2, h - 2),
  14. ]
  15. for x, y in border_points:
  16. samples.append(px[x, y][:3])
  17. bg = (
  18. sum(c[0] for c in samples) // len(samples),
  19. sum(c[1] for c in samples) // len(samples),
  20. sum(c[2] for c in samples) // len(samples),
  21. )
  22. # For checkerboard backgrounds, we also capture another dominant border color.
  23. border_all = []
  24. for x in range(w):
  25. border_all.append(px[x, 0][:3])
  26. border_all.append(px[x, h - 1][:3])
  27. for y in range(h):
  28. border_all.append(px[0, y][:3])
  29. border_all.append(px[w - 1, y][:3])
  30. top_border_colors = Counter(border_all).most_common(2)
  31. bg2 = top_border_colors[1][0] if len(top_border_colors) > 1 else bg
  32. threshold = 52.0
  33. visited = [[False] * w for _ in range(h)]
  34. q = deque()
  35. for x in range(w):
  36. q.append((x, 0))
  37. q.append((x, h - 1))
  38. for y in range(h):
  39. q.append((0, y))
  40. q.append((w - 1, y))
  41. def dist(a: tuple[int, int, int], b: tuple[int, int, int]) -> float:
  42. dr = a[0] - b[0]
  43. dg = a[1] - b[1]
  44. db = a[2] - b[2]
  45. return (dr * dr + dg * dg + db * db) ** 0.5
  46. def is_grayish(rgb: tuple[int, int, int]) -> bool:
  47. hi = max(rgb)
  48. lo = min(rgb)
  49. return (hi - lo) <= 20
  50. def close_to_bg(rgb: tuple[int, int, int]) -> bool:
  51. # Match one of checkerboard tones or similar gray edge tone.
  52. if dist(rgb, bg) <= threshold or dist(rgb, bg2) <= threshold:
  53. return True
  54. return is_grayish(rgb) and 65 <= rgb[0] <= 180 and 65 <= rgb[1] <= 180 and 65 <= rgb[2] <= 180
  55. changed = 0
  56. while q:
  57. x, y = q.popleft()
  58. if x < 0 or x >= w or y < 0 or y >= h or visited[y][x]:
  59. continue
  60. visited[y][x] = True
  61. r, g, b, a = px[x, y]
  62. if a == 0 or not close_to_bg((r, g, b)):
  63. continue
  64. px[x, y] = (r, g, b, 0)
  65. changed += 1
  66. q.append((x + 1, y))
  67. q.append((x - 1, y))
  68. q.append((x, y + 1))
  69. q.append((x, y - 1))
  70. # Second pass: remove remaining checkerboard "islands" anywhere in canvas
  71. # (not necessarily edge-connected). This targets neutral gray pixels only.
  72. for y in range(h):
  73. for x in range(w):
  74. r, g, b, a = px[x, y]
  75. if a == 0:
  76. continue
  77. mx = max(r, g, b)
  78. mn = min(r, g, b)
  79. avg = (r + g + b) // 3
  80. # Typical checkerboard: neutral gray in mid-dark range.
  81. if (mx - mn) <= 16 and 35 <= avg <= 185:
  82. px[x, y] = (r, g, b, 0)
  83. changed += 1
  84. img.save(path)
  85. alpha_hist = Image.open(path).convert("RGBA").getchannel("A").histogram()
  86. transparent = alpha_hist[0]
  87. nonzero = sum(alpha_hist[1:])
  88. return changed, bg, bg2, transparent, nonzero
  89. def main() -> int:
  90. if len(sys.argv) < 2:
  91. print("Usage: python make_png_bg_transparent.py <png_path>")
  92. return 2
  93. path = Path(sys.argv[1])
  94. if not path.exists():
  95. print(f"not found: {path}")
  96. return 1
  97. changed, bg, bg2, transparent, nonzero = make_transparent(path)
  98. print(f"done: {path}")
  99. print(
  100. f"bg1={bg}, bg2={bg2}, changed={changed}, "
  101. f"transparent={transparent}, nonzero_alpha={nonzero}"
  102. )
  103. return 0
  104. if __name__ == "__main__":
  105. raise SystemExit(main())