import os
import sys
import cv2
import time
import json
import logging
import torch
import pytesseract
from pytesseract import Output
from datetime import datetime
from PIL import Image, ImageEnhance
import subprocess
from werkzeug.utils import secure_filename
from flask import Flask, request, render_template, jsonify, url_for, send_from_directory
import numpy as np
import uuid
import shutil
from paddleocr import PaddleOCR

# Укажите путь к tesseract.exe, если используете Windows и Tesseract
pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'

# --- Базовые пути и настройки ---
base_dir = os.path.dirname(os.path.abspath(__file__))
UPLOAD_FOLDER = os.path.join(base_dir, 'uploads')
os.makedirs(UPLOAD_FOLDER, exist_ok=True)

PROCESSED_IMAGES_FOLDER = 'processed_images'
PROCESSED_IMAGES_PATH = os.path.join(UPLOAD_FOLDER, PROCESSED_IMAGES_FOLDER)
os.makedirs(PROCESSED_IMAGES_PATH, exist_ok=True)

CROPPED_FIELDS_FOLDER = 'cropped_fields'
CROPPED_FIELDS_PATH = os.path.join(base_dir, CROPPED_FIELDS_FOLDER)
os.makedirs(CROPPED_FIELDS_PATH, exist_ok=True)

ANNOTATED_IMAGES_FOLDER = 'annotated_images'
ANNOTATED_IMAGES_PATH = os.path.join(base_dir, ANNOTATED_IMAGES_FOLDER)
os.makedirs(ANNOTATED_IMAGES_PATH, exist_ok=True)

ANNOTATED_TEXT_FOLDER = 'annotated_texts'
ANNOTATED_TEXT_PATH = os.path.join(base_dir, ANNOTATED_TEXT_FOLDER)
os.makedirs(ANNOTATED_TEXT_PATH, exist_ok=True)

COMMENTS_FOLDER = 'comments'
COMMENTS_PATH = os.path.join(base_dir, COMMENTS_FOLDER)
os.makedirs(COMMENTS_PATH, exist_ok=True)

ANNOTATIONS_FILE = os.path.join(base_dir, 'annotations.json')

# --- Логирование ---
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

# --- Пути к моделям ---
BASE_MODEL_PATH = os.path.join(base_dir, 'model_store')
PRODUCTION_MODEL_DIR = 'production'
PRODUCTION_MODEL_FILENAME = 'passport_field_detection_model.pt'
PRODUCTION_MODEL_DISPLAY_NAME = 'production_yolov5'

# --- Инициализация Flask ---
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER

# --- Кэш загруженных моделей YOLO ---
YOLO_MODELS = {}

# --- Попытка импорта кастомной PaddleOCR (если есть) ---
OCR_RECOGNIZER_CUSTOM = None
CUSTOM_OCR_AVAILABLE = False
try:
    # Предполагается, что у вас есть модуль 'passport_rec.module'
    sys.path.append(os.path.join(base_dir, 'ocr', 'passport_rec'))
    from module import OCRModule

    ocr_config_path = os.path.join(base_dir, 'ocr', 'passport_rec', 'config.json')
    OCR_RECOGNIZER_CUSTOM = OCRModule(config_path=ocr_config_path)
    CUSTOM_OCR_AVAILABLE = True
    logger.info("Кастомная OCR-модель PaddlePaddle успешно инициализирована.")
except Exception as e:
    logger.warning(f"Кастомная OCR-модель не загружена: {e}")

# --- Инициализация стандартной PaddleOCR ---
PADDLE_OCR_DEFAULT = None
PADDLE_OCR_DEFAULT_AVAILABLE = False
try:
    PADDLE_OCR_DEFAULT = PaddleOCR(
        lang='ru',
        use_angle_cls=False,
        det_db_box_thresh=0.5
    )
    PADDLE_OCR_DEFAULT_AVAILABLE = True
    logger.info("Стандартная OCR-модель PaddleOCR успешно инициализирована (GPU отключен).")
except Exception as e:
    PADDLE_OCR_DEFAULT_AVAILABLE = False
    logger.warning(f"Ошибка инициализации стандартной OCR-модели PaddleOCR: {e}")


# --- Инициализация EasyOCR ---
EASY_OCR_READER = None
EASY_OCR_AVAILABLE = False
try:
    import easyocr

    gpu_avail = torch.cuda.is_available()
    EASY_OCR_READER = easyocr.Reader(['ru'], gpu=gpu_avail)
    EASY_OCR_AVAILABLE = True
    logger.info(f"EasyOCR успешно инициализирована. GPU={gpu_avail}")
except ImportError:
    logger.warning("EasyOCR не установлена. Для установки: pip install easyocr")
except Exception as e:
    logger.warning(f"Ошибка инициализации EasyOCR: {e}")

# --- YOLOv5 локальная папка, для torch.hub ---
YOLOV5_LOCAL_PATH = os.path.join(base_dir, 'yolov5')

if not os.path.isdir(YOLOV5_LOCAL_PATH):
    try:
        logger.info(f"Клонирование YOLOv5 в {YOLOV5_LOCAL_PATH} (если git доступен)...")
        subprocess.check_call(["git", "clone", "https://github.com/ultralytics/yolov5.git", YOLOV5_LOCAL_PATH])
    except Exception as e:
        logger.warning(f"Не удалось клонировать YOLOv5: {e}")

# --- ultralytics YOLO (v8+) ---
ULTRALYTICS_YOLO_AVAILABLE = False
try:
    from ultralytics import YOLO

    ULTRALYTICS_YOLO_AVAILABLE = True
    logger.info("Ultralytics YOLO доступен.")
except ImportError:
    logger.warning("ultralytics.YOLO не найден, YOLOv8/v10 поддержка отключена.")


# --- Вспомогательные функции ---

def get_available_models():
    models = []
    # В продакшен модели
    prod_model_path = os.path.join(BASE_MODEL_PATH, PRODUCTION_MODEL_DIR, PRODUCTION_MODEL_FILENAME)
    if os.path.exists(prod_model_path):
        models.append(PRODUCTION_MODEL_DISPLAY_NAME)

    # Модели из папки 'models' (свой репозиторий)
    models_dir = os.path.join(base_dir, 'models')
    if os.path.isdir(models_dir):
        for folder in os.listdir(models_dir):
            folder_path = os.path.join(models_dir, folder)
            if os.path.isdir(folder_path):
                best_pt = os.path.join(folder_path, 'best.pt')
                if os.path.exists(best_pt):
                    if folder not in models:
                        models.append(folder)

    # Модели из base_dir (например train1, train2...)
    for folder in os.listdir(base_dir):
        folder_path = os.path.join(base_dir, folder)
        if os.path.isdir(folder_path) and folder.startswith('train') and folder[-1].isdigit():
            weights_path = os.path.join(folder_path, 'weights', 'best.pt')
            if os.path.exists(weights_path) and folder not in models:
                models.append(folder)

    return sorted(models)


def clear_folder(path):
    if not os.path.exists(path):
        return
    for f in os.listdir(path):
        try:
            fp = os.path.join(path, f)
            if os.path.isfile(fp) or os.path.islink(fp):
                os.unlink(fp)
            elif os.path.isdir(fp):
                shutil.rmtree(fp)
        except Exception as e:
            logger.error(f"Не удалось очистить {fp}: {e}")


def get_model_comments(model_name):
    fpath = os.path.join(COMMENTS_PATH, f"{model_name}.txt")
    comments = []
    if os.path.exists(fpath):
        try:
            with open(fpath, 'r', encoding='utf-8') as f:
                for line in f:
                    line = line.strip()
                    if not line:
                        continue
                    try:
                        comments.append(json.loads(line))
                    except json.JSONDecodeError:
                        comments.append({'text': line, 'timestamp': 'N/A', 'userId': 'unknown'})
        except Exception as e:
            logger.error(f"Ошибка чтения комментариев для модели {model_name}: {e}")
    return comments


def save_model_comment(model_name, comment_text, user_id='anonymous'):
    fpath = os.path.join(COMMENTS_PATH, f"{model_name}.txt")
    comment_data = {
        'text': comment_text,
        'timestamp': datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
        'userId': user_id
    }
    try:
        with open(fpath, 'a', encoding='utf-8') as f:
            f.write(json.dumps(comment_data, ensure_ascii=False) + '\n')
        return True
    except Exception as e:
        logger.error(f"Ошибка сохранения комментария для {model_name}: {e}")
        return False


def load_yolo_model(model_name):
    if model_name in YOLO_MODELS:
        return YOLO_MODELS[model_name]

    weights_path = None
    if model_name == PRODUCTION_MODEL_DISPLAY_NAME:
        weights_path = os.path.join(BASE_MODEL_PATH, PRODUCTION_MODEL_DIR, PRODUCTION_MODEL_FILENAME)
    else:
        weights_path_candidates = [
            os.path.join(base_dir, 'models', model_name, 'best.pt'),
            os.path.join(base_dir, model_name, 'weights', 'best.pt'),
            os.path.join(BASE_MODEL_PATH, model_name, 'weights', 'best.pt'),
        ]
        for path_candidate in weights_path_candidates:
            if os.path.exists(path_candidate):
                weights_path = path_candidate
                break

    if not weights_path:
        logger.error(f"Веса модели '{model_name}' не найдены.")
        return None

    try:
        model = None
        if ULTRALYTICS_YOLO_AVAILABLE:
            model = YOLO(weights_path)
            logger.info(f"YOLO (ultralytics) модель '{model_name}' загружена из {weights_path}.")
        else:
            # fallback на yolov5 через torch.hub
            model = torch.hub.load(YOLOV5_LOCAL_PATH, 'custom', path=weights_path, source='local', verbose=False)
            model.eval()
            logger.info(f"YOLOv5 модель '{model_name}' загружена через torch.hub из {weights_path}.")
        YOLO_MODELS[model_name] = model
        return model
    except Exception as e:
        logger.error(f"Ошибка загрузки модели '{model_name}': {e}")
        return None


# --- OCR engine функции ---

def run_tesseract_ocr(pil_image, lang='rus'):
    try:
        text_data = pytesseract.image_to_data(pil_image, lang=lang, output_type=Output.DICT)
        texts = text_data.get('text', [])
        confs = text_data.get('conf', [])

        best_text = ''
        best_conf = -1.0
        for t, c in zip(texts, confs):
            try:
                conf_val = float(c)
            except ValueError:
                continue
            if conf_val > best_conf and t.strip():
                best_conf = conf_val
                best_text = t.strip()

        return best_text if best_text else "Не удалось распознать текст", max(best_conf / 100.0, 0.0)
    except Exception as e:
        logger.error(f"Tesseract OCR ошибка: {e}")
        return "Ошибка OCR", 0.0


def run_paddle_ocr_custom(pil_image):
    if not CUSTOM_OCR_AVAILABLE or OCR_RECOGNIZER_CUSTOM is None:
        return "Custom PaddleOCR недоступна", 0.0
    try:
        img_cv = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
        results = OCR_RECOGNIZER_CUSTOM.predict(images=[img_cv], run_benchmark=False)
        if not results:
            return "Пустой результат OCR", 0.0
        # Предполагаемые форматы результатов
        first = results[0]
        if isinstance(first, tuple) and len(first) == 2:
            text, conf = first
            if isinstance(text, bytes):
                text = text.decode('utf-8', 'ignore')
            confidence = float(conf) if conf is not None else 0.0
            return text.strip(), confidence
        elif isinstance(first, list) and len(first) > 0:
            first_item = first[0]
            if isinstance(first_item, dict):
                return first_item.get('text', ''), float(first_item.get('confidence', 0.0))
            elif isinstance(first_item, str):
                return first_item.strip(), 0.0
        return "Неизвестный формат вывода OCR", 0.0
    except Exception as e:
        logger.error(f"Custom PaddleOCR ошибка: {e}")
        return "Ошибка OCR", 0.0


def run_paddle_ocr_default(pil_image):
    if not PADDLE_OCR_DEFAULT_AVAILABLE or PADDLE_OCR_DEFAULT is None:
        return "Стандартная PaddleOCR недоступна", 0.0
    try:
        if pil_image.mode != 'RGB':
            pil_image = pil_image.convert('RGB')

        img_cv = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
        results = PADDLE_OCR_DEFAULT.ocr(img_cv)
        print(results)
        if not results or len(results) == 0:
            return "Текст не найден", 0.0

        res = results[0]

        rec_texts = res.get('rec_texts', [])
        rec_scores = res.get('rec_scores', [])

        if not rec_texts:
            return "Текст не найден", 0.0

        # Объединяем все распознанные тексты через пробел
        full_text = " ".join([t.strip() for t in rec_texts if t.strip()])

        # Усреднённая уверенность (если есть)
        avg_conf = float(np.mean(rec_scores)) if rec_scores else 0.0

        return full_text.strip(), avg_conf

    except Exception as e:
        logger.error(f"PaddleOCR default ошибка: {e}", exc_info=True)
        return "Ошибка OCR", 0.0



def run_easy_ocr(pil_image):
    if not EASY_OCR_AVAILABLE or EASY_OCR_READER is None:
        return "EasyOCR не доступен", 0.0
    try:
        img_cv = np.array(pil_image)
        results = EASY_OCR_READER.readtext(img_cv)
        if not results:
            return "Не удалось распознать текст", 0.0
        best_text, best_conf = max(results, key=lambda x: x[2])[1:3]
        return best_text.strip(), best_conf
    except Exception as e:
        logger.error(f"EasyOCR ошибка: {e}")
        return "Ошибка OCR", 0.0


def run_selected_ocr(pil_image, ocr_engine_name, _class_name=None):
    """
    Запуск выбранного OCR движка на изображении.
    """
    start = time.perf_counter()
    text, confidence = "Движок не выбран", 0.0

    if ocr_engine_name == 'TesseractOCR':
        text, confidence = run_tesseract_ocr(pil_image)
    elif ocr_engine_name == 'EasyOCR':
        text, confidence = run_easy_ocr(pil_image)
    elif ocr_engine_name == 'PaddleOCRCustom':
        text, confidence = run_paddle_ocr_custom(pil_image)
    elif ocr_engine_name == 'PaddleOCRDefault':
        text, confidence = run_paddle_ocr_default(pil_image)
    else:
        logger.warning(f"Неизвестный OCR движок: {ocr_engine_name}")

    end = time.perf_counter()
    speed_ms = (end - start) * 1000
    return text, confidence, speed_ms


# Улучшения изображения для OCR
def apply_image_enhancements(pil_img):
    out_imgs = [(pil_img, 'Original')]

    # Оставим только контраст с 1.3
    contrast_enhancer = ImageEnhance.Contrast(pil_img)
    out_imgs.append((contrast_enhancer.enhance(1.3), 'Contrast x1.3'))

    # Grayscale
    out_imgs.append((pil_img.convert('L'), 'Grayscale'))

    # Бинаризация Otsu
    try:
        gray_cv = np.array(pil_img.convert('L'))
        _, binary_cv = cv2.threshold(gray_cv, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
        binary_pil = Image.fromarray(binary_cv)
        out_imgs.append((binary_pil, 'Binary Otsu'))
    except Exception as e:
        logger.warning(f"Ошибка при бинаризации Otsu: {e}")

    return out_imgs


def optimize_ocr_for_image(cropped_pil_img, class_name, ocr_engine_name):
    """Пробуем разные варианты улучшений, выбираем лучший по confidence."""
    best_text = "Не удалось распознать текст"
    best_conf = 0.0
    best_speed = -1
    best_pil_for_display = cropped_pil_img.copy()

    images_to_try = []
    # Для серийных номеров крутим
    if class_name in ['pass_SerialNumberDown', 'pass_SerialNumberUp']:
        rotated = cropped_pil_img.rotate(90, expand=True, fillcolor=(0, 0, 0))
        enhanced_imgs = apply_image_enhancements(rotated)
        images_to_try.extend([(img, f"Rotated 90 | {desc}") for img, desc in enhanced_imgs])
    else:
        enhanced_imgs = apply_image_enhancements(cropped_pil_img)
        images_to_try.extend([(img, f"Original | {desc}") for img, desc in enhanced_imgs])

    for img, desc in images_to_try:
        text, conf, speed = run_selected_ocr(img, ocr_engine_name, class_name)
        logger.debug(f"OCR [{ocr_engine_name}] для {class_name} ({desc}): '{text}' (conf={conf:.3f}) speed={speed:.1f}ms")
        if conf > best_conf:
            best_conf = conf
            best_text = text
            best_speed = speed
            best_pil_for_display = img.copy()

    return best_text.strip(), best_conf, best_speed, best_pil_for_display


def process_predictions(preds, class_names, img_pil, ocr_engines, img_cv):
    """
    preds: numpy.ndarray с detections в формате [x1, y1, x2, y2, confidence, class_id]
    """
    detection_results = []
    class_counts = {}
    conf_sum = 0.0
    for p in preds:
        if len(p) < 6:
            continue
        x1, y1, x2, y2, conf, cls_id = p[:6]
        x1, y1, x2, y2 = map(int, [x1, y1, x2, y2])

        # Корректируем по размеру изображения
        x1 = max(0, x1)
        y1 = max(0, y1)
        x2 = min(img_pil.width, x2)
        y2 = min(img_pil.height, y2)
        if x2 <= x1 or y2 <= y1:
            continue

        class_name = class_names.get(int(cls_id), "unknown")

        # Вырезаем поле
        cropped_pil = img_pil.crop((x1, y1, x2, y2))

        # Собираем результаты OCR из разных движков
        all_ocr_results = {}
        for engine in ocr_engines:
            ocr_text, ocr_conf, ocr_speed, _ = optimize_ocr_for_image(cropped_pil, class_name, engine)
            all_ocr_results[engine] = {
                'text': ocr_text,
                'confidence': f"{ocr_conf:.2f}",
                'speed_ms': f"{ocr_speed:.0f}"
            }

        detection_results.append({
            'label': class_name,
            'confidence': f"{conf:.2f}",
            'box': [x1, y1, x2, y2],
            'ocr_results': all_ocr_results
        })

        conf_sum += conf
        class_counts[class_name] = class_counts.get(class_name, 0) + 1

        # Нарисуем bbox + подпись на img_cv
        cv2.rectangle(img_cv, (x1, y1), (x2, y2), (0, 255, 0), 2)
        text_disp = f"{class_name} {conf:.2f}"
        cv2.putText(img_cv, text_disp, (x1, max(y1 - 10, 0)), cv2.FONT_HERSHEY_SIMPLEX,
                    0.5, (0, 255, 0), 2)

    return detection_results, class_counts, conf_sum, img_cv


# --- Flask маршруты ---

@app.route('/')
def index():
    models = get_available_models()
    comments = {m: get_model_comments(m) for m in models}
    return render_template('index.html', models=models, model_comments=comments)


@app.route('/upload', methods=['POST'])
def upload_files():
    if 'files' not in request.files:
        return jsonify({"error": "Файлы не отправлены"}), 400

    files = request.files.getlist('files')
    if not files or all(f.filename == '' for f in files):
        return jsonify({"error": "Файлы не выбраны"}), 400

    selected_models = request.form.getlist('model_folder')
    if not selected_models:
        return jsonify({"error": "Модели не выбраны"}), 400

    selected_ocr_engines = request.form.getlist('ocr_engine')
    if not selected_ocr_engines:
        selected_ocr_engines = ['PaddleOCRCustom']

    clear_folder(PROCESSED_IMAGES_PATH)

    saved_filepaths = []
    for file in files:
        if file and file.filename:
            filename = secure_filename(file.filename)
            save_path = os.path.join(app.config['UPLOAD_FOLDER'], filename)
            try:
                file.save(save_path)
                saved_filepaths.append(save_path)
            except Exception as e:
                logger.error(f"Ошибка сохранения файла {filename}: {e}")

    if not saved_filepaths:
        return jsonify({"error": "Не удалось сохранить файлы"}), 400

    results_by_model = {}
    for model_name in selected_models:
        model = load_yolo_model(model_name)
        if model is None:
            results_by_model[model_name] = {"success": False, "error": f"Модель {model_name} не найдена."}
            continue

        all_file_results = []
        total_conf = 0.0
        total_detections = 0

        for fpath in saved_filepaths:
            fname = os.path.basename(fpath)
            try:
                img_pil = Image.open(fpath).convert('RGB')
                img_cv = cv2.imread(fpath)
                if img_cv is None:
                    logger.error(f"CV2 не смог загрузить изображение: {fpath}")
                    continue

                # Получаем предсказания
                if ULTRALYTICS_YOLO_AVAILABLE:
                    preds = model(img_pil)
                    # preds - list of Results: берем первый
                    preds_arr = []
                    if preds and len(preds) > 0 and hasattr(preds[0], 'boxes'):
                        for box in preds[0].boxes.data.cpu().numpy():
                            preds_arr.append(box)
                    preds_arr = np.array(preds_arr)
                    class_names_map = model.names
                else:
                    # torch.hub yolov5
                    results = model(img_cv)
                    if results is None:
                        logger.warning("Результат модели пустой")
                        continue
                    # results.pred - torch tensor Nx6: x1,y1,x2,y2,conf,class
                    preds_arr = results.pred[0].cpu().numpy() if results.pred is not None else np.array([])
                    class_names_map = model.names if hasattr(model, 'names') else {}

                if preds_arr.size == 0:
                    logger.info(f"Детекций для файла {fname} моделю {model_name} не найдено")
                    all_file_results.append({
                        'filename': fname,
                        'total_detections': 0,
                        'class_counts': {},
                        'detections': [],
                        'processed_image_url': None,
                    })
                    continue

                detections, class_counts, conf_sum, img_cv_res = process_predictions(
                    preds_arr,
                    class_names_map,
                    img_pil,
                    selected_ocr_engines,
                    img_cv.copy()
                )

                # Сохраняем результат с bbox
                timestamp = int(time.time() * 1000)
                name_noext, ext = os.path.splitext(fname)
                out_fname = f"{model_name}_{name_noext}_{timestamp}{ext}"
                out_path = os.path.join(PROCESSED_IMAGES_PATH, out_fname)

                try:
                    if ext.lower() in ['.jpg', '.jpeg', '.png', '.bmp']:
                        cv2.imwrite(out_path, img_cv_res)
                    else:
                        # Если формат неизвестен, используем png
                        out_path = os.path.join(PROCESSED_IMAGES_PATH, f"{model_name}_{name_noext}_{timestamp}.png")
                        cv2.imwrite(out_path, img_cv_res)
                    processed_url = url_for('get_processed_image', filename=os.path.basename(out_path))
                except Exception as e:
                    logger.error(f"Ошибка сохранения результата обработки: {e}")
                    processed_url = None

                all_file_results.append({
                    'filename': fname,
                    'total_detections': len(detections),
                    'class_counts': class_counts,
                    'detections': detections,
                    'processed_image_url': processed_url,
                })

                total_conf += conf_sum
                total_detections += len(detections)

            except Exception as e:
                logger.error(f"Ошибка обработки файла {fname} с моделью {model_name}: {e}")

        avg_det = total_detections / len(saved_filepaths) if saved_filepaths else 0
        avg_conf = total_conf / total_detections if total_detections > 0 else 0

        results_by_model[model_name] = {
            'success': True,
            'overall_stats': {
                'total_files': len(saved_filepaths),
                'total_detections': total_detections,
                'avg_detections_per_file': f"{avg_det:.2f}",
                'avg_confidence': f"{avg_conf:.2f}"
            },
            'per_file_results': all_file_results
        }

    for fpath in saved_filepaths:
        try:
            os.remove(fpath)
        except Exception as e:
            logger.warning(f"Не удалось удалить файл {fpath}: {e}")

    # Формируем финальный ответ
    final_results = {
        "dashboard_stats": {},
        "per_file_comparison": {}
    }

    unique_filenames = sorted(set([os.path.basename(p) for p in saved_filepaths]))

    for model_name, model_data in results_by_model.items():
        final_results['dashboard_stats'][model_name] = model_data.get('overall_stats', {'error': model_data.get('error', '-')})

    for fname in unique_filenames:
        final_results['per_file_comparison'][fname] = {
            "models": {},
            "processed_image_url": None
        }
        # Сначала ставим processed_image_url, если есть
        for model_name in selected_models:
            model_data = results_by_model.get(model_name)
            if model_data and model_data.get('success'):
                file_res = next((r for r in model_data['per_file_results'] if r['filename'] == fname), None)
                if file_res and file_res.get('processed_image_url'):
                    final_results['per_file_comparison'][fname]['processed_image_url'] = file_res['processed_image_url']
                    break

        # Подробности по моделям
        for model_name in selected_models:
            model_data = results_by_model.get(model_name)
            if model_data and model_data.get('success'):
                file_res = next((r for r in model_data['per_file_results'] if r['filename'] == fname), None)
                if file_res:
                    final_results['per_file_comparison'][fname]['models'][model_name] = {
                        'success': True,
                        'total_detections': file_res['total_detections'],
                        'class_counts': file_res.get('class_counts', {}),
                        'detections': file_res.get('detections', {}),
                        'processed_image_url': file_res.get('processed_image_url')
                    }
                else:
                    final_results['per_file_comparison'][fname]['models'][model_name] = {
                        'success': False,
                        'error': 'Результат для файла не найден'
                    }
            else:
                final_results['per_file_comparison'][fname]['models'][model_name] = {
                    'success': False,
                    'error': model_data.get('error', 'Модель не обработана') if model_data else 'Модель не найдена'
                }

    return jsonify(final_results)


@app.route('/processed_images/<filename>')
def get_processed_image(filename):
    return send_from_directory(PROCESSED_IMAGES_PATH, filename)


# КРОППИНГ, АННОТАЦИЯ и SAVE ANNOTATIONS с проверками
@app.route('/annotate')
def annotate_page():
    models = get_available_models()
    return render_template('annotate.html', models=models)


@app.route('/process_for_annotation', methods=['POST'])
def process_for_annotation():
    if 'files' not in request.files:
        return jsonify({"error": "Файлы не отправлены"}), 400
    files = request.files.getlist('files')
    if not files or all(f.filename == '' for f in files):
        return jsonify({"error": "Файлы не выбраны"}), 400

    selected_model_name = request.form.get('model_folder')
    selected_ocr_engine = request.form.get('ocr_engine', 'TesseractOCR')

    clear_folder(CROPPED_FIELDS_PATH)

    model = load_yolo_model(selected_model_name)
    if model is None:
        return jsonify({"error": f"Модель '{selected_model_name}' не найдена"}), 400

    processed_fields = []

    for file in files:
        if not file or not file.filename:
            continue

        filename = secure_filename(file.filename)
        save_path = os.path.join(UPLOAD_FOLDER, filename)
        try:
            file.save(save_path)
        except Exception as e:
            logger.error(f"Ошибка сохранения {filename}: {e}")
            continue

        try:
            img_pil = Image.open(save_path).convert('RGB')
            img_cv = cv2.imread(save_path)
            if img_cv is None:
                logger.error(f"Не удалось загрузить изображение cv2: {save_path}")
                continue

            if ULTRALYTICS_YOLO_AVAILABLE:
                preds = model(img_pil)
                preds_arr = []
                if preds and len(preds) > 0 and hasattr(preds[0], 'boxes'):
                    for box in preds[0].boxes.data.cpu().numpy():
                        preds_arr.append(box)
                preds_arr = np.array(preds_arr)
                class_names_map = model.names
            else:
                results = model(img_cv)
                preds_arr = results.pred[0].cpu().numpy() if results.pred is not None else np.array([])
                class_names_map = model.names if hasattr(model, 'names') else {}

            for p in preds_arr:
                if len(p) < 6:
                    continue
                x1, y1, x2, y2, conf, cls_id = p[:6]
                x1, y1, x2, y2 = map(int, [x1, y1, x2, y2])
                x1 = max(0, x1)
                y1 = max(0, y1)
                x2 = min(img_pil.width, x2)
                y2 = min(img_pil.height, y2)
                if x2 <= x1 or y2 <= y1:
                    continue

                label = class_names_map.get(int(cls_id), 'unknown')

                orig_crop = img_pil.crop((x1, y1, x2, y2))
                to_save_img = orig_crop.copy()

                if label in ['pass_SerialNumberDown', 'pass_SerialNumberUp']:
                    to_save_img = orig_crop.rotate(90, expand=True, fillcolor=(0, 0, 0))

                unique_file = f"{uuid.uuid4()}.png"
                save_crop_path = os.path.join(CROPPED_FIELDS_PATH, unique_file)
                to_save_img.save(save_crop_path)

                ocr_text, ocr_conf, ocr_speed, _ = optimize_ocr_for_image(orig_crop, label, selected_ocr_engine)

                processed_fields.append({
                    'original_filename': filename,
                    'label': label,
                    'confidence': f"{conf:.2f}",
                    'box': [x1, y1, x2, y2],
                    'cropped_image_url': url_for('get_cropped_field', filename=unique_file),
                    'ocr_text': ocr_text,
                    'ocr_confidence': f"{ocr_conf:.2f}",
                    'ocr_speed_ms': f"{ocr_speed:.0f}",
                    'ocr_engine': selected_ocr_engine
                })
        except Exception as e:
            logger.error(f"Ошибка обработки файла {filename}: {e}")
        finally:
            try:
                if os.path.exists(save_path):
                    os.remove(save_path)
            except Exception as e:
                logger.warning(f"Ошибка удаления временного файла {save_path}: {e}")

    return jsonify({'processed_fields': processed_fields})


@app.route('/cropped_fields/<filename>')
def get_cropped_field(filename):
    return send_from_directory(CROPPED_FIELDS_PATH, filename)


@app.route('/save_annotations', methods=['POST'])
def save_annotations():
    data = request.json
    annotations = data.get('annotations', [])
    if not annotations:
        return jsonify({'status': 'error', 'message': 'Нет данных для сохранения'}), 400

    saved = 0
    for item in annotations:
        manual_text = item.get('manual_text')
        if manual_text is None or not manual_text.strip():
            continue

        label = item.get('label', 'unknown')
        cropped_url = item.get('cropped_image_url')
        if not cropped_url:
            continue
        cropped_filename = os.path.basename(cropped_url)
        cropped_path = os.path.join(CROPPED_FIELDS_PATH, cropped_filename)

        if os.path.exists(cropped_path):
            try:
                unique_id = uuid.uuid4()
                img_save_name = f"{label}_{unique_id}.png"
                txt_save_name = f"{label}_{unique_id}.txt"
                img_save_path = os.path.join(ANNOTATED_IMAGES_PATH, img_save_name)
                txt_save_path = os.path.join(ANNOTATED_TEXT_PATH, txt_save_name)
                shutil.copy2(cropped_path, img_save_path)
                with open(txt_save_path, 'w', encoding='utf-8') as f:
                    f.write(manual_text.strip())
                saved += 1
            except Exception as e:
                logger.error(f"Ошибка сохранения аннотации '{label}': {e}")

    if saved > 0:
        return jsonify({'status': 'success', 'message': f'Сохранено записей: {saved}', 'saved_count': saved})
    else:
        return jsonify({'status': 'error', 'message': 'Данные не сохранены (пустые или ошибка)'}), 400


@app.route('/save_single_annotation', methods=['POST'])
def save_single_annotation():
    data = request.json
    manual_text = data.get('manual_text')
    label = data.get('label', 'unknown')
    cropped_image_url = data.get('cropped_image_url')

    if not manual_text or not cropped_image_url:
        return jsonify({'status': 'error', 'message': 'Отсутствуют обязательные поля'}), 400

    cropped_filename = os.path.basename(cropped_image_url)
    cropped_path = os.path.join(CROPPED_FIELDS_PATH, cropped_filename)

    if not os.path.exists(cropped_path):
        return jsonify({'status': 'error', 'message': 'Обрезанное изображение не найдено'}), 404

    try:
        unique_id = uuid.uuid4()
        img_save_name = f"{label}_{unique_id}.png"
        txt_save_name = f"{label}_{unique_id}.txt"
        img_save_path = os.path.join(ANNOTATED_IMAGES_PATH, img_save_name)
        txt_save_path = os.path.join(ANNOTATED_TEXT_PATH, txt_save_name)

        shutil.copy2(cropped_path, img_save_path)
        with open(txt_save_path, 'w', encoding='utf-8') as f:
            f.write(manual_text.strip())

        return jsonify({'status': 'success', 'message': f'Поле "{label}" сохранено.'})
    except Exception as e:
        logger.error(f"Ошибка сохранения индивид. аннотации '{label}': {e}")
        return jsonify({'status': 'error', 'message': 'Ошибка при сохранении'}), 500


# --- Запуск приложения ---

if __name__ == '__main__':
    logger.info("Запуск сервера Flask...")
    app.run(host='0.0.0.0', port=5000, debug=True)
Откуда загружаю и какую я использую версию Paddle OCR? Как именно ее дообучить?