Рефакторинг: выделение get_crop_coordinates(), интеграция с Django API

This commit is contained in:
2026-08-14 09:37:35 +07:00
parent c7ae88a3e0
commit 707dcf3437
+108 -110
View File
@@ -1,127 +1,125 @@
import io import io
import cv2 import cv2
import os
import logging import logging
import numpy as np import numpy as np
import requests
from PIL import Image, ImageFilter, ImageEnhance from PIL import Image, ImageFilter, ImageEnhance
logger = logging.getLogger(__name__) 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: 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): if isinstance(input_path, bytes):
img = cv2.imdecode(np.frombuffer(input_path, np.uint8), cv2.IMREAD_COLOR) image_bytes = input_path
else: else:
img = cv2.imread(input_path) with open(input_path, 'rb') as f:
if img is None: image_bytes = f.read()
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": "Не удалось прочитать файл изображения."}
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 2. Ищем лицо и получаем координаты
coords = get_crop_coordinates(image_bytes)
# 2. Загружаем каскад Хаара для поиска лица if not coords:
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:
return {"success": False, "error": "Лицо не найдено! Пожалуйста, сделайте фото анфас при хорошем освещении."} return {"success": False, "error": "Лицо не найдено! Пожалуйста, сделайте фото анфас при хорошем освещении."}
if len(faces) > 1: # 3. Отправляем в API
faces = sorted(faces, key=lambda f: f[2] * f[3], reverse=True) api_result = _upload_to_api(image_bytes, user_id, coords)
x, y, w, h = faces[0] # 4. Возвращаем результат
if api_result.get("status") == "ok":
# ========================================================= return {
# АБСОЛЮТНАЯ ЗАЩИТА ОТ ГРАФИКИ (НОРМАЛИЗОВАННАЯ МАТЕМАТИКА) "success": True,
# ========================================================= "photo_id": api_result.get("photo_id"),
# 1. Вырезаем центральную зону лица "verdict": api_result.get("verdict"),
face_roi_original = gray[y + int(h*0.2):y + int(h*0.8), x + int(w*0.2):x + int(w*0.8)] "message": api_result.get("message"),
}
# 2. Сжимаем до 100x100 пикселей. else:
# Это уравнивает 4K-фотографии и мелкие картинки, делая метрики стабильными. return {
face_roi = cv2.resize(face_roi_original, (100, 100)) "success": False,
"error": api_result.get("reason", "Неизвестная ошибка API")
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)}
except Exception as e: except Exception as e:
logger.error(f"Ошибка в photo_processor: {e}") logger.error(f"Ошибка в prepare_ad_photo: {e}")
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}