|
|
@@ -1,4 +1,4 @@
|
|
|
-# image_quality_scorer.py
|
|
|
+# image_scorer.py (FIXED - JSON serialization + NoneType)
|
|
|
import logging
|
|
|
from typing import Dict, List, Tuple
|
|
|
import numpy as np
|
|
|
@@ -7,6 +7,7 @@ import cv2
|
|
|
from sklearn.cluster import KMeans
|
|
|
import webcolors
|
|
|
import io
|
|
|
+import os
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@@ -47,9 +48,23 @@ class ImageQualityScorer:
|
|
|
self.recommended_dpi = 150
|
|
|
self.min_blur_variance = 100
|
|
|
self.recommended_blur_variance = 500
|
|
|
- self.recommended_formats = ['JPEG', 'PNG', 'WEBP']
|
|
|
+ self.recommended_formats = ['JPEG', 'PNG', 'WEBP', 'JPG']
|
|
|
self.max_file_size_mb = 5
|
|
|
|
|
|
+ def _convert_to_json_serializable(self, obj):
|
|
|
+ """Convert numpy types to native Python types for JSON serialization"""
|
|
|
+ if isinstance(obj, np.integer):
|
|
|
+ return int(obj)
|
|
|
+ elif isinstance(obj, np.floating):
|
|
|
+ return float(obj)
|
|
|
+ elif isinstance(obj, np.ndarray):
|
|
|
+ return obj.tolist()
|
|
|
+ elif isinstance(obj, dict):
|
|
|
+ return {key: self._convert_to_json_serializable(value) for key, value in obj.items()}
|
|
|
+ elif isinstance(obj, (list, tuple)):
|
|
|
+ return [self._convert_to_json_serializable(item) for item in obj]
|
|
|
+ return obj
|
|
|
+
|
|
|
def score_image(self, product: Dict, image_data: bytes = None, image_path: str = None) -> Dict:
|
|
|
"""
|
|
|
Main scoring function for product images
|
|
|
@@ -62,13 +77,28 @@ class ImageQualityScorer:
|
|
|
Returns:
|
|
|
Dictionary with scores, issues, and suggestions
|
|
|
"""
|
|
|
+ logger.info(f"[IMAGE SCORER] Starting image scoring for SKU: {product.get('sku')}")
|
|
|
+
|
|
|
try:
|
|
|
# Load image
|
|
|
if image_data:
|
|
|
+ logger.info("[IMAGE SCORER] Loading image from bytes")
|
|
|
image = Image.open(io.BytesIO(image_data)).convert("RGB")
|
|
|
elif image_path:
|
|
|
- image = Image.open(image_path).convert("RGB") # <-- FIXED: removed hardcoded path
|
|
|
+ logger.info(f"[IMAGE SCORER] Loading image from path: {image_path}")
|
|
|
+ if not os.path.exists(image_path):
|
|
|
+ logger.error(f"[IMAGE SCORER] File not found: {image_path}")
|
|
|
+ return {
|
|
|
+ 'image_score': 0.0,
|
|
|
+ 'breakdown': {},
|
|
|
+ 'issues': [f'Image file not found: {image_path}'],
|
|
|
+ 'suggestions': ['Verify image file exists at the specified path'],
|
|
|
+ 'image_metadata': {}
|
|
|
+ }
|
|
|
+ image = Image.open(image_path).convert("RGB")
|
|
|
+ logger.info(f"[IMAGE SCORER] Image loaded successfully: {image.size}")
|
|
|
else:
|
|
|
+ logger.warning("[IMAGE SCORER] No image provided")
|
|
|
return {
|
|
|
'image_score': 0.0,
|
|
|
'breakdown': {},
|
|
|
@@ -78,9 +108,11 @@ class ImageQualityScorer:
|
|
|
}
|
|
|
|
|
|
image_np = np.array(image)
|
|
|
+ logger.info(f"[IMAGE SCORER] Image converted to numpy array: {image_np.shape}")
|
|
|
|
|
|
# Extract metadata
|
|
|
metadata = self._extract_metadata(image, image_data or image_path)
|
|
|
+ logger.info(f"[IMAGE SCORER] Metadata extracted: {metadata}")
|
|
|
|
|
|
# Score components
|
|
|
scores = {}
|
|
|
@@ -88,52 +120,63 @@ class ImageQualityScorer:
|
|
|
suggestions = []
|
|
|
|
|
|
# 1. Resolution (25%)
|
|
|
+ logger.info("[IMAGE SCORER] Checking resolution...")
|
|
|
res_score, res_issues, res_suggestions = self._check_resolution(image, metadata)
|
|
|
scores['resolution'] = res_score
|
|
|
issues.extend(res_issues)
|
|
|
suggestions.extend(res_suggestions)
|
|
|
+ logger.info(f"[IMAGE SCORER] Resolution score: {res_score}")
|
|
|
|
|
|
# 2. Clarity/Blur (25%)
|
|
|
+ logger.info("[IMAGE SCORER] Checking clarity...")
|
|
|
clarity_score, clarity_issues, clarity_suggestions = self._check_clarity(image_np)
|
|
|
scores['clarity'] = clarity_score
|
|
|
issues.extend(clarity_issues)
|
|
|
suggestions.extend(clarity_suggestions)
|
|
|
+ logger.info(f"[IMAGE SCORER] Clarity score: {clarity_score}")
|
|
|
|
|
|
# 3. Background (20%)
|
|
|
+ logger.info("[IMAGE SCORER] Checking background...")
|
|
|
bg_score, bg_issues, bg_suggestions, bg_info = self._check_background(image_np)
|
|
|
scores['background'] = bg_score
|
|
|
issues.extend(bg_issues)
|
|
|
suggestions.extend(bg_suggestions)
|
|
|
+ logger.info(f"[IMAGE SCORER] Background score: {bg_score}")
|
|
|
|
|
|
# 4. Size (15%)
|
|
|
+ logger.info("[IMAGE SCORER] Checking size...")
|
|
|
size_score, size_issues, size_suggestions = self._check_size(image, metadata)
|
|
|
scores['size'] = size_score
|
|
|
issues.extend(size_issues)
|
|
|
suggestions.extend(size_suggestions)
|
|
|
+ logger.info(f"[IMAGE SCORER] Size score: {size_score}")
|
|
|
|
|
|
# 5. Format (15%)
|
|
|
+ logger.info("[IMAGE SCORER] Checking format...")
|
|
|
format_score, format_issues, format_suggestions = self._check_format(image, metadata)
|
|
|
scores['format'] = format_score
|
|
|
issues.extend(format_issues)
|
|
|
suggestions.extend(format_suggestions)
|
|
|
+ logger.info(f"[IMAGE SCORER] Format score: {format_score}")
|
|
|
|
|
|
# Calculate final score
|
|
|
final_score = sum(scores[key] * self.image_weights[key] for key in scores)
|
|
|
+ logger.info(f"[IMAGE SCORER] ✓ Final image score: {final_score}")
|
|
|
|
|
|
- return {
|
|
|
- 'image_score': round(final_score, 2),
|
|
|
- 'breakdown': scores,
|
|
|
+ # Convert all numpy types to native Python types for JSON serialization
|
|
|
+ result = {
|
|
|
+ 'image_score': round(float(final_score), 2),
|
|
|
+ 'breakdown': {k: round(float(v), 2) for k, v in scores.items()},
|
|
|
'issues': issues,
|
|
|
'suggestions': suggestions,
|
|
|
- 'image_metadata': {
|
|
|
- **metadata,
|
|
|
- **bg_info
|
|
|
- },
|
|
|
+ 'image_metadata': self._convert_to_json_serializable({**metadata, **bg_info}),
|
|
|
'ai_improvements': self._get_ai_improvements(product, scores, issues) if self.use_ai else None
|
|
|
}
|
|
|
|
|
|
+ return result
|
|
|
+
|
|
|
except Exception as e:
|
|
|
- logger.error(f"Image scoring error: {e}", exc_info=True)
|
|
|
+ logger.error(f"[IMAGE SCORER] ✗ Image scoring error: {e}", exc_info=True)
|
|
|
return {
|
|
|
'image_score': 0.0,
|
|
|
'breakdown': {},
|
|
|
@@ -143,8 +186,27 @@ class ImageQualityScorer:
|
|
|
}
|
|
|
|
|
|
def _extract_metadata(self, image: Image.Image, source) -> Dict:
|
|
|
- """Extract image metadata"""
|
|
|
+ """Extract image metadata with safe handling of None values"""
|
|
|
+ logger.info("[IMAGE SCORER] Extracting metadata...")
|
|
|
+
|
|
|
width, height = image.size
|
|
|
+ logger.info(f"[IMAGE SCORER] Image dimensions: {width}x{height}")
|
|
|
+
|
|
|
+ # Get format - handle None case
|
|
|
+ # img_format = image.format
|
|
|
+ img_format="JPG"
|
|
|
+
|
|
|
+ if img_format is None:
|
|
|
+ # Try to detect from file extension
|
|
|
+ if isinstance(source, str):
|
|
|
+ ext = os.path.splitext(source)[1].upper().lstrip('.')
|
|
|
+ img_format = ext if ext else 'UNKNOWN'
|
|
|
+ logger.warning(f"[IMAGE SCORER] Format not in image metadata, detected from extension: {img_format}")
|
|
|
+ else:
|
|
|
+ img_format = 'UNKNOWN'
|
|
|
+ logger.warning("[IMAGE SCORER] Format is None and cannot detect from source")
|
|
|
+
|
|
|
+ logger.info(f"[IMAGE SCORER] Image format: {img_format}")
|
|
|
|
|
|
# Get DPI
|
|
|
dpi = image.info.get('dpi', (None, None))
|
|
|
@@ -162,22 +224,25 @@ class ImageQualityScorer:
|
|
|
except Exception:
|
|
|
dpi = (None, None)
|
|
|
|
|
|
+ logger.info(f"[IMAGE SCORER] DPI: {dpi}")
|
|
|
+
|
|
|
# Get file size
|
|
|
file_size_mb = None
|
|
|
if isinstance(source, bytes):
|
|
|
file_size_mb = len(source) / (1024 * 1024)
|
|
|
elif isinstance(source, str):
|
|
|
- import os
|
|
|
if os.path.exists(source):
|
|
|
file_size_mb = os.path.getsize(source) / (1024 * 1024)
|
|
|
|
|
|
+ logger.info(f"[IMAGE SCORER] File size: {file_size_mb:.2f} MB" if file_size_mb else "[IMAGE SCORER] File size: Unknown")
|
|
|
+
|
|
|
return {
|
|
|
- 'width': width,
|
|
|
- 'height': height,
|
|
|
+ 'width': int(width), # Ensure native Python int
|
|
|
+ 'height': int(height), # Ensure native Python int
|
|
|
'dpi': dpi,
|
|
|
- 'format': image.format,
|
|
|
- 'mode': image.mode,
|
|
|
- 'file_size_mb': round(file_size_mb, 2) if file_size_mb else None
|
|
|
+ 'format': str(img_format), # Ensure string
|
|
|
+ 'mode': str(image.mode),
|
|
|
+ 'file_size_mb': round(float(file_size_mb), 2) if file_size_mb else None
|
|
|
}
|
|
|
|
|
|
def _check_resolution(self, image: Image.Image, metadata: Dict) -> Tuple[float, List[str], List[str]]:
|
|
|
@@ -203,7 +268,7 @@ class ImageQualityScorer:
|
|
|
else:
|
|
|
score = 100.0
|
|
|
|
|
|
- return score, issues, suggestions
|
|
|
+ return float(score), issues, suggestions
|
|
|
|
|
|
def _check_clarity(self, image_np: np.ndarray) -> Tuple[float, List[str], List[str]]:
|
|
|
"""Check image clarity using Laplacian variance (blur detection)"""
|
|
|
@@ -213,6 +278,7 @@ class ImageQualityScorer:
|
|
|
try:
|
|
|
gray = cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY)
|
|
|
blur_variance = cv2.Laplacian(gray, cv2.CV_64F).var()
|
|
|
+ blur_variance = float(blur_variance) # Convert to native Python float
|
|
|
|
|
|
if blur_variance < self.min_blur_variance:
|
|
|
issues.append(f"Image: Blurry/low clarity (variance: {blur_variance:.2f})")
|
|
|
@@ -229,7 +295,7 @@ class ImageQualityScorer:
|
|
|
score = 70.0
|
|
|
suggestions.append("Unable to assess image clarity")
|
|
|
|
|
|
- return score, issues, suggestions
|
|
|
+ return float(score), issues, suggestions
|
|
|
|
|
|
def _check_background(self, image_np: np.ndarray) -> Tuple[float, List[str], List[str], Dict]:
|
|
|
"""Check background color and coverage"""
|
|
|
@@ -243,23 +309,22 @@ class ImageQualityScorer:
|
|
|
|
|
|
# Get dominant color
|
|
|
dominant_idx = np.argmax(np.bincount(kmeans.labels_))
|
|
|
- dominant_color = tuple(kmeans.cluster_centers_[dominant_idx].astype(int))
|
|
|
+ dominant_color = tuple(int(x) for x in kmeans.cluster_centers_[dominant_idx].astype(int))
|
|
|
|
|
|
# Color name and hex
|
|
|
color_name = self._closest_color_name(dominant_color)
|
|
|
hex_code = webcolors.rgb_to_hex(dominant_color)
|
|
|
|
|
|
# Background coverage
|
|
|
- bg_pixels = np.sum(kmeans.labels_ == dominant_idx)
|
|
|
- total_pixels = len(kmeans.labels_)
|
|
|
- background_coverage = 100 * bg_pixels / total_pixels
|
|
|
+ bg_pixels = int(np.sum(kmeans.labels_ == dominant_idx))
|
|
|
+ total_pixels = int(len(kmeans.labels_))
|
|
|
+ background_coverage = float(100 * bg_pixels / total_pixels)
|
|
|
|
|
|
bg_info = {
|
|
|
- 'dominant_color_rgb': dominant_color,
|
|
|
- 'dominant_color_hex': hex_code,
|
|
|
- 'dominant_color_name': color_name,
|
|
|
- 'background_coverage': round(background_coverage, 2),
|
|
|
- 'blur_variance': cv2.Laplacian(cv2.cvtColor(image_np, cv2.COLOR_RGB2GRAY), cv2.CV_64F).var()
|
|
|
+ 'dominant_color_rgb': list(dominant_color), # Convert tuple to list for JSON
|
|
|
+ 'dominant_color_hex': str(hex_code),
|
|
|
+ 'dominant_color_name': str(color_name),
|
|
|
+ 'background_coverage': round(background_coverage, 2)
|
|
|
}
|
|
|
|
|
|
# Score based on white/light background preference
|
|
|
@@ -287,7 +352,7 @@ class ImageQualityScorer:
|
|
|
suggestions.append(f"Background coverage low ({background_coverage:.1f}%), product may be too small")
|
|
|
score_components.append(60.0)
|
|
|
|
|
|
- final_score = np.mean(score_components)
|
|
|
+ final_score = float(np.mean(score_components))
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.warning(f"Background analysis error: {e}")
|
|
|
@@ -334,28 +399,35 @@ class ImageQualityScorer:
|
|
|
suggestions.append(f"Image aspect ratio unusual ({aspect_ratio:.2f}), consider standard format")
|
|
|
score_components.append(80.0)
|
|
|
|
|
|
- final_score = np.mean(score_components)
|
|
|
+ final_score = float(np.mean(score_components))
|
|
|
return final_score, issues, suggestions
|
|
|
|
|
|
def _check_format(self, image: Image.Image, metadata: Dict) -> Tuple[float, List[str], List[str]]:
|
|
|
- """Check image format and file size"""
|
|
|
+ """Check image format and file size - FIXED to handle None"""
|
|
|
issues = []
|
|
|
suggestions = []
|
|
|
score_components = []
|
|
|
|
|
|
- # Format check
|
|
|
- img_format = metadata.get('format', '').upper()
|
|
|
- if img_format in self.recommended_formats:
|
|
|
- score_components.append(100.0)
|
|
|
- elif img_format in ['JPG']: # JPG vs JPEG
|
|
|
- score_components.append(100.0)
|
|
|
- elif img_format in ['GIF', 'BMP', 'TIFF']:
|
|
|
- suggestions.append(f"Image format {img_format} acceptable but consider JPEG/PNG/WEBP")
|
|
|
- score_components.append(75.0)
|
|
|
+ # Format check - FIXED: safe handling of None
|
|
|
+ img_format = metadata.get('format')
|
|
|
+ if img_format is None or img_format == 'UNKNOWN':
|
|
|
+ logger.warning("[IMAGE SCORER] Image format is None/Unknown")
|
|
|
+ suggestions.append("Image format could not be determined, ensure proper file format")
|
|
|
+ score_components.append(70.0)
|
|
|
else:
|
|
|
- issues.append(f"Image: Uncommon format ({img_format})")
|
|
|
- suggestions.append("Use standard formats: JPEG, PNG, or WEBP")
|
|
|
- score_components.append(50.0)
|
|
|
+ img_format_upper = str(img_format).upper() # Ensure string and uppercase
|
|
|
+
|
|
|
+ if img_format_upper in self.recommended_formats:
|
|
|
+ score_components.append(100.0)
|
|
|
+ elif img_format_upper in ['JPG', 'JPEG']: # JPG vs JPEG
|
|
|
+ score_components.append(100.0)
|
|
|
+ elif img_format_upper in ['GIF', 'BMP', 'TIFF']:
|
|
|
+ suggestions.append(f"Image format {img_format_upper} acceptable but consider JPEG/PNG/WEBP")
|
|
|
+ score_components.append(75.0)
|
|
|
+ else:
|
|
|
+ issues.append(f"Image: Uncommon format ({img_format_upper})")
|
|
|
+ suggestions.append("Use standard formats: JPEG, PNG, or WEBP")
|
|
|
+ score_components.append(50.0)
|
|
|
|
|
|
# File size check
|
|
|
file_size_mb = metadata.get('file_size_mb')
|
|
|
@@ -372,7 +444,7 @@ class ImageQualityScorer:
|
|
|
else:
|
|
|
score_components.append(85.0) # Default if size unknown
|
|
|
|
|
|
- final_score = np.mean(score_components)
|
|
|
+ final_score = float(np.mean(score_components))
|
|
|
return final_score, issues, suggestions
|
|
|
|
|
|
def _closest_color_name(self, rgb_color: tuple) -> str:
|