crop_img.py 6.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. """
  2. 按四边比例裁剪图像(路径或 ``numpy`` ndarray)。模块:``thirdparty/crop_img.py``。
  3. 文件读入默认 **保留颜色**(``IMREAD_UNCHANGED``:灰度 / BGR / BGRA),**不强行转灰度**。
  4. **只做裁剪,不做缩放**:内部仅为 ``numpy`` 切片 ``img[y0:y1, x0:x1]``,**从不调用** ``cv2.resize`` 等插值。
  5. 若原图接近正方形、且只裁上下不裁左右,则输出 **像素宽度与原版相同**、**高度变矮**,宽高比变大,
  6. 在显示器上会显得「横向更宽」——这是比例变化,不是把图横向拉伸。
  7. 四个比例参数:取值 **0~1**,直接表示该边要裁掉的相对长度——
  8. 上、下相对**图像高度**,左、右相对**图像宽度**;**1.0 = 裁掉该边方向 100%**。
  9. 例如 ``0.1, 0.1, 0, 0`` 表示上下各裁 10% 高;``0.1`` 即 10%。总裁切过量时会压缩,保证至少剩 4×4 像素。
  10. 可选第 6 个位置参数 ``output_path``:若给出(相对路径相对仓库根目录),则保存到该路径并等价于 ``save=True``。
  11. 否则由关键字 ``save``、``output_dir``、``output_filename`` 控制写出(默认 ``output/cut-img.png``)。
  12. 简约用法(顺序:上、下、左、右、图源、可选写出路径)::
  13. CropImg().crop_edges(0.1, 0.1, 0.0, 0.0, path, "output/crop-img.png")
  14. CropImg().crop_edges(0.1, 0.1, 0.0, 0.0, path, save=True, output_dir=..., output_filename="...")
  15. """
  16. from __future__ import annotations
  17. from pathlib import Path
  18. from typing import Union
  19. import cv2
  20. import numpy as np
  21. from routing import REPO_ROOT, repo_path
  22. ImageSource = Union[str, Path, np.ndarray]
  23. _DEFAULT_OUTPUT_NAME = "cut-img.png"
  24. def read_image_unchanged(path: str | Path) -> np.ndarray:
  25. """
  26. 与裁剪管线读图一致:``IMREAD_UNCHANGED``,二值/灰度为 ``(H,W)``,彩色为 ``(H,W,3)`` 或带透明 ``(H,W,4)``。
  27. """
  28. p = repo_path(path)
  29. img = cv2.imread(str(p), cv2.IMREAD_UNCHANGED)
  30. if img is None:
  31. raise FileNotFoundError(f"无法读取图片:{p}")
  32. return img
  33. def _load_for_crop(image: ImageSource) -> np.ndarray:
  34. if isinstance(image, np.ndarray):
  35. arr = image
  36. if arr.ndim == 2:
  37. return arr
  38. if arr.ndim == 3 and arr.shape[2] in (3, 4):
  39. return arr
  40. raise ValueError("ndarray 须为 (H,W) 或 (H,W,3)/(H,W,4)。")
  41. return read_image_unchanged(image)
  42. def _crop_frac(x: float | int) -> float:
  43. """裁切比例:0~1,1 为 100% 边长。"""
  44. return float(np.clip(float(x), 0.0, 1.0))
  45. def crop_slice_bounds(
  46. h: int,
  47. w: int,
  48. top: float | int,
  49. bottom: float | int,
  50. left: float | int,
  51. right: float | int,
  52. ) -> tuple[int, int, int, int]:
  53. """
  54. 与 ``crop_image_edges`` 相同规则,返回 ``img[y0:y1, x0:x1]`` 的半开切片(与 numpy 一致)。
  55. 返回值 ``(y0, y1, x0, x1)``;若无法安全裁剪则 ``(0, h, 0, w)`` 表示整幅。
  56. """
  57. if h < 4 or w < 4:
  58. return 0, h, 0, w
  59. tf = _crop_frac(top)
  60. bf = _crop_frac(bottom)
  61. lf = _crop_frac(left)
  62. rf = _crop_frac(right)
  63. dt = int(round(h * tf))
  64. db = int(round(h * bf))
  65. dl = int(round(w * lf))
  66. dr = int(round(w * rf))
  67. dt, db = max(0, dt), max(0, db)
  68. dl, dr = max(0, dl), max(0, dr)
  69. if dt + db > h - 4:
  70. s = (h - 4) / max(dt + db, 1)
  71. dt = int(dt * s)
  72. db = int(db * s)
  73. db = (h - 4) - dt
  74. if dl + dr > w - 4:
  75. s = (w - 4) / max(dl + dr, 1)
  76. dl = int(dl * s)
  77. dr = int(dr * s)
  78. dr = (w - 4) - dl
  79. nh = h - dt - db
  80. nw = w - dl - dr
  81. if nh < 4 or nw < 4:
  82. return 0, h, 0, w
  83. y0, y1, x0, x1 = dt, h - db, dl, w - dr
  84. return y0, y1, x0, x1
  85. def crop_image_edges(
  86. top: float | int,
  87. bottom: float | int,
  88. left: float | int,
  89. right: float | int,
  90. image: ImageSource,
  91. output_path: str | Path | None = None,
  92. *,
  93. save: bool = True,
  94. output_dir: str | Path | None = None,
  95. output_filename: str = _DEFAULT_OUTPUT_NAME,
  96. ) -> np.ndarray:
  97. """
  98. 按上、下、左、右四边裁切;各参数为 **0~1**,即该边裁掉的相对高/宽(``0.1`` = 10%)。
  99. :param top, bottom: 相对**高度**的比例
  100. :param left, right: 相对**宽度**的比例
  101. :param image: 路径,或 ``(H,W)`` / ``(H,W,3)`` / ``(H,W,4)`` ndarray(相对路径相对仓库根目录)
  102. :param output_path: 若给定则写入该文件并强制 ``save=True``(目录由路径决定)
  103. :param save: 未给 ``output_path`` 时是否写入 ``output_dir / output_filename``
  104. """
  105. if output_path is not None:
  106. out_full = repo_path(output_path)
  107. output_dir = out_full.parent
  108. output_filename = out_full.name
  109. save = True
  110. img = _load_for_crop(image)
  111. h, w = int(img.shape[0]), int(img.shape[1])
  112. y0, y1, x0, x1 = crop_slice_bounds(h, w, top, bottom, left, right)
  113. if (y0, y1, x0, x1) == (0, h, 0, w):
  114. out = img
  115. else:
  116. # 纯切片裁切;不改分辨率、不做插值(与 cv2.resize 无关)。
  117. out = np.ascontiguousarray(img[y0:y1, x0:x1])
  118. if save:
  119. _save_cropped_image(out, output_dir, output_filename)
  120. return out
  121. def _save_cropped_image(
  122. image: np.ndarray,
  123. output_dir: str | Path | None,
  124. output_filename: str,
  125. ) -> Path:
  126. if output_dir is not None:
  127. out_dir = repo_path(output_dir)
  128. else:
  129. out_dir = REPO_ROOT / "output"
  130. out_dir.mkdir(parents=True, exist_ok=True)
  131. out_path = out_dir / output_filename
  132. if not cv2.imwrite(str(out_path), image):
  133. raise OSError(f"无法写入:{out_path.resolve()}")
  134. return out_path
  135. def write_gray(path: str | Path, gray: np.ndarray) -> Path:
  136. """写入灰度图(路径后缀决定格式,``.png`` 最稳妥);相对路径相对仓库根目录。"""
  137. out = repo_path(path)
  138. out.parent.mkdir(parents=True, exist_ok=True)
  139. if not cv2.imwrite(str(out), gray):
  140. raise OSError(f"无法写入:{out}")
  141. return out
  142. class CropImg:
  143. """四边比例裁剪;无状态,可重复 ``CropImg().crop_edges(...)``。"""
  144. def crop_edges(
  145. self,
  146. top: float | int,
  147. bottom: float | int,
  148. left: float | int,
  149. right: float | int,
  150. image: ImageSource,
  151. output_path: str | Path | None = None,
  152. *,
  153. save: bool = True,
  154. output_dir: str | Path | None = None,
  155. output_filename: str = _DEFAULT_OUTPUT_NAME,
  156. ) -> np.ndarray:
  157. return crop_image_edges(
  158. top,
  159. bottom,
  160. left,
  161. right,
  162. image,
  163. output_path=output_path,
  164. save=save,
  165. output_dir=output_dir,
  166. output_filename=output_filename,
  167. )