Evgeniy Ierusalimov 1 неделю назад
Родитель
Сommit
9db1f48fe4
11 измененных файлов: 538 добавлений и 282 удалений
  1. 22
    0
      .ai/AI_RULES.md
  2. 101
    0
      .ai/CODE_STYLE.md
  3. 49
    0
      .ai/DEVELOPMENT.md
  4. 49
    0
      .ai/IMAGE_PROCESSING.md
  5. 57
    34
      src/cli.py
  6. 0
    2
      src/split/__init__.py
  7. 1
    5
      src/split/slicer.py
  8. 19
    0
      tests/helpers.py
  9. 68
    0
      tests/test_detect_grid.py
  10. 136
    0
      tests/test_post_crop.py
  11. 36
    241
      tests/test_slicer.py

+ 22
- 0
.ai/AI_RULES.md Просмотреть файл

1
+# Правила работы AI-агента
2
+
3
+Не изменять архитектуру проекта без явного запроса пользователя.
4
+
5
+Не добавлять новые зависимости без необходимости.
6
+
7
+Не выполнять рефакторинг только ради красоты кода.
8
+
9
+Не изменять существующее поведение без требования пользователя.
10
+
11
+Не реализовывать функциональность, отсутствующую в техническом задании.
12
+
13
+Если существует несколько вариантов реализации:
14
+
15
+- выбрать самый простой;
16
+- если различия существенны — предложить варианты пользователю.
17
+
18
+Предпочитать понятный код компактному.
19
+
20
+Предпочитать простое решение сложному.
21
+
22
+При сомнениях задавать вопрос вместо предположений.

+ 101
- 0
.ai/CODE_STYLE.md Просмотреть файл

1
+# Стиль кода
2
+
3
+## Язык
4
+
5
+Python 3.12+
6
+
7
+---
8
+
9
+## Типизация
10
+
11
+Полная типизация обязательна.
12
+
13
+Избегать использования Any.
14
+
15
+---
16
+
17
+## Размер функций
18
+
19
+Желательно не более 40 строк.
20
+
21
+Максимум около 80 строк.
22
+
23
+Если функция становится длиннее — разделить её.
24
+
25
+---
26
+
27
+## Размер файлов
28
+
29
+Желательно до 300 строк.
30
+
31
+Максимум около 500 строк.
32
+
33
+---
34
+
35
+## Именование
36
+
37
+Использовать понятные имена.
38
+
39
+Хорошо:
40
+
41
+load_image()
42
+
43
+split_grid()
44
+
45
+rotate_image()
46
+
47
+add_border()
48
+
49
+Плохо:
50
+
51
+proc()
52
+
53
+run2()
54
+
55
+tmp()
56
+
57
+---
58
+
59
+## Глобальное состояние
60
+
61
+Не использовать глобальные переменные.
62
+
63
+Не использовать Singleton.
64
+
65
+---
66
+
67
+## Магические числа
68
+
69
+Все значения должны быть вынесены в именованные константы либо параметры функций.
70
+
71
+---
72
+
73
+## Ошибки
74
+
75
+Использовать исключения.
76
+
77
+CLI должен завершаться ненулевым кодом возврата.
78
+
79
+Сообщения должны быть понятны пользователю.
80
+
81
+---
82
+
83
+## Логирование
84
+
85
+Использовать logging.
86
+
87
+Не использовать print() для отладки.
88
+
89
+---
90
+
91
+## Качество
92
+
93
+Следовать PEP8.
94
+
95
+Использовать Ruff.
96
+
97
+Не оставлять:
98
+
99
+- закомментированный код;
100
+- неиспользуемый код;
101
+- TODO без объяснения.

+ 49
- 0
.ai/DEVELOPMENT.md Просмотреть файл

1
+# Правила разработки
2
+
3
+## Цель проекта
4
+
5
+Проект предназначен для полностью офлайн-подготовки документов к OCR.
6
+
7
+Главный приоритет — сохранить максимальное качество исходного изображения.
8
+
9
+Любое преобразование изображения должно либо улучшать OCR, либо не изменять качество изображения.
10
+
11
+---
12
+
13
+## Принципы
14
+
15
+Приоритеты разработки:
16
+
17
+1. Простота.
18
+2. Предсказуемость.
19
+3. Читаемость.
20
+4. Качество изображения.
21
+5. Производительность.
22
+
23
+Не реализовывать функциональность "на будущее".
24
+
25
+Реализовывать только то, что требуется текущим техническим заданием.
26
+
27
+---
28
+
29
+## Поведение
30
+
31
+Если требования неоднозначны:
32
+
33
+- не делать предположений;
34
+- не придумывать функциональность самостоятельно;
35
+- предложить варианты и дождаться решения пользователя.
36
+
37
+Не изменять существующее поведение без необходимости.
38
+
39
+Новые возможности добавлять как отдельные независимые этапы обработки.
40
+
41
+---
42
+
43
+## Зависимости
44
+
45
+Каждая новая библиотека должна иметь явное обоснование.
46
+
47
+Предпочитать стандартную библиотеку Python.
48
+
49
+Не добавлять тяжёлые зависимости без необходимости.

+ 49
- 0
.ai/IMAGE_PROCESSING.md Просмотреть файл

1
+# Правила обработки изображений
2
+
3
+Главная цель — максимально сохранить качество изображения для последующего OCR.
4
+
5
+## По умолчанию запрещено
6
+
7
+Без явного требования пользователя не выполнять:
8
+
9
+- изменение DPI;
10
+- повторное JPEG-сжатие;
11
+- изменение цветового пространства;
12
+- преобразование в grayscale;
13
+- бинаризацию;
14
+- изменение контрастности;
15
+- повышение резкости;
16
+- шумоподавление;
17
+- масштабирование.
18
+
19
+---
20
+
21
+## OpenCV
22
+
23
+Использовать OpenCV только там, где это действительно необходимо.
24
+
25
+Если задачу проще решить средствами NumPy — использовать NumPy.
26
+
27
+Например:
28
+
29
+разрезание изображения выполняется исключительно срезами массива NumPy.
30
+
31
+---
32
+
33
+## Потери качества
34
+
35
+Не выполнять преобразований, ухудшающих изображение.
36
+
37
+Избегать лишнего копирования изображений.
38
+
39
+Предпочитать NumPy views вместо копирования данных.
40
+
41
+---
42
+
43
+## Форматы
44
+
45
+Формат выходного изображения должен совпадать с входным.
46
+
47
+Цветовое пространство должно сохраняться.
48
+
49
+Все операции должны быть обратимыми либо явно управляться параметрами CLI.

+ 57
- 34
src/cli.py Просмотреть файл

20
 app = typer.Typer(add_completion=False, no_args_is_help=True)
20
 app = typer.Typer(add_completion=False, no_args_is_help=True)
21
 
21
 
22
 
22
 
23
+def _validate_input(input_path: Path | None) -> Path:
24
+    if input_path is None:
25
+        raise typer.BadParameter(
26
+            "Укажите путь к входному изображению", param_hint="--input"
27
+        )
28
+    if not input_path.exists():
29
+        raise typer.BadParameter(
30
+            f"Файл не найден: {input_path}", param_hint="--input"
31
+        )
32
+    if not input_path.is_file():
33
+        raise typer.BadParameter(
34
+            f"Не является файлом: {input_path}", param_hint="--input"
35
+        )
36
+    if not os.access(input_path, os.R_OK):
37
+        raise typer.BadParameter(
38
+            f"Нет доступа на чтение: {input_path}", param_hint="--input"
39
+        )
40
+    return input_path.resolve()
41
+
42
+
43
+def _validate_rotation(degrees: int | None) -> None:
44
+    if degrees is not None and degrees not in VALID_ROTATIONS:
45
+        raise typer.BadParameter(
46
+            f"Недопустимый угол: {degrees}. "
47
+            f"Допустимые: {sorted(VALID_ROTATIONS)}"
48
+        )
49
+
50
+
51
+def _resolve_grid(
52
+    slice_str: str | None,
53
+    slice_auto: bool,
54
+    input_path: Path,
55
+    pre_rotate: int | None,
56
+) -> tuple[int, int]:
57
+    if slice_str is not None and slice_auto:
58
+        raise typer.BadParameter(
59
+            "Нельзя указывать одновременно --slice и --slice-auto"
60
+        )
61
+    if slice_str is None and not slice_auto:
62
+        raise typer.BadParameter("Укажите --slice или --slice-auto")
63
+
64
+    if slice_auto:
65
+        image = load_image(input_path)
66
+        if pre_rotate is not None:
67
+            image = rotate_image(image, pre_rotate)
68
+        cols, rows = detect_grid(image)
69
+        typer.echo(f"Определена сетка: {cols}×{rows}")
70
+        return cols, rows
71
+
72
+    assert slice_str is not None
73
+    return parse_slice(slice_str)
74
+
75
+
23
 @app.command()
76
 @app.command()
24
 def slice_pages(
77
 def slice_pages(
25
     input: Annotated[
78
     input: Annotated[
86
     ] = False,
139
     ] = False,
87
 ) -> None:
140
 ) -> None:
88
     """Разрезать отсканированное изображение на отдельные страницы по геометрической сетке."""
141
     """Разрезать отсканированное изображение на отдельные страницы по геометрической сетке."""
89
-    if input is None:
90
-        raise typer.BadParameter("Укажите путь к входному изображению", param_hint="--input")
91
-
92
-    if not input.exists():
93
-        raise typer.BadParameter(f"Файл не найден: {input}", param_hint="--input")
94
-    if not input.is_file():
95
-        raise typer.BadParameter(f"Не является файлом: {input}", param_hint="--input")
96
-    if not os.access(input, os.R_OK):
97
-        raise typer.BadParameter(f"Нет доступа на чтение: {input}", param_hint="--input")
98
-
99
-    if pre_rotate is not None and pre_rotate not in VALID_ROTATIONS:
100
-        raise typer.BadParameter(
101
-            f"Недопустимый угол: {pre_rotate}. Допустимые: {sorted(VALID_ROTATIONS)}"
102
-        )
103
-
104
-    if slice is not None and slice_auto:
105
-        raise typer.BadParameter(
106
-            "Нельзя указывать одновременно --slice и --slice-auto"
107
-        )
108
-    if slice is None and not slice_auto:
109
-        raise typer.BadParameter(
110
-            "Укажите --slice или --slice-auto"
111
-        )
112
-
113
-    if slice_auto:
114
-        image = load_image(input.resolve())
115
-        if pre_rotate is not None:
116
-            image = rotate_image(image, pre_rotate)
117
-        cols, rows = detect_grid(image)
118
-        typer.echo(f"Определена сетка: {cols}×{rows}")
119
-    else:
120
-        assert slice is not None
121
-        cols, rows = parse_slice(slice)
142
+    input_path = _validate_input(input)
143
+    _validate_rotation(pre_rotate)
144
+    cols, rows = _resolve_grid(slice, slice_auto, input_path, pre_rotate)
122
 
145
 
123
     output_paths = process_image(
146
     output_paths = process_image(
124
-        input_path=input.resolve(),
147
+        input_path=input_path,
125
         rows=rows,
148
         rows=rows,
126
         cols=cols,
149
         cols=cols,
127
         output_dir=output_dir.resolve(),
150
         output_dir=output_dir.resolve(),

+ 0
- 2
src/split/__init__.py Просмотреть файл

1
 from src.split.slicer import (
1
 from src.split.slicer import (
2
-    CONTENT_THRESHOLD,
3
     DEFAULT_BORDER_PX,
2
     DEFAULT_BORDER_PX,
4
     PAGE_NUMBER_WIDTH,
3
     PAGE_NUMBER_WIDTH,
5
     SUPPORTED_EXTENSIONS,
4
     SUPPORTED_EXTENSIONS,
19
 )
18
 )
20
 
19
 
21
 __all__ = [
20
 __all__ = [
22
-    "CONTENT_THRESHOLD",
23
     "DEFAULT_BORDER_PX",
21
     "DEFAULT_BORDER_PX",
24
     "PAGE_NUMBER_WIDTH",
22
     "PAGE_NUMBER_WIDTH",
25
     "SUPPORTED_EXTENSIONS",
23
     "SUPPORTED_EXTENSIONS",

+ 1
- 5
src/split/slicer.py Просмотреть файл

10
 DEFAULT_BORDER_PX: int = 50
10
 DEFAULT_BORDER_PX: int = 50
11
 BORDER_COLOR: tuple[int, int, int] = (255, 255, 255)
11
 BORDER_COLOR: tuple[int, int, int] = (255, 255, 255)
12
 VALID_ROTATIONS: frozenset[int] = frozenset({90, 180, 270})
12
 VALID_ROTATIONS: frozenset[int] = frozenset({90, 180, 270})
13
-CONTENT_THRESHOLD: int = 200
14
 DENSITY_RATIO: float = 0.005
13
 DENSITY_RATIO: float = 0.005
15
 AUTO_GRID_MAX: int = 4
14
 AUTO_GRID_MAX: int = 4
16
 
15
 
164
     return max(1, bands)
163
     return max(1, bands)
165
 
164
 
166
 
165
 
167
-def find_content_bounds(
168
-    image: np.ndarray,
169
-    threshold: int = CONTENT_THRESHOLD,
170
-) -> tuple[int, int, int, int]:
166
+def find_content_bounds(image: np.ndarray) -> tuple[int, int, int, int]:
171
     gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
167
     gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
172
     blurred = cv2.GaussianBlur(gray, (3, 3), 0)
168
     blurred = cv2.GaussianBlur(gray, (3, 3), 0)
173
     otsu_th, binary = cv2.threshold(
169
     otsu_th, binary = cv2.threshold(

+ 19
- 0
tests/helpers.py Просмотреть файл

1
+from __future__ import annotations
2
+
3
+import tempfile
4
+from pathlib import Path
5
+
6
+import cv2
7
+import numpy as np
8
+
9
+
10
+def create_test_image(width: int = 400, height: int = 300) -> np.ndarray:
11
+    image = np.zeros((height, width, 3), dtype=np.uint8)
12
+    image[:, :] = (200, 200, 200)
13
+    return image
14
+
15
+
16
+def image_to_path(image: np.ndarray, suffix: str = ".png") -> Path:
17
+    with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
18
+        cv2.imwrite(tmp.name, image)
19
+        return Path(tmp.name)

+ 68
- 0
tests/test_detect_grid.py Просмотреть файл

1
+from __future__ import annotations
2
+
3
+from src.split.slicer import detect_grid
4
+from tests.helpers import create_test_image
5
+
6
+
7
+class TestDetectGrid:
8
+    def test_2x2(self) -> None:
9
+        image = create_test_image(400, 300)
10
+        image[:, :] = (255, 255, 255)
11
+        dark = (0, 0, 0)
12
+        gap = 20
13
+        bw = (400 - 3 * gap) // 2
14
+        bh = (300 - 3 * gap) // 2
15
+        for r in range(2):
16
+            for c in range(2):
17
+                y = gap + r * (bh + gap)
18
+                x = gap + c * (bw + gap)
19
+                image[y : y + bh, x : x + bw] = dark
20
+        cols, rows = detect_grid(image)
21
+        assert cols == 2
22
+        assert rows == 2
23
+
24
+    def test_2x4(self) -> None:
25
+        image = create_test_image(800, 400)
26
+        image[:, :] = (255, 255, 255)
27
+        gap = 15
28
+        bw = (800 - 5 * gap) // 4
29
+        bh = (400 - 3 * gap) // 2
30
+        dark = (0, 0, 0)
31
+        for r in range(2):
32
+            for c in range(4):
33
+                y = gap + r * (bh + gap)
34
+                x = gap + c * (bw + gap)
35
+                image[y : y + bh, x : x + bw] = dark
36
+        cols, rows = detect_grid(image)
37
+        assert cols == 4
38
+        assert rows == 2
39
+
40
+    def test_1x1(self) -> None:
41
+        image = create_test_image(200, 200)
42
+        image[:, :] = (255, 255, 255)
43
+        image[20:180, 20:180] = (0, 0, 0)
44
+        cols, rows = detect_grid(image)
45
+        assert cols == 1
46
+        assert rows == 1
47
+
48
+    def test_3x3(self) -> None:
49
+        image = create_test_image(600, 450)
50
+        image[:, :] = (255, 255, 255)
51
+        dark = (0, 0, 0)
52
+        gap = 10
53
+        bw = (600 - 4 * gap) // 3
54
+        bh = (450 - 4 * gap) // 3
55
+        for r in range(3):
56
+            for c in range(3):
57
+                y = gap + r * (bh + gap)
58
+                x = gap + c * (bw + gap)
59
+                image[y : y + bh, x : x + bw] = dark
60
+        cols, rows = detect_grid(image)
61
+        assert cols == 3
62
+        assert rows == 3
63
+
64
+    def test_uniform_image(self) -> None:
65
+        image = create_test_image()
66
+        cols, rows = detect_grid(image)
67
+        assert cols == 1
68
+        assert rows == 1

+ 136
- 0
tests/test_post_crop.py Просмотреть файл

1
+from __future__ import annotations
2
+
3
+import tempfile
4
+from pathlib import Path
5
+
6
+import cv2
7
+
8
+from src.split.slicer import crop_to_content, find_content_bounds, process_image
9
+from tests.helpers import create_test_image, image_to_path
10
+
11
+
12
+class TestFindContentBounds:
13
+    def test_uniform_image(self) -> None:
14
+        image = create_test_image(200, 150)
15
+        image[:, :] = (255, 255, 255)
16
+        x1, y1, x2, y2 = find_content_bounds(image)
17
+        assert (x1, y1, x2, y2) == (0, 0, 200, 150)
18
+
19
+    def test_single_dot_center(self) -> None:
20
+        image = create_test_image(200, 150)
21
+        image[:, :] = (255, 255, 255)
22
+        image[75, 100] = (0, 0, 0)
23
+        x1, y1, x2, y2 = find_content_bounds(image)
24
+        assert abs(x1 - 100) <= 1 and abs(x2 - 100) <= 1
25
+        assert abs(y1 - 75) <= 1 and abs(y2 - 75) <= 1
26
+
27
+    def test_rectangle_content(self) -> None:
28
+        image = create_test_image(200, 150)
29
+        image[:, :] = (255, 255, 255)
30
+        image[30:100, 50:140] = (0, 0, 0)
31
+        x1, y1, x2, y2 = find_content_bounds(image)
32
+        assert x1 == 50
33
+        assert y1 == 30
34
+        assert x2 == 139
35
+        assert y2 == 99
36
+
37
+    def test_content_at_edges(self) -> None:
38
+        image = create_test_image(200, 150)
39
+        image[:, :] = (255, 255, 255)
40
+        image[0:10, :] = (0, 0, 0)
41
+        image[:, 0:10] = (0, 0, 0)
42
+        x1, y1, _x2, _y2 = find_content_bounds(image)
43
+        assert y1 == 0
44
+        assert x1 == 0
45
+
46
+    def test_grayscale_content(self) -> None:
47
+        image = create_test_image(100, 100)
48
+        image[:, :] = (255, 255, 255)
49
+        image[20:30, 20:30] = (128, 128, 128)
50
+        x1, y1, x2, y2 = find_content_bounds(image)
51
+        assert x1 <= 29 and x2 >= 20
52
+        assert y1 <= 29 and y2 >= 20
53
+
54
+
55
+class TestCropToContent:
56
+    def test_crop_centered_content(self) -> None:
57
+        image = create_test_image(200, 150)
58
+        image[:, :] = (255, 255, 255)
59
+        image[30:50, 40:60] = (0, 0, 0)
60
+        result = crop_to_content(image, border_px=10)
61
+        assert result.shape[0] >= 20
62
+        assert result.shape[1] >= 20
63
+
64
+    def test_crop_uniform_image_no_change(self) -> None:
65
+        image = create_test_image(100, 100)
66
+        image[:, :] = (255, 255, 255)
67
+        result = crop_to_content(image, border_px=10)
68
+        assert result.shape == (100, 100, 3)
69
+
70
+    def test_border_capped_by_image_edge(self) -> None:
71
+        image = create_test_image(100, 100)
72
+        image[:, :] = (255, 255, 255)
73
+        image[10:30, 10:30] = (0, 0, 0)
74
+        result = crop_to_content(image, border_px=200)
75
+        assert result.shape == (100, 100, 3)
76
+
77
+    def test_border_equals_distance_to_edge(self) -> None:
78
+        image = create_test_image(100, 100)
79
+        image[:, :] = (255, 255, 255)
80
+        image[40:60, 30:70] = (0, 0, 0)
81
+        result = crop_to_content(image, border_px=50)
82
+        assert result.shape[1] <= 100
83
+        assert result.shape[0] <= 100
84
+
85
+    def test_dark_bg_no_crop_uniform(self) -> None:
86
+        image = create_test_image(100, 100)
87
+        image[:, :] = (0, 0, 0)
88
+        result = crop_to_content(image, border_px=0)
89
+        assert result.shape == (100, 100, 3)
90
+
91
+    def test_zero_border_exact_crop(self) -> None:
92
+        image = create_test_image(200, 150)
93
+        image[:, :] = (255, 255, 255)
94
+        image[20:100, 30:170] = (0, 0, 0)
95
+        result = crop_to_content(image, border_px=0)
96
+        assert result.shape == (80, 140, 3)
97
+
98
+
99
+class TestProcessImagePostCrop:
100
+    def test_post_crop_reduces_size(self) -> None:
101
+        image = create_test_image(400, 400)
102
+        image[:, :] = (255, 255, 255)
103
+        image[100:200, 100:200] = (0, 0, 0)
104
+        path = image_to_path(image, ".png")
105
+        with tempfile.TemporaryDirectory() as tmp:
106
+            result = process_image(Path(path), 1, 1, Path(tmp), border_px=30, post_crop=True)
107
+            assert len(result) == 1
108
+            loaded = cv2.imread(str(result[0]))
109
+            assert loaded.shape[0] < 400 or loaded.shape[1] < 400
110
+
111
+    def test_post_crop_off_no_crop(self) -> None:
112
+        image = create_test_image(400, 300)
113
+        path = image_to_path(image, ".png")
114
+        with tempfile.TemporaryDirectory() as tmp:
115
+            result = process_image(Path(path), 1, 1, Path(tmp), post_crop=False)
116
+            assert len(result) == 1
117
+            loaded = cv2.imread(str(result[0]))
118
+            assert loaded.shape == (400, 500, 3)
119
+
120
+    def test_post_crop_e2e_2x2(self) -> None:
121
+        image = create_test_image(400, 400)
122
+        image[:, :] = (255, 255, 255)
123
+        image[50:150, 50:150] = (0, 0, 0)
124
+        image[50:150, 250:350] = (0, 0, 0)
125
+        image[250:350, 50:150] = (0, 0, 0)
126
+        image[250:350, 250:350] = (0, 0, 0)
127
+        path = image_to_path(image, ".png")
128
+        with tempfile.TemporaryDirectory() as tmp:
129
+            result_crop = process_image(Path(path), 2, 2, Path(tmp) / "crop", border_px=30, post_crop=True)
130
+            result_no = process_image(Path(path), 2, 2, Path(tmp) / "no", border_px=30, post_crop=False)
131
+            assert len(result_crop) == 4
132
+            for cp, np in zip(result_crop, result_no):
133
+                c = cv2.imread(str(cp))
134
+                n = cv2.imread(str(np))
135
+                assert c.shape[0] < n.shape[0]
136
+                assert c.shape[1] < n.shape[1]

+ 36
- 241
tests/test_slicer.py Просмотреть файл

11
     SUPPORTED_EXTENSIONS,
11
     SUPPORTED_EXTENSIONS,
12
     VALID_ROTATIONS,
12
     VALID_ROTATIONS,
13
     add_border,
13
     add_border,
14
-    crop_to_content,
15
-    detect_grid,
16
-    find_content_bounds,
17
     generate_output_paths,
14
     generate_output_paths,
18
     load_image,
15
     load_image,
19
     parse_slice,
16
     parse_slice,
22
     save_page,
19
     save_page,
23
     slice_grid,
20
     slice_grid,
24
 )
21
 )
25
-
26
-
27
-def _create_test_image(width: int = 400, height: int = 300) -> np.ndarray:
28
-    image = np.zeros((height, width, 3), dtype=np.uint8)
29
-    image[:, :] = (200, 200, 200)
30
-    return image
31
-
32
-
33
-def _image_to_path(image: np.ndarray, suffix: str = ".png") -> Path:
34
-    with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
35
-        cv2.imwrite(tmp.name, image)
36
-        return Path(tmp.name)
22
+from tests.helpers import create_test_image, image_to_path
37
 
23
 
38
 
24
 
39
 class TestParseSlice:
25
 class TestParseSlice:
84
 
70
 
85
 class TestSliceGrid:
71
 class TestSliceGrid:
86
     def test_2x2_exact(self) -> None:
72
     def test_2x2_exact(self) -> None:
87
-        image = _create_test_image(400, 300)
73
+        image = create_test_image(400, 300)
88
         pages = slice_grid(image, 2, 2)
74
         pages = slice_grid(image, 2, 2)
89
         assert len(pages) == 4
75
         assert len(pages) == 4
90
         assert pages[0].shape == (150, 200, 3)
76
         assert pages[0].shape == (150, 200, 3)
93
         assert pages[3].shape == (150, 200, 3)
79
         assert pages[3].shape == (150, 200, 3)
94
 
80
 
95
     def test_1x1(self) -> None:
81
     def test_1x1(self) -> None:
96
-        image = _create_test_image(100, 100)
82
+        image = create_test_image(100, 100)
97
         pages = slice_grid(image, 1, 1)
83
         pages = slice_grid(image, 1, 1)
98
         assert len(pages) == 1
84
         assert len(pages) == 1
99
         assert pages[0].shape == (100, 100, 3)
85
         assert pages[0].shape == (100, 100, 3)
100
 
86
 
101
     def test_2x3(self) -> None:
87
     def test_2x3(self) -> None:
102
-        image = _create_test_image(300, 200)
88
+        image = create_test_image(300, 200)
103
         pages = slice_grid(image, 2, 3)
89
         pages = slice_grid(image, 2, 3)
104
         assert len(pages) == 6
90
         assert len(pages) == 6
105
         for page in pages:
91
         for page in pages:
106
             assert page.shape == (100, 100, 3)
92
             assert page.shape == (100, 100, 3)
107
 
93
 
108
     def test_uneven_division_width(self) -> None:
94
     def test_uneven_division_width(self) -> None:
109
-        image = _create_test_image(width=403, height=300)
95
+        image = create_test_image(width=403, height=300)
110
         pages = slice_grid(image, 1, 2)
96
         pages = slice_grid(image, 1, 2)
111
         assert len(pages) == 2
97
         assert len(pages) == 2
112
         assert pages[0].shape == (300, 201, 3)
98
         assert pages[0].shape == (300, 201, 3)
113
         assert pages[1].shape == (300, 202, 3)
99
         assert pages[1].shape == (300, 202, 3)
114
 
100
 
115
     def test_uneven_division_height(self) -> None:
101
     def test_uneven_division_height(self) -> None:
116
-        image = _create_test_image(width=400, height=301)
102
+        image = create_test_image(width=400, height=301)
117
         pages = slice_grid(image, 2, 1)
103
         pages = slice_grid(image, 2, 1)
118
         assert len(pages) == 2
104
         assert len(pages) == 2
119
         assert pages[0].shape == (150, 400, 3)
105
         assert pages[0].shape == (150, 400, 3)
120
         assert pages[1].shape == (151, 400, 3)
106
         assert pages[1].shape == (151, 400, 3)
121
 
107
 
122
     def test_uneven_both(self) -> None:
108
     def test_uneven_both(self) -> None:
123
-        image = _create_test_image(width=401, height=301)
109
+        image = create_test_image(width=401, height=301)
124
         pages = slice_grid(image, 2, 2)
110
         pages = slice_grid(image, 2, 2)
125
         assert len(pages) == 4
111
         assert len(pages) == 4
126
         assert pages[0].shape == (150, 200, 3)
112
         assert pages[0].shape == (150, 200, 3)
137
         assert np.array_equal(image[50:100, 50:100], pages[3])
123
         assert np.array_equal(image[50:100, 50:100], pages[3])
138
 
124
 
139
     def test_total_pixels_preserved(self) -> None:
125
     def test_total_pixels_preserved(self) -> None:
140
-        image = _create_test_image(width=401, height=301)
126
+        image = create_test_image(width=401, height=301)
141
         pages = slice_grid(image, 3, 3)
127
         pages = slice_grid(image, 3, 3)
142
         total = sum(page.shape[0] * page.shape[1] for page in pages)
128
         total = sum(page.shape[0] * page.shape[1] for page in pages)
143
         assert total == 401 * 301
129
         assert total == 401 * 301
144
 
130
 
145
     def test_order_2x2(self) -> None:
131
     def test_order_2x2(self) -> None:
146
-        image = _create_test_image(200, 200)
132
+        image = create_test_image(200, 200)
147
         image[0:100, 0:100] = (255, 0, 0)
133
         image[0:100, 0:100] = (255, 0, 0)
148
         image[0:100, 100:200] = (0, 255, 0)
134
         image[0:100, 100:200] = (0, 255, 0)
149
         image[100:200, 0:100] = (0, 0, 255)
135
         image[100:200, 0:100] = (0, 0, 255)
155
         assert pages[3][0, 0].tolist() == [255, 255, 0]
141
         assert pages[3][0, 0].tolist() == [255, 255, 0]
156
 
142
 
157
     def test_order_2x4(self) -> None:
143
     def test_order_2x4(self) -> None:
158
-        image = _create_test_image(400, 200)
144
+        image = create_test_image(400, 200)
159
         pages = slice_grid(image, 2, 4)
145
         pages = slice_grid(image, 2, 4)
160
         assert len(pages) == 8
146
         assert len(pages) == 8
161
         assert pages[0].shape == (100, 100, 3)
147
         assert pages[0].shape == (100, 100, 3)
162
         assert pages[7].shape == (100, 100, 3)
148
         assert pages[7].shape == (100, 100, 3)
163
 
149
 
164
     def test_4x4(self) -> None:
150
     def test_4x4(self) -> None:
165
-        image = _create_test_image(400, 400)
151
+        image = create_test_image(400, 400)
166
         pages = slice_grid(image, 4, 4)
152
         pages = slice_grid(image, 4, 4)
167
         assert len(pages) == 16
153
         assert len(pages) == 16
168
         for page in pages:
154
         for page in pages:
171
 
157
 
172
 class TestAddBorder:
158
 class TestAddBorder:
173
     def test_default_border(self) -> None:
159
     def test_default_border(self) -> None:
174
-        image = _create_test_image(100, 100)
160
+        image = create_test_image(100, 100)
175
         result = add_border(image, 20)
161
         result = add_border(image, 20)
176
         assert result.shape == (140, 140, 3)
162
         assert result.shape == (140, 140, 3)
177
 
163
 
178
     def test_zero_border(self) -> None:
164
     def test_zero_border(self) -> None:
179
-        image = _create_test_image(100, 100)
165
+        image = create_test_image(100, 100)
180
         result = add_border(image, 0)
166
         result = add_border(image, 0)
181
         assert np.array_equal(result, image)
167
         assert np.array_equal(result, image)
182
 
168
 
183
     def test_border_is_white(self) -> None:
169
     def test_border_is_white(self) -> None:
184
-        image = _create_test_image(100, 100)
170
+        image = create_test_image(100, 100)
185
         border = 10
171
         border = 10
186
         result = add_border(image, border)
172
         result = add_border(image, border)
187
         assert np.all(result[0:border, :] == 255)
173
         assert np.all(result[0:border, :] == 255)
190
         assert np.all(result[:, -border:] == 255)
176
         assert np.all(result[:, -border:] == 255)
191
 
177
 
192
     def test_large_border(self) -> None:
178
     def test_large_border(self) -> None:
193
-        image = _create_test_image(10, 10)
179
+        image = create_test_image(10, 10)
194
         result = add_border(image, 50)
180
         result = add_border(image, 50)
195
         assert result.shape == (110, 110, 3)
181
         assert result.shape == (110, 110, 3)
196
 
182
 
197
 
183
 
198
 class TestRotateImage:
184
 class TestRotateImage:
199
     def test_rotate_90(self) -> None:
185
     def test_rotate_90(self) -> None:
200
-        image = _create_test_image(200, 100)
186
+        image = create_test_image(200, 100)
201
         result = rotate_image(image, 90)
187
         result = rotate_image(image, 90)
202
         assert result.shape == (200, 100, 3)
188
         assert result.shape == (200, 100, 3)
203
 
189
 
204
     def test_rotate_180(self) -> None:
190
     def test_rotate_180(self) -> None:
205
-        image = _create_test_image(200, 100)
191
+        image = create_test_image(200, 100)
206
         result = rotate_image(image, 180)
192
         result = rotate_image(image, 180)
207
         assert result.shape == (100, 200, 3)
193
         assert result.shape == (100, 200, 3)
208
 
194
 
209
     def test_rotate_270(self) -> None:
195
     def test_rotate_270(self) -> None:
210
-        image = _create_test_image(200, 100)
196
+        image = create_test_image(200, 100)
211
         result = rotate_image(image, 270)
197
         result = rotate_image(image, 270)
212
         assert result.shape == (200, 100, 3)
198
         assert result.shape == (200, 100, 3)
213
 
199
 
214
     def test_invalid_angle(self) -> None:
200
     def test_invalid_angle(self) -> None:
215
-        image = _create_test_image()
201
+        image = create_test_image()
216
         with pytest.raises(ValueError, match="Недопустимый угол поворота"):
202
         with pytest.raises(ValueError, match="Недопустимый угол поворота"):
217
             rotate_image(image, 45)
203
             rotate_image(image, 45)
218
 
204
 
219
     def test_invalid_angle_zero(self) -> None:
205
     def test_invalid_angle_zero(self) -> None:
220
-        image = _create_test_image()
206
+        image = create_test_image()
221
         with pytest.raises(ValueError):
207
         with pytest.raises(ValueError):
222
             rotate_image(image, 0)
208
             rotate_image(image, 0)
223
 
209
 
260
 
246
 
261
 class TestLoadImage:
247
 class TestLoadImage:
262
     def test_valid_jpeg(self) -> None:
248
     def test_valid_jpeg(self) -> None:
263
-        image = _create_test_image()
264
-        path = _image_to_path(image, ".jpg")
249
+        image = create_test_image()
250
+        path = image_to_path(image, ".jpg")
265
         loaded = load_image(path)
251
         loaded = load_image(path)
266
         assert loaded.shape == (300, 400, 3)
252
         assert loaded.shape == (300, 400, 3)
267
 
253
 
268
     def test_valid_png(self) -> None:
254
     def test_valid_png(self) -> None:
269
-        image = _create_test_image()
270
-        path = _image_to_path(image, ".png")
255
+        image = create_test_image()
256
+        path = image_to_path(image, ".png")
271
         loaded = load_image(path)
257
         loaded = load_image(path)
272
         assert loaded.shape == (300, 400, 3)
258
         assert loaded.shape == (300, 400, 3)
273
 
259
 
274
     def test_valid_tiff(self) -> None:
260
     def test_valid_tiff(self) -> None:
275
-        image = _create_test_image()
276
-        path = _image_to_path(image, ".tiff")
261
+        image = create_test_image()
262
+        path = image_to_path(image, ".tiff")
277
         loaded = load_image(path)
263
         loaded = load_image(path)
278
         assert loaded.shape == (300, 400, 3)
264
         assert loaded.shape == (300, 400, 3)
279
 
265
 
297
 
283
 
298
 class TestSavePage:
284
 class TestSavePage:
299
     def test_saves_file(self) -> None:
285
     def test_saves_file(self) -> None:
300
-        image = _create_test_image()
286
+        image = create_test_image()
301
         with tempfile.TemporaryDirectory() as tmp:
287
         with tempfile.TemporaryDirectory() as tmp:
302
             path = Path(tmp) / "test.png"
288
             path = Path(tmp) / "test.png"
303
             save_page(image, path)
289
             save_page(image, path)
307
             assert loaded.shape == (300, 400, 3)
293
             assert loaded.shape == (300, 400, 3)
308
 
294
 
309
     def test_creates_parent_dir(self) -> None:
295
     def test_creates_parent_dir(self) -> None:
310
-        image = _create_test_image()
296
+        image = create_test_image()
311
         with tempfile.TemporaryDirectory() as tmp:
297
         with tempfile.TemporaryDirectory() as tmp:
312
             path = Path(tmp) / "subdir" / "test.png"
298
             path = Path(tmp) / "subdir" / "test.png"
313
             save_page(image, path)
299
             save_page(image, path)
316
 
302
 
317
 class TestProcessImage:
303
 class TestProcessImage:
318
     def test_end_to_end_2x2(self) -> None:
304
     def test_end_to_end_2x2(self) -> None:
319
-        image = _create_test_image(400, 300)
320
-        path = _image_to_path(image, ".jpg")
305
+        image = create_test_image(400, 300)
306
+        path = image_to_path(image, ".jpg")
321
         with tempfile.TemporaryDirectory() as tmp:
307
         with tempfile.TemporaryDirectory() as tmp:
322
             result = process_image(Path(path), 2, 2, Path(tmp))
308
             result = process_image(Path(path), 2, 2, Path(tmp))
323
             assert len(result) == 4
309
             assert len(result) == 4
327
                 assert loaded.shape == (250, 300, 3)
313
                 assert loaded.shape == (250, 300, 3)
328
 
314
 
329
     def test_end_to_end_with_border(self) -> None:
315
     def test_end_to_end_with_border(self) -> None:
330
-        image = _create_test_image(200, 200)
331
-        path = _image_to_path(image, ".png")
316
+        image = create_test_image(200, 200)
317
+        path = image_to_path(image, ".png")
332
         with tempfile.TemporaryDirectory() as tmp:
318
         with tempfile.TemporaryDirectory() as tmp:
333
             result = process_image(Path(path), 2, 2, Path(tmp), border_px=10)
319
             result = process_image(Path(path), 2, 2, Path(tmp), border_px=10)
334
             assert len(result) == 4
320
             assert len(result) == 4
338
                 assert loaded.shape == (120, 120, 3)
324
                 assert loaded.shape == (120, 120, 3)
339
 
325
 
340
     def test_end_to_end_tiff(self) -> None:
326
     def test_end_to_end_tiff(self) -> None:
341
-        image = _create_test_image(400, 400)
342
-        path = _image_to_path(image, ".tiff")
327
+        image = create_test_image(400, 400)
328
+        path = image_to_path(image, ".tiff")
343
         with tempfile.TemporaryDirectory() as tmp:
329
         with tempfile.TemporaryDirectory() as tmp:
344
             result = process_image(Path(path), 2, 2, Path(tmp))
330
             result = process_image(Path(path), 2, 2, Path(tmp))
345
             assert len(result) == 4
331
             assert len(result) == 4
348
                 assert p.exists()
334
                 assert p.exists()
349
 
335
 
350
     def test_end_to_end_with_rotation(self) -> None:
336
     def test_end_to_end_with_rotation(self) -> None:
351
-        image = _create_test_image(200, 400)
352
-        path = _image_to_path(image, ".png")
337
+        image = create_test_image(200, 400)
338
+        path = image_to_path(image, ".png")
353
         with tempfile.TemporaryDirectory() as tmp:
339
         with tempfile.TemporaryDirectory() as tmp:
354
             result = process_image(Path(path), 2, 2, Path(tmp), pre_rotate=90)
340
             result = process_image(Path(path), 2, 2, Path(tmp), pre_rotate=90)
355
             assert len(result) == 4
341
             assert len(result) == 4
357
                 assert p.exists()
343
                 assert p.exists()
358
             loaded = cv2.imread(str(result[0]))
344
             loaded = cv2.imread(str(result[0]))
359
             assert loaded.shape[0] > 0
345
             assert loaded.shape[0] > 0
360
-
361
-
362
-class TestFindContentBounds:
363
-    def test_uniform_image(self) -> None:
364
-        image = _create_test_image(200, 150)
365
-        image[:, :] = (255, 255, 255)
366
-        x1, y1, x2, y2 = find_content_bounds(image)
367
-        assert (x1, y1, x2, y2) == (0, 0, 200, 150)
368
-
369
-    def test_single_dot_center(self) -> None:
370
-        image = _create_test_image(200, 150)
371
-        image[:, :] = (255, 255, 255)
372
-        image[75, 100] = (0, 0, 0)
373
-        x1, y1, x2, y2 = find_content_bounds(image)
374
-        assert abs(x1 - 100) <= 1 and abs(x2 - 100) <= 1
375
-        assert abs(y1 - 75) <= 1 and abs(y2 - 75) <= 1
376
-
377
-    def test_rectangle_content(self) -> None:
378
-        image = _create_test_image(200, 150)
379
-        image[:, :] = (255, 255, 255)
380
-        image[30:100, 50:140] = (0, 0, 0)
381
-        x1, y1, x2, y2 = find_content_bounds(image)
382
-        assert x1 == 50
383
-        assert y1 == 30
384
-        assert x2 == 139
385
-        assert y2 == 99
386
-
387
-    def test_content_at_edges(self) -> None:
388
-        image = _create_test_image(200, 150)
389
-        image[:, :] = (255, 255, 255)
390
-        image[0:10, :] = (0, 0, 0)
391
-        image[:, 0:10] = (0, 0, 0)
392
-        x1, y1, _x2, _y2 = find_content_bounds(image)
393
-        assert y1 == 0
394
-        assert x1 == 0
395
-
396
-    def test_grayscale_content(self) -> None:
397
-        image = _create_test_image(100, 100)
398
-        image[:, :] = (255, 255, 255)
399
-        image[20:30, 20:30] = (128, 128, 128)
400
-        x1, y1, x2, y2 = find_content_bounds(image)
401
-        assert x1 <= 29 and x2 >= 20
402
-        assert y1 <= 29 and y2 >= 20
403
-
404
-
405
-class TestCropToContent:
406
-    def test_crop_centered_content(self) -> None:
407
-        image = _create_test_image(200, 150)
408
-        image[:, :] = (255, 255, 255)
409
-        image[30:50, 40:60] = (0, 0, 0)
410
-        result = crop_to_content(image, border_px=10)
411
-        assert result.shape[0] >= 20
412
-        assert result.shape[1] >= 20
413
-
414
-    def test_crop_uniform_image_no_change(self) -> None:
415
-        image = _create_test_image(100, 100)
416
-        image[:, :] = (255, 255, 255)
417
-        result = crop_to_content(image, border_px=10)
418
-        assert result.shape == (100, 100, 3)
419
-
420
-    def test_border_capped_by_image_edge(self) -> None:
421
-        image = _create_test_image(100, 100)
422
-        image[:, :] = (255, 255, 255)
423
-        image[10:30, 10:30] = (0, 0, 0)
424
-        result = crop_to_content(image, border_px=200)
425
-        assert result.shape == (100, 100, 3)
426
-
427
-    def test_border_equals_distance_to_edge(self) -> None:
428
-        image = _create_test_image(100, 100)
429
-        image[:, :] = (255, 255, 255)
430
-        image[40:60, 30:70] = (0, 0, 0)
431
-        result = crop_to_content(image, border_px=50)
432
-        assert result.shape[1] <= 100
433
-        assert result.shape[0] <= 100
434
-
435
-    def test_dark_bg_no_crop_uniform(self) -> None:
436
-        image = _create_test_image(100, 100)
437
-        image[:, :] = (0, 0, 0)
438
-        result = crop_to_content(image, border_px=0)
439
-        assert result.shape == (100, 100, 3)
440
-
441
-    def test_zero_border_exact_crop(self) -> None:
442
-        image = _create_test_image(200, 150)
443
-        image[:, :] = (255, 255, 255)
444
-        image[20:100, 30:170] = (0, 0, 0)
445
-        result = crop_to_content(image, border_px=0)
446
-        assert result.shape == (80, 140, 3)
447
-
448
-
449
-class TestProcessImagePostCrop:
450
-    def test_post_crop_reduces_size(self) -> None:
451
-        image = _create_test_image(400, 400)
452
-        image[:, :] = (255, 255, 255)
453
-        image[100:200, 100:200] = (0, 0, 0)
454
-        path = _image_to_path(image, ".png")
455
-        with tempfile.TemporaryDirectory() as tmp:
456
-            result = process_image(Path(path), 1, 1, Path(tmp), border_px=30, post_crop=True)
457
-            assert len(result) == 1
458
-            loaded = cv2.imread(str(result[0]))
459
-            assert loaded.shape[0] < 400 or loaded.shape[1] < 400
460
-
461
-    def test_post_crop_off_no_crop(self) -> None:
462
-        image = _create_test_image(400, 300)
463
-        path = _image_to_path(image, ".png")
464
-        with tempfile.TemporaryDirectory() as tmp:
465
-            result = process_image(Path(path), 1, 1, Path(tmp), post_crop=False)
466
-            assert len(result) == 1
467
-            loaded = cv2.imread(str(result[0]))
468
-            assert loaded.shape == (400, 500, 3)
469
-
470
-    def test_post_crop_e2e_2x2(self) -> None:
471
-        image = _create_test_image(400, 400)
472
-        image[:, :] = (255, 255, 255)
473
-        image[50:150, 50:150] = (0, 0, 0)
474
-        image[50:150, 250:350] = (0, 0, 0)
475
-        image[250:350, 50:150] = (0, 0, 0)
476
-        image[250:350, 250:350] = (0, 0, 0)
477
-        path = _image_to_path(image, ".png")
478
-        with tempfile.TemporaryDirectory() as tmp:
479
-            result_crop = process_image(Path(path), 2, 2, Path(tmp) / "crop", border_px=30, post_crop=True)
480
-            result_no = process_image(Path(path), 2, 2, Path(tmp) / "no", border_px=30, post_crop=False)
481
-            assert len(result_crop) == 4
482
-            for cp, np in zip(result_crop, result_no):
483
-                c = cv2.imread(str(cp))
484
-                n = cv2.imread(str(np))
485
-                assert c.shape[0] < n.shape[0]
486
-                assert c.shape[1] < n.shape[1]
487
-
488
-
489
-class TestDetectGrid:
490
-    def test_2x2(self) -> None:
491
-        image = _create_test_image(400, 300)
492
-        image[:, :] = (255, 255, 255)
493
-        dark = (0, 0, 0)
494
-        gap = 20
495
-        bw = (400 - 3 * gap) // 2
496
-        bh = (300 - 3 * gap) // 2
497
-        for r in range(2):
498
-            for c in range(2):
499
-                y = gap + r * (bh + gap)
500
-                x = gap + c * (bw + gap)
501
-                image[y:y + bh, x:x + bw] = dark
502
-        cols, rows = detect_grid(image)
503
-        assert cols == 2
504
-        assert rows == 2
505
-
506
-    def test_2x4(self) -> None:
507
-        image = _create_test_image(800, 400)
508
-        image[:, :] = (255, 255, 255)
509
-        gap = 15
510
-        bw = (800 - 5 * gap) // 4
511
-        bh = (400 - 3 * gap) // 2
512
-        dark = (0, 0, 0)
513
-        for r in range(2):
514
-            for c in range(4):
515
-                y = gap + r * (bh + gap)
516
-                x = gap + c * (bw + gap)
517
-                image[y:y + bh, x:x + bw] = dark
518
-        cols, rows = detect_grid(image)
519
-        assert cols == 4
520
-        assert rows == 2
521
-
522
-    def test_1x1(self) -> None:
523
-        image = _create_test_image(200, 200)
524
-        image[:, :] = (255, 255, 255)
525
-        image[20:180, 20:180] = (0, 0, 0)
526
-        cols, rows = detect_grid(image)
527
-        assert cols == 1
528
-        assert rows == 1
529
-
530
-    def test_3x3(self) -> None:
531
-        image = _create_test_image(600, 450)
532
-        image[:, :] = (255, 255, 255)
533
-        dark = (0, 0, 0)
534
-        gap = 10
535
-        bw = (600 - 4 * gap) // 3
536
-        bh = (450 - 4 * gap) // 3
537
-        for r in range(3):
538
-            for c in range(3):
539
-                y = gap + r * (bh + gap)
540
-                x = gap + c * (bw + gap)
541
-                image[y:y + bh, x:x + bw] = dark
542
-        cols, rows = detect_grid(image)
543
-        assert cols == 3
544
-        assert rows == 3
545
-
546
-    def test_uniform_image(self) -> None:
547
-        image = _create_test_image()
548
-        cols, rows = detect_grid(image)
549
-        assert cols == 1
550
-        assert rows == 1

Загрузка…
Отмена
Сохранить