소스 검색

initial release with base split functionality

master
Evgeniy Ierusalimov 1 주 전
커밋
4445acc18f
12개의 변경된 파일1292개의 추가작업 그리고 0개의 파일을 삭제
  1. 23
    0
      .ai/AGENTS.md
  2. 24
    0
      .ai/ARCHITECTURE.md
  3. 40
    0
      .ai/README.md
  4. 55
    0
      .ai/REQUIREMENTS.md
  5. 21
    0
      .ai/TASKS.md
  6. 18
    0
      .gitignore
  7. 0
    0
      src/__init__.py
  8. 103
    0
      src/cli.py
  9. 37
    0
      src/split/__init__.py
  10. 201
    0
      src/split/slicer.py
  11. 285
    0
      stage1.md
  12. 485
    0
      tests/test_slicer.py

+ 23
- 0
.ai/AGENTS.md 파일 보기

@@ -0,0 +1,23 @@
1
+# Coding rules
2
+
3
+Python 3.12
4
+
5
+PEP8
6
+
7
+Type hints required
8
+
9
+ruff
10
+
11
+pytest
12
+
13
+Никаких глобальных переменных.
14
+
15
+CLI через Typer.
16
+
17
+OpenCV.
18
+
19
+Не использовать магические числа.
20
+
21
+Каждая функция менее 40 строк.
22
+
23
+Комментарии только если они действительно объясняют алгоритм.

+ 24
- 0
.ai/ARCHITECTURE.md 파일 보기

@@ -0,0 +1,24 @@
1
+src/
2
+
3
+split/
4
+    разделение изображения
5
+
6
+preprocess/
7
+    deskew
8
+    crop
9
+    denoise
10
+
11
+ocr/
12
+    PaddleOCR
13
+
14
+html/
15
+    генерация HTML
16
+
17
+cli.py
18
+
19
+Этапы:
20
+1. Input
21
+2. Split
22
+3. Preprocess
23
+4. OCR
24
+5. HTML

+ 40
- 0
.ai/README.md 파일 보기

@@ -0,0 +1,40 @@
1
+# Offline Document Pipeline
2
+
3
+Назначение проекта
4
+
5
+Преобразование больших технических документов,
6
+полученных после печати и сканирования,
7
+в структурированный HTML без использования облачных сервисов.
8
+
9
+Поддерживается полностью offline работа.
10
+
11
+Pipeline
12
+
13
+JPEG/TIFF/PDF
14
+
15
+↓
16
+
17
+Split pages
18
+
19
+↓
20
+
21
+Deskew
22
+
23
+↓
24
+
25
+Crop
26
+
27
+↓
28
+
29
+PaddleOCR
30
+
31
+↓
32
+
33
+HTML
34
+
35
+Вне scope проекта
36
+
37
+- LLM
38
+- RAG
39
+- поиск
40
+- анализ документов

+ 55
- 0
.ai/REQUIREMENTS.md 파일 보기

@@ -0,0 +1,55 @@
1
+# Functional requirements
2
+
3
+Проект работает полностью offline.
4
+
5
+Linux only.
6
+
7
+Python 3.12+
8
+
9
+Первый этап проекта
10
+
11
+Разделение большого JPEG/TIFF
12
+на несколько страниц.
13
+
14
+Параметры
15
+
16
+--rows
17
+
18
+--cols
19
+
20
+Например
21
+
22
+2x2
23
+
24
+2x4
25
+
26
+3x3
27
+
28
+4x4
29
+
30
+Страницы сохраняются
31
+в порядке чтения.
32
+
33
+Поддерживаемые входные форматы
34
+
35
+JPEG
36
+
37
+PNG
38
+
39
+TIFF
40
+
41
+PDF (позже)
42
+
43
+Следующие этапы
44
+
45
+Deskew
46
+
47
+Crop
48
+
49
+PaddleOCR
50
+
51
+HTML generation
52
+
53
+Никаких GUI.
54
+
55
+Только CLI.

+ 21
- 0
.ai/TASKS.md 파일 보기

@@ -0,0 +1,21 @@
1
+# MVP
2
+
3
+[x] Создать структуру проекта
4
+
5
+[ ] CLI
6
+
7
+[ ] Разрезание изображения
8
+
9
+[ ] Автоматические тесты
10
+
11
+[ ] Deskew
12
+
13
+[ ] Crop
14
+
15
+[ ] PaddleOCR
16
+
17
+[ ] HTML
18
+
19
+[ ] Docker
20
+
21
+[ ] Release 0.1

+ 18
- 0
.gitignore 파일 보기

@@ -0,0 +1,18 @@
1
+# Виртуальное окружение
2
+.venv/
3
+venv/
4
+env/
5
+
6
+# Кэш и байт-код Python
7
+__pycache__/
8
+*.py[cod]
9
+*$py.class
10
+
11
+# Настройки среды и секреты
12
+.env
13
+
14
+# Системные файлы и редакторы
15
+.idea/
16
+.vscode/
17
+.DS_Store
18
+

+ 0
- 0
src/__init__.py 파일 보기


+ 103
- 0
src/cli.py 파일 보기

@@ -0,0 +1,103 @@
1
+from __future__ import annotations
2
+
3
+from pathlib import Path
4
+from typing import Annotated
5
+
6
+import typer
7
+
8
+from src.split.slicer import (
9
+    DEFAULT_BORDER_PX,
10
+    VALID_ROTATIONS,
11
+    parse_slice,
12
+    process_image,
13
+)
14
+
15
+app = typer.Typer(add_completion=False)
16
+
17
+
18
+@app.command()
19
+def slice_pages(
20
+    input: Annotated[
21
+        Path,
22
+        typer.Option(
23
+            "--input",
24
+            "-i",
25
+            exists=True,
26
+            file_okay=True,
27
+            dir_okay=False,
28
+            readable=True,
29
+            help="Путь к входному изображению (JPEG, PNG, TIFF)",
30
+        ),
31
+    ],
32
+    slice: Annotated[
33
+        str,
34
+        typer.Option(
35
+            "--slice",
36
+            "-s",
37
+            help="Размер сетки в формате <колонки>:<строки>, например 3:2",
38
+        ),
39
+    ],
40
+    output_dir: Annotated[
41
+        Path,
42
+        typer.Option(
43
+            "--output-dir",
44
+            "-o",
45
+            file_okay=False,
46
+            dir_okay=True,
47
+            writable=True,
48
+            help="Каталог для сохранения результатов (по умолчанию — текущий)",
49
+        ),
50
+    ] = Path(),
51
+    pre_rotate: Annotated[
52
+        int | None,
53
+        typer.Option(
54
+            "--pre-rotate",
55
+            "-r",
56
+            help="Поворот изображения перед разрезанием: 90, 180 или 270",
57
+        ),
58
+    ] = None,
59
+    border: Annotated[
60
+        int,
61
+        typer.Option(
62
+            "--border",
63
+            "-b",
64
+            min=0,
65
+            help="Отступ в пикселях (белая рамка / отступ при обрезке содержимого, по умолчанию 50)",
66
+        ),
67
+    ] = DEFAULT_BORDER_PX,
68
+    post_crop: Annotated[
69
+        bool,
70
+        typer.Option(
71
+            "--post-crop",
72
+            "-c",
73
+            help="Обрезать каждую страницу по границам полезного содержимого",
74
+        ),
75
+    ] = False,
76
+) -> None:
77
+    """Разрезать отсканированное изображение на отдельные страницы по геометрической сетке."""
78
+    if pre_rotate is not None and pre_rotate not in VALID_ROTATIONS:
79
+        raise typer.BadParameter(
80
+            f"Недопустимый угол: {pre_rotate}. Допустимые: {sorted(VALID_ROTATIONS)}"
81
+        )
82
+
83
+    cols, rows = parse_slice(slice)
84
+
85
+    output_paths = process_image(
86
+        input_path=input.resolve(),
87
+        rows=rows,
88
+        cols=cols,
89
+        output_dir=output_dir.resolve(),
90
+        pre_rotate=pre_rotate,
91
+        border_px=border,
92
+        post_crop=post_crop,
93
+    )
94
+
95
+    typer.echo(f"Сохранено {len(output_paths)} страниц в {output_dir.resolve()}")
96
+
97
+
98
+def main() -> None:
99
+    app()
100
+
101
+
102
+if __name__ == "__main__":
103
+    main()

+ 37
- 0
src/split/__init__.py 파일 보기

@@ -0,0 +1,37 @@
1
+from src.split.slicer import (
2
+    CONTENT_THRESHOLD,
3
+    DEFAULT_BORDER_PX,
4
+    PAGE_NUMBER_WIDTH,
5
+    SUPPORTED_EXTENSIONS,
6
+    VALID_ROTATIONS,
7
+    add_border,
8
+    crop_to_content,
9
+    find_content_bounds,
10
+    generate_output_paths,
11
+    load_image,
12
+    parse_slice,
13
+    process_image,
14
+    rotate_image,
15
+    save_page,
16
+    slice_grid,
17
+    validate_output_dir,
18
+)
19
+
20
+__all__ = [
21
+    "CONTENT_THRESHOLD",
22
+    "DEFAULT_BORDER_PX",
23
+    "PAGE_NUMBER_WIDTH",
24
+    "SUPPORTED_EXTENSIONS",
25
+    "VALID_ROTATIONS",
26
+    "add_border",
27
+    "crop_to_content",
28
+    "find_content_bounds",
29
+    "generate_output_paths",
30
+    "load_image",
31
+    "parse_slice",
32
+    "process_image",
33
+    "rotate_image",
34
+    "save_page",
35
+    "slice_grid",
36
+    "validate_output_dir",
37
+]

+ 201
- 0
src/split/slicer.py 파일 보기

@@ -0,0 +1,201 @@
1
+from __future__ import annotations
2
+
3
+from pathlib import Path
4
+
5
+import cv2
6
+import numpy as np
7
+
8
+SUPPORTED_EXTENSIONS: frozenset[str] = frozenset({".jpg", ".jpeg", ".png", ".tiff", ".tif"})
9
+PAGE_NUMBER_WIDTH: int = 2
10
+DEFAULT_BORDER_PX: int = 50
11
+BORDER_COLOR: tuple[int, int, int] = (255, 255, 255)
12
+VALID_ROTATIONS: frozenset[int] = frozenset({90, 180, 270})
13
+CONTENT_THRESHOLD: int = 200
14
+DENSITY_RATIO: float = 0.005
15
+
16
+
17
+def load_image(path: Path) -> np.ndarray:
18
+    if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
19
+        raise ValueError(
20
+            f"Неподдерживаемый формат: {path.suffix}. "
21
+            f"Поддерживаются: {', '.join(sorted(SUPPORTED_EXTENSIONS))}"
22
+        )
23
+    if not path.exists():
24
+        raise FileNotFoundError(f"Файл не найден: {path}")
25
+    image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
26
+    if image is None:
27
+        raise ValueError(f"Не удалось загрузить изображение: {path}")
28
+    return image
29
+
30
+
31
+def rotate_image(image: np.ndarray, degrees: int) -> np.ndarray:
32
+    if degrees == 90:
33
+        return cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
34
+    if degrees == 180:
35
+        return cv2.rotate(image, cv2.ROTATE_180)
36
+    if degrees == 270:
37
+        return cv2.rotate(image, cv2.ROTATE_90_COUNTERCLOCKWISE)
38
+    raise ValueError(f"Недопустимый угол поворота: {degrees}. Допустимые: {sorted(VALID_ROTATIONS)}")
39
+
40
+
41
+def slice_grid(image: np.ndarray, rows: int, cols: int) -> list[np.ndarray]:
42
+    height, width = image.shape[:2]
43
+    base_h = height // rows
44
+    base_w = width // cols
45
+    extra_h = height % rows
46
+    extra_w = width % cols
47
+
48
+    pages: list[np.ndarray] = []
49
+    y = 0
50
+    for row in range(rows):
51
+        cell_h = base_h + (1 if row >= rows - extra_h else 0)
52
+        x = 0
53
+        for col in range(cols):
54
+            cell_w = base_w + (1 if col >= cols - extra_w else 0)
55
+            page = image[y : y + cell_h, x : x + cell_w]
56
+            pages.append(page)
57
+            x += cell_w
58
+        y += cell_h
59
+    return pages
60
+
61
+
62
+def add_border(image: np.ndarray, border_px: int) -> np.ndarray:
63
+    return cv2.copyMakeBorder(
64
+        image,
65
+        top=border_px,
66
+        bottom=border_px,
67
+        left=border_px,
68
+        right=border_px,
69
+        borderType=cv2.BORDER_CONSTANT,
70
+        value=BORDER_COLOR,
71
+    )
72
+
73
+
74
+def save_page(image: np.ndarray, path: Path) -> None:
75
+    path.parent.mkdir(parents=True, exist_ok=True)
76
+    success = cv2.imwrite(str(path), image)
77
+    if not success:
78
+        raise OSError(f"Не удалось сохранить изображение: {path}")
79
+
80
+
81
+def generate_output_paths(input_path: Path, page_count: int, output_dir: Path) -> list[Path]:
82
+    stem = input_path.stem
83
+    suffix = input_path.suffix.lower()
84
+    max_digits = max(PAGE_NUMBER_WIDTH, len(str(page_count)))
85
+    return [
86
+        output_dir / f"{stem}_{i:0{max_digits}d}{suffix}"
87
+        for i in range(1, page_count + 1)
88
+    ]
89
+
90
+
91
+def parse_slice(value: str) -> tuple[int, int]:
92
+    parts = value.split(":")
93
+    if len(parts) != 2:
94
+        raise ValueError(
95
+            f"Неверный формат --slice: {value}. Ожидается <колонки>:<строки>, например 3:2"
96
+        )
97
+    try:
98
+        cols = int(parts[0])
99
+        rows = int(parts[1])
100
+    except ValueError:
101
+        raise ValueError(
102
+            f"Неверный формат --slice: {value}. Колонки и строки должны быть целыми числами"
103
+        )
104
+    if rows < 1 or cols < 1:
105
+        raise ValueError(
106
+            f"Неверное значение --slice: {value}. Строки и столбцы должны быть >= 1"
107
+        )
108
+    return cols, rows
109
+
110
+
111
+def validate_output_dir(path: Path) -> Path:
112
+    path.mkdir(parents=True, exist_ok=True)
113
+    if not path.is_dir():
114
+        raise NotADirectoryError(f"Не удалось создать каталог: {path}")
115
+    return path
116
+
117
+
118
+def find_content_bounds(
119
+    image: np.ndarray,
120
+    threshold: int = CONTENT_THRESHOLD,
121
+) -> tuple[int, int, int, int]:
122
+    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
123
+    blurred = cv2.GaussianBlur(gray, (3, 3), 0)
124
+    otsu_th, binary = cv2.threshold(
125
+        blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU,
126
+    )
127
+    if otsu_th < 50:
128
+        binary = cv2.adaptiveThreshold(
129
+            blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
130
+            cv2.THRESH_BINARY_INV, 31, 10,
131
+        )
132
+    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
133
+    binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)
134
+
135
+    h, w = binary.shape
136
+    min_density = int(w * DENSITY_RATIO)
137
+
138
+    row_density = (binary > 0).sum(axis=1)
139
+    col_density = (binary > 0).sum(axis=0)
140
+
141
+    y1 = 0
142
+    while y1 < h and row_density[y1] < min_density:
143
+        y1 += 1
144
+    y2 = h - 1
145
+    while y2 >= 0 and row_density[y2] < min_density:
146
+        y2 -= 1
147
+    x1 = 0
148
+    while x1 < w and col_density[x1] < min_density:
149
+        x1 += 1
150
+    x2 = w - 1
151
+    while x2 >= 0 and col_density[x2] < min_density:
152
+        x2 -= 1
153
+
154
+    if y1 > y2 or x1 > x2:
155
+        return 0, 0, w, h
156
+    return x1, y1, x2, y2
157
+
158
+
159
+def crop_to_content(image: np.ndarray, border_px: int = DEFAULT_BORDER_PX) -> np.ndarray:
160
+    h, w = image.shape[:2]
161
+    x1, y1, x2, y2 = find_content_bounds(image)
162
+
163
+    left = min(border_px, x1)
164
+    top = min(border_px, y1)
165
+    right = min(border_px, w - 1 - x2)
166
+    bottom = min(border_px, h - 1 - y2)
167
+
168
+    x1_crop = x1 - left
169
+    y1_crop = y1 - top
170
+    x2_crop = x2 + right + 1
171
+    y2_crop = y2 + bottom + 1
172
+
173
+    return image[y1_crop:y2_crop, x1_crop:x2_crop]
174
+
175
+
176
+def process_image(
177
+    input_path: Path,
178
+    rows: int,
179
+    cols: int,
180
+    output_dir: Path,
181
+    pre_rotate: int | None = None,
182
+    border_px: int = DEFAULT_BORDER_PX,
183
+    post_crop: bool = False,
184
+) -> list[Path]:
185
+    image = load_image(input_path)
186
+
187
+    if pre_rotate is not None:
188
+        image = rotate_image(image, pre_rotate)
189
+
190
+    pages = slice_grid(image, rows, cols)
191
+
192
+    if post_crop:
193
+        pages = [crop_to_content(page, border_px) for page in pages]
194
+
195
+    pages_with_border = [add_border(page, border_px) for page in pages]
196
+
197
+    output_paths = generate_output_paths(input_path, len(pages_with_border), output_dir)
198
+    for page, path in zip(pages_with_border, output_paths):
199
+        save_page(page, path)
200
+
201
+    return output_paths

+ 285
- 0
stage1.md 파일 보기

@@ -0,0 +1,285 @@
1
+# Техническое задание
2
+
3
+## Проект
4
+
5
+Утилита является первым этапом конвейера офлайн-обработки документов.
6
+
7
+Назначение утилиты — разрезание отсканированного изображения, содержащего несколько страниц документа, напечатанных на одном листе A4 с использованием функции Microsoft Word **«Несколько страниц на листе»**.
8
+
9
+На данном этапе OCR не выполняется.
10
+
11
+---
12
+
13
+# Предпосылки
14
+
15
+Предполагается, что:
16
+
17
+- документ распечатан из Microsoft Word;
18
+- использована функция «Несколько страниц на листе»;
19
+- все страницы имеют одинаковый размер;
20
+- страницы расположены в правильной прямоугольной сетке;
21
+- изображение получено качественным сканером;
22
+- поиск границ страниц не требуется.
23
+
24
+Разрезание выполняется исключительно по геометрической сетке.
25
+
26
+Использование OpenCV для поиска страниц, контуров или линий не требуется.
27
+
28
+---
29
+
30
+# Важно!
31
+
32
+Главный приоритет проекта — максимально сохранить качество исходного изображения для последующего OCR. Любые преобразования, способные ухудшить распознавание, должны выполняться только при наличии явного параметра командной строки или быть реализованы на последующих этапах конвейера.
33
+
34
+---
35
+
36
+# Поддерживаемые форматы
37
+
38
+Входные изображения:
39
+
40
+- JPEG
41
+- PNG
42
+- TIFF
43
+
44
+Формат выходных файлов должен совпадать с форматом входного файла.
45
+
46
+Цветовое пространство сохраняется без изменений.
47
+
48
+Не выполнять:
49
+
50
+- перевод в оттенки серого;
51
+- бинаризацию;
52
+- изменение цветовой модели;
53
+- сжатие с потерями сверх уже имеющегося.
54
+
55
+---
56
+
57
+# Интерфейс командной строки
58
+
59
+## Обязательные параметры
60
+
61
+```text
62
+--input <путь_к_файлу>
63
+```
64
+
65
+Путь к входному изображению.
66
+
67
+```text
68
+--slice <строки:столбцы>
69
+```
70
+
71
+Размер сетки.
72
+
73
+Поддерживаются любые положительные значения.
74
+
75
+Примеры:
76
+
77
+```text
78
+1:2
79
+2:2
80
+2:4
81
+3:3
82
+4:4
83
+```
84
+
85
+Практический максимум — 4×4.
86
+
87
+---
88
+
89
+## Необязательные параметры
90
+
91
+```text
92
+--output-dir <каталог>
93
+```
94
+
95
+Каталог сохранения результатов.
96
+
97
+По умолчанию — текущий каталог.
98
+
99
+---
100
+
101
+```text
102
+--pre-rotate 90|180|270
103
+```
104
+
105
+Повернуть изображение перед разрезанием.
106
+
107
+---
108
+
109
+```text
110
+--border <пикселей>
111
+```
112
+
113
+Добавить белую рамку вокруг каждой страницы.
114
+
115
+По умолчанию:
116
+
117
+```text
118
+20
119
+```
120
+
121
+---
122
+
123
+# Разрезание
124
+
125
+Изображение делится на равномерную прямоугольную сетку.
126
+
127
+Не использовать:
128
+
129
+- поиск страниц;
130
+- поиск контуров;
131
+- поиск линий;
132
+- Hough Transform;
133
+- распознавание структуры изображения;
134
+- любые алгоритмы компьютерного зрения для определения координат страниц.
135
+
136
+Координаты вычисляются исключительно на основании размеров изображения и параметра:
137
+
138
+```text
139
+--slice
140
+```
141
+
142
+---
143
+
144
+# Порядок страниц
145
+
146
+Порядок должен полностью соответствовать порядку печати Microsoft Word.
147
+
148
+Пример для 2×2:
149
+
150
+```text
151
+01 02
152
+03 04
153
+```
154
+
155
+Пример для 2×4:
156
+
157
+```text
158
+01 02
159
+03 04
160
+05 06
161
+07 08
162
+```
163
+
164
+Используется порядок **row-major** (слева направо, сверху вниз).
165
+
166
+---
167
+
168
+# Размеры частей
169
+
170
+Если размеры изображения не делятся без остатка на количество строк или столбцов, остаток пикселей должен быть добавлен к последней строке и/или последнему столбцу.
171
+
172
+Потеря пикселей недопустима.
173
+
174
+---
175
+
176
+# Имена выходных файлов
177
+
178
+Входной файл:
179
+
180
+```text
181
+scan001.jpg
182
+```
183
+
184
+Результат:
185
+
186
+```text
187
+scan001_01.jpg
188
+scan001_02.jpg
189
+scan001_03.jpg
190
+...
191
+```
192
+
193
+Использовать двузначную нумерацию.
194
+
195
+---
196
+
197
+# Постобработка
198
+
199
+После разрезания для каждой страницы выполнить:
200
+
201
+1. Добавление белой рамки (`--border`).
202
+2. Сохранение результата.
203
+
204
+Белая рамка необходима для повышения качества последующего OCR.
205
+
206
+---
207
+
208
+# Архитектура
209
+
210
+Обработка должна состоять из независимых этапов:
211
+
212
+```text
213
+Загрузка изображения
214
+        ↓
215
+Опциональный поворот
216
+        ↓
217
+Разрезание на страницы
218
+        ↓
219
+Добавление белой рамки
220
+        ↓
221
+Сохранение результата
222
+```
223
+
224
+Каждый этап должен быть реализован отдельной функцией.
225
+
226
+Это необходимо для последующего расширения конвейера.
227
+
228
+---
229
+
230
+# Не входит в MVP
231
+
232
+На данном этапе НЕ реализовывать:
233
+
234
+- OCR;
235
+- PaddleOCR;
236
+- генерацию HTML;
237
+- поддержку PDF;
238
+- GUI;
239
+- Docker;
240
+- многопоточность;
241
+- автоматическое определение сетки;
242
+- поиск страниц;
243
+- deskew;
244
+- автоматическую обрезку полей;
245
+- удаление шума;
246
+- повышение контрастности;
247
+- бинаризацию.
248
+
249
+Все перечисленные возможности будут реализованы на следующих этапах проекта.
250
+
251
+---
252
+
253
+# Требования к реализации
254
+
255
+Использовать:
256
+
257
+- Python 3.12+
258
+- OpenCV
259
+- NumPy
260
+
261
+Для разрезания изображения использовать срезы массива NumPy.
262
+
263
+OpenCV использовать только для:
264
+
265
+- загрузки изображения;
266
+- поворота;
267
+- добавления белой рамки;
268
+- сохранения результата.
269
+
270
+Не использовать OpenCV для вычисления координат разрезания.
271
+
272
+---
273
+
274
+# Требования к качеству кода
275
+
276
+- Полная типизация (type hints).
277
+- Отсутствие глобальных переменных.
278
+- Отсутствие дублирования кода.
279
+- Каждая функция должна выполнять одну логическую задачу.
280
+- Проверка всех параметров командной строки.
281
+- Понятные сообщения об ошибках.
282
+- Ненулевой код завершения при ошибках.
283
+- Соответствие PEP 8.
284
+
285
+Код должен стать основой для последующих этапов конвейера подготовки документов к OCR.

+ 485
- 0
tests/test_slicer.py 파일 보기

@@ -0,0 +1,485 @@
1
+from __future__ import annotations
2
+
3
+import tempfile
4
+from pathlib import Path
5
+
6
+import cv2
7
+import numpy as np
8
+import pytest
9
+
10
+from src.split.slicer import (
11
+    SUPPORTED_EXTENSIONS,
12
+    VALID_ROTATIONS,
13
+    add_border,
14
+    crop_to_content,
15
+    find_content_bounds,
16
+    generate_output_paths,
17
+    load_image,
18
+    parse_slice,
19
+    process_image,
20
+    rotate_image,
21
+    save_page,
22
+    slice_grid,
23
+)
24
+
25
+
26
+def _create_test_image(width: int = 400, height: int = 300) -> np.ndarray:
27
+    image = np.zeros((height, width, 3), dtype=np.uint8)
28
+    image[:, :] = (200, 200, 200)
29
+    return image
30
+
31
+
32
+def _image_to_path(image: np.ndarray, suffix: str = ".png") -> Path:
33
+    with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
34
+        cv2.imwrite(tmp.name, image)
35
+        return Path(tmp.name)
36
+
37
+
38
+class TestParseSlice:
39
+    def test_simple_2x2(self) -> None:
40
+        assert parse_slice("2:2") == (2, 2)
41
+
42
+    def test_2x4(self) -> None:
43
+        assert parse_slice("2:4") == (2, 4)
44
+
45
+    def test_1x1(self) -> None:
46
+        assert parse_slice("1:1") == (1, 1)
47
+
48
+    def test_large(self) -> None:
49
+        assert parse_slice("10:10") == (10, 10)
50
+
51
+    def test_invalid_format_missing_colon(self) -> None:
52
+        with pytest.raises(ValueError, match="Неверный формат --slice"):
53
+            parse_slice("22")
54
+
55
+    def test_invalid_format_extra_colon(self) -> None:
56
+        with pytest.raises(ValueError, match="Неверный формат --slice"):
57
+            parse_slice("2:3:4")
58
+
59
+    def test_non_integer_value(self) -> None:
60
+        with pytest.raises(ValueError, match="Неверный формат --slice"):
61
+            parse_slice("a:b")
62
+
63
+    def test_non_integer_col(self) -> None:
64
+        with pytest.raises(ValueError, match="Неверный формат --slice"):
65
+            parse_slice("x:2")
66
+
67
+    def test_zero_cols(self) -> None:
68
+        with pytest.raises(ValueError, match="Неверное значение --slice"):
69
+            parse_slice("0:2")
70
+
71
+    def test_zero_rows(self) -> None:
72
+        with pytest.raises(ValueError, match="Неверное значение --slice"):
73
+            parse_slice("2:0")
74
+
75
+    def test_negative_cols(self) -> None:
76
+        with pytest.raises(ValueError, match="Неверное значение --slice"):
77
+            parse_slice("-1:2")
78
+
79
+    def test_blank_string(self) -> None:
80
+        with pytest.raises(ValueError):
81
+            parse_slice("")
82
+
83
+
84
+class TestSliceGrid:
85
+    def test_2x2_exact(self) -> None:
86
+        image = _create_test_image(400, 300)
87
+        pages = slice_grid(image, 2, 2)
88
+        assert len(pages) == 4
89
+        assert pages[0].shape == (150, 200, 3)
90
+        assert pages[1].shape == (150, 200, 3)
91
+        assert pages[2].shape == (150, 200, 3)
92
+        assert pages[3].shape == (150, 200, 3)
93
+
94
+    def test_1x1(self) -> None:
95
+        image = _create_test_image(100, 100)
96
+        pages = slice_grid(image, 1, 1)
97
+        assert len(pages) == 1
98
+        assert pages[0].shape == (100, 100, 3)
99
+
100
+    def test_2x3(self) -> None:
101
+        image = _create_test_image(300, 200)
102
+        pages = slice_grid(image, 2, 3)
103
+        assert len(pages) == 6
104
+        for page in pages:
105
+            assert page.shape == (100, 100, 3)
106
+
107
+    def test_uneven_division_width(self) -> None:
108
+        image = _create_test_image(width=403, height=300)
109
+        pages = slice_grid(image, 1, 2)
110
+        assert len(pages) == 2
111
+        assert pages[0].shape == (300, 201, 3)
112
+        assert pages[1].shape == (300, 202, 3)
113
+
114
+    def test_uneven_division_height(self) -> None:
115
+        image = _create_test_image(width=400, height=301)
116
+        pages = slice_grid(image, 2, 1)
117
+        assert len(pages) == 2
118
+        assert pages[0].shape == (150, 400, 3)
119
+        assert pages[1].shape == (151, 400, 3)
120
+
121
+    def test_uneven_both(self) -> None:
122
+        image = _create_test_image(width=401, height=301)
123
+        pages = slice_grid(image, 2, 2)
124
+        assert len(pages) == 4
125
+        assert pages[0].shape == (150, 200, 3)
126
+        assert pages[1].shape == (150, 201, 3)
127
+        assert pages[2].shape == (151, 200, 3)
128
+        assert pages[3].shape == (151, 201, 3)
129
+
130
+    def test_pixel_content_preserved(self) -> None:
131
+        image = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
132
+        pages = slice_grid(image, 2, 2)
133
+        assert np.array_equal(image[0:50, 0:50], pages[0])
134
+        assert np.array_equal(image[0:50, 50:100], pages[1])
135
+        assert np.array_equal(image[50:100, 0:50], pages[2])
136
+        assert np.array_equal(image[50:100, 50:100], pages[3])
137
+
138
+    def test_total_pixels_preserved(self) -> None:
139
+        image = _create_test_image(width=401, height=301)
140
+        pages = slice_grid(image, 3, 3)
141
+        total = sum(page.shape[0] * page.shape[1] for page in pages)
142
+        assert total == 401 * 301
143
+
144
+    def test_order_2x2(self) -> None:
145
+        image = _create_test_image(200, 200)
146
+        image[0:100, 0:100] = (255, 0, 0)
147
+        image[0:100, 100:200] = (0, 255, 0)
148
+        image[100:200, 0:100] = (0, 0, 255)
149
+        image[100:200, 100:200] = (255, 255, 0)
150
+        pages = slice_grid(image, 2, 2)
151
+        assert pages[0][0, 0].tolist() == [255, 0, 0]
152
+        assert pages[1][0, 0].tolist() == [0, 255, 0]
153
+        assert pages[2][0, 0].tolist() == [0, 0, 255]
154
+        assert pages[3][0, 0].tolist() == [255, 255, 0]
155
+
156
+    def test_order_2x4(self) -> None:
157
+        image = _create_test_image(400, 200)
158
+        pages = slice_grid(image, 2, 4)
159
+        assert len(pages) == 8
160
+        assert pages[0].shape == (100, 100, 3)
161
+        assert pages[7].shape == (100, 100, 3)
162
+
163
+    def test_4x4(self) -> None:
164
+        image = _create_test_image(400, 400)
165
+        pages = slice_grid(image, 4, 4)
166
+        assert len(pages) == 16
167
+        for page in pages:
168
+            assert page.shape == (100, 100, 3)
169
+
170
+
171
+class TestAddBorder:
172
+    def test_default_border(self) -> None:
173
+        image = _create_test_image(100, 100)
174
+        result = add_border(image, 20)
175
+        assert result.shape == (140, 140, 3)
176
+
177
+    def test_zero_border(self) -> None:
178
+        image = _create_test_image(100, 100)
179
+        result = add_border(image, 0)
180
+        assert np.array_equal(result, image)
181
+
182
+    def test_border_is_white(self) -> None:
183
+        image = _create_test_image(100, 100)
184
+        border = 10
185
+        result = add_border(image, border)
186
+        assert np.all(result[0:border, :] == 255)
187
+        assert np.all(result[-border:, :] == 255)
188
+        assert np.all(result[:, 0:border] == 255)
189
+        assert np.all(result[:, -border:] == 255)
190
+
191
+    def test_large_border(self) -> None:
192
+        image = _create_test_image(10, 10)
193
+        result = add_border(image, 50)
194
+        assert result.shape == (110, 110, 3)
195
+
196
+
197
+class TestRotateImage:
198
+    def test_rotate_90(self) -> None:
199
+        image = _create_test_image(200, 100)
200
+        result = rotate_image(image, 90)
201
+        assert result.shape == (200, 100, 3)
202
+
203
+    def test_rotate_180(self) -> None:
204
+        image = _create_test_image(200, 100)
205
+        result = rotate_image(image, 180)
206
+        assert result.shape == (100, 200, 3)
207
+
208
+    def test_rotate_270(self) -> None:
209
+        image = _create_test_image(200, 100)
210
+        result = rotate_image(image, 270)
211
+        assert result.shape == (200, 100, 3)
212
+
213
+    def test_invalid_angle(self) -> None:
214
+        image = _create_test_image()
215
+        with pytest.raises(ValueError, match="Недопустимый угол поворота"):
216
+            rotate_image(image, 45)
217
+
218
+    def test_invalid_angle_zero(self) -> None:
219
+        image = _create_test_image()
220
+        with pytest.raises(ValueError):
221
+            rotate_image(image, 0)
222
+
223
+    def test_valid_rotations_set(self) -> None:
224
+        assert VALID_ROTATIONS == frozenset({90, 180, 270})
225
+
226
+
227
+class TestGenerateOutputPaths:
228
+    def test_simple(self) -> None:
229
+        paths = generate_output_paths(Path("scan001.jpg"), 2, Path("/tmp"))
230
+        assert paths == [Path("/tmp/scan001_01.jpg"), Path("/tmp/scan001_02.jpg")]
231
+
232
+    def test_tiff_extension(self) -> None:
233
+        paths = generate_output_paths(Path("doc.tiff"), 3, Path("out"))
234
+        assert paths == [
235
+            Path("out/doc_01.tiff"),
236
+            Path("out/doc_02.tiff"),
237
+            Path("out/doc_03.tiff"),
238
+        ]
239
+
240
+    def test_png_extension(self) -> None:
241
+        paths = generate_output_paths(Path("img.PNG"), 1, Path("out"))
242
+        assert paths == [Path("out/img_01.png")]
243
+
244
+    def test_zero_padding(self) -> None:
245
+        paths = generate_output_paths(Path("scan.jpg"), 12, Path("out"))
246
+        assert paths[0] == Path("out/scan_01.jpg")
247
+        assert paths[9] == Path("out/scan_10.jpg")
248
+        assert paths[11] == Path("out/scan_12.jpg")
249
+
250
+    def test_many_pages(self) -> None:
251
+        paths = generate_output_paths(Path("scan.jpg"), 100, Path("out"))
252
+        assert paths[0] == Path("out/scan_001.jpg")
253
+        assert paths[99] == Path("out/scan_100.jpg")
254
+
255
+    def test_dot_in_filename(self) -> None:
256
+        paths = generate_output_paths(Path("scan.001.jpg"), 2, Path("out"))
257
+        assert paths == [Path("out/scan.001_01.jpg"), Path("out/scan.001_02.jpg")]
258
+
259
+
260
+class TestLoadImage:
261
+    def test_valid_jpeg(self) -> None:
262
+        image = _create_test_image()
263
+        path = _image_to_path(image, ".jpg")
264
+        loaded = load_image(path)
265
+        assert loaded.shape == (300, 400, 3)
266
+
267
+    def test_valid_png(self) -> None:
268
+        image = _create_test_image()
269
+        path = _image_to_path(image, ".png")
270
+        loaded = load_image(path)
271
+        assert loaded.shape == (300, 400, 3)
272
+
273
+    def test_valid_tiff(self) -> None:
274
+        image = _create_test_image()
275
+        path = _image_to_path(image, ".tiff")
276
+        loaded = load_image(path)
277
+        assert loaded.shape == (300, 400, 3)
278
+
279
+    def test_file_not_found(self) -> None:
280
+        with pytest.raises(FileNotFoundError, match="Файл не найден"):
281
+            load_image(Path("/tmp/nonexistent_scan2html_test.jpg"))
282
+
283
+    def test_unsupported_extension(self) -> None:
284
+        path = Path("/tmp/test.bmp")
285
+        with pytest.raises(ValueError, match="Неподдерживаемый формат"):
286
+            load_image(path)
287
+
288
+    def test_unsupported_extension_no_dot(self) -> None:
289
+        path = Path("/tmp/test")
290
+        with pytest.raises(ValueError, match="Неподдерживаемый формат"):
291
+            load_image(path)
292
+
293
+    def test_supported_extensions_set(self) -> None:
294
+        assert SUPPORTED_EXTENSIONS == frozenset({".jpg", ".jpeg", ".png", ".tiff", ".tif"})
295
+
296
+
297
+class TestSavePage:
298
+    def test_saves_file(self) -> None:
299
+        image = _create_test_image()
300
+        with tempfile.TemporaryDirectory() as tmp:
301
+            path = Path(tmp) / "test.png"
302
+            save_page(image, path)
303
+            assert path.exists()
304
+            loaded = cv2.imread(str(path))
305
+            assert loaded is not None
306
+            assert loaded.shape == (300, 400, 3)
307
+
308
+    def test_creates_parent_dir(self) -> None:
309
+        image = _create_test_image()
310
+        with tempfile.TemporaryDirectory() as tmp:
311
+            path = Path(tmp) / "subdir" / "test.png"
312
+            save_page(image, path)
313
+            assert path.exists()
314
+
315
+
316
+class TestProcessImage:
317
+    def test_end_to_end_2x2(self) -> None:
318
+        image = _create_test_image(400, 300)
319
+        path = _image_to_path(image, ".jpg")
320
+        with tempfile.TemporaryDirectory() as tmp:
321
+            result = process_image(Path(path), 2, 2, Path(tmp))
322
+            assert len(result) == 4
323
+            for p in result:
324
+                assert p.exists()
325
+                loaded = cv2.imread(str(p))
326
+                assert loaded.shape == (250, 300, 3)
327
+
328
+    def test_end_to_end_with_border(self) -> None:
329
+        image = _create_test_image(200, 200)
330
+        path = _image_to_path(image, ".png")
331
+        with tempfile.TemporaryDirectory() as tmp:
332
+            result = process_image(Path(path), 2, 2, Path(tmp), border_px=10)
333
+            assert len(result) == 4
334
+            for p in result:
335
+                assert p.exists()
336
+                loaded = cv2.imread(str(p))
337
+                assert loaded.shape == (120, 120, 3)
338
+
339
+    def test_end_to_end_tiff(self) -> None:
340
+        image = _create_test_image(400, 400)
341
+        path = _image_to_path(image, ".tiff")
342
+        with tempfile.TemporaryDirectory() as tmp:
343
+            result = process_image(Path(path), 2, 2, Path(tmp))
344
+            assert len(result) == 4
345
+            for p in result:
346
+                assert p.suffix.lower() == ".tiff"
347
+                assert p.exists()
348
+
349
+    def test_end_to_end_with_rotation(self) -> None:
350
+        image = _create_test_image(200, 400)
351
+        path = _image_to_path(image, ".png")
352
+        with tempfile.TemporaryDirectory() as tmp:
353
+            result = process_image(Path(path), 2, 2, Path(tmp), pre_rotate=90)
354
+            assert len(result) == 4
355
+            for p in result:
356
+                assert p.exists()
357
+            loaded = cv2.imread(str(result[0]))
358
+            assert loaded.shape[0] > 0
359
+
360
+
361
+class TestFindContentBounds:
362
+    def test_uniform_image(self) -> None:
363
+        image = _create_test_image(200, 150)
364
+        image[:, :] = (255, 255, 255)
365
+        x1, y1, x2, y2 = find_content_bounds(image)
366
+        assert (x1, y1, x2, y2) == (0, 0, 200, 150)
367
+
368
+    def test_single_dot_center(self) -> None:
369
+        image = _create_test_image(200, 150)
370
+        image[:, :] = (255, 255, 255)
371
+        image[75, 100] = (0, 0, 0)
372
+        x1, y1, x2, y2 = find_content_bounds(image)
373
+        assert abs(x1 - 100) <= 1 and abs(x2 - 100) <= 1
374
+        assert abs(y1 - 75) <= 1 and abs(y2 - 75) <= 1
375
+
376
+    def test_rectangle_content(self) -> None:
377
+        image = _create_test_image(200, 150)
378
+        image[:, :] = (255, 255, 255)
379
+        image[30:100, 50:140] = (0, 0, 0)
380
+        x1, y1, x2, y2 = find_content_bounds(image)
381
+        assert x1 == 50
382
+        assert y1 == 30
383
+        assert x2 == 139
384
+        assert y2 == 99
385
+
386
+    def test_content_at_edges(self) -> None:
387
+        image = _create_test_image(200, 150)
388
+        image[:, :] = (255, 255, 255)
389
+        image[0:10, :] = (0, 0, 0)
390
+        image[:, 0:10] = (0, 0, 0)
391
+        x1, y1, _x2, _y2 = find_content_bounds(image)
392
+        assert y1 == 0
393
+        assert x1 == 0
394
+
395
+    def test_grayscale_content(self) -> None:
396
+        image = _create_test_image(100, 100)
397
+        image[:, :] = (255, 255, 255)
398
+        image[20:30, 20:30] = (128, 128, 128)
399
+        x1, y1, x2, y2 = find_content_bounds(image)
400
+        assert x1 <= 29 and x2 >= 20
401
+        assert y1 <= 29 and y2 >= 20
402
+
403
+
404
+class TestCropToContent:
405
+    def test_crop_centered_content(self) -> None:
406
+        image = _create_test_image(200, 150)
407
+        image[:, :] = (255, 255, 255)
408
+        image[30:50, 40:60] = (0, 0, 0)
409
+        result = crop_to_content(image, border_px=10)
410
+        assert result.shape[0] >= 20
411
+        assert result.shape[1] >= 20
412
+
413
+    def test_crop_uniform_image_no_change(self) -> None:
414
+        image = _create_test_image(100, 100)
415
+        image[:, :] = (255, 255, 255)
416
+        result = crop_to_content(image, border_px=10)
417
+        assert result.shape == (100, 100, 3)
418
+
419
+    def test_border_capped_by_image_edge(self) -> None:
420
+        image = _create_test_image(100, 100)
421
+        image[:, :] = (255, 255, 255)
422
+        image[10:30, 10:30] = (0, 0, 0)
423
+        result = crop_to_content(image, border_px=200)
424
+        assert result.shape == (100, 100, 3)
425
+
426
+    def test_border_equals_distance_to_edge(self) -> None:
427
+        image = _create_test_image(100, 100)
428
+        image[:, :] = (255, 255, 255)
429
+        image[40:60, 30:70] = (0, 0, 0)
430
+        result = crop_to_content(image, border_px=50)
431
+        assert result.shape[1] <= 100
432
+        assert result.shape[0] <= 100
433
+
434
+    def test_dark_bg_no_crop_uniform(self) -> None:
435
+        image = _create_test_image(100, 100)
436
+        image[:, :] = (0, 0, 0)
437
+        result = crop_to_content(image, border_px=0)
438
+        assert result.shape == (100, 100, 3)
439
+
440
+    def test_zero_border_exact_crop(self) -> None:
441
+        image = _create_test_image(200, 150)
442
+        image[:, :] = (255, 255, 255)
443
+        image[20:100, 30:170] = (0, 0, 0)
444
+        result = crop_to_content(image, border_px=0)
445
+        assert result.shape == (80, 140, 3)
446
+
447
+
448
+class TestProcessImagePostCrop:
449
+    def test_post_crop_reduces_size(self) -> None:
450
+        image = _create_test_image(400, 400)
451
+        image[:, :] = (255, 255, 255)
452
+        image[100:200, 100:200] = (0, 0, 0)
453
+        path = _image_to_path(image, ".png")
454
+        with tempfile.TemporaryDirectory() as tmp:
455
+            result = process_image(Path(path), 1, 1, Path(tmp), border_px=30, post_crop=True)
456
+            assert len(result) == 1
457
+            loaded = cv2.imread(str(result[0]))
458
+            assert loaded.shape[0] < 400 or loaded.shape[1] < 400
459
+
460
+    def test_post_crop_off_no_crop(self) -> None:
461
+        image = _create_test_image(400, 300)
462
+        path = _image_to_path(image, ".png")
463
+        with tempfile.TemporaryDirectory() as tmp:
464
+            result = process_image(Path(path), 1, 1, Path(tmp), post_crop=False)
465
+            assert len(result) == 1
466
+            loaded = cv2.imread(str(result[0]))
467
+            assert loaded.shape == (400, 500, 3)
468
+
469
+    def test_post_crop_e2e_2x2(self) -> None:
470
+        image = _create_test_image(400, 400)
471
+        image[:, :] = (255, 255, 255)
472
+        image[50:150, 50:150] = (0, 0, 0)
473
+        image[50:150, 250:350] = (0, 0, 0)
474
+        image[250:350, 50:150] = (0, 0, 0)
475
+        image[250:350, 250:350] = (0, 0, 0)
476
+        path = _image_to_path(image, ".png")
477
+        with tempfile.TemporaryDirectory() as tmp:
478
+            result_crop = process_image(Path(path), 2, 2, Path(tmp) / "crop", border_px=30, post_crop=True)
479
+            result_no = process_image(Path(path), 2, 2, Path(tmp) / "no", border_px=30, post_crop=False)
480
+            assert len(result_crop) == 4
481
+            for cp, np in zip(result_crop, result_no):
482
+                c = cv2.imread(str(cp))
483
+                n = cv2.imread(str(np))
484
+                assert c.shape[0] < n.shape[0]
485
+                assert c.shape[1] < n.shape[1]

Loading…
취소
저장