浏览代码

refactored code

master
Evgeniy Ierusalimov 1周前
父节点
当前提交
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 查看文件

@@ -0,0 +1,22 @@
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 查看文件

@@ -0,0 +1,101 @@
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 查看文件

@@ -0,0 +1,49 @@
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 查看文件

@@ -0,0 +1,49 @@
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,6 +20,59 @@ from src.split.slicer import (
20 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 76
 @app.command()
24 77
 def slice_pages(
25 78
     input: Annotated[
@@ -86,42 +139,12 @@ def slice_pages(
86 139
     ] = False,
87 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 146
     output_paths = process_image(
124
-        input_path=input.resolve(),
147
+        input_path=input_path,
125 148
         rows=rows,
126 149
         cols=cols,
127 150
         output_dir=output_dir.resolve(),

+ 0
- 2
src/split/__init__.py 查看文件

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

+ 1
- 5
src/split/slicer.py 查看文件

@@ -10,7 +10,6 @@ PAGE_NUMBER_WIDTH: int = 2
10 10
 DEFAULT_BORDER_PX: int = 50
11 11
 BORDER_COLOR: tuple[int, int, int] = (255, 255, 255)
12 12
 VALID_ROTATIONS: frozenset[int] = frozenset({90, 180, 270})
13
-CONTENT_THRESHOLD: int = 200
14 13
 DENSITY_RATIO: float = 0.005
15 14
 AUTO_GRID_MAX: int = 4
16 15
 
@@ -164,10 +163,7 @@ def _count_bands(binary: np.ndarray, axis: int) -> int:
164 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 167
     gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
172 168
     blurred = cv2.GaussianBlur(gray, (3, 3), 0)
173 169
     otsu_th, binary = cv2.threshold(

+ 19
- 0
tests/helpers.py 查看文件

@@ -0,0 +1,19 @@
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 查看文件

@@ -0,0 +1,68 @@
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 查看文件

@@ -0,0 +1,136 @@
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,9 +11,6 @@ from src.split.slicer import (
11 11
     SUPPORTED_EXTENSIONS,
12 12
     VALID_ROTATIONS,
13 13
     add_border,
14
-    crop_to_content,
15
-    detect_grid,
16
-    find_content_bounds,
17 14
     generate_output_paths,
18 15
     load_image,
19 16
     parse_slice,
@@ -22,18 +19,7 @@ from src.split.slicer import (
22 19
     save_page,
23 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 25
 class TestParseSlice:
@@ -84,7 +70,7 @@ class TestParseSlice:
84 70
 
85 71
 class TestSliceGrid:
86 72
     def test_2x2_exact(self) -> None:
87
-        image = _create_test_image(400, 300)
73
+        image = create_test_image(400, 300)
88 74
         pages = slice_grid(image, 2, 2)
89 75
         assert len(pages) == 4
90 76
         assert pages[0].shape == (150, 200, 3)
@@ -93,34 +79,34 @@ class TestSliceGrid:
93 79
         assert pages[3].shape == (150, 200, 3)
94 80
 
95 81
     def test_1x1(self) -> None:
96
-        image = _create_test_image(100, 100)
82
+        image = create_test_image(100, 100)
97 83
         pages = slice_grid(image, 1, 1)
98 84
         assert len(pages) == 1
99 85
         assert pages[0].shape == (100, 100, 3)
100 86
 
101 87
     def test_2x3(self) -> None:
102
-        image = _create_test_image(300, 200)
88
+        image = create_test_image(300, 200)
103 89
         pages = slice_grid(image, 2, 3)
104 90
         assert len(pages) == 6
105 91
         for page in pages:
106 92
             assert page.shape == (100, 100, 3)
107 93
 
108 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 96
         pages = slice_grid(image, 1, 2)
111 97
         assert len(pages) == 2
112 98
         assert pages[0].shape == (300, 201, 3)
113 99
         assert pages[1].shape == (300, 202, 3)
114 100
 
115 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 103
         pages = slice_grid(image, 2, 1)
118 104
         assert len(pages) == 2
119 105
         assert pages[0].shape == (150, 400, 3)
120 106
         assert pages[1].shape == (151, 400, 3)
121 107
 
122 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 110
         pages = slice_grid(image, 2, 2)
125 111
         assert len(pages) == 4
126 112
         assert pages[0].shape == (150, 200, 3)
@@ -137,13 +123,13 @@ class TestSliceGrid:
137 123
         assert np.array_equal(image[50:100, 50:100], pages[3])
138 124
 
139 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 127
         pages = slice_grid(image, 3, 3)
142 128
         total = sum(page.shape[0] * page.shape[1] for page in pages)
143 129
         assert total == 401 * 301
144 130
 
145 131
     def test_order_2x2(self) -> None:
146
-        image = _create_test_image(200, 200)
132
+        image = create_test_image(200, 200)
147 133
         image[0:100, 0:100] = (255, 0, 0)
148 134
         image[0:100, 100:200] = (0, 255, 0)
149 135
         image[100:200, 0:100] = (0, 0, 255)
@@ -155,14 +141,14 @@ class TestSliceGrid:
155 141
         assert pages[3][0, 0].tolist() == [255, 255, 0]
156 142
 
157 143
     def test_order_2x4(self) -> None:
158
-        image = _create_test_image(400, 200)
144
+        image = create_test_image(400, 200)
159 145
         pages = slice_grid(image, 2, 4)
160 146
         assert len(pages) == 8
161 147
         assert pages[0].shape == (100, 100, 3)
162 148
         assert pages[7].shape == (100, 100, 3)
163 149
 
164 150
     def test_4x4(self) -> None:
165
-        image = _create_test_image(400, 400)
151
+        image = create_test_image(400, 400)
166 152
         pages = slice_grid(image, 4, 4)
167 153
         assert len(pages) == 16
168 154
         for page in pages:
@@ -171,17 +157,17 @@ class TestSliceGrid:
171 157
 
172 158
 class TestAddBorder:
173 159
     def test_default_border(self) -> None:
174
-        image = _create_test_image(100, 100)
160
+        image = create_test_image(100, 100)
175 161
         result = add_border(image, 20)
176 162
         assert result.shape == (140, 140, 3)
177 163
 
178 164
     def test_zero_border(self) -> None:
179
-        image = _create_test_image(100, 100)
165
+        image = create_test_image(100, 100)
180 166
         result = add_border(image, 0)
181 167
         assert np.array_equal(result, image)
182 168
 
183 169
     def test_border_is_white(self) -> None:
184
-        image = _create_test_image(100, 100)
170
+        image = create_test_image(100, 100)
185 171
         border = 10
186 172
         result = add_border(image, border)
187 173
         assert np.all(result[0:border, :] == 255)
@@ -190,34 +176,34 @@ class TestAddBorder:
190 176
         assert np.all(result[:, -border:] == 255)
191 177
 
192 178
     def test_large_border(self) -> None:
193
-        image = _create_test_image(10, 10)
179
+        image = create_test_image(10, 10)
194 180
         result = add_border(image, 50)
195 181
         assert result.shape == (110, 110, 3)
196 182
 
197 183
 
198 184
 class TestRotateImage:
199 185
     def test_rotate_90(self) -> None:
200
-        image = _create_test_image(200, 100)
186
+        image = create_test_image(200, 100)
201 187
         result = rotate_image(image, 90)
202 188
         assert result.shape == (200, 100, 3)
203 189
 
204 190
     def test_rotate_180(self) -> None:
205
-        image = _create_test_image(200, 100)
191
+        image = create_test_image(200, 100)
206 192
         result = rotate_image(image, 180)
207 193
         assert result.shape == (100, 200, 3)
208 194
 
209 195
     def test_rotate_270(self) -> None:
210
-        image = _create_test_image(200, 100)
196
+        image = create_test_image(200, 100)
211 197
         result = rotate_image(image, 270)
212 198
         assert result.shape == (200, 100, 3)
213 199
 
214 200
     def test_invalid_angle(self) -> None:
215
-        image = _create_test_image()
201
+        image = create_test_image()
216 202
         with pytest.raises(ValueError, match="Недопустимый угол поворота"):
217 203
             rotate_image(image, 45)
218 204
 
219 205
     def test_invalid_angle_zero(self) -> None:
220
-        image = _create_test_image()
206
+        image = create_test_image()
221 207
         with pytest.raises(ValueError):
222 208
             rotate_image(image, 0)
223 209
 
@@ -260,20 +246,20 @@ class TestGenerateOutputPaths:
260 246
 
261 247
 class TestLoadImage:
262 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 251
         loaded = load_image(path)
266 252
         assert loaded.shape == (300, 400, 3)
267 253
 
268 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 257
         loaded = load_image(path)
272 258
         assert loaded.shape == (300, 400, 3)
273 259
 
274 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 263
         loaded = load_image(path)
278 264
         assert loaded.shape == (300, 400, 3)
279 265
 
@@ -297,7 +283,7 @@ class TestLoadImage:
297 283
 
298 284
 class TestSavePage:
299 285
     def test_saves_file(self) -> None:
300
-        image = _create_test_image()
286
+        image = create_test_image()
301 287
         with tempfile.TemporaryDirectory() as tmp:
302 288
             path = Path(tmp) / "test.png"
303 289
             save_page(image, path)
@@ -307,7 +293,7 @@ class TestSavePage:
307 293
             assert loaded.shape == (300, 400, 3)
308 294
 
309 295
     def test_creates_parent_dir(self) -> None:
310
-        image = _create_test_image()
296
+        image = create_test_image()
311 297
         with tempfile.TemporaryDirectory() as tmp:
312 298
             path = Path(tmp) / "subdir" / "test.png"
313 299
             save_page(image, path)
@@ -316,8 +302,8 @@ class TestSavePage:
316 302
 
317 303
 class TestProcessImage:
318 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 307
         with tempfile.TemporaryDirectory() as tmp:
322 308
             result = process_image(Path(path), 2, 2, Path(tmp))
323 309
             assert len(result) == 4
@@ -327,8 +313,8 @@ class TestProcessImage:
327 313
                 assert loaded.shape == (250, 300, 3)
328 314
 
329 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 318
         with tempfile.TemporaryDirectory() as tmp:
333 319
             result = process_image(Path(path), 2, 2, Path(tmp), border_px=10)
334 320
             assert len(result) == 4
@@ -338,8 +324,8 @@ class TestProcessImage:
338 324
                 assert loaded.shape == (120, 120, 3)
339 325
 
340 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 329
         with tempfile.TemporaryDirectory() as tmp:
344 330
             result = process_image(Path(path), 2, 2, Path(tmp))
345 331
             assert len(result) == 4
@@ -348,8 +334,8 @@ class TestProcessImage:
348 334
                 assert p.exists()
349 335
 
350 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 339
         with tempfile.TemporaryDirectory() as tmp:
354 340
             result = process_image(Path(path), 2, 2, Path(tmp), pre_rotate=90)
355 341
             assert len(result) == 4
@@ -357,194 +343,3 @@ class TestProcessImage:
357 343
                 assert p.exists()
358 344
             loaded = cv2.imread(str(result[0]))
359 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

正在加载...
取消
保存