Рефакторинг: выделение get_crop_coordinates(), интеграция с Django API
This commit is contained in:
+108
-110
@@ -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)}
|
||||
logger.error(f"Ошибка в prepare_ad_photo: {e}")
|
||||
return {"success": False, "error": str(e)}
|
||||
|
||||
Reference in New Issue
Block a user