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.

image_prepare.py 5.8KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. from __future__ import annotations
  2. import logging
  3. import os
  4. from pathlib import Path
  5. from typing import Annotated
  6. import typer
  7. from src.cli_utils import run_main
  8. from src.split.slicer import (
  9. DEFAULT_BORDER_PX,
  10. VALID_ROTATIONS,
  11. denoise_image,
  12. detect_grid,
  13. load_image,
  14. parse_slice,
  15. process_image,
  16. rotate_image,
  17. unwarp_image,
  18. )
  19. logging.basicConfig(
  20. level=logging.INFO,
  21. format="%(levelname)-8s %(message)s",
  22. )
  23. app = typer.Typer(add_completion=False, no_args_is_help=True)
  24. def _validate_input(input_path: Path | None) -> Path:
  25. if input_path is None:
  26. raise typer.BadParameter(
  27. "Укажите путь к входному изображению", param_hint="--input"
  28. )
  29. if not input_path.exists():
  30. raise typer.BadParameter(
  31. f"Файл не найден: {input_path}", param_hint="--input"
  32. )
  33. if not input_path.is_file():
  34. raise typer.BadParameter(
  35. f"Не является файлом: {input_path}", param_hint="--input"
  36. )
  37. if not os.access(input_path, os.R_OK):
  38. raise typer.BadParameter(
  39. f"Нет доступа на чтение: {input_path}", param_hint="--input"
  40. )
  41. return input_path.resolve()
  42. def _validate_rotation(degrees: int | None) -> None:
  43. if degrees is not None and degrees not in VALID_ROTATIONS:
  44. raise typer.BadParameter(
  45. f"Недопустимый угол: {degrees}. "
  46. f"Допустимые: {sorted(VALID_ROTATIONS)}"
  47. )
  48. def _resolve_grid(
  49. slice_str: str | None,
  50. slice_auto: bool,
  51. input_path: Path,
  52. pre_rotate: int | None,
  53. unwarp: bool,
  54. denoise: bool,
  55. ) -> tuple[int, int]:
  56. if slice_str is not None and slice_auto:
  57. raise typer.BadParameter(
  58. "Нельзя указывать одновременно --slice и --slice-auto"
  59. )
  60. if slice_str is None and not slice_auto:
  61. raise typer.BadParameter("Укажите --slice или --slice-auto")
  62. if slice_auto:
  63. image = load_image(input_path)
  64. if pre_rotate is not None:
  65. image = rotate_image(image, pre_rotate)
  66. if unwarp:
  67. image = unwarp_image(image)
  68. if denoise:
  69. image = denoise_image(image)
  70. cols, rows = detect_grid(image)
  71. typer.echo(f"Определена сетка: {cols}×{rows}")
  72. return cols, rows
  73. if slice_str is None:
  74. raise typer.BadParameter("Укажите --slice или --slice-auto")
  75. return parse_slice(slice_str)
  76. @app.command()
  77. def slice_pages(
  78. input: Annotated[
  79. Path | None,
  80. typer.Option(
  81. "--input",
  82. "-i",
  83. file_okay=True,
  84. dir_okay=False,
  85. help="Путь к входному изображению (JPEG, PNG, TIFF)",
  86. ),
  87. ] = None,
  88. slice: Annotated[
  89. str | None,
  90. typer.Option(
  91. "--slice",
  92. "-s",
  93. help="Размер сетки в формате <колонки>:<строки>, например 3:2",
  94. ),
  95. ] = None,
  96. slice_auto: Annotated[
  97. bool,
  98. typer.Option(
  99. "--slice-auto",
  100. "-a",
  101. help="Автоматически определить размер сетки по содержимому изображения",
  102. ),
  103. ] = False,
  104. output_dir: Annotated[
  105. Path,
  106. typer.Option(
  107. "--output-dir",
  108. "-o",
  109. file_okay=False,
  110. dir_okay=True,
  111. writable=True,
  112. help="Каталог для сохранения результатов (по умолчанию — текущий)",
  113. ),
  114. ] = Path(),
  115. pre_rotate: Annotated[
  116. int | None,
  117. typer.Option(
  118. "--pre-rotate",
  119. "-r",
  120. help="Поворот изображения перед разрезанием: 90, 180 или 270",
  121. ),
  122. ] = None,
  123. border: Annotated[
  124. int,
  125. typer.Option(
  126. "--border",
  127. "-b",
  128. min=0,
  129. help="Отступ в пикселях (белая рамка / отступ при обрезке содержимого, по умолчанию 5)",
  130. ),
  131. ] = DEFAULT_BORDER_PX,
  132. unwarp: Annotated[
  133. bool,
  134. typer.Option(
  135. "--unwarp",
  136. "-u",
  137. help="Выпрямить перспективные искажения перед разрезанием",
  138. ),
  139. ] = False,
  140. denoise: Annotated[
  141. bool,
  142. typer.Option(
  143. "--denoise",
  144. help="Убрать шум сканера (Non-Local Means) перед разрезанием",
  145. ),
  146. ] = False,
  147. post_crop: Annotated[
  148. bool,
  149. typer.Option(
  150. "--post-crop / --no-post-crop",
  151. "-c",
  152. help="Обрезать каждую страницу по границам полезного содержимого (включено по умолчанию)",
  153. ),
  154. ] = True,
  155. ) -> None:
  156. """Подготовить отсканированное изображение: разрезать по сетке, обрезать, добавить рамку."""
  157. input_path = _validate_input(input)
  158. _validate_rotation(pre_rotate)
  159. cols, rows = _resolve_grid(slice, slice_auto, input_path, pre_rotate, unwarp, denoise)
  160. output_paths = process_image(
  161. input_path=input_path,
  162. rows=rows,
  163. cols=cols,
  164. output_dir=output_dir.resolve(),
  165. pre_rotate=pre_rotate,
  166. border_px=border,
  167. post_crop=post_crop,
  168. unwarp=unwarp,
  169. denoise=denoise,
  170. )
  171. typer.echo(f"Сохранено {len(output_paths)} страниц в {output_dir.resolve()}")
  172. def main() -> None:
  173. run_main(app)
  174. if __name__ == "__main__":
  175. main()