diff --git a/photo_bot/photo_processor.py b/photo_bot/photo_processor.py index a6f292d..b6cde0e 100644 --- a/photo_bot/photo_processor.py +++ b/photo_bot/photo_processor.py @@ -1,127 +1,125 @@ import io import cv2 -import os import logging import numpy as np +import requests from PIL import Image, ImageFilter, ImageEnhance logger = logging.getLogger(__name__) -def prepare_ad_photo(input_path: str, output_path: str, target_size: int = 96) -> dict: +# Настройки API +API_URL = "https://profile.sibcem.ru/SibCemProfile/api/photo-bot/upload/" +API_KEY = "sibcem_bot_2026_a7f3k9m2x8q1w5e4r6t" + + +def get_crop_coordinates(image_bytes: bytes) -> tuple | None: + """ + Ищет лицо на фото и возвращает координаты квадратного кропа. + + Returns: + (crop_x1, crop_y1, crop_x2, crop_y2) или None если лицо не найдено. + """ + img = cv2.imdecode(np.frombuffer(image_bytes, np.uint8), cv2.IMREAD_COLOR) + if img is None: + return None + + gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) + + cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' + face_cascade = cv2.CascadeClassifier(cascade_path) + faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=8, minSize=(50, 50)) + + if len(faces) == 0: + return None + + if len(faces) > 1: + faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True) + + x, y, w, h = faces[0] + + margin = int(w * 0.3) + y1 = max(0, y - int(margin * 1.5)) + y2 = min(img.shape[0], y + h + margin) + x1 = max(0, x - margin) + x2 = min(img.shape[1], x + w + margin) + + side = min(x2 - x1, y2 - y1) + center_x, center_y = (x1 + x2) // 2, (y1 + y2) // 2 + crop_x1 = max(0, center_x - side // 2) + crop_y1 = max(0, center_y - side // 2) + crop_x2 = crop_x1 + side + crop_y2 = crop_y1 + side + + return (crop_x1, crop_y1, crop_x2, crop_y2) + + +def _upload_to_api(image_bytes: bytes, user_id: str, coords: tuple) -> dict: + """ + Отправляет фото и координаты в Django API. + Возвращает JSON от сервера. + """ + x1, y1, x2, y2 = coords try: - # 1. Загружаем изображение для OpenCV + response = requests.post( + API_URL, + headers={"X-API-Key": API_KEY}, + data={ + "user_id": user_id, + "crop_x1": x1, + "crop_y1": y1, + "crop_x2": x2, + "crop_y2": y2, + }, + files={ + "image": ("photo.jpg", image_bytes, "image/jpeg") + }, + timeout=30 + ) + response.raise_for_status() + return response.json() + except requests.exceptions.RequestException as e: + logger.error(f"Ошибка API: {e}") + return {"status": "error", "reason": f"Ошибка сети: {str(e)}"} + except Exception as e: + logger.error(f"Ошибка отправки в API: {e}") + return {"status": "error", "reason": f"Ошибка: {str(e)}"} + + +def prepare_ad_photo(input_path: str, user_id: str, output_path: str = None, target_size: int = 96) -> dict: + """ + Отправляет фото на сервер через API. + output_path и target_size больше не используются (оставлены для обратной совместимости). + """ + try: + # 1. Читаем байты if isinstance(input_path, bytes): - img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR) + image_bytes = input_path else: - img = cv2.imread(input_path) - if img is None: - return {"success": False, "error": "Не удалось прочитать файл изображения."} - if isinstance(input_path, bytes): - img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR) - else: - img = cv2.imread(input_path) - if img is None: - return {"success": False, "error": "Не удалось прочитать файл изображения."} + with open(input_path, 'rb') as f: + image_bytes = f.read() - gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) - - # 2. Загружаем каскад Хаара для поиска лица - cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml' - face_cascade = cv2.CascadeClassifier(cascade_path) - - # 3. Ищем лица - # Оставляем оптимальное значение minNeighbors=8 для баланса - faces = face_cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=8, minSize=(50, 50)) - - if len(faces) == 0: + # 2. Ищем лицо и получаем координаты + coords = get_crop_coordinates(image_bytes) + if not coords: return {"success": False, "error": "Лицо не найдено! Пожалуйста, сделайте фото анфас при хорошем освещении."} - if len(faces) > 1: - faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True) + # 3. Отправляем в API + api_result = _upload_to_api(image_bytes, user_id, coords) - x, y, w, h = faces[0] - - # ========================================================= - # АБСОЛЮТНАЯ ЗАЩИТА ОТ ГРАФИКИ (НОРМАЛИЗОВАННАЯ МАТЕМАТИКА) - # ========================================================= - # 1. Вырезаем центральную зону лица - face_roi_original = gray[y + int(h*0.2):y + int(h*0.8), x + int(w*0.2):x + int(w*0.8)] - - # 2. Сжимаем до 100x100 пикселей. - # Это уравнивает 4K-фотографии и мелкие картинки, делая метрики стабильными. - face_roi = cv2.resize(face_roi_original, (100, 100)) - - std_dev = np.std(face_roi) - laplacian_var = cv2.Laplacian(face_roi, cv2.CV_64F).var() - unique_colors = len(np.unique(face_roi)) - - logger.info(f"Анализ нормализованного лица: std_dev={std_dev:.2f}, laplacian={laplacian_var:.2f}, colors={unique_colors}") - - # ПРОВЕРКА 1: Плоские, замыленные картинки - if std_dev < 10.0 or laplacian_var < 30.0: - logger.warning(f"Заблокирован плоский рисунок/замыливание: std={std_dev:.1f}, lap={laplacian_var:.1f}") - return {"success": False, "error": "Изображение слишком размыто или имеет плоскую текстуру. Отправьте реальное фото."} - - # ПРОВЕРКА 2: Векторы, комиксы и жесткий 2D-арт (Умный порог резкости + цвета) - # У реального человека на хорошую камеру laplacian может доходить до 500-600, - # но при этом всегда будет богатая палитра цветов (шум матрицы). - # У мультиков высокая резкость всегда сопровождается бедной палитрой. - if laplacian_var > 850.0 or (laplacian_var > 400.0 and unique_colors < 150): - logger.warning(f"Заблокирован резкий 2D-арт: lap={laplacian_var:.1f}, colors={unique_colors}") - return {"success": False, "error": "Изображение похоже на графический арт или сильно отфильтровано. Пожалуйста, загрузите естественное фото."} - - # ПРОВЕРКА 3: 3D-рендеры и нейросети (Мало шума) - # На реальной фотографии (даже с хорошим светом) шум матрицы телефона - # даст минимум 110-120 уникальных оттенков на 10000 пикселей. - if unique_colors < 105: - logger.warning(f"Заблокирован 3D-рендер по палитре: unique_colors={unique_colors}") - return {"success": False, "error": "Изображение распознано как 3D-модель или рисунок (неестественная палитра). Ждем реальное фото!"} - # ========================================================= - - # 4. Кадрируем (делаем квадрат с отступами вокруг лица) - margin = int(w * 0.3) - y1 = max(0, y - int(margin * 1.5)) - y2 = min(img.shape[0], y + h + margin) - x1 = max(0, x - margin) - x2 = min(img.shape[1], x + w + margin) - - side = min(x2 - x1, y2 - y1) - center_x, center_y = (x1 + x2) // 2, (y1 + y2) // 2 - crop_x1 = max(0, center_x - side // 2) - crop_y1 = max(0, center_y - side // 2) - crop_x2 = crop_x1 + side - crop_y2 = crop_y1 + side - - # 5. Обработка через Pillow - pil_img = Image.open(io.BytesIO(input_path)) - - # ВАЖНО: Убираем альфа-канал (прозрачность), иначе BMP выдаст ошибку - if pil_img.mode in ('RGBA', 'P', 'LA'): - background = Image.new('RGB', pil_img.size, (255, 255, 255)) - background.paste(pil_img, mask=pil_img.split()[3] if pil_img.mode == 'RGBA' else None) - pil_img = background - elif pil_img.mode != 'RGB': - pil_img = pil_img.convert('RGB') - - cropped_img = pil_img.crop((crop_x1, crop_y1, crop_x2, crop_y2)) - - # Сжимаем до нужного размера (96x96) - final_img = cropped_img.resize((target_size, target_size), Image.Resampling.LANCZOS) - - # Добавляем немного резкости, чтобы лицо не мылилось при 96px - final_img = final_img.filter(ImageFilter.UnsharpMask(radius=1.5, percent=100, threshold=3)) - - # 6. Сохраняем в формате BMP - final_img.save(output_path, format="BMP") - - # 7. Проверяем финальный вес - size_kb = os.path.getsize(output_path) / 1024 - - if size_kb > 99: - return {"success": False, "error": f"Сбой: размер файла ({size_kb:.1f} KB) превысил лимит в 99 KB."} - - return {"success": True, "size_kb": round(size_kb, 1)} + # 4. Возвращаем результат + if api_result.get("status") == "ok": + return { + "success": True, + "photo_id": api_result.get("photo_id"), + "verdict": api_result.get("verdict"), + "message": api_result.get("message"), + } + else: + return { + "success": False, + "error": api_result.get("reason", "Неизвестная ошибка API") + } except Exception as e: - logger.error(f"Ошибка в photo_processor: {e}") - return {"success": False, "error": str(e)} \ No newline at end of file + logger.error(f"Ошибка в prepare_ad_photo: {e}") + return {"success": False, "error": str(e)}