Hayk Arutyunyan commited on
Commit ·
7b0cda0
1
Parent(s): 39da704
Update project structure, add pipeline and tests
Browse files- configs/config.yaml +16 -6
- notebooks/mnemo_ocr_final_v03_color_v01.ipynb +0 -0
- pytest.ini +2 -0
- src/__init__.py +0 -0
- src/config_loader.py +16 -0
- src/ocr_utils.py +67 -0
- src/pipeline.py +166 -0
- tests/__init__.py +0 -0
- tests/conftest.py +8 -0
- tests/test_extract_text.py +72 -0
- tests/test_iter_input_images.py +72 -0
- tests/test_ocr_utils.py +50 -0
- tests/test_process_all_images.py +95 -0
configs/config.yaml
CHANGED
|
@@ -1,7 +1,17 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
export:
|
| 7 |
-
excel_filename:
|
|
|
|
|
|
| 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()
|