scan, split, OCR, prepare for LLM
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. from __future__ import annotations
  2. import logging
  3. from pathlib import Path
  4. import cv2
  5. import numpy as np
  6. logger = logging.getLogger(__name__)
  7. SUPPORTED_EXTENSIONS: frozenset[str] = frozenset({".jpg", ".jpeg", ".png", ".tiff", ".tif"})
  8. PAGE_NUMBER_WIDTH: int = 2
  9. DEFAULT_BORDER_PX: int = 5
  10. BORDER_COLOR: tuple[int, int, int] = (255, 255, 255)
  11. VALID_ROTATIONS: frozenset[int] = frozenset({90, 180, 270})
  12. DENSITY_RATIO: float = 0.005
  13. AUTO_GRID_MAX: int = 4
  14. EMPTY_DARK_THRESHOLD: int = 100
  15. EMPTY_DARK_RATIO: float = 0.001
  16. def load_image(path: Path) -> np.ndarray:
  17. if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
  18. raise ValueError(
  19. f"Неподдерживаемый формат: {path.suffix}. "
  20. f"Поддерживаются: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
  21. )
  22. if not path.exists():
  23. raise FileNotFoundError(f"Файл не найден: {path}")
  24. image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
  25. if image is None:
  26. raise ValueError(f"Не удалось загрузить изображение: {path}")
  27. return image
  28. def rotate_image(image: np.ndarray, degrees: int) -> np.ndarray:
  29. if degrees == 90:
  30. return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
  31. if degrees == 180:
  32. return cv2.rotate(image, cv2.ROTATE_180)
  33. if degrees == 270:
  34. return cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE)
  35. raise ValueError(f"Недопустимый угол поворота: {degrees}. Допустимые: {sorted(VALID_ROTATIONS)}")
  36. def slice_grid(image: np.ndarray, rows: int, cols: int) -> list[np.ndarray]:
  37. height, width = image.shape[:2]
  38. base_h = height // rows
  39. base_w = width // cols
  40. extra_h = height % rows
  41. extra_w = width % cols
  42. pages: list[np.ndarray] = []
  43. y = 0
  44. for row in range(rows):
  45. cell_h = base_h + (1 if row >= rows - extra_h else 0)
  46. x = 0
  47. for col in range(cols):
  48. cell_w = base_w + (1 if col >= cols - extra_w else 0)
  49. page = image[y : y + cell_h, x : x + cell_w]
  50. pages.append(page)
  51. x += cell_w
  52. y += cell_h
  53. return pages
  54. def add_border(image: np.ndarray, border_px: int) -> np.ndarray:
  55. return cv2.copyMakeBorder(
  56. image,
  57. top=border_px,
  58. bottom=border_px,
  59. left=border_px,
  60. right=border_px,
  61. borderType=cv2.BORDER_CONSTANT,
  62. value=BORDER_COLOR,
  63. )
  64. def save_page(image: np.ndarray, path: Path) -> None:
  65. path.parent.mkdir(parents=True, exist_ok=True)
  66. success = cv2.imwrite(str(path), image)
  67. if not success:
  68. raise OSError(f"Не удалось сохранить изображение: {path}")
  69. def generate_output_paths(input_path: Path, page_count: int, output_dir: Path) -> list[Path]:
  70. stem = input_path.stem
  71. suffix = input_path.suffix.lower()
  72. if page_count == 1:
  73. return [output_dir / f"{stem}{suffix}"]
  74. max_digits = max(PAGE_NUMBER_WIDTH, len(str(page_count)))
  75. return [
  76. output_dir / f"{stem}_{i:0{max_digits}d}{suffix}"
  77. for i in range(1, page_count + 1)
  78. ]
  79. def parse_slice(value: str) -> tuple[int, int]:
  80. parts = value.split(":")
  81. if len(parts) != 2:
  82. raise ValueError(
  83. f"Неверный формат --slice: {value}. Ожидается <колонки>:<строки>, например 3:2"
  84. )
  85. try:
  86. cols = int(parts[0])
  87. rows = int(parts[1])
  88. except ValueError:
  89. raise ValueError(
  90. f"Неверный формат --slice: {value}. Колонки и строки должны быть целыми числами"
  91. )
  92. if rows < 1 or cols < 1:
  93. raise ValueError(
  94. f"Неверное значение --slice: {value}. Строки и столбцы должны быть >= 1"
  95. )
  96. return cols, rows
  97. def is_empty(image: np.ndarray) -> bool:
  98. gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
  99. dark_pixels = (gray < EMPTY_DARK_THRESHOLD).sum()
  100. threshold = int(image.shape[0] * image.shape[1] * EMPTY_DARK_RATIO)
  101. return dark_pixels <= threshold
  102. def _binarize(image: np.ndarray) -> np.ndarray:
  103. """Бинаризация: серый → Гаусс-блюр → Otsu-порог → морф.закрытие.
  104. При слишком низком пороге Otsu (<50) — адаптивный порог (Gaussian, окно 31).
  105. Морфологическое закрытие (3×3) склеивает фрагменты букв в непрерывные регионы."""
  106. gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
  107. blurred = cv2.GaussianBlur(gray, (3, 3), 0)
  108. otsu_th, binary = cv2.threshold(
  109. blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU,
  110. )
  111. if otsu_th < 50:
  112. binary = cv2.adaptiveThreshold(
  113. blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
  114. cv2.THRESH_BINARY_INV, 31, 10,
  115. )
  116. kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
  117. return cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
  118. def detect_grid(image: np.ndarray) -> tuple[int, int]:
  119. """Автоопределение сетки: бинаризация → проекция плотности → дилатация → подсчёт полос.
  120. Дилатация (ядро = max(5, длина/40)) сливает строки текста в непрерывные блоки,
  121. но не перекрывает межстраничные пробелы (они шире)."""
  122. binary = _binarize(image)
  123. cols = _count_bands(binary, axis=0)
  124. rows = _count_bands(binary, axis=1)
  125. return cols, rows
  126. def _count_bands(binary: np.ndarray, axis: int) -> int:
  127. other_dim = binary.shape[axis]
  128. length = binary.shape[1 - axis]
  129. projection = (binary > 0).sum(axis=axis).astype(np.float64)
  130. density = projection / other_dim
  131. dilate_width = max(5, length // 80)
  132. kernel_1d = np.ones(dilate_width, dtype=np.uint8)
  133. density_u8 = (density * 255).clip(0, 255).astype(np.uint8)
  134. density_2d = density_u8.reshape(1, -1)
  135. dilated = cv2.dilate(density_2d, kernel_1d)
  136. is_content = (dilated[0] > 1).tolist()
  137. bands = 0
  138. in_band = False
  139. for val in is_content:
  140. if val and not in_band:
  141. bands += 1
  142. in_band = True
  143. elif not val:
  144. in_band = False
  145. return max(1, bands)
  146. def find_content_bounds(image: np.ndarray) -> tuple[int, int, int, int]:
  147. binary = _binarize(image)
  148. h, w = binary.shape
  149. min_density = int(w * DENSITY_RATIO)
  150. row_density = (binary > 0).sum(axis=1)
  151. col_density = (binary > 0).sum(axis=0)
  152. y1 = 0
  153. while y1 < h and row_density[y1] < min_density:
  154. y1 += 1
  155. y2 = h - 1
  156. while y2 >= 0 and row_density[y2] < min_density:
  157. y2 -= 1
  158. x1 = 0
  159. while x1 < w and col_density[x1] < min_density:
  160. x1 += 1
  161. x2 = w - 1
  162. while x2 >= 0 and col_density[x2] < min_density:
  163. x2 -= 1
  164. if y1 > y2 or x1 > x2:
  165. return 0, 0, w, h
  166. return x1, y1, x2, y2
  167. def crop_to_content(image: np.ndarray, border_px: int = DEFAULT_BORDER_PX) -> np.ndarray:
  168. h, w = image.shape[:2]
  169. x1, y1, x2, y2 = find_content_bounds(image)
  170. left = min(border_px, x1)
  171. top = min(border_px, y1)
  172. right = min(border_px, w - 1 - x2)
  173. bottom = min(border_px, h - 1 - y2)
  174. x1_crop = x1 - left
  175. y1_crop = y1 - top
  176. x2_crop = x2 + right + 1
  177. y2_crop = y2 + bottom + 1
  178. return image[y1_crop:y2_crop, x1_crop:x2_crop]
  179. def _order_points(pts: np.ndarray) -> np.ndarray:
  180. rect = np.zeros((4, 2), dtype=np.float32)
  181. s = pts.sum(axis=1)
  182. rect[0] = pts[np.argmin(s)]
  183. rect[2] = pts[np.argmax(s)]
  184. diff = np.diff(pts, axis=1)
  185. rect[1] = pts[np.argmin(diff)]
  186. rect[3] = pts[np.argmax(diff)]
  187. return rect
  188. def _apply_perspective(image: np.ndarray, rect: np.ndarray) -> np.ndarray:
  189. (tl, tr, br, bl) = rect
  190. max_w = int(max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl)))
  191. max_h = int(max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl)))
  192. dst = np.array([[0, 0], [max_w - 1, 0], [max_w - 1, max_h - 1], [0, max_h - 1]], dtype=np.float32)
  193. mtx = cv2.getPerspectiveTransform(rect, dst)
  194. return cv2.warpPerspective(image, mtx, (max_w, max_h), flags=cv2.INTER_CUBIC)
  195. def unwarp_image(image: np.ndarray) -> np.ndarray:
  196. gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
  197. blurred = cv2.GaussianBlur(gray, (5, 5), 0)
  198. edges = cv2.Canny(blurred, 50, 150)
  199. contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
  200. if not contours:
  201. return image
  202. h, w = image.shape[:2]
  203. min_area = w * h * 0.3
  204. contours = sorted(contours, key=cv2.contourArea, reverse=True)
  205. for cnt in contours[:10]:
  206. area = cv2.contourArea(cnt)
  207. if area < min_area:
  208. continue
  209. peri = cv2.arcLength(cnt, True)
  210. approx = cv2.approxPolyDP(cnt, 0.02 * peri, True)
  211. if len(approx) == 4:
  212. rect = _order_points(approx.reshape(4, 2))
  213. return _apply_perspective(image, rect)
  214. return image
  215. def denoise_image(image: np.ndarray) -> np.ndarray:
  216. """Non-Local Means denoising. Убирает шум, сохраняя границы символов."""
  217. return cv2.fastNlMeansDenoisingColored(image, None, 10, 10, 7, 21)
  218. def process_image(
  219. input_path: Path,
  220. rows: int | None = None,
  221. cols: int | None = None,
  222. output_dir: Path = Path(),
  223. pre_rotate: int | None = None,
  224. border_px: int = DEFAULT_BORDER_PX,
  225. post_crop: bool = False,
  226. unwarp: bool = False,
  227. denoise: bool = False,
  228. ) -> list[Path]:
  229. image = load_image(input_path)
  230. if pre_rotate is not None:
  231. image = rotate_image(image, pre_rotate)
  232. if unwarp:
  233. image = unwarp_image(image)
  234. if denoise:
  235. image = denoise_image(image)
  236. if rows is None or cols is None:
  237. cols, rows = detect_grid(image)
  238. pages = slice_grid(image, rows, cols)
  239. if post_crop:
  240. pages = [crop_to_content(page, border_px) for page in pages]
  241. pages_with_border = [add_border(page, border_px) for page in pages]
  242. non_empty: list[np.ndarray] = []
  243. skipped: int = 0
  244. for i, page in enumerate(pages_with_border):
  245. if is_empty(page):
  246. logger.info("Пропущена пустая страница %d (размер %dx%d)", i + 1, *page.shape[:2])
  247. skipped += 1
  248. else:
  249. non_empty.append(page)
  250. output_paths = generate_output_paths(input_path, len(non_empty), output_dir)
  251. for page, path in zip(non_empty, output_paths):
  252. save_page(page, path)
  253. return output_paths