round_corners.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. """给图像加圆角矩形遮罩:可导出带透明通道的 PNG,或与纯色背景合成(便于 JPG)。"""
  2. from __future__ import annotations
  3. import argparse
  4. import sys
  5. from pathlib import Path
  6. import cv2
  7. import numpy as np
  8. from routing import repo_path
  9. def rounded_rect_mask(height: int, width: int, radius_px: int) -> np.ndarray:
  10. """单通道遮罩,圆角内为 255,外为 0。"""
  11. r = int(max(0, min(radius_px, min(height, width) // 2)))
  12. m = np.zeros((height, width), dtype=np.uint8)
  13. if r == 0:
  14. m[:] = 255
  15. return m
  16. h, w = height - 1, width - 1
  17. cv2.rectangle(m, (r, 0), (w - r, height - 1), 255, thickness=-1)
  18. cv2.rectangle(m, (0, r), (width - 1, h - r), 255, thickness=-1)
  19. cv2.ellipse(m, (r, r), (r, r), 180, 0, 90, 255, thickness=-1)
  20. cv2.ellipse(m, (w - r, r), (r, r), 270, 0, 90, 255, thickness=-1)
  21. cv2.ellipse(m, (w - r, h - r), (r, r), 0, 0, 90, 255, thickness=-1)
  22. cv2.ellipse(m, (r, h - r), (r, r), 90, 0, 90, 255, thickness=-1)
  23. return m
  24. def round_corners(
  25. input_path: str | Path,
  26. output_path: str | Path,
  27. radius_px: int,
  28. *,
  29. background_bgr: tuple[int, int, int] | None = None,
  30. feather_px: float = 0.0,
  31. ) -> Path:
  32. """
  33. :param background_bgr: 若为 ``None``,输出保留透明(圆角外 alpha=0);否则与给定 BGR 底色合成(适合 ``.jpg``)。
  34. :param feather_px: 对遮罩做轻微高斯模糊半径(像素),边缘更柔和;``0`` 为硬边。
  35. """
  36. inp = repo_path(input_path)
  37. out = repo_path(output_path)
  38. img = cv2.imread(str(inp), cv2.IMREAD_UNCHANGED)
  39. if img is None:
  40. raise FileNotFoundError(f"无法读取:{inp}")
  41. if img.ndim == 2:
  42. img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
  43. h, w = img.shape[:2]
  44. mask = rounded_rect_mask(h, w, radius_px).astype(np.float32) / 255.0
  45. if feather_px > 0:
  46. k = max(1, int(round(feather_px * 2)) | 1)
  47. mask_u8 = (mask * 255).astype(np.uint8)
  48. mask_u8 = cv2.GaussianBlur(mask_u8, (k, k), feather_px)
  49. mask = mask_u8.astype(np.float32) / 255.0
  50. out.parent.mkdir(parents=True, exist_ok=True)
  51. if background_bgr is None:
  52. if img.shape[2] == 4:
  53. bgra = img.astype(np.float32)
  54. a_in = bgra[:, :, 3:4] / 255.0
  55. a_out = mask[..., np.newaxis] * a_in
  56. bgra[:, :, :3] *= mask[..., np.newaxis]
  57. bgra[:, :, 3:4] = np.clip(a_out * 255.0, 0, 255)
  58. write_img = np.clip(bgra, 0, 255).astype(np.uint8)
  59. else:
  60. bgr = img.astype(np.float32)
  61. bgr *= mask[..., np.newaxis]
  62. alpha = (mask * 255.0).astype(np.uint8)
  63. write_img = cv2.merge(
  64. [
  65. bgr[:, :, 0].astype(np.uint8),
  66. bgr[:, :, 1].astype(np.uint8),
  67. bgr[:, :, 2].astype(np.uint8),
  68. alpha,
  69. ]
  70. )
  71. suffix = out.suffix.lower()
  72. if suffix not in (".png", ".webp", ".tif", ".tiff"):
  73. out = out.with_suffix(".png")
  74. if not cv2.imwrite(str(out), write_img):
  75. raise OSError(f"无法写入:{out}")
  76. return out
  77. bg_b, bg_g, bg_r = background_bgr
  78. if img.shape[2] == 4:
  79. bgr = cv2.cvtColor(img, cv2.COLOR_BGRA2BGR).astype(np.float32)
  80. alpha = (img[:, :, 3].astype(np.float32) / 255.0) * mask
  81. else:
  82. bgr = img.astype(np.float32)
  83. alpha = mask
  84. bg = np.zeros_like(bgr)
  85. bg[:, :, 0] = bg_b
  86. bg[:, :, 1] = bg_g
  87. bg[:, :, 2] = bg_r
  88. a = alpha[..., np.newaxis]
  89. blended = np.clip(bgr * a + bg * (1.0 - a), 0, 255).astype(np.uint8)
  90. if not cv2.imwrite(str(out), blended):
  91. raise OSError(f"无法写入:{out}")
  92. return out
  93. def round_corner(
  94. *,
  95. degree: int,
  96. image: str | Path,
  97. output_path: str | Path,
  98. background_bgr: tuple[int, int, int] | None = None,
  99. feather_px: float = 0.0,
  100. ) -> Path:
  101. """
  102. 与 ``main`` 等调用约定一致的关键字接口。
  103. ``degree``:圆角半径(像素),取值含义与 ``round_corners(..., radius_px=...)`` 相同(命名为 ``degree`` 仅为兼容现有调用)。
  104. ``image`` / ``output_path``:相对路径相对仓库根目录(见 ``routing.repo_path``)。
  105. """
  106. return round_corners(
  107. image,
  108. output_path,
  109. int(degree),
  110. background_bgr=background_bgr,
  111. feather_px=feather_px,
  112. )
  113. def main() -> None:
  114. ap = argparse.ArgumentParser(description="给图片加圆角并保存(相对路径相对仓库根目录)。")
  115. ap.add_argument("input", type=Path, help="输入图路径")
  116. ap.add_argument("-o", "--output", type=Path, required=True, help="输出路径")
  117. ap.add_argument(
  118. "-r",
  119. "--radius",
  120. type=int,
  121. default=24,
  122. help="圆角半径(像素),不超过短边一半",
  123. )
  124. ap.add_argument(
  125. "--bg",
  126. type=str,
  127. default=None,
  128. help="合成底色 B,G,R(0-255),逗号分隔;省略则输出透明 PNG(自动改后缀为 .png)",
  129. )
  130. ap.add_argument(
  131. "--feather",
  132. type=float,
  133. default=0.0,
  134. help="遮罩边缘羽化(高斯 sigma,像素),0 为硬边",
  135. )
  136. args = ap.parse_args()
  137. bg = None
  138. if args.bg:
  139. parts = [int(x.strip()) for x in args.bg.split(",")]
  140. if len(parts) != 3:
  141. raise SystemExit("--bg 须为三个整数:B,G,R")
  142. bg = (parts[0], parts[1], parts[2])
  143. p = round_corners(
  144. args.input,
  145. args.output,
  146. args.radius,
  147. background_bgr=bg,
  148. feather_px=args.feather,
  149. )
  150. print(f"已写入:{p}", flush=True)
  151. if __name__ == "__main__":
  152. if sys.platform == "win32":
  153. for stream in (sys.stdout, sys.stderr):
  154. try:
  155. stream.reconfigure(encoding="utf-8")
  156. except Exception:
  157. pass
  158. main()