| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317 |
- from __future__ import annotations
-
- import logging
- from pathlib import Path
-
- import cv2
- import numpy as np
-
- logger = logging.getLogger(__name__)
-
- SUPPORTED_EXTENSIONS: frozenset[str] = frozenset({".jpg", ".jpeg", ".png", ".tiff", ".tif"})
- PAGE_NUMBER_WIDTH: int = 2
- DEFAULT_BORDER_PX: int = 5
- BORDER_COLOR: tuple[int, int, int] = (255, 255, 255)
- VALID_ROTATIONS: frozenset[int] = frozenset({90, 180, 270})
- DENSITY_RATIO: float = 0.005
- AUTO_GRID_MAX: int = 4
- EMPTY_DARK_THRESHOLD: int = 100
- EMPTY_DARK_RATIO: float = 0.001
-
-
- def load_image(path: Path) -> np.ndarray:
- if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
- raise ValueError(
- f"Неподдерживаемый формат: {path.suffix}. "
- f"Поддерживаются: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
- )
- if not path.exists():
- raise FileNotFoundError(f"Файл не найден: {path}")
- image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
- if image is None:
- raise ValueError(f"Не удалось загрузить изображение: {path}")
- return image
-
-
- def rotate_image(image: np.ndarray, degrees: int) -> np.ndarray:
- if degrees == 90:
- return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
- if degrees == 180:
- return cv2.rotate(image, cv2.ROTATE_180)
- if degrees == 270:
- return cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE)
- raise ValueError(f"Недопустимый угол поворота: {degrees}. Допустимые: {sorted(VALID_ROTATIONS)}")
-
-
- def slice_grid(image: np.ndarray, rows: int, cols: int) -> list[np.ndarray]:
- height, width = image.shape[:2]
- base_h = height // rows
- base_w = width // cols
- extra_h = height % rows
- extra_w = width % cols
-
- pages: list[np.ndarray] = []
- y = 0
- for row in range(rows):
- cell_h = base_h + (1 if row >= rows - extra_h else 0)
- x = 0
- for col in range(cols):
- cell_w = base_w + (1 if col >= cols - extra_w else 0)
- page = image[y : y + cell_h, x : x + cell_w]
- pages.append(page)
- x += cell_w
- y += cell_h
- return pages
-
-
- def add_border(image: np.ndarray, border_px: int) -> np.ndarray:
- return cv2.copyMakeBorder(
- image,
- top=border_px,
- bottom=border_px,
- left=border_px,
- right=border_px,
- borderType=cv2.BORDER_CONSTANT,
- value=BORDER_COLOR,
- )
-
-
- def save_page(image: np.ndarray, path: Path) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- success = cv2.imwrite(str(path), image)
- if not success:
- raise OSError(f"Не удалось сохранить изображение: {path}")
-
-
- def generate_output_paths(input_path: Path, page_count: int, output_dir: Path) -> list[Path]:
- stem = input_path.stem
- suffix = input_path.suffix.lower()
- if page_count == 1:
- return [output_dir / f"{stem}{suffix}"]
- max_digits = max(PAGE_NUMBER_WIDTH, len(str(page_count)))
- return [
- output_dir / f"{stem}_{i:0{max_digits}d}{suffix}"
- for i in range(1, page_count + 1)
- ]
-
-
- def parse_slice(value: str) -> tuple[int, int]:
- parts = value.split(":")
- if len(parts) != 2:
- raise ValueError(
- f"Неверный формат --slice: {value}. Ожидается <колонки>:<строки>, например 3:2"
- )
- try:
- cols = int(parts[0])
- rows = int(parts[1])
- except ValueError:
- raise ValueError(
- f"Неверный формат --slice: {value}. Колонки и строки должны быть целыми числами"
- )
- if rows < 1 or cols < 1:
- raise ValueError(
- f"Неверное значение --slice: {value}. Строки и столбцы должны быть >= 1"
- )
- return cols, rows
-
-
- def is_empty(image: np.ndarray) -> bool:
- gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
- dark_pixels = (gray < EMPTY_DARK_THRESHOLD).sum()
- threshold = int(image.shape[0] * image.shape[1] * EMPTY_DARK_RATIO)
- return dark_pixels <= threshold
-
-
- def _binarize(image: np.ndarray) -> np.ndarray:
- """Бинаризация: серый → Гаусс-блюр → Otsu-порог → морф.закрытие.
- При слишком низком пороге Otsu (<50) — адаптивный порог (Gaussian, окно 31).
- Морфологическое закрытие (3×3) склеивает фрагменты букв в непрерывные регионы."""
- gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
- blurred = cv2.GaussianBlur(gray, (3, 3), 0)
- otsu_th, binary = cv2.threshold(
- blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU,
- )
- if otsu_th < 50:
- binary = cv2.adaptiveThreshold(
- blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
- cv2.THRESH_BINARY_INV, 31, 10,
- )
- kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
- return cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
-
-
- def detect_grid(image: np.ndarray) -> tuple[int, int]:
- """Автоопределение сетки: бинаризация → проекция плотности → дилатация → подсчёт полос.
- Дилатация (ядро = max(5, длина/40)) сливает строки текста в непрерывные блоки,
- но не перекрывает межстраничные пробелы (они шире)."""
- binary = _binarize(image)
-
- cols = _count_bands(binary, axis=0)
- rows = _count_bands(binary, axis=1)
-
- return cols, rows
-
-
- def _count_bands(binary: np.ndarray, axis: int) -> int:
- other_dim = binary.shape[axis]
- length = binary.shape[1 - axis]
- projection = (binary > 0).sum(axis=axis).astype(np.float64)
- density = projection / other_dim
-
- dilate_width = max(5, length // 80)
- kernel_1d = np.ones(dilate_width, dtype=np.uint8)
- density_u8 = (density * 255).clip(0, 255).astype(np.uint8)
- density_2d = density_u8.reshape(1, -1)
- dilated = cv2.dilate(density_2d, kernel_1d)
- is_content = (dilated[0] > 1).tolist()
-
- bands = 0
- in_band = False
- for val in is_content:
- if val and not in_band:
- bands += 1
- in_band = True
- elif not val:
- in_band = False
- return max(1, bands)
-
-
- def find_content_bounds(image: np.ndarray) -> tuple[int, int, int, int]:
- binary = _binarize(image)
-
- h, w = binary.shape
- min_density = int(w * DENSITY_RATIO)
-
- row_density = (binary > 0).sum(axis=1)
- col_density = (binary > 0).sum(axis=0)
-
- y1 = 0
- while y1 < h and row_density[y1] < min_density:
- y1 += 1
- y2 = h - 1
- while y2 >= 0 and row_density[y2] < min_density:
- y2 -= 1
- x1 = 0
- while x1 < w and col_density[x1] < min_density:
- x1 += 1
- x2 = w - 1
- while x2 >= 0 and col_density[x2] < min_density:
- x2 -= 1
-
- if y1 > y2 or x1 > x2:
- return 0, 0, w, h
- return x1, y1, x2, y2
-
-
- def crop_to_content(image: np.ndarray, border_px: int = DEFAULT_BORDER_PX) -> np.ndarray:
- h, w = image.shape[:2]
- x1, y1, x2, y2 = find_content_bounds(image)
-
- left = min(border_px, x1)
- top = min(border_px, y1)
- right = min(border_px, w - 1 - x2)
- bottom = min(border_px, h - 1 - y2)
-
- x1_crop = x1 - left
- y1_crop = y1 - top
- x2_crop = x2 + right + 1
- y2_crop = y2 + bottom + 1
-
- return image[y1_crop:y2_crop, x1_crop:x2_crop]
-
-
- def _order_points(pts: np.ndarray) -> np.ndarray:
- rect = np.zeros((4, 2), dtype=np.float32)
- s = pts.sum(axis=1)
- rect[0] = pts[np.argmin(s)]
- rect[2] = pts[np.argmax(s)]
- diff = np.diff(pts, axis=1)
- rect[1] = pts[np.argmin(diff)]
- rect[3] = pts[np.argmax(diff)]
- return rect
-
-
- def _apply_perspective(image: np.ndarray, rect: np.ndarray) -> np.ndarray:
- (tl, tr, br, bl) = rect
- max_w = int(max(np.linalg.norm(br - bl), np.linalg.norm(tr - tl)))
- max_h = int(max(np.linalg.norm(tr - br), np.linalg.norm(tl - bl)))
- dst = np.array([[0, 0], [max_w - 1, 0], [max_w - 1, max_h - 1], [0, max_h - 1]], dtype=np.float32)
- mtx = cv2.getPerspectiveTransform(rect, dst)
- return cv2.warpPerspective(image, mtx, (max_w, max_h), flags=cv2.INTER_CUBIC)
-
-
- def unwarp_image(image: np.ndarray) -> np.ndarray:
- gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
- blurred = cv2.GaussianBlur(gray, (5, 5), 0)
- edges = cv2.Canny(blurred, 50, 150)
- contours, _ = cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
- if not contours:
- return image
-
- h, w = image.shape[:2]
- min_area = w * h * 0.3
-
- contours = sorted(contours, key=cv2.contourArea, reverse=True)
- for cnt in contours[:10]:
- area = cv2.contourArea(cnt)
- if area < min_area:
- continue
- peri = cv2.arcLength(cnt, True)
- approx = cv2.approxPolyDP(cnt, 0.02 * peri, True)
- if len(approx) == 4:
- rect = _order_points(approx.reshape(4, 2))
- return _apply_perspective(image, rect)
- return image
-
-
- def denoise_image(image: np.ndarray) -> np.ndarray:
- """Non-Local Means denoising. Убирает шум, сохраняя границы символов."""
- return cv2.fastNlMeansDenoisingColored(image, None, 10, 10, 7, 21)
-
-
- def process_image(
- input_path: Path,
- rows: int | None = None,
- cols: int | None = None,
- output_dir: Path = Path(),
- pre_rotate: int | None = None,
- border_px: int = DEFAULT_BORDER_PX,
- post_crop: bool = False,
- unwarp: bool = False,
- denoise: bool = False,
- ) -> list[Path]:
- image = load_image(input_path)
-
- if pre_rotate is not None:
- image = rotate_image(image, pre_rotate)
-
- if unwarp:
- image = unwarp_image(image)
-
- if denoise:
- image = denoise_image(image)
-
- if rows is None or cols is None:
- cols, rows = detect_grid(image)
-
- pages = slice_grid(image, rows, cols)
-
- if post_crop:
- pages = [crop_to_content(page, border_px) for page in pages]
-
- pages_with_border = [add_border(page, border_px) for page in pages]
-
- non_empty: list[np.ndarray] = []
- skipped: int = 0
- for i, page in enumerate(pages_with_border):
- if is_empty(page):
- logger.info("Пропущена пустая страница %d (размер %dx%d)", i + 1, *page.shape[:2])
- skipped += 1
- else:
- non_empty.append(page)
-
- output_paths = generate_output_paths(input_path, len(non_empty), output_dir)
- for page, path in zip(non_empty, output_paths):
- save_page(page, path)
-
- return output_paths
|