Загрузка данных


#!/usr/bin/env python3
"""
=============================================================================
  cable_ocr.py  —  Быстрое распознавание текста на кабелях (RU + EN)
=============================================================================

ТРЕБОВАНИЯ (установить один раз):
    pip install opencv-python pytesseract numpy Pillow

    Tesseract OCR + языковые пакеты:
      Windows : https://github.com/UB-Mannheim/tesseract/wiki
                При установке отметьте "Russian" в списке языков!
                Укажите путь ниже в TESSERACT_CMD.
      Linux   : sudo apt install tesseract-ocr tesseract-ocr-rus tesseract-ocr-eng
      macOS   : brew install tesseract tesseract-lang

ЗАПУСК:
    python cable_ocr.py

КЛАВИШИ в окне:
    Q / ESC  — выход
    S        — сохранить текущий кадр
    D        — debug: показать бинаризованную маску препроцессинга
    +/-      — порог уверенности OCR ±5%
    R        — сбросить все текущие результаты
=============================================================================
"""

import cv2
import numpy as np
import pytesseract
import threading
import queue
import time
import sys
import os
from collections import deque
from datetime import datetime
from PIL import Image, ImageDraw, ImageFont

# ─────────────────────────────────────────────────────────────────────────────
#  НАСТРОЙКИ
# ─────────────────────────────────────────────────────────────────────────────

# Путь к tesseract.exe (только Windows; Linux/macOS — оставьте пустым '')
TESSERACT_CMD = r'C:\Program Files\Tesseract-OCR\tesseract.exe'

# Номер USB-камеры (0, 1, 2...)
CAMERA_INDEX = 0

# Разрешение захвата с камеры
CAPTURE_WIDTH  = 1280
CAPTURE_HEIGHT = 720

# Разрешение окна отображения
DISPLAY_WIDTH  = 1280
DISPLAY_HEIGHT = 720

# Интервал запуска OCR в миллисекундах (не блокирует видеопоток!)
# ~80 мс = 12 распознаваний/сек. На слабом CPU ставьте 150–250.
OCR_INTERVAL_MS = 80

# Минимальная уверенность Tesseract (0–100) для отображения результата
MIN_CONFIDENCE = 30

# Масштаб апскейла ROI перед Tesseract. Больше = точнее, но медленнее.
# 3.0 оптимально для маленьких квадратных символов. На слабом CPU — 2.0.
UPSCALE_FACTOR = 3.0

# Полосы сканирования (доли высоты кадра: от, до).
# Сузьте под реальное положение кабелей — OCR ускорится кратно!
# Например, если кабели идут через центр: SCAN_BANDS = [(0.35, 0.65)]
SCAN_BANDS = [
    (0.10, 0.90),
]

# Языки OCR. Оба активны по умолчанию.
# Если кабели только с латинскими символами — замените на 'eng' (быстрее).
# Если только русские — замените на 'rus'.
OCR_LANG = 'eng+rus'

# Tesseract config:
# psm 11 = sparse text (ищет текст в любом месте, без строчной структуры)
# psm 6  = одиночный блок текста (если символы всегда горизонтальные)
# psm 3  = auto (работает медленнее)
TESS_CONFIG = f'--oem 1 --psm 11 -l {OCR_LANG}'

# Размер шрифта для подписей распознанного текста (пиксели)
FONT_SIZE = 18

# Время жизни результата OCR на экране (секунды)
RESULT_TTL = 1.5

# Цвета (BGR для OpenCV; RGB для Pillow — конвертируем автоматически)
BOX_COLOR  = (0, 255, 0)      # зелёный — рамка вокруг текста
TEXT_COLOR = (0, 255, 255)    # жёлтый — подпись над рамкой
HUD_COLOR  = (200, 200, 200)  # серый — HUD

# ─────────────────────────────────────────────────────────────────────────────
#  ПОИСК ШРИФТА С ПОДДЕРЖКОЙ КИРИЛЛИЦЫ
# ─────────────────────────────────────────────────────────────────────────────

def find_cyrillic_font() -> str | None:
    """
    Ищет TTF-шрифт с поддержкой кириллицы в системных путях.
    Возвращает путь к файлу или None.
    """
    candidates = []

    if sys.platform == 'win32':
        win_fonts = os.path.join(os.environ.get('WINDIR', r'C:\Windows'), 'Fonts')
        candidates = [
            os.path.join(win_fonts, 'arial.ttf'),
            os.path.join(win_fonts, 'arialbd.ttf'),
            os.path.join(win_fonts, 'calibri.ttf'),
            os.path.join(win_fonts, 'verdana.ttf'),
            os.path.join(win_fonts, 'tahoma.ttf'),
            os.path.join(win_fonts, 'times.ttf'),
            os.path.join(win_fonts, 'consola.ttf'),
            os.path.join(win_fonts, 'cour.ttf'),
        ]
    elif sys.platform == 'darwin':
        candidates = [
            '/Library/Fonts/Arial.ttf',
            '/Library/Fonts/Arial Unicode.ttf',
            '/System/Library/Fonts/Helvetica.ttc',
            '/Library/Fonts/Microsoft/Arial.ttf',
            '/usr/local/share/fonts/dejavu/DejaVuSans.ttf',
        ]
    else:  # Linux
        candidates = [
            '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
            '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf',
            '/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf',
            '/usr/share/fonts/truetype/ubuntu/Ubuntu-R.ttf',
            '/usr/share/fonts/truetype/freefont/FreeSans.ttf',
            '/usr/share/fonts/TTF/DejaVuSans.ttf',
            '/usr/share/fonts/dejavu/DejaVuSans.ttf',
        ]

    for path in candidates:
        if os.path.isfile(path):
            return path

    # Последняя попытка — поиск через fc-list (Linux)
    try:
        import subprocess
        result = subprocess.run(
            ['fc-list', ':lang=ru', '--format=%{file}\n'],
            capture_output=True, text=True, timeout=3
        )
        for line in result.stdout.strip().splitlines():
            line = line.strip()
            if line.lower().endswith('.ttf') and os.path.isfile(line):
                return line
    except Exception:
        pass

    return None


# ─────────────────────────────────────────────────────────────────────────────
#  РЕНДЕР ТЕКСТА ЧЕРЕЗ PIL (поддержка кириллицы + ASCII)
# ─────────────────────────────────────────────────────────────────────────────

class TextRenderer:
    """
    Рендерит Unicode-текст (включая кириллицу) поверх BGR-кадра OpenCV.
    Использует Pillow + TTF-шрифт. Шрифт загружается один раз.
    """

    def __init__(self, font_path: str | None, size: int = FONT_SIZE):
        self._size = size
        self._font_path = font_path
        self._font_cache: dict[int, ImageFont.FreeTypeFont | ImageFont.ImageFont] = {}
        self._load_font(size)
        if font_path:
            print(f"[FONT] Используется шрифт: {font_path}")
        else:
            print("[FONT] TTF-шрифт не найден, используется встроенный (кириллица может не отображаться)")

    def _load_font(self, size: int):
        if size in self._font_cache:
            return
        if self._font_path:
            try:
                self._font_cache[size] = ImageFont.truetype(self._font_path, size)
                return
            except Exception as e:
                print(f"[FONT] Ошибка загрузки {self._font_path}: {e}")
        # Fallback: встроенный шрифт Pillow (только ASCII)
        self._font_cache[size] = ImageFont.load_default()

    def get_font(self, size: int | None = None):
        s = size or self._size
        if s not in self._font_cache:
            self._load_font(s)
        return self._font_cache[s]

    def text_size(self, text: str, size: int | None = None) -> tuple[int, int]:
        """Возвращает (ширину, высоту) строки в пикселях."""
        font = self.get_font(size)
        try:
            bbox = font.getbbox(text)           # Pillow >= 9.2
            return bbox[2] - bbox[0], bbox[3] - bbox[1]
        except AttributeError:
            return font.getsize(text)           # Pillow < 9.2

    def put_text(self,
                 frame_bgr: np.ndarray,
                 text: str,
                 pos: tuple[int, int],
                 color_bgr: tuple[int, int, int] = TEXT_COLOR,
                 size: int | None = None,
                 bg_color_bgr: tuple[int, int, int] | None = (0, 0, 0),
                 bg_padding: int = 3) -> np.ndarray:
        """
        Рисует text на frame_bgr в позиции pos (x, y = левый верхний угол текста).
        Возвращает тот же frame_bgr (in-place через PIL конвертацию).
        """
        font = self.get_font(size)
        color_rgb = (color_bgr[2], color_bgr[1], color_bgr[0])

        # Размер текста для фона
        tw, th = self.text_size(text, size)
        x, y = pos

        # Нарисовать фон
        if bg_color_bgr is not None:
            bg_rgb = (bg_color_bgr[2], bg_color_bgr[1], bg_color_bgr[0])
            # Рисуем фон напрямую через OpenCV (быстро, нет кириллицы — не важно)
            x1 = max(0, x - bg_padding)
            y1 = max(0, y - bg_padding)
            x2 = min(frame_bgr.shape[1], x + tw + bg_padding)
            y2 = min(frame_bgr.shape[0], y + th + bg_padding)
            cv2.rectangle(frame_bgr, (x1, y1), (x2, y2), bg_color_bgr, cv2.FILLED)

        # Конвертировать ROI в PIL и нарисовать текст
        # Чтобы не гонять весь кадр в PIL — вырезаем только нужный регион
        pad = bg_padding
        rx1 = max(0, x - pad)
        ry1 = max(0, y - pad)
        rx2 = min(frame_bgr.shape[1], x + tw + pad + 2)
        ry2 = min(frame_bgr.shape[0], y + th + pad + 2)

        if rx2 <= rx1 or ry2 <= ry1:
            return frame_bgr

        roi = frame_bgr[ry1:ry2, rx1:rx2]
        roi_rgb = cv2.cvtColor(roi, cv2.COLOR_BGR2RGB)
        pil_roi = Image.fromarray(roi_rgb)
        draw = ImageDraw.Draw(pil_roi)
        draw.text((x - rx1, y - ry1), text, font=font, fill=color_rgb)
        roi_bgr_new = cv2.cvtColor(np.array(pil_roi), cv2.COLOR_RGB2BGR)
        frame_bgr[ry1:ry2, rx1:rx2] = roi_bgr_new

        return frame_bgr


# ─────────────────────────────────────────────────────────────────────────────
#  ИНИЦИАЛИЗАЦИЯ
# ─────────────────────────────────────────────────────────────────────────────

if os.name == 'nt' and TESSERACT_CMD:
    pytesseract.pytesseract.tesseract_cmd = TESSERACT_CMD

# Проверка Tesseract
try:
    ver = pytesseract.get_tesseract_version()
    print(f"[OK] Tesseract версия: {ver}")
except Exception as e:
    print(f"[ОШИБКА] Tesseract не найден: {e}")
    print("         Установите Tesseract и укажите путь в TESSERACT_CMD")
    sys.exit(1)

# Проверка наличия языковых пакетов
try:
    available_langs = pytesseract.get_languages()
    print(f"[OK] Языки Tesseract: {', '.join(available_langs)}")
    missing = [l for l in OCR_LANG.split('+') if l not in available_langs]
    if missing:
        print(f"[WARN] Языковые пакеты НЕ установлены: {missing}")
        print(f"       Linux: sudo apt install tesseract-ocr-{' tesseract-ocr-'.join(missing)}")
        print(f"       Windows: переустановите Tesseract, отметив нужные языки")
        # Откатиться к только eng если rus нет
        fallback_langs = [l for l in OCR_LANG.split('+') if l in available_langs]
        if fallback_langs:
            OCR_LANG = '+'.join(fallback_langs)
            TESS_CONFIG = f'--oem 1 --psm 11 -l {OCR_LANG}'
            print(f"[WARN] Переключился на: {OCR_LANG}")
        else:
            print("[ОШИБКА] Нет ни одного рабочего языкового пакета!")
            sys.exit(1)
except Exception:
    pass

# Шрифт
_font_path = find_cyrillic_font()
renderer = TextRenderer(_font_path, size=FONT_SIZE)


# ─────────────────────────────────────────────────────────────────────────────
#  ПРЕПРОЦЕССИНГ КАДРА
# ─────────────────────────────────────────────────────────────────────────────

def preprocess_for_ocr(roi_bgr: np.ndarray, upscale: float = UPSCALE_FACTOR) -> np.ndarray:
    """
    Полный препроцессинг ROI для OCR мелких символов на кабелях.
    Шаги: апскейл → grayscale → CLAHE → деноиз → шарпен → адаптивный порог → закрытие.
    """
    h, w = roi_bgr.shape[:2]
    roi = cv2.resize(roi_bgr, (int(w * upscale), int(h * upscale)),
                     interpolation=cv2.INTER_CUBIC)

    gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)

    # CLAHE — выравнивание контраста локально (борется с тенями и бликами на кабеле)
    clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
    gray = clahe.apply(gray)

    # Деноиз — убираем шум не размывая контуры символов
    denoised = cv2.fastNlMeansDenoising(gray, h=10, templateWindowSize=7, searchWindowSize=21)

    # Шарпен — восстанавливаем резкость после motion blur
    kernel_sharp = np.array([[-1, -1, -1],
                              [-1,  9, -1],
                              [-1, -1, -1]], dtype=np.float32)
    sharpened = cv2.filter2D(denoised, -1, kernel_sharp)
    sharpened = np.clip(sharpened, 0, 255).astype(np.uint8)

    # Адаптивная бинаризация — работает при неравномерном освещении кабеля
    binary = cv2.adaptiveThreshold(
        sharpened, 255,
        cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
        cv2.THRESH_BINARY,
        blockSize=15,
        C=8
    )

    # Морфологическое закрытие — соединяем разорванные части символов
    kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (2, 2))
    binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel)

    return binary


def preprocess_fast(roi_bgr: np.ndarray, upscale: float = UPSCALE_FACTOR) -> np.ndarray:
    """
    Быстрый препроцессинг (без деноиза) для слабого CPU.
    Замените preprocess_for_ocr на эту функцию в run_ocr_on_frame если лагает.
    """
    h, w = roi_bgr.shape[:2]
    roi = cv2.resize(roi_bgr, (int(w * upscale), int(h * upscale)),
                     interpolation=cv2.INTER_LINEAR)
    gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
    clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8))
    gray = clahe.apply(gray)
    _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    return binary


# ─────────────────────────────────────────────────────────────────────────────
#  OCR ДВИЖОК
# ─────────────────────────────────────────────────────────────────────────────

class OcrResult:
    """Один распознанный текстовый блок с координатами и временем жизни."""
    __slots__ = ('text', 'conf', 'x', 'y', 'w', 'h', 'timestamp')

    def __init__(self, text: str, conf: int, x: int, y: int, w: int, h: int):
        self.text      = text.strip()
        self.conf      = conf
        self.x, self.y, self.w, self.h = x, y, w, h
        self.timestamp = time.time()

    def is_alive(self) -> bool:
        return (time.time() - self.timestamp) < RESULT_TTL


def run_ocr_on_frame(frame_bgr: np.ndarray, min_conf: int = MIN_CONFIDENCE) -> list[OcrResult]:
    """
    Запускает OCR по всем полосам SCAN_BANDS.
    Возвращает список OcrResult в координатах исходного кадра.
    """
    fh, fw = frame_bgr.shape[:2]
    results = []

    for (band_top_frac, band_bot_frac) in SCAN_BANDS:
        y0 = int(fh * band_top_frac)
        y1 = int(fh * band_bot_frac)
        if y1 <= y0:
            continue
        roi_bgr = frame_bgr[y0:y1, 0:fw]

        binary = preprocess_for_ocr(roi_bgr)
        # ↑ Замените на preprocess_fast(roi_bgr) если CPU слабый

        try:
            data = pytesseract.image_to_data(
                binary,
                config=TESS_CONFIG,
                output_type=pytesseract.Output.DICT,
                lang=OCR_LANG,
            )
        except Exception as e:
            print(f"[OCR error] {e}")
            continue

        scale = 1.0 / UPSCALE_FACTOR
        n = len(data['text'])

        for i in range(n):
            raw = data['text'][i]
            if not raw or not raw.strip():
                continue

            try:
                conf = int(data['conf'][i])
            except (ValueError, TypeError):
                conf = -1

            if conf < min_conf:
                continue

            text = raw.strip()
            if len(text) < 1:
                continue

            bx = int(data['left'][i]   * scale)
            by = int(data['top'][i]    * scale) + y0
            bw = int(data['width'][i]  * scale)
            bh = int(data['height'][i] * scale)

            if bw < 5 or bh < 5:
                continue

            results.append(OcrResult(text, conf, bx, by, bw, bh))

    return results


# ─────────────────────────────────────────────────────────────────────────────
#  ПОТОК OCR (фоновый — не блокирует видеопоток)
# ─────────────────────────────────────────────────────────────────────────────

class OcrThread(threading.Thread):
    def __init__(self, frame_q: queue.Queue, result_q: queue.Queue):
        super().__init__(daemon=True, name='OCR-Worker')
        self.frame_q   = frame_q
        self.result_q  = result_q
        self.running   = True
        self.min_conf  = MIN_CONFIDENCE
        self._timings  = deque(maxlen=20)

    def run(self):
        print('[OCR] Поток запущен')
        while self.running:
            try:
                frame = self.frame_q.get(timeout=0.5)
            except queue.Empty:
                continue

            t0 = time.perf_counter()
            try:
                results = run_ocr_on_frame(frame, self.min_conf)
            except Exception as e:
                print(f'[OCR] Исключение: {e}')
                results = []
            self._timings.append(time.perf_counter() - t0)

            # Не копить старые результаты
            while not self.result_q.empty():
                try:
                    self.result_q.get_nowait()
                except queue.Empty:
                    break
            self.result_q.put(results)

    def stop(self):
        self.running = False

    @property
    def avg_ocr_ms(self) -> float:
        return (sum(self._timings) / len(self._timings) * 1000) if self._timings else 0.0


# ─────────────────────────────────────────────────────────────────────────────
#  ОТРИСОВКА
# ─────────────────────────────────────────────────────────────────────────────

def draw_results(frame: np.ndarray, results: list[OcrResult]) -> None:
    """
    Рисует рамки и подписи для всех живых OCR-результатов.
    Текст рендерится через Pillow — кириллица отображается корректно.
    """
    for r in results:
        if not r.is_alive():
            continue

        x, y, w, h = r.x, r.y, r.w, r.h

        # Рамка вокруг текста (уголки чуть жирнее для заметности)
        cv2.rectangle(frame, (x, y), (x + w, y + h), BOX_COLOR, 2)
        # Небольшие акцентные уголки
        corner = 8
        thick  = 3
        cv2.line(frame, (x, y), (x + corner, y), BOX_COLOR, thick)
        cv2.line(frame, (x, y), (x, y + corner), BOX_COLOR, thick)
        cv2.line(frame, (x + w, y), (x + w - corner, y), BOX_COLOR, thick)
        cv2.line(frame, (x + w, y), (x + w, y + corner), BOX_COLOR, thick)
        cv2.line(frame, (x, y + h), (x + corner, y + h), BOX_COLOR, thick)
        cv2.line(frame, (x, y + h), (x, y + h - corner), BOX_COLOR, thick)
        cv2.line(frame, (x + w, y + h), (x + w - corner, y + h), BOX_COLOR, thick)
        cv2.line(frame, (x + w, y + h), (x + w, y + h - corner), BOX_COLOR, thick)

        # Подпись с распознанным текстом + уверенностью
        label = f"{r.text}  [{r.conf}%]"
        tw, th = renderer.text_size(label)

        # Позиция подписи: над рамкой если есть место, иначе — внутри
        label_y = y - th - 6 if y > (th + 10) else y + 4

        # Рендер через PIL (поддерживает кириллицу)
        renderer.put_text(
            frame, label,
            pos=(x, label_y),
            color_bgr=TEXT_COLOR,
            bg_color_bgr=(0, 0, 0),
            bg_padding=3
        )


def draw_scan_bands(frame: np.ndarray) -> None:
    """Рисует линии зон сканирования."""
    fh, fw = frame.shape[:2]
    for (top_f, bot_f) in SCAN_BANDS:
        y0 = int(fh * top_f)
        y1 = int(fh * bot_f)
        cv2.line(frame, (0, y0), (fw, y0), (60, 60, 220), 1)
        cv2.line(frame, (0, y1), (fw, y1), (60, 60, 220), 1)


def draw_hud(frame: np.ndarray,
             cam_fps: float,
             ocr_ms: float,
             n_results: int,
             min_conf: int,
             lang: str,
             debug: bool) -> None:
    """Рисует информационную панель (HUD) — только ASCII, cv2.putText достаточно."""
    lines = [
        f"CAM: {cam_fps:5.1f} fps",
        f"OCR: {ocr_ms:5.0f} ms",
        f"Lang: {lang}",
        f"Found: {n_results}",
        f"Conf >= {min_conf}%  [+/-]",
        f"[D]ebug {'ON ' if debug else 'OFF'}  [S]ave  [R]eset  [Q]uit",
    ]

    x0, y0  = 10, 10
    lh      = 20
    panel_h = lh * len(lines) + 12
    panel_w = 280

    # Полупрозрачный фон через blending
    overlay = frame.copy()
    cv2.rectangle(overlay, (x0 - 4, y0 - 4), (x0 + panel_w, y0 + panel_h), (10, 10, 10), cv2.FILLED)
    cv2.addWeighted(overlay, 0.65, frame, 0.35, 0, frame)
    cv2.rectangle(frame, (x0 - 4, y0 - 4), (x0 + panel_w, y0 + panel_h), (70, 70, 70), 1)

    for i, line in enumerate(lines):
        clr = (180, 180, 180) if i < len(lines) - 1 else (100, 100, 100)
        cv2.putText(frame, line,
                    (x0, y0 + lh * (i + 1)),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.48, clr, 1, cv2.LINE_AA)


# ─────────────────────────────────────────────────────────────────────────────
#  ГЛАВНЫЙ ЦИКЛ
# ─────────────────────────────────────────────────────────────────────────────

def main():
    print('=' * 60)
    print('  Cable OCR — распознавание текста на кабелях (RU + EN)')
    print('=' * 60)

    # ── Открыть камеру ───────────────────────────────────────────────────────
    backends = []
    if os.name == 'nt':
        backends = [cv2.CAP_DSHOW, cv2.CAP_MSMF, 0]
    else:
        backends = [cv2.CAP_V4L2, 0]

    cap = None
    for backend in backends:
        try:
            c = cv2.VideoCapture(CAMERA_INDEX, backend) if backend != 0 else cv2.VideoCapture(CAMERA_INDEX)
            if c.isOpened():
                cap = c
                break
            c.release()
        except Exception:
            pass

    if cap is None or not cap.isOpened():
        print(f'[ОШИБКА] Не удалось открыть камеру #{CAMERA_INDEX}')
        sys.exit(1)

    cap.set(cv2.CAP_PROP_FRAME_WIDTH,  CAPTURE_WIDTH)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, CAPTURE_HEIGHT)
    cap.set(cv2.CAP_PROP_FPS, 60)
    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)   # только свежие кадры
    cap.set(cv2.CAP_PROP_AUTOFOCUS, 0)    # выключить автофокус

    actual_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
    actual_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
    print(f'[CAM] Разрешение: {actual_w}×{actual_h}')

    # ── Очереди и поток OCR ──────────────────────────────────────────────────
    frame_q  = queue.Queue(maxsize=2)
    result_q = queue.Queue(maxsize=4)
    ocr      = OcrThread(frame_q, result_q)
    ocr.start()

    # ── Состояние ────────────────────────────────────────────────────────────
    ocr_results   : list[OcrResult] = []
    last_ocr_send : float           = 0.0
    cam_fps_buf   : deque[float]    = deque(maxlen=30)
    prev_time     : float           = time.perf_counter()
    debug_mode    : bool            = False
    min_conf      : int             = MIN_CONFIDENCE

    cv2.namedWindow('Cable OCR', cv2.WINDOW_NORMAL)
    cv2.resizeWindow('Cable OCR', DISPLAY_WIDTH, DISPLAY_HEIGHT)
    print('[INFO] Запуск. Нажмите Q для выхода.')

    try:
        while True:
            ret, frame = cap.read()
            if not ret:
                print('[WARN] Потерян кадр...')
                time.sleep(0.03)
                continue

            now      = time.perf_counter()
            dt       = now - prev_time
            prev_time = now
            cam_fps_buf.append(1.0 / max(dt, 1e-6))
            cam_fps = sum(cam_fps_buf) / len(cam_fps_buf)

            # ── Отправить кадр в OCR ─────────────────────────────────────────
            if (now - last_ocr_send) * 1000 >= OCR_INTERVAL_MS:
                if not frame_q.full():
                    frame_q.put_nowait(frame.copy())
                    last_ocr_send = now

            # ── Забрать результаты OCR ───────────────────────────────────────
            while not result_q.empty():
                try:
                    new_res = result_q.get_nowait()
                    alive   = [r for r in ocr_results if r.is_alive()]
                    seen    = {r.text for r in new_res}
                    merged  = [r for r in alive if r.text not in seen] + new_res
                    ocr_results = merged
                except queue.Empty:
                    break

            ocr_results = [r for r in ocr_results if r.is_alive()]

            # ── Отрисовка ────────────────────────────────────────────────────
            display = frame.copy()
            draw_scan_bands(display)
            draw_results(display, ocr_results)
            draw_hud(display, cam_fps, ocr.avg_ocr_ms,
                     len(ocr_results), min_conf, OCR_LANG, debug_mode)

            # DEBUG: бинаризованная маска ROI в правом углу
            if debug_mode and SCAN_BANDS:
                fh, fw = frame.shape[:2]
                top_f, bot_f = SCAN_BANDS[0]
                y0d, y1d = int(fh * top_f), int(fh * bot_f)
                roi_d = frame[y0d:y1d, 0:fw]
                if roi_d.size > 0:
                    mask     = preprocess_for_ocr(roi_d)
                    mask_bgr = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
                    dh, dw   = (y1d - y0d) // 2, fw // 2
                    small    = cv2.resize(mask_bgr, (dw, dh))
                    display[y0d: y0d + dh, fw - dw: fw] = small
                    cv2.putText(display, 'DEBUG: preprocessed',
                                (fw - dw + 6, y0d + 18),
                                cv2.FONT_HERSHEY_SIMPLEX, 0.48, (0, 200, 255), 1)

            cv2.imshow('Cable OCR', display)

            # ── Клавиши ──────────────────────────────────────────────────────
            key = cv2.waitKey(1) & 0xFF

            if key in (ord('q'), ord('Q'), 27):
                break

            elif key in (ord('s'), ord('S')):
                fname = f'cable_ocr_{datetime.now().strftime("%H%M%S_%f")}.jpg'
                cv2.imwrite(fname, display)
                print(f'[SAVE] {fname}')

            elif key in (ord('d'), ord('D')):
                debug_mode = not debug_mode
                print(f'[DEBUG] {"ON" if debug_mode else "OFF"}')

            elif key in (ord('r'), ord('R')):
                ocr_results.clear()
                print('[RESET] Результаты очищены')

            elif key in (ord('+'), ord('=')):
                min_conf = min(min_conf + 5, 95)
                ocr.min_conf = min_conf
                print(f'[CONF] Порог: {min_conf}%')

            elif key in (ord('-'), ord('_')):
                min_conf = max(min_conf - 5, 5)
                ocr.min_conf = min_conf
                print(f'[CONF] Порог: {min_conf}%')

    except KeyboardInterrupt:
        print('\n[INFO] Прерывание пользователем')

    finally:
        print('[INFO] Завершение...')
        ocr.stop()
        cap.release()
        cv2.destroyAllWindows()
        print('[INFO] Готово.')


if __name__ == '__main__':
    main()