Hayk Arutyunyan commited on
Commit
7b0cda0
·
1 Parent(s): 39da704

Update project structure, add pipeline and tests

Browse files
configs/config.yaml CHANGED
@@ -1,7 +1,17 @@
1
- input_dir: data/sample/input
2
- output_dir: outputs
3
- ocr:
4
- engine: paddleocr
5
- lang: en
 
 
 
 
 
 
 
 
 
6
  export:
7
- excel_filename: results.xlsx
 
 
1
+ paths:
2
+ input: "input"
3
+ output: "outputs"
4
+
5
+ colors:
6
+ purple: [[120, 80, 50], [150, 255, 255]]
7
+ cyan: [[85, 150, 150], [100, 255, 255]]
8
+ white: [[0, 0, 250], [180, 15, 255]]
9
+ green_light: [[85, 118, 118], [95, 138, 138]]
10
+ green_dark: [[55, 245, 186], [65, 255, 206]]
11
+ gray_dark: [[0, 0, 187], [180, 5, 197]]
12
+ yellow: [[20, 100, 100], [40, 255, 255]]
13
+ red: [[0, 150, 150], [10, 255, 255]]
14
+
15
  export:
16
+ excel_filename: "ocr_results.xlsx"
17
+ >>>>>>> e710af8 (Update project structure, add pipeline and tests)
notebooks/mnemo_ocr_final_v03_color_v01.ipynb DELETED
The diff for this file is too large to render. See raw diff
 
pytest.ini ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [pytest]
2
+ pythonpath = src
src/__init__.py ADDED
File without changes
src/config_loader.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Загрузка конфигурационного файла config.yaml
3
+ """
4
+
5
+ import yaml
6
+ from pathlib import Path
7
+
8
+
9
+ def load_config(path: str | Path = "config.yaml") -> dict:
10
+ cfg_path = Path(path).resolve()
11
+
12
+ if not cfg_path.exists():
13
+ raise FileNotFoundError(f"Файл конфигурации не найден: {cfg_path}")
14
+
15
+ with open(cfg_path, "r", encoding="utf-8") as f:
16
+ return yaml.safe_load(f)
src/ocr_utils.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ OCR-инструменты: pytesseract для титула и PaddleOCR для датчиков.
3
+ """
4
+
5
+ import cv2
6
+ import numpy as np
7
+ from paddleocr import PaddleOCR
8
+ import pytesseract
9
+
10
+
11
+ # --------------------------
12
+ # OCR титула (pytesseract)
13
+ # --------------------------
14
+ def ocr_title(img: np.ndarray) -> str:
15
+ """
16
+ OCR верхней области (титул мнемосхемы).
17
+ """
18
+ if img is None or img.size == 0:
19
+ return ""
20
+
21
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
22
+ gray = cv2.convertScaleAbs(gray, alpha=2.0, beta=-40)
23
+
24
+ binary = cv2.adaptiveThreshold(
25
+ gray, 255,
26
+ cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
27
+ cv2.THRESH_BINARY_INV,
28
+ 15, 9
29
+ )
30
+
31
+ text = pytesseract.image_to_string(binary, lang="eng", config="--psm 7").strip()
32
+ return text
33
+
34
+
35
+ # --------------------------
36
+ # OCR датчиков (PaddleOCR)
37
+ # --------------------------
38
+ paddle_ocr = PaddleOCR(
39
+ lang="en",
40
+ use_textline_orientation=True
41
+ )
42
+
43
+
44
+ def ocr_sensors(rois: list[np.ndarray]) -> list[dict]:
45
+ """
46
+ OCR областей сенсоров через PaddleOCR.predict().
47
+ Формат вывода:
48
+ [{"text": str, "score": float}, ...]
49
+ """
50
+ results = []
51
+
52
+ if not rois:
53
+ return []
54
+
55
+ try:
56
+ ocr_results = paddle_ocr.predict(rois)
57
+ except Exception as e:
58
+ print(f"⚠ Ошибка OCR.predict: {e}")
59
+ return [{"text": "?", "score": 0.0} for _ in rois]
60
+
61
+ for out in ocr_results:
62
+ text = out.get("rec_texts", ["?"])[0]
63
+ score = out.get("rec_scores", [0.0])[0]
64
+ results.append({"text": text, "score": float(score)})
65
+
66
+ return results
67
+
src/pipeline.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Главный pipeline проекта
3
+ """
4
+
5
+ import cv2
6
+ import numpy as np
7
+ import pandas as pd
8
+ from pathlib import Path
9
+ from docx import Document
10
+
11
+ from src.config_loader import load_config
12
+ from src.ocr_utils import ocr_title, ocr_sensors
13
+
14
+
15
+ # ---------------------------------------------------------
16
+ # Итератор входных данных (СЦЕНАРИЙ A)
17
+ # ---------------------------------------------------------
18
+ def iter_input_images(input_dir: Path):
19
+ """
20
+ Универсальный вход:
21
+ - читаем ВСЕ png/jpg/bmp
22
+ - читаем ВСЕ изображения внутри DOCX
23
+ """
24
+ exts = {".png", ".jpg", ".jpeg", ".bmp"}
25
+
26
+ image_files = []
27
+ docx_files = []
28
+
29
+ for p in input_dir.rglob("*"):
30
+ if not p.is_file():
31
+ continue
32
+
33
+ suf = p.suffix.lower()
34
+ if suf in exts:
35
+ image_files.append(p)
36
+ elif suf == ".docx":
37
+ docx_files.append(p)
38
+
39
+ # ---------- PNG/JPG ----------
40
+ for path in sorted(image_files):
41
+ img = cv2.imread(str(path), cv2.IMREAD_COLOR)
42
+ if img is not None:
43
+ yield path.stem, img
44
+
45
+ # ---------- DOCX ----------
46
+ for docx_path in sorted(docx_files):
47
+ doc = Document(docx_path)
48
+
49
+ idx = 0
50
+ for rel in doc.part._rels.values():
51
+ if "image" not in rel.target_ref:
52
+ continue
53
+
54
+ idx += 1
55
+ blob = rel.target_part.blob
56
+ arr = np.frombuffer(blob, np.uint8)
57
+ img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
58
+
59
+ if img is not None:
60
+ yield f"{docx_path.stem}_img{idx}", img
61
+
62
+ # Если не нашли ничего
63
+ if not image_files and not docx_files:
64
+ print("⚠ Папка не содержит ни изображений, ни DOCX.")
65
+
66
+
67
+
68
+ # ---------------------------------------------------------
69
+ # Извлечение текста титула + датчиков
70
+ # ---------------------------------------------------------
71
+ def extract_text(img: np.ndarray, color_ranges: dict) -> tuple[str, list[dict]]:
72
+ """
73
+ 1. Обрезаем титул (верхняя область)
74
+ 2. OCR титула
75
+ 3. Извлекаем сенсоры по цветовым маскам
76
+ 4. OCR сенсоров
77
+ """
78
+
79
+ # ---------- 1. титул ----------
80
+ h, w = img.shape[:2]
81
+ title_roi = img[:45, :int(w / 2.4)]
82
+ title_text = ocr_title(title_roi)
83
+
84
+ # ---------- 2. сенсоры ----------
85
+ hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
86
+
87
+ mask = None
88
+ for (lo, hi) in color_ranges.values():
89
+ lo_np = np.array(lo, dtype=np.uint8)
90
+ hi_np = np.array(hi, dtype=np.uint8)
91
+ cur = cv2.inRange(hsv, lo_np, hi_np)
92
+ mask = cur if mask is None else cv2.bitwise_or(mask, cur)
93
+
94
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
95
+
96
+ rois, positions = [], []
97
+ for cnt in contours:
98
+ x, y, ww, hh = cv2.boundingRect(cnt)
99
+ if ww < 90 or hh < 17:
100
+ continue
101
+
102
+ hh = min(hh, 17)
103
+ rois.append(img[y:y + hh, x:x + ww])
104
+ positions.append((x, y, ww, hh))
105
+
106
+ # ---------- 3. OCR сенсоров ----------
107
+ sensors = []
108
+ if rois:
109
+ ocr_results = ocr_sensors(rois)
110
+
111
+ for (x, y, ww, hh), r in zip(positions, ocr_results):
112
+ sensors.append({
113
+ "text": r["text"],
114
+ "score": r["score"],
115
+ "x": x,
116
+ "y": y,
117
+ "w": ww,
118
+ "h": hh
119
+ })
120
+
121
+ return title_text, sensors
122
+
123
+
124
+ # ---------------------------------------------------------
125
+ # Основной pipeline → Excel
126
+ # ---------------------------------------------------------
127
+ def process_all_images_to_excel(cfg: dict):
128
+ input_dir = Path(cfg["paths"]["input"])
129
+ output_dir = Path(cfg["paths"]["output"])
130
+ excel_name = cfg["export"]["excel_filename"]
131
+
132
+ output_dir.mkdir(parents=True, exist_ok=True)
133
+ excel_path = output_dir / excel_name
134
+
135
+ # Цветовые диапазоны сенсоров
136
+ color_ranges = cfg["colors"]
137
+
138
+ results = []
139
+
140
+ for name, img in iter_input_images(input_dir):
141
+ title_text, sensors = extract_text(img, color_ranges)
142
+
143
+ for sen in sensors:
144
+ results.append({
145
+ "filename": name,
146
+ "title": title_text,
147
+ "sensor_name": sen["text"],
148
+ "score": sen["score"]
149
+ })
150
+
151
+ if not results:
152
+ print("⚠ Нет данных для записи.")
153
+ return
154
+
155
+ df = pd.DataFrame(results)
156
+ pd.DataFrame.to_excel(df, excel_path, index=False, engine="openpyxl")
157
+
158
+ print(f"✅ Готово! Excel сохранён: {excel_path.resolve()}")
159
+
160
+
161
+ # ---------------------------------------------------------
162
+ # Запуск
163
+ # ---------------------------------------------------------
164
+ if __name__ == "__main__":
165
+ cfg = load_config("config.yaml")
166
+ process_all_images_to_excel(cfg)
tests/__init__.py ADDED
File without changes
tests/conftest.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import os
3
+
4
+ ROOT_DIR = os.path.dirname(os.path.dirname(__file__))
5
+ SRC_DIR = os.path.join(ROOT_DIR, "src")
6
+
7
+ sys.path.insert(0, SRC_DIR)
8
+
tests/test_extract_text.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import cv2
3
+ import pytest
4
+ from unittest.mock import patch
5
+
6
+ from src.pipeline import extract_text # поправь название модуля
7
+
8
+
9
+ # ---------------------------------------------------------
10
+ # ВСПОМОГАТЕЛЬНАЯ ФУНКЦИЯ: создаём тестовую картинку
11
+ # ---------------------------------------------------------
12
+ def make_test_image():
13
+ """
14
+ Создаёт искусственное изображение 200×300 + цветовой диапазон,
15
+ чтобы extract_text мог найти один "сенсор".
16
+ """
17
+ img = np.zeros((200, 300, 3), dtype=np.uint8)
18
+
19
+ # Рисуем титул (верхний левый угол) — просто белую полосу
20
+ img[0:45, 0:120] = (255, 255, 255)
21
+
22
+ # Рисуем сенсор в центре картинки
23
+ cv2.rectangle(img, (100, 80), (200, 97), (0, 255, 0), -1)
24
+
25
+ return img
26
+
27
+
28
+ # ---------------------------------------------------------
29
+ # ГЛАВНЫЙ ТЕСТ extract_text
30
+ # ---------------------------------------------------------
31
+ @patch("src.pipeline.ocr_title")
32
+ @patch("src.pipeline.ocr_sensors")
33
+ def test_extract_text(mock_ocr_sensors, mock_ocr_title):
34
+ img = make_test_image()
35
+
36
+ # Цветовой диапазон, по которому найдётся наш зелёный прямоугольник
37
+ color_ranges = {
38
+ "green": (
39
+ [50, 80, 80], # нижняя граница HSV
40
+ [80, 255, 255] # верхняя граница HSV
41
+ )
42
+ }
43
+
44
+ # --- подменяем OCR ---
45
+ mock_ocr_title.return_value = "TITLE_OK"
46
+
47
+ mock_ocr_sensors.return_value = [
48
+ {"text": "123", "score": 0.95},
49
+ ]
50
+
51
+ # --- вызываем тестируемую функцию ---
52
+ title, sensors = extract_text(img, color_ranges)
53
+
54
+ # --- ПРОВЕРКИ ---
55
+
56
+ # титул
57
+ assert title == "TITLE_OK"
58
+
59
+ # сенсоры
60
+ assert isinstance(sensors, list)
61
+ assert len(sensors) == 1
62
+
63
+ s = sensors[0]
64
+
65
+ assert s["text"] == "123"
66
+ assert s["score"] == 0.95
67
+
68
+ # координаты должны быть внутри изображения
69
+ assert 0 <= s["x"] <= img.shape[1]
70
+ assert 0 <= s["y"] <= img.shape[0]
71
+ assert s["w"] > 0
72
+ assert s["h"] > 0
tests/test_iter_input_images.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+ from unittest.mock import patch, MagicMock
4
+ from pathlib import Path
5
+
6
+ from src.pipeline import iter_input_images # замените под путь вашего файла
7
+
8
+ @patch("src.pipeline.cv2.imread")
9
+ def test_iter_input_images_with_images(mock_imread, tmp_path):
10
+ # Создаем временные файлы
11
+ img1 = tmp_path / "a.png"
12
+ img2 = tmp_path / "b.jpg"
13
+ img1.write_bytes(b"fake")
14
+ img2.write_bytes(b"fake")
15
+
16
+ # Мокаем результат чтения изображений
17
+ dummy_img = np.zeros((10, 10, 3), dtype=np.uint8)
18
+ mock_imread.return_value = dummy_img
19
+
20
+ results = list(iter_input_images(tmp_path))
21
+
22
+ assert len(results) == 2
23
+ assert results[0][0] == "a" # stem
24
+ assert isinstance(results[0][1], np.ndarray)
25
+
26
+ assert results[1][0] == "b"
27
+ assert isinstance(results[1][1], np.ndarray)
28
+
29
+ assert mock_imread.call_count == 2
30
+
31
+ @patch("src.pipeline.cv2.imdecode")
32
+ @patch("src.pipeline.Document")
33
+ def test_iter_input_images_with_docx(mock_Document, mock_imdecode, tmp_path):
34
+ docx_file = tmp_path / "test.docx"
35
+ docx_file.write_bytes(b"fake")
36
+
37
+ # Мокаем Document
38
+ mock_doc = MagicMock()
39
+ mock_Document.return_value = mock_doc
40
+
41
+ # Имитация doc.part._rels
42
+ mock_rel = MagicMock()
43
+ mock_rel.target_ref = "/media/image1.png"
44
+ mock_rel.target_part.blob = b"FAKE_IMAGE_DATA"
45
+
46
+ mock_doc.part._rels = {"r1": mock_rel}
47
+
48
+ # cv2.imdecode → возвращает искусственное изображение
49
+ dummy_img = np.ones((5, 5, 3), dtype=np.uint8)
50
+ mock_imdecode.return_value = dummy_img
51
+
52
+ results = list(iter_input_images(tmp_path))
53
+
54
+ assert len(results) == 1
55
+ name, img = results[0]
56
+
57
+ assert name == "test_img1"
58
+ assert isinstance(img, np.ndarray)
59
+ assert img.shape == (5, 5, 3)
60
+
61
+ mock_Document.assert_called_once()
62
+ mock_imdecode.assert_called_once()
63
+
64
+ def test_iter_input_images_empty_folder(tmp_path, capsys):
65
+ results = list(iter_input_images(tmp_path))
66
+
67
+ # Функция ничего не вернёт
68
+ assert results == []
69
+
70
+ # Проверяем, что напечатано предупреждение
71
+ captured = capsys.readouterr()
72
+ assert "не содержит ни изображений" in captured.out.lower()
tests/test_ocr_utils.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pytest
3
+ from unittest.mock import patch
4
+
5
+ from src.ocr_utils import ocr_title, ocr_sensors
6
+
7
+ @patch("src.ocr_utils.pytesseract.image_to_string")
8
+ def test_ocr_title_basic(mock_tesseract):
9
+ # Подделываем OCR-результат
10
+ mock_tesseract.return_value = "TEST_TITLE"
11
+
12
+ # Синтетическое изображение
13
+ img = np.zeros((50, 200, 3), dtype=np.uint8)
14
+
15
+ text = ocr_title(img)
16
+
17
+ assert text == "TEST_TITLE"
18
+ mock_tesseract.assert_called_once()
19
+
20
+ @patch("src.ocr_utils.paddle_ocr.predict")
21
+ def test_ocr_sensors_basic(mock_predict):
22
+ # Подменяем результат работы paddleocr.predict
23
+ mock_predict.return_value = [
24
+ {"rec_texts": ["123"], "rec_scores": [0.98]}
25
+ ]
26
+
27
+ # Одна синтетическая ROI
28
+ img = np.zeros((20, 60, 3), dtype=np.uint8)
29
+
30
+ out = ocr_sensors([img])
31
+
32
+ assert isinstance(out, list)
33
+ assert len(out) == 1
34
+ assert out[0]["text"] == "123"
35
+ assert out[0]["score"] == 0.98
36
+ mock_predict.assert_called_once()
37
+
38
+ @patch("src.ocr_utils.paddle_ocr.predict")
39
+ def test_ocr_sensors_empty_roi(mock_predict):
40
+ # paddleOCR должен корректно отработать
41
+ mock_predict.return_value = [
42
+ {"rec_texts": [""], "rec_scores": [0]}
43
+ ]
44
+
45
+ img = np.zeros((10, 10, 3), dtype=np.uint8)
46
+
47
+ out = ocr_sensors([img])
48
+
49
+ assert out[0]["text"] == ""
50
+ assert out[0]["score"] == 0
tests/test_process_all_images.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from unittest.mock import patch, MagicMock
3
+ from pathlib import Path
4
+ import pandas as pd
5
+ import numpy as np
6
+
7
+ from src.pipeline import process_all_images_to_excel # заменить на реальный путь
8
+
9
+
10
+ @patch("src.pipeline.extract_text")
11
+ @patch("src.pipeline.iter_input_images")
12
+ @patch("src.pipeline.pd.DataFrame.to_excel")
13
+ def test_process_all_images_to_excel_ok(mock_to_excel, mock_iter, mock_extract, tmp_path):
14
+ # Подготовим конфиг
15
+ cfg = {
16
+ "paths": {
17
+ "input": str(tmp_path / "input"),
18
+ "output": str(tmp_path / "output")
19
+ },
20
+ "export": {
21
+ "excel_filename": "out.xlsx"
22
+ },
23
+ "colors": {"dummy_color": ([0,0,0],[255,255,255])}
24
+ }
25
+
26
+ # Создаём папки
27
+ (tmp_path / "input").mkdir()
28
+ (tmp_path / "output").mkdir()
29
+
30
+ # --- Мокаем iter_input_images ---
31
+ mock_iter.return_value = [
32
+ ("img1", np.zeros((10,10,3))),
33
+ ("img2", np.zeros((10,10,3))),
34
+ ]
35
+
36
+ # --- Мокаем extract_text ---
37
+ mock_extract.side_effect = [
38
+ ("TITLE1", [{"text": "S1", "score": 0.9}]),
39
+ ("TITLE2", [{"text": "S2", "score": 0.95}]),
40
+ ]
41
+
42
+ # --- Выполняем функцию ---
43
+ process_all_images_to_excel(cfg)
44
+
45
+ # --- ПРОВЕРКИ ---
46
+
47
+ # extract_text должен быть вызван 2 раза
48
+ assert mock_extract.call_count == 2
49
+
50
+ # Excel действительно формировался
51
+ mock_to_excel.assert_called_once()
52
+
53
+ # Проверяем, что путь правильный
54
+ called_path, called_kwargs = mock_to_excel.call_args[0][0], mock_to_excel.call_args[1]
55
+ assert called_kwargs["index"] is False
56
+ assert "openpyxl" in called_kwargs["engine"]
57
+
58
+ # Проверяем, что DataFrame содержит нужные строки
59
+ df_created: pd.DataFrame = mock_to_excel.call_args[0][0] # аргумент-DataFrame
60
+
61
+ assert len(df_created) == 2
62
+ assert set(df_created.columns) == {"filename", "title", "sensor_name", "score"}
63
+
64
+ assert df_created.iloc[0]["filename"] == "img1"
65
+ assert df_created.iloc[0]["title"] == "TITLE1"
66
+ assert df_created.iloc[0]["sensor_name"] == "S1"
67
+ assert df_created.iloc[0]["score"] == 0.9
68
+
69
+
70
+ @patch("src.pipeline.iter_input_images")
71
+ @patch("src.pipeline.pd.DataFrame.to_excel")
72
+ def test_process_all_images_no_data(mock_to_excel, mock_iter, tmp_path, capsys):
73
+ cfg = {
74
+ "paths": {
75
+ "input": str(tmp_path / "input"),
76
+ "output": str(tmp_path / "output")
77
+ },
78
+ "export": {"excel_filename": "out.xlsx"},
79
+ "colors": {}
80
+ }
81
+
82
+ (tmp_path / "input").mkdir()
83
+ (tmp_path / "output").mkdir()
84
+
85
+ # iter_input_images вернёт пустой список
86
+ mock_iter.return_value = []
87
+
88
+ process_all_images_to_excel(cfg)
89
+
90
+ # Excel не должен создаваться
91
+ mock_to_excel.assert_not_called()
92
+
93
+ # Проверяем вывод
94
+ captured = capsys.readouterr()
95
+ assert "нет данных" in captured.out.lower()